Skip to content
Draft
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';

Check warning on line 1 in src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: Lines of Code in a Single File

This module has 323 lines of code, improve code health by reducing it to 300. The number of Lines of Code in a single file. More Lines of Code lowers the code health.
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
Expand Down Expand Up @@ -158,6 +158,82 @@
).toBeInTheDocument();
});

it('disables non-Setup step icons when Setup fields are invalid', async () => {
const { findByRole } = render(
<PdsGoalCalculatorTestWrapper calculationMock={{ payRate: null }}>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

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(
<PdsGoalCalculatorTestWrapper>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

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(
<PdsGoalCalculatorTestWrapper calculationMock={{ payRate: null }}>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

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(
<PdsGoalCalculatorTestWrapper
calculationMock={{ formType: DesignationSupportFormType.Simple }}
>
<PdsGoalCalculator />
</PdsGoalCalculatorTestWrapper>,
);

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
Expand All @@ -171,7 +247,13 @@
</PdsGoalCalculatorTestWrapper>,
);

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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,
Expand Down Expand Up @@ -268,7 +269,9 @@
onCall={onCall}
>
{withProvider ? (
<PdsGoalCalculatorProvider>{children}</PdsGoalCalculatorProvider>
<PdsGoalCalculatorProvider>
<AutosaveForm>{children}</AutosaveForm>
</PdsGoalCalculatorProvider>

Check warning on line 274 in src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Large Method

PdsGoalCalculatorTestWrapper:React.FC<PdsGoalCalculatorTestWrapperProps> increases from 133 to 135 lines of code, threshold = 100. Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
) : (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>{children}</>
Expand Down
21 changes: 18 additions & 3 deletions src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import CalculateIcon from '@mui/icons-material/Calculate';
import {
Autocomplete,
Expand All @@ -17,6 +17,7 @@
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,
Expand All @@ -38,8 +39,22 @@
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]);

Check warning on line 57 in src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

SetupStep:React.FC already has high cyclomatic complexity, and now it increases in Lines of Code from 284 to 296. 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.
const { data: userData } = useGetUserQuery();
const schema = useMemo(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@

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;
Expand Down Expand Up @@ -103,101 +108,106 @@
const steps = useSteps(
calculation?.formType ?? DesignationSupportFormType.Detailed,
);
const [isCurrentStepValid, setCurrentStepValid] = useState(true);
const [rightPanelContent, setRightPanelContent] =
useState<React.ReactNode>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(true);
const { trackMutation, isMutating } = useTrackMutation();

useEffect(() => {
if (steps.some((s) => s.step === activeStep)) {
return;
}
setActiveStep(steps[0]?.step ?? PdsGoalCalculatorStepEnum.Setup);
enqueueSnackbar(
t('Returned to Setup because the current step is no longer available.'),
{ variant: 'info' },
);
}, [steps, activeStep, enqueueSnackbar, t]);

const stepIndex = useMemo(() => {
const idx = steps.findIndex((s) => s.step === activeStep);
return idx === -1 ? 0 : idx;
}, [steps, activeStep]);

const currentStep = steps[stepIndex];

const percentComplete = Math.round(
safeProgressRatio(stepIndex + 1, steps.length) * 100,
);

const handleStepChange = useCallback(
(newStep: PdsGoalCalculatorStepEnum) => {
if (steps.some((step) => step.step === newStep)) {
setActiveStep(newStep);
} else {
enqueueSnackbar(t('The selected step does not exist.'), {
variant: 'error',
});
}
},
[steps, enqueueSnackbar, t],
);

const handleContinue = useCallback(() => {
if (stepIndex < steps.length - 1) {
setActiveStep(steps[stepIndex + 1].step);
}
}, [stepIndex, steps]);

const handlePreviousStep = useCallback(() => {
if (stepIndex > 0) {
setActiveStep(steps[stepIndex - 1].step);
}
}, [stepIndex, steps]);

const closeRightPanel = useCallback(() => {
setRightPanelContent(null);
}, []);

const toggleDrawer = useCallback(() => {
setIsDrawerOpen((prev) => !prev);
}, []);

const contextValue = useMemo(
(): PdsGoalCalculatorType => ({
steps,
currentStep,
stepIndex,
calculation,
calculationLoading,
summaryData,
percentComplete,
isMutating,
trackMutation,
hcmUser,
rightPanelContent,
isDrawerOpen,
isCurrentStepValid,
setCurrentStepValid,
handleStepChange,
handleContinue,
handlePreviousStep,
setRightPanelContent,
closeRightPanel,
toggleDrawer,
setDrawerOpen: setIsDrawerOpen,
}),
[
steps,
currentStep,
stepIndex,
calculation,
calculationLoading,
summaryData,
percentComplete,
isMutating,
trackMutation,
hcmUser,
rightPanelContent,
isDrawerOpen,
isCurrentStepValid,
setCurrentStepValid,

Check warning on line 210 in src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Large Method

PdsGoalCalculatorProvider:React.FC<Props> increases from 120 to 125 lines of code, threshold = 100. Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
handleStepChange,
handleContinue,
handlePreviousStep,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const PdsGoalCalculatorLayout: React.FC<
const {
steps,
currentStep,
isCurrentStepValid,
handleStepChange,
isDrawerOpen,
setDrawerOpen,
Expand All @@ -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 (
<PanelLayout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import { IconPanelItem, PanelLayout, PanelLayoutProps } from './PanelLayout';

const title = 'Sidebar Title';

const iconClickSpy = jest.fn();
const disabledIconClickSpy = jest.fn();

const mockIcons: IconPanelItem[] = [
{
key: 'mock-icon-1',
icon: <MenuOpenSharp />,
label: 'Mock Icon 1',
isActive: true,
onClick: jest.fn(),
onClick: iconClickSpy,
},
{
key: 'mock-icon-2',
Expand All @@ -29,6 +32,16 @@ const mockIcons: IconPanelItem[] = [
},
];

const disabledIcon: IconPanelItem = {
key: 'mock-icon-disabled',
icon: <HomeIcon />,
label: 'Disabled Icon',
isActive: false,
disabled: true,
tooltip: 'Complete all required fields to continue',
onClick: disabledIconClickSpy,
};

interface TestComponentProps extends Partial<PanelLayoutProps> {
panelType: PanelTypeEnum;
}
Expand All @@ -51,6 +64,10 @@ const TestComponent: React.FC<TestComponentProps> = (props) => (
);

describe('PanelLayout', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders main content and sidebar title for Empty panel type', () => {
const { getByRole } = render(
<TestComponent panelType={PanelTypeEnum.Empty} />,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
<TestComponent
panelType={PanelTypeEnum.Other}
icons={[...mockIcons, disabledIcon]}
/>,
);

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(
<TestComponent
panelType={PanelTypeEnum.Other}
icons={[...mockIcons, disabledIcon]}
/>,
);

userEvent.hover(getByRole('button', { name: 'Disabled Icon' }));
expect(
await findByText('Complete all required fields to continue'),
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +20,8 @@
icon: React.ReactNode;
label: string;
isActive?: boolean;
disabled?: boolean;
tooltip?: string;
onClick: () => void;
}

Expand Down Expand Up @@ -140,19 +142,25 @@
)}

{icons?.map((item) => (
<IconButton
key={item.key}
aria-label={item.label}
aria-current={item.isActive ? 'step' : undefined}
sx={{
color: item.isActive
? theme.palette.mpdxBlue.main
: theme.palette.mpdxGrayDark.main,
}}
onClick={item.onClick}
>
{item.icon}
</IconButton>
<Tooltip key={item.key} title={item.tooltip ?? ''}>
<IconButton
aria-label={item.label}
aria-current={item.isActive ? 'step' : undefined}
aria-disabled={item.disabled || undefined}
sx={{
color: item.isActive
? theme.palette.mpdxBlue.main
: theme.palette.mpdxGrayDark.main,
...(item.disabled && {
opacity: 0.5,
cursor: 'not-allowed',
}),
}}
onClick={item.onClick}
>
{item.icon}
</IconButton>
</Tooltip>

Check warning on line 163 in src/components/HrTools/Shared/CalculationReports/PanelLayout/PanelLayout.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

PanelLayout:React.FC<PanelLayoutProps> increases in cyclomatic complexity from 16 to 18, threshold = 15. 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.
))}
{(backHref || onBack) && (
<BackArrow
Expand Down
Loading