Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
168 changes: 168 additions & 0 deletions src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ 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 { PdsGoalCalculator } from './PdsGoalCalculator';
import { PdsGoalCalculatorTestWrapper } from './PdsGoalCalculatorTestWrapper';

// Math.round(overallTotal) for the default PdsGoalCalculatorTestWrapper mock
// (Salaried full-time, $50k pay rate, $1.5k benefits, default rate constants).
// Salary subtotal $4,500 + benefits $1,500 = $6,000; after attrition / CC fees
// / admin assessment this rounds to $7,689. See usePdsSummaryData.
const EXPECTED_MONTHLY_GOAL = 7689;

describe('PdsGoalCalculator', () => {
it('renders the setup step by default', () => {
const { getByRole } = render(
Expand Down Expand Up @@ -149,6 +156,27 @@ describe('PdsGoalCalculator', () => {
).toBeInTheDocument();
});

it('disables Finish & Apply Goal on the Summary Report step when summary data is unavailable', async () => {
// Empty misc constants make `buildPdsGoalConstants` return null, so
// `summaryData` stays null even though every form field is valid. Without
// the guard on `disableNext`, the button would be enabled and clicking it
// would submit monthlyGoal: 0 — overwriting any prior real goal.
const { findByRole } = render(
<PdsGoalCalculatorTestWrapper
constantsMock={{ mpdGoalMiscConstants: [] }}
>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

userEvent.click(await findByRole('button', { name: 'Summary Report' }));

const finishButton = await findByRole('button', {
name: /Finish & Apply Goal/i,
});
await waitFor(() => expect(finishButton).toBeDisabled());
});

it('re-enables Continue when switching from Full-time to Part-time hides the only invalid field', async () => {
const { findByRole, getByRole, queryByRole } = render(
<PdsGoalCalculatorTestWrapper
Expand Down Expand Up @@ -184,4 +212,144 @@ describe('PdsGoalCalculator', () => {
});
await waitFor(() => expect(continueButton).not.toBeDisabled());
});

describe('submit goal flow', () => {
// Use Simple formType so the Detailed-only ReimbursableExpenses step is
// skipped (Setup → SupportItem → SummaryReport), keeping the test focused
// on the last-step submit rather than mid-flow navigation.
const simpleFormMock = {
formType: DesignationSupportFormType.Simple,
};

const advanceToLastStep = async (
findByRole: ReturnType<typeof render>['findByRole'],
) => {
// Step 1 → 2: Continue is initially disabled while autosave fields
// register their validity; the default mock data is fully valid.
let next = await findByRole('button', { name: /continue/i });
await waitFor(() => expect(next).not.toBeDisabled());
userEvent.click(next);

// Step 2 → 3: SupportItem has no autosave-tracked fields, so allValid
// stays true and Continue is enabled immediately.
next = await findByRole('button', { name: /continue/i });
await waitFor(() => expect(next).not.toBeDisabled());
userEvent.click(next);
};

it('renders "Finish & Apply Goal" instead of "Continue" on the last step', async () => {
const { findByRole, queryByRole } = render(
<PdsGoalCalculatorTestWrapper calculationMock={simpleFormMock}>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

await advanceToLastStep(findByRole);

expect(
await findByRole('button', { name: 'Finish & Apply Goal' }),
).toBeInTheDocument();
expect(
queryByRole('button', { name: /^continue$/i }),
).not.toBeInTheDocument();
});

it('submits the rounded monthlyGoal for the current accountListId', async () => {
const mutationSpy = jest.fn();
const { findByRole } = render(
<PdsGoalCalculatorTestWrapper
calculationMock={simpleFormMock}
onCall={mutationSpy}
>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

await advanceToLastStep(findByRole);

const finishButton = await findByRole('button', {
name: 'Finish & Apply Goal',
});
userEvent.click(finishButton);

await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation('UpdateAccountPreferences', {
input: {
id: 'abc123',
attributes: {
id: 'abc123',
settings: { monthlyGoal: EXPECTED_MONTHLY_GOAL },
},
},
}),
);
});

it('shows a success snackbar with the formatted monthly goal on completion', async () => {
const { findByRole, findByText } = render(
<PdsGoalCalculatorTestWrapper calculationMock={simpleFormMock}>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

await advanceToLastStep(findByRole);

userEvent.click(
await findByRole('button', { name: 'Finish & Apply Goal' }),
);

expect(
await findByText(
`Successfully updated your monthly goal to $${EXPECTED_MONTHLY_GOAL.toLocaleString(
'en-US',
)}!`,
),
).toBeInTheDocument();
});

it('shows a "Saving..." label and disables the button while the mutation is in flight', async () => {
const { findByRole, getByRole } = render(
<PdsGoalCalculatorTestWrapper calculationMock={simpleFormMock}>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

await advanceToLastStep(findByRole);

const finishButton = await findByRole('button', {
name: 'Finish & Apply Goal',
});
userEvent.click(finishButton);

// Synchronously after click, the mutation is pending — the button label
// becomes "Saving..." with an in-button spinner and stays disabled to
// prevent a double-submit.
const savingButton = getByRole('button', { name: 'Saving...' });
expect(savingButton).toBeDisabled();
expect(
savingButton.querySelector('.MuiCircularProgress-root'),
).toBeInTheDocument();
});

it('keeps the Finish & Apply Goal button disabled after a successful submission', async () => {
const { findByRole, findByText } = render(
<PdsGoalCalculatorTestWrapper calculationMock={simpleFormMock}>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

await advanceToLastStep(findByRole);

const finishButton = await findByRole('button', {
name: 'Finish & Apply Goal',
});
userEvent.click(finishButton);

// Wait for the success snackbar so we know the mutation has settled,
// then verify the button stays disabled — otherwise a blur+click or
// double-click after success would fire a second mutation.
await findByText(/Successfully updated your monthly goal/);
expect(finishButton).toBeDisabled();
});
});
});
67 changes: 62 additions & 5 deletions src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import React from 'react';
import React, { useState } from 'react';
import { useSnackbar } from 'notistack';
import { useTranslation } from 'react-i18next';
import { DirectionButtons } from 'src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons';
import { useUpdateAccountPreferencesMutation } from 'src/components/Settings/preferences/accordions/UpdateAccountPreferences.generated';
import {
AutosaveForm,
useAutosaveForm,
} from 'src/components/Shared/Autosave/AutosaveForm';
import { useAccountListId } from 'src/hooks/useAccountListId';
import { useLocale } from 'src/hooks/useLocale';
import { currencyFormat } from 'src/lib/intlFormat';
import { PdsGoalCalculatorStepEnum } from './PdsGoalCalculatorHelper';
import { ReimbursableExpensesSectionList } from './ReimbursableExpenses/ReimbursableExpensesSectionList';
import { ReimbursableExpensesStep } from './ReimbursableExpenses/ReimbursableExpensesStep';
Expand Down Expand Up @@ -47,13 +53,54 @@ const CurrentSectionList: React.FC = () => {
};

const MainContent: React.FC = () => {
const { currentStep, stepIndex, steps, handleContinue, handlePreviousStep } =
usePdsGoalCalculator();
const { t } = useTranslation();
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const accountListId = useAccountListId();
const {
currentStep,
stepIndex,
steps,
summaryData,
handleContinue,
handlePreviousStep,
} = usePdsGoalCalculator();
const { allValid } = useAutosaveForm();
const [updateAccountPreferences, { loading: updating }] =
useUpdateAccountPreferencesMutation();
const [submitted, setSubmitted] = useState(false);

const isFirstStep = stepIndex === 0;
const isLastStep = stepIndex === steps.length - 1;

const handleSubmitGoal = async () => {
Comment thread
wjames111 marked this conversation as resolved.
if (!accountListId || !summaryData?.overallTotal) {
return;
}
const monthlyGoal = Math.round(summaryData.overallTotal);
await updateAccountPreferences({
Comment thread
wjames111 marked this conversation as resolved.
variables: {
input: {
id: accountListId,
attributes: {
id: accountListId,
settings: { monthlyGoal },
},
},
},
refetchQueries: ['GetDashboard', 'GetDonationGraph'],
onCompleted: () => {
Comment thread
wjames111 marked this conversation as resolved.
setSubmitted(true);
enqueueSnackbar(
t('Successfully updated your monthly goal to {{formattedTotal}}!', {
formattedTotal: currencyFormat(monthlyGoal, 'USD', locale),
}),
{ variant: 'success' },
);
},
});
};

return (
<>
<CurrentStep />
Expand All @@ -62,8 +109,18 @@ const MainContent: React.FC = () => {
handleNextStep={handleContinue}
handlePreviousStep={handlePreviousStep}
showBackButton={!isFirstStep}
hideNextButton={isLastStep}
disableNext={!allValid}
buttonTitle={isLastStep ? t('Finish & Apply Goal') : undefined}
overrideNext={isLastStep ? handleSubmitGoal : undefined}
Comment thread
wjames111 marked this conversation as resolved.
disableNext={
!allValid ||
(isLastStep &&
(submitted || !summaryData?.overallTotal || !accountListId))
}
disabledNextTooltip={
isLastStep ? t('Complete all required fields to submit') : undefined
}
loadingNext={isLastStep && updating}
loadingNextTitle={t('Saving...')}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,14 +335,15 @@ describe('HoursPerWeekGrid', () => {
});

it('renders 0.00 (not NaN) when total weeks is zero', async () => {
const { findByText, getByDisplayValue, queryByText } = render(
<PdsGoalCalculatorTestWrapper
calculationMock={defaultCalculationMock}
onCall={mutationSpy}
>
<HoursPerWeekGrid />
</PdsGoalCalculatorTestWrapper>,
);
const { findByText, findAllByText, getByDisplayValue, queryByText } =
render(
<PdsGoalCalculatorTestWrapper
calculationMock={defaultCalculationMock}
onCall={mutationSpy}
>
<HoursPerWeekGrid />
</PdsGoalCalculatorTestWrapper>,
);

await waitForDataToLoad();

Expand All @@ -362,7 +363,7 @@ describe('HoursPerWeekGrid', () => {
await waitFor(() => {
expect(queryByText('NaN')).not.toBeInTheDocument();
});
expect(await findByText('0.00')).toBeInTheDocument();
expect((await findAllByText('0.00')).length).toBeGreaterThan(0);
});

it('clamps weeks to 52 total across all entries', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,11 @@
}
return (row.hoursPerWeek ?? 0) * (row.weeks ?? 0);
},
valueFormatter: (value: number) =>
numberFormat(value ?? 0, locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),

Check warning on line 423 in src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

HoursPerWeekGrid:React.FC<HoursPerWeekGridProps> already has high cyclomatic complexity, and now it increases in Lines of Code from 436 to 441. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
},
{
field: 'actions',
Expand Down
Loading
Loading