From acbb3aff8648bade0fc290b370879b8ac07596cc Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:27:45 -0500 Subject: [PATCH 1/4] Return the account list ID from useNewStaffGoalCalculation --- .../GoalSettings/useNewStaffGoalCalculation.test.tsx | 2 ++ .../GoalSettings/useNewStaffGoalCalculation.tsx | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.test.tsx index 4f5b8e2633..c1d279e4f0 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.test.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.test.tsx @@ -42,6 +42,7 @@ describe('useNewStaffGoalCalculation', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.isScenario).toBe(false); + expect(result.current.accountListId).toBe('account-list-1'); expect(result.current.goalCalculation?.id).toBe('goal-1'); expect(mutationSpy).toHaveGraphqlOperation('NewStaffGoalCalculation', { accountListId: 'account-list-1', @@ -57,6 +58,7 @@ describe('useNewStaffGoalCalculation', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.isScenario).toBe(true); + expect(result.current.accountListId).toBeNull(); expect(result.current.goalCalculation?.id).toBe('scenario-2'); expect(result.current.goalCalculation?.firstName).toBe('Grace'); expect(mutationSpy).toHaveGraphqlOperation('NewStaffGoalCalculation', { diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.tsx index b6e1f96cea..69da336072 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/useNewStaffGoalCalculation.tsx @@ -30,6 +30,8 @@ interface UseNewStaffGoalCalculationResult { fallback: ReactElement | null; /** True when the source is a scenario goal (fetched/saved by id). */ isScenario: boolean; + /** The account list the goal belongs to, or `null` for a scenario goal. */ + accountListId: string | null; /** * Save edits to the goal. Real goals save by account list + id; scenario goals * by id alone. Both go through the updateNewStaffGoalCalculation mutation. @@ -82,6 +84,7 @@ export const useNewStaffGoalCalculation = ( error, fallback, isScenario, + accountListId: isScenario ? null : source.accountListId, save: async (attributes) => { if (!goalCalculation) { return; From 9390f6d13b40662242a8470c703a721a702eabe6 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:28:01 -0500 Subject: [PATCH 2/4] Add over cap flags to the goal calculation query --- .../GoalSettings/NewStaffGoalCalculation.graphql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql b/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql index cf50f82ad4..05db347679 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql @@ -44,6 +44,9 @@ fragment NewStaffGoalCalculationFields on NewStaffGoalCalculation { specialNeedsTotal specialNeedsDevelopedToDate specialNeedsLeft + + salaryOverCap + debtOverCap } # Identity (read-only display in the header; prepopulated from HCM). @@ -135,6 +138,8 @@ mutation PreviewNewStaffGoalCalculation( id calculations { monthlyGoal + salaryOverCap + debtOverCap } } } From 624415cac56ab1f676bb557acf8b10e9f34581a5 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:28:11 -0500 Subject: [PATCH 3/4] Show admin warnings in Goal Settings --- .../GoalSettings/GoalSettingsForm.test.tsx | 65 +++ .../GoalSettings/GoalSettingsForm.tsx | 103 ++-- .../GoalSettings/GoalSettingsHeader.test.tsx | 2 - .../GoalSettings/GoalSettingsHeader.tsx | 12 +- .../GoalSettingsPreviewContext.tsx | 89 +++ .../GoalSettings/GoalSettingsWarning.test.tsx | 550 ++++++++++++++++++ .../GoalSettings/GoalSettingsWarning.tsx | 50 ++ .../GoalSettings/MpdGoalPreview.test.tsx | 34 +- .../GoalSettings/MpdGoalPreview.tsx | 35 +- .../GoalSettings/goalSettingsWarnings.ts | 179 ++++++ .../GoalSettings/useMpdGoalPreview.ts | 86 +-- 11 files changed, 1084 insertions(+), 121 deletions(-) create mode 100644 src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx create mode 100644 src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.test.tsx create mode 100644 src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.tsx create mode 100644 src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsWarnings.ts diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.test.tsx index 2b42d3ef5a..2cb6d4acc5 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.test.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.test.tsx @@ -339,6 +339,71 @@ describe('GoalSettingsForm', () => { ); }); + // Before the shared provider, each consumer ran its own preview request. + it('previews an edit exactly once with every consumer mounted', async () => { + const { findByRole } = render(); + + const salary = await findByRole('spinbutton', { + name: 'Annual Requested Salary — John', + }); + userEvent.clear(salary); + userEvent.type(salary, '250000'); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'PreviewNewStaffGoalCalculation', + ), + ); + + const previewCalls = mutationSpy.mock.calls.filter( + ([{ operation }]) => + operation.operationName === 'PreviewNewStaffGoalCalculation', + ); + expect(previewCalls).toHaveLength(1); + }); + + it('shows the salary over-cap warning', async () => { + const { findByText, findByRole } = render( + , + ); + + await findByRole('button', { name: 'Save & Share' }); + expect( + await findByText('Total salary is over the standard cap'), + ).toBeInTheDocument(); + }); + + it('shows the debt over-cap warning', async () => { + const { findByText, findByRole } = render( + , + ); + + await findByRole('button', { name: 'Save & Share' }); + expect( + await findByText('Annual debt is over the standard cap'), + ).toBeInTheDocument(); + }); + it('clears spouse attributes when marital status changes from married to single', async () => { const { findByRole, getByRole } = render(); diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.tsx index f983bcd3ac..d7e67b4d14 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsForm.tsx @@ -11,7 +11,9 @@ import { Form, Formik } from 'formik'; import { useTranslation } from 'react-i18next'; import { NewStaffQuestionnaireMaritalStatusEnum } from 'src/graphql/types.generated'; import { GoalSettingsHeader } from './GoalSettingsHeader'; +import { GoalSettingsPreviewProvider } from './GoalSettingsPreviewContext'; import { GoalSettingsScrollContainer } from './GoalSettingsScrollContainer'; +import { GoalSettingsWarning } from './GoalSettingsWarning'; import { ContactInformationSection } from './Sections/ContactInformationSection'; import { ExemptionsSection } from './Sections/ExemptionsSection'; import { FinancialInformationSection } from './Sections/FinancialInformationSection'; @@ -67,6 +69,8 @@ export const GoalSettingsForm: React.FC = (props) => { goalCalculation: calculation, fallback, isScenario, + // `null` for scenario goals, which have no account list. + accountListId, save, } = useNewStaffGoalCalculation(props); @@ -155,51 +159,62 @@ export const GoalSettingsForm: React.FC = (props) => { }; return ( -
- - - - - {isScenario && } - - - - - - - - - - - - - - + + + + + + + + + ); }} diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsHeader.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsHeader.test.tsx index fa73f48aae..3f7ba3f893 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsHeader.test.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsHeader.test.tsx @@ -51,8 +51,6 @@ const TestComponent: React.FC = ({ >
= ({ person }) => { }; interface GoalSettingsHeaderProps { - /** Account list the goal belongs to, or `null` for a scenario goal. */ - accountListId: string | null; - /** Id of the calculation to preview unsaved changes against. */ - calculationId: string; primaryPerson: GoalSettingsPerson; spousePerson: GoalSettingsPerson | null; /** The saved goal total, shown when there are no goal-affecting edits. */ @@ -87,8 +83,6 @@ interface GoalSettingsHeaderProps { } export const GoalSettingsHeader: React.FC = ({ - accountListId, - calculationId, primaryPerson, spousePerson, mpdGoal, @@ -175,11 +169,7 @@ export const GoalSettingsHeader: React.FC = ({ - + {!isScenario && ( diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx new file mode 100644 index 0000000000..6d79b42558 --- /dev/null +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx @@ -0,0 +1,89 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { useFormikContext } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { GoalSettingsFormValues } from './goalSettingsFormValues'; +import { + GoalSettingsWarningItem, + WarningSeverity, + buildGoalSettingsWarnings, + getFieldSeverity, +} from './goalSettingsWarnings'; +import { useMpdGoalPreview } from './useMpdGoalPreview'; + +interface GoalSettingsPreviewValue { + calculating: boolean; + failed: boolean; + previewGoal: number | null; + warnings: GoalSettingsWarningItem[]; + fieldSeverity: (name: string) => WarningSeverity | undefined; +} + +const GoalSettingsPreviewContext = + createContext(null); + +interface GoalSettingsPreviewProviderProps { + /** `null` for a scenario goal, which has no account list. */ + accountListId: string | null; + calculationId: string; + /** The over-cap flags on the saved record; used until a preview settles. */ + savedSalaryOverCap: boolean; + savedDebtOverCap: boolean; + children?: React.ReactNode; +} + +/** + * Owns the Goal Settings preview for the whole form. Every consumer — the goal + * figure, the warnings, the field outlines — reads from here, so an edit costs + * one request no matter how many are mounted, and they can't disagree mid-flight. + * + * Must render inside the ``, since the preview keys off live values. + */ +export const GoalSettingsPreviewProvider: React.FC< + GoalSettingsPreviewProviderProps +> = ({ + accountListId, + calculationId, + savedSalaryOverCap, + savedDebtOverCap, + children, +}) => { + const { t } = useTranslation(); + const { values } = useFormikContext(); + const { + calculating, + failed, + previewGoal, + previewSalaryOverCap, + previewDebtOverCap, + } = useMpdGoalPreview({ accountListId, calculationId }); + + const salaryOverCap = previewSalaryOverCap ?? savedSalaryOverCap; + const debtOverCap = previewDebtOverCap ?? savedDebtOverCap; + + const value = useMemo(() => { + const warnings = buildGoalSettingsWarnings({ + values, + salaryOverCap, + debtOverCap, + t, + }); + + return { + calculating, + failed, + previewGoal, + warnings, + fieldSeverity: (name) => getFieldSeverity(warnings, name), + }; + }, [values, salaryOverCap, debtOverCap, t, calculating, failed, previewGoal]); + + return ( + + {children} + + ); +}; + +/** `null` outside a provider, so standalone fields just skip highlighting. */ +export const useGoalSettingsPreview = (): GoalSettingsPreviewValue | null => + useContext(GoalSettingsPreviewContext); diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.test.tsx new file mode 100644 index 0000000000..af521b62e1 --- /dev/null +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.test.tsx @@ -0,0 +1,550 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Field, Form, Formik } from 'formik'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { + MpdGoalBenefitsConstantPlanEnum, + NewStaffGoalCalculationSalaryOverCapEnum, + NewStaffQuestionnaireMaritalStatusEnum, +} from 'src/graphql/types.generated'; +import i18n from 'src/lib/i18n'; +import { defaultGoalCalculation } from '../NsGoalCalculatorTestWrapper'; +import { GoalSettingsPreviewProvider } from './GoalSettingsPreviewContext'; +import { GoalSettingsWarning } from './GoalSettingsWarning'; +import { PreviewNewStaffGoalCalculationMutation } from './NewStaffGoalCalculation.generated'; +import { calculationToFormValues } from './goalSettingsApiMapping'; +import { GoalSettingsFormValues } from './goalSettingsFormValues'; +import { getGoalSettingsSchema } from './goalSettingsSchema'; + +const mutationSpy = jest.fn(); + +const { Married, Single, Sosa } = NewStaffQuestionnaireMaritalStatusEnum; +const { Base, Exempt, Select } = MpdGoalBenefitsConstantPlanEnum; +const { No, UpToBcc } = NewStaffGoalCalculationSalaryOverCapEnum; + +interface TestComponentProps { + savedSalaryOverCap?: boolean; + savedDebtOverCap?: boolean; + previewSalaryOverCap?: boolean; + previewDebtOverCap?: boolean; + allowSalaryOverCap?: GoalSettingsFormValues['allowSalaryOverCap']; + allowDebtOverCap?: GoalSettingsFormValues['allowDebtOverCap']; + maritalStatus?: GoalSettingsFormValues['maritalStatus']; + healthcareExempt?: GoalSettingsFormValues['healthcareExempt']; + spouseHealthcareExempt?: GoalSettingsFormValues['spouseHealthcareExempt']; + secaExempt?: GoalSettingsFormValues['secaExempt']; + spouseSecaExempt?: GoalSettingsFormValues['spouseSecaExempt']; + benefitsPlan?: GoalSettingsFormValues['benefitsPlan']; + validationSchema?: ReturnType; +} + +const TestComponent: React.FC = ({ + savedSalaryOverCap = false, + savedDebtOverCap = false, + previewSalaryOverCap = false, + previewDebtOverCap = false, + allowSalaryOverCap = No, + allowDebtOverCap = 'false', + maritalStatus = Single, + healthcareExempt = 'false', + spouseHealthcareExempt = 'false', + secaExempt = 'false', + spouseSecaExempt = 'false', + benefitsPlan = Base, + validationSchema, +}) => ( + + mocks={{ + PreviewNewStaffGoalCalculation: { + previewNewStaffGoalCalculation: { + newStaffGoalCalculation: { + id: defaultGoalCalculation.id, + calculations: { + monthlyGoal: 5200, + salaryOverCap: previewSalaryOverCap, + debtOverCap: previewDebtOverCap, + }, + }, + }, + }, + }} + onCall={mutationSpy} + > + + {({ isValid }) => ( + + + + {String(isValid)} + + + + )} + + +); + +describe('GoalSettingsWarning', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('warns when the saved calculation is over the salary cap', () => { + const { getByText } = render(); + + expect( + getByText('Total salary is over the standard cap'), + ).toBeInTheDocument(); + }); + + it('clears a salary warning an edit resolves, and keeps it clear while a later edit previews', async () => { + const { getByRole, getByText, queryByText } = render( + , + ); + + expect( + getByText('Total salary is over the standard cap'), + ).toBeInTheDocument(); + + const salary = getByRole('spinbutton', { name: 'Salary' }); + userEvent.clear(salary); + userEvent.type(salary, '40000'); + + await waitFor(() => + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(), + ); + + userEvent.clear(salary); + userEvent.type(salary, '41000'); + + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(); + }); + + it('raises a salary warning an edit introduces, and does not resurrect it once undone', async () => { + const { findByText, getByRole, queryByText } = render( + , + ); + + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(); + + const salary = getByRole('spinbutton', { + name: 'Salary', + }) as HTMLInputElement; + const original = salary.value; + + userEvent.clear(salary); + userEvent.type(salary, '250000'); + + expect( + await findByText('Total salary is over the standard cap'), + ).toBeInTheDocument(); + + userEvent.clear(salary); + userEvent.type(salary, original); + + await waitFor(() => + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(), + ); + + userEvent.clear(salary); + userEvent.type(salary, '41000'); + + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(); + }); + + it('keeps a resolved salary warning hidden while the form is invalid', async () => { + const { getByRole, getByTestId, getByText, queryByText } = render( + , + ); + + expect( + getByText('Total salary is over the standard cap'), + ).toBeInTheDocument(); + + const salary = getByRole('spinbutton', { name: 'Salary' }); + userEvent.clear(salary); + userEvent.type(salary, '40000'); + + await waitFor(() => + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(), + ); + + userEvent.clear(salary); + userEvent.type(salary, '-100'); + + await waitFor(() => + expect(getByTestId('is-valid')).toHaveTextContent('false'), + ); + + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(); + }); + + it('shows the salary warning in the error color when no exception allows it', () => { + const { getByText } = render( + , + ); + + expect( + getByText('Total salary is over the standard cap').closest( + '.MuiAlert-root', + ), + ).toHaveClass('MuiAlert-standardError'); + }); + + it('treats an unset salary exception as not allowed', () => { + const { getByText } = render( + , + ); + + expect( + getByText('Total salary is over the standard cap').closest( + '.MuiAlert-root', + ), + ).toHaveClass('MuiAlert-standardError'); + }); + + it('warns when the saved calculation is over the debt cap', () => { + const { getByText } = render(); + + expect( + getByText('Annual debt is over the standard cap'), + ).toBeInTheDocument(); + }); + + // The preview lifecycle is shared machinery, covered by the salary tests + // above; this only checks the debt flag is wired to it. + it('raises a debt warning an edit introduces', async () => { + const { findByText, getByRole, queryByText } = render( + , + ); + + expect( + queryByText('Annual debt is over the standard cap'), + ).not.toBeInTheDocument(); + + const salary = getByRole('spinbutton', { name: 'Salary' }); + userEvent.clear(salary); + userEvent.type(salary, '250000'); + + expect( + await findByText('Annual debt is over the standard cap'), + ).toBeInTheDocument(); + }); + + it('shows the debt warning in the warning color when an exception allows it', () => { + const { getByText } = render( + , + ); + + expect( + getByText('Annual debt is over the standard cap').closest( + '.MuiAlert-root', + ), + ).toHaveClass('MuiAlert-standardWarning'); + }); + + it('warns a single exempt applicant on a non-Select plan', () => { + const { getByText } = render( + , + ); + + expect( + getByText('John is exempt, so they must have the Select plan'), + ).toBeInTheDocument(); + }); + + it('warns a SOSA exempt applicant on a non-Select plan', () => { + const { getByText } = render( + , + ); + + expect( + getByText('John is exempt, so they must have the Select plan'), + ).toBeInTheDocument(); + }); + + it('warns a married couple who are both exempt on a non-Select plan', () => { + const { getByText } = render( + , + ); + + expect( + getByText('John and Jane are exempt, so they must have the Select plan'), + ).toBeInTheDocument(); + }); + + it('stays silent when only the applicant is exempt', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(/must have the Select plan/)).not.toBeInTheDocument(); + }); + + it('stays silent when only the spouse is exempt', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(/must have the Select plan/)).not.toBeInTheDocument(); + }); + + it('stays silent when an exempt applicant already has the Select plan', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(/must have the Select plan/)).not.toBeInTheDocument(); + }); + + // Follows NSGC DA5 literally: it tests `NOT(plan = "Select")`, and its plan + // list has no Exempt option, so Exempt counts as a violating plan here. + it('warns about the Exempt plan, which DA5 has no option for', () => { + const { getByText } = render( + , + ); + + expect( + getByText('John is exempt, so they must have the Select plan'), + ).toBeInTheDocument(); + }); + + it('stays silent while no benefits plan has been chosen', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(/must have the Select plan/)).not.toBeInTheDocument(); + }); + + it('shows the exempt plan warning as an error, since the rule has no exception', () => { + const { getByText } = render( + , + ); + + expect( + getByText('John is exempt, so they must have the Select plan').closest( + '.MuiAlert-root', + ), + ).toHaveClass('MuiAlert-standardError'); + }); + + it('warns about only the cap that is exceeded', () => { + const { getByText, queryByText } = render( + , + ); + + expect( + getByText('Annual debt is over the standard cap'), + ).toBeInTheDocument(); + expect( + queryByText('Total salary is over the standard cap'), + ).not.toBeInTheDocument(); + }); + + it('colors each warning by its own exception', () => { + const { getByText } = render( + , + ); + + expect( + getByText('Total salary is over the standard cap').closest( + '.MuiAlert-root', + ), + ).toHaveClass('MuiAlert-standardWarning'); + expect( + getByText('Annual debt is over the standard cap').closest( + '.MuiAlert-root', + ), + ).toHaveClass('MuiAlert-standardError'); + }); + + it('warns when staff opted out of SECA', () => { + const { getByText } = render( + , + ); + + expect(getByText('Staff opted out of SECA')).toBeInTheDocument(); + }); + + it('warns when the spouse opted out of SECA', () => { + const { getByText } = render( + , + ); + + expect(getByText('Spouse opted out of SECA')).toBeInTheDocument(); + }); + + it('warns when both opted out of SECA', () => { + const { getByText } = render( + , + ); + + expect(getByText('Both opted out of SECA')).toBeInTheDocument(); + }); + + it('warns when staff is healthcare exempt', () => { + const { getByText } = render( + , + ); + + expect(getByText('Staff is healthcare exempt')).toBeInTheDocument(); + }); + + it('warns when the spouse is healthcare exempt', () => { + const { getByText } = render( + , + ); + + expect(getByText('Spouse is healthcare exempt')).toBeInTheDocument(); + }); + + it('warns when both are healthcare exempt', () => { + const { getByText } = render( + , + ); + + expect(getByText('Both are healthcare exempt')).toBeInTheDocument(); + }); + + it('shows exemption warnings in the warning color, never as errors', () => { + const { getByText } = render( + , + ); + + expect( + getByText('Staff opted out of SECA').closest('.MuiAlert-root'), + ).toHaveClass('MuiAlert-standardWarning'); + expect( + getByText('Staff is healthcare exempt').closest('.MuiAlert-root'), + ).toHaveClass('MuiAlert-standardWarning'); + }); + + it('ignores a stale spouse SECA exemption once the applicant is single', () => { + const { getByText, queryByText } = render( + , + ); + + expect(getByText('Staff opted out of SECA')).toBeInTheDocument(); + expect(queryByText('Both opted out of SECA')).not.toBeInTheDocument(); + }); + it('announces warnings politely, from a region that exists while empty', () => { + const { container, rerender } = render(); + + const region = container.querySelector('[aria-live="polite"]'); + expect(region).toBeInTheDocument(); + expect(region).toBeEmptyDOMElement(); + + rerender(); + + expect(region).toHaveTextContent('Total salary is over the standard cap'); + }); +}); diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.tsx new file mode 100644 index 0000000000..7bc7a9624f --- /dev/null +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsWarning.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Alert, Stack } from '@mui/material'; +import { useGoalSettingsPreview } from './GoalSettingsPreviewContext'; +import { WarningSeverity } from './goalSettingsWarnings'; + +/** Errors before cautions, so the more urgent group reads first. */ +const SEVERITIES: readonly WarningSeverity[] = ['error', 'warning']; + +/** + * The non-blocking admin warnings from NSGC A33, rendered beside the save + * actions so an admin sees them at the point of Save & Share regardless of how + * far the long form has scrolled. Built by the preview provider, which also + * drives the matching field outlines, so the two always agree. + * + * Grouped into one alert per severity rather than a row per warning: up to five + * can apply at once, and five separately coloured rows read as noise. The two + * alerts sit side by side so the sticky bar stays short. + */ +export const GoalSettingsWarning: React.FC = () => { + const warnings = useGoalSettingsPreview()?.warnings ?? []; + + return ( + + {SEVERITIES.map((severity) => { + const grouped = warnings.filter( + (warning) => warning.severity === severity, + ); + + return grouped.length ? ( + +
    + {grouped.map(({ key, message }) => ( +
  • {message}
  • + ))} +
+
+ ) : null; + })} +
+ ); +}; diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx index 6e68447cc0..f9c202e446 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx @@ -9,6 +9,7 @@ import * as yup from 'yup'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import theme from 'src/theme'; import { defaultGoalCalculation } from '../NsGoalCalculatorTestWrapper'; +import { GoalSettingsPreviewProvider } from './GoalSettingsPreviewContext'; import { MpdGoalPreview } from './MpdGoalPreview'; import { PreviewNewStaffGoalCalculationMutation } from './NewStaffGoalCalculation.generated'; import { calculationToFormValues } from './goalSettingsApiMapping'; @@ -35,7 +36,7 @@ const previewGoalMock = ( previewNewStaffGoalCalculation: { newStaffGoalCalculation: { id: calculationId, - calculations: { monthlyGoal }, + calculations: { monthlyGoal, salaryOverCap: false, debtOverCap: false }, }, }, }, @@ -67,20 +68,23 @@ const TestComponent: React.FC = ({ validationSchema={validationSchema} onSubmit={jest.fn()} > -
- {/* Two fields so the coalescing test can edit across both. */} - - - - + +
+ {/* Two fields so the coalescing test can edit across both. */} + + + + +
diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.tsx index 3622ad1cb0..3fd7cd6508 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.tsx @@ -2,36 +2,39 @@ import React from 'react'; import { CircularProgress, Stack, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useFormatters } from '../../Shared/useFormatters'; -import { useMpdGoalPreview } from './useMpdGoalPreview'; +import { useGoalSettingsPreview } from './GoalSettingsPreviewContext'; interface MpdGoalPreviewProps { - /** - * Account list the goal belongs to, or `null` for a scenario goal that has no - * account list. - */ - accountListId: string | null; - calculationId: string; /** The saved goal total; shown when there are no goal-affecting edits. */ savedMonthlyGoal: number; } +/** Diffs smaller than half a cent are treated as no change. */ +const CENT_EPSILON = 0.005; + /** * MPD Goal figure in the header. While the form has unsaved, valid edits it - * shows the recomputed goal total plus the signed difference from the saved - * goal (e.g. `+$200.00` / `-$75.00`) so coaches and admins can see the impact - * of a change before saving. All the preview orchestration lives in - * {@link useMpdGoalPreview}; this component only renders its result. + * shows the recomputed total plus the signed difference from the saved goal + * (e.g. `+$200.00`), so admins see the impact before saving. The provider does + * the orchestration; this only renders the result. */ export const MpdGoalPreview: React.FC = ({ - accountListId, - calculationId, savedMonthlyGoal, }) => { const { t } = useTranslation(); const { formatCurrency } = useFormatters(); - const { calculating, displayGoal, diff, changed, failed } = useMpdGoalPreview( - { accountListId, calculationId, savedMonthlyGoal }, - ); + const { + calculating, + failed, + previewGoal = null, + } = useGoalSettingsPreview() ?? {}; + + const displayGoal = previewGoal ?? savedMonthlyGoal; + const diff = + previewGoal === null + ? 0 + : Math.round((previewGoal - savedMonthlyGoal) * 100) / 100; + const changed = Math.abs(diff) >= CENT_EPSILON; const diffLabel = (diff > 0 ? '+' : '-') + formatCurrency(Math.abs(diff)); diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsWarnings.ts b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsWarnings.ts new file mode 100644 index 0000000000..381dd9cfbc --- /dev/null +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsWarnings.ts @@ -0,0 +1,179 @@ +import { TFunction } from 'i18next'; +import { + MpdGoalBenefitsConstantPlanEnum, + NewStaffGoalCalculationSalaryOverCapEnum, + NewStaffQuestionnaireMaritalStatusEnum, +} from 'src/graphql/types.generated'; +import { GoalSettingsFormValues } from './goalSettingsFormValues'; + +export type WarningSeverity = 'warning' | 'error'; + +export interface GoalSettingsWarningItem { + key: string; + severity: WarningSeverity; + message: string; + /** Fields that feed this warning; they take a matching outline while active. */ + fields: readonly string[]; +} + +const SALARY_CAP_FIELDS: readonly (keyof GoalSettingsFormValues)[] = [ + 'annualRequestedSalary', + 'spouseRequestedAnnualSalary', + 'contribution403bPercentage', + 'spouseContribution403bPercentage', + // Sets the cost-of-living multiplier, and so the cap itself. + 'geographicLocation', +]; + +interface BuildWarningsArgs { + values: GoalSettingsFormValues; + salaryOverCap: boolean; + debtOverCap: boolean; + t: TFunction; +} + +type ExemptParty = 'both' | 'staff' | 'spouse'; + +type PartyFields = Record< + ExemptParty, + readonly (keyof GoalSettingsFormValues)[] +>; + +const SECA_FIELDS: PartyFields = { + both: ['secaExempt', 'spouseSecaExempt'], + staff: ['secaExempt'], + spouse: ['spouseSecaExempt'], +}; + +const HEALTHCARE_FIELDS: PartyFields = { + both: ['healthcareExempt', 'spouseHealthcareExempt'], + staff: ['healthcareExempt'], + spouse: ['spouseHealthcareExempt'], +}; + +/** + * Who an exemption covers, or `null` for nobody. + */ +const exemptParty = (staff: boolean, spouse: boolean): ExemptParty | null => { + if (staff && spouse) { + return 'both'; + } + if (staff) { + return 'staff'; + } + return spouse ? 'spouse' : null; +}; + +/** + * Builds the active Goal Settings warnings (NSGC A33 and DA5). + */ +export const buildGoalSettingsWarnings = ({ + values, + salaryOverCap, + debtOverCap, + t, +}: BuildWarningsArgs): GoalSettingsWarningItem[] => { + const salaryOverCapAllowed = + values.allowSalaryOverCap !== '' && + values.allowSalaryOverCap !== NewStaffGoalCalculationSalaryOverCapEnum.No; + + // Spouse flags only count while there is a spouse; the form can hold a stale + // one after a marital status change. + const hasSpouse = + values.maritalStatus === NewStaffQuestionnaireMaritalStatusEnum.Married; + const secaParty = exemptParty( + values.secaExempt === 'true', + hasSpouse && values.spouseSecaExempt === 'true', + ); + const healthcareParty = exemptParty( + values.healthcareExempt === 'true', + hasSpouse && values.spouseHealthcareExempt === 'true', + ); + + const secaMessages = { + both: t('Both opted out of SECA'), + staff: t('Staff opted out of SECA'), + spouse: t('Spouse opted out of SECA'), + }; + const healthcareMessages = { + both: t('Both are healthcare exempt'), + staff: t('Staff is healthcare exempt'), + spouse: t('Spouse is healthcare exempt'), + }; + + // DA5 fires on exempt="Both", or exempt="One" with a Single or SOSA status — + // both of which mean everyone on the plan is exempt. + const everyoneExempt = + healthcareParty === 'both' || (!hasSpouse && healthcareParty === 'staff'); + // Discrepancy with the sheet: DA5's plan list has no "Exempt" option, so it + // never defined this case. Following it literally makes Exempt a violating + // plan. Revisit with whoever owns the plan list if that reading is wrong. + const exemptPlanMismatch = + everyoneExempt && + values.benefitsPlan !== '' && + values.benefitsPlan !== MpdGoalBenefitsConstantPlanEnum.Select; + + // Only reachable via `everyoneExempt`, which is either both spouses or a lone applicant + const staffName = values.firstName || t('Staff'); + const spouseName = values.spouseFirstName || t('Spouse'); + const exemptPlanMessage = + healthcareParty === 'both' + ? t( + '{{staffName}} and {{spouseName}} are exempt, so they must have the Select plan', + { staffName, spouseName }, + ) + : t('{{staffName}} is exempt, so they must have the Select plan', { + staffName, + }); + + const candidates: (GoalSettingsWarningItem & { active: boolean })[] = [ + { + key: 'salaryOverCap', + active: salaryOverCap, + severity: salaryOverCapAllowed ? 'warning' : 'error', + message: t('Total salary is over the standard cap'), + fields: SALARY_CAP_FIELDS, + }, + { + key: 'debtOverCap', + active: debtOverCap, + severity: values.allowDebtOverCap === 'true' ? 'warning' : 'error', + message: t('Annual debt is over the standard cap'), + fields: [], + }, + { + key: 'exemptPlan', + active: exemptPlanMismatch, + severity: 'error', + message: exemptPlanMessage, + fields: [], + }, + { + key: 'secaExempt', + active: secaParty !== null, + severity: 'warning', + message: secaParty ? secaMessages[secaParty] : '', + fields: secaParty ? SECA_FIELDS[secaParty] : [], + }, + { + key: 'healthcareExempt', + active: healthcareParty !== null, + severity: 'warning', + message: healthcareParty ? healthcareMessages[healthcareParty] : '', + fields: healthcareParty ? HEALTHCARE_FIELDS[healthcareParty] : [], + }, + ]; + + return candidates.filter(({ active }) => active); +}; + +export const getFieldSeverity = ( + warnings: GoalSettingsWarningItem[], + name: string, +): WarningSeverity | undefined => { + const severities = warnings + .filter(({ fields }) => fields.includes(name)) + .map(({ severity }) => severity); + + return severities.find((severity) => severity === 'error') ?? severities[0]; +}; diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts b/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts index 0faf2d0a42..b93f0a7b68 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts @@ -9,49 +9,50 @@ import { GoalSettingsFormValues } from './goalSettingsFormValues'; /** Coalesce a burst of edits (e.g. picking through selects) into one request. */ export const PREVIEW_DEBOUNCE_MS = 500; -/** Diffs smaller than half a cent are treated as no change. */ -const CENT_EPSILON = 0.005; - -/** The previewed goal, tagged with the attributes it was computed for. */ +/** + * A preview tagged with the attributes it was computed for. Every value is + * `null` when the request failed. + */ interface PreviewState { attributes: NewStaffGoalCalculationAttributesInput; - /** Computed goal, or `null` if the preview request failed. */ monthlyGoal: number | null; + salaryOverCap: boolean | null; + debtOverCap: boolean | null; } interface UseMpdGoalPreviewArgs { - /** Account list the goal belongs to, or `null` for a scenario goal. */ + /** `null` for a scenario goal, which has no account list. */ accountListId: string | null; calculationId: string; - savedMonthlyGoal: number; } interface UseMpdGoalPreviewResult { /** True while a debounced edit's preview request is in flight. */ calculating: boolean; - /** Goal to display: the previewed total when settled, else the saved goal. */ - displayGoal: number; - /** Signed difference from the saved goal, rounded to cents (0 when unchanged). */ - diff: number; - /** Whether the preview differs from the saved goal by at least a cent. */ - changed: boolean; - /** True when the settled preview request failed (and fell back to the saved goal). */ + /** True when the settled preview request failed. */ failed: boolean; + /** `null` when the form is clean or invalid, or the request is pending or failed. */ + previewGoal: number | null; + /** + * Unlike the goal, these are held across an in-flight or invalid edit, so a + * warning doesn't blink off and re-announce itself on every keystroke. + */ + previewSalaryOverCap: boolean | null; + previewDebtOverCap: boolean | null; } /** - * Orchestrates the goal preview for {@link MpdGoalPreview}: watches the Goal - * Settings form and, whenever the unsaved, valid form values change, debounces - * a call to the API to recompute the goal (`previewNewStaffGoalCalculation`), - * exposing the new total plus the signed difference from the saved goal. + * Recomputes the goal whenever the form has unsaved, valid edits, debounced so + * it previews settled values rather than every keystroke. Consumers read the + * result through the provider and fall back to their own saved value when no + * preview applies. * - * Previewing keys off the Formik values directly rather than field blur, since - * some inputs (selects) don't produce a usable blur. + * Keys off the Formik values rather than field blur, since some inputs + * (selects) don't produce a usable blur. */ export const useMpdGoalPreview = ({ accountListId, calculationId, - savedMonthlyGoal, }: UseMpdGoalPreviewArgs): UseMpdGoalPreviewResult => { const { values, dirty, isValid } = useFormikContext(); @@ -75,6 +76,12 @@ export const useMpdGoalPreview = ({ // Debounced so we preview the settled values, not every keystroke. const debounced = useDebouncedValue(previewable, PREVIEW_DEBOUNCE_MS); + useEffect(() => { + if (!dirty) { + setPreview(null); + } + }, [dirty]); + useEffect(() => { if (debounced === null) { return; @@ -94,19 +101,30 @@ export const useMpdGoalPreview = ({ }) .then(({ data }) => { if (active) { + const { + monthlyGoal = null, + salaryOverCap = null, + debtOverCap = null, + } = data?.previewNewStaffGoalCalculation?.newStaffGoalCalculation + ?.calculations ?? {}; setPreview({ attributes: debounced, - monthlyGoal: - data?.previewNewStaffGoalCalculation?.newStaffGoalCalculation - ?.calculations?.monthlyGoal ?? null, + monthlyGoal, + salaryOverCap, + debtOverCap, }); } }) .catch(() => { // Record the failed attributes so the spinner stops and we fall back to - // the saved goal. + // the saved goal and warnings. if (active) { - setPreview({ attributes: debounced, monthlyGoal: null }); + setPreview({ + attributes: debounced, + monthlyGoal: null, + salaryOverCap: null, + debtOverCap: null, + }); } }); @@ -125,12 +143,14 @@ export const useMpdGoalPreview = ({ const failed = settledPreview !== null && settledPreview.monthlyGoal === null; const previewGoal = settledPreview?.monthlyGoal ?? null; - const displayGoal = previewGoal ?? savedMonthlyGoal; - const diff = - previewGoal === null - ? 0 - : Math.round((previewGoal - savedMonthlyGoal) * 100) / 100; - const changed = Math.abs(diff) >= CENT_EPSILON; + const previewSalaryOverCap = dirty ? (preview?.salaryOverCap ?? null) : null; + const previewDebtOverCap = dirty ? (preview?.debtOverCap ?? null) : null; - return { calculating, displayGoal, diff, changed, failed }; + return { + calculating, + failed, + previewGoal, + previewSalaryOverCap, + previewDebtOverCap, + }; }; From bb84087ac62b69f5ee70bd7d19f2a0c0b074ff05 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:28:19 -0500 Subject: [PATCH 4/4] Outline the fields behind a warning --- .../Fields/useGoalSettingsField.test.tsx | 190 +++++++++++++++--- .../Fields/useGoalSettingsField.ts | 22 ++ 2 files changed, 182 insertions(+), 30 deletions(-) diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.test.tsx index 561ea88888..03400de1a6 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.test.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.test.tsx @@ -1,19 +1,61 @@ import React from 'react'; import { ThemeProvider } from '@mui/material'; import { render } from '@testing-library/react'; -import { Form, Formik } from 'formik'; +import { Form, Formik, FormikErrors, FormikTouched } from 'formik'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { NewStaffGoalCalculationSalaryOverCapEnum } from 'src/graphql/types.generated'; import theme from 'src/theme'; +import { defaultGoalCalculation } from '../../NsGoalCalculatorTestWrapper'; +import { GoalSettingsPreviewProvider } from '../GoalSettingsPreviewContext'; +import { calculationToFormValues } from '../goalSettingsApiMapping'; +import { GoalSettingsFormValues } from '../goalSettingsFormValues'; import { GoalSettingsTextField } from './GoalSettingsTextField'; import { GoalSettingsFieldBaseProps } from './useGoalSettingsField'; +interface TestComponentProps extends GoalSettingsFieldBaseProps { + initialErrors?: FormikErrors; + initialTouched?: FormikTouched; + savedSalaryOverCap?: boolean; + allowSalaryOverCap?: NewStaffGoalCalculationSalaryOverCapEnum; + secaExempt?: GoalSettingsFormValues['secaExempt']; + spouseSecaExempt?: GoalSettingsFormValues['spouseSecaExempt']; +} + // Exercises the hook through a real consumer (GoalSettingsTextField). -const TestComponent: React.FC = (props) => ( +const TestComponent: React.FC = ({ + initialErrors, + initialTouched, + savedSalaryOverCap = false, + allowSalaryOverCap = NewStaffGoalCalculationSalaryOverCapEnum.No, + secaExempt = 'false', + spouseSecaExempt = 'false', + ...fieldProps +}) => ( - -
- - -
+ + + +
+ + +
+
+
); @@ -65,18 +107,12 @@ describe('useGoalSettingsField', () => { it('surfaces the Formik error as helperText once the field is touched', () => { const { getByRole, getByText } = render( - - -
- - -
-
, + , ); expect(getByRole('textbox', { name: 'Age' })).toBeInvalid(); @@ -85,20 +121,114 @@ describe('useGoalSettingsField', () => { it('hides the error until the field is touched', () => { const { getByRole, queryByText } = render( - - -
- - -
-
, + , ); expect(getByRole('textbox', { name: 'Age' })).toBeValid(); expect(queryByText('Age is required')).not.toBeInTheDocument(); }); + + it('outlines a contributing field in the warning color when allowed', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('.MuiOutlinedInput-notchedOutline'), + ).toHaveStyle({ borderColor: theme.palette.warning.main }); + }); + + it('outlines a contributing field in the error color when not allowed', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('.MuiOutlinedInput-notchedOutline'), + ).toHaveStyle({ borderColor: theme.palette.error.main }); + }); + + it('leaves fields that do not feed the warning alone', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('.MuiOutlinedInput-notchedOutline'), + ).not.toHaveStyle({ borderColor: theme.palette.error.main }); + }); + + it('does not outline anything while the cap is not exceeded', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('.MuiOutlinedInput-notchedOutline'), + ).not.toHaveStyle({ borderColor: theme.palette.error.main }); + }); + + it('lets a validation error take over the field', () => { + const { getByRole, getByText } = render( + , + ); + + expect(getByRole('textbox', { name: 'Salary' })).toBeInvalid(); + expect(getByText('Salary is required')).toBeInTheDocument(); + }); + + it('outlines the exemption field the warning came from', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('.MuiOutlinedInput-notchedOutline'), + ).toHaveStyle({ borderColor: theme.palette.warning.main }); + }); + + it('leaves the spouse exemption field alone when only staff opted out', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('.MuiOutlinedInput-notchedOutline'), + ).not.toHaveStyle({ borderColor: theme.palette.warning.main }); + }); + + it('does not mark a highlighted field invalid to assistive tech', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('textbox', { name: 'Salary' })).toBeValid(); + }); }); diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.ts b/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.ts index 50c09fabf4..5db4caf9c4 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.ts +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/Fields/useGoalSettingsField.ts @@ -1,6 +1,8 @@ import { TextFieldProps } from '@mui/material'; import { useField } from 'formik'; import { NewStaffGoalCalculationAttributesInput } from 'src/graphql/types.generated'; +import { useGoalSettingsPreview } from '../GoalSettingsPreviewContext'; +import { WarningSeverity } from '../goalSettingsWarnings'; export type GoalSettingsFieldBaseProps = Omit & { /** Formik field key. Required here even though MUI's `name` is optional. */ @@ -17,6 +19,19 @@ export type GoalSettingsFieldBaseProps = Omit & { showLabel?: boolean; }; +const outlineSx = (severity: WarningSeverity) => ({ + '.MuiOutlinedInput-notchedOutline': { + borderColor: `${severity}.main`, + }, + // Needs the ampersand: :hover applies to the root, not a descendant. + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: `${severity}.dark`, + }, + '.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: `${severity}.main`, + }, +}); + /** * Shared wiring for every Goal Settings field: Formik binding, consistent MUI * chrome defaults, and accessible-name construction. Returns merged @@ -38,6 +53,7 @@ export const useGoalSettingsField = ({ ...props }: GoalSettingsFieldBaseProps): TextFieldProps => { const [field, meta] = useField(name); + const preview = useGoalSettingsPreview(); const accessibleName = typeof label === 'string' @@ -47,6 +63,8 @@ export const useGoalSettingsField = ({ : undefined; const showError = Boolean(meta.touched && meta.error); + // A validation error is actionable, so it wins over the advisory outline. + const severity = showError ? undefined : preview?.fieldSeverity(name); return { id: name, @@ -58,6 +76,10 @@ export const useGoalSettingsField = ({ ...field, error: showError, helperText: showError ? meta.error : undefined, + sx: { + ...(severity ? outlineSx(severity) : {}), + ...props.sx, + }, inputProps: { ...(!showLabel && accessibleName ? { 'aria-label': accessibleName } : {}), ...inputProps,