Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
23f2138
Add formType to PdsGoalCalculationFields fragment
wjames111 May 7, 2026
30b419c
Zero out reimbursable and 403b in summary data when formType is Simple
wjames111 May 7, 2026
79ade1f
Exclude reimbursable and 403b from PDS goal total when formType is Si…
wjames111 May 7, 2026
b7f4c38
Filter Reimbursable Expenses step from useSteps when formType is Simple
wjames111 May 7, 2026
df39d3a
Clamp PDS stepIndex when steps array shrinks (defensive)
wjames111 May 7, 2026
9461d65
Add Form Type select and conditionally hide 403b field in SetupStep
wjames111 May 7, 2026
cc2d616
Constrain formType Yup schema to enum values; cover null formType leg…
wjames111 May 7, 2026
6f96c65
Hide Reimbursable Expenses and 403b summary rows when formType is Simple
wjames111 May 7, 2026
baeb297
Show Default/Simple badge on PdsGoalCard
wjames111 May 7, 2026
0e08043
Add CreateGoalDialog for picking form type at goal creation
wjames111 May 7, 2026
33e001a
Add visually-hidden FormLabel to CreateGoalDialog fieldset for a11y
wjames111 May 7, 2026
023fa02
Open CreateGoalDialog from PdsGoalsList; pass formType to create muta…
wjames111 May 7, 2026
29c0810
Add error handling, state reset, and aria-labelledby to PDS goal crea…
wjames111 May 7, 2026
da99bac
PR fixes
wjames111 May 8, 2026
e3d03e5
More PR fixes
wjames111 May 8, 2026
ea5386d
Fix chip styling on goalCard
wjames111 May 8, 2026
2ebf482
Hide reimbursable expenses and 403b references
wjames111 May 8, 2026
ee14bb1
Fix premature validation
wjames111 May 8, 2026
39d8493
Replace useState with formik
wjames111 May 8, 2026
ca41ce9
Remove backward compat on PDS goal types
wjames111 May 8, 2026
1f48081
More PR fixes
wjames111 May 8, 2026
da951cb
Runs prettier
wjames111 May 8, 2026
0510eec
And more PR fixes...
wjames111 May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
<PdsGoalCalculatorTestWrapper
withProvider={false}
calculationsMock={{
nodes: [{ name, formType }],
}}
>
<PdsGoalsList />
</PdsGoalCalculatorTestWrapper>,
);

await findByText(name);
expect(queryByText(expectedBadge)).toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,6 +20,7 @@ export interface PdsGoalCardProps {
}

export const PdsGoalCard: React.FC<PdsGoalCardProps> = ({ goal }) => {
const { t } = useTranslation();
const accountListId = useAccountListId() ?? '';
const [deletePdsGoalCalculation] = useDeletePdsGoalCalculationMutation();

Expand All @@ -38,6 +42,14 @@ export const PdsGoalCard: React.FC<PdsGoalCardProps> = ({ goal }) => {
return constants ? calculatePdsGoalTotal(goal, constants) : 0;
}, [goal, goalMiscConstants, goalGeographicConstantMap, hcmUser]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] [Pre-existing] **`PdsGoalCard.goalAmount` shows `assessment` (~$1,038) while `PdsSummaryTable` "Total Goal" shows `overallTotal` (~$9,816)** — a ~9× discrepancy on the same goal.
  • calculatePdsGoalTotal() returns otherExpenses.assessment only (the assessment line: (subtotal + creditCardFees + attrition) * adminRate).
  • usePdsSummaryData.overallTotal = otherTotals.subtotal + otherTotals.attrition + otherTotals.creditCardFees + otherTotals.assessment.
  • The unit tests pin these distinct values: expect(result).toBeCloseTo(1038.21, 1) for calculatePdsGoalTotal vs expect(overallTotal).toBeCloseTo(9815.77, 0).

Pre-dates this PR (introduced in commit edfa70868 per the goal-card landing PR #1755), but this PR makes the inconsistency more visible by adding the Default/Simple badge directly next to the card amount. Staff comparing card-vs-summary will see two very different numbers labeled "goal."

Recommended follow-up (not blocking this PR):

  • Either change PdsGoalCard.goalTotal to use usePdsSummaryData(goal, hcmUser).overallTotal, or rename calculatePdsGoalTotal to calculatePdsAssessment and add a calculatePdsGoalOverallTotal helper. Current naming is semantically misleading.

Flag for a separate ticket — does not block this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump


const formType = goal.formType ?? DesignationSupportFormType.Detailed;
const formTypeBadge =
formType === DesignationSupportFormType.Simple ? (
<Chip label={t('Simple')} size="small" variant="outlined" />
) : (
<Chip label={t('Default')} size="small" />
);

const handleDelete = async () => {
await deletePdsGoalCalculation({
variables: { id: goal.id },
Expand All @@ -57,6 +69,7 @@ export const PdsGoalCard: React.FC<PdsGoalCardProps> = ({ goal }) => {
updatedAt={goal.updatedAt}
viewHref={`/accountLists/${accountListId}/hrTools/pdsGoalCalculator/${goal.id}`}
onDelete={handleDelete}
badge={formTypeBadge}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof CreateGoalDialog>> = {},
) => {
const defaults = {
open: true,
onClose: jest.fn(),
onCreate: jest.fn().mockResolvedValue(undefined),
};
const utils = render(
<ThemeProvider theme={theme}>
<CreateGoalDialog {...defaults} {...props} />
</ThemeProvider>,
);
const rerenderDialog = (
nextProps: Partial<React.ComponentProps<typeof CreateGoalDialog>> = {},
) =>
utils.rerender(
<ThemeProvider theme={theme}>
<CreateGoalDialog {...defaults} {...nextProps} />
</ThemeProvider>,
);
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<void>(() => {}));
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();
});
});
Original file line number Diff line number Diff line change
@@ -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<void>;
}

interface FormValues {
formType: DesignationSupportFormType | '';
}

const schema = yup.object({
formType: yup
.string()
.oneOf(Object.values(DesignationSupportFormType))
.required(),
});

export const CreateGoalDialog: React.FC<CreateGoalDialogProps> = ({
open,
onClose,
onCreate,
}) => {
const { t } = useTranslation();
const formikRef = useRef<FormikProps<FormValues>>(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 (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
aria-labelledby="create-goal-dialog-title"
>
<DialogTitle id="create-goal-dialog-title">
{t('Create a New Goal')}
</DialogTitle>
<Formik<FormValues>
innerRef={formikRef}
initialValues={{ formType: '' }}
validationSchema={schema}
onSubmit={async ({ formType }) => {
if (formType) {
await onCreate(formType);
}
}}
>
{({ values, isSubmitting, handleChange, handleSubmit }) => (
<form onSubmit={handleSubmit}>
<DialogContent>
<FormControl component="fieldset">
<FormLabel sx={visuallyHidden}>
{t('Select a form type')}
</FormLabel>
<RadioGroup
name="formType"
value={values.formType}
onChange={handleChange}
>
{formTypeOptions.map(
({ value, title, description }, index) => (
<FormControlLabel
key={value}
value={value}
control={
<Radio
inputProps={{
'aria-labelledby': `${value}-title`,
'aria-describedby': `${value}-desc`,
}}
/>
}
label={
<>
<Typography
id={`${value}-title`}
variant="subtitle1"
component="span"
display="block"
>
{title}
</Typography>
<Typography
id={`${value}-desc`}
variant="body2"
color="text.secondary"
component="span"
display="block"
>
{description}
</Typography>
</>
}
sx={{
alignItems: 'flex-start',
mb: index < formTypeOptions.length - 1 ? 2 : 0,
}}
/>
),
)}
</RadioGroup>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button
type="submit"
variant="contained"
disabled={!values.formType || isSubmitting}
startIcon={
isSubmitting ? (
<CircularProgress size={16} color="inherit" />
) : null
}
>
{t('Create')}
</Button>
</DialogActions>
</form>
)}
</Formik>
</Dialog>
);
};

Check warning on line 171 in src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: Large Method

CreateGoalDialog:React.FC<CreateGoalDialogProps> has 130 lines, threshold = 100. Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
fragment PdsGoalCalculationFields on DesignationSupportCalculation {
id
formType
name
updatedAt
averageHoursPerWeek
Expand Down
Loading
Loading