From 23f213840131395a9dd8c353b58e074eef06698e Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 15:01:44 -0400 Subject: [PATCH 01/23] Add formType to PdsGoalCalculationFields fragment --- .../PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql index 0ed9a14176..9787331b2b 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql @@ -1,5 +1,6 @@ fragment PdsGoalCalculationFields on DesignationSupportCalculation { id + formType name updatedAt averageHoursPerWeek From 30b419ccb02f6a22e4000b8e57b6ffdb34f4e84b Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 15:09:08 -0400 Subject: [PATCH 02/23] Zero out reimbursable and 403b in summary data when formType is Simple --- .../calculations/usePdsSummaryData.test.ts | 72 +++++++++++++++++++ .../calculations/usePdsSummaryData.ts | 8 ++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts index 04cbfbafd5..e0088b8c6c 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { gqlMock } from '__tests__/util/graphqlMocking'; import { + DesignationSupportFormType, DesignationSupportSalaryType, DesignationSupportStatus, MpdGoalMiscConstantCategoryEnum, @@ -97,6 +98,7 @@ const defaultCalculation = gqlMock( mocks: { salaryOrHourly: DesignationSupportSalaryType.Salaried, status: DesignationSupportStatus.FullTime, + formType: DesignationSupportFormType.Detailed, payRate: 60000, benefits: 1500, ministryCellPhone: 100, @@ -332,4 +334,74 @@ describe('usePdsSummaryData', () => { expect(data).toHaveProperty('geographicMultiplier'); }); }); + + describe('Simple form type', () => { + it('zeroes reimbursableTotal in otherConstants when formType is Simple', () => { + const calc = { + ...defaultCalculation, + formType: DesignationSupportFormType.Simple, + }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + expect(result.current?.otherConstants.reimbursableTotal).toBe(0); + }); + + it('zeroes fourOThreeBPercentage in otherConstants when formType is Simple', () => { + const calc = { + ...defaultCalculation, + formType: DesignationSupportFormType.Simple, + }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + expect(result.current?.otherConstants.fourOThreeBPercentage).toBe(0); + }); + + it('still computes reimbursableTotals (saved values are preserved)', () => { + const calc = { + ...defaultCalculation, + formType: DesignationSupportFormType.Simple, + }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + // The reimbursableTotals object is still computed (data isn't lost), + // but the otherConstants.reimbursableTotal that feeds the Other Expenses + // calculation is zeroed. + expect(result.current?.reimbursableTotals.total).toBeGreaterThan(0); + }); + + it('uses saved reimbursable + 403b values when formType is Detailed', () => { + const calc = { + ...defaultCalculation, + formType: DesignationSupportFormType.Detailed, + }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + expect(result.current?.otherConstants.reimbursableTotal).toBeGreaterThan( + 0, + ); + expect( + result.current?.otherConstants.fourOThreeBPercentage, + ).toBeCloseTo(0.08); + }); + + it('uses saved values when formType is null/undefined (legacy goals default to Detailed behavior)', () => { + const calc = { + ...defaultCalculation, + formType: null, + }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + expect(result.current?.otherConstants.reimbursableTotal).toBeGreaterThan( + 0, + ); + expect( + result.current?.otherConstants.fourOThreeBPercentage, + ).toBeCloseTo(0.08); + }); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts index 9717e072ef..9870a9c96c 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { PdsGoalCalculationFieldsFragment } from '../GoalsList/PdsGoalCalculations.generated'; import { HcmUserQuery } from '../Shared/HCM.generated'; @@ -73,10 +74,13 @@ export const usePdsSummaryData = ( const rothPct = (hcmUser?.fourOThreeB?.currentRothContributionPercentage ?? 0) / 100; + const isSimple = + calculation.formType === DesignationSupportFormType.Simple; + const otherConstants: OtherExpensesConstants = { - reimbursableTotal: reimbursableTotals.total, + reimbursableTotal: isSimple ? 0 : reimbursableTotals.total, salarySubtotal: salaryTotals.subtotal, - fourOThreeBPercentage: taxDeferredPct + rothPct, + fourOThreeBPercentage: isSimple ? 0 : taxDeferredPct + rothPct, grossMonthlyPay: salaryTotals.grossMonthlyPay, workCompPercentage, attritionRate, From 79ade1fe475059c97cf615fc4c299ed9e1bdffe4 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 15:28:45 -0400 Subject: [PATCH 03/23] Exclude reimbursable and 403b from PDS goal total when formType is Simple Adds `formType` to `PdsGoalTotalFields` and short-circuits reimbursable total and 403b percentage to zero inside `calculatePdsGoalTotal` when the form type is Simple. Also pins `formType: Detailed` in the shared test wrapper defaults so that `gqlMock`-generated enum values no longer cause flaky dollar-amount assertions across the PdsGoalCalculator suite. Co-Authored-By: Claude Sonnet 4.6 --- .../PdsGoalCalculatorTestWrapper.tsx | 3 +++ .../calculatePdsGoalTotal.test.ts | 22 +++++++++++++++++++ .../calculations/calculatePdsGoalTotal.ts | 12 +++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index c6ec7f7772..ed9f228020 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -8,6 +8,7 @@ import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider, gqlMock } from '__tests__/util/graphqlMocking'; import { GetUserQuery } from 'src/components/User/GetUser.generated'; import { + DesignationSupportFormType, DesignationSupportSalaryType, DesignationSupportStatus, MpdGoalMiscConstantCategoryEnum, @@ -41,6 +42,7 @@ const calculationsDefault = gqlMock< { id: 'goal-1', name: 'Test Goal', + formType: DesignationSupportFormType.Detailed, status: DesignationSupportStatus.FullTime, salaryOrHourly: DesignationSupportSalaryType.Salaried, payRate: 50000, @@ -73,6 +75,7 @@ const calculationDefault = gqlMock< designationSupportCalculation: { id: 'goal-1', name: 'Test Goal', + formType: DesignationSupportFormType.Detailed, status: DesignationSupportStatus.FullTime, salaryOrHourly: DesignationSupportSalaryType.Salaried, payRate: 50000, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts index 41f274b43f..affd7aa4fd 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts @@ -1,4 +1,5 @@ import { + DesignationSupportFormType, DesignationSupportSalaryType, DesignationSupportStatus, } from 'src/graphql/types.generated'; @@ -74,4 +75,25 @@ describe('calculatePdsGoalTotal', () => { const withoutGeo = calculatePdsGoalTotal(goal, defaultConstants); expect(withGeo).toBeGreaterThan(withoutGeo); }); + + it('excludes reimbursable expenses and 403b when formType is Simple', () => { + const detailed = makeGoal({ formType: DesignationSupportFormType.Detailed }); + const simple = makeGoal({ formType: DesignationSupportFormType.Simple }); + + const detailedTotal = calculatePdsGoalTotal(detailed, defaultConstants); + const simpleTotal = calculatePdsGoalTotal(simple, defaultConstants); + + // Simple skips reimbursable + 403b lines, so its assessment is strictly lower + // than Detailed when those values are non-zero. + expect(simpleTotal).toBeLessThan(detailedTotal); + expect(simpleTotal).toBeGreaterThan(0); + }); + + it('treats null formType the same as Detailed (legacy goals)', () => { + const legacy = makeGoal({ formType: null }); + const detailed = makeGoal({ formType: DesignationSupportFormType.Detailed }); + expect(calculatePdsGoalTotal(legacy, defaultConstants)).toBeCloseTo( + calculatePdsGoalTotal(detailed, defaultConstants), + ); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts index dd89b36732..7b992ca431 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts @@ -1,3 +1,4 @@ +import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { GoalGeographicConstantMap, GoalMiscConstants, @@ -15,7 +16,9 @@ import { export type PdsGoalTotalFields = SalaryCalculationFields & ReimbursableCalculationFields & - OtherExpensesFields; + OtherExpensesFields & { + formType?: DesignationSupportFormType | null; + }; export interface PdsGoalTotalConstants { employerFicaRate: number; @@ -76,6 +79,9 @@ export const calculatePdsGoalTotal = ( calculation: PdsGoalTotalFields, constants: PdsGoalTotalConstants, ): number => { + const isSimple = + calculation.formType === DesignationSupportFormType.Simple; + const salaryTotals = calculateSalaryTotals(calculation, { geographicMultiplier: constants.geographicMultiplier, employerFicaRate: constants.employerFicaRate, @@ -84,9 +90,9 @@ export const calculatePdsGoalTotal = ( const reimbursableTotals = calculateReimbursableTotals(calculation); const otherExpenses = calculateOtherExpenses(calculation, { - reimbursableTotal: reimbursableTotals.total, + reimbursableTotal: isSimple ? 0 : reimbursableTotals.total, salarySubtotal: salaryTotals.subtotal, - fourOThreeBPercentage: constants.fourOThreeBPercentage, + fourOThreeBPercentage: isSimple ? 0 : constants.fourOThreeBPercentage, grossMonthlyPay: salaryTotals.grossMonthlyPay, workCompPercentage: constants.workCompPercentage, attritionRate: constants.attritionRate, From b7f4c38470a70050dcbf27dcbd2a950b7d477dde Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 15:34:14 -0400 Subject: [PATCH 04/23] Filter Reimbursable Expenses step from useSteps when formType is Simple Co-Authored-By: Claude Sonnet 4.6 --- .../Shared/PdsGoalCalculatorContext.tsx | 2 +- .../Shared/useSteps.test.tsx | 39 +++++++++++++++++++ .../PdsGoalCalculator/Shared/useSteps.tsx | 23 +++++++---- 3 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index 3611a04148..7463fc5859 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -78,7 +78,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const summaryData = usePdsSummaryData(calculation, hcmUser); - const steps = useSteps(); + const steps = useSteps(calculation?.formType); const [stepIndex, setStepIndex] = useState(0); const [rightPanelContent, setRightPanelContent] = useState(null); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx new file mode 100644 index 0000000000..96a182237d --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; +import { PdsGoalCalculatorStepEnum } from '../PdsGoalCalculatorHelper'; +import { useSteps } from './useSteps'; + +describe('useSteps', () => { + it('returns four steps when formType is Detailed', () => { + const { result } = renderHook(() => + useSteps(DesignationSupportFormType.Detailed), + ); + expect(result.current.map((s) => s.step)).toEqual([ + PdsGoalCalculatorStepEnum.Setup, + PdsGoalCalculatorStepEnum.ReimbursableExpenses, + PdsGoalCalculatorStepEnum.SupportItem, + PdsGoalCalculatorStepEnum.SummaryReport, + ]); + }); + + it('omits the Reimbursable Expenses step when formType is Simple', () => { + const { result } = renderHook(() => + useSteps(DesignationSupportFormType.Simple), + ); + expect(result.current.map((s) => s.step)).toEqual([ + PdsGoalCalculatorStepEnum.Setup, + PdsGoalCalculatorStepEnum.SupportItem, + PdsGoalCalculatorStepEnum.SummaryReport, + ]); + }); + + it('returns four steps (Detailed behavior) when formType is null/undefined', () => { + const { result } = renderHook(() => useSteps(null)); + expect(result.current.map((s) => s.step)).toEqual([ + PdsGoalCalculatorStepEnum.Setup, + PdsGoalCalculatorStepEnum.ReimbursableExpenses, + PdsGoalCalculatorStepEnum.SupportItem, + PdsGoalCalculatorStepEnum.SummaryReport, + ]); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx index c175374c50..23ac88b2cb 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx @@ -4,6 +4,7 @@ import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; import RequestQuoteIcon from '@mui/icons-material/RequestQuote'; import SettingsIcon from '@mui/icons-material/Settings'; import { useTranslation } from 'react-i18next'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { PdsGoalCalculatorStepEnum } from '../PdsGoalCalculatorHelper'; export interface PdsGoalCalculatorSection { @@ -18,11 +19,15 @@ export interface PdsGoalCalculatorStep { sections: PdsGoalCalculatorSection[]; } -export const useSteps = (): PdsGoalCalculatorStep[] => { +export const useSteps = ( + formType: DesignationSupportFormType | null | undefined, +): PdsGoalCalculatorStep[] => { const { t } = useTranslation(); - const steps = useMemo( - () => [ + return useMemo(() => { + const isSimple = formType === DesignationSupportFormType.Simple; + + const allSteps: PdsGoalCalculatorStep[] = [ { step: PdsGoalCalculatorStepEnum.Setup, title: t('Settings'), @@ -53,9 +58,13 @@ export const useSteps = (): PdsGoalCalculatorStep[] => { icon: , sections: [{ title: t('MPD Goal'), complete: false }], }, - ], - [t], - ); + ]; - return steps; + return isSimple + ? allSteps.filter( + (step) => + step.step !== PdsGoalCalculatorStepEnum.ReimbursableExpenses, + ) + : allSteps; + }, [t, formType]); }; From df39d3aec4127d648efd5bfb0ec467248f2e6b7a Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 15:39:43 -0400 Subject: [PATCH 05/23] Clamp PDS stepIndex when steps array shrinks (defensive) Add a useEffect that resets stepIndex to 0 whenever it falls outside the bounds of the current steps array. Guards against a future crash if formType switches from Detailed to Simple while the user is on a later step (e.g. SummaryReport at index 3 becomes out-of-bounds when steps shrinks from 4 to 3). Co-Authored-By: Claude Sonnet 4.6 --- .../Shared/PdsGoalCalculatorContext.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index 7463fc5859..876cdc7236 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -1,5 +1,11 @@ import { useRouter } from 'next/router'; -import React, { createContext, useCallback, useMemo, useState } from 'react'; +import React, { + createContext, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { useTrackMutation } from 'src/hooks/useTrackMutation'; @@ -85,6 +91,14 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const [isDrawerOpen, setIsDrawerOpen] = useState(true); const { trackMutation, isMutating } = useTrackMutation(); + // Clamp stepIndex to valid range when the steps array shrinks (e.g. formType + // switches from Detailed to Simple, dropping the ReimbursableExpenses step). + useEffect(() => { + if (stepIndex >= steps.length) { + setStepIndex(0); + } + }, [steps.length, stepIndex]); + const currentStep = steps[stepIndex]; const handleStepChange = useCallback( From 9461d65b8adbeb9fdb0ee7daa6b55dbba13460d3 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 15:49:46 -0400 Subject: [PATCH 06/23] Add Form Type select and conditionally hide 403b field in SetupStep Co-Authored-By: Claude Sonnet 4.6 --- .../Setup/SetupStep.test.tsx | 87 +++++++++++++++++++ .../PdsGoalCalculator/Setup/SetupStep.tsx | 54 ++++++++---- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index 4d853a38a8..2bfbed8136 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { + DesignationSupportFormType, DesignationSupportSalaryType, DesignationSupportStatus, } from 'src/graphql/types.generated'; @@ -412,4 +413,90 @@ describe('SetupStep', () => { await findByRole('spinbutton', { name: 'Hours Worked' }), ).toBeInTheDocument(); }); + + it('renders the Form Type select with both options', async () => { + const { findByRole, getByRole } = render( + + + , + ); + + const select = await findByRole('combobox', { name: /Form Type/ }); + await waitFor(() => expect(select).toHaveTextContent('Default')); + userEvent.click(select); + + expect(getByRole('option', { name: 'Default' })).toBeInTheDocument(); + expect(getByRole('option', { name: 'Simple' })).toBeInTheDocument(); + }); + + it('shows 403b Contribution Percentage field when formType is Detailed', async () => { + const { findByRole } = render( + + + , + ); + + expect( + await findByRole('textbox', { name: '403b Contribution Percentage' }), + ).toBeInTheDocument(); + }); + + it('hides 403b Contribution Percentage field when formType is Simple', async () => { + const { findByRole, queryByRole } = render( + + + , + ); + + // Wait for the Form Type select to reflect the loaded Simple value + const formTypeSelect = await findByRole('combobox', { name: /Form Type/ }); + await waitFor(() => expect(formTypeSelect).toHaveTextContent('Simple')); + + expect( + queryByRole('textbox', { name: '403b Contribution Percentage' }), + ).not.toBeInTheDocument(); + }); + + it('fires UpdatePdsGoalCalculation with formType when toggled to Simple', async () => { + const { findByRole, getByRole } = render( + + + , + ); + + const select = await findByRole('combobox', { name: /Form Type/ }); + await waitFor(() => expect(select).toHaveTextContent('Default')); + userEvent.click(select); + userEvent.click(getByRole('option', { name: 'Simple' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + id: 'goal-1', + formType: 'SIMPLE', + }, + }), + ); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index 64a3da338a..2117929f8a 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; import { useGetUserQuery } from 'src/components/User/GetUser.generated'; import { + DesignationSupportFormType, DesignationSupportSalaryType, DesignationSupportStatus, } from 'src/graphql/types.generated'; @@ -46,6 +47,7 @@ export const SetupStep: React.FC = () => { const schema = useMemo( () => yup.object({ + formType: yup.string().optional(), name: yup.string().required(t('Goal Name is a required field')), status: yup .string() @@ -87,6 +89,8 @@ export const SetupStep: React.FC = () => { const isSalaried = calculation?.salaryOrHourly === DesignationSupportSalaryType.Salaried; const isPartTime = calculation?.status === DesignationSupportStatus.PartTime; + const isSimpleForm = + calculation?.formType === DesignationSupportFormType.Simple; const payRateHelperText = isSalaried ? t('Enter yearly salary') @@ -138,6 +142,22 @@ export const SetupStep: React.FC = () => { + + + + {t('Default')} + + + {t('Simple')} + + + + { )} - - , - }} - /> - + {!isSimpleForm && ( + + , + }} + /> + + )} Date: Thu, 7 May 2026 15:54:07 -0400 Subject: [PATCH 07/23] Constrain formType Yup schema to enum values; cover null formType legacy case Tightens the formType validation to only accept values in DesignationSupportFormType, and adds a test documenting that a null formType (pre-feature records) falls back to Default-view rendering (403b field shown). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PdsGoalCalculator/Setup/SetupStep.test.tsx | 17 +++++++++++++++++ .../PdsGoalCalculator/Setup/SetupStep.tsx | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index 2bfbed8136..37622a5287 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -499,4 +499,21 @@ describe('SetupStep', () => { }), ); }); + + it('shows the 403b Contribution Percentage field when formType is null (legacy goal)', async () => { + const { findByRole } = render( + + + , + ); + + expect( + await findByRole('textbox', { name: '403b Contribution Percentage' }), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index 2117929f8a..a431e6b3b6 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -47,7 +47,10 @@ export const SetupStep: React.FC = () => { const schema = useMemo( () => yup.object({ - formType: yup.string().optional(), + formType: yup + .string() + .oneOf(Object.values(DesignationSupportFormType)) + .optional(), name: yup.string().required(t('Goal Name is a required field')), status: yup .string() From 6f96c6572fed6fdfe12cebbe6182b55040e415ed Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 15:56:51 -0400 Subject: [PATCH 08/23] Hide Reimbursable Expenses and 403b summary rows when formType is Simple Co-Authored-By: Claude Sonnet 4.6 --- .../SummaryReport/PdsSummaryTable.test.tsx | 64 +++++++++++++++++++ .../SummaryReport/PdsSummaryTable.tsx | 31 +++++---- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx index bdf2331d5a..4165cc8e52 100644 --- a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { + DesignationSupportFormType, DesignationSupportSalaryType, DesignationSupportStatus, } from 'src/graphql/types.generated'; @@ -168,4 +169,67 @@ describe('PdsSummaryTable', () => { }).parentElement; expect(supportRaisedRow).toHaveClass('progress-start'); }); + + it('omits Reimbursable Expenses and 403b Contributions rows when formType is Simple', async () => { + const { findByRole, queryByRole } = render( + + + , + ); + + // Wait for the grid to render + await findByRole('gridcell', { name: 'Salary Subtotal' }); + + expect( + queryByRole('gridcell', { name: 'Reimbursable Expenses' }), + ).not.toBeInTheDocument(); + expect( + queryByRole('gridcell', { name: '403b Contributions' }), + ).not.toBeInTheDocument(); + }); + + it('renders Benefits / Work Comp + totals rows when formType is Simple', async () => { + const { findByRole, getByRole } = render( + + + , + ); + + await findByRole('gridcell', { name: 'Benefits' }); + + expect(getByRole('gridcell', { name: 'Other Subtotal' })).toBeInTheDocument(); + expect(getByRole('gridcell', { name: 'Total Goal' })).toBeInTheDocument(); + }); + + it('renders Reimbursable Expenses and 403b Contributions rows when formType is Detailed', async () => { + const { findByRole, getByRole } = render( + + + , + ); + + await findByRole('gridcell', { name: 'Reimbursable Expenses' }); + + expect( + getByRole('gridcell', { name: 'Reimbursable Expenses' }), + ).toBeInTheDocument(); + expect( + getByRole('gridcell', { name: '403b Contributions' }), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx index be02fc7af2..0a02db64cf 100644 --- a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx @@ -2,7 +2,10 @@ import React, { useCallback, useMemo } from 'react'; import { styled } from '@mui/material/styles'; import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { useTranslation } from 'react-i18next'; -import { DesignationSupportStatus } from 'src/graphql/types.generated'; +import { + DesignationSupportFormType, + DesignationSupportStatus, +} from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; import { useDataGridLocaleText } from 'src/hooks/useMuiLocaleText'; import { @@ -85,6 +88,8 @@ export const PdsSummaryTable: React.FC = ({ const isFullTime = calculation.status === DesignationSupportStatus.FullTime; const isPartTime = calculation.status === DesignationSupportStatus.PartTime; + const isSimple = + calculation.formType === DesignationSupportFormType.Simple; const rows: PdsSummaryRow[] = [ // Salary section @@ -104,16 +109,20 @@ export const PdsSummaryTable: React.FC = ({ amount: salaryTotals.subtotal, }, // Other expenses section - { - line: '2A', - category: t('Reimbursable Expenses'), - amount: otherTotals.reimbursableExpenses, - }, - { - line: '2B', - category: t('403b Contributions'), - amount: otherTotals.fourOThreeBContributions, - }, + ...(isSimple + ? [] + : [ + { + line: '2A', + category: t('Reimbursable Expenses'), + amount: otherTotals.reimbursableExpenses, + }, + { + line: '2B', + category: t('403b Contributions'), + amount: otherTotals.fourOThreeBContributions, + }, + ]), ...(isPartTime ? [ { From baeb2974bd1906708ca90fd8dacfd39fc55a4a93 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 16:01:33 -0400 Subject: [PATCH 09/23] Show Default/Simple badge on PdsGoalCard Add an optional `badge` prop to the shared GoalCard and render a MUI Chip labelled "Default" (Detailed form type) or "Simple" in PdsGoalCard, so users can distinguish goal types at a glance. Co-Authored-By: Claude Sonnet 4.6 --- .../GoalCard/PdsGoalCard.test.tsx | 59 +++++++++++++++++++ .../GoalCard/PdsGoalCard.tsx | 15 +++++ .../Reports/Shared/GoalCard/GoalCard.tsx | 5 +- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx index 88e4901a1c..feface5b5b 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { PdsGoalsList } from '../GoalsList/PdsGoalsList'; import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; @@ -36,4 +37,62 @@ describe('PdsGoalCard', () => { '/accountLists/abc123/hrTools/pdsGoalCalculator/pds-goal-1', ); }); + + it('renders a Default badge when formType is Detailed', async () => { + const { findByText } = render( + + + , + ); + + expect(await findByText('Default')).toBeInTheDocument(); + }); + + it('renders a Simple badge when formType is Simple', async () => { + const { findByText } = render( + + + , + ); + + expect(await findByText('Simple')).toBeInTheDocument(); + }); + + it('renders no form-type badge when formType is null (legacy goal)', async () => { + const { findByText, queryByText } = render( + + + , + ); + + await findByText('Legacy Goal'); + + expect(queryByText('Default')).not.toBeInTheDocument(); + expect(queryByText('Simple')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx index 285eb76ebe..e820db3397 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx @@ -1,5 +1,8 @@ import React, { useMemo } from 'react'; +import { Chip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import { GoalCard } from 'src/components/Reports/Shared/GoalCard/GoalCard'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { @@ -17,6 +20,7 @@ export interface PdsGoalCardProps { } export const PdsGoalCard: React.FC = ({ goal }) => { + const { t } = useTranslation(); const accountListId = useAccountListId() ?? ''; const [deletePdsGoalCalculation] = useDeletePdsGoalCalculationMutation(); @@ -38,6 +42,16 @@ export const PdsGoalCard: React.FC = ({ goal }) => { return constants ? calculatePdsGoalTotal(goal, constants) : 0; }, [goal, goalMiscConstants, goalGeographicConstantMap, hcmUser]); + const formTypeBadge = useMemo(() => { + if (goal.formType === DesignationSupportFormType.Simple) { + return ; + } + if (goal.formType === DesignationSupportFormType.Detailed) { + return ; + } + return null; + }, [goal.formType, t]); + const handleDelete = async () => { await deletePdsGoalCalculation({ variables: { id: goal.id }, @@ -57,6 +71,7 @@ export const PdsGoalCard: React.FC = ({ goal }) => { updatedAt={goal.updatedAt} viewHref={`/accountLists/${accountListId}/hrTools/pdsGoalCalculator/${goal.id}`} onDelete={handleDelete} + badge={formTypeBadge} /> ); }; diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.tsx index f1802780d6..f93becd946 100644 --- a/src/components/Reports/Shared/GoalCard/GoalCard.tsx +++ b/src/components/Reports/Shared/GoalCard/GoalCard.tsx @@ -62,6 +62,7 @@ export interface GoalCardProps { updatedAt: string; viewHref: string; onDelete: () => Promise; + badge?: React.ReactNode; } export const GoalCard: React.FC = ({ @@ -72,6 +73,7 @@ export const GoalCard: React.FC = ({ updatedAt, viewHref, onDelete, + badge, }) => { const { t } = useTranslation(); const locale = useLocale(); @@ -105,7 +107,7 @@ export const GoalCard: React.FC = ({ /> - + = ({ {displayName} + {badge} From 0e0804305f7f792d8624ba18ddbc4e58b4f21586 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 16:05:18 -0400 Subject: [PATCH 10/23] Add CreateGoalDialog for picking form type at goal creation Implements a presentational dialog that lets the user choose between Default (Detailed) and Simple DesignationSupportFormType before creating a new PDS goal. Follows TDD: failing test written first, then component. Co-Authored-By: Claude Sonnet 4.6 --- .../GoalsList/CreateGoalDialog.test.tsx | 85 ++++++++++++++++ .../GoalsList/CreateGoalDialog.tsx | 99 +++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx create mode 100644 src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx new file mode 100644 index 0000000000..4fbfaa9fb9 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; +import theme from 'src/theme'; +import { CreateGoalDialog } from './CreateGoalDialog'; + +const renderDialog = ( + props: Partial> = {}, +) => { + const defaults = { + open: true, + onClose: jest.fn(), + onCreate: jest.fn().mockResolvedValue(undefined), + creating: false, + }; + return { + props: { ...defaults, ...props }, + ...render( + + + , + ), + }; +}; + +describe('CreateGoalDialog', () => { + it('renders both form-type options with descriptions', () => { + const { getByRole, getByText } = renderDialog(); + expect(getByRole('radio', { name: /Default/ })).toBeInTheDocument(); + expect(getByRole('radio', { name: /Simple/ })).toBeInTheDocument(); + expect( + getByText( + 'Full calculator with reimbursable expenses and 403b contributions.', + ), + ).toBeInTheDocument(); + expect( + getByText( + 'Streamlined calculator without reimbursable expenses or 403b contributions.', + ), + ).toBeInTheDocument(); + }); + + it('disables the Create button until a form type is picked', () => { + const { getByRole } = renderDialog(); + expect(getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('enables Create after a form type is picked', () => { + const { getByRole } = renderDialog(); + userEvent.click(getByRole('radio', { name: /Simple/ })); + expect(getByRole('button', { name: 'Create' })).toBeEnabled(); + }); + + it('calls onCreate with the chosen form type when Create is clicked', async () => { + const onCreate = jest.fn().mockResolvedValue(undefined); + const { getByRole } = renderDialog({ onCreate }); + + userEvent.click(getByRole('radio', { name: /Simple/ })); + userEvent.click(getByRole('button', { name: 'Create' })); + + await waitFor(() => + expect(onCreate).toHaveBeenCalledWith(DesignationSupportFormType.Simple), + ); + }); + + it('calls onClose when Cancel is clicked', () => { + const onClose = jest.fn(); + const { getByRole } = renderDialog({ onClose }); + userEvent.click(getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); + }); + + it('disables actions while creating is true', () => { + const { getByRole } = renderDialog({ creating: true }); + expect(getByRole('button', { name: 'Create' })).toBeDisabled(); + expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + it('does not render when open is false', () => { + const { queryByRole } = renderDialog({ open: false }); + expect(queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx new file mode 100644 index 0000000000..392f1a06d5 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; + +export interface CreateGoalDialogProps { + open: boolean; + onClose: () => void; + onCreate: (formType: DesignationSupportFormType) => Promise; + creating: boolean; +} + +export const CreateGoalDialog: React.FC = ({ + open, + onClose, + onCreate, + creating, +}) => { + const { t } = useTranslation(); + const [selected, setSelected] = useState( + null, + ); + + const handleCreate = async () => { + if (selected) { + await onCreate(selected); + } + }; + + return ( + + {t('Create a New Goal')} + + + + setSelected(value as DesignationSupportFormType) + } + > + } + label={ + <> + {t('Default')} + + {t( + 'Full calculator with reimbursable expenses and 403b contributions.', + )} + + + } + sx={{ alignItems: 'flex-start', mb: 2 }} + /> + } + label={ + <> + {t('Simple')} + + {t( + 'Streamlined calculator without reimbursable expenses or 403b contributions.', + )} + + + } + sx={{ alignItems: 'flex-start' }} + /> + + + + + + + + + ); +}; From 33e001a614cd5d95016e40810bb95364fe90d417 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 16:07:28 -0400 Subject: [PATCH 11/23] Add visually-hidden FormLabel to CreateGoalDialog fieldset for a11y --- .../HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx index 392f1a06d5..e41bed2517 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, FormControl, FormControlLabel, + FormLabel, Radio, RadioGroup, Typography, @@ -43,6 +44,9 @@ export const CreateGoalDialog: React.FC = ({ {t('Create a New Goal')} + + {t('Select a form type')} + From 023fa02491b6d8550080ea9eba67ea1d799313bf Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 16:10:01 -0400 Subject: [PATCH 12/23] Open CreateGoalDialog from PdsGoalsList; pass formType to create mutation Co-Authored-By: Claude Sonnet 4.6 --- .../GoalsList/PdsGoalsList.test.tsx | 53 +++++++++++++++++-- .../GoalsList/PdsGoalsList.tsx | 35 +++++++++--- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx index fb1944c563..649a9f5091 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx @@ -49,7 +49,21 @@ describe('PdsGoalsList', () => { expect(queryByTestId('goal-name')).not.toBeInTheDocument(); }); - it('seeds reimbursable defaults from MPD constants on create', async () => { + it('opens the create dialog when Create a New Goal is clicked', async () => { + const { findByRole } = render( + + + , + ); + + const button = await findByRole('button', { name: 'Create a New Goal' }); + await waitFor(() => expect(button).toBeEnabled()); + userEvent.click(button); + + expect(await findByRole('dialog')).toBeInTheDocument(); + }); + + it('creates a Default goal with seeded reimbursable defaults via the dialog', async () => { const { findByRole } = render( { , ); - const button = await findByRole('button', { name: 'Create a New Goal' }); - await waitFor(() => { - expect(button).toBeEnabled(); + const openButton = await findByRole('button', { + name: 'Create a New Goal', }); - userEvent.click(button); + await waitFor(() => expect(openButton).toBeEnabled()); + userEvent.click(openButton); + + userEvent.click(await findByRole('radio', { name: /Default/ })); + userEvent.click(await findByRole('button', { name: 'Create' })); await waitFor(() => { expect(mutationSpy).toHaveGraphqlOperation('CreatePdsGoalCalculation', { attributes: { + formType: 'DETAILED', ministryCellPhone: 75, ministryInternet: 50, }, @@ -91,6 +109,31 @@ describe('PdsGoalsList', () => { }); }); + it('creates a Simple goal via the dialog', async () => { + const { findByRole } = render( + + + , + ); + + const openButton = await findByRole('button', { + name: 'Create a New Goal', + }); + await waitFor(() => expect(openButton).toBeEnabled()); + userEvent.click(openButton); + + userEvent.click(await findByRole('radio', { name: /Simple/ })); + userEvent.click(await findByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('CreatePdsGoalCalculation', { + attributes: { + formType: 'SIMPLE', + }, + }); + }); + }); + it('View link navigates to the goal calculator page', async () => { const { findByRole } = render( { error, pageInfo: data?.designationSupportCalculations.pageInfo, }); - const [createPdsGoalCalculation] = useCreatePdsGoalCalculationMutation(); + const [createPdsGoalCalculation, { loading: creating }] = + useCreatePdsGoalCalculationMutation(); const { goalMiscConstants, loading: constantsLoading } = useGoalCalculatorConstants(); + const [dialogOpen, setDialogOpen] = useState(false); + const goals = data?.designationSupportCalculations.nodes; - const handleCreateGoal = async () => { + const handleCreateGoal = async (formType: DesignationSupportFormType) => { + const isDetailed = formType === DesignationSupportFormType.Detailed; const { data } = await createPdsGoalCalculation({ variables: { attributes: { - ministryCellPhone: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.PHONE?.fee, - ministryInternet: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.INTERNET?.fee, + formType, + ...(isDetailed + ? { + ministryCellPhone: + goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.PHONE?.fee, + ministryInternet: + goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.INTERNET?.fee, + } + : {}), }, }, }); @@ -59,6 +70,7 @@ export const PdsGoalsList: React.FC = () => { data?.createDesignationSupportCalculation?.designationSupportCalculation; if (calculation) { + setDialogOpen(false); router.push( `/accountLists/${accountListId}/hrTools/pdsGoalCalculator/${calculation.id}`, ); @@ -71,13 +83,20 @@ export const PdsGoalsList: React.FC = () => { + setDialogOpen(false)} + onCreate={handleCreateGoal} + creating={creating} + /> + {loading ? ( ) : goals?.length === 0 ? ( From 29c081032f90f312d854d791fcbcb36934ae6214 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 7 May 2026 16:28:19 -0400 Subject: [PATCH 13/23] Add error handling, state reset, and aria-labelledby to PDS goal creation flow - Wrap createPdsGoalCalculation in try/catch with notistack error snackbar so network/server errors surface to the user and the dialog stays open for retry - Reset selected radio state on dialog close so the form starts clean if reopened - Add aria-labelledby to Dialog referencing DialogTitle id for accessibility Co-Authored-By: Claude Sonnet 4.6 --- .../GoalsList/CreateGoalDialog.test.tsx | 20 ++++++++ .../GoalsList/CreateGoalDialog.tsx | 19 +++++-- .../GoalsList/PdsGoalsList.test.tsx | 7 +++ .../GoalsList/PdsGoalsList.tsx | 50 +++++++++++-------- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx index 4fbfaa9fb9..9976dd1bb7 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx @@ -82,4 +82,24 @@ describe('CreateGoalDialog', () => { const { queryByRole } = renderDialog({ open: false }); expect(queryByRole('dialog')).not.toBeInTheDocument(); }); + + it('clears the selected option when reopened after Cancel', () => { + const { getByRole, rerender } = renderDialog(); + + userEvent.click(getByRole('radio', { name: /Simple/ })); + userEvent.click(getByRole('button', { name: 'Cancel' })); + + rerender( + + + , + ); + + expect(getByRole('button', { name: 'Create' })).toBeDisabled(); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx index e41bed2517..c9fe0b91c7 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -33,6 +33,11 @@ export const CreateGoalDialog: React.FC = ({ null, ); + const handleClose = () => { + setSelected(null); + onClose(); + }; + const handleCreate = async () => { if (selected) { await onCreate(selected); @@ -40,8 +45,16 @@ export const CreateGoalDialog: React.FC = ({ }; return ( - - {t('Create a New Goal')} + + + {t('Create a New Goal')} + @@ -87,7 +100,7 @@ export const CreateGoalDialog: React.FC = ({ - diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx index 69c6e9966f..147fd8224b 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx @@ -10,6 +10,25 @@ import { PdsGoalsList } from './PdsGoalsList'; const mutationSpy = jest.fn(); +type FindByRole = ReturnType['findByRole']; + +// The "Create a New Goal" button is disabled while the GoalCalculatorConstants +// query is in flight (constantsLoading drives `disabled`), so we wait for it +// to become enabled before clicking. +const openCreateGoalDialog = async (findByRole: FindByRole) => { + const button = await findByRole('button', { name: 'Create a New Goal' }); + await waitFor(() => expect(button).toBeEnabled()); + userEvent.click(button); +}; + +const submitFormType = async ( + findByRole: FindByRole, + formType: 'Default' | 'Simple', +) => { + userEvent.click(await findByRole('radio', { name: new RegExp(formType) })); + userEvent.click(await findByRole('button', { name: 'Create' })); +}; + describe('PdsGoalsList', () => { it('renders the create button', () => { const { getByRole } = render( @@ -56,9 +75,7 @@ describe('PdsGoalsList', () => { , ); - const button = await findByRole('button', { name: 'Create a New Goal' }); - await waitFor(() => expect(button).toBeEnabled()); - userEvent.click(button); + await openCreateGoalDialog(findByRole); expect(await findByRole('dialog')).toBeInTheDocument(); }); @@ -89,14 +106,8 @@ describe('PdsGoalsList', () => { , ); - const openButton = await findByRole('button', { - name: 'Create a New Goal', - }); - await waitFor(() => expect(openButton).toBeEnabled()); - userEvent.click(openButton); - - userEvent.click(await findByRole('radio', { name: /Default/ })); - userEvent.click(await findByRole('button', { name: 'Create' })); + await openCreateGoalDialog(findByRole); + await submitFormType(findByRole, 'Default'); await waitFor(() => { expect(mutationSpy).toHaveGraphqlOperation('CreatePdsGoalCalculation', { @@ -116,14 +127,8 @@ describe('PdsGoalsList', () => { , ); - const openButton = await findByRole('button', { - name: 'Create a New Goal', - }); - await waitFor(() => expect(openButton).toBeEnabled()); - userEvent.click(openButton); - - userEvent.click(await findByRole('radio', { name: /Simple/ })); - userEvent.click(await findByRole('button', { name: 'Create' })); + await openCreateGoalDialog(findByRole); + await submitFormType(findByRole, 'Simple'); await waitFor(() => { expect(mutationSpy).toHaveGraphqlOperation('CreatePdsGoalCalculation', { @@ -198,10 +203,45 @@ describe('PdsGoalsList', () => { }); }); - // Skipped: testing the error-snackbar path requires overriding useCreatePdsGoalCalculationMutation, - // but the hook's exports are non-configurable in the generated file (jest.spyOn fails) and a - // module-level jest.mock would break the existing GqlMockedProvider-based mutation tests that - // verify calls via onCall/mutationSpy. The production fix (try/catch + enqueueSnackbar) is in - // PdsGoalsList.tsx; integration coverage can be added once a test-helper pattern for mocking - // individual generated hooks alongside GqlMockedProvider is established. + it('shows error snackbar and skips mutation when reimbursement constants are missing for a Default goal', async () => { + const { findByRole, findByText } = render( + + + , + ); + + await openCreateGoalDialog(findByRole); + await submitFormType(findByRole, 'Default'); + + expect( + await findByText('Failed to create goal. Please try again.'), + ).toBeInTheDocument(); + expect(mutationSpy).not.toHaveGraphqlOperation('CreatePdsGoalCalculation'); + }); + + it('shows error snackbar when create mutation fails', async () => { + const { findByRole, findByText } = render( + { + throw new Error('Server Error'); + }, + }} + > + + , + ); + + await openCreateGoalDialog(findByRole); + await submitFormType(findByRole, 'Simple'); + + expect( + await findByText('Failed to create goal. Please try again.'), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx index 756a9b7a17..0d18f7e0d8 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx @@ -53,19 +53,31 @@ export const PdsGoalsList: React.FC = () => { const handleCreateGoal = async (formType: DesignationSupportFormType) => { const isDetailed = formType === DesignationSupportFormType.Detailed; + let detailedDefaults: { + ministryCellPhone: number; + ministryInternet: number; + } | null = null; + if (isDetailed) { + const reimbursements = goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM; + const phoneFee = reimbursements?.PHONE?.fee; + const internetFee = reimbursements?.INTERNET?.fee; + if (phoneFee === undefined || internetFee === undefined) { + enqueueSnackbar(t('Failed to create goal. Please try again.'), { + variant: 'error', + }); + return; + } + detailedDefaults = { + ministryCellPhone: phoneFee, + ministryInternet: internetFee, + }; + } try { const { data } = await createPdsGoalCalculation({ variables: { attributes: { formType, - ...(isDetailed - ? { - ministryCellPhone: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.PHONE?.fee, - ministryInternet: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.INTERNET?.fee, - } - : {}), + ...(detailedDefaults ?? {}), }, }, }); diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index ed9f228020..d88b2746a5 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; +import { ApolloErgonoMockMap } from 'graphql-ergonomock'; import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; import { merge, mergeWith } from 'lodash'; import { SnackbarProvider } from 'notistack'; @@ -119,6 +120,7 @@ export interface PdsGoalCalculatorTestWrapperProps { userMock?: GetUserMock; constantsMock?: GoalCalculatorConstantsMock; supportRaisedMock?: number; + mocksOverride?: ApolloErgonoMockMap; onCall?: MockLinkCallHandler; router?: React.ComponentProps['router']; } @@ -134,6 +136,7 @@ export const PdsGoalCalculatorTestWrapper: React.FC< userMock, constantsMock, supportRaisedMock, + mocksOverride, onCall, router, }) => { @@ -242,6 +245,7 @@ export const PdsGoalCalculatorTestWrapper: React.FC< Array.isArray(srcValue) ? srcValue : undefined, ), }, + ...mocksOverride, }} onCall={onCall} > diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index 37622a5287..9587ced11f 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -6,7 +6,10 @@ import { DesignationSupportSalaryType, DesignationSupportStatus, } from 'src/graphql/types.generated'; -import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; +import { + PdsGoalCalculatorTestWrapper, + PdsGoalCalculatorTestWrapperProps, +} from '../PdsGoalCalculatorTestWrapper'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; import { SetupStep } from './SetupStep'; @@ -45,13 +48,24 @@ const partTimeHourlyMock = { benefits: null, }; +const setupTree = ( + props: Partial = {}, + extraChildren?: React.ReactNode, +) => ( + + + {extraChildren} + +); + +const renderSetup = ( + props?: Partial, + extraChildren?: React.ReactNode, +) => render(setupTree(props, extraChildren)); + describe('SetupStep', () => { it('disables fields while calculation data is loading', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ calculationMock: undefined }); // When calculation is undefined (loading), autosave fields should be disabled const goalName = await findByRole('textbox', { name: 'Goal Name' }); @@ -59,16 +73,12 @@ describe('SetupStep', () => { }); it('shows validation errors for multiple empty required fields simultaneously', async () => { - const { findByRole, findByText } = render( - - - , - ); + const { findByRole, findByText } = renderSetup({ + calculationMock: { + ...fullTimeHourlyMock, + name: 'Test Goal', + }, + }); const goalNameInput = await findByRole('textbox', { name: 'Goal Name' }); await waitFor(() => expect(goalNameInput).toHaveValue('Test Goal')); @@ -94,93 +104,69 @@ describe('SetupStep', () => { ).toBeInTheDocument(); }); - it('renders all visible fields for full-time salaried (Benefits shown, Hours Worked hidden)', async () => { - const { findByRole, queryByRole } = render( - - - , - ); - - expect( - await findByRole('textbox', { name: 'Goal Name' }), - ).toBeInTheDocument(); - expect( - await findByRole('spinbutton', { name: 'Pay Rate' }), - ).toBeInTheDocument(); - expect( - await findByRole('spinbutton', { name: 'Benefits' }), - ).toBeInTheDocument(); - expect( - await findByRole('textbox', { name: '403b Contribution Percentage' }), - ).toBeInTheDocument(); - expect( - await findByRole('combobox', { name: 'Geographic Multiplier' }), - ).toBeInTheDocument(); - - // Hours Worked should be hidden for salaried - await waitFor(() => { - expect( - queryByRole('spinbutton', { name: 'Hours Worked' }), - ).not.toBeInTheDocument(); - }); - }); - - it('hides Benefits when Part-time', async () => { - const { findByRole, queryByRole } = render( - - - , - ); - - expect( - await findByRole('textbox', { name: 'Goal Name' }), - ).toBeInTheDocument(); - - // Benefits should be hidden for part-time - await waitFor(() => { - expect( - queryByRole('spinbutton', { name: 'Benefits' }), - ).not.toBeInTheDocument(); - }); - }); - - it('shows Hours Worked when Hourly', async () => { - const { findByRole } = render( - - - , - ); - - expect( - await findByRole('spinbutton', { name: 'Hours Worked' }), - ).toBeInTheDocument(); - }); + it.each([ + { + name: 'full-time salaried', + calculationMock: fullTimeSalariedMock, + benefits: 'visible' as const, + hoursWorked: 'hidden' as const, + }, + { + name: 'part-time salaried', + calculationMock: partTimeSalariedMock, + benefits: 'hidden' as const, + hoursWorked: 'hidden' as const, + }, + { + name: 'full-time hourly', + calculationMock: fullTimeHourlyMock, + benefits: 'visible' as const, + hoursWorked: 'visible' as const, + }, + { + name: 'part-time hourly', + calculationMock: partTimeHourlyMock, + benefits: 'hidden' as const, + hoursWorked: 'visible' as const, + }, + ])( + 'shows Benefits=$benefits and Hours Worked=$hoursWorked for $name', + async ({ calculationMock, benefits, hoursWorked }) => { + const { findByRole, queryByRole } = renderSetup({ calculationMock }); + + // Wait for the form to load before asserting on conditional fields + await findByRole('textbox', { name: 'Goal Name' }); + + await waitFor(() => + expect({ + benefits: queryByRole('spinbutton', { name: 'Benefits' }) + ? 'visible' + : 'hidden', + hoursWorked: queryByRole('spinbutton', { name: 'Hours Worked' }) + ? 'visible' + : 'hidden', + }).toEqual({ benefits, hoursWorked }), + ); + }, + ); it('shows dynamic Pay Rate helper text based on salary type', async () => { - const { findByRole, findByText, rerender } = render( - - - , - ); + const { findByRole, findByText, rerender } = renderSetup({ + calculationMock: fullTimeSalariedMock, + }); await findByRole('spinbutton', { name: 'Pay Rate' }); expect(await findByText('Enter yearly salary')).toBeInTheDocument(); - rerender( - - - , - ); + rerender(setupTree({ calculationMock: fullTimeHourlyMock })); expect(await findByText('Enter hourly rate')).toBeInTheDocument(); }); it('403b field is disabled', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + }); expect( await findByRole('textbox', { name: '403b Contribution Percentage' }), @@ -188,19 +174,15 @@ describe('SetupStep', () => { }); it('displays the sum of tax-deferred and Roth 403b contribution percentages', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + hcmUserMock: { + fourOThreeB: { + currentTaxDeferredContributionPercentage: 5, + currentRothContributionPercentage: 3, + }, + }, + }); await waitFor(async () => expect( @@ -212,14 +194,10 @@ describe('SetupStep', () => { }); it('403b field is empty when hcm data has no 403b entry', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + hcmUserMock: null, + }); expect( await findByRole('textbox', { @@ -229,19 +207,15 @@ describe('SetupStep', () => { }); it('displays the logged-in user first name and avatar', async () => { - const { getByTestId, getByAltText } = render( - - - , - ); + const { getByTestId, getByAltText } = renderSetup({ + calculationMock: fullTimeSalariedMock, + userMock: { + user: { + firstName: 'Jane', + avatar: 'https://example.com/jane.png', + }, + }, + }); await waitFor(() => expect(getByTestId('info-name-typography')).toHaveTextContent('Jane'), @@ -253,11 +227,9 @@ describe('SetupStep', () => { }); it('Geographic Multiplier autocomplete renders', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + }); expect( await findByRole('combobox', { name: 'Geographic Multiplier' }), @@ -265,14 +237,10 @@ describe('SetupStep', () => { }); it('fires mutation when Goal Name is changed', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: { ...fullTimeSalariedMock, name: 'Test Goal' }, + onCall: mutationSpy, + }); const input = await findByRole('textbox', { name: 'Goal Name' }); await waitFor(() => expect(input).toHaveValue('Test Goal')); @@ -292,14 +260,10 @@ describe('SetupStep', () => { }); it('fires mutation when Geographic Multiplier is selected', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + onCall: mutationSpy, + }); const input = await findByRole('combobox', { name: 'Geographic Multiplier', @@ -320,11 +284,9 @@ describe('SetupStep', () => { }); it('renders the calculator icon button next to Hours Worked', async () => { - const { findByLabelText } = render( - - - , - ); + const { findByLabelText } = renderSetup({ + calculationMock: fullTimeHourlyMock, + }); expect( await findByLabelText('Open hours per week calculator'), @@ -332,11 +294,9 @@ describe('SetupStep', () => { }); it('opens the hours per week calculator in the right panel when the icon is clicked', async () => { - const { findByLabelText, findByText, queryByText } = render( - - - - , + const { findByLabelText, findByText, queryByText } = renderSetup( + { calculationMock: fullTimeHourlyMock }, + , ); expect(queryByText('Hours Per Week Calculator')).not.toBeInTheDocument(); @@ -347,13 +307,9 @@ describe('SetupStep', () => { }); it('hides Hours Worked and adapts validation when switching from Hourly to Salaried', async () => { - const { findByRole, queryByRole, rerender } = render( - - - , - ); + const { findByRole, queryByRole, rerender } = renderSetup({ + calculationMock: { ...fullTimeHourlyMock, hoursWorkedPerWeek: null }, + }); // Hours Worked is visible and required when Hourly const hoursInput = await findByRole('spinbutton', { @@ -362,11 +318,7 @@ describe('SetupStep', () => { expect(hoursInput).toBeInTheDocument(); // Switch to Salaried — Hours Worked should disappear - rerender( - - - , - ); + rerender(setupTree({ calculationMock: fullTimeSalariedMock })); await waitFor(() => { expect( @@ -381,13 +333,9 @@ describe('SetupStep', () => { }); it('hides Benefits and adapts validation when switching from Full-time to Part-time', async () => { - const { findByRole, queryByRole, rerender } = render( - - - , - ); + const { findByRole, queryByRole, rerender } = renderSetup({ + calculationMock: { ...fullTimeHourlyMock, benefits: null }, + }); // Benefits is visible and required when Full-time const benefitsInput = await findByRole('spinbutton', { @@ -396,11 +344,7 @@ describe('SetupStep', () => { expect(benefitsInput).toBeInTheDocument(); // Switch to Part-time Hourly — Benefits should disappear - rerender( - - - , - ); + rerender(setupTree({ calculationMock: partTimeHourlyMock })); await waitFor(() => { expect( @@ -415,16 +359,12 @@ describe('SetupStep', () => { }); it('renders the Form Type select with both options', async () => { - const { findByRole, getByRole } = render( - - - , - ); + const { findByRole, getByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Detailed, + }, + }); const select = await findByRole('combobox', { name: /Form Type/ }); await waitFor(() => expect(select).toHaveTextContent('Default')); @@ -435,16 +375,12 @@ describe('SetupStep', () => { }); it('shows 403b Contribution Percentage field when formType is Detailed', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Detailed, + }, + }); expect( await findByRole('textbox', { name: '403b Contribution Percentage' }), @@ -452,16 +388,12 @@ describe('SetupStep', () => { }); it('hides 403b Contribution Percentage field when formType is Simple', async () => { - const { findByRole, queryByRole } = render( - - - , - ); + const { findByRole, queryByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Simple, + }, + }); // Wait for the Form Type select to reflect the loaded Simple value const formTypeSelect = await findByRole('combobox', { name: /Form Type/ }); @@ -473,17 +405,13 @@ describe('SetupStep', () => { }); it('fires UpdatePdsGoalCalculation with formType when toggled to Simple', async () => { - const { findByRole, getByRole } = render( - - - , - ); + const { findByRole, getByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Detailed, + }, + onCall: mutationSpy, + }); const select = await findByRole('combobox', { name: /Form Type/ }); await waitFor(() => expect(select).toHaveTextContent('Default')); @@ -501,16 +429,12 @@ describe('SetupStep', () => { }); it('shows the 403b Contribution Percentage field when formType is null (legacy goal)', async () => { - const { findByRole } = render( - - - , - ); + const { findByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: null, + }, + }); expect( await findByRole('textbox', { name: '403b Contribution Percentage' }), diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index a431e6b3b6..cedaa18df4 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -31,6 +31,7 @@ import { import { AutosaveTextField } from '../Shared/Autosave/AutosaveTextField'; import { useSaveField } from '../Shared/Autosave/useSaveField'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { isSimpleFormType } from '../Shared/formType'; import { HoursPerWeekGrid } from './HoursPerWeekGrid/HoursPerWeekGrid'; export const SetupStep: React.FC = () => { @@ -38,18 +39,13 @@ export const SetupStep: React.FC = () => { const theme = useTheme(); const { calculation, hcmUser, setRightPanelContent } = usePdsGoalCalculator(); const { data: userData } = useGetUserQuery(); - const fourOThreeB = hcmUser?.fourOThreeB; - const totalFourOThreeBContributionPercentage = fourOThreeB - ? (fourOThreeB.currentTaxDeferredContributionPercentage ?? 0) + - (fourOThreeB.currentRothContributionPercentage ?? 0) - : null; - const schema = useMemo( () => yup.object({ formType: yup .string() .oneOf(Object.values(DesignationSupportFormType)) + .nullable() .optional(), name: yup.string().required(t('Goal Name is a required field')), status: yup @@ -92,8 +88,7 @@ export const SetupStep: React.FC = () => { const isSalaried = calculation?.salaryOrHourly === DesignationSupportSalaryType.Salaried; const isPartTime = calculation?.status === DesignationSupportStatus.PartTime; - const isSimpleForm = - calculation?.formType === DesignationSupportFormType.Simple; + const isSimpleForm = isSimpleFormType(calculation?.formType); const payRateHelperText = isSalaried ? t('Enter yearly salary') @@ -151,6 +146,9 @@ export const SetupStep: React.FC = () => { schema={schema} select label={t('Form Type')} + helperText={t( + 'Default includes reimbursable expenses and 403b contributions; Simple omits them.', + )} > {t('Default')} @@ -255,7 +253,14 @@ export const SetupStep: React.FC = () => { variant="outlined" label={t('403b Contribution Percentage')} disabled - value={totalFourOThreeBContributionPercentage ?? ''} + value={ + hcmUser?.fourOThreeB + ? (hcmUser.fourOThreeB + .currentTaxDeferredContributionPercentage ?? 0) + + (hcmUser.fourOThreeB.currentRothContributionPercentage ?? + 0) + : '' + } helperText={t( 'Retrieved from Principal. A combined percentage of your current tax deferred and Roth contributions.', )} diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx index 96679a110a..76261d38e6 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx @@ -1,26 +1,103 @@ import React from 'react'; -import { act, renderHook } from '@testing-library/react'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { act, render, renderHook } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PdsGoalCalculatorStepEnum } from '../PdsGoalCalculatorHelper'; import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; import { usePdsGoalCalculator } from './PdsGoalCalculatorContext'; +import { useSteps } from './useSteps'; +import type { PdsGoalCalculatorStep } from './useSteps'; + +jest.mock('./useSteps', () => ({ + __esModule: true, + ...jest.requireActual('./useSteps'), + useSteps: jest.fn(), +})); + +const mockedUseSteps = useSteps as jest.MockedFunction; + +const stub = (step: PdsGoalCalculatorStepEnum): PdsGoalCalculatorStep => ({ + step, + title: step, + icon: , + sections: [], +}); + +const detailedSteps: PdsGoalCalculatorStep[] = [ + stub(PdsGoalCalculatorStepEnum.Setup), + stub(PdsGoalCalculatorStepEnum.ReimbursableExpenses), + stub(PdsGoalCalculatorStepEnum.SupportItem), + stub(PdsGoalCalculatorStepEnum.SummaryReport), +]; + +const simpleSteps: PdsGoalCalculatorStep[] = [ + stub(PdsGoalCalculatorStepEnum.Setup), + stub(PdsGoalCalculatorStepEnum.SupportItem), + stub(PdsGoalCalculatorStepEnum.SummaryReport), +]; + +const minimalSteps: PdsGoalCalculatorStep[] = [ + stub(PdsGoalCalculatorStepEnum.Setup), + stub(PdsGoalCalculatorStepEnum.SummaryReport), +]; + +const renderUsePdsGoalCalculator = () => + renderHook(() => usePdsGoalCalculator(), { + wrapper: ({ children }) => ( + {children} + ), + }); + +const StepProbe: React.FC = () => { + const { currentStep, stepIndex, steps, handleStepChange } = + usePdsGoalCalculator(); + return ( +
+
{currentStep?.step ?? 'none'}
+
{stepIndex}
+
{steps.length}
+ + + +
+ ); +}; + +beforeEach(() => { + mockedUseSteps.mockReturnValue(detailedSteps); +}); describe('PdsGoalCalculatorContext', () => { it('provides steps and current step', () => { - const { result } = renderHook(() => usePdsGoalCalculator(), { - wrapper: ({ children }) => ( - {children} - ), - }); + const { result } = renderUsePdsGoalCalculator(); expect(result.current.steps).toHaveLength(4); expect(result.current.currentStep.step).toBe('setup'); }); it('handleContinue advances to the next step', () => { - const { result } = renderHook(() => usePdsGoalCalculator(), { - wrapper: ({ children }) => ( - {children} - ), - }); + const { result } = renderUsePdsGoalCalculator(); expect(result.current.stepIndex).toBe(0); @@ -29,11 +106,7 @@ describe('PdsGoalCalculatorContext', () => { }); it('handlePreviousStep goes back to the previous step', () => { - const { result } = renderHook(() => usePdsGoalCalculator(), { - wrapper: ({ children }) => ( - {children} - ), - }); + const { result } = renderUsePdsGoalCalculator(); act(() => result.current.handleContinue()); expect(result.current.stepIndex).toBe(1); @@ -43,15 +116,74 @@ describe('PdsGoalCalculatorContext', () => { }); it('handlePreviousStep does nothing on the first step', () => { - const { result } = renderHook(() => usePdsGoalCalculator(), { - wrapper: ({ children }) => ( - {children} - ), - }); + const { result } = renderUsePdsGoalCalculator(); expect(result.current.stepIndex).toBe(0); act(() => result.current.handlePreviousStep()); expect(result.current.stepIndex).toBe(0); }); + + describe('preserves the user step when the steps array changes', () => { + it.each([ + { + name: 'keeps the user on SummaryReport when steps shrink Detailed → Simple', + initialSteps: detailedSteps, + click: 'go to summary', + newSteps: simpleSteps, + expectedStep: PdsGoalCalculatorStepEnum.SummaryReport, + expectedIndex: '2', + }, + { + name: 'falls back to Setup when current step does not exist in new form', + initialSteps: detailedSteps, + click: 'go to reimbursable', + newSteps: simpleSteps, + expectedStep: PdsGoalCalculatorStepEnum.Setup, + expectedIndex: '0', + }, + { + name: 'reconciles to the first step when an active step past index 1 disappears', + initialSteps: detailedSteps, + click: 'go to support item', + newSteps: minimalSteps, + expectedStep: PdsGoalCalculatorStepEnum.Setup, + expectedIndex: '0', + }, + { + name: 'keeps the user on SupportItem when steps grow Simple → Detailed', + initialSteps: simpleSteps, + click: 'go to support item', + newSteps: detailedSteps, + expectedStep: PdsGoalCalculatorStepEnum.SupportItem, + expectedIndex: '2', + }, + ])( + '$name', + async ({ initialSteps, click, newSteps, expectedStep, expectedIndex }) => { + mockedUseSteps.mockReturnValue(initialSteps); + const { findByTestId, getByRole, rerender } = render( + + + , + ); + + userEvent.click(getByRole('button', { name: click })); + + mockedUseSteps.mockReturnValue(newSteps); + rerender( + + + , + ); + + expect(await findByTestId('current-step')).toHaveTextContent( + expectedStep, + ); + expect(await findByTestId('step-index')).toHaveTextContent( + expectedIndex, + ); + }, + ); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index 876cdc7236..9135ff74e9 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -2,7 +2,6 @@ import { useRouter } from 'next/router'; import React, { createContext, useCallback, - useEffect, useMemo, useState, } from 'react'; @@ -85,27 +84,29 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const summaryData = usePdsSummaryData(calculation, hcmUser); const steps = useSteps(calculation?.formType); - const [stepIndex, setStepIndex] = useState(0); + // Track the user's place by step enum, not numeric index, so that a change + // to the steps array (e.g. formType switch Detailed → Simple, dropping the + // ReimbursableExpenses step) preserves their step when it still exists and + // falls back to Setup only when it doesn't. + const [activeStep, setActiveStep] = useState( + PdsGoalCalculatorStepEnum.Setup, + ); const [rightPanelContent, setRightPanelContent] = useState(null); const [isDrawerOpen, setIsDrawerOpen] = useState(true); const { trackMutation, isMutating } = useTrackMutation(); - // Clamp stepIndex to valid range when the steps array shrinks (e.g. formType - // switches from Detailed to Simple, dropping the ReimbursableExpenses step). - useEffect(() => { - if (stepIndex >= steps.length) { - setStepIndex(0); - } - }, [steps.length, stepIndex]); + const stepIndex = useMemo(() => { + const idx = steps.findIndex((s) => s.step === activeStep); + return idx === -1 ? 0 : idx; + }, [steps, activeStep]); const currentStep = steps[stepIndex]; const handleStepChange = useCallback( (newStep: PdsGoalCalculatorStepEnum) => { - const newIndex = steps.findIndex((step) => step.step === newStep); - if (newIndex !== -1) { - setStepIndex(newIndex); + if (steps.some((step) => step.step === newStep)) { + setActiveStep(newStep); } else { enqueueSnackbar(t('The selected step does not exist.'), { variant: 'error', @@ -117,15 +118,15 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const handleContinue = useCallback(() => { if (stepIndex < steps.length - 1) { - setStepIndex(stepIndex + 1); + setActiveStep(steps[stepIndex + 1].step); } }, [stepIndex, steps]); const handlePreviousStep = useCallback(() => { if (stepIndex > 0) { - setStepIndex(stepIndex - 1); + setActiveStep(steps[stepIndex - 1].step); } - }, [stepIndex]); + }, [stepIndex, steps]); const closeRightPanel = useCallback(() => { setRightPanelContent(null); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/formType.ts b/src/components/HrTools/PdsGoalCalculator/Shared/formType.ts new file mode 100644 index 0000000000..a5e5e45331 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Shared/formType.ts @@ -0,0 +1,10 @@ +import { DesignationSupportFormType } from 'src/graphql/types.generated'; + +export const isSimpleFormType = ( + formType: DesignationSupportFormType | null | undefined, +): boolean => formType === DesignationSupportFormType.Simple; + +export const isDesignationSupportFormType = ( + value: string, +): value is DesignationSupportFormType => + (Object.values(DesignationSupportFormType) as string[]).includes(value); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx index 23ac88b2cb..5a7ce594e9 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx @@ -6,6 +6,7 @@ import SettingsIcon from '@mui/icons-material/Settings'; import { useTranslation } from 'react-i18next'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { PdsGoalCalculatorStepEnum } from '../PdsGoalCalculatorHelper'; +import { isSimpleFormType } from './formType'; export interface PdsGoalCalculatorSection { title: string; @@ -25,7 +26,7 @@ export const useSteps = ( const { t } = useTranslation(); return useMemo(() => { - const isSimple = formType === DesignationSupportFormType.Simple; + const isSimple = isSimpleFormType(formType); const allSteps: PdsGoalCalculatorStep[] = [ { diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx index 4165cc8e52..92d418d8cf 100644 --- a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx @@ -211,6 +211,32 @@ describe('PdsSummaryTable', () => { expect(getByRole('gridcell', { name: 'Total Goal' })).toBeInTheDocument(); }); + it('renders Work Comp without Benefits or detailed rows when formType is Simple and status is PartTime', async () => { + const { findByRole, getByRole, queryByRole } = render( + + + , + ); + + await findByRole('gridcell', { name: 'Work Comp' }); + + expect(getByRole('gridcell', { name: 'Work Comp' })).toBeInTheDocument(); + expect( + queryByRole('gridcell', { name: 'Benefits' }), + ).not.toBeInTheDocument(); + expect( + queryByRole('gridcell', { name: 'Reimbursable Expenses' }), + ).not.toBeInTheDocument(); + expect( + queryByRole('gridcell', { name: '403b Contributions' }), + ).not.toBeInTheDocument(); + }); + it('renders Reimbursable Expenses and 403b Contributions rows when formType is Detailed', async () => { const { findByRole, getByRole } = render( = ({ const isFullTime = calculation.status === DesignationSupportStatus.FullTime; const isPartTime = calculation.status === DesignationSupportStatus.PartTime; - const isSimple = - calculation.formType === DesignationSupportFormType.Simple; + const isSimple = isSimpleFormType(calculation.formType); const rows: PdsSummaryRow[] = [ // Salary section @@ -126,7 +123,7 @@ export const PdsSummaryTable: React.FC = ({ ...(isPartTime ? [ { - line: '2C', + line: isSimple ? '2A' : '2C', category: t('Work Comp'), amount: otherTotals.workComp, }, @@ -135,7 +132,7 @@ export const PdsSummaryTable: React.FC = ({ ...(isFullTime ? [ { - line: '2C', + line: isSimple ? '2A' : '2C', category: t('Benefits'), amount: otherTotals.benefits, }, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts index 7b992ca431..14ef635060 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts @@ -4,13 +4,19 @@ import { GoalMiscConstants, } from 'src/hooks/useGoalCalculatorConstants'; import { HcmUserQuery } from '../Shared/HCM.generated'; -import { OtherExpensesFields, calculateOtherExpenses } from './OtherExpenses'; +import { isSimpleFormType } from '../Shared/formType'; +import { + OtherExpensesConstants, + OtherExpensesFields, + calculateOtherExpenses, +} from './OtherExpenses'; import { ReimbursableCalculationFields, calculateReimbursableTotals, } from './reimbursableExpenses'; import { SalaryCalculationFields, + SalaryTotals, calculateSalaryTotals, } from './salaryCalculation'; @@ -75,30 +81,45 @@ export const buildPdsGoalConstants = ( }; }; +export const buildOtherExpensesConstants = ( + formType: DesignationSupportFormType | null | undefined, + constants: PdsGoalTotalConstants, + salaryTotals: SalaryTotals, + reimbursableTotal: number, +): OtherExpensesConstants => { + const isSimple = isSimpleFormType(formType); + return { + reimbursableTotal: isSimple ? 0 : reimbursableTotal, + salarySubtotal: salaryTotals.subtotal, + fourOThreeBPercentage: isSimple ? 0 : constants.fourOThreeBPercentage, + grossMonthlyPay: salaryTotals.grossMonthlyPay, + workCompPercentage: constants.workCompPercentage, + attritionRate: constants.attritionRate, + creditCardFeeRate: constants.creditCardFeeRate, + adminRate: constants.adminRate, + }; +}; + export const calculatePdsGoalTotal = ( calculation: PdsGoalTotalFields, constants: PdsGoalTotalConstants, ): number => { - const isSimple = - calculation.formType === DesignationSupportFormType.Simple; - const salaryTotals = calculateSalaryTotals(calculation, { geographicMultiplier: constants.geographicMultiplier, employerFicaRate: constants.employerFicaRate, }); - const reimbursableTotals = calculateReimbursableTotals(calculation); + const reimbursableTotal = calculateReimbursableTotals(calculation).total; - const otherExpenses = calculateOtherExpenses(calculation, { - reimbursableTotal: isSimple ? 0 : reimbursableTotals.total, - salarySubtotal: salaryTotals.subtotal, - fourOThreeBPercentage: isSimple ? 0 : constants.fourOThreeBPercentage, - grossMonthlyPay: salaryTotals.grossMonthlyPay, - workCompPercentage: constants.workCompPercentage, - attritionRate: constants.attritionRate, - creditCardFeeRate: constants.creditCardFeeRate, - adminRate: constants.adminRate, - }); + const otherExpenses = calculateOtherExpenses( + calculation, + buildOtherExpensesConstants( + calculation.formType, + constants, + salaryTotals, + reimbursableTotal, + ), + ); return otherExpenses.assessment; }; diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts index 9870a9c96c..e3f5ca03af 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { PdsGoalCalculationFieldsFragment } from '../GoalsList/PdsGoalCalculations.generated'; import { HcmUserQuery } from '../Shared/HCM.generated'; @@ -8,6 +7,10 @@ import { OtherExpensesTotals, calculateOtherExpenses, } from './OtherExpenses'; +import { + buildOtherExpensesConstants, + buildPdsGoalConstants, +} from './calculatePdsGoalTotal'; import { ReimbursableTotals, calculateReimbursableTotals, @@ -21,8 +24,21 @@ import { export interface PdsSummaryData { salaryTotals: SalaryTotals; salaryConstants: SalaryConstants; + /** + * Reimbursable totals computed from the saved calculation data, preserved + * across formType changes so a user switching Detailed → Simple → Detailed + * doesn't lose what they entered. NOT the effective value used in downstream + * Other Expenses math — for that, use `otherConstants.reimbursableTotal`, + * which is zeroed when `formType === Simple`. + */ reimbursableTotals: ReimbursableTotals; otherTotals: OtherExpensesTotals; + /** + * Inputs fed into `calculateOtherExpenses`. `reimbursableTotal` and + * `fourOThreeBPercentage` here are zeroed when `formType === Simple` + * (Simple goals exclude reimbursables and 403b from the goal calc), so they + * differ from `reimbursableTotals.total` and the user's HCM 403b percentages. + */ otherConstants: OtherExpensesConstants; overallTotal: number; geographicMultiplier: number; @@ -40,53 +56,29 @@ export const usePdsSummaryData = ( return null; } - const additionalRates = goalMiscConstants.ADDITIONAL_RATES; - const employerFicaRate = additionalRates?.EMPLOYER_FICA_RATE?.fee; - const workCompPercentage = - additionalRates?.PART_TIME_WORK_COMPENSATION?.fee; - const attritionRate = goalMiscConstants.RATES?.ATTRITION_RATE?.fee; - const creditCardFeeRate = additionalRates?.CREDIT_CARD_FEE_RATE?.fee; - const adminRate = goalMiscConstants.RATES?.ADMIN_RATE?.fee; - - if ( - employerFicaRate === undefined || - workCompPercentage === undefined || - attritionRate === undefined || - creditCardFeeRate === undefined || - adminRate === undefined - ) { + const constants = buildPdsGoalConstants( + goalMiscConstants, + goalGeographicConstantMap, + calculation.geographicLocation, + hcmUser?.fourOThreeB, + ); + if (!constants) { return null; } - const geographicMultiplier = - goalGeographicConstantMap.get(calculation.geographicLocation ?? '') ?? 0; - const salaryConstants: SalaryConstants = { - geographicMultiplier, - employerFicaRate, + geographicMultiplier: constants.geographicMultiplier, + employerFicaRate: constants.employerFicaRate, }; const salaryTotals = calculateSalaryTotals(calculation, salaryConstants); const reimbursableTotals = calculateReimbursableTotals(calculation); - const taxDeferredPct = - (hcmUser?.fourOThreeB?.currentTaxDeferredContributionPercentage ?? 0) / - 100; - const rothPct = - (hcmUser?.fourOThreeB?.currentRothContributionPercentage ?? 0) / 100; - - const isSimple = - calculation.formType === DesignationSupportFormType.Simple; - - const otherConstants: OtherExpensesConstants = { - reimbursableTotal: isSimple ? 0 : reimbursableTotals.total, - salarySubtotal: salaryTotals.subtotal, - fourOThreeBPercentage: isSimple ? 0 : taxDeferredPct + rothPct, - grossMonthlyPay: salaryTotals.grossMonthlyPay, - workCompPercentage, - attritionRate, - creditCardFeeRate, - adminRate, - }; + const otherConstants = buildOtherExpensesConstants( + calculation.formType, + constants, + salaryTotals, + reimbursableTotals.total, + ); const otherTotals = calculateOtherExpenses(calculation, otherConstants); const overallTotal = @@ -102,7 +94,7 @@ export const usePdsSummaryData = ( otherTotals, otherConstants, overallTotal, - geographicMultiplier, + geographicMultiplier: constants.geographicMultiplier, }; }, [calculation, hcmUser, goalMiscConstants, goalGeographicConstantMap]); }; diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx index d067cacff7..347c1d54f8 100644 --- a/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx +++ b/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx @@ -97,4 +97,18 @@ describe('GoalCard', () => { expect(mutationSpy).not.toHaveBeenCalled(); }); + + it('renders the badge when provided', () => { + const { getByText } = renderCard({ + badge: Default, + }); + + expect(getByText('Default')).toBeInTheDocument(); + }); + + it('renders without a badge when none is provided', () => { + const { queryByText } = renderCard(); + + expect(queryByText('Default')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.tsx index f93becd946..65b0d42b7d 100644 --- a/src/components/Reports/Shared/GoalCard/GoalCard.tsx +++ b/src/components/Reports/Shared/GoalCard/GoalCard.tsx @@ -107,7 +107,9 @@ export const GoalCard: React.FC = ({ /> - + Date: Fri, 8 May 2026 13:09:40 -0400 Subject: [PATCH 15/23] More PR fixes --- .../GoalCard/PdsGoalCard.test.tsx | 17 -------- .../GoalsList/PdsGoalsList.test.tsx | 35 ++++------------- .../GoalsList/PdsGoalsList.tsx | 39 +++++++------------ .../Shared/useSteps.test.tsx | 6 +-- .../calculations/usePdsSummaryData.ts | 13 ------- 5 files changed, 24 insertions(+), 86 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx index 4dfc647efe..1f508b8339 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx @@ -66,21 +66,4 @@ describe('PdsGoalCard', () => { await findByText(name); expect(queryByText(expectedBadge)).toBeInTheDocument(); }); - - it('renders no form-type badge when formType is null (legacy goal)', async () => { - const { findByText, queryByText } = render( - - - , - ); - - await findByText('Legacy Goal'); - expect(queryByText('Default')).not.toBeInTheDocument(); - expect(queryByText('Simple')).not.toBeInTheDocument(); - }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx index 147fd8224b..9e03e2a41e 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx @@ -203,8 +203,8 @@ describe('PdsGoalsList', () => { }); }); - it('shows error snackbar and skips mutation when reimbursement constants are missing for a Default goal', async () => { - const { findByRole, findByText } = render( + it('skips mutation when reimbursement constants are missing for a Default goal', async () => { + const { findByRole } = render( { await openCreateGoalDialog(findByRole); await submitFormType(findByRole, 'Default'); - expect( - await findByText('Failed to create goal. Please try again.'), - ).toBeInTheDocument(); - expect(mutationSpy).not.toHaveGraphqlOperation('CreatePdsGoalCalculation'); - }); - - it('shows error snackbar when create mutation fails', async () => { - const { findByRole, findByText } = render( - { - throw new Error('Server Error'); - }, - }} - > - - , - ); - - await openCreateGoalDialog(findByRole); - await submitFormType(findByRole, 'Simple'); - - expect( - await findByText('Failed to create goal. Please try again.'), - ).toBeInTheDocument(); + await waitFor(() => { + expect(mutationSpy).not.toHaveGraphqlOperation( + 'CreatePdsGoalCalculation', + ); + }); }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx index 0d18f7e0d8..0f49f7525d 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx @@ -1,7 +1,6 @@ import { useRouter } from 'next/router'; import React, { useState } from 'react'; import { Box, Button, CircularProgress, Stack, styled } from '@mui/material'; -import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { useGetUserQuery } from 'src/components/User/GetUser.generated'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; @@ -28,7 +27,6 @@ const PlaceholderImage = styled('img')(({ theme }) => ({ export const PdsGoalsList: React.FC = () => { const { t } = useTranslation(); - const { enqueueSnackbar } = useSnackbar(); const router = useRouter(); const accountListId = useAccountListId() ?? ''; @@ -62,9 +60,6 @@ export const PdsGoalsList: React.FC = () => { const phoneFee = reimbursements?.PHONE?.fee; const internetFee = reimbursements?.INTERNET?.fee; if (phoneFee === undefined || internetFee === undefined) { - enqueueSnackbar(t('Failed to create goal. Please try again.'), { - variant: 'error', - }); return; } detailedDefaults = { @@ -72,28 +67,22 @@ export const PdsGoalsList: React.FC = () => { ministryInternet: internetFee, }; } - try { - const { data } = await createPdsGoalCalculation({ - variables: { - attributes: { - formType, - ...(detailedDefaults ?? {}), - }, + const { data } = await createPdsGoalCalculation({ + variables: { + attributes: { + formType, + ...(detailedDefaults ?? {}), }, - }); - const calculation = - data?.createDesignationSupportCalculation?.designationSupportCalculation; + }, + }); + const calculation = + data?.createDesignationSupportCalculation?.designationSupportCalculation; - if (calculation) { - setDialogOpen(false); - router.push( - `/accountLists/${accountListId}/hrTools/pdsGoalCalculator/${calculation.id}`, - ); - } - } catch (error) { - enqueueSnackbar(t('Failed to create goal. Please try again.'), { - variant: 'error', - }); + if (calculation) { + setDialogOpen(false); + router.push( + `/accountLists/${accountListId}/hrTools/pdsGoalCalculator/${calculation.id}`, + ); } }; diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx index 96a182237d..619374a698 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx @@ -8,7 +8,7 @@ describe('useSteps', () => { const { result } = renderHook(() => useSteps(DesignationSupportFormType.Detailed), ); - expect(result.current.map((s) => s.step)).toEqual([ + expect(result.current.map((step) => step.step)).toEqual([ PdsGoalCalculatorStepEnum.Setup, PdsGoalCalculatorStepEnum.ReimbursableExpenses, PdsGoalCalculatorStepEnum.SupportItem, @@ -20,7 +20,7 @@ describe('useSteps', () => { const { result } = renderHook(() => useSteps(DesignationSupportFormType.Simple), ); - expect(result.current.map((s) => s.step)).toEqual([ + expect(result.current.map((step) => step.step)).toEqual([ PdsGoalCalculatorStepEnum.Setup, PdsGoalCalculatorStepEnum.SupportItem, PdsGoalCalculatorStepEnum.SummaryReport, @@ -29,7 +29,7 @@ describe('useSteps', () => { it('returns four steps (Detailed behavior) when formType is null/undefined', () => { const { result } = renderHook(() => useSteps(null)); - expect(result.current.map((s) => s.step)).toEqual([ + expect(result.current.map((step) => step.step)).toEqual([ PdsGoalCalculatorStepEnum.Setup, PdsGoalCalculatorStepEnum.ReimbursableExpenses, PdsGoalCalculatorStepEnum.SupportItem, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts index e3f5ca03af..a67c7b8f31 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts @@ -24,21 +24,8 @@ import { export interface PdsSummaryData { salaryTotals: SalaryTotals; salaryConstants: SalaryConstants; - /** - * Reimbursable totals computed from the saved calculation data, preserved - * across formType changes so a user switching Detailed → Simple → Detailed - * doesn't lose what they entered. NOT the effective value used in downstream - * Other Expenses math — for that, use `otherConstants.reimbursableTotal`, - * which is zeroed when `formType === Simple`. - */ reimbursableTotals: ReimbursableTotals; otherTotals: OtherExpensesTotals; - /** - * Inputs fed into `calculateOtherExpenses`. `reimbursableTotal` and - * `fourOThreeBPercentage` here are zeroed when `formType === Simple` - * (Simple goals exclude reimbursables and 403b from the goal calc), so they - * differ from `reimbursableTotals.total` and the user's HCM 403b percentages. - */ otherConstants: OtherExpensesConstants; overallTotal: number; geographicMultiplier: number; From ea5386d3506d20c56611cd369b554993255ed8c9 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Fri, 8 May 2026 13:17:37 -0400 Subject: [PATCH 16/23] Fix chip styling on goalCard --- src/components/Reports/Shared/GoalCard/GoalCard.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.tsx index 65b0d42b7d..3523a68c31 100644 --- a/src/components/Reports/Shared/GoalCard/GoalCard.tsx +++ b/src/components/Reports/Shared/GoalCard/GoalCard.tsx @@ -107,15 +107,16 @@ export const GoalCard: React.FC = ({ /> - + {displayName} From 2ebf482a6d10eeb1ff8c904e891de8641d919574 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Fri, 8 May 2026 13:23:59 -0400 Subject: [PATCH 17/23] Hide reimbursable expenses and 403b references --- .../SupportItem/OtherSection.test.tsx | 21 +++++++ .../SupportItem/otherBreakdown.test.tsx | 46 ++++++++++++++- .../SupportItem/otherBreakdown.tsx | 57 +++++++++++-------- .../calculations/OtherExpenses.ts | 6 +- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.test.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.test.tsx index 16b34fe7f6..196d1c579d 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { + DesignationSupportFormType, DesignationSupportSalaryType, DesignationSupportStatus, } from 'src/graphql/types.generated'; @@ -163,6 +164,26 @@ describe('OtherSection', () => { }); }); + describe('simple form type', () => { + it('hides reimbursable expenses and 403b contributions rows', async () => { + const simpleMock: PdsGoalCalculationMock = { + ...fullTimeMock, + formType: DesignationSupportFormType.Simple, + }; + + const { findByTestId, queryByTestId } = render( + , + ); + + await findByTestId('other-subtotal'); + + expect( + queryByTestId('other-reimbursable-expenses'), + ).not.toBeInTheDocument(); + expect(queryByTestId('other-403b-contributions')).not.toBeInTheDocument(); + }); + }); + it('renders nothing when constants are missing', async () => { const { container, queryByRole } = render( { expect(row.testId).toBeDefined(); }); }); + + it('omits reimbursable expenses and 403b contributions when formType is Simple', () => { + const simpleCalculation: OtherExpensesFields = { + ...fullTimeCalculation, + formType: DesignationSupportFormType.Simple, + }; + + const rows = buildOtherBreakdownRows( + simpleCalculation, + constants, + 'en-US', + i18n.t, + ); + + expect(rows.map((row) => row.id)).toEqual([ + 'benefits', + 'subtotal', + 'attrition', + 'credit-card-fees', + 'assessment', + ]); + }); + + it('uses a subtotal formula without reimbursable/403b in Simple form', () => { + const simpleCalculation: OtherExpensesFields = { + ...fullTimeCalculation, + formType: DesignationSupportFormType.Simple, + }; + + const rows = buildOtherBreakdownRows( + simpleCalculation, + constants, + 'en-US', + i18n.t, + ); + + const subtotal = rows.find((row) => row.id === 'subtotal'); + expect(subtotal?.formula).toBe( + 'Gross Monthly Pay Subtotal + Work Comp + Benefits', + ); + }); }); describe('buildOtherBreakdownColumns', () => { diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx index 9fdf037958..5be30100b4 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx @@ -5,6 +5,7 @@ import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { TFunction } from 'i18next'; import { DesignationSupportStatus } from 'src/graphql/types.generated'; import { currencyFormat, percentageFormat } from 'src/lib/intlFormat'; +import { isSimpleFormType } from '../Shared/formType'; import { OtherExpensesConstants, OtherExpensesFields, @@ -40,28 +41,34 @@ export const buildOtherBreakdownRows = ( constants, ); + const isSimple = isSimpleFormType(calculation.formType); + const rows: OtherBreakdownRow[] = [ - { - id: 'reimbursable-expenses', - category: t('Reimbursable Expenses'), - formula: t( - 'The greater of $300/month or the amount calculated in the Reimbursable Expenses step', - ), - amount: totals.reimbursableExpenses, - testId: 'other-reimbursable-expenses', - tooltip: t( - 'To change this amount, update the Reimbursable Expenses step', - ), - }, - { - id: '403b-contributions', - category: t('403b Contributions if Applicable'), - formula: t( - 'Gross Monthly Pay from Salary section × 403b Contribution Percentage from Setup section', - ), - amount: totals.fourOThreeBContributions, - testId: 'other-403b-contributions', - }, + ...(isSimple + ? [] + : [ + { + id: 'reimbursable-expenses', + category: t('Reimbursable Expenses'), + formula: t( + 'The greater of $300/month or the amount calculated in the Reimbursable Expenses step', + ), + amount: totals.reimbursableExpenses, + testId: 'other-reimbursable-expenses', + tooltip: t( + 'To change this amount, update the Reimbursable Expenses step', + ), + }, + { + id: '403b-contributions', + category: t('403b Contributions if Applicable'), + formula: t( + 'Gross Monthly Pay from Salary section × 403b Contribution Percentage from Setup section', + ), + amount: totals.fourOThreeBContributions, + testId: 'other-403b-contributions', + }, + ]), ...(calculation.status === DesignationSupportStatus.PartTime ? [ { @@ -85,9 +92,11 @@ export const buildOtherBreakdownRows = ( { id: 'subtotal', category: t('Subtotal'), - formula: t( - 'Gross Monthly Pay Subtotal + Reimbursable Expenses + 403b Contributions + Work Comp + Benefits', - ), + formula: isSimple + ? t('Gross Monthly Pay Subtotal + Work Comp + Benefits') + : t( + 'Gross Monthly Pay Subtotal + Reimbursable Expenses + 403b Contributions + Work Comp + Benefits', + ), amount: totals.subtotal, testId: 'other-subtotal', bold: true, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts b/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts index 63debadd4c..c9240ae34b 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts @@ -1,8 +1,12 @@ -import { DesignationSupportStatus } from 'src/graphql/types.generated'; +import { + DesignationSupportFormType, + DesignationSupportStatus, +} from 'src/graphql/types.generated'; export interface OtherExpensesFields { status?: DesignationSupportStatus | null; benefits?: number | null; + formType?: DesignationSupportFormType | null; } export interface OtherExpensesConstants { From ee14bb12838ad29db938ee7176aa0223d2709de6 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Fri, 8 May 2026 13:30:03 -0400 Subject: [PATCH 18/23] Fix premature validation --- .../Autosave/AutosaveTextField.test.tsx | 56 ++++++++++++++++++- .../Shared/Autosave/AutosaveTextField.tsx | 20 ++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx index 4ccb4a1215..8101abfd3b 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { MenuItem } from '@mui/material'; -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as yup from 'yup'; import { @@ -202,6 +202,60 @@ describe('AutosaveTextField', () => { ); }); + it('hides validation error for an empty untouched field', async () => { + const requiredSchema = yup.object({ + name: yup.string().required('Goal Name is required'), + }); + const { findByRole } = render( + + + , + ); + + const input = await findByRole('textbox', { name: 'Goal Name' }); + await waitFor(() => expect(input).toHaveValue('')); + + expect(input).toHaveAccessibleDescription('Enter the goal name'); + expect(input).not.toHaveAttribute('aria-invalid', 'true'); + }); + + it('shows validation error after the field is touched', async () => { + const requiredSchema = yup.object({ + name: yup.string().required('Goal Name is required'), + }); + const { findByRole } = render( + + + , + ); + + const input = await findByRole('textbox', { name: 'Goal Name' }); + await waitFor(() => expect(input).toHaveValue('')); + + fireEvent.focus(input); + fireEvent.blur(input); + + await waitFor(() => + expect(input).toHaveAccessibleDescription('Goal Name is required'), + ); + }); + describe('select input', () => { it('saves on change', async () => { const { getByRole } = render( diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.tsx index d8a7b5b9b4..b392f3baf8 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { TextField, TextFieldProps } from '@mui/material'; import * as yup from 'yup'; import { DesignationSupportCalculationUpdateInput } from 'src/graphql/types.generated'; @@ -23,6 +23,11 @@ export const AutosaveTextField: React.FC = ({ schema, saveOnChange: props.select, }); + const [touched, setTouched] = useState(false); + + const showValidationError = Boolean( + fieldProps.error && (touched || fieldProps.value !== ''), + ); return ( = ({ variant="outlined" {...fieldProps} {...props} + onChange={(event) => { + setTouched(true); + fieldProps.onChange?.(event as React.ChangeEvent); + }} + onBlur={() => { + setTouched(true); + fieldProps.onBlur?.(); + }} disabled={fieldProps.disabled || props.disabled} - helperText={fieldProps.helperText ?? props.helperText} + error={showValidationError || undefined} + helperText={ + showValidationError ? fieldProps.helperText : props.helperText + } /> ); }; From 39d8493956106694a44e4d62836494bb25ccb882 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Fri, 8 May 2026 13:57:45 -0400 Subject: [PATCH 19/23] Replace useState with formik --- .../GoalsList/CreateGoalDialog.test.tsx | 16 +- .../GoalsList/CreateGoalDialog.tsx | 155 ++++++++++-------- .../GoalsList/PdsGoalsList.tsx | 4 +- .../Shared/PdsGoalCalculatorContext.test.tsx | 86 ++++++++-- .../Shared/PdsGoalCalculatorContext.tsx | 31 +++- .../PdsGoalCalculator/Shared/formType.ts | 5 - .../PdsGoalCalculator/Shared/useSteps.tsx | 76 ++++----- 7 files changed, 238 insertions(+), 135 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx index b6f20645cc..56f350e8ef 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx @@ -13,7 +13,6 @@ const renderDialog = ( open: true, onClose: jest.fn(), onCreate: jest.fn().mockResolvedValue(undefined), - creating: false, }; const utils = render( @@ -78,10 +77,17 @@ describe('CreateGoalDialog', () => { expect(onClose).toHaveBeenCalled(); }); - it('disables actions while creating is true', () => { - const { getByRole } = renderDialog({ creating: true }); - expect(getByRole('button', { name: 'Create' })).toBeDisabled(); - expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); + it('disables Create but keeps Cancel enabled while submitting', async () => { + const onCreate = jest.fn().mockReturnValue(new Promise(() => {})); + const { getByRole } = renderDialog({ onCreate }); + + userEvent.click(getByRole('radio', { name: /Simple/ })); + userEvent.click(getByRole('button', { name: 'Create' })); + + await waitFor(() => + expect(getByRole('button', { name: 'Create' })).toBeDisabled(), + ); + expect(getByRole('button', { name: 'Cancel' })).toBeEnabled(); }); it('does not render when open is false', () => { diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx index 882fa7037b..1ff8c75208 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Button, CircularProgress, @@ -14,39 +14,34 @@ import { Typography, } from '@mui/material'; import { visuallyHidden } from '@mui/utils'; +import { Formik } from 'formik'; import { useTranslation } from 'react-i18next'; +import * as yup from 'yup'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; -import { isDesignationSupportFormType } from '../Shared/formType'; export interface CreateGoalDialogProps { open: boolean; onClose: () => void; onCreate: (formType: DesignationSupportFormType) => Promise; - creating: boolean; } +interface FormValues { + formType: DesignationSupportFormType | ''; +} + +const schema = yup.object({ + formType: yup + .string() + .oneOf(Object.values(DesignationSupportFormType)) + .required(), +}); + export const CreateGoalDialog: React.FC = ({ open, onClose, onCreate, - creating, }) => { const { t } = useTranslation(); - const [selected, setSelected] = useState( - null, - ); - - useEffect(() => { - if (open) { - setSelected(null); - } - }, [open]); - - const handleCreate = async () => { - if (selected) { - await onCreate(selected); - } - }; const formTypeOptions: Array<{ value: DesignationSupportFormType; @@ -80,54 +75,82 @@ export const CreateGoalDialog: React.FC = ({ {t('Create a New Goal')} - - - {t('Select a form type')} - { - if (isDesignationSupportFormType(value)) { - setSelected(value); - } - }} - > - {formTypeOptions.map(({ value, title, description }, index) => ( - } - label={ - <> - {title} - - {description} - - - } - sx={{ - alignItems: 'flex-start', - mb: index < formTypeOptions.length - 1 ? 2 : 0, - }} - /> - ))} - - - - - - - + }} + > + {({ values, isSubmitting, handleChange, handleSubmit }) => ( +
+ + + + {t('Select a form type')} + + + {formTypeOptions.map( + ({ value, title, description }, index) => ( + + + } + label={ + + {title} + + } + sx={{ alignItems: 'flex-start' }} + /> + + {description} + + + ), + )} + + + + + + + +
+ )} + ); }; diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx index 0f49f7525d..3705dda266 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx @@ -40,8 +40,7 @@ export const PdsGoalsList: React.FC = () => { error, pageInfo: data?.designationSupportCalculations.pageInfo, }); - const [createPdsGoalCalculation, { loading: creating }] = - useCreatePdsGoalCalculationMutation(); + const [createPdsGoalCalculation] = useCreatePdsGoalCalculationMutation(); const { goalMiscConstants, loading: constantsLoading } = useGoalCalculatorConstants(); @@ -103,7 +102,6 @@ export const PdsGoalsList: React.FC = () => { open={dialogOpen} onClose={() => setDialogOpen(false)} onCreate={handleCreateGoal} - creating={creating} /> {loading ? ( diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx index 76261d38e6..c37bdc84b5 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx @@ -6,7 +6,10 @@ import { PdsGoalCalculatorStepEnum } from '../PdsGoalCalculatorHelper'; import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; import { usePdsGoalCalculator } from './PdsGoalCalculatorContext'; import { useSteps } from './useSteps'; -import type { PdsGoalCalculatorStep } from './useSteps'; +import type { + PdsGoalCalculatorStep, + PdsGoalCalculatorSteps, +} from './useSteps'; jest.mock('./useSteps', () => ({ __esModule: true, @@ -23,20 +26,20 @@ const stub = (step: PdsGoalCalculatorStepEnum): PdsGoalCalculatorStep => ({ sections: [], }); -const detailedSteps: PdsGoalCalculatorStep[] = [ +const detailedSteps: PdsGoalCalculatorSteps = [ stub(PdsGoalCalculatorStepEnum.Setup), stub(PdsGoalCalculatorStepEnum.ReimbursableExpenses), stub(PdsGoalCalculatorStepEnum.SupportItem), stub(PdsGoalCalculatorStepEnum.SummaryReport), ]; -const simpleSteps: PdsGoalCalculatorStep[] = [ +const simpleSteps: PdsGoalCalculatorSteps = [ stub(PdsGoalCalculatorStepEnum.Setup), stub(PdsGoalCalculatorStepEnum.SupportItem), stub(PdsGoalCalculatorStepEnum.SummaryReport), ]; -const minimalSteps: PdsGoalCalculatorStep[] = [ +const minimalSteps: PdsGoalCalculatorSteps = [ stub(PdsGoalCalculatorStepEnum.Setup), stub(PdsGoalCalculatorStepEnum.SummaryReport), ]; @@ -53,7 +56,7 @@ const StepProbe: React.FC = () => { usePdsGoalCalculator(); return (
-
{currentStep?.step ?? 'none'}
+
{currentStep.step}
{stepIndex}
{steps.length}
@@ -88,17 +87,36 @@ const StepProbe: React.FC = () => { }; beforeEach(() => { - mockedUseSteps.mockReturnValue(detailedSteps); + mockedUseSteps.mockImplementation(actualUseSteps); }); describe('PdsGoalCalculatorContext', () => { - it('provides steps and current step', () => { + it('provides steps and current step', async () => { const { result } = renderUsePdsGoalCalculator(); - expect(result.current.steps).toHaveLength(4); + await waitFor(() => expect(result.current.steps).toHaveLength(4)); expect(result.current.currentStep.step).toBe('setup'); }); + it('passes calculation.formType through to useSteps (Simple → 3 steps)', async () => { + const { result } = renderHook(() => usePdsGoalCalculator(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => expect(result.current.steps).toHaveLength(3)); + expect(result.current.steps.map((s) => s.step)).toEqual([ + PdsGoalCalculatorStepEnum.Setup, + PdsGoalCalculatorStepEnum.SupportItem, + PdsGoalCalculatorStepEnum.SummaryReport, + ]); + }); + it('handleContinue advances to the next step', () => { const { result } = renderUsePdsGoalCalculator(); @@ -202,11 +220,10 @@ describe('PdsGoalCalculatorContext', () => { expectedIndex, ); - if (expectSnackbar) { - expect(await findByText(reconcileMessage)).toBeInTheDocument(); - } else { - expect(queryByText(reconcileMessage)).not.toBeInTheDocument(); - } + const snackbar = expectSnackbar + ? await findByText(reconcileMessage) + : queryByText(reconcileMessage); + expect(Boolean(snackbar)).toBe(expectSnackbar); }, ); @@ -218,9 +235,7 @@ describe('PdsGoalCalculatorContext', () => { , ); - userEvent.click( - getByRole('button', { name: 'go to reimbursable' }), - ); + userEvent.click(getByRole('button', { name: 'go to reimbursable' })); mockedUseSteps.mockReturnValue(simpleSteps); rerender( diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx index 3e3287da92..478bf40256 100644 --- a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx @@ -128,7 +128,7 @@ export const PdsSummaryTable: React.FC = ({ ...(isPartTime ? [ { - line: isSimple ? '2A' : '2C', + line: '2C', category: t('Work Comp'), amount: otherTotals.workComp, }, @@ -137,7 +137,7 @@ export const PdsSummaryTable: React.FC = ({ ...(isFullTime ? [ { - line: isSimple ? '2A' : '2C', + line: '2C', category: t('Benefits'), amount: otherTotals.benefits, }, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts index affd7aa4fd..b8f006f63a 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts @@ -77,16 +77,14 @@ describe('calculatePdsGoalTotal', () => { }); it('excludes reimbursable expenses and 403b when formType is Simple', () => { - const detailed = makeGoal({ formType: DesignationSupportFormType.Detailed }); const simple = makeGoal({ formType: DesignationSupportFormType.Simple }); - - const detailedTotal = calculatePdsGoalTotal(detailed, defaultConstants); - const simpleTotal = calculatePdsGoalTotal(simple, defaultConstants); - - // Simple skips reimbursable + 403b lines, so its assessment is strictly lower - // than Detailed when those values are non-zero. - expect(simpleTotal).toBeLessThan(detailedTotal); - expect(simpleTotal).toBeGreaterThan(0); + const result = calculatePdsGoalTotal(simple, defaultConstants); + // With reimbursableTotal=0 and fourOThreeBPercentage=0: + // subtotal = 5400 (salary) + 0 + 0 + 0 + 1500 (benefits) = 6900 + // attrition = 6900 * 0.06 = 414 + // creditCardFees = (6900 + 414) * 0.06 = 438.84 + // assessment = (6900 + 438.84 + 414) * 0.12 ≈ 930.34 + expect(result).toBeCloseTo(930.34, 1); }); it('treats null formType the same as Detailed (legacy goals)', () => { diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts index e0088b8c6c..a31d5b6e5e 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts @@ -17,6 +17,10 @@ import { PdsGoalCalculationFieldsFragmentDoc, } from '../GoalsList/PdsGoalCalculations.generated'; import { HcmUserDocument, HcmUserQuery } from '../Shared/HCM.generated'; +import { + PdsGoalTotalConstants, + calculatePdsGoalTotal, +} from './calculatePdsGoalTotal'; import { usePdsSummaryData } from './usePdsSummaryData'; jest.mock('src/hooks/useGoalCalculatorConstants'); @@ -404,4 +408,34 @@ describe('usePdsSummaryData', () => { ).toBeCloseTo(0.08); }); }); + + describe('consistency with calculatePdsGoalTotal', () => { + // Mirrors what buildPdsGoalConstants would derive from the mocked + // useGoalCalculatorConstants + defaultHcmUser, so we can call + // calculatePdsGoalTotal directly without the hook. + const directConstants: PdsGoalTotalConstants = { + employerFicaRate: EMPLOYER_FICA_RATE, + workCompPercentage: WORK_COMP_PERCENTAGE, + attritionRate: ATTRITION_RATE, + creditCardFeeRate: CREDIT_CARD_FEE_RATE, + adminRate: ADMIN_RATE, + fourOThreeBPercentage: 0.08, + geographicMultiplier: 0, + }; + + it.each([ + DesignationSupportFormType.Detailed, + DesignationSupportFormType.Simple, + ])( + 'calculatePdsGoalTotal matches usePdsSummaryData.otherTotals.assessment when formType is %s', + (formType) => { + const calc = { ...defaultCalculation, formType }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + const direct = calculatePdsGoalTotal(calc, directConstants); + expect(direct).toBeCloseTo(result.current!.otherTotals.assessment, 5); + }, + ); + }); }); From da951cb7350c081092da2bdbf8b588a808c52195 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Fri, 8 May 2026 14:11:09 -0400 Subject: [PATCH 22/23] Runs prettier --- .../PdsGoalCalculator/Shared/useSteps.test.tsx | 1 - .../SummaryReport/PdsSummaryTable.test.tsx | 4 +++- .../calculations/calculatePdsGoalTotal.test.ts | 4 +++- .../calculations/usePdsSummaryData.test.ts | 12 ++++++------ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx index b7a236356f..72ff2ea48d 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx @@ -26,5 +26,4 @@ describe('useSteps', () => { PdsGoalCalculatorStepEnum.SummaryReport, ]); }); - }); diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx index 92d418d8cf..aa128b807c 100644 --- a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx @@ -207,7 +207,9 @@ describe('PdsSummaryTable', () => { await findByRole('gridcell', { name: 'Benefits' }); - expect(getByRole('gridcell', { name: 'Other Subtotal' })).toBeInTheDocument(); + expect( + getByRole('gridcell', { name: 'Other Subtotal' }), + ).toBeInTheDocument(); expect(getByRole('gridcell', { name: 'Total Goal' })).toBeInTheDocument(); }); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts index b8f006f63a..27fd79a857 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts @@ -89,7 +89,9 @@ describe('calculatePdsGoalTotal', () => { it('treats null formType the same as Detailed (legacy goals)', () => { const legacy = makeGoal({ formType: null }); - const detailed = makeGoal({ formType: DesignationSupportFormType.Detailed }); + const detailed = makeGoal({ + formType: DesignationSupportFormType.Detailed, + }); expect(calculatePdsGoalTotal(legacy, defaultConstants)).toBeCloseTo( calculatePdsGoalTotal(detailed, defaultConstants), ); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts index a31d5b6e5e..fc8a70e068 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts @@ -387,9 +387,9 @@ describe('usePdsSummaryData', () => { expect(result.current?.otherConstants.reimbursableTotal).toBeGreaterThan( 0, ); - expect( - result.current?.otherConstants.fourOThreeBPercentage, - ).toBeCloseTo(0.08); + expect(result.current?.otherConstants.fourOThreeBPercentage).toBeCloseTo( + 0.08, + ); }); it('uses saved values when formType is null/undefined (legacy goals default to Detailed behavior)', () => { @@ -403,9 +403,9 @@ describe('usePdsSummaryData', () => { expect(result.current?.otherConstants.reimbursableTotal).toBeGreaterThan( 0, ); - expect( - result.current?.otherConstants.fourOThreeBPercentage, - ).toBeCloseTo(0.08); + expect(result.current?.otherConstants.fourOThreeBPercentage).toBeCloseTo( + 0.08, + ); }); }); From 0510eecc0aa84ae379e81b2a04bf3cbad2913ae2 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Fri, 8 May 2026 15:17:28 -0400 Subject: [PATCH 23/23] And more PR fixes... --- .../GoalCard/PdsGoalCard.tsx | 13 ++-- .../GoalsList/CreateGoalDialog.test.tsx | 7 +- .../GoalsList/CreateGoalDialog.tsx | 75 +++++++++++-------- .../GoalsList/PdsGoalsList.test.tsx | 23 ++++-- .../GoalsList/PdsGoalsList.tsx | 8 ++ .../PdsGoalCalculatorTestWrapper.tsx | 4 - .../PdsGoalCalculator/Setup/SetupStep.tsx | 2 +- .../Autosave/usePdsGoalAutoSave.test.tsx | 48 ++++++++++++ .../Shared/Autosave/usePdsGoalAutoSave.ts | 8 +- .../Shared/PdsGoalCalculatorContext.test.tsx | 2 +- .../Shared/PdsGoalCalculatorContext.tsx | 4 +- .../PdsGoalCalculator/Shared/formType.test.ts | 12 +++ .../calculations/calculatePdsGoalTotal.ts | 4 +- 13 files changed, 150 insertions(+), 60 deletions(-) create mode 100644 src/components/HrTools/PdsGoalCalculator/Shared/formType.test.ts diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx index 1f3963f98e..70c2b32d80 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx @@ -42,12 +42,13 @@ export const PdsGoalCard: React.FC = ({ goal }) => { return constants ? calculatePdsGoalTotal(goal, constants) : 0; }, [goal, goalMiscConstants, goalGeographicConstantMap, hcmUser]); - let formTypeBadge: React.ReactElement | null = null; - if (goal.formType === DesignationSupportFormType.Simple) { - formTypeBadge = ; - } else if (goal.formType === DesignationSupportFormType.Detailed) { - formTypeBadge = ; - } + const formType = goal.formType ?? DesignationSupportFormType.Detailed; + const formTypeBadge = + formType === DesignationSupportFormType.Simple ? ( + + ) : ( + + ); const handleDelete = async () => { await deletePdsGoalCalculation({ diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx index 56f350e8ef..c86a0706df 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx @@ -79,14 +79,13 @@ describe('CreateGoalDialog', () => { it('disables Create but keeps Cancel enabled while submitting', async () => { const onCreate = jest.fn().mockReturnValue(new Promise(() => {})); - const { getByRole } = renderDialog({ onCreate }); + const { getByRole, findByRole } = renderDialog({ onCreate }); userEvent.click(getByRole('radio', { name: /Simple/ })); userEvent.click(getByRole('button', { name: 'Create' })); - await waitFor(() => - expect(getByRole('button', { name: 'Create' })).toBeDisabled(), - ); + await findByRole('progressbar'); + expect(getByRole('button', { name: 'Create' })).toBeDisabled(); expect(getByRole('button', { name: 'Cancel' })).toBeEnabled(); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx index 1ff8c75208..bb967461a0 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { Button, CircularProgress, @@ -14,7 +14,7 @@ import { Typography, } from '@mui/material'; import { visuallyHidden } from '@mui/utils'; -import { Formik } from 'formik'; +import { Formik, FormikProps } from 'formik'; import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; @@ -42,6 +42,13 @@ export const CreateGoalDialog: React.FC = ({ onCreate, }) => { const { t } = useTranslation(); + const formikRef = useRef>(null); + + useEffect(() => { + if (open) { + formikRef.current?.resetForm(); + } + }, [open]); const formTypeOptions: Array<{ value: DesignationSupportFormType; @@ -76,7 +83,7 @@ export const CreateGoalDialog: React.FC = ({ {t('Create a New Goal')} - key={String(open)} + innerRef={formikRef} initialValues={{ formType: '' }} validationSchema={schema} onSubmit={async ({ formType }) => { @@ -99,35 +106,43 @@ export const CreateGoalDialog: React.FC = ({ > {formTypeOptions.map( ({ value, title, description }, index) => ( - - - } - label={ - + + } + label={ + <> + {title} - } - sx={{ alignItems: 'flex-start' }} - /> - - {description} - - + + {description} + + + } + sx={{ + alignItems: 'flex-start', + mb: index < formTypeOptions.length - 1 ? 2 : 0, + }} + /> ), )} diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx index 9e03e2a41e..f8e0b1cfc7 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx @@ -9,6 +9,12 @@ import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; import { PdsGoalsList } from './PdsGoalsList'; const mutationSpy = jest.fn(); +const mockEnqueue = jest.fn(); + +jest.mock('notistack', () => ({ + ...jest.requireActual('notistack'), + useSnackbar: () => ({ enqueueSnackbar: mockEnqueue }), +})); type FindByRole = ReturnType['findByRole']; @@ -203,7 +209,7 @@ describe('PdsGoalsList', () => { }); }); - it('skips mutation when reimbursement constants are missing for a Default goal', async () => { + it('shows an error and skips mutation when reimbursement constants are missing for a Default goal', async () => { const { findByRole } = render( { await openCreateGoalDialog(findByRole); await submitFormType(findByRole, 'Default'); - await waitFor(() => { - expect(mutationSpy).not.toHaveGraphqlOperation( - 'CreatePdsGoalCalculation', - ); - }); + await waitFor(() => + expect(mockEnqueue).toHaveBeenCalledWith( + 'Could not load required defaults. Please try again or pick Simple.', + { variant: 'error' }, + ), + ); + expect(mutationSpy).not.toHaveGraphqlOperation('CreatePdsGoalCalculation'); + await waitFor(() => + expect(findByRole('button', { name: 'Create' })).resolves.toBeEnabled(), + ); }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx index 0054a28ef5..ccab9c1c4e 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx @@ -1,6 +1,7 @@ import { useRouter } from 'next/router'; import React, { useState } from 'react'; import { Box, Button, CircularProgress, Stack, styled } from '@mui/material'; +import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { useGetUserQuery } from 'src/components/User/GetUser.generated'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; @@ -28,6 +29,7 @@ const PlaceholderImage = styled('img')(({ theme }) => ({ export const PdsGoalsList: React.FC = () => { const { t } = useTranslation(); const router = useRouter(); + const { enqueueSnackbar } = useSnackbar(); const accountListId = useAccountListId() ?? ''; const { data: userData } = useGetUserQuery(); @@ -59,6 +61,12 @@ export const PdsGoalsList: React.FC = () => { const phoneFee = reimbursements?.PHONE?.fee; const internetFee = reimbursements?.INTERNET?.fee; if (phoneFee === undefined || internetFee === undefined) { + enqueueSnackbar( + t( + 'Could not load required defaults. Please try again or pick Simple.', + ), + { variant: 'error' }, + ); return; } detailedDefaults = { diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index d88b2746a5..ed9f228020 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; -import { ApolloErgonoMockMap } from 'graphql-ergonomock'; import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; import { merge, mergeWith } from 'lodash'; import { SnackbarProvider } from 'notistack'; @@ -120,7 +119,6 @@ export interface PdsGoalCalculatorTestWrapperProps { userMock?: GetUserMock; constantsMock?: GoalCalculatorConstantsMock; supportRaisedMock?: number; - mocksOverride?: ApolloErgonoMockMap; onCall?: MockLinkCallHandler; router?: React.ComponentProps['router']; } @@ -136,7 +134,6 @@ export const PdsGoalCalculatorTestWrapper: React.FC< userMock, constantsMock, supportRaisedMock, - mocksOverride, onCall, router, }) => { @@ -245,7 +242,6 @@ export const PdsGoalCalculatorTestWrapper: React.FC< Array.isArray(srcValue) ? srcValue : undefined, ), }, - ...mocksOverride, }} onCall={onCall} > diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index f42c73e17b..5c86927e5a 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -147,7 +147,7 @@ export const SetupStep: React.FC = () => { select label={t('Form Type')} helperText={t( - 'Default includes reimbursable expenses and 403b contributions; Simple omits them.', + 'Default includes reimbursable expenses and 403b contributions in the goal total. Simple excludes them; existing entries are preserved and will count again if you switch back.', )} > diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.test.tsx index 4f3e964690..7528c89522 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.test.tsx @@ -11,6 +11,7 @@ import { usePdsGoalAutoSave } from './usePdsGoalAutoSave'; const schema = yup.object({ name: yup.string().required('Goal Name is required'), payRate: yup.number().nullable().min(0), + formType: yup.string().nullable(), }); const mutationSpy = jest.fn(); @@ -119,4 +120,51 @@ describe('usePdsGoalAutoSave', () => { await waitFor(() => expect(result.current.value).toBe('50000')); }); + + it('disables saveOnChange fields while a save is in flight', async () => { + const { result } = renderHook( + () => + usePdsGoalAutoSave({ + fieldName: 'formType', + schema, + saveOnChange: true, + }), + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(result.current.disabled).toBe(false)); + + act(() => { + result.current.onChange({ + target: { value: 'simple' }, + } as React.ChangeEvent); + }); + + await waitFor(() => expect(result.current.disabled).toBe(true)); + await waitFor(() => expect(result.current.disabled).toBe(false)); + }); + + it('does not disable blur-driven fields while a save is in flight', async () => { + const { result } = renderHook( + () => usePdsGoalAutoSave({ fieldName: 'name', schema }), + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(result.current.value).toBe('Test Goal')); + + act(() => { + result.current.onChange({ + target: { value: 'Updated Goal' }, + } as React.ChangeEvent); + }); + act(() => { + result.current.onBlur(); + }); + + expect(result.current.disabled).toBe(false); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation'), + ); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.ts b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.ts index 062eabf1a2..deaf5b0aae 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.ts +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/usePdsGoalAutoSave.ts @@ -15,13 +15,17 @@ export const usePdsGoalAutoSave = ({ ...options }: UsePdsAutoSaveOptions) => { const saveField = useSaveField(); - const { calculation } = usePdsGoalCalculator(); + const { calculation, isMutating } = usePdsGoalCalculator(); return useAutoSave({ value: calculation?.[fieldName] as string | number | null | undefined, saveValue: (value) => saveField({ [fieldName]: value }), fieldName, - disabled: !calculation, ...options, + // Block change-driven (select) autosaves while a save is in flight: rapid + // back-and-forth toggles can otherwise land out of order in the Apollo + // cache. formType is the load-bearing case — its value reshapes the goal + // calculation, so a stale final value silently understates the total. + disabled: !calculation || (options.saveOnChange === true && isMutating), }); }; diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx index f6b6ce5672..3177c0a424 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx @@ -147,7 +147,7 @@ describe('PdsGoalCalculatorContext', () => { describe('preserves the user step when the steps array changes', () => { const reconcileMessage = - 'Returned to Setup because that step is not available in this form type.'; + 'Returned to Setup because the current step is no longer available.'; it.each([ { diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index eb3eb3924e..552243cb9a 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -111,9 +111,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { } setActiveStep(steps[0]?.step ?? PdsGoalCalculatorStepEnum.Setup); enqueueSnackbar( - t( - 'Returned to Setup because that step is not available in this form type.', - ), + t('Returned to Setup because the current step is no longer available.'), { variant: 'info' }, ); }, [steps, activeStep, enqueueSnackbar, t]); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/formType.test.ts b/src/components/HrTools/PdsGoalCalculator/Shared/formType.test.ts new file mode 100644 index 0000000000..584e480267 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Shared/formType.test.ts @@ -0,0 +1,12 @@ +import { DesignationSupportFormType } from 'src/graphql/types.generated'; +import { isSimpleFormType } from './formType'; + +describe('isSimpleFormType', () => { + it('returns true for Simple form type', () => { + expect(isSimpleFormType(DesignationSupportFormType.Simple)).toBe(true); + }); + + it('returns false for Detailed form type', () => { + expect(isSimpleFormType(DesignationSupportFormType.Detailed)).toBe(false); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts index ca8dc3c8e4..4a7b8c7e6f 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts @@ -22,9 +22,7 @@ import { export type PdsGoalTotalFields = SalaryCalculationFields & ReimbursableCalculationFields & - OtherExpensesFields & { - formType?: DesignationSupportFormType | null; - }; + OtherExpensesFields; export interface PdsGoalTotalConstants { employerFicaRate: number;