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) && (