diff --git a/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.test.tsx b/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.test.tsx index b8b446449b..f078d2dd20 100644 --- a/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.test.tsx +++ b/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.test.tsx @@ -31,6 +31,17 @@ describe('SectionList', () => { within(incompleteSection).getByTestId('RadioButtonUncheckedIcon'), ).toBeInTheDocument(); }); + + it('renders the Complete/Incomplete title', () => { + const { getByTitle } = render( + + + , + ); + + expect(getByTitle('Complete')).toBeInTheDocument(); + expect(getByTitle('Incomplete')).toBeInTheDocument(); + }); }); describe('ReportSectionList', () => { diff --git a/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.tsx b/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.tsx index 0c5470e11e..08c9524db8 100644 --- a/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.tsx +++ b/src/components/HrTools/GoalCalculator/SharedComponents/SectionList.tsx @@ -13,28 +13,42 @@ import { useGoalCalculator } from '../Shared/GoalCalculatorContext'; interface ListItemContentProps { title: string; complete: boolean; + announceCompletion?: boolean; } const ListItemContent: React.FC = ({ title, complete, -}) => ( - <> - ({ - color: complete - ? theme.palette.mpdxBlue.main - : theme.palette.mpdxGrayDark.main, - })} - > - {complete ? : } - - - -); + announceCompletion = false, +}) => { + const { t } = useTranslation(); + const titleAccess = announceCompletion + ? complete + ? t('Complete') + : t('Incomplete') + : undefined; + return ( + <> + ({ + color: complete + ? theme.palette.mpdxBlue.main + : theme.palette.mpdxGrayDark.main, + })} + > + {complete ? ( + + ) : ( + + )} + + + + ); +}; export interface SectionItem { title: string; @@ -50,7 +64,11 @@ export const SectionList: React.FC = ({ sections }) => { {sections.map(({ title, complete }, index) => ( - + ))} diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx index bb967461a0..066142e62c 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Button, CircularProgress, @@ -50,26 +50,31 @@ export const CreateGoalDialog: React.FC = ({ } }, [open]); - const formTypeOptions: Array<{ - value: DesignationSupportFormType; - title: string; - description: string; - }> = [ - { - value: DesignationSupportFormType.Detailed, - title: t('Default'), - description: t( - 'Full calculator with reimbursable expenses and 403b contributions.', - ), - }, - { - value: DesignationSupportFormType.Simple, - title: t('Simple'), - description: t( - 'Streamlined calculator without reimbursable expenses or 403b contributions.', - ), - }, - ]; + const formTypeOptions = useMemo< + Array<{ + value: DesignationSupportFormType; + title: string; + description: string; + }> + >( + () => [ + { + value: DesignationSupportFormType.Detailed, + title: t('Default'), + description: t( + 'Full calculator with reimbursable expenses and 403b contributions.', + ), + }, + { + value: DesignationSupportFormType.Simple, + title: t('Simple'), + description: t( + 'Streamlined calculator without reimbursable expenses or 403b contributions.', + ), + }, + ], + [t], + ); return ( ({ - ...jest.requireActual('notistack'), - useSnackbar: () => ({ enqueueSnackbar: mockEnqueue }), -})); type FindByRole = ReturnType['findByRole']; @@ -210,7 +204,7 @@ describe('PdsGoalsList', () => { }); it('shows an error and skips mutation when reimbursement constants are missing for a Default goal', async () => { - const { findByRole } = render( + const { findByRole, findByText } = render( { await openCreateGoalDialog(findByRole); await submitFormType(findByRole, 'Default'); - await waitFor(() => - expect(mockEnqueue).toHaveBeenCalledWith( + expect( + await findByText( 'Could not load required defaults. Please try again or pick Simple.', - { variant: 'error' }, ), - ); + ).toBeInTheDocument(); expect(mutationSpy).not.toHaveGraphqlOperation('CreatePdsGoalCalculation'); await waitFor(() => expect(findByRole('button', { name: 'Create' })).resolves.toBeEnabled(), diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx index f3aba67f33..2cbb3403cd 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx @@ -4,13 +4,16 @@ import { AutosaveForm, useAutosaveForm, } from 'src/components/Shared/Autosave/AutosaveForm'; -import { SectionList } from '../GoalCalculator/SharedComponents/SectionList'; import { PdsGoalCalculatorStepEnum } from './PdsGoalCalculatorHelper'; +import { ReimbursableExpensesSectionList } from './ReimbursableExpenses/ReimbursableExpensesSectionList'; import { ReimbursableExpensesStep } from './ReimbursableExpenses/ReimbursableExpensesStep'; +import { SetupSectionList } from './Setup/SetupSectionList'; import { SetupStep } from './Setup/SetupStep'; import { usePdsGoalCalculator } from './Shared/PdsGoalCalculatorContext'; import { PdsGoalCalculatorLayout } from './Shared/PdsGoalCalculatorLayout'; +import { SummaryReportSectionList } from './SummaryReport/SummaryReportSectionList'; import { SummaryReportStep } from './SummaryReport/SummaryReportStep'; +import { SupportItemSectionList } from './SupportItem/SupportItemSectionList'; import { SupportItemStep } from './SupportItem/SupportItemStep'; const CurrentStep: React.FC = () => { @@ -28,6 +31,21 @@ const CurrentStep: React.FC = () => { } }; +const CurrentSectionList: React.FC = () => { + const { currentStep } = usePdsGoalCalculator(); + + switch (currentStep.step) { + case PdsGoalCalculatorStepEnum.Setup: + return ; + case PdsGoalCalculatorStepEnum.ReimbursableExpenses: + return ; + case PdsGoalCalculatorStepEnum.SupportItem: + return ; + case PdsGoalCalculatorStepEnum.SummaryReport: + return ; + } +}; + const MainContent: React.FC = () => { const { currentStep, stepIndex, steps, handleContinue, handlePreviousStep } = usePdsGoalCalculator(); @@ -52,12 +70,9 @@ const MainContent: React.FC = () => { }; export const PdsGoalCalculator: React.FC = () => { - const { currentStep } = usePdsGoalCalculator(); - const sections = currentStep.sections; - return ( } + sectionListPanel={} mainContent={ diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesSectionList.test.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesSectionList.test.tsx new file mode 100644 index 0000000000..d0b44e2ffa --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesSectionList.test.tsx @@ -0,0 +1,80 @@ +import { render, waitFor, within } from '@testing-library/react'; +import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; +import { ReimbursableExpensesSectionList } from './ReimbursableExpensesSectionList'; + +const allReimbursableUntouched = { + ministryCellPhone: null, + ministryInternet: null, + mpdNewsletter: null, + mpdMiscellaneous: null, + accountTransfers: null, + otherMonthlyReimbursements: null, + conferenceRetreatCosts: null, + ministryTravelMeals: null, + otherAnnualReimbursements: null, +}; + +describe('ReimbursableExpensesSectionList', () => { + it('renders both sections as incomplete when no reimbursable fields have been touched', async () => { + const { findAllByRole } = render( + + + , + ); + + const items = await findAllByRole('listitem'); + expect(items).toHaveLength(2); + + const [monthly, annual] = items; + expect(monthly).toHaveTextContent('Monthly Reimbursable Expenses'); + expect(annual).toHaveTextContent('Annual Reimbursable Expenses'); + + await waitFor(() => { + expect( + within(monthly).getByTestId('RadioButtonUncheckedIcon'), + ).toBeInTheDocument(); + expect( + within(annual).getByTestId('RadioButtonUncheckedIcon'), + ).toBeInTheDocument(); + }); + }); + + it('marks only Monthly complete when a monthly field is touched', async () => { + const { findAllByRole } = render( + + + , + ); + + const [monthly, annual] = await findAllByRole('listitem'); + await waitFor(() => { + expect(within(monthly).getByTestId('CircleIcon')).toBeInTheDocument(); + expect( + within(annual).getByTestId('RadioButtonUncheckedIcon'), + ).toBeInTheDocument(); + }); + }); + + it('marks only Annual complete when an annual field is touched', async () => { + const { findAllByRole } = render( + + + , + ); + + const [monthly, annual] = await findAllByRole('listitem'); + await waitFor(() => { + expect( + within(monthly).getByTestId('RadioButtonUncheckedIcon'), + ).toBeInTheDocument(); + expect(within(annual).getByTestId('CircleIcon')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesSectionList.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesSectionList.tsx new file mode 100644 index 0000000000..a37b1da61f --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesSectionList.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SectionList } from '../../GoalCalculator/SharedComponents/SectionList'; +import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { + isAnnualReimbursableComplete, + isMonthlyReimbursableComplete, +} from '../Shared/pdsCompletion'; + +export const ReimbursableExpensesSectionList: React.FC = () => { + const { t } = useTranslation(); + const { calculation } = usePdsGoalCalculator(); + + return ( + + ); +}; diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupSectionList.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupSectionList.test.tsx new file mode 100644 index 0000000000..7db17a0ee2 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupSectionList.test.tsx @@ -0,0 +1,35 @@ +import { render, waitFor, within } from '@testing-library/react'; +import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; +import { SetupSectionList } from './SetupSectionList'; + +describe('SetupSectionList', () => { + it('renders the Setup section as complete when calculation has all required fields', async () => { + const { findByRole } = render( + + + , + ); + + const setupItem = await findByRole('listitem'); + expect(setupItem).toHaveTextContent('Setup'); + await waitFor(() => + expect(within(setupItem).getByTestId('CircleIcon')).toBeInTheDocument(), + ); + }); + + it('renders the Setup section as incomplete when calculation is missing required fields', async () => { + const { findByRole } = render( + + + , + ); + + const setupItem = await findByRole('listitem'); + expect(setupItem).toHaveTextContent('Setup'); + await waitFor(() => + expect( + within(setupItem).getByTestId('RadioButtonUncheckedIcon'), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupSectionList.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupSectionList.tsx new file mode 100644 index 0000000000..167d91a3d0 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupSectionList.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SectionList } from '../../GoalCalculator/SharedComponents/SectionList'; +import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { isSetupComplete } from '../Shared/pdsCompletion'; + +export const SetupSectionList: React.FC = () => { + const { t } = useTranslation(); + const { calculation } = usePdsGoalCalculator(); + + return ( + + ); +}; diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index 9587ced11f..430ea947f1 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -104,51 +104,71 @@ describe('SetupStep', () => { ).toBeInTheDocument(); }); - it.each([ - { - name: 'full-time salaried', + it('shows Benefits and hides Hours Worked for full-time salaried', async () => { + const { findByRole, queryByRole } = renderSetup({ calculationMock: fullTimeSalariedMock, - benefits: 'visible' as const, - hoursWorked: 'hidden' as const, - }, - { - name: 'part-time salaried', + }); + + await findByRole('textbox', { name: 'Goal Name' }); + + expect( + await findByRole('spinbutton', { name: 'Benefits' }), + ).toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('spinbutton', { name: 'Hours Worked' }), + ).not.toBeInTheDocument(), + ); + }); + + it('hides Benefits and Hours Worked for part-time salaried', async () => { + const { findByRole, queryByRole } = renderSetup({ calculationMock: partTimeSalariedMock, - benefits: 'hidden' as const, - hoursWorked: 'hidden' as const, - }, - { - name: 'full-time hourly', + }); + + await findByRole('textbox', { name: 'Goal Name' }); + + await waitFor(() => { + expect( + queryByRole('spinbutton', { name: 'Benefits' }), + ).not.toBeInTheDocument(); + expect( + queryByRole('spinbutton', { name: 'Hours Worked' }), + ).not.toBeInTheDocument(); + }); + }); + + it('shows Benefits and Hours Worked for full-time hourly', async () => { + const { findByRole } = renderSetup({ calculationMock: fullTimeHourlyMock, - benefits: 'visible' as const, - hoursWorked: 'visible' as const, - }, - { - name: 'part-time hourly', + }); + + await findByRole('textbox', { name: 'Goal Name' }); + + expect( + await findByRole('spinbutton', { name: 'Benefits' }), + ).toBeInTheDocument(); + expect( + await findByRole('spinbutton', { name: 'Hours Worked' }), + ).toBeInTheDocument(); + }); + + it('hides Benefits and shows Hours Worked for part-time hourly', async () => { + const { findByRole, queryByRole } = renderSetup({ 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 }), - ); - }, - ); + }); + + await findByRole('textbox', { name: 'Goal Name' }); + + expect( + await findByRole('spinbutton', { name: 'Hours Worked' }), + ).toBeInTheDocument(); + await waitFor(() => + expect( + queryByRole('spinbutton', { name: 'Benefits' }), + ).not.toBeInTheDocument(), + ); + }); it('shows dynamic Pay Rate helper text based on salary type', async () => { const { findByRole, findByText, rerender } = renderSetup({ diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index 5c86927e5a..8eec4eccad 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -90,6 +90,12 @@ export const SetupStep: React.FC = () => { const isSimpleForm = calculation?.formType === DesignationSupportFormType.Simple; + const fourOThreeB = hcmUser?.fourOThreeB; + const totalFourOThreeBContributionPercentage = fourOThreeB + ? (fourOThreeB.currentTaxDeferredContributionPercentage ?? 0) + + (fourOThreeB.currentRothContributionPercentage ?? 0) + : ''; + const payRateHelperText = isSalaried ? t('Enter yearly salary') : t('Enter hourly rate'); @@ -253,14 +259,7 @@ export const SetupStep: React.FC = () => { variant="outlined" label={t('403b Contribution Percentage')} disabled - value={ - hcmUser?.fourOThreeB - ? (hcmUser.fourOThreeB - .currentTaxDeferredContributionPercentage ?? 0) + - (hcmUser.fourOThreeB.currentRothContributionPercentage ?? - 0) - : '' - } + value={totalFourOThreeBContributionPercentage} helperText={t( 'Retrieved from Principal. A combined percentage of your current tax deferred and Roth contributions.', )} diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx index 8101abfd3b..7e23964a04 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx @@ -202,58 +202,48 @@ describe('AutosaveTextField', () => { ); }); - it('hides validation error for an empty untouched field', async () => { + describe('required field', () => { const requiredSchema = yup.object({ name: yup.string().required('Goal Name is required'), }); - const { findByRole } = render( - - - , - ); + const renderRequired = () => + render( + + + , + ); - const input = await findByRole('textbox', { name: 'Goal Name' }); - await waitFor(() => expect(input).toHaveValue('')); + it('hides validation error for an empty untouched field', async () => { + const { findByRole } = renderRequired(); - expect(input).toHaveAccessibleDescription('Enter the goal name'); - expect(input).not.toHaveAttribute('aria-invalid', 'true'); - }); + const input = await findByRole('textbox', { name: 'Goal Name' }); + await waitFor(() => expect(input).toHaveValue('')); - it('shows validation error after the field is touched', async () => { - const requiredSchema = yup.object({ - name: yup.string().required('Goal Name is required'), + expect(input).toHaveAccessibleDescription('Enter the goal name'); + expect(input).not.toHaveAttribute('aria-invalid', 'true'); }); - const { findByRole } = render( - - - , - ); - const input = await findByRole('textbox', { name: 'Goal Name' }); - await waitFor(() => expect(input).toHaveValue('')); + it('shows validation error after the field is touched', async () => { + const { findByRole } = renderRequired(); - fireEvent.focus(input); - fireEvent.blur(input); + const input = await findByRole('textbox', { name: 'Goal Name' }); + await waitFor(() => expect(input).toHaveValue('')); - await waitFor(() => - expect(input).toHaveAccessibleDescription('Goal Name is required'), - ); + fireEvent.focus(input); + fireEvent.blur(input); + + await waitFor(() => + expect(input).toHaveAccessibleDescription('Goal Name is required'), + ); + }); }); describe('select input', () => { diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx index 3177c0a424..50ba006bd7 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx @@ -9,14 +9,15 @@ import { usePdsGoalCalculator } from './PdsGoalCalculatorContext'; import { useSteps } from './useSteps'; import type { PdsGoalCalculatorStep, PdsGoalCalculatorSteps } from './useSteps'; -jest.mock('./useSteps', () => ({ - __esModule: true, - ...jest.requireActual('./useSteps'), - useSteps: jest.fn(), -})); - -const { useSteps: actualUseSteps } = - jest.requireActual('./useSteps'); +// `jest.spyOn` can't redefine, so we mock the module with a `jest.fn` that delegates to the real implementation, giving reconcile tests a handle to override the return value. +jest.mock('./useSteps', () => { + const actual = jest.requireActual('./useSteps'); + return { + __esModule: true, + ...actual, + useSteps: jest.fn(actual.useSteps), + }; +}); const mockedUseSteps = useSteps as jest.MockedFunction; @@ -24,7 +25,6 @@ const stub = (step: PdsGoalCalculatorStepEnum): PdsGoalCalculatorStep => ({ step, title: step, icon: , - sections: [], }); const detailedSteps: PdsGoalCalculatorSteps = [ @@ -86,10 +86,6 @@ const StepProbe: React.FC = () => { ); }; -beforeEach(() => { - mockedUseSteps.mockImplementation(actualUseSteps); -}); - describe('PdsGoalCalculatorContext', () => { it('provides steps and current step', async () => { const { result } = renderUsePdsGoalCalculator(); @@ -145,6 +141,38 @@ describe('PdsGoalCalculatorContext', () => { expect(result.current.stepIndex).toBe(0); }); + it('exposes percentComplete reflecting current step / total steps', () => { + const { result } = renderUsePdsGoalCalculator(); + + expect(result.current.percentComplete).toBe(25); + act(() => result.current.handleContinue()); + expect(result.current.percentComplete).toBe(50); + act(() => result.current.handleContinue()); + expect(result.current.percentComplete).toBe(75); + act(() => result.current.handleContinue()); + expect(result.current.percentComplete).toBe(100); + }); + + it('rounds percentComplete to 33/67/100 for the 3-step Simple form', async () => { + const { result } = renderHook(() => usePdsGoalCalculator(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => expect(result.current.steps).toHaveLength(3)); + + expect(result.current.percentComplete).toBe(33); + act(() => result.current.handleContinue()); + expect(result.current.percentComplete).toBe(67); + act(() => result.current.handleContinue()); + expect(result.current.percentComplete).toBe(100); + }); + describe('preserves the user step when the steps array changes', () => { const reconcileMessage = 'Returned to Setup because the current step is no longer available.'; diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index 552243cb9a..c96bb9025d 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -10,6 +10,7 @@ import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { useTrackMutation } from 'src/hooks/useTrackMutation'; +import { safeProgressRatio } from '../../GoalCalculator/Shared/safeProgressRatio'; import { PdsGoalCalculationFieldsFragment, usePdsGoalCalculationQuery, @@ -34,6 +35,7 @@ export type PdsGoalCalculatorType = { calculationLoading: boolean; hcmUser?: HcmUserQuery['hcm'][number]; summaryData: PdsSummaryData | null; + percentComplete: number; /** Whether any mutations are currently in progress */ isMutating: boolean; @@ -89,9 +91,6 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const summaryData = usePdsSummaryData(calculation, hcmUser); - const steps = useSteps( - calculation?.formType ?? DesignationSupportFormType.Detailed, - ); // 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. @@ -100,6 +99,10 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const [activeStep, setActiveStep] = useState( PdsGoalCalculatorStepEnum.Setup, ); + + const steps = useSteps( + calculation?.formType ?? DesignationSupportFormType.Detailed, + ); const [rightPanelContent, setRightPanelContent] = useState(null); const [isDrawerOpen, setIsDrawerOpen] = useState(true); @@ -121,9 +124,11 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { return idx === -1 ? 0 : idx; }, [steps, activeStep]); - // steps is a non-empty tuple, so steps[0] is guaranteed defined; the fallback - // protects against an out-of-range stepIndex. - const currentStep = steps[stepIndex] ?? steps[0]; + const currentStep = steps[stepIndex]; + + const percentComplete = Math.round( + safeProgressRatio(stepIndex + 1, steps.length) * 100, + ); const handleStepChange = useCallback( (newStep: PdsGoalCalculatorStepEnum) => { @@ -166,6 +171,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { calculation, calculationLoading, summaryData, + percentComplete, isMutating, trackMutation, hcmUser, @@ -186,6 +192,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { calculation, calculationLoading, summaryData, + percentComplete, isMutating, trackMutation, hcmUser, diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.test.tsx index 4429ac7282..437e613c14 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.test.tsx @@ -16,4 +16,38 @@ describe('PdsGoalCalculatorLayout', () => { expect(getByText('Main Content')).toBeInTheDocument(); }); + + it('passes percentComplete from context to the progress indicator', async () => { + // Detailed formType yields 4 steps; the initial Setup step is index 0, + // so percentComplete = round((0 + 1) / 4 * 100) = 25. + const { findByRole } = render( + + Section List} + mainContent={
Main Content
} + /> +
, + ); + + const progressIndicator = await findByRole('progressbar', { + name: 'Form Progress', + }); + expect(progressIndicator).toHaveAttribute('aria-valuenow', '25'); + }); + + it('shows an indeterminate progress indicator while calculation is loading', async () => { + const { findByRole } = render( + + Section List} + mainContent={
Main Content
} + /> +
, + ); + + const loadingIndicator = await findByRole('progressbar', { + name: 'Calculating progress', + }); + expect(loadingIndicator).not.toHaveAttribute('aria-valuenow'); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx index 818af71cd9..517a7e7576 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx @@ -23,6 +23,8 @@ export const PdsGoalCalculatorLayout: React.FC< isDrawerOpen, setDrawerOpen, toggleDrawer, + percentComplete, + calculationLoading, } = usePdsGoalCalculator(); const handleStepIconClick = (step: PdsGoalCalculatorStepEnum) => { @@ -45,7 +47,8 @@ export const PdsGoalCalculatorLayout: React.FC< return ( { - 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/Shared/formType.ts b/src/components/HrTools/PdsGoalCalculator/Shared/formType.ts deleted file mode 100644 index 1b21671a75..0000000000 --- a/src/components/HrTools/PdsGoalCalculator/Shared/formType.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { DesignationSupportFormType } from 'src/graphql/types.generated'; - -export const isSimpleFormType = ( - formType: DesignationSupportFormType, -): boolean => formType === DesignationSupportFormType.Simple; diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/pdsCompletion.test.ts b/src/components/HrTools/PdsGoalCalculator/Shared/pdsCompletion.test.ts new file mode 100644 index 0000000000..eb9269de4a --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Shared/pdsCompletion.test.ts @@ -0,0 +1,132 @@ +import { + DesignationSupportSalaryType, + DesignationSupportStatus, +} from 'src/graphql/types.generated'; +import { PdsGoalCalculationFieldsFragment } from '../GoalsList/PdsGoalCalculations.generated'; +import { PdsSummaryData } from '../calculations/usePdsSummaryData'; +import { + isAnnualReimbursableComplete, + isMonthlyReimbursableComplete, + isMpdGoalComplete, + isSetupComplete, +} from './pdsCompletion'; + +const baseCalculation: PdsGoalCalculationFieldsFragment = { + id: 'goal-1', + name: 'Test Goal', + status: DesignationSupportStatus.FullTime, + salaryOrHourly: DesignationSupportSalaryType.Salaried, + payRate: 50000, + hoursWorkedPerWeek: null, + benefits: 1500, + formType: null, + updatedAt: '2026-01-01T00:00:00Z', + averageHoursPerWeek: null, + geographicLocation: null, + ministryCellPhone: null, + ministryInternet: null, + mpdNewsletter: null, + mpdMiscellaneous: null, + accountTransfers: null, + otherMonthlyReimbursements: null, + conferenceRetreatCosts: null, + ministryTravelMeals: null, + otherAnnualReimbursements: null, + designationSupportHoursItems: [], +}; + +describe('isSetupComplete', () => { + it('returns false when calculation is undefined', () => { + expect(isSetupComplete(undefined)).toBe(false); + }); + + it('returns true when all required fields are set for a salaried full-time goal', () => { + expect(isSetupComplete(baseCalculation)).toBe(true); + }); + + it('returns false when name is empty', () => { + expect(isSetupComplete({ ...baseCalculation, name: '' })).toBe(false); + }); + + it('returns false when payRate is missing or zero', () => { + expect(isSetupComplete({ ...baseCalculation, payRate: null })).toBe(false); + expect(isSetupComplete({ ...baseCalculation, payRate: 0 })).toBe(false); + }); + + it('requires hoursWorkedPerWeek when pay type is Hourly', () => { + const hourly: PdsGoalCalculationFieldsFragment = { + ...baseCalculation, + salaryOrHourly: DesignationSupportSalaryType.Hourly, + hoursWorkedPerWeek: null, + }; + expect(isSetupComplete(hourly)).toBe(false); + expect(isSetupComplete({ ...hourly, hoursWorkedPerWeek: 30 })).toBe(true); + }); + + it('does not require benefits when status is Part-time', () => { + const partTime: PdsGoalCalculationFieldsFragment = { + ...baseCalculation, + status: DesignationSupportStatus.PartTime, + benefits: null, + }; + expect(isSetupComplete(partTime)).toBe(true); + }); + + it('requires benefits when status is Full-time', () => { + expect(isSetupComplete({ ...baseCalculation, benefits: null })).toBe(false); + }); +}); + +describe('isMonthlyReimbursableComplete', () => { + it('returns false when no monthly fields have been touched', () => { + expect(isMonthlyReimbursableComplete(baseCalculation)).toBe(false); + }); + + it('returns true when any monthly field has a value, including zero', () => { + expect( + isMonthlyReimbursableComplete({ + ...baseCalculation, + ministryCellPhone: 0, + }), + ).toBe(true); + expect( + isMonthlyReimbursableComplete({ + ...baseCalculation, + otherMonthlyReimbursements: 25, + }), + ).toBe(true); + }); +}); + +describe('isAnnualReimbursableComplete', () => { + it('returns false when no annual fields have been touched', () => { + expect(isAnnualReimbursableComplete(baseCalculation)).toBe(false); + }); + + it('returns true when any annual field has a value', () => { + expect( + isAnnualReimbursableComplete({ + ...baseCalculation, + conferenceRetreatCosts: 0, + }), + ).toBe(true); + }); +}); + +describe('isMpdGoalComplete', () => { + it('returns false when summaryData is null', () => { + expect(isMpdGoalComplete(null)).toBe(false); + }); + + it('returns false when overallTotal is zero', () => { + expect(isMpdGoalComplete({ overallTotal: 0 } as PdsSummaryData)).toBe( + false, + ); + }); + + it('returns true when overallTotal is positive', () => { + expect(isMpdGoalComplete({ overallTotal: 1234 } as PdsSummaryData)).toBe( + true, + ); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/pdsCompletion.ts b/src/components/HrTools/PdsGoalCalculator/Shared/pdsCompletion.ts new file mode 100644 index 0000000000..26fa90ff71 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Shared/pdsCompletion.ts @@ -0,0 +1,66 @@ +import { + DesignationSupportSalaryType, + DesignationSupportStatus, +} from 'src/graphql/types.generated'; +import { PdsGoalCalculationFieldsFragment } from '../GoalsList/PdsGoalCalculations.generated'; +import { PdsSummaryData } from '../calculations/usePdsSummaryData'; + +type Calculation = PdsGoalCalculationFieldsFragment | undefined; + +export const isSetupComplete = (calculation: Calculation): boolean => { + if (!calculation) { + return false; + } + + const isSalaried = + calculation.salaryOrHourly === DesignationSupportSalaryType.Salaried; + const isPartTime = calculation.status === DesignationSupportStatus.PartTime; + + return Boolean( + calculation.name && + calculation.status && + calculation.salaryOrHourly && + (calculation.payRate ?? 0) > 0 && + (isSalaried || (calculation.hoursWorkedPerWeek ?? 0) > 0) && + (isPartTime || (calculation.benefits ?? 0) > 0), + ); +}; + +// Cells default to null and only become a number when the user types one, +// so we can distinguish "user engaged with this section" from a fresh state. +const isAnyFieldTouched = ( + values: ReadonlyArray, +): boolean => values.some((value) => value !== null && value !== undefined); + +export const isMonthlyReimbursableComplete = ( + calculation: Calculation, +): boolean => { + if (!calculation) { + return false; + } + return isAnyFieldTouched([ + calculation.ministryCellPhone, + calculation.ministryInternet, + calculation.mpdNewsletter, + calculation.mpdMiscellaneous, + calculation.accountTransfers, + calculation.otherMonthlyReimbursements, + ]); +}; + +export const isAnnualReimbursableComplete = ( + calculation: Calculation, +): boolean => { + if (!calculation) { + return false; + } + return isAnyFieldTouched([ + calculation.conferenceRetreatCosts, + calculation.ministryTravelMeals, + calculation.otherAnnualReimbursements, + ]); +}; + +export const isMpdGoalComplete = ( + summaryData: PdsSummaryData | null, +): boolean => !!summaryData && summaryData.overallTotal > 0; diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx index 52fa7d7245..6fb5df552d 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx @@ -6,18 +6,11 @@ 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; - complete: boolean; -} export interface PdsGoalCalculatorStep { step: PdsGoalCalculatorStepEnum; title: string; icon: React.ReactNode; - sections: PdsGoalCalculatorSection[]; } export type PdsGoalCalculatorSteps = [ @@ -31,40 +24,28 @@ export const useSteps = ( const { t } = useTranslation(); return useMemo(() => { - const isSimple = isSimpleFormType(formType); - const setup: PdsGoalCalculatorStep = { step: PdsGoalCalculatorStepEnum.Setup, title: t('Settings'), icon: , - sections: [{ title: t('Setup'), complete: false }], }; const reimbursableExpenses: PdsGoalCalculatorStep = { step: PdsGoalCalculatorStepEnum.ReimbursableExpenses, title: t('Reimbursable Expenses'), icon: , - sections: [ - { title: t('Monthly Reimbursable Expenses'), complete: false }, - { title: t('Annual Reimbursable Expenses'), complete: false }, - ], }; const supportItem: PdsGoalCalculatorStep = { step: PdsGoalCalculatorStepEnum.SupportItem, title: t('Support Item'), icon: , - sections: [ - { title: t('Salary'), complete: false }, - { title: t('Other'), complete: false }, - ], }; const summaryReport: PdsGoalCalculatorStep = { step: PdsGoalCalculatorStepEnum.SummaryReport, title: t('Summary Report'), icon: , - sections: [{ title: t('MPD Goal'), complete: false }], }; - return isSimple + return formType === DesignationSupportFormType.Simple ? [setup, supportItem, summaryReport] : [setup, reimbursableExpenses, supportItem, summaryReport]; }, [t, formType]); diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx index 478bf40256..47a17ad84d 100644 --- a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.tsx @@ -15,7 +15,6 @@ import { } from 'src/lib/intlFormat'; import { safeProgressRatio } from '../../GoalCalculator/Shared/safeProgressRatio'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; -import { isSimpleFormType } from '../Shared/formType'; import { PdsSummaryHeaderCards } from './PdsSummaryHeaderCards'; interface PdsSummaryRow { @@ -89,9 +88,7 @@ export const PdsSummaryTable: React.FC = ({ const isFullTime = calculation.status === DesignationSupportStatus.FullTime; const isPartTime = calculation.status === DesignationSupportStatus.PartTime; - const isSimple = isSimpleFormType( - calculation.formType ?? DesignationSupportFormType.Detailed, - ); + const isSimple = calculation.formType === DesignationSupportFormType.Simple; const rows: PdsSummaryRow[] = [ // Salary section diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportSectionList.test.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportSectionList.test.tsx new file mode 100644 index 0000000000..8498eee1e5 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportSectionList.test.tsx @@ -0,0 +1,19 @@ +import { render, waitFor, within } from '@testing-library/react'; +import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; +import { SummaryReportSectionList } from './SummaryReportSectionList'; + +describe('SummaryReportSectionList', () => { + it('renders the MPD Goal section as complete when summary data has a positive overall total', async () => { + const { findByRole } = render( + + + , + ); + + const mpdGoalItem = await findByRole('listitem'); + expect(mpdGoalItem).toHaveTextContent('MPD Goal'); + await waitFor(() => + expect(within(mpdGoalItem).getByTestId('CircleIcon')).toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportSectionList.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportSectionList.tsx new file mode 100644 index 0000000000..b0f2593c7e --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportSectionList.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SectionList } from '../../GoalCalculator/SharedComponents/SectionList'; +import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { isMpdGoalComplete } from '../Shared/pdsCompletion'; + +export const SummaryReportSectionList: React.FC = () => { + const { t } = useTranslation(); + const { summaryData } = usePdsGoalCalculator(); + + return ( + + ); +}; diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/SupportItemSectionList.test.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/SupportItemSectionList.test.tsx new file mode 100644 index 0000000000..731513e3c0 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/SupportItemSectionList.test.tsx @@ -0,0 +1,43 @@ +import { render, waitFor, within } from '@testing-library/react'; +import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; +import { SupportItemSectionList } from './SupportItemSectionList'; + +describe('SupportItemSectionList', () => { + it('renders Salary and Other as complete when setup is complete', async () => { + const { findAllByRole } = render( + + + , + ); + + const items = await findAllByRole('listitem'); + expect(items).toHaveLength(2); + + const [salary, other] = items; + expect(salary).toHaveTextContent('Salary'); + expect(other).toHaveTextContent('Other'); + + await waitFor(() => { + expect(within(salary).getByTestId('CircleIcon')).toBeInTheDocument(); + expect(within(other).getByTestId('CircleIcon')).toBeInTheDocument(); + }); + }); + + it('renders Salary and Other as incomplete when setup is incomplete', async () => { + const { findAllByRole } = render( + + + , + ); + + const [salary, other] = await findAllByRole('listitem'); + await waitFor(() => { + expect( + within(salary).getByTestId('RadioButtonUncheckedIcon'), + ).toBeInTheDocument(); + expect( + within(other).getByTestId('RadioButtonUncheckedIcon'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/SupportItemSectionList.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/SupportItemSectionList.tsx new file mode 100644 index 0000000000..c9ed4fd365 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/SupportItemSectionList.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SectionList } from '../../GoalCalculator/SharedComponents/SectionList'; +import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { isSetupComplete } from '../Shared/pdsCompletion'; + +export const SupportItemSectionList: React.FC = () => { + const { t } = useTranslation(); + const { calculation } = usePdsGoalCalculator(); + + // Salary and Other are read-only derived tables whose only required inputs + // come from the Setup step. + const isComplete = isSetupComplete(calculation); + + return ( + + ); +}; diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx index e5f381063d..7937838ce1 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx @@ -8,7 +8,6 @@ import { DesignationSupportStatus, } from 'src/graphql/types.generated'; import { currencyFormat, percentageFormat } from 'src/lib/intlFormat'; -import { isSimpleFormType } from '../Shared/formType'; import { OtherExpensesConstants, OtherExpensesFields, @@ -44,9 +43,7 @@ export const buildOtherBreakdownRows = ( constants, ); - const isSimple = isSimpleFormType( - calculation.formType ?? DesignationSupportFormType.Detailed, - ); + const isSimple = calculation.formType === DesignationSupportFormType.Simple; const rows: OtherBreakdownRow[] = [ ...(isSimple diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts index 27fd79a857..d5c613222a 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts @@ -77,14 +77,49 @@ describe('calculatePdsGoalTotal', () => { }); it('excludes reimbursable expenses and 403b when formType is Simple', () => { - const simple = makeGoal({ formType: DesignationSupportFormType.Simple }); - 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); + const baseline = calculatePdsGoalTotal( + makeGoal({ formType: DesignationSupportFormType.Simple }), + { ...defaultConstants, fourOThreeBPercentage: 0.1 }, + ); + + const withDifferentReimbursables = calculatePdsGoalTotal( + makeGoal({ + formType: DesignationSupportFormType.Simple, + ministryCellPhone: 9999, + otherAnnualReimbursements: 9999, + }), + { ...defaultConstants, fourOThreeBPercentage: 0.1 }, + ); + expect(withDifferentReimbursables).toBeCloseTo(baseline); + + const withDifferent403b = calculatePdsGoalTotal( + makeGoal({ formType: DesignationSupportFormType.Simple }), + { ...defaultConstants, fourOThreeBPercentage: 0.5 }, + ); + expect(withDifferent403b).toBeCloseTo(baseline); + }); + + it('includes reimbursable expenses and 403b when formType is Detailed', () => { + const baseline = calculatePdsGoalTotal( + makeGoal({ formType: DesignationSupportFormType.Detailed }), + { ...defaultConstants, fourOThreeBPercentage: 0.1 }, + ); + + const withDifferentReimbursables = calculatePdsGoalTotal( + makeGoal({ + formType: DesignationSupportFormType.Detailed, + ministryCellPhone: 9999, + otherAnnualReimbursements: 9999, + }), + { ...defaultConstants, fourOThreeBPercentage: 0.1 }, + ); + expect(withDifferentReimbursables).toBeGreaterThan(baseline); + + const withDifferent403b = calculatePdsGoalTotal( + makeGoal({ formType: DesignationSupportFormType.Detailed }), + { ...defaultConstants, fourOThreeBPercentage: 0.5 }, + ); + expect(withDifferent403b).toBeGreaterThan(baseline); }); it('treats null formType the same as Detailed (legacy goals)', () => { diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts index 4a7b8c7e6f..36e9aad328 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts @@ -4,7 +4,6 @@ import { GoalMiscConstants, } from 'src/hooks/useGoalCalculatorConstants'; import { HcmUserQuery } from '../Shared/HCM.generated'; -import { isSimpleFormType } from '../Shared/formType'; import { OtherExpensesConstants, OtherExpensesFields, @@ -85,7 +84,7 @@ export const buildOtherExpensesConstants = ( salaryTotals: SalaryTotals, reimbursableTotal: number, ): OtherExpensesConstants => { - const isSimple = isSimpleFormType(formType); + const isSimple = formType === DesignationSupportFormType.Simple; return { reimbursableTotal: isSimple ? 0 : reimbursableTotal, salarySubtotal: salaryTotals.subtotal, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts index fc8a70e068..9e58409a7c 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts @@ -340,7 +340,7 @@ describe('usePdsSummaryData', () => { }); describe('Simple form type', () => { - it('zeroes reimbursableTotal in otherConstants when formType is Simple', () => { + it('zeroes reimbursable + 403b but preserves reimbursableTotals', () => { const calc = { ...defaultCalculation, formType: DesignationSupportFormType.Simple, @@ -349,27 +349,7 @@ describe('usePdsSummaryData', () => { 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. diff --git a/src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel.test.tsx b/src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel.test.tsx new file mode 100644 index 0000000000..e001f9cc7a --- /dev/null +++ b/src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render } from '__tests__/util/testingLibraryReactMock'; +import theme from 'src/theme'; +import { CircularProgressWithLabel } from './CircularProgressWithLabel'; + +interface TestComponentProps { + progress?: number; + loading?: boolean; +} + +const TestComponent: React.FC = ({ + progress = 0, + loading, +}) => ( + + + +); + +describe('CircularProgressWithLabel', () => { + it('renders the determinate progressbar with a static accessible name and aria-valuenow', () => { + const { getByRole } = render(); + + const progressbar = getByRole('progressbar', { name: 'Form Progress' }); + expect(progressbar).toHaveAttribute('aria-valuenow', '25'); + }); + + it('does not duplicate the percentage in the accessible name', () => { + const { getByRole } = render(); + + expect( + getByRole('progressbar', { name: 'Form Progress' }), + ).toBeInTheDocument(); + }); + + it('hides the visual percentage label from assistive tech', () => { + const { getByText } = render(); + + expect(getByText('25%')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders an indeterminate progressbar with a calculating label when loading', () => { + const { getByRole, queryByText } = render( + , + ); + + const progressbar = getByRole('progressbar', { + name: 'Calculating progress', + }); + expect(progressbar).not.toHaveAttribute('aria-valuenow'); + expect(queryByText('25%')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel.tsx b/src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel.tsx index 18b77b19e8..26917dd12a 100644 --- a/src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel.tsx +++ b/src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel.tsx @@ -1,16 +1,20 @@ import { Box, CircularProgress } from '@mui/material'; import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; import { percentageFormat } from 'src/lib/intlFormat'; interface CircularProgressWithLabelProps { progress: number; + loading?: boolean; } export const CircularProgressWithLabel: React.FC< CircularProgressWithLabelProps -> = ({ progress }) => { +> = ({ progress, loading = false }) => { const locale = useLocale(); + const { t } = useTranslation(); + const formattedProgress = percentageFormat(progress / 100, locale); return ( - - - - {percentageFormat(progress / 100, locale)} - - + {loading ? ( + + ) : ( + <> + + + + {formattedProgress} + + + + )} ); }; diff --git a/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.test.tsx b/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.test.tsx index cefe768a69..0e116d9bd8 100644 --- a/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.test.tsx +++ b/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.test.tsx @@ -170,6 +170,8 @@ describe('PanelLayout', () => { expect(inactiveButton).toHaveStyle({ color: theme.palette.mpdxGrayDark.main, }); + expect(activeButton).toHaveAttribute('aria-current', 'step'); + expect(inactiveButton).not.toHaveAttribute('aria-current'); }); it('scrolls main content to top when currentIndex changes', async () => { @@ -187,6 +189,29 @@ describe('PanelLayout', () => { await waitFor(() => expect(mainContent.scrollTop).toBe(0)); }); + it('renders an indeterminate progress indicator when progressLoading is true', () => { + const { getByRole, queryByText } = render( + , + ); + + const progressIndicator = getByRole('progressbar'); + expect(progressIndicator).toBeInTheDocument(); + expect(progressIndicator).not.toHaveAttribute('aria-valuenow'); + expect(queryByText('42%')).not.toBeInTheDocument(); + }); + + it('hides the progress indicator when showPercentage is false', () => { + const { queryByRole } = render( + , + ); + + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + }); + it('handles empty icon panel items gracefully', () => { const { getByTestId, queryAllByRole } = render( = ({ panelType, percentComplete, showPercentage = true, + progressLoading = false, icons, sidebarContent, backHref, @@ -130,7 +132,10 @@ export const PanelLayout: React.FC = ({ <> {showPercentage && ( - + )} @@ -138,6 +143,7 @@ export const PanelLayout: React.FC = ({