From 1dc5c4cc5668d21c777d114a2d5f9992a99b1603 Mon Sep 17 00:00:00 2001 From: Sebastian Blom Date: Fri, 8 May 2026 09:35:17 +0200 Subject: [PATCH 1/2] application form errors, navbar fixes --- frontend/apply/src/app/apply/[id]/page.tsx | 4 +- .../src/app/components/ApplicationForm.tsx | 199 +++++++++++++----- .../apply/src/app/components/FormInput.tsx | 5 +- .../apply/src/app/components/FormTextarea.tsx | 7 +- frontend/apply/src/app/components/Navbar.tsx | 14 +- frontend/apply/src/app/i18n/locales/en.json | 7 + frontend/apply/src/app/i18n/locales/sv.json | 7 + .../src/app/styles/application.module.css | 5 + .../apply/src/app/styles/navbar.module.css | 17 +- frontend/apply/src/app/utils/api.ts | 17 +- 10 files changed, 209 insertions(+), 73 deletions(-) diff --git a/frontend/apply/src/app/apply/[id]/page.tsx b/frontend/apply/src/app/apply/[id]/page.tsx index 986829b..8b31190 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 b6d6abf..5bc4a59 100644 --- a/frontend/apply/src/app/components/ApplicationForm.tsx +++ b/frontend/apply/src/app/components/ApplicationForm.tsx @@ -22,6 +22,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({ @@ -34,6 +41,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 || "", @@ -55,7 +63,6 @@ export default function ApplicationForm({ ); const [editable, setEditable] = useState(!existingApplication || isDraft); - // Load existing references when editing useEffect(() => { if ( existingApplication?.references && @@ -87,6 +94,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") => { @@ -97,34 +178,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, }); } @@ -135,17 +212,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")); } @@ -158,14 +245,13 @@ export default function ApplicationForm({ } }; - const openDeleteModal = async () => { + const openDeleteModal = () => { if (!isDraft) return; setShowDeleteModal(true); }; const handleDeleteDraft = async (event: React.FormEvent) => { event.preventDefault(); - console.log("Deleting draft with ID:", draftId); if (!isDraft || draftId === null) return; setDeletingDraft(true); setError(null); @@ -175,7 +261,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 +285,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 +302,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 +339,7 @@ export default function ApplicationForm({ updateReference(index, "name", e.target.value) } disabled={!editable} + required error={referenceErrors[index]?.name?.[0]} icon={ @@ -296,6 +398,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")}{" "} + {formErrors.gdpr && ( +

{formErrors.gdpr}

+ )} {error &&

{error}

} @@ -363,14 +478,7 @@ export default function ApplicationForm({ {isDraft && ( @@ -445,14 +545,19 @@ export default function ApplicationForm({ setShowSubmitModal(false)} - title={submitSuccess ? t("applicationStatus.submitted") : t("applicationForm.submitApplication")} - primaryButtonText={submitSuccess ? t("common.backToHome") : t("common.submit")} + title={ + submitSuccess + ? t("applicationStatus.submitted") + : t("applicationForm.submitApplication") + } + primaryButtonText={ + submitSuccess ? t("common.backToHome") : t("common.submit") + } onSubmit={() => { if (submitSuccess) { router.push("/"); return; } - handleSubmit("submitted"); }} primaryButtonDisabled={submittingApplication} diff --git a/frontend/apply/src/app/components/FormInput.tsx b/frontend/apply/src/app/components/FormInput.tsx index e8c695f..21d58d1 100644 --- a/frontend/apply/src/app/components/FormInput.tsx +++ b/frontend/apply/src/app/components/FormInput.tsx @@ -32,7 +32,10 @@ export default function FormInput({ return (
- +
{icon && {icon}} - {label && } + {label && ( + + )}