diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx
index 6335996a6d..1288cd63a7 100644
--- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx
@@ -158,6 +158,82 @@ describe('PdsGoalCalculator', () => {
).toBeInTheDocument();
});
+ it('disables non-Setup step icons when Setup fields are invalid', async () => {
+ const { findByRole } = render(
+
+
+ ,
+ );
+
+ expect(
+ await findByRole('button', { name: 'Reimbursable Expenses' }),
+ ).toHaveAttribute('aria-disabled', 'true');
+ expect(
+ await findByRole('button', { name: 'Support Item' }),
+ ).toHaveAttribute('aria-disabled', 'true');
+ expect(
+ await findByRole('button', { name: 'Summary Report' }),
+ ).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('enables non-Setup step icons when all Setup fields are valid', async () => {
+ const { getByRole } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ getByRole('button', { name: 'Reimbursable Expenses' }),
+ ).not.toHaveAttribute('aria-disabled', 'true');
+ });
+ expect(getByRole('button', { name: 'Support Item' })).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
+ expect(getByRole('button', { name: 'Summary Report' })).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
+ });
+
+ it('shows tooltip on disabled side panel icons', async () => {
+ const { findByRole, findByText } = render(
+
+
+ ,
+ );
+
+ const reimbursableButton = await findByRole('button', {
+ name: 'Reimbursable Expenses',
+ });
+ expect(reimbursableButton).toHaveAttribute('aria-disabled', 'true');
+ userEvent.hover(reimbursableButton);
+
+ expect(
+ await findByText('Complete all required fields to continue'),
+ ).toBeInTheDocument();
+ });
+
+ it('re-enables side panel navigation after advancing from the Setup step', async () => {
+ const { findByRole } = render(
+
+
+ ,
+ );
+
+ const continueButton = await findByRole('button', { name: 'Continue' });
+ await waitFor(() => expect(continueButton).not.toBeDisabled());
+ userEvent.click(continueButton);
+
+ expect(
+ await findByRole('button', { name: 'Summary Report' }),
+ ).not.toHaveAttribute('aria-disabled', 'true');
+ });
+
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
@@ -171,7 +247,13 @@ describe('PdsGoalCalculator', () => {
,
);
- userEvent.click(await findByRole('button', { name: 'Summary Report' }));
+ const summaryReportButton = await findByRole('button', {
+ name: 'Summary Report',
+ });
+ await waitFor(() =>
+ expect(summaryReportButton).not.toHaveAttribute('aria-disabled', 'true'),
+ );
+ userEvent.click(summaryReportButton);
const finishButton = await findByRole('button', {
name: /Finish & Apply Goal/i,
diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx
index 728bdb74f4..be17e10d7f 100644
--- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx
@@ -6,6 +6,7 @@ import { SnackbarProvider } from 'notistack';
import { DeepPartial } from 'ts-essentials';
import TestRouter from '__tests__/util/TestRouter';
import { GqlMockedProvider, gqlMock } from '__tests__/util/graphqlMocking';
+import { AutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm';
import { GetUserQuery } from 'src/components/User/GetUser.generated';
import {
DesignationSupportFormType,
@@ -268,7 +269,9 @@ export const PdsGoalCalculatorTestWrapper: React.FC<
onCall={onCall}
>
{withProvider ? (
- {children}
+
+ {children}
+
) : (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>{children}>
diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx
index 83d60fd6fd..d8b325001c 100644
--- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useEffect, useMemo } from 'react';
import CalculateIcon from '@mui/icons-material/Calculate';
import {
Autocomplete,
@@ -17,6 +17,7 @@ import {
import { useTheme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import * as yup from 'yup';
+import { useAutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm';
import { useGetUserQuery } from 'src/components/User/GetUser.generated';
import {
DesignationSupportFormType,
@@ -38,8 +39,22 @@ import { HoursPerWeekGrid } from './HoursPerWeekGrid/HoursPerWeekGrid';
export const SetupStep: React.FC = () => {
const { t } = useTranslation();
const theme = useTheme();
- const { calculation, hcmUser, isMutating, setRightPanelContent } =
- usePdsGoalCalculator();
+ const {
+ calculation,
+ hcmUser,
+ isMutating,
+ setRightPanelContent,
+ setCurrentStepValid,
+ } = usePdsGoalCalculator();
+ const { allValid } = useAutosaveForm();
+
+ useEffect(() => {
+ setCurrentStepValid(allValid);
+ }, [allValid, setCurrentStepValid]);
+
+ useEffect(() => {
+ return () => setCurrentStepValid(true);
+ }, [setCurrentStepValid]);
const { data: userData } = useGetUserQuery();
const schema = useMemo(
() =>
diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx
index f2442c6947..870914935c 100644
--- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx
@@ -48,6 +48,11 @@ export type PdsGoalCalculatorType = {
stepIndex: number;
isDrawerOpen: boolean;
+
+ /** Whether the current step's required fields are valid. Defaults to true. */
+ isCurrentStepValid: boolean;
+ setCurrentStepValid: (valid: boolean) => void;
+
handleStepChange: (stepId: PdsGoalCalculatorStepEnum) => void;
handleContinue: () => void;
handlePreviousStep: () => void;
@@ -103,6 +108,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => {
const steps = useSteps(
calculation?.formType ?? DesignationSupportFormType.Detailed,
);
+ const [isCurrentStepValid, setCurrentStepValid] = useState(true);
const [rightPanelContent, setRightPanelContent] =
useState(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(true);
@@ -177,6 +183,8 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => {
hcmUser,
rightPanelContent,
isDrawerOpen,
+ isCurrentStepValid,
+ setCurrentStepValid,
handleStepChange,
handleContinue,
handlePreviousStep,
@@ -198,6 +206,8 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => {
hcmUser,
rightPanelContent,
isDrawerOpen,
+ isCurrentStepValid,
+ setCurrentStepValid,
handleStepChange,
handleContinue,
handlePreviousStep,
diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx
index 517a7e7576..62e98b4db7 100644
--- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorLayout.tsx
@@ -19,6 +19,7 @@ export const PdsGoalCalculatorLayout: React.FC<
const {
steps,
currentStep,
+ isCurrentStepValid,
handleStepChange,
isDrawerOpen,
setDrawerOpen,
@@ -27,22 +28,32 @@ export const PdsGoalCalculatorLayout: React.FC<
calculationLoading,
} = usePdsGoalCalculator();
+ const navigationDisabled = !isCurrentStepValid;
+
const handleStepIconClick = (step: PdsGoalCalculatorStepEnum) => {
if (currentStep.step === step) {
toggleDrawer();
- } else {
+ } else if (!navigationDisabled) {
handleStepChange(step);
setDrawerOpen(true);
}
};
- const iconPanelItems = steps.map((step) => ({
- key: step.step,
- icon: step.icon,
- label: step.title,
- isActive: currentStep.step === step.step,
- onClick: () => handleStepIconClick(step.step),
- }));
+ const iconPanelItems = steps.map((step) => {
+ const isOtherStep = currentStep.step !== step.step;
+ const isDisabled = navigationDisabled && isOtherStep;
+ return {
+ key: step.step,
+ icon: step.icon,
+ label: step.title,
+ isActive: currentStep.step === step.step,
+ disabled: isDisabled,
+ tooltip: isDisabled
+ ? t('Complete all required fields to continue')
+ : undefined,
+ onClick: () => handleStepIconClick(step.step),
+ };
+ });
return (
,
label: 'Mock Icon 1',
isActive: true,
- onClick: jest.fn(),
+ onClick: iconClickSpy,
},
{
key: 'mock-icon-2',
@@ -29,6 +32,16 @@ const mockIcons: IconPanelItem[] = [
},
];
+const disabledIcon: IconPanelItem = {
+ key: 'mock-icon-disabled',
+ icon: ,
+ label: 'Disabled Icon',
+ isActive: false,
+ disabled: true,
+ tooltip: 'Complete all required fields to continue',
+ onClick: disabledIconClickSpy,
+};
+
interface TestComponentProps extends Partial {
panelType: PanelTypeEnum;
}
@@ -51,6 +64,10 @@ const TestComponent: React.FC = (props) => (
);
describe('PanelLayout', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('renders main content and sidebar title for Empty panel type', () => {
const { getByRole } = render(
,
@@ -90,7 +107,7 @@ describe('PanelLayout', () => {
userEvent.click(getByRole('button', { name: 'Mock Icon 1' }));
- expect(mockIcons[0].onClick).toHaveBeenCalled();
+ expect(iconClickSpy).toHaveBeenCalled();
});
it('renders sidebar when content is provided', () => {
@@ -225,4 +242,31 @@ describe('PanelLayout', () => {
// With empty icons array, no icon buttons should render
expect(queryAllByRole('button')).toHaveLength(0);
});
+
+ it('renders a disabled icon button as focusable with aria-disabled', () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ const button = getByRole('button', { name: 'Disabled Icon' });
+ expect(button).toHaveAttribute('aria-disabled', 'true');
+ expect(button).not.toBeDisabled();
+ });
+
+ it('shows tooltip when hovering a disabled icon button', async () => {
+ const { getByRole, findByText } = render(
+ ,
+ );
+
+ userEvent.hover(getByRole('button', { name: 'Disabled Icon' }));
+ expect(
+ await findByText('Complete all required fields to continue'),
+ ).toBeInTheDocument();
+ });
});
diff --git a/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.tsx b/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.tsx
index 917c541e74..e4c37a264f 100644
--- a/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.tsx
+++ b/src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { CheckCircleOutline, CircleSharp } from '@mui/icons-material';
-import { Box, Divider, IconButton, Stack } from '@mui/material';
+import { Box, Divider, IconButton, Stack, Tooltip } from '@mui/material';
import { CircularProgressWithLabel } from 'src/components/HrTools/Shared/CalculationReports/CircularProgressWithLabel/CircularProgressWithLabel';
import {
MainContent,
@@ -20,6 +20,8 @@ export interface IconPanelItem {
icon: React.ReactNode;
label: string;
isActive?: boolean;
+ disabled?: boolean;
+ tooltip?: string;
onClick: () => void;
}
@@ -140,19 +142,25 @@ export const PanelLayout: React.FC = ({
)}
{icons?.map((item) => (
-
- {item.icon}
-
+
+
+ {item.icon}
+
+
))}
{(backHref || onBack) && (