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 (
+
+ );
+};
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 = () => {
+
+
+
+
+
+
+
{
)}
-
- ,
- }}
- />
-
+ {!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}