From 0f72c6fcbc48d5f5ad239a7b199482078c8c3291 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 18 May 2026 11:37:45 -0400 Subject: [PATCH 1/4] Submit button and modal on PDS GC --- .../PdsGoalCalculator/PdsGoalCalculator.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx index 2cbb3403cd..b64886aa99 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { DirectionButtons } from 'src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons'; import { AutosaveForm, @@ -47,6 +48,7 @@ const CurrentSectionList: React.FC = () => { }; const MainContent: React.FC = () => { + const { t } = useTranslation(); const { currentStep, stepIndex, steps, handleContinue, handlePreviousStep } = usePdsGoalCalculator(); const { allValid } = useAutosaveForm(); @@ -54,15 +56,28 @@ const MainContent: React.FC = () => { const isFirstStep = stepIndex === 0; const isLastStep = stepIndex === steps.length - 1; + const handleSubmitGoal = async () => {}; + const validateSubmitGoal = async () => ({}); + return ( <> From a1181753e6ef915231e2bbfa6cd928ade961ea2f Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 19 May 2026 15:31:25 -0400 Subject: [PATCH 2/4] Fix tooltip --- .../PdsGoalCalculator/PdsGoalCalculator.tsx | 62 ++++++++++++++----- .../DirectionButtons/DirectionButtons.tsx | 7 ++- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx index b64886aa99..16a902601c 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx @@ -1,10 +1,15 @@ import React 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'; @@ -49,36 +54,61 @@ const CurrentSectionList: React.FC = () => { const MainContent: React.FC = () => { const { t } = useTranslation(); - const { currentStep, stepIndex, steps, handleContinue, handlePreviousStep } = - usePdsGoalCalculator(); + 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 isFirstStep = stepIndex === 0; const isLastStep = stepIndex === steps.length - 1; - const handleSubmitGoal = async () => {}; - const validateSubmitGoal = async () => ({}); + const handleSubmitGoal = async () => { + const monthlyGoal = Math.round(summaryData?.overallTotal ?? 0); + await updateAccountPreferences({ + variables: { + input: { + id: accountListId, + attributes: { + id: accountListId, + settings: { monthlyGoal }, + }, + }, + }, + onCompleted: () => { + enqueueSnackbar( + t('Successfully updated your monthly goal to {{formattedTotal}}!', { + formattedTotal: currencyFormat(monthlyGoal, 'USD', locale), + }), + { variant: 'success' }, + ); + }, + }); + }; return ( <> ); diff --git a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx index b4266f250c..5a1c71c8b3 100644 --- a/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx +++ b/src/components/HrTools/Shared/CalculationReports/DirectionButtons/DirectionButtons.tsx @@ -23,6 +23,7 @@ interface DirectionButtonsProps { splitAsr?: boolean; disableSubmit?: boolean; disableNext?: boolean; + disabledNextTooltip?: string; //Formik validation for submit modal isSubmission?: boolean; submitForm?: () => Promise; @@ -55,6 +56,7 @@ export const DirectionButtons: React.FC = ({ splitAsr, disableSubmit, disableNext, + disabledNextTooltip, }) => { const { t } = useTranslation(); @@ -143,7 +145,10 @@ export const DirectionButtons: React.FC = ({ ) : ( From 69aa43252a5979764ede00083bb34b2b7d494e0c Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 19 May 2026 16:33:15 -0400 Subject: [PATCH 3/4] PR fixes --- .../PdsGoalCalculator.test.tsx | 168 ++++++++++++++++++ .../PdsGoalCalculator/PdsGoalCalculator.tsx | 20 ++- .../DirectionButtons.test.tsx | 66 +++++++ .../DirectionButtons/DirectionButtons.tsx | 19 +- 4 files changed, 264 insertions(+), 9 deletions(-) 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 16a902601c..dfd8d3629c 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.tsx @@ -1,4 +1,4 @@ -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'; @@ -56,7 +56,7 @@ const MainContent: React.FC = () => { const { t } = useTranslation(); const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); - const accountListId = useAccountListId() ?? ''; + const accountListId = useAccountListId(); const { currentStep, stepIndex, @@ -68,12 +68,16 @@ const MainContent: React.FC = () => { 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 () => { - const monthlyGoal = Math.round(summaryData?.overallTotal ?? 0); + if (!accountListId || !summaryData?.overallTotal) { + return; + } + const monthlyGoal = Math.round(summaryData.overallTotal); await updateAccountPreferences({ variables: { input: { @@ -84,7 +88,9 @@ const MainContent: React.FC = () => { }, }, }, + refetchQueries: ['GetDashboard', 'GetDonationGraph'], onCompleted: () => { + setSubmitted(true); enqueueSnackbar( t('Successfully updated your monthly goal to {{formattedTotal}}!', { formattedTotal: currencyFormat(monthlyGoal, 'USD', locale), @@ -105,10 +111,16 @@ const MainContent: React.FC = () => { showBackButton={!isFirstStep} buttonTitle={isLastStep ? t('Finish & Apply Goal') : undefined} overrideNext={isLastStep ? handleSubmitGoal : undefined} - disableNext={!allValid || (isLastStep && updating)} + 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/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 5a1c71c8b3..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'; @@ -24,6 +24,8 @@ interface DirectionButtonsProps { disableSubmit?: boolean; disableNext?: boolean; disabledNextTooltip?: string; + loadingNext?: boolean; + loadingNextTitle?: string; //Formik validation for submit modal isSubmission?: boolean; submitForm?: () => Promise; @@ -57,6 +59,8 @@ export const DirectionButtons: React.FC = ({ disableSubmit, disableNext, disabledNextTooltip, + loadingNext, + loadingNextTitle, }) => { const { t } = useTranslation(); @@ -145,7 +149,7 @@ export const DirectionButtons: React.FC = ({ ) : ( = ({ From 9d4840dcadf3bb1b507391dcb22a10a35c56f928 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 19 May 2026 16:40:31 -0400 Subject: [PATCH 4/4] Fix rounding in total in hours per calculator --- .../HoursPerWeekGrid.test.tsx | 19 ++++++++++--------- .../HoursPerWeekGrid/HoursPerWeekGrid.tsx | 5 +++++ 2 files changed, 15 insertions(+), 9 deletions(-) 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',