diff --git a/frontend/app/.server/locales/protected-en.json b/frontend/app/.server/locales/protected-en.json index d6127082..02ef0fde 100644 --- a/frontend/app/.server/locales/protected-en.json +++ b/frontend/app/.server/locales/protected-en.json @@ -12,6 +12,27 @@ "no-cases": "No cases are currently assigned to you.", "coming-soon": "Coming soon" }, + "countries": { + "select-option": "Select option", + "CAN": "Canada", + "FRA": "France" + }, + "provinces": { + "select-option": "Select option", + "AB": "Alberta", + "BC": "British Columbia", + "MB": "Manitoba", + "NB": "New Brunswick", + "NL": "Newfoundland and Labrador", + "NT": "Northwest Territories", + "NS": "Nova Scotia", + "NU": "Nunavut", + "ON": "Ontario", + "PE": "Prince Edward Island", + "QC": "Quebec", + "SK": "Saskatchewan", + "YT": "Yukon" + }, "in-person": { "title": "In-person SIN request", "description": "Process an application or amendment" @@ -335,5 +356,39 @@ "required-from-multiple": "Is applicant from multiple birth is required.", "invalid-from-multiple": "Is applicant from multiple birth is invalid." } + }, + "parent-details": { + "page-title": "Parents/legal guardians", + "section-title": "Parent/legal guardian", + "details-unavailable": "Parent/legal guardian details unavailable", + "given-name": "Given name(s)", + "last-name": "Last name at birth", + "country": "Country of birth", + "province": "Province, state, or region of birth", + "city": "City, town, or village of birth", + "add-parent": "Add a parent/legal guardian", + "remove": "Remove", + "given-name-error": { + "format-error": "Given name must not contain any digits.", + "max-length-error": "Given name must contain at most {{maximum}} character(s).", + "required-error": "Given name is required." + }, + "last-name-error": { + "format-error": "Last name must not contain any digits.", + "max-length-error": "Last name must contain at most {{maximum}} character(s).", + "required-error": "Last name is required." + }, + "country-error": { + "required-country": "Country of birth is required.", + "invalid-country": "Country of birth is invalid." + }, + "province-error": { + "required-province": "Province, state, or region of birth is required.", + "invalid-province": "Province, state, or region of birth is invalid." + }, + "city-error": { + "required-city": "City, town, or village of birth is required.", + "invalid-city": "City, town, or village of birth is invalid." + } } } diff --git a/frontend/app/.server/locales/protected-fr.json b/frontend/app/.server/locales/protected-fr.json index 0ae74709..f84c5992 100644 --- a/frontend/app/.server/locales/protected-fr.json +++ b/frontend/app/.server/locales/protected-fr.json @@ -12,6 +12,27 @@ "no-cases": "Aucun dossier n'est actuellement assigné à vous.", "coming-soon": "Bientôt disponible" }, + "countries": { + "select-option": "Sélectionner une option", + "CAN": "Canada", + "FRA": "France" + }, + "provinces": { + "select-option": "Sélectionner une option", + "AB": "Alberta", + "BC": "Colombie-Britannique", + "MB": "Manitoba", + "NB": "Nouveau-Brunswick", + "NL": "Terre-Neuve-et-Labrador", + "NT": "Territoires du Nord-Ouest", + "NS": "Nouvelle-Écosse", + "NU": "Nunavut", + "ON": "Ontario", + "PE": "Île-du-Prince-Édouard", + "QC": "Québec", + "SK": "Saskatchewan", + "YT": "Yukon" + }, "in-person": { "title": "Demande NAS en personne", "description": "Traiter une demande ou une modification" @@ -336,5 +357,39 @@ "required-from-multiple": "Il est requis de savoir si le demandeur fait partie d'une naissance multiple.", "invalid-from-multiple": "Le statut de naissance multiple du demandeur est invalide." } + }, + "parent-details": { + "page-title": "Parents/tuteurs légaux", + "section-title": "Parent/tuteur légal", + "details-unavailable": "Informations sur le parent/tuteur légal non disponibles", + "given-name": "Prénom(s)", + "last-name": "Nom de naissance", + "country": "Pays de naissance", + "province": "Province, état ou région de naissance", + "city": "Ville, commune ou village de naissance", + "add-parent": "Ajouter un parent/tuteur légal", + "remove": "Supprimer", + "given-name-error": { + "format-error": "Le prénom ne doit pas contenir de chiffres.", + "max-length-error": "Le prénom ne doit pas contenir plus de {{maximum}} caractère(s).", + "required-error": "Le prénom est requis." + }, + "last-name-error": { + "format-error": "Le nom de famille ne doit pas contenir de chiffres.", + "max-length-error": "Le nom de famille ne doit pas contenir plus de {{maximum}} caractère(s).", + "required-error": "Le nom de famille est requis." + }, + "country-error": { + "required-country": "Le pays de naissance est requis.", + "invalid-country": "Le pays de naissance est invalide." + }, + "province-error": { + "required-province": "La province, l'état ou la région de naissance est requis.", + "invalid-province": "La province, l'état ou la région de naissance est invalide." + }, + "city-error": { + "required-city": "La ville, la commune ou le village de naissance est requis.", + "invalid-city": "La ville, la commune ou le village de naissance est invalide." + } } } diff --git a/frontend/app/i18n-routes.ts b/frontend/app/i18n-routes.ts index 447964e3..4ae3db3d 100644 --- a/frontend/app/i18n-routes.ts +++ b/frontend/app/i18n-routes.ts @@ -152,6 +152,14 @@ export const i18nRoutes = [ }, { id: 'PROT-0011', + file: 'routes/protected/person-case/parent-details.tsx', + paths: { + en: '/en/protected/person-case/parent-details', + fr: '/fr/protege/cas-personnel/details-des-parents', + }, + }, + { + id: 'PROT-0012', file: 'routes/protected/person-case/previous-sin.tsx', paths: { en: '/en/protected/person-case/previous-sin', @@ -159,7 +167,7 @@ export const i18nRoutes = [ }, }, { - id: 'PROT-0012', + id: 'PROT-0013', file: 'routes/protected/person-case/contact-information.tsx', paths: { en: '/en/protected/person-case/contact-information', diff --git a/frontend/app/routes/protected/person-case/@types.d.ts b/frontend/app/routes/protected/person-case/@types.d.ts index daa63c2a..ee4c710d 100644 --- a/frontend/app/routes/protected/person-case/@types.d.ts +++ b/frontend/app/routes/protected/person-case/@types.d.ts @@ -64,6 +64,25 @@ declare module 'express-session' { birthDetails: | { country: ServerEnvironment['PP_CANADA_COUNTRY_CODE']; province: string; city: string; fromMultipleBirth: boolean } | { country: string; province?: string; city?: string; fromMultipleBirth: boolean }; + parentDetails: ( + | { unavailable: true } + | { + unavailable: false; + givenName: string; + lastName: string; + birthLocation: + | { + country: 'CAN'; + province: string; + city: string; + } + | { + country: string; + province?: string; + city?: string; + }; + } + )[]; }>; } } diff --git a/frontend/app/routes/protected/person-case/parent-details.tsx b/frontend/app/routes/protected/person-case/parent-details.tsx new file mode 100644 index 00000000..26b34f55 --- /dev/null +++ b/frontend/app/routes/protected/person-case/parent-details.tsx @@ -0,0 +1,432 @@ +import { useId, useState } from 'react'; + +import type { RouteHandle } from 'react-router'; +import { data, useFetcher } from 'react-router'; + +import { faPlus, faXmark } from '@fortawesome/free-solid-svg-icons'; +import type { SessionData } from 'express-session'; +import { useTranslation } from 'react-i18next'; +import * as v from 'valibot'; + +import type { Info, Route } from './+types/parent-details'; + +import { requireAuth } from '~/.server/utils/auth-utils'; +import { i18nRedirect } from '~/.server/utils/route-utils'; +import { Button } from '~/components/button'; +import { FetcherErrorSummary } from '~/components/error-summary'; +import { InputCheckbox } from '~/components/input-checkbox'; +import { InputField } from '~/components/input-field'; +import { InputSelect } from '~/components/input-select'; +import { PageTitle } from '~/components/page-title'; +import { AppError } from '~/errors/app-error'; +import { ErrorCodes } from '~/errors/error-codes'; +import { getTranslation } from '~/i18n-config.server'; +import { handle as parentHandle } from '~/routes/protected/layout'; +import { REGEX_PATTERNS } from '~/utils/regex-utils'; +import { trimToUndefined } from '~/utils/string-utils'; + +type ParentDetailsSessionData = NonNullable; + +const COUNTRIES = ['CAN', 'FRA'] as const; +const PROVINCES = ['AB', 'BC', 'MB', 'NB', 'NL', 'NT', 'NS', 'NU', 'ON', 'PE', 'QC', 'SK', 'YT'] as const; + +const COUNTRY_CODE_CANADA = 'CAN'; +const MAX_PARENTS = 4; + +export const handle = { + i18nNamespace: [...parentHandle.i18nNamespace, 'protected'], +} as const satisfies RouteHandle; + +export async function loader({ context, request }: Route.LoaderArgs) { + requireAuth(context.session, new URL(request.url), ['user']); + + const { t } = await getTranslation(request, handle.i18nNamespace); + const sessionData = context.session.inPersonSINCase?.parentDetails ?? []; + + return { + documentTitle: t('protected:parent-details.page-title'), + defaultFormValues: sessionData.map((details) => + details.unavailable + ? { unavailable: true } + : { + unavailable: false, + givenName: details.givenName, + lastName: details.lastName, + country: details.birthLocation.country, + province: details.birthLocation.province, + city: details.birthLocation.city, + }, + ), + }; +} + +export function meta({ data }: Route.MetaArgs) { + return [{ title: data.documentTitle }]; +} + +export async function action({ context, request }: Route.ActionArgs) { + requireAuth(context.session, new URL(request.url), ['user']); + + const { lang, t } = await getTranslation(request, handle.i18nNamespace); + const formData = await request.formData(); + const action = formData.get('action'); + const maxStringLength = 100; + + switch (action) { + case 'back': { + throw i18nRedirect('routes/protected/person-case/birth-details.tsx', request); + } + + case 'next': { + const schema = v.pipe( + v.array( + v.variant( + 'unavailable', + [ + v.object({ + unavailable: v.literal(true), + }), + v.object({ + unavailable: v.literal(false), + givenName: v.pipe( + v.string(t('protected:parent-details.given-name-error.required-error')), + v.trim(), + v.nonEmpty(t('protected:parent-details.given-name-error.required-error')), + v.maxLength(maxStringLength, t('protected:parent-details.given-name-error.max-length-error')), + v.regex(REGEX_PATTERNS.NON_DIGIT, t('protected:parent-details.given-name-error.format-error')), + ), + lastName: v.pipe( + v.string(t('protected:parent-details.last-name-error.required-error')), + v.trim(), + v.nonEmpty(t('protected:parent-details.last-name-error.required-error')), + v.maxLength(maxStringLength, t('protected:parent-details.last-name-error.max-length-error')), + v.regex(REGEX_PATTERNS.NON_DIGIT, t('protected:parent-details.last-name-error.format-error')), + ), + birthLocation: v.variant( + 'country', + [ + v.object({ + country: v.literal(COUNTRY_CODE_CANADA, t('protected:parent-details.country-error.invalid-country')), + province: v.pipe( + v.string(t('protected:parent-details.province-error.required-province')), + v.trim(), + v.nonEmpty(t('protected:parent-details.province-error.required-province')), + v.maxLength(maxStringLength, t('protected:parent-details.province-error.invalid-province')), + v.regex(REGEX_PATTERNS.NON_DIGIT, t('protected:parent-details.province-error.invalid-province')), + ), + city: v.pipe( + v.string(t('protected:parent-details.city-error.required-city')), + v.trim(), + v.nonEmpty(t('protected:parent-details.city-error.required-city')), + v.maxLength(maxStringLength, t('protected:parent-details.city-error.invalid-city')), + v.regex(REGEX_PATTERNS.NON_DIGIT, t('protected:parent-details.city-error.invalid-city')), + ), + }), + v.object({ + country: v.pipe( + v.string(t('protected:parent-details.country-error.required-country')), + v.nonEmpty(t('protected:parent-details.country-error.required-country')), + v.excludes(COUNTRY_CODE_CANADA, t('protected:parent-details.country-error.invalid-country')), + v.picklist(COUNTRIES, t('protected:parent-details.country-error.invalid-country')), + ), + province: v.optional( + v.pipe( + v.string(t('protected:parent-details.province-error.required-province')), + v.trim(), + v.nonEmpty(t('protected:parent-details.province-error.required-province')), + v.maxLength(maxStringLength, t('protected:parent-details.province-error.invalid-province')), + v.regex(REGEX_PATTERNS.NON_DIGIT, t('protected:parent-details.province-error.invalid-province')), + ), + ), + city: v.optional( + v.pipe( + v.string(t('protected:parent-details.city-error.required-city')), + v.trim(), + v.nonEmpty(t('protected:parent-details.city-error.required-city')), + v.maxLength(maxStringLength, t('protected:parent-details.city-error.invalid-city')), + v.regex(REGEX_PATTERNS.NON_DIGIT, t('protected:parent-details.city-error.invalid-city')), + ), + ), + }), + ], + t('protected:parent-details.country-error.required-country'), + ), + }), + ], + t('protected:parent-details.details-unavailable'), + ), + t('protected:parent-details.details-unavailable'), + ), + v.minLength(1), + v.maxLength(MAX_PARENTS), + ) satisfies v.GenericSchema; + + const parentAmount = Number(formData.get('parent-amount')) || 0; + const inputLength = Math.min(parentAmount, MAX_PARENTS); + + const input = Array.from({ length: inputLength }).map((_, i) => ({ + unavailable: Boolean(formData.get(`${i}-unavailable`)), + givenName: String(formData.get(`${i}-given-name`)), + lastName: String(formData.get(`${i}-last-name`)), + birthLocation: { + country: String(formData.get(`${i}-country`)), + province: trimToUndefined(String(formData.get(`${i}-province`))), + city: trimToUndefined(String(formData.get(`${i}-city`))), + }, + })) satisfies ParentDetailsSessionData; + + const parseResult = v.safeParse(schema, input, { lang }); + + if (!parseResult.success) { + return data({ errors: v.flatten(parseResult.issues).nested }, { status: 400 }); + } + + (context.session.inPersonSINCase ??= {}).parentDetails = parseResult.output; + throw i18nRedirect('routes/protected/person-case/previous-sin.tsx', request); + } + + default: { + throw new AppError(`Unrecognized action: ${action}`, ErrorCodes.UNRECOGNIZED_ACTION); + } + } +} + +export default function CreateRequest({ loaderData, actionData, params }: Route.ComponentProps) { + const { t } = useTranslation(handle.i18nNamespace); + + const fetcherKey = useId(); + const fetcher = useFetcher({ key: fetcherKey }); + + const isSubmitting = fetcher.state !== 'idle'; + const errors = fetcher.data?.errors; + const defaultFormValues = loaderData.defaultFormValues; + + return ( + <> + {t('protected:parent-details.page-title')} + + + +
+ + +
+
+
+ + ); +} + +type FormData = { + unavailable?: boolean; + givenName?: string; + lastName?: string; + country?: string; + province?: string; + city?: string; +}; + +interface ParentInformationProps { + defaultFormValues: FormData[]; + errors?: Record; +} + +function ParentInformation({ defaultFormValues, errors }: ParentInformationProps) { + const { t } = useTranslation(handle.i18nNamespace); + const { idList, addId, removeId } = useIdList(Math.max(defaultFormValues.length, 1)); + + const canAddParent = idList.length < MAX_PARENTS; + + function onAddParent() { + if (canAddParent) addId(); + } + + function onRemoveParent(index: number) { + // remove parent data from the form values + defaultFormValues.splice(index, 1); + removeId(index); + } + + return ( + <> + +
+ {idList.map((id, index) => ( + 1 ? onRemoveParent : undefined} + /> + ))} +
+ {canAddParent && ( + + )} + + ); +} + +interface ParentFormProps { + index: number; + defaultValues?: FormData; + errors?: Record; + onRemove?: (index: number) => void; +} + +function ParentForm({ index, defaultValues, errors, onRemove }: ParentFormProps) { + const { t } = useTranslation(handle.i18nNamespace); + + const [unavailable, setUnavailable] = useState(defaultValues?.unavailable); + const [country, setCountry] = useState(defaultValues?.country); + + const countryOptions = (['select-option', ...COUNTRIES] as const).map((value) => ({ + value: value === 'select-option' ? '' : value, + children: t(`protected:countries.${value}`), + })); + + const provinceOptions = (['select-option', ...PROVINCES] as const).map((value) => ({ + value: value === 'select-option' ? '' : value, + children: t(`protected:provinces.${value}`), + })); + + return ( +
+
+

+ {t('protected:parent-details.section-title')} + {index + 1} +

+ {onRemove && ( + + )} +
+ setUnavailable(target.checked)} + labelClassName="text-lg" + > + {t('protected:parent-details.details-unavailable')} + + {!unavailable && ( + <> + + + setCountry(target.value)} + /> + {country == COUNTRY_CODE_CANADA ? ( + + ) : ( + + )} + + + )} +
+ ); +} + +/** + * A custom hook that manages a collection of unique numeric IDs. + * + * Useful for dynamically adding/removing form elements or list items with stable identifiers. + * + * @param initialSize - The initial number of IDs to generate in the collection. Must be a non-negative integer. + * + * @returns An object containing: + * - idList: An array of unique numeric IDs + * - addId: Function that appends a new unique ID to the list + * - removeId: Function that removes an ID at the specified index + */ +function useIdList(initialSize: number) { + const [idList, setIdList] = useState(Array.from({ length: initialSize }, (_, index) => index + 1)); + + return { + /** + * The list of current ids + */ + idList: idList, + + /** + * Adds a new id to the id list + */ + addId: () => { + setIdList((prev) => { + const nextId = (prev[prev.length - 1] ?? 0) + 1; + return [...prev, nextId]; + }); + }, + + /** + * Removes an id at the specified index. + * + * @param index - The index of the id to remove. + */ + removeId: (index: number) => { + if (index < idList.length) { + setIdList((prev) => prev.filter((_, i) => i !== index)); + } + }, + }; +}