diff --git a/frontend/apply/src/app/apply/[id]/page.tsx b/frontend/apply/src/app/apply/[id]/page.tsx index 7141cd5..7ca1607 100644 --- a/frontend/apply/src/app/apply/[id]/page.tsx +++ b/frontend/apply/src/app/apply/[id]/page.tsx @@ -57,7 +57,7 @@ export default function ApplyPage() { const applicationData = await applicationAPI .getByPositionId(positionId) .catch((error) => { - if (error.detail === "Application not found for this position.") { + if (error.status === 404) { return null; } @@ -66,7 +66,7 @@ export default function ApplyPage() { return null; } - throw new Error(error.detail || "Failed to load application"); + throw error; }); setPosition(positionData); diff --git a/frontend/apply/src/app/components/ApplicationForm.tsx b/frontend/apply/src/app/components/ApplicationForm.tsx index a938e95..ae561b8 100644 --- a/frontend/apply/src/app/components/ApplicationForm.tsx +++ b/frontend/apply/src/app/components/ApplicationForm.tsx @@ -23,6 +23,13 @@ type ReferenceErrors = { name?: string[]; email?: string[]; phone_num?: string[]; + non_field_errors?: string[]; +}; + +type FormErrors = { + coverLetter?: string; + qualifications?: string; + gdpr?: string; }; export default function ApplicationForm({ @@ -35,6 +42,7 @@ export default function ApplicationForm({ const [savingDraft, setSavingDraft] = useState(false); const [deletingDraft, setDeletingDraft] = useState(false); const [error, setError] = useState(null); + const [formErrors, setFormErrors] = useState({}); const [referenceErrors, setReferenceErrors] = useState([]); const [coverLetter, setCoverLetter] = useState( existingApplication?.cover_letter || "", @@ -56,7 +64,6 @@ export default function ApplicationForm({ ); const [editable, setEditable] = useState(!existingApplication || isDraft); - // Load existing references when editing useEffect(() => { if ( existingApplication?.references && @@ -88,6 +95,80 @@ export default function ApplicationForm({ const updated = [...references]; updated[index] = { ...updated[index], [field]: value }; setReferences(updated); + + // Clear errors for this field; phone/email errors are coupled so clear both + if (referenceErrors[index]) { + const updatedErrors = [...referenceErrors]; + if (field === "phone_num" || field === "email") { + updatedErrors[index] = { + ...updatedErrors[index], + phone_num: undefined, + email: undefined, + }; + } else { + updatedErrors[index] = { ...updatedErrors[index], [field]: undefined }; + } + setReferenceErrors(updatedErrors); + } + }; + + const validateReferences = (): ReferenceErrors[] => + references.map((ref) => { + const errors: ReferenceErrors = {}; + const hasAnyField = [ + ref.name, + ref.title, + ref.phone_num, + ref.email, + ref.comment, + ].some((v) => v?.trim()); + + if (!hasAnyField) return errors; + + if (!ref.name?.trim()) { + errors.name = [t("applicationForm.referenceNameRequired")]; + } + + if (!ref.phone_num?.trim() && !ref.email?.trim()) { + const msg = t("applicationForm.referencePhoneOrEmailRequired"); + errors.phone_num = [msg]; + errors.email = [msg]; + } else { + if ( + ref.email?.trim() && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ref.email.trim()) + ) { + errors.email = [t("applicationForm.invalidEmail")]; + } + if ( + ref.phone_num?.trim() && + !/^[+\d\s\-().]{5,20}$/.test(ref.phone_num.trim()) + ) { + errors.phone_num = [t("applicationForm.invalidPhone")]; + } + } + + return errors; + }); + + const validateAndOpenSubmitModal = () => { + const errors: FormErrors = {}; + + if (!coverLetter.trim()) errors.coverLetter = t("applicationForm.fieldRequired"); + if (!qualifications.trim()) errors.qualifications = t("applicationForm.fieldRequired"); + if (!gdpr) errors.gdpr = t("applicationForm.gdprRequired"); + + const refErrors = validateReferences(); + const hasRefErrors = refErrors.some((e) => Object.keys(e).length > 0); + + if (Object.keys(errors).length > 0 || hasRefErrors) { + setFormErrors(errors); + if (hasRefErrors) setReferenceErrors(refErrors); + return; + } + + setSubmitSuccess(false); + setShowSubmitModal(true); }; const handleSubmit = async (status: "draft" | "submitted") => { @@ -98,34 +179,30 @@ export default function ApplicationForm({ } setError(null); - setReferenceErrors([]); setShowDraftSavedMessage(false); try { if (!isDraft) { - // Create new application const createdApplication = await applicationAPI.create({ position: position.id, cover_letter: coverLetter, qualifications, gdpr, status, - references: references, + references, }); if (status === "draft") { - // Enter draft mode setIsDraft(true); setDraftId(createdApplication.id); } } else if (draftId !== null) { - // Edit existing application draft await applicationAPI.update(draftId, { cover_letter: coverLetter, qualifications, gdpr, status, - references: references, + references, }); } @@ -136,17 +213,27 @@ export default function ApplicationForm({ setShowDraftSavedMessage(true); } } catch (err: unknown) { - const error = err as { - message?: string; - fieldErrors?: { references?: ReferenceErrors[] }; - non_field_errors?: string[]; + const error = err as Error & { + fieldErrors?: { + references?: ReferenceErrors[]; + non_field_errors?: string[]; + }; }; - if (error.non_field_errors) { - setError(error.non_field_errors.join(" ")); - } else if (error.fieldErrors?.references) { - setReferenceErrors(error.fieldErrors.references); - setError(t("applicationForm.validationErrorsInReferences")); + const refErrors = error.fieldErrors?.references; + if (refErrors?.some((e) => Object.keys(e).length > 0)) { + setReferenceErrors( + refErrors.map((refErr) => { + if (!refErr.non_field_errors?.length) return refErr; + return { + ...refErr, + phone_num: refErr.phone_num ?? refErr.non_field_errors, + email: refErr.email ?? refErr.non_field_errors, + }; + }), + ); + } else if (error.fieldErrors?.non_field_errors) { + setError(error.fieldErrors.non_field_errors.join(" ")); } else { setError(error.message || t("applicationForm.failedToSubmitApplication")); } @@ -159,7 +246,7 @@ export default function ApplicationForm({ } }; - const openDeleteModal = async () => { + const openDeleteModal = () => { if (!isDraft) return; setShowDeleteModal(true); }; @@ -175,7 +262,9 @@ export default function ApplicationForm({ window.location.reload(); } catch (err) { setError( - err instanceof Error ? err.message : t("applicationForm.failedToDeleteApplication"), + err instanceof Error + ? err.message + : t("applicationForm.failedToDeleteApplication"), ); setShowDeleteModal(false); } finally { @@ -197,10 +286,15 @@ export default function ApplicationForm({ setCoverLetter(e.target.value)} + onChange={(e) => { + setCoverLetter(e.target.value); + if (formErrors.coverLetter) + setFormErrors((prev) => ({ ...prev, coverLetter: undefined })); + }} rows={6} required disabled={!editable} + error={formErrors.coverLetter} /> @@ -209,10 +303,18 @@ export default function ApplicationForm({ setQualifications(e.target.value)} + onChange={(e) => { + setQualifications(e.target.value); + if (formErrors.qualifications) + setFormErrors((prev) => ({ + ...prev, + qualifications: undefined, + })); + }} rows={6} required disabled={!editable} + error={formErrors.qualifications} /> @@ -238,6 +340,7 @@ export default function ApplicationForm({ updateReference(index, "name", e.target.value) } disabled={!editable} + required error={referenceErrors[index]?.name?.[0]} icon={ @@ -296,6 +399,12 @@ export default function ApplicationForm({ /> + {editable && ( +

+ {t("applicationForm.referenceContactHint")} +

+ )} + setGdpr(e.target.checked)} - required + onChange={(e) => { + setGdpr(e.target.checked); + if (formErrors.gdpr) + setFormErrors((prev) => ({ ...prev, gdpr: undefined })); + }} disabled={!editable} /> + *{" "} {t("applicationForm.gdprConsentText")}{" "} - https://utn.se/dokumentarkiv + {t("applicationForm.gdprPolicyLinkText")} + {formErrors.gdpr && ( +

{formErrors.gdpr}

+ )} {error &&

{error}

} @@ -363,14 +479,7 @@ export default function ApplicationForm({