From da4660bc0a84c3011d26e4bcbfa27d8bebc2905d Mon Sep 17 00:00:00 2001 From: Greg Baker <48123208+gregory-j-baker@users.noreply.github.com> Date: Fri, 21 Feb 2025 11:20:28 -0330 Subject: [PATCH] feat(frontend): add `getTranslation()` function (#245) * feat(fronend): add `getTranslation()` function * feat(fronend): use `getTranslation()` function --- frontend/app/i18n-config.server.ts | 23 +++++++++++++++++ frontend/app/routes/protected/admin.tsx | 4 +-- frontend/app/routes/protected/index.tsx | 4 +-- .../person-case/contact-information.tsx | 11 +++----- .../protected/person-case/current-name.tsx | 19 ++++++++------ .../protected/person-case/previous-sin.tsx | 13 +++++----- .../protected/person-case/primary-docs.tsx | 19 +++++--------- .../person-case/privacy-statement.tsx | 14 +++++------ .../protected/person-case/request-details.tsx | 25 +++++++++---------- frontend/app/routes/protected/request.tsx | 4 +-- frontend/app/routes/public/index.tsx | 4 +-- 11 files changed, 76 insertions(+), 64 deletions(-) diff --git a/frontend/app/i18n-config.server.ts b/frontend/app/i18n-config.server.ts index 013156e3..b44ed695 100644 --- a/frontend/app/i18n-config.server.ts +++ b/frontend/app/i18n-config.server.ts @@ -15,6 +15,7 @@ import { getLanguage } from '~/utils/i18n-utils'; * @param languageOrRequest - The language code or Request object to get the language from. * @param namespace - The namespace to get the translation function for. * @returns A translation function for the given language and namespace. + * @throws {AppError} If no language is found in the `languageOrRequest`. */ export async function getFixedT( languageOrRequest: Language | Request, @@ -34,6 +35,28 @@ export async function getFixedT( return i18n.getFixedT(language, namespace); } +/** + * Similar to react-i18next's `useTranslation()` hook, this function will return a `t` + * function and a `lang` value that represents the current language. + * + * @param languageOrRequest - The language code or Request object to get the language from. + * @param namespace - The namespace to get the translation function for. + * @returns A Promise resolving to an object containing the language code (`lang`) and a translation function (`t`) for the given namespace. + * @throws {AppError} If no language is found in the `languageOrRequest`. + */ +export async function getTranslation( + languageOrRequest: Language | Request, + namespace: NS, +): Promise<{ lang: Language; t: TFunction }> { + const lang = getLanguage(languageOrRequest); + + if (lang === undefined) { + throw new AppError('No language found in request', ErrorCodes.NO_LANGUAGE_FOUND); + } + + return { lang, t: await getFixedT(languageOrRequest, namespace) }; +} + /** * Creates and initializes an i18next instance for server-side rendering. */ diff --git a/frontend/app/routes/protected/admin.tsx b/frontend/app/routes/protected/admin.tsx index 54ed8009..595bf84f 100644 --- a/frontend/app/routes/protected/admin.tsx +++ b/frontend/app/routes/protected/admin.tsx @@ -6,7 +6,7 @@ import type { Route } from './+types/admin'; import { requireAuth } from '~/.server/utils/auth-utils'; import { PageTitle } from '~/components/page-title'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; export const handle = { @@ -15,7 +15,7 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['admin']); - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); return { documentTitle: t('protected:index.page-title') }; } diff --git a/frontend/app/routes/protected/index.tsx b/frontend/app/routes/protected/index.tsx index 029eb84f..d9b3f907 100644 --- a/frontend/app/routes/protected/index.tsx +++ b/frontend/app/routes/protected/index.tsx @@ -13,7 +13,7 @@ import { requireAuth } from '~/.server/utils/auth-utils'; import { Card, CardDescription, CardHeader, CardIcon, CardTitle } from '~/components/card'; import { AppLink } from '~/components/links'; import { PageTitle } from '~/components/page-title'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import type { I18nRouteFile } from '~/i18n-routes'; import { handle as parentHandle } from '~/routes/protected/layout'; @@ -23,7 +23,7 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); return { documentTitle: t('protected:index.page-title') }; } diff --git a/frontend/app/routes/protected/person-case/contact-information.tsx b/frontend/app/routes/protected/person-case/contact-information.tsx index 55dc2d64..b3c1c92b 100644 --- a/frontend/app/routes/protected/person-case/contact-information.tsx +++ b/frontend/app/routes/protected/person-case/contact-information.tsx @@ -1,7 +1,7 @@ import { useId } from 'react'; -import { data, useFetcher } from 'react-router'; import type { RouteHandle, SessionData } from 'react-router'; +import { data, useFetcher } from 'react-router'; import { useTranslation } from 'react-i18next'; import * as v from 'valibot'; @@ -19,9 +19,8 @@ import { InputRadios } from '~/components/input-radios'; import { InputSelect } from '~/components/input-select'; import { PageTitle } from '~/components/page-title'; import { AppError } from '~/errors/app-error'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; -import { getLanguage } from '~/utils/i18n-utils'; type ContactInformationSessionData = NonNullable; @@ -33,8 +32,7 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const lang = getLanguage(request); - const t = await getFixedT(request, handle.i18nNamespace); + const { lang, t } = await getTranslation(request, handle.i18nNamespace); return { documentTitle: t('protected:contact-information.page-title'), @@ -49,9 +47,8 @@ export function meta({ data }: Route.MetaArgs) { export async function action({ context, request }: Route.ActionArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const lang = getLanguage(request); - const t = await getFixedT(request, handle.i18nNamespace); + const { lang, t } = await getTranslation(request, handle.i18nNamespace); const formData = await request.formData(); const action = formData.get('action'); diff --git a/frontend/app/routes/protected/person-case/current-name.tsx b/frontend/app/routes/protected/person-case/current-name.tsx index 932868c8..b9240921 100644 --- a/frontend/app/routes/protected/person-case/current-name.tsx +++ b/frontend/app/routes/protected/person-case/current-name.tsx @@ -1,15 +1,15 @@ import type { ChangeEvent } from 'react'; import { useId, useState } from 'react'; -import { data, useFetcher } from 'react-router'; import type { RouteHandle } from 'react-router'; +import { data, useFetcher } from 'react-router'; import { faExclamationCircle, 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 { Route, Info } from './+types/current-name'; +import type { Info, Route } from './+types/current-name'; import { requireAuth } from '~/.server/utils/auth-utils'; import { i18nRedirect } from '~/.server/utils/route-utils'; @@ -23,15 +23,17 @@ import { PageTitle } from '~/components/page-title'; import { Progress } from '~/components/progress'; import { AppError } from '~/errors/app-error'; import { ErrorCodes } from '~/errors/error-codes'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; -import { getLanguage } from '~/utils/i18n-utils'; import { REGEX_PATTERNS } from '~/utils/regex-utils'; import { trimToUndefined } from '~/utils/string-utils'; type CurrentNameSessionData = NonNullable; -const REQUIRE_OPTIONS = { yes: 'Yes', no: 'No' } as const; +const REQUIRE_OPTIONS = { + yes: 'Yes', // + no: 'No', +} as const; const VALID_DOC_TYPES: string[] = [ 'marriage-document', @@ -51,9 +53,11 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const t = await getFixedT(request, handle.i18nNamespace); + + const { t } = await getTranslation(request, handle.i18nNamespace); const currentNameInfo = context.session.inPersonSINCase?.currentNameInfo; const preferredSameAsDocumentName = currentNameInfo?.preferredSameAsDocumentName; + return { documentTitle: t('protected:primary-identity-document.page-title'), defaultFormValues: { @@ -83,9 +87,8 @@ export function meta({ data }: Route.MetaArgs) { export async function action({ context, request }: Route.ActionArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const lang = getLanguage(request); - const t = await getFixedT(request, handle.i18nNamespace); + const { lang, t } = await getTranslation(request, handle.i18nNamespace); const formData = await request.formData(); const action = formData.get('action'); const nameMaxLength = 100; diff --git a/frontend/app/routes/protected/person-case/previous-sin.tsx b/frontend/app/routes/protected/person-case/previous-sin.tsx index 6f161ec9..88300bc1 100644 --- a/frontend/app/routes/protected/person-case/previous-sin.tsx +++ b/frontend/app/routes/protected/person-case/previous-sin.tsx @@ -1,8 +1,8 @@ -import { useId, useState } from 'react'; import type { ChangeEvent } from 'react'; +import { useId, useState } from 'react'; -import { data, useFetcher } from 'react-router'; import type { RouteHandle, SessionData } from 'react-router'; +import { data, useFetcher } from 'react-router'; import { useTranslation } from 'react-i18next'; import * as v from 'valibot'; @@ -17,9 +17,8 @@ import { InputPatternField } from '~/components/input-pattern-field'; import { InputRadios } from '~/components/input-radios'; import { PageTitle } from '~/components/page-title'; import { AppError } from '~/errors/app-error'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; -import { getLanguage } from '~/utils/i18n-utils'; import { formatSin, isValidSin, sinInputPatternFormat } from '~/utils/sin-utils'; type PreviousSinSessionData = NonNullable; @@ -32,7 +31,8 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); + return { documentTitle: t('protected:previous-sin.page-title'), defaultFormValues: context.session.inPersonSINCase?.previousSin, @@ -45,9 +45,8 @@ export function meta({ data }: Route.MetaArgs) { export async function action({ context, request }: Route.ActionArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const lang = getLanguage(request); - const t = await getFixedT(request, handle.i18nNamespace); + const { lang, t } = await getTranslation(request, handle.i18nNamespace); const formData = await request.formData(); const action = formData.get('action'); diff --git a/frontend/app/routes/protected/person-case/primary-docs.tsx b/frontend/app/routes/protected/person-case/primary-docs.tsx index 8f691811..97e5d79f 100644 --- a/frontend/app/routes/protected/person-case/primary-docs.tsx +++ b/frontend/app/routes/protected/person-case/primary-docs.tsx @@ -1,14 +1,14 @@ import { useId, useState } from 'react'; -import { data, useFetcher } from 'react-router'; import type { RouteHandle } from 'react-router'; +import { data, useFetcher } from 'react-router'; import { faExclamationCircle, 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 { Route, Info } from './+types/primary-docs'; +import type { Info, Route } from './+types/primary-docs'; import { requireAuth } from '~/.server/utils/auth-utils'; import { i18nRedirect } from '~/.server/utils/route-utils'; @@ -19,19 +19,13 @@ import { PageTitle } from '~/components/page-title'; import { Progress } from '~/components/progress'; import { AppError } from '~/errors/app-error'; import { ErrorCodes } from '~/errors/error-codes'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; -import { getLanguage } from '~/utils/i18n-utils'; type PrimaryDocumentsSessionData = NonNullable; -/** - * Valid current status in Canada for proof of concept - */ const VALID_CURRENT_STATUS = ['canadian-citizen-born-outside-canada']; -/** - * Valid document type for proof of concept - */ + const VALID_DOCTYPE = ['certificate-of-canadian-citizenship', 'certificate-of-registration-of-birth-abroad']; export const handle = { @@ -40,7 +34,7 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); return { documentTitle: t('protected:primary-identity-document.page-title'), @@ -54,9 +48,8 @@ export function meta({ data }: Route.MetaArgs) { export async function action({ context, request }: Route.ActionArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const lang = getLanguage(request); - const t = await getFixedT(request, handle.i18nNamespace); + const { lang, t } = await getTranslation(request, handle.i18nNamespace); const formData = await request.formData(); const action = formData.get('action'); diff --git a/frontend/app/routes/protected/person-case/privacy-statement.tsx b/frontend/app/routes/protected/person-case/privacy-statement.tsx index c7bb4569..4abfe56f 100644 --- a/frontend/app/routes/protected/person-case/privacy-statement.tsx +++ b/frontend/app/routes/protected/person-case/privacy-statement.tsx @@ -1,14 +1,14 @@ import { useId } from 'react'; -import { data, useFetcher } from 'react-router'; import type { RouteHandle } from 'react-router'; +import { data, useFetcher } from 'react-router'; -import { faXmark, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { faExclamationCircle, 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 { Route, Info } from './+types/privacy-statement'; +import type { Info, Route } from './+types/privacy-statement'; import { requireAuth } from '~/.server/utils/auth-utils'; import { i18nRedirect } from '~/.server/utils/route-utils'; @@ -18,9 +18,8 @@ import { InputCheckbox } from '~/components/input-checkbox'; import { PageTitle } from '~/components/page-title'; import { Progress } from '~/components/progress'; import { AppError } from '~/errors/app-error'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; -import { getLanguage } from '~/utils/i18n-utils'; type PrivacyStatmentSessionData = NonNullable; @@ -30,7 +29,7 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); return { documentTitle: t('protected:privacy-statement.page-title'), @@ -44,9 +43,8 @@ export function meta({ data }: Route.MetaArgs) { export async function action({ context, request }: Route.ActionArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const lang = getLanguage(request); - const t = await getFixedT(request, handle.i18nNamespace); + const { lang, t } = await getTranslation(request, handle.i18nNamespace); const formData = await request.formData(); const action = formData.get('action'); diff --git a/frontend/app/routes/protected/person-case/request-details.tsx b/frontend/app/routes/protected/person-case/request-details.tsx index d5847ad6..dcb0678c 100644 --- a/frontend/app/routes/protected/person-case/request-details.tsx +++ b/frontend/app/routes/protected/person-case/request-details.tsx @@ -1,7 +1,7 @@ import { useId } from 'react'; -import { data, useFetcher } from 'react-router'; import type { RouteHandle, SessionData } from 'react-router'; +import { data, useFetcher } from 'react-router'; import { useTranslation } from 'react-i18next'; import * as v from 'valibot'; @@ -16,15 +16,11 @@ import { InputRadios } from '~/components/input-radios'; import { InputSelect } from '~/components/input-select'; import { PageTitle } from '~/components/page-title'; import { AppError } from '~/errors/app-error'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; -import { getLanguage } from '~/utils/i18n-utils'; type RequestDetailsSessionData = NonNullable; -/** - * Valid requests when creating a request - */ const VALID_REQUESTS = [ 'first-time', 'record-confirmation', @@ -35,10 +31,13 @@ const VALID_REQUESTS = [ 'new-sin', ] as const; -/** - * Valid scenarios when creating a request - */ -const VALID_SCENARIOS = ['for-self', 'legal-guardian', 'legal-representative', 'as-employee', 'estate-representative'] as const; +const VALID_SCENARIOS = [ + 'for-self', // + 'legal-guardian', + 'legal-representative', + 'as-employee', + 'estate-representative', +] as const; export const handle = { i18nNamespace: [...parentHandle.i18nNamespace, 'protected'], @@ -46,7 +45,8 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); + return { documentTitle: t('protected:request-details.page-title'), defaultFormValues: context.session.inPersonSINCase?.requestDetails, @@ -59,9 +59,8 @@ export function meta({ data }: Route.MetaArgs) { export async function action({ context, request }: Route.ActionArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const lang = getLanguage(request); - const t = await getFixedT(request, handle.i18nNamespace); + const { lang, t } = await getTranslation(request, handle.i18nNamespace); const formData = await request.formData(); const action = formData.get('action'); diff --git a/frontend/app/routes/protected/request.tsx b/frontend/app/routes/protected/request.tsx index 99e9aa87..77985100 100644 --- a/frontend/app/routes/protected/request.tsx +++ b/frontend/app/routes/protected/request.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { Route } from './+types/request'; import { requireAuth } from '~/.server/utils/auth-utils'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/protected/layout'; export const handle = { @@ -14,7 +14,7 @@ export const handle = { export async function loader({ context, request }: Route.LoaderArgs) { requireAuth(context.session, new URL(request.url), ['user']); - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); return { documentTitle: t('protected:index.page-title') }; } diff --git a/frontend/app/routes/public/index.tsx b/frontend/app/routes/public/index.tsx index a954fa49..b31cba16 100644 --- a/frontend/app/routes/public/index.tsx +++ b/frontend/app/routes/public/index.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { Route } from './+types/index'; import { PageTitle } from '~/components/page-title'; -import { getFixedT } from '~/i18n-config.server'; +import { getTranslation } from '~/i18n-config.server'; import { handle as parentHandle } from '~/routes/public/layout'; export const handle = { @@ -13,7 +13,7 @@ export const handle = { } as const satisfies RouteHandle; export async function loader({ request }: Route.LoaderArgs) { - const t = await getFixedT(request, handle.i18nNamespace); + const { t } = await getTranslation(request, handle.i18nNamespace); return { documentTitle: t('public:index.page-title') }; }