From 26249cd5e61bd8b6fc1d882094cd6c6821ead17f Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:02:23 -0500 Subject: [PATCH 1/2] Add ESLint rules for component to enforce t prop and prevent single-brace interpolation --- .eslintrc.js | 21 ++++ .../eslintrules/eslintTransRules.test.ts | 96 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 __tests__/eslintrules/eslintTransRules.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index f71608ffb4..694916a7dd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,6 +64,27 @@ module.exports = { ignoreMemberSort: false, }, ], + 'no-restricted-syntax': [ + 'error', + { + selector: + "JSXOpeningElement[name.name='Trans']:not(:has(JSXAttribute[name.name='t']))", + message: + ' must be passed a t={t} prop from useTranslation() so it resolves keys against the component i18n instance.', + }, + { + /* i18next interpolates {{ name }}, not {name}. A bare {name} child is a + * plain value at runtime, so it lands in the extracted key and the + * lookup can never match. + * Includes Trans and children of Trans, and determines if the descendent matches an + * Identifier (e.g. {name}), MemberExpression (e.g. {user.name}), or CallExpression (e.g. {getName()}) + */ + selector: + ":matches(JSXElement[openingElement.name.name='Trans'], JSXElement[openingElement.name.name='Trans'] JSXElement) > JSXExpressionContainer > :matches(Identifier, MemberExpression, CallExpression)", + message: + 'Single-brace {name} inside becomes part of the extracted key, so the lookup never matches. Use {{ name }} which only typechecks as a direct child of , so if this sits inside a nested element, move that element outside the instead.', + }, + ], curly: 'error', eqeqeq: 'error', 'no-console': 'error', diff --git a/__tests__/eslintrules/eslintTransRules.test.ts b/__tests__/eslintrules/eslintTransRules.test.ts new file mode 100644 index 0000000000..270b36060e --- /dev/null +++ b/__tests__/eslintrules/eslintTransRules.test.ts @@ -0,0 +1,96 @@ +import { Linter } from 'eslint'; +import eslintConfig from '../../.eslintrc'; + +interface RestrictedSyntaxOption { + selector: string; + message: string; +} + +// Assert against the real rules so these tests survive wording changes and fail +// if a selector is narrowed. +const restrictedSyntax = eslintConfig.rules['no-restricted-syntax'] as [ + 'error', + RestrictedSyntaxOption, + RestrictedSyntaxOption, +]; +const [, missingTRule, singleBraceRule] = restrictedSyntax; + +const linter = new Linter(); + +const lintTrans = (body: string): string[] => + linter + .verify(`export const Probe = ({ name, url, t }) => (\n ${body}\n);`, { + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + rules: { 'no-restricted-syntax': restrictedSyntax }, + }) + .filter((message) => message.ruleId === 'no-restricted-syntax') + .map((message) => message.message); + +describe(' no-restricted-syntax rules', () => { + it('flags a with no t prop', () => { + expect(lintTrans('All set')).toEqual([missingTRule.message]); + }); + + it('flags a self-closing with no t prop', () => { + expect( + lintTrans(''), + ).toEqual([missingTRule.message]); + }); + + it('accepts a that is passed t', () => { + expect(lintTrans('All set')).toEqual([]); + }); + + it('flags a single-brace interpolation', () => { + expect(lintTrans('Hello {name}')).toEqual([ + singleBraceRule.message, + ]); + }); + + it('flags a single-brace interpolation nested inside a child element', () => { + expect(lintTrans('Hello {name}')).toEqual([ + singleBraceRule.message, + ]); + }); + + it('flags a t() call used as a child', () => { + expect(lintTrans(`{t('Hello')}`)).toEqual([ + singleBraceRule.message, + ]); + }); + + it('accepts double-brace interpolation', () => { + expect( + lintTrans('Hello {{ name }}'), + ).toEqual([]); + }); + + /* + * react-i18next supports nested JSX/interpolation: nodesToString recursively + * serializes nested nodes, and the Trans rendering path resolves interpolation + * values recursively. The JSX form {{ name }} is different: it is parsed as an + * object literal ({ name }) inside a JSX expression container, so it is not a + * ReactNode and TypeScript rejects it when 's children prop is ReactNode. + */ + it('does not flag nested double-brace interpolation, which only TypeScript rejects', () => { + expect( + lintTrans('Hello {{ name }}'), + ).toEqual([]); + }); + + it('accepts a string literal child such as a whitespace separator', () => { + expect(lintTrans(`Hello{' '}there`)).toEqual( + [], + ); + }); + + it('accepts an expression in a nested element attribute', () => { + expect( + lintTrans('Docs'), + ).toEqual([]); + }); +}); From 944a6daff0526394e76ea5111b3f20a4ada15fe1 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:02:37 -0500 Subject: [PATCH 2/2] Refactor Trans component usage to include translation function for improved i18n support --- .../[accountListId]/setup/finish.page.tsx | 6 ++-- .../RequestPage/Helper/SplitCapSubContent.tsx | 8 ++--- .../Helper/SpouseOverCapSubContent.tsx | 2 +- .../MainPages/EligibleDisplay.tsx | 10 +++--- .../Steps/StepOne/AboutForm.tsx | 34 +++++++++---------- .../CalcComponents/FairRentalValue.tsx | 4 +-- .../CalcComponents/RequestSummaryCard.tsx | 8 ++--- .../Steps/StepThree/Calculation.tsx | 6 ++-- .../EffectiveDateBanner.tsx | 5 +-- .../integrations/Google/GoogleAccordion.tsx | 1 + .../Shared/Filters/NullState/NullState.tsx | 7 ++-- .../Shared/LimitedAccess/LimitedAccess.tsx | 4 +-- .../Shared/LimitedAccess/getLimitedText.tsx | 31 +++++++++-------- .../SuggestedContactStatus.tsx | 4 ++- .../Tool/Appeal/InitialPage/Appeals.tsx | 1 + .../UpdateDonationsModal.tsx | 1 + .../FixCommitmentInfo/FixCommitmentInfo.tsx | 1 + .../FixEmailAddresses/FixEmailAddresses.tsx | 1 + .../FixMailingAddresses.tsx | 1 + .../Tool/FixPhoneNumbers/FixPhoneNumbers.tsx | 1 + .../Tool/FixSendNewsletter/Contact.tsx | 2 ++ .../FixSendNewsletter/FixSendNewsletter.tsx | 3 ++ .../Tool/GoogleImport/GoogleImport.tsx | 1 + .../Tool/MergeContacts/ContactPair.tsx | 3 ++ .../Tool/MergeContacts/MergeContacts.tsx | 1 + .../MergeContacts/StickyConfirmButtons.tsx | 1 + .../Tool/MergePeople/MergePeople.tsx | 1 + 27 files changed, 87 insertions(+), 61 deletions(-) diff --git a/pages/accountLists/[accountListId]/setup/finish.page.tsx b/pages/accountLists/[accountListId]/setup/finish.page.tsx index 5abbd42129..6e43bac59c 100644 --- a/pages/accountLists/[accountListId]/setup/finish.page.tsx +++ b/pages/accountLists/[accountListId]/setup/finish.page.tsx @@ -43,10 +43,10 @@ const FinishPage: React.FC = () => { - {t('Congratulations!')} + + Congratulations!
- {t("You're all set!")} + You're all set!
} > diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx index db1ba2afc5..37ea7ea957 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx @@ -54,10 +54,10 @@ export const SplitCapSubContent: React.FC = ({ <> Please make adjustments to your request to continue. You may make a - separate request up to {spouseName}'s maximum allowable salary if - desired. After using both you and {spouseName}'s maximum allowable - salary, any additional requests can be submitted online but will require - approval through our{' '} + separate request up to {{ spouseName }}'s maximum allowable salary + if desired. After using both you and {{ spouseName }}'s maximum + allowable salary, any additional requests can be submitted online but + will require approval through our{' '} Please consider submitting your request at your maximum allowable salary - to reduce the amount on {spouseName}'s request, which may avoid + to reduce the amount on {{ spouseName }}'s request, which may avoid requiring approval through our{' '} = ({ isPending, isEditable, }) => { + const { t } = useTranslation(); + return ( {isPending ? (

- + Our records indicate that you have an MHA request{' '} waiting to be processed. To view your MHA request, click on the "View Current MHA" button below. {isEditable && ( - + If you would like to make changes to your request, click on the "Edit Request" button below. )}

) : ( - +

Our records indicate that you have an approved MHA amount. To view your MHA amount, click on the "View Current MHA" button diff --git a/src/components/HrTools/MinisterHousingAllowance/Steps/StepOne/AboutForm.tsx b/src/components/HrTools/MinisterHousingAllowance/Steps/StepOne/AboutForm.tsx index 7d7a4427eb..11d98ef3a7 100644 --- a/src/components/HrTools/MinisterHousingAllowance/Steps/StepOne/AboutForm.tsx +++ b/src/components/HrTools/MinisterHousingAllowance/Steps/StepOne/AboutForm.tsx @@ -57,7 +57,7 @@ export const AboutForm: React.FC = ({ {t('About this Form')}

- + A Minister's Housing Allowance Request is a form ministers complete to designate part of their compensation as tax-free housing allowance. To complete this form for the {{ nextYear }} tax year, @@ -90,26 +90,26 @@ export const AboutForm: React.FC = ({ - - - The next time the board will approve MHA Requests is {after} and your - approved annual MHA amount will appear on your{' '} + + + The next time the board will approve MHA Requests is {{ after }} and + your approved annual MHA amount will appear on your{' '} Salary Calculation Form - {approval} Once approved by the board, keep a copy for your tax + {{ approval }} Once approved by the board, keep a copy for your tax records. - - - {' '} - - What expenses can I claim on my MHA? - - - + + + + {' '} + + {t('What expenses can I claim on my MHA?')} + + = ({ schema }) => { {t('Monthly market rental value of your home.')} - + The best way to determine this amount is to have an appraiser or rental real estate specialist provide you with a written estimate of the monthly rental value. If this is not possible, @@ -72,7 +72,7 @@ export const FairRentalValue: React.FC = ({ schema }) => { )} - + This is a reasonable amount by which the monthly rental of your home would increase if it were furnished. diff --git a/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/CalcComponents/RequestSummaryCard.tsx b/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/CalcComponents/RequestSummaryCard.tsx index f5e0cdba0c..af2867bc4d 100644 --- a/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/CalcComponents/RequestSummaryCard.tsx +++ b/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/CalcComponents/RequestSummaryCard.tsx @@ -85,10 +85,10 @@ export const RequestSummaryCard: React.FC = ({ {t('Your Annual MHA Total')} - - This is calculated from your {above} responses and is the - lower of the Annual Fair Rental Value or the Annual Cost of - Providing a Home. + + This is calculated from your {{ above }} responses and is + the lower of the Annual Fair Rental Value or the Annual Cost + of Providing a Home. diff --git a/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/Calculation.tsx b/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/Calculation.tsx index a6d22123e1..cd9824c0be 100644 --- a/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/Calculation.tsx +++ b/src/components/HrTools/MinisterHousingAllowance/Steps/StepThree/Calculation.tsx @@ -278,7 +278,7 @@ export const Calculation: React.FC = ({ ) : actionRequired ? (

- + Please review the Annual MHA Request that you have submitted for Board approval and make any changes necessary here. The board will review this {{ after }} and you will receive notice @@ -287,7 +287,7 @@ export const Calculation: React.FC = ({

) : (

- + Please enter dollar amounts for each category below to calculate your Annual MHA. The board will review this{' '} {{ after }} and you will receive notice of your {{ approval }} @@ -302,7 +302,7 @@ export const Calculation: React.FC = ({ sx={{ verticalAlign: 'middle', opacity: 0.56 }} />{' '} - What expenses can I claim on my MHA? + What expenses can I claim on my MHA? diff --git a/src/components/HrTools/SalaryCalculator/EffectiveDateStep/EffectiveDateBanner/EffectiveDateBanner.tsx b/src/components/HrTools/SalaryCalculator/EffectiveDateStep/EffectiveDateBanner/EffectiveDateBanner.tsx index fd2c873d7d..d6835b0c7b 100644 --- a/src/components/HrTools/SalaryCalculator/EffectiveDateStep/EffectiveDateBanner/EffectiveDateBanner.tsx +++ b/src/components/HrTools/SalaryCalculator/EffectiveDateStep/EffectiveDateBanner/EffectiveDateBanner.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Alert, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; -import { Trans } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { navBarHeight } from 'src/components/Layouts/Primary/Primary'; const StyledAlert = styled(Alert)({ @@ -25,6 +25,7 @@ interface EffectiveDateBannerProps { export const EffectiveDateBanner: React.FC = ({ onClose, }) => { + const { t } = useTranslation(); const thisYear = DateTime.now().year; const nextYear = thisYear + 1; @@ -36,7 +37,7 @@ export const EffectiveDateBanner: React.FC = ({ data-testid="effective-date-banner-text" > - + Dates for {'{{nextYear}}'} are unavailable at this time while we update salary level tables. By December 15, {'{{thisYear}}'} you will be able to request a salary change for {'{{nextYear}}'}. diff --git a/src/components/Settings/integrations/Google/GoogleAccordion.tsx b/src/components/Settings/integrations/Google/GoogleAccordion.tsx index b711bbd5f2..fb4c344619 100644 --- a/src/components/Settings/integrations/Google/GoogleAccordion.tsx +++ b/src/components/Settings/integrations/Google/GoogleAccordion.tsx @@ -236,6 +236,7 @@ export const GoogleAccordion: React.FC = ({ color={theme.palette.mpdxGrayDark.main} > = ({ page }) => { + const { t } = useTranslation(); const { openTaskModal, preloadTaskModal } = useTaskModal(); const [contactsDialogOpen, setContactsDialogOpen] = useState(false); @@ -51,7 +52,7 @@ const CreateButton: React.FC = ({ page }) => { backgroundColor: theme.palette.mpdxBlue.main, }} > - + {renderDialog( AddMenuItemsEnum.NewContact, @@ -104,19 +105,21 @@ const NullState: React.FC = ({ <> diff --git a/src/components/Shared/LimitedAccess/LimitedAccess.tsx b/src/components/Shared/LimitedAccess/LimitedAccess.tsx index 71fbf08bad..fbd01174bf 100644 --- a/src/components/Shared/LimitedAccess/LimitedAccess.tsx +++ b/src/components/Shared/LimitedAccess/LimitedAccess.tsx @@ -21,9 +21,7 @@ export const LimitedAccess: React.FC = ({ - support@mpdx.org - + /> ); const { title, content } = getLimitedText({ diff --git a/src/components/Shared/LimitedAccess/getLimitedText.tsx b/src/components/Shared/LimitedAccess/getLimitedText.tsx index 68e7440a19..6a2494d1ed 100644 --- a/src/components/Shared/LimitedAccess/getLimitedText.tsx +++ b/src/components/Shared/LimitedAccess/getLimitedText.tsx @@ -3,7 +3,7 @@ import { Trans } from 'react-i18next'; interface GetLimitedTextProps { t: TFunction; - link: React.ReactNode; + link: React.ReactElement; noStaffAccount?: boolean; userGroupError?: boolean; } @@ -18,10 +18,11 @@ export const getLimitedText = ({ return { title: t('Unable to load this page'), content: ( - - Something went wrong while loading your account information. Please - try again later. If the problem persists, please contact {link}. - + ), }; } @@ -30,11 +31,11 @@ export const getLimitedText = ({ return { title: t('Access to this feature is limited.'), content: ( - - Our records show that you do not have a staff account. You cannot - access this feature if you do not have a staff account. If you think - this is a mistake, please contact {link}. - + ), }; } @@ -42,11 +43,11 @@ export const getLimitedText = ({ return { title: t('Access to this feature is limited.'), content: ( - - Our records show that you are not part of the user group that has access - to this feature. If you think this is a mistake, please contact {link}{' '} - to change your user group. - + ), }; }; diff --git a/src/components/Task/Modal/Form/Inputs/SuggestedContactStatus/SuggestedContactStatus.tsx b/src/components/Task/Modal/Form/Inputs/SuggestedContactStatus/SuggestedContactStatus.tsx index 19e13d376c..120ce1ca8f 100644 --- a/src/components/Task/Modal/Form/Inputs/SuggestedContactStatus/SuggestedContactStatus.tsx +++ b/src/components/Task/Modal/Form/Inputs/SuggestedContactStatus/SuggestedContactStatus.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { Checkbox, FormControl, FormControlLabel, Grid } from '@mui/material'; -import { Trans } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { PhaseEnum, StatusEnum } from 'src/graphql/types.generated'; import { useContactPartnershipStatuses } from 'src/hooks/useContactPartnershipStatuses'; import { useLocalizedConstants } from 'src/hooks/useLocalizedConstants'; @@ -44,6 +44,7 @@ export const SuggestedContactStatus: React.FC = ({ skip: !!contactStatus, }); + const { t } = useTranslation(); const { getLocalizedContactStatus } = useLocalizedConstants(); const { getContactStatusesByPhase } = useContactPartnershipStatuses(); @@ -80,6 +81,7 @@ export const SuggestedContactStatus: React.FC = ({ } label={ = ({ accountListId }) => { = ({ } else if (pledge && totalSelectedDonationsAmount < pledge.amount) { setLessThanPledgeConfirmationMessage( }} />, diff --git a/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx index e5541fbf87..c41b201011 100644 --- a/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx +++ b/src/components/Tool/FixCommitmentInfo/FixCommitmentInfo.tsx @@ -288,6 +288,7 @@ const FixCommitmentInfo: React.FC = ({ accountListId }: Props) => { title={modalState.title} message={ = ({ = ({ accountListId }: Props) => { = ({ accountListId }: Props) => { }} /> diff --git a/src/components/Tool/FixSendNewsletter/FixSendNewsletter.tsx b/src/components/Tool/FixSendNewsletter/FixSendNewsletter.tsx index b5d48e6670..728240a51e 100644 --- a/src/components/Tool/FixSendNewsletter/FixSendNewsletter.tsx +++ b/src/components/Tool/FixSendNewsletter/FixSendNewsletter.tsx @@ -132,6 +132,7 @@ const FixSendNewsletter: React.FC = ({ accountListId }: Props) => { { = ({ accountListId }: Props) => { = ({ accountListId }: Props) => { > { = ({ accountListId }: Props) => { }} title={ = ({ {isContactType && ( = ({ = ({ = ({ > = ({ = ({ accountListId }: Props) => { >