diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx index 88e4901a1c..1f508b8339 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,33 @@ describe('PdsGoalCard', () => { '/accountLists/abc123/hrTools/pdsGoalCalculator/pds-goal-1', ); }); + + it.each([ + { + description: 'a Default badge when formType is Detailed', + name: 'Detailed Goal', + formType: DesignationSupportFormType.Detailed, + expectedBadge: 'Default', + }, + { + description: 'a Simple badge when formType is Simple', + name: 'Simple Goal', + formType: DesignationSupportFormType.Simple, + expectedBadge: 'Simple', + }, + ])('renders $description', async ({ name, formType, expectedBadge }) => { + const { findByText, queryByText } = render( + + + , + ); + + await findByText(name); + expect(queryByText(expectedBadge)).toBeInTheDocument(); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx index 285eb76ebe..70c2b32d80 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,14 @@ export const PdsGoalCard: React.FC = ({ goal }) => { return constants ? calculatePdsGoalTotal(goal, constants) : 0; }, [goal, goalMiscConstants, goalGeographicConstantMap, hcmUser]); + const formType = goal.formType ?? DesignationSupportFormType.Detailed; + const formTypeBadge = + formType === DesignationSupportFormType.Simple ? ( + + ) : ( + + ); + const handleDelete = async () => { await deletePdsGoalCalculation({ variables: { id: goal.id }, @@ -57,6 +69,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/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx new file mode 100644 index 0000000000..c86a0706df --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.test.tsx @@ -0,0 +1,108 @@ +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), + }; + const utils = render( + + + , + ); + const rerenderDialog = ( + nextProps: Partial> = {}, + ) => + utils.rerender( + + + , + ); + return { ...utils, rerenderDialog }; +}; + +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 Create but keeps Cancel enabled while submitting', async () => { + const onCreate = jest.fn().mockReturnValue(new Promise(() => {})); + const { getByRole, findByRole } = renderDialog({ onCreate }); + + userEvent.click(getByRole('radio', { name: /Simple/ })); + userEvent.click(getByRole('button', { name: 'Create' })); + + await findByRole('progressbar'); + expect(getByRole('button', { name: 'Create' })).toBeDisabled(); + expect(getByRole('button', { name: 'Cancel' })).toBeEnabled(); + }); + + it('does not render when open is false', () => { + const { queryByRole } = renderDialog({ open: false }); + expect(queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('clears the selected option when reopened after Cancel', () => { + const { getByRole, rerenderDialog } = renderDialog(); + + userEvent.click(getByRole('radio', { name: /Simple/ })); + userEvent.click(getByRole('button', { name: 'Cancel' })); + + rerenderDialog({ open: false }); + rerenderDialog({ open: true }); + + expect(getByRole('button', { name: 'Create' })).toBeDisabled(); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx new file mode 100644 index 0000000000..bb967461a0 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useRef } from 'react'; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, + Typography, +} from '@mui/material'; +import { visuallyHidden } from '@mui/utils'; +import { Formik, FormikProps } from 'formik'; +import { useTranslation } from 'react-i18next'; +import * as yup from 'yup'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; + +export interface CreateGoalDialogProps { + open: boolean; + onClose: () => void; + onCreate: (formType: DesignationSupportFormType) => Promise; +} + +interface FormValues { + formType: DesignationSupportFormType | ''; +} + +const schema = yup.object({ + formType: yup + .string() + .oneOf(Object.values(DesignationSupportFormType)) + .required(), +}); + +export const CreateGoalDialog: React.FC = ({ + open, + onClose, + onCreate, +}) => { + const { t } = useTranslation(); + const formikRef = useRef>(null); + + useEffect(() => { + if (open) { + formikRef.current?.resetForm(); + } + }, [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.', + ), + }, + ]; + + return ( + + + {t('Create a New Goal')} + + + innerRef={formikRef} + initialValues={{ formType: '' }} + validationSchema={schema} + onSubmit={async ({ formType }) => { + if (formType) { + await onCreate(formType); + } + }} + > + {({ values, isSubmitting, handleChange, handleSubmit }) => ( +
+ + + + {t('Select a form type')} + + + {formTypeOptions.map( + ({ value, title, description }, index) => ( + + } + label={ + <> + + {title} + + + {description} + + + } + sx={{ + alignItems: 'flex-start', + mb: index < formTypeOptions.length - 1 ? 2 : 0, + }} + /> + ), + )} + + + + + + + +
+ )} + +
+ ); +}; 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 diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx index fb1944c563..f8e0b1cfc7 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx @@ -9,6 +9,31 @@ 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']; + +// 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', () => { @@ -49,7 +74,19 @@ 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( + + + , + ); + + await openCreateGoalDialog(findByRole); + + 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(); - }); - userEvent.click(button); + await openCreateGoalDialog(findByRole); + await submitFormType(findByRole, 'Default'); await waitFor(() => { expect(mutationSpy).toHaveGraphqlOperation('CreatePdsGoalCalculation', { attributes: { + formType: 'DETAILED', ministryCellPhone: 75, ministryInternet: 50, }, @@ -91,6 +126,25 @@ describe('PdsGoalsList', () => { }); }); + it('creates a Simple goal via the dialog', async () => { + const { findByRole } = render( + + + , + ); + + await openCreateGoalDialog(findByRole); + await submitFormType(findByRole, 'Simple'); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('CreatePdsGoalCalculation', { + attributes: { + formType: 'SIMPLE', + }, + }); + }); + }); + it('View link navigates to the goal calculator page', async () => { const { findByRole } = render( { expect(mutationSpy).toHaveGraphqlOperation('DeletePdsGoalCalculation'); }); }); + + 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(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 32b908c746..ccab9c1c4e 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx @@ -1,13 +1,16 @@ import { useRouter } from 'next/router'; -import React from 'react'; +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'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useFetchAllPages } from 'src/hooks/useFetchAllPages'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import illustration6graybg from 'src/images/drawkit/grape/drawkit-grape-pack-illustration-6-gray-bg.svg'; import { PdsGoalCard } from '../GoalCard/PdsGoalCard'; +import { CreateGoalDialog } from './CreateGoalDialog'; import { useCreatePdsGoalCalculationMutation, usePdsGoalCalculationsQuery, @@ -26,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(); @@ -42,23 +46,48 @@ export const PdsGoalsList: React.FC = () => { 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; + 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( + 'Could not load required defaults. Please try again or pick Simple.', + ), + { variant: 'error' }, + ); + return; + } + detailedDefaults = { + ministryCellPhone: phoneFee, + ministryInternet: internetFee, + }; + } const { data } = await createPdsGoalCalculation({ variables: { attributes: { - ministryCellPhone: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.PHONE?.fee, - ministryInternet: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.INTERNET?.fee, + formType, + ...(detailedDefaults ?? {}), }, }, + refetchQueries: ['PdsGoalCalculations'], }); const calculation = data?.createDesignationSupportCalculation?.designationSupportCalculation; if (calculation) { + setDialogOpen(false); router.push( `/accountLists/${accountListId}/hrTools/pdsGoalCalculator/${calculation.id}`, ); @@ -71,13 +100,19 @@ export const PdsGoalsList: React.FC = () => { + setDialogOpen(false)} + onCreate={handleCreateGoal} + /> + {loading ? ( ) : goals?.length === 0 ? ( 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/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index 4d853a38a8..9587ced11f 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -2,10 +2,14 @@ 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'; -import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; +import { + PdsGoalCalculatorTestWrapper, + PdsGoalCalculatorTestWrapperProps, +} from '../PdsGoalCalculatorTestWrapper'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; import { SetupStep } from './SetupStep'; @@ -44,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' }); @@ -58,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')); @@ -93,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' }), @@ -187,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( @@ -211,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', { @@ -228,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'), @@ -252,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' }), @@ -264,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')); @@ -291,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', @@ -319,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'), @@ -331,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(); @@ -346,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', { @@ -361,11 +318,7 @@ describe('SetupStep', () => { expect(hoursInput).toBeInTheDocument(); // Switch to Salaried — Hours Worked should disappear - rerender( - - - , - ); + rerender(setupTree({ calculationMock: fullTimeSalariedMock })); await waitFor(() => { expect( @@ -380,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', { @@ -395,11 +344,7 @@ describe('SetupStep', () => { expect(benefitsInput).toBeInTheDocument(); // Switch to Part-time Hourly — Benefits should disappear - rerender( - - - , - ); + rerender(setupTree({ calculationMock: partTimeHourlyMock })); await waitFor(() => { expect( @@ -412,4 +357,87 @@ describe('SetupStep', () => { await findByRole('spinbutton', { name: 'Hours Worked' }), ).toBeInTheDocument(); }); + + it('renders the Form Type select with both options', async () => { + const { findByRole, getByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Detailed, + }, + }); + + 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 } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Detailed, + }, + }); + + expect( + await findByRole('textbox', { name: '403b Contribution Percentage' }), + ).toBeInTheDocument(); + }); + + it('hides 403b Contribution Percentage field when formType is Simple', async () => { + 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/ }); + 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 } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Detailed, + }, + onCall: mutationSpy, + }); + + 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', + }, + }), + ); + }); + + it('shows the 403b Contribution Percentage field when formType is null (legacy goal)', async () => { + const { findByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: null, + }, + }); + + 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 64a3da338a..5c86927e5a 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'; @@ -37,15 +38,14 @@ 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 .string() @@ -87,6 +87,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 +140,25 @@ export const SetupStep: React.FC = () => { + + + + {t('Default')} + + + {t('Simple')} + + + + { )} - - , - }} - /> - + {!isSimpleForm && ( + + , + }} + /> + + )} { ); }); + 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 + } /> ); }; 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 96679a110a..3177c0a424 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.test.tsx @@ -1,27 +1,125 @@ import React from 'react'; -import { act, renderHook } from '@testing-library/react'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DesignationSupportFormType } from 'src/graphql/types.generated'; +import { PdsGoalCalculatorStepEnum } from '../PdsGoalCalculatorHelper'; import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; 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'); + +const mockedUseSteps = useSteps as jest.MockedFunction; + +const stub = (step: PdsGoalCalculatorStepEnum): PdsGoalCalculatorStep => ({ + step, + title: step, + icon: , + sections: [], +}); + +const detailedSteps: PdsGoalCalculatorSteps = [ + stub(PdsGoalCalculatorStepEnum.Setup), + stub(PdsGoalCalculatorStepEnum.ReimbursableExpenses), + stub(PdsGoalCalculatorStepEnum.SupportItem), + stub(PdsGoalCalculatorStepEnum.SummaryReport), +]; + +const simpleSteps: PdsGoalCalculatorSteps = [ + stub(PdsGoalCalculatorStepEnum.Setup), + stub(PdsGoalCalculatorStepEnum.SupportItem), + stub(PdsGoalCalculatorStepEnum.SummaryReport), +]; + +const minimalSteps: PdsGoalCalculatorSteps = [ + 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}
+
{stepIndex}
+
{steps.length}
+ + + +
+ ); +}; + +beforeEach(() => { + mockedUseSteps.mockImplementation(actualUseSteps); +}); describe('PdsGoalCalculatorContext', () => { - it('provides steps and current step', () => { - const { result } = renderHook(() => usePdsGoalCalculator(), { - wrapper: ({ children }) => ( - {children} - ), - }); + 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('handleContinue advances to the next step', () => { + it('passes calculation.formType through to useSteps (Simple → 3 steps)', async () => { const { result } = renderHook(() => usePdsGoalCalculator(), { wrapper: ({ children }) => ( - {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(); + expect(result.current.stepIndex).toBe(0); act(() => result.current.handleContinue()); @@ -29,11 +127,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 +137,128 @@ 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', () => { + const reconcileMessage = + 'Returned to Setup because the current step is no longer available.'; + + 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', + expectSnackbar: false, + }, + { + name: 'falls back to Setup and notifies when current step does not exist in new form', + initialSteps: detailedSteps, + click: 'go to reimbursable', + newSteps: simpleSteps, + expectedStep: PdsGoalCalculatorStepEnum.Setup, + expectedIndex: '0', + expectSnackbar: true, + }, + { + name: 'reconciles to the first step and notifies when an active step past index 1 disappears', + initialSteps: detailedSteps, + click: 'go to support item', + newSteps: minimalSteps, + expectedStep: PdsGoalCalculatorStepEnum.Setup, + expectedIndex: '0', + expectSnackbar: true, + }, + { + 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', + expectSnackbar: false, + }, + ])( + '$name', + async ({ + initialSteps, + click, + newSteps, + expectedStep, + expectedIndex, + expectSnackbar, + }) => { + mockedUseSteps.mockReturnValue(initialSteps); + const { findByTestId, findByText, getByRole, queryByText, 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, + ); + + const snackbar = expectSnackbar + ? await findByText(reconcileMessage) + : queryByText(reconcileMessage); + expect(Boolean(snackbar)).toBe(expectSnackbar); + }, + ); + + it('reconciles activeStep state so toggling formType back does not teleport the user', async () => { + mockedUseSteps.mockReturnValue(detailedSteps); + const { findByTestId, getByRole, rerender } = render( + + + , + ); + + userEvent.click(getByRole('button', { name: 'go to reimbursable' })); + + mockedUseSteps.mockReturnValue(simpleSteps); + rerender( + + + , + ); + + expect(await findByTestId('current-step')).toHaveTextContent( + PdsGoalCalculatorStepEnum.Setup, + ); + + mockedUseSteps.mockReturnValue(detailedSteps); + rerender( + + + , + ); + + expect(await findByTestId('current-step')).toHaveTextContent( + PdsGoalCalculatorStepEnum.Setup, + ); + expect(await findByTestId('step-index')).toHaveTextContent('0'); + }); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index 3611a04148..552243cb9a 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -1,7 +1,14 @@ 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 { DesignationSupportFormType } from 'src/graphql/types.generated'; import { useTrackMutation } from 'src/hooks/useTrackMutation'; import { PdsGoalCalculationFieldsFragment, @@ -13,10 +20,14 @@ import { usePdsSummaryData, } from '../calculations/usePdsSummaryData'; import { HcmUserQuery, useHcmUserQuery } from './HCM.generated'; -import { PdsGoalCalculatorStep, useSteps } from './useSteps'; +import { + PdsGoalCalculatorStep, + PdsGoalCalculatorSteps, + useSteps, +} from './useSteps'; export type PdsGoalCalculatorType = { - steps: PdsGoalCalculatorStep[]; + steps: PdsGoalCalculatorSteps; currentStep: PdsGoalCalculatorStep; calculation?: PdsGoalCalculationFieldsFragment; @@ -78,20 +89,46 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const summaryData = usePdsSummaryData(calculation, hcmUser); - const steps = useSteps(); - const [stepIndex, setStepIndex] = useState(0); + 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. + // When it doesn't exist, the effect below reconciles activeStep to the first + // step and notifies the user. + const [activeStep, setActiveStep] = useState( + PdsGoalCalculatorStepEnum.Setup, + ); const [rightPanelContent, setRightPanelContent] = useState(null); const [isDrawerOpen, setIsDrawerOpen] = useState(true); const { trackMutation, isMutating } = useTrackMutation(); - const currentStep = steps[stepIndex]; + useEffect(() => { + if (steps.some((s) => s.step === activeStep)) { + return; + } + setActiveStep(steps[0]?.step ?? PdsGoalCalculatorStepEnum.Setup); + enqueueSnackbar( + t('Returned to Setup because the current step is no longer available.'), + { variant: 'info' }, + ); + }, [steps, activeStep, enqueueSnackbar, t]); + + const stepIndex = useMemo(() => { + const idx = steps.findIndex((s) => s.step === activeStep); + 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 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', @@ -103,15 +140,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.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/Shared/formType.ts b/src/components/HrTools/PdsGoalCalculator/Shared/formType.ts new file mode 100644 index 0000000000..1b21671a75 --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Shared/formType.ts @@ -0,0 +1,5 @@ +import { DesignationSupportFormType } from 'src/graphql/types.generated'; + +export const isSimpleFormType = ( + formType: DesignationSupportFormType, +): boolean => formType === DesignationSupportFormType.Simple; 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..72ff2ea48d --- /dev/null +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.test.tsx @@ -0,0 +1,29 @@ +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((step) => step.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((step) => step.step)).toEqual([ + PdsGoalCalculatorStepEnum.Setup, + PdsGoalCalculatorStepEnum.SupportItem, + PdsGoalCalculatorStepEnum.SummaryReport, + ]); + }); +}); diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx index c175374c50..52fa7d7245 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/useSteps.tsx @@ -4,7 +4,9 @@ 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'; +import { isSimpleFormType } from './formType'; export interface PdsGoalCalculatorSection { title: string; @@ -18,44 +20,52 @@ export interface PdsGoalCalculatorStep { sections: PdsGoalCalculatorSection[]; } -export const useSteps = (): PdsGoalCalculatorStep[] => { +export type PdsGoalCalculatorSteps = [ + PdsGoalCalculatorStep, + ...PdsGoalCalculatorStep[], +]; + +export const useSteps = ( + formType: DesignationSupportFormType, +): PdsGoalCalculatorSteps => { const { t } = useTranslation(); - const steps = useMemo( - () => [ - { - step: PdsGoalCalculatorStepEnum.Setup, - title: t('Settings'), - icon: , - sections: [{ title: t('Setup'), complete: false }], - }, - { - step: PdsGoalCalculatorStepEnum.ReimbursableExpenses, - title: t('Reimbursable Expenses'), - icon: , - sections: [ - { title: t('Monthly Reimbursable Expenses'), complete: false }, - { title: t('Annual Reimbursable Expenses'), complete: false }, - ], - }, - { - step: PdsGoalCalculatorStepEnum.SupportItem, - title: t('Support Item'), - icon: , - sections: [ - { title: t('Salary'), complete: false }, - { title: t('Other'), complete: false }, - ], - }, - { - step: PdsGoalCalculatorStepEnum.SummaryReport, - title: t('Summary Report'), - icon: , - sections: [{ title: t('MPD Goal'), complete: false }], - }, - ], - [t], - ); + 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 steps; + return isSimple + ? [setup, supportItem, summaryReport] + : [setup, reimbursableExpenses, supportItem, summaryReport]; + }, [t, formType]); }; diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/PdsSummaryTable.test.tsx index bdf2331d5a..aa128b807c 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,95 @@ 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 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( + + + , + ); + + 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..478bf40256 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 { @@ -12,6 +15,7 @@ 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 { @@ -85,6 +89,9 @@ 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 rows: PdsSummaryRow[] = [ // Salary section @@ -104,16 +111,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 ? [ { 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..e5f381063d 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx @@ -3,8 +3,12 @@ import InfoIcon from '@mui/icons-material/Info'; import { Box, Tooltip, styled } from '@mui/material'; import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { TFunction } from 'i18next'; -import { DesignationSupportStatus } from 'src/graphql/types.generated'; +import { + DesignationSupportFormType, + DesignationSupportStatus, +} from 'src/graphql/types.generated'; import { currencyFormat, percentageFormat } from 'src/lib/intlFormat'; +import { isSimpleFormType } from '../Shared/formType'; import { OtherExpensesConstants, OtherExpensesFields, @@ -40,28 +44,36 @@ export const buildOtherBreakdownRows = ( constants, ); + const isSimple = isSimpleFormType( + calculation.formType ?? DesignationSupportFormType.Detailed, + ); + 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 +97,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 { diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts index 41f274b43f..27fd79a857 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 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); + }); + + 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..4a7b8c7e6f 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts @@ -1,15 +1,22 @@ +import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { GoalGeographicConstantMap, 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'; @@ -72,6 +79,25 @@ export const buildPdsGoalConstants = ( }; }; +export const buildOtherExpensesConstants = ( + formType: DesignationSupportFormType, + 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, @@ -81,18 +107,17 @@ export const calculatePdsGoalTotal = ( employerFicaRate: constants.employerFicaRate, }); - const reimbursableTotals = calculateReimbursableTotals(calculation); + const reimbursableTotal = calculateReimbursableTotals(calculation).total; - const otherExpenses = calculateOtherExpenses(calculation, { - reimbursableTotal: reimbursableTotals.total, - salarySubtotal: salaryTotals.subtotal, - fourOThreeBPercentage: constants.fourOThreeBPercentage, - grossMonthlyPay: salaryTotals.grossMonthlyPay, - workCompPercentage: constants.workCompPercentage, - attritionRate: constants.attritionRate, - creditCardFeeRate: constants.creditCardFeeRate, - adminRate: constants.adminRate, - }); + const otherExpenses = calculateOtherExpenses( + calculation, + buildOtherExpensesConstants( + calculation.formType ?? DesignationSupportFormType.Detailed, + constants, + salaryTotals, + reimbursableTotal, + ), + ); return otherExpenses.assessment; }; diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts index 04cbfbafd5..fc8a70e068 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, @@ -16,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'); @@ -97,6 +102,7 @@ const defaultCalculation = gqlMock( mocks: { salaryOrHourly: DesignationSupportSalaryType.Salaried, status: DesignationSupportStatus.FullTime, + formType: DesignationSupportFormType.Detailed, payRate: 60000, benefits: 1500, ministryCellPhone: 100, @@ -332,4 +338,104 @@ 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, + ); + }); + }); + + 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); + }, + ); + }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts index 9717e072ef..0dc642c228 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'; @@ -7,6 +8,10 @@ import { OtherExpensesTotals, calculateOtherExpenses, } from './OtherExpenses'; +import { + buildOtherExpensesConstants, + buildPdsGoalConstants, +} from './calculatePdsGoalTotal'; import { ReimbursableTotals, calculateReimbursableTotals, @@ -39,50 +44,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 otherConstants: OtherExpensesConstants = { - reimbursableTotal: reimbursableTotals.total, - salarySubtotal: salaryTotals.subtotal, - fourOThreeBPercentage: taxDeferredPct + rothPct, - grossMonthlyPay: salaryTotals.grossMonthlyPay, - workCompPercentage, - attritionRate, - creditCardFeeRate, - adminRate, - }; + const otherConstants = buildOtherExpensesConstants( + calculation.formType ?? DesignationSupportFormType.Detailed, + constants, + salaryTotals, + reimbursableTotals.total, + ); const otherTotals = calculateOtherExpenses(calculation, otherConstants); const overallTotal = @@ -98,7 +82,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 f1802780d6..3523a68c31 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,17 +107,21 @@ export const GoalCard: React.FC = ({ /> - + {displayName} + {badge}