diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx
index 83ddbae96a..7adc73d2b7 100644
--- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx
@@ -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(
@@ -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(
+
+
+ ,
+ );
+
+ 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(
{
});
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['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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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();
+ });
+ });
});
diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx
index 2cbb3403cd..dfd8d3629c 100644
--- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx
@@ -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';
@@ -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 () => {
+ if (!accountListId || !summaryData?.overallTotal) {
+ return;
+ }
+ const monthlyGoal = Math.round(summaryData.overallTotal);
+ await updateAccountPreferences({
+ variables: {
+ input: {
+ id: accountListId,
+ attributes: {
+ id: accountListId,
+ settings: { monthlyGoal },
+ },
+ },
+ },
+ refetchQueries: ['GetDashboard', 'GetDonationGraph'],
+ onCompleted: () => {
+ setSubmitted(true);
+ enqueueSnackbar(
+ t('Successfully updated your monthly goal to {{formattedTotal}}!', {
+ formattedTotal: currencyFormat(monthlyGoal, 'USD', locale),
+ }),
+ { variant: 'success' },
+ );
+ },
+ });
+ };
+
return (
<>
@@ -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}
+ disableNext={
+ !allValid ||
+ (isLastStep &&
+ (submitted || !summaryData?.overallTotal || !accountListId))
+ }
+ disabledNextTooltip={
+ isLastStep ? t('Complete all required fields to submit') : undefined
+ }
+ loadingNext={isLastStep && updating}
+ loadingNextTitle={t('Saving...')}
/>
>
);
diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx
index 50a7a1e29b..43e33f913e 100644
--- a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx
@@ -335,14 +335,15 @@ describe('HoursPerWeekGrid', () => {
});
it('renders 0.00 (not NaN) when total weeks is zero', async () => {
- const { findByText, getByDisplayValue, queryByText } = render(
-
-
- ,
- );
+ const { findByText, findAllByText, getByDisplayValue, queryByText } =
+ render(
+
+
+ ,
+ );
await waitForDataToLoad();
@@ -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 () => {
diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx
index 5d8af2daa7..1283f005eb 100644
--- a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx
@@ -416,6 +416,11 @@ export const HoursPerWeekGrid: React.FC = ({
}
return (row.hoursPerWeek ?? 0) * (row.weeks ?? 0);
},
+ valueFormatter: (value: number) =>
+ numberFormat(value ?? 0, locale, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }),
},
{
field: 'actions',
diff --git a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx
index 540cf55729..18f8750e61 100644
--- a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx
+++ b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.test.tsx
@@ -26,6 +26,9 @@ interface TestComponentProps {
buttonTitle?: string;
isEdit?: boolean;
disableNext?: boolean;
+ disabledNextTooltip?: string;
+ loadingNext?: boolean;
+ loadingNextTitle?: string;
noDiscard?: boolean;
}
@@ -36,6 +39,9 @@ const TestComponent: React.FC = ({
buttonTitle,
isEdit,
disableNext,
+ disabledNextTooltip,
+ loadingNext,
+ loadingNextTitle,
noDiscard = false,
}) => (
@@ -59,6 +65,9 @@ const TestComponent: React.FC = ({
buttonTitle={buttonTitle}
isEdit={isEdit}
disableNext={disableNext}
+ disabledNextTooltip={disabledNextTooltip}
+ loadingNext={loadingNext}
+ loadingNextTitle={loadingNextTitle}
/>
@@ -157,6 +166,63 @@ describe('DirectionButtons', () => {
});
});
+ it('shows the custom disabledNextTooltip when provided and Next is disabled', async () => {
+ const { findByRole, findByText, queryByText } = render(
+ ,
+ );
+
+ const continueButton = await findByRole('button', { name: 'Continue' });
+ expect(continueButton).toBeDisabled();
+
+ userEvent.hover(continueButton.parentElement!);
+
+ expect(
+ await findByText('Complete all required fields to submit'),
+ ).toBeInTheDocument();
+ expect(
+ queryByText('Complete all required fields to continue'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the loadingNextTitle with an in-button spinner and disables the button while loadingNext is true', async () => {
+ const { findByRole } = render(
+ ,
+ );
+
+ const savingButton = await findByRole('button', { name: 'Saving...' });
+ expect(savingButton).toBeDisabled();
+ expect(
+ savingButton.querySelector('.MuiCircularProgress-root'),
+ ).toBeInTheDocument();
+ });
+
+ it('suppresses the disabledNextTooltip while loadingNext is true', async () => {
+ const { findByRole, queryByText } = render(
+ ,
+ );
+
+ const savingButton = await findByRole('button', { name: 'Saving...' });
+ userEvent.hover(savingButton.parentElement!);
+
+ await waitFor(() => {
+ expect(
+ queryByText('Complete all required fields to submit'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
it('renders Discard, Back, and Continue in left-to-right order', async () => {
const { findAllByRole } = render();
diff --git a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx
index b4266f250c..2dfe0593a3 100644
--- a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx
+++ b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
-import { Box, Button, Tooltip } from '@mui/material';
+import { Box, Button, CircularProgress, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { SubmitModal } from '../SubmitModal/SubmitModal';
@@ -23,6 +23,9 @@ interface DirectionButtonsProps {
splitAsr?: boolean;
disableSubmit?: boolean;
disableNext?: boolean;
+ disabledNextTooltip?: string;
+ loadingNext?: boolean;
+ loadingNextTitle?: string;
//Formik validation for submit modal
isSubmission?: boolean;
submitForm?: () => Promise;
@@ -55,6 +58,9 @@ export const DirectionButtons: React.FC = ({
splitAsr,
disableSubmit,
disableNext,
+ disabledNextTooltip,
+ loadingNext,
+ loadingNextTitle,
}) => {
const { t } = useTranslation();
@@ -143,18 +149,26 @@ export const DirectionButtons: React.FC = ({
) : (
}
+ endIcon={loadingNext ? undefined : }
+ startIcon={
+ loadingNext ? : undefined
+ }
onClick={overrideNext ?? handleNextStep}
- disabled={disableNext}
+ disabled={disableNext || loadingNext}
>
- {buttonTitle ?? t('Continue')}
+ {loadingNext
+ ? (loadingNextTitle ?? buttonTitle ?? t('Continue'))
+ : (buttonTitle ?? t('Continue'))}