Skip to content

Commit

Permalink
add conditional rednering and beefed up validation to /contact-inform…
Browse files Browse the repository at this point in the history
…ation (#256)
  • Loading branch information
Fbasham authored Feb 25, 2025
1 parent 9783c39 commit 9c715c3
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 85 deletions.
4 changes: 3 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,6 @@ AZUREAD_CLIENT_SECRET=
# Power Platform lanuage code for English type (default: 1033)
PP_ENGLISH_LANGUAGE_CODE=
# Power Platform lanuage code for French type (default: 1036)
PP_FRENCH_LANGUAGE_CODE=
PP_FRENCH_LANGUAGE_CODE=
# Power Platform lanuage code for Canada Country Code (default: '0cf5389e-97ae-eb11-8236-000d3af4bfc3')
PP_CANADA_COUNTRY_CODE=
2 changes: 2 additions & 0 deletions frontend/app/.server/environment/power-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ export type PowerPlatform = Readonly<v.InferOutput<typeof powerPlatform>>;
export const defaults = {
PP_ENGLISH_LANGUAGE_CODE: '1033',
PP_FRENCH_LANGUAGE_CODE: '1036',
PP_CANADA_COUNTRY_CODE: '0cf5389e-97ae-eb11-8236-000d3af4bfc3',
} as const;

export const powerPlatform = v.object({
PP_ENGLISH_LANGUAGE_CODE: v.optional(v.pipe(stringToIntegerSchema()), defaults.PP_ENGLISH_LANGUAGE_CODE),
PP_FRENCH_LANGUAGE_CODE: v.optional(v.pipe(stringToIntegerSchema()), defaults.PP_FRENCH_LANGUAGE_CODE),
PP_CANADA_COUNTRY_CODE: v.optional(v.string(), defaults.PP_CANADA_COUNTRY_CODE),
});
3 changes: 2 additions & 1 deletion frontend/app/.server/locales/protected-en.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@
"address-help-message": "Include apartment number (if applicable), street number, street name. For example: 123 Main St. Suite 4B",
"postal-code-label": "Postal code",
"city-label": "City, town, or village",
"province-label": "Province, state, or region",
"canada-province-label": "Province or territory",
"other-country-province-label": "Province, state, or region",
"error-messages": {
"preferred-language-required": "Please select a preferred language",
"primary-phone-required": "Please enter a primary phone number",
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/.server/locales/protected-fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@
"address-help-message": "Include apartment number (if applicable), street number, street name. For example: 123 Main St. Suite 4B",
"postal-code-label": "Postal code",
"city-label": "City, town, or village",
"province-label": "Province, state, or region",
"canada-province-label": "Province or territory",
"other-country-province-label": "Province, state, or region",
"error-messages": {
"preferred-language-required": "Please select a preferred language",
"primary-phone-required": "Please enter a primary phone number",
Expand Down
27 changes: 13 additions & 14 deletions frontend/app/.server/services/locale-data-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,32 +29,31 @@ export function getLocalizedCountries(locale: Language = 'en'): readonly Localiz
}));
}

type ProvinceTerritoryState = Readonly<{
type ProvinceTerritory = Readonly<{
id: string;
countryId: string;
nameEn: string;
nameFr: string;
}>;

export function getProvincesTerritoriesStates(): readonly ProvinceTerritoryState[] {
return provinceTerritoryStateData.value.map((region) => ({
id: region.esdc_provinceterritorystateid,
countryId: region._esdc_countryid_value,
nameEn: region.esdc_nameenglish,
nameFr: region.esdc_namefrench,
}));
export function getProvincesTerritories(): readonly ProvinceTerritory[] {
const { PP_CANADA_COUNTRY_CODE } = serverEnvironment;
return provinceTerritoryStateData.value
.filter((region) => region._esdc_countryid_value === PP_CANADA_COUNTRY_CODE)
.map((region) => ({
id: region.esdc_provinceterritorystateid,
nameEn: region.esdc_nameenglish,
nameFr: region.esdc_namefrench,
}));
}

type LocalizedProvinceTerritoryState = Readonly<{
type LocalizedProvinceTerritory = Readonly<{
id: string;
countryId: string;
name: string;
}>;

export function getLocalizedProvincesTerritoriesStates(locale: Language = 'en'): readonly LocalizedProvinceTerritoryState[] {
return getProvincesTerritoriesStates().map((region) => ({
export function getLocalizedProvincesTerritoriesStates(locale: Language = 'en'): readonly LocalizedProvinceTerritory[] {
return getProvincesTerritories().map((region) => ({
id: region.id,
countryId: region.countryId,
name: region[locale === 'en' ? 'nameEn' : 'nameFr'],
}));
}
Expand Down
180 changes: 112 additions & 68 deletions frontend/app/routes/protected/person-case/contact-information.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId } from 'react';
import { useId, useState } from 'react';

import type { RouteHandle, SessionData } from 'react-router';
import { data, useFetcher } from 'react-router';
Expand All @@ -8,11 +8,12 @@ import * as v from 'valibot';

import type { Info, Route } from './+types/contact-information';

import { serverEnvironment } from '~/.server/environment';
import {
getCountries,
getLocalizedCountries,
getLocalizedProvincesTerritoriesStates,
getProvincesTerritoriesStates,
getProvincesTerritories,
getPreferredLanguages,
getLocalizedPreferredLanguages,
} from '~/.server/services/locale-data-service';
Expand All @@ -38,13 +39,15 @@ export const handle = {
export async function loader({ context, request }: Route.LoaderArgs) {
requireAuth(context.session, new URL(request.url), ['user']);
const { lang, t } = await getTranslation(request, handle.i18nNamespace);
const { PP_CANADA_COUNTRY_CODE } = serverEnvironment;

return {
documentTitle: t('protected:contact-information.page-title'),
defaultFormValues: context.session.inPersonSINCase?.contactInformation,
localizedpreferredLanguages: getLocalizedPreferredLanguages(lang),
localizedCountries: getLocalizedCountries(lang),
localizedProvincesTerritoriesStates: getLocalizedProvincesTerritoriesStates(lang),
PP_CANADA_COUNTRY_CODE,
};
}

Expand All @@ -56,6 +59,7 @@ 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 { PP_CANADA_COUNTRY_CODE } = serverEnvironment;
const formData = await request.formData();
const action = formData.get('action');

Expand All @@ -65,37 +69,60 @@ export async function action({ context, request }: Route.ActionArgs) {
}

case 'next': {
// TODO beef up validation
const schema = v.object({
preferredLanguage: v.picklist(
getPreferredLanguages().map(({ id }) => id),
t('protected:contact-information.error-messages.preferred-language-required'),
),
primaryPhoneNumber: v.pipe(
v.string(),
v.trim(),
v.nonEmpty(t('protected:contact-information.error-messages.primary-phone-required')),
),
secondaryPhoneNumber: v.optional(v.pipe(v.string(), v.trim())),
emailAddress: v.optional(
v.pipe(v.string(), v.trim(), v.email(t('protected:contact-information.error-messages.email-address-invalid-format'))),
),
country: v.picklist(
getCountries().map(({ id }) => id),
t('protected:contact-information.error-messages.country-required'),
),
address: v.pipe(v.string(), v.trim(), v.nonEmpty(t('protected:contact-information.error-messages.address-required'))),
postalCode: v.pipe(
v.string(),
v.trim(),
v.nonEmpty(t('protected:contact-information.error-messages.postal-code-required')),
),
city: v.pipe(v.string(), v.trim(), v.nonEmpty(t('protected:contact-information.error-messages.city-required'))),
province: v.picklist(
getProvincesTerritoriesStates().map(({ id }) => id),
t('protected:contact-information.error-messages.province-required'),
),
}) satisfies v.GenericSchema<ContactInformationSessionData>;
const schema = v.intersect([
v.object({
preferredLanguage: v.picklist(
getPreferredLanguages().map(({ id }) => id),
t('protected:contact-information.error-messages.preferred-language-required'),
),
primaryPhoneNumber: v.pipe(
v.string(),
v.trim(),
v.nonEmpty(t('protected:contact-information.error-messages.primary-phone-required')),
),
secondaryPhoneNumber: v.optional(v.pipe(v.string(), v.trim())),
emailAddress: v.optional(
v.pipe(
v.string(),
v.trim(),
v.email(t('protected:contact-information.error-messages.email-address-invalid-format')),
),
),
country: v.picklist(
getCountries().map(({ id }) => id),
t('protected:contact-information.error-messages.country-required'),
),
address: v.pipe(v.string(), v.trim(), v.nonEmpty(t('protected:contact-information.error-messages.address-required'))),
postalCode: v.pipe(
v.string(),
v.trim(),
v.nonEmpty(t('protected:contact-information.error-messages.postal-code-required')),
),
city: v.pipe(v.string(), v.trim(), v.nonEmpty(t('protected:contact-information.error-messages.city-required'))),
province: v.pipe(
v.string(),
v.trim(),
v.nonEmpty(t('protected:contact-information.error-messages.province-required')),
),
}),
v.variant('country', [
v.object({
country: v.literal(PP_CANADA_COUNTRY_CODE),
province: v.picklist(
getProvincesTerritories().map(({ id }) => id),
t('protected:contact-information.error-messages.province-required'),
),
}),
v.object({
country: v.string(),
province: v.pipe(
v.string(),
v.trim(),
v.nonEmpty(t('protected:contact-information.error-messages.province-required')),
),
}),
]),
]) satisfies v.GenericSchema<ContactInformationSessionData>;

const input = {
preferredLanguage: formData.get('preferredLanguage') as string,
Expand Down Expand Up @@ -135,6 +162,8 @@ export default function ContactInformation({ loaderData, actionData, params }: R
const isSubmitting = fetcher.state !== 'idle';
const errors = fetcher.data?.errors;

const [country, setCountry] = useState<string | undefined>(loaderData.defaultFormValues?.country);

const languageOptions = loaderData.localizedpreferredLanguages.map(({ id, name }) => ({
value: id,
children: name,
Expand All @@ -154,7 +183,6 @@ export default function ContactInformation({ loaderData, actionData, params }: R
children: id === 'select-option' ? t('protected:contact-information.select-option') : name,
}));

// TODO conditionally render different address fields if Canada is selected as a country
return (
<div className="max-w-prose">
<PageTitle subTitle={t('protected:in-person.title')}>{t('protected:contact-information.page-title')}</PageTitle>
Expand Down Expand Up @@ -208,42 +236,58 @@ export default function ContactInformation({ loaderData, actionData, params }: R
options={countryOptions}
errorMessage={errors?.country?.at(0)}
defaultValue={loaderData.defaultFormValues?.country}
onChange={({ target }) => setCountry(target.value)}
required
/>
<InputField
id="address"
label={t('protected:contact-information.address-label')}
helpMessagePrimary={t('protected:contact-information.address-help-message')}
name="address"
className="w-full"
errorMessage={errors?.address?.at(0)}
defaultValue={loaderData.defaultFormValues?.address}
/>
<InputField
id="postal-code"
label={t('protected:contact-information.postal-code-label')}
name="postalCode"
errorMessage={errors?.postalCode?.at(0)}
defaultValue={loaderData.defaultFormValues?.postalCode}
/>
<InputField
id="city"
label={t('protected:contact-information.city-label')}
name="city"
className="w-full"
errorMessage={errors?.city?.at(0)}
defaultValue={loaderData.defaultFormValues?.city}
/>
<InputSelect
className="w-max rounded-sm"
id="province"
label={t('protected:contact-information.province-label')}
name="province"
options={provinceTerritoryStateOptions}
errorMessage={errors?.province?.at(0)}
defaultValue={loaderData.defaultFormValues?.province}
required
/>
{country && (
<>
<InputField
id="address"
label={t('protected:contact-information.address-label')}
helpMessagePrimary={t('protected:contact-information.address-help-message')}
name="address"
className="w-full"
errorMessage={errors?.address?.at(0)}
defaultValue={loaderData.defaultFormValues?.address}
/>
<InputField
id="postal-code"
label={t('protected:contact-information.postal-code-label')}
name="postalCode"
errorMessage={errors?.postalCode?.at(0)}
defaultValue={loaderData.defaultFormValues?.postalCode}
/>
<InputField
id="city"
label={t('protected:contact-information.city-label')}
name="city"
className="w-full"
errorMessage={errors?.city?.at(0)}
defaultValue={loaderData.defaultFormValues?.city}
/>
{country === loaderData.PP_CANADA_COUNTRY_CODE ? (
<InputSelect
className="w-max rounded-sm"
id="province"
label={t('protected:contact-information.canada-province-label')}
name="province"
options={provinceTerritoryStateOptions}
errorMessage={errors?.province?.at(0)}
defaultValue={loaderData.defaultFormValues?.province}
required
/>
) : (
<InputField
id="province"
label={t('protected:contact-information.other-country-province-label')}
name="province"
className="w-full"
errorMessage={errors?.province?.at(0)}
defaultValue={loaderData.defaultFormValues?.province}
/>
)}
</>
)}
</div>
<div className="mt-8 flex flex-row-reverse flex-wrap items-center justify-end gap-3">
<Button name="action" value="next" variant="primary" id="continue-button" disabled={isSubmitting}>
Expand Down

0 comments on commit 9c715c3

Please sign in to comment.