diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.test.tsx new file mode 100644 index 000000000..263502b15 --- /dev/null +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Formik } from 'formik'; +import { DeepPartial } from 'ts-essentials'; +import { + NsGoalCalculatorTestWrapper, + defaultGoalCalculation, +} from '../NsGoalCalculatorTestWrapper'; +import { GoalSettingsPreviewProvider } from './GoalSettingsPreviewContext'; +import { PreviewNewStaffGoalCalculationMutation } from './NewStaffGoalCalculation.generated'; +import { FinancialInformationSection } from './Sections/FinancialInformationSection'; +import { NsoInformationSection } from './Sections/NsoInformationSection'; +import { calculationToFormValues } from './goalSettingsApiMapping'; +import { GoalSettingsSectionProps } from './goalSettingsSectionProps'; + +const accountListId = 'account-list-1'; +const mutationSpy = jest.fn(); + +const savedCalculation = { + ...defaultGoalCalculation, + calculations: { + ...defaultGoalCalculation.calculations, + contributing403bAmount: 150, + spouseContributing403bAmount: 200, + specialNeedsLeft: 900, + }, +}; + +const sectionProps: GoalSettingsSectionProps = { + hasSpouse: true, + seniorStaff: false, + calculations: { + ...savedCalculation.calculations, + contributing403bAmount: 1, + spouseContributing403bAmount: 2, + specialNeedsLeft: 3, + }, + primaryName: 'John', + spouseName: 'Jane', + visibleHeaders: ['John (Joining)', 'Jane (Joining)'], + sharedHeader: 'John (Joining) & Jane (Joining)', +}; + +const previewOf = (calculations: { + contributing403bAmount?: number; + spouseContributing403bAmount?: number; + specialNeedsLeft?: number; +}): DeepPartial => ({ + previewNewStaffGoalCalculation: { + newStaffGoalCalculation: { + id: savedCalculation.id, + calculations, + }, + }, +}); + +const preview403b = previewOf({ + contributing403bAmount: 175, + spouseContributing403bAmount: 210, +}); + +const TestComponent: React.FC<{ + previewMock?: DeepPartial; +}> = ({ previewMock }) => ( + + + + + + + + +); + +describe('GoalSettingsPreviewContext', () => { + it('shows the saved worksheet figures while the form is untouched', async () => { + const { findByText, getByText } = render(); + + const johnAmount = (await findByText('403(b) Amount — John')).parentElement; + expect(johnAmount).toHaveTextContent('$150.00'); + expect(johnAmount).toHaveAttribute('aria-busy', 'false'); + expect(getByText('403(b) Amount — Jane').parentElement).toHaveTextContent( + '$200.00', + ); + expect(getByText('$900.00')).toBeInTheDocument(); + + expect(mutationSpy).not.toHaveGraphqlOperation( + 'PreviewNewStaffGoalCalculation', + ); + }); + + it('substitutes the previewed 403(b) amounts after an unsaved edit', async () => { + const { findByText, getByText, getByRole } = render( + , + ); + + const percentage = getByRole('spinbutton', { + name: '403(b) Contribution — John', + }); + userEvent.clear(percentage); + userEvent.type(percentage, '10'); + expect(await findByText('$175.00')).toBeInTheDocument(); + expect(getByText('403(b) Amount — John').parentElement).toHaveTextContent( + '$175.00', + ); + expect(getByText('403(b) Amount — Jane').parentElement).toHaveTextContent( + '$210.00', + ); + expect(mutationSpy).toHaveGraphqlOperation( + 'PreviewNewStaffGoalCalculation', + { + input: { + accountListId, + id: savedCalculation.id, + attributes: { contribution403bPercentage: 10 }, + }, + }, + ); + }); + + it('substitutes the previewed special-needs remainder after an unsaved edit', async () => { + const { findByText, getByRole } = render( + , + ); + + const supportRaised = getByRole('spinbutton', { + name: 'Support Raised for NSO', + }); + userEvent.clear(supportRaised); + userEvent.type(supportRaised, '50'); + + expect(await findByText('$450.00')).toBeInTheDocument(); + expect(mutationSpy).toHaveGraphqlOperation( + 'PreviewNewStaffGoalCalculation', + { + input: { + accountListId, + id: savedCalculation.id, + attributes: { nsoSpecialNeedsSupportReceived: 50 }, + }, + }, + ); + }); + + it('holds the previewed amount while a further edit is still in flight', async () => { + const { findByText, getByText, queryByText, getByRole } = render( + , + ); + + const percentage = getByRole('spinbutton', { + name: '403(b) Contribution — John', + }); + userEvent.clear(percentage); + userEvent.type(percentage, '10'); + expect(await findByText('$175.00')).toBeInTheDocument(); + + userEvent.clear(percentage); + userEvent.type(percentage, '12'); + + const johnAmount = getByText('403(b) Amount — John').parentElement; + expect(johnAmount).toHaveTextContent('$175.00'); + expect(johnAmount).toHaveAttribute('aria-busy', 'true'); + expect(queryByText('$150.00')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx index accfe30c4..610c1cc34 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/GoalSettingsPreviewContext.tsx @@ -11,10 +11,16 @@ import { import { useMpdGoalPreview } from './useMpdGoalPreview'; import { NewStaffGoalCalculation } from './useNewStaffGoalCalculation'; +type PreviewCalculations = Pick< + NewStaffGoalCalculation['calculations'], + 'contributing403bAmount' | 'spouseContributing403bAmount' | 'specialNeedsLeft' +>; + interface GoalSettingsPreviewValue { calculating: boolean; failed: boolean; previewGoal: number | null; + previewCalculations: PreviewCalculations; warnings: GoalSettingsWarningItem[]; fieldSeverity: (name: string) => WarningSeverity | undefined; } @@ -42,24 +48,33 @@ export const GoalSettingsPreviewProvider: React.FC< const { t } = useTranslation(); const { values } = useFormikContext(); - const { - id: calculationId, - calculations: { - salaryOverCap: savedSalaryOverCap, - debtOverCap: savedDebtOverCap, - }, - } = calculation; + const { id: calculationId, calculations: savedCalculations } = calculation; const { calculating, failed, previewGoal, + previewLineItems, previewSalaryOverCap, previewDebtOverCap, } = useMpdGoalPreview({ accountListId, calculationId }); - const salaryOverCap = previewSalaryOverCap ?? savedSalaryOverCap; - const debtOverCap = previewDebtOverCap ?? savedDebtOverCap; + const previewCalculations = useMemo( + () => ({ + contributing403bAmount: + previewLineItems?.contributing403bAmount ?? + savedCalculations.contributing403bAmount, + spouseContributing403bAmount: + previewLineItems?.spouseContributing403bAmount ?? + savedCalculations.spouseContributing403bAmount, + specialNeedsLeft: + previewLineItems?.specialNeedsLeft ?? + savedCalculations.specialNeedsLeft, + }), + [savedCalculations, previewLineItems], + ); + const salaryOverCap = previewSalaryOverCap ?? savedCalculations.salaryOverCap; + const debtOverCap = previewDebtOverCap ?? savedCalculations.debtOverCap; const value = useMemo(() => { const warnings = buildGoalSettingsWarnings({ @@ -73,10 +88,20 @@ export const GoalSettingsPreviewProvider: React.FC< calculating, failed, previewGoal, + previewCalculations, warnings, fieldSeverity: (name) => getFieldSeverity(warnings, name), }; - }, [values, salaryOverCap, debtOverCap, t, calculating, failed, previewGoal]); + }, [ + values, + salaryOverCap, + debtOverCap, + t, + calculating, + failed, + previewGoal, + previewCalculations, + ]); return ( diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx index 0f6e888cf..de8adcfe8 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/MpdGoalPreview.test.tsx @@ -46,7 +46,17 @@ const previewGoalMock = ( previewNewStaffGoalCalculation: { newStaffGoalCalculation: { id: calculationId, - calculations: { monthlyGoal, salaryOverCap: false, debtOverCap: false }, + calculations: { + monthlyGoal, + contributing403bAmount: + defaultGoalCalculation.calculations.contributing403bAmount, + spouseContributing403bAmount: + defaultGoalCalculation.calculations.spouseContributing403bAmount, + specialNeedsLeft: + defaultGoalCalculation.calculations.specialNeedsLeft, + salaryOverCap: false, + debtOverCap: false, + }, }, }, }, diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql b/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql index 05db34767..4a18e5e82 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/NewStaffGoalCalculation.graphql @@ -137,6 +137,9 @@ mutation PreviewNewStaffGoalCalculation( newStaffGoalCalculation { id calculations { + contributing403bAmount + spouseContributing403bAmount + specialNeedsLeft monthlyGoal salaryOverCap debtOverCap diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/FinancialInformationSection.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/FinancialInformationSection.tsx index c8a224d8d..38a6526d6 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/FinancialInformationSection.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/FinancialInformationSection.tsx @@ -8,6 +8,7 @@ import { useFormatters } from '../../../Shared/useFormatters'; import { GoalSettingsNumberField } from '../Fields/GoalSettingsNumberField'; import { GoalSettingsSelect, SelectOption } from '../Fields/GoalSettingsSelect'; import { ColumnHeaderRow, FieldRow, Section } from '../GoalSettingsLayout'; +import { useGoalSettingsPreview } from '../GoalSettingsPreviewContext'; import { GoalSettingsFormValues } from '../goalSettingsFormValues'; import { GoalSettingsSectionProps } from '../goalSettingsSectionProps'; @@ -25,6 +26,13 @@ export const FinancialInformationSection: React.FC< const { t } = useTranslation(); const { formatCurrency } = useFormatters(); const seniorStaffOnly = t('Senior Staff Only'); + + const preview = useGoalSettingsPreview(); + const { contributing403bAmount, spouseContributing403bAmount } = + preview?.previewCalculations ?? calculations; + + const calculating = preview?.calculating ?? false; + const { values: { calculationsYear }, } = useFormikContext(); @@ -84,18 +92,26 @@ export const FinancialInformationSection: React.FC< label={t('403(b) Amount')} helperText={t('Calculated monthly amount')} > - + }> {t('403(b) Amount — {{name}}', { name: primaryName })} - {formatCurrency(calculations.contributing403bAmount)} + {formatCurrency(contributing403bAmount)} {hasSpouse && ( - + }> {t('403(b) Amount — {{name}}', { name: spouseName })} - {formatCurrency(calculations.spouseContributing403bAmount)} + {formatCurrency(spouseContributing403bAmount)} )} diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/NsoInformationSection.tsx b/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/NsoInformationSection.tsx index 497fd9d92..49c7ebbee 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/NsoInformationSection.tsx +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/Sections/NsoInformationSection.tsx @@ -12,6 +12,7 @@ import { GoalSettingsNumberField } from '../Fields/GoalSettingsNumberField'; import { GoalSettingsPlaceholder } from '../Fields/GoalSettingsPlaceholder'; import { GoalSettingsSelect, SelectOption } from '../Fields/GoalSettingsSelect'; import { ColumnHeaderRow, FieldRow, Section } from '../GoalSettingsLayout'; +import { useGoalSettingsPreview } from '../GoalSettingsPreviewContext'; import { GoalSettingsSectionProps } from '../goalSettingsSectionProps'; export const NsoInformationSection: React.FC = ({ @@ -21,6 +22,11 @@ export const NsoInformationSection: React.FC = ({ const { t } = useTranslation(); const { formatCurrency } = useFormatters(); + const preview = useGoalSettingsPreview(); + const { specialNeedsLeft } = preview?.previewCalculations ?? calculations; + + const calculating = preview?.calculating ?? false; + const nsoHousingOptions = useMemo( () => [ @@ -86,8 +92,12 @@ export const NsoInformationSection: React.FC = ({ - - {formatCurrency(calculations.specialNeedsLeft)} + + {formatCurrency(specialNeedsLeft)} diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsSchema.ts b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsSchema.ts index 3ca3ebf58..9d75ad2ca 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsSchema.ts +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsSchema.ts @@ -25,6 +25,12 @@ const optionalInteger = (label: string, t: TFunction) => const optionalPercentage = (label: string, t: TFunction) => percentage(label, t).nullable().transform(emptyToNull); +const optional403bPercentage = (label: string, t: TFunction) => + optionalPercentage(label, t).lessThan( + 100, + t('{{fieldName}} must be less than 100%', { fieldName: label }), + ); + export const getGoalSettingsSchema = (t: TFunction) => yup.object({ // Personal @@ -37,8 +43,11 @@ export const getGoalSettingsSchema = (t: TFunction) => t('Annual Requested Salary'), t, ), - contribution403bPercentage: optionalPercentage(t('403(b) Contribution'), t), - spouseContribution403bPercentage: optionalPercentage( + contribution403bPercentage: optional403bPercentage( + t('403(b) Contribution'), + t, + ), + spouseContribution403bPercentage: optional403bPercentage( t('403(b) Contribution'), t, ), diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts b/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts index b93f0a7b6..84d2da2a5 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/useMpdGoalPreview.ts @@ -9,12 +9,23 @@ import { GoalSettingsFormValues } from './goalSettingsFormValues'; /** Coalesce a burst of edits (e.g. picking through selects) into one request. */ export const PREVIEW_DEBOUNCE_MS = 500; +/** + * Worksheet lines the form displays in their own right, rather than only as + * part of the goal total. `null` when the request failed. + */ +interface PreviewLineItems { + contributing403bAmount: number | null; + spouseContributing403bAmount: number | null; + specialNeedsLeft: number | null; +} + /** * A preview tagged with the attributes it was computed for. Every value is * `null` when the request failed. */ interface PreviewState { attributes: NewStaffGoalCalculationAttributesInput; + previewLineItems: PreviewLineItems; monthlyGoal: number | null; salaryOverCap: boolean | null; debtOverCap: boolean | null; @@ -35,8 +46,10 @@ interface UseMpdGoalPreviewResult { 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. + * warning doesn't blink off and re-announce itself on every keystroke, and a + * figure doesn't fall back to its saved value between keystrokes. */ + previewLineItems: PreviewLineItems | null; previewSalaryOverCap: boolean | null; previewDebtOverCap: boolean | null; } @@ -105,22 +118,33 @@ export const useMpdGoalPreview = ({ monthlyGoal = null, salaryOverCap = null, debtOverCap = null, + contributing403bAmount = null, + spouseContributing403bAmount = null, + specialNeedsLeft = null, } = data?.previewNewStaffGoalCalculation?.newStaffGoalCalculation ?.calculations ?? {}; setPreview({ attributes: debounced, monthlyGoal, + previewLineItems: { + contributing403bAmount, + spouseContributing403bAmount, + specialNeedsLeft, + }, salaryOverCap, debtOverCap, }); } }) .catch(() => { - // Record the failed attributes so the spinner stops and we fall back to - // the saved goal and warnings. if (active) { setPreview({ attributes: debounced, + previewLineItems: { + contributing403bAmount: null, + spouseContributing403bAmount: null, + specialNeedsLeft: null, + }, monthlyGoal: null, salaryOverCap: null, debtOverCap: null, @@ -143,6 +167,7 @@ export const useMpdGoalPreview = ({ const failed = settledPreview !== null && settledPreview.monthlyGoal === null; const previewGoal = settledPreview?.monthlyGoal ?? null; + const previewLineItems = dirty ? (preview?.previewLineItems ?? null) : null; const previewSalaryOverCap = dirty ? (preview?.salaryOverCap ?? null) : null; const previewDebtOverCap = dirty ? (preview?.debtOverCap ?? null) : null; @@ -150,6 +175,7 @@ export const useMpdGoalPreview = ({ calculating, failed, previewGoal, + previewLineItems, previewSalaryOverCap, previewDebtOverCap, }; diff --git a/src/components/HrTools/NsGoalCalculator/NsGoalCalculatorTestWrapper.tsx b/src/components/HrTools/NsGoalCalculator/NsGoalCalculatorTestWrapper.tsx index e2169ada9..8fa0498cf 100644 --- a/src/components/HrTools/NsGoalCalculator/NsGoalCalculatorTestWrapper.tsx +++ b/src/components/HrTools/NsGoalCalculator/NsGoalCalculatorTestWrapper.tsx @@ -16,6 +16,7 @@ import { NewStaffGoalCalculationDocument, NewStaffGoalCalculationQuery, NewStaffGoalCalculationQueryVariables, + PreviewNewStaffGoalCalculationMutation, } from './GoalSettings/NewStaffGoalCalculation.generated'; import { NsGoalCalculatorProvider } from './Shared/NsGoalCalculatorContext'; @@ -54,6 +55,7 @@ export interface NsGoalCalculatorTestWrapperProps { goalCalculationMock?: | DeepPartial | ApolloErgonoMockMap; + previewMock?: DeepPartial; onCall?: ErgonoMockedProviderProps['onCall']; router?: Partial; } @@ -63,6 +65,7 @@ export const NsGoalCalculatorTestWrapper: React.FC< > = ({ children, goalCalculationMock = defaultGoalCalculationMock, + previewMock, onCall, router = { query: { accountListId } }, }) => ( @@ -72,10 +75,12 @@ export const NsGoalCalculatorTestWrapper: React.FC< mocks={{ GoalCalculatorConstants: { constant: constantsMock }, NewStaffGoalCalculation: goalCalculationMock, + PreviewNewStaffGoalCalculation: previewMock ?? {}, }} onCall={onCall} >