Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ describe('AutosaveTextField', () => {
expect(input).toHaveAccessibleDescription('');
});

it('disables the input when the goal is read-only but still shows its value', async () => {
const { getByRole } = render(
<GoalCalculatorTestWrapper onCall={mutationSpy} readOnly>
<AutosaveTextField
label="MHA Amount"
fieldName="mhaAmount"
schema={defaultSchema}
/>
</GoalCalculatorTestWrapper>,
);

const input = getByRole('textbox', { name: 'MHA Amount' });
await waitFor(() => expect(input).toHaveValue('1000'));
expect(input).toBeDisabled();
});

it('shows validation error for invalid type', async () => {
const { getByRole } = render(<TestComponent />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ export const useGoalAutoSave = ({
const saveField = useSaveField();
const {
goalCalculationResult: { data },
isReadOnly,
} = useGoalCalculator();

return useAutoSave({
value: data?.goalCalculation[fieldName],
saveValue: (value) => saveField({ [fieldName]: value }),
fieldName,
disabled: !data,
disabled: !data || isReadOnly,
...options,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,65 +30,67 @@
const {
setRightPanelContent,
goalCalculationResult: { data },
isReadOnly,
} = useGoalCalculator();

const saveField = useSaveField();
const secaField = isSpouse ? 'spouseSecaExempt' : 'secaExempt';

return (
<>
<Typography variant="h6" gutterBottom>
{isSpouse
? t("Spouse's Financial Information")
: t('Financial Information')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{isSpouse
? t('Review spouse financial details and settings here.')
: t('Review your financial details and settings here.')}
</Typography>
<Grid container spacing={3}>
<Grid size={12}>
<AutosaveTextField
fieldName={
isSpouse ? 'spouseNetPaycheckAmount' : 'netPaycheckAmount'
}
schema={schema}
label={
isSpouse
? t('Spouse Net Paycheck Amount')
: t('Net Paycheck Amount')
}
type="number"
inputProps={{ min: 0, step: 0.01 }}
InputProps={{
startAdornment: <CurrencyAdornment />,
}}
/>
</Grid>

<Grid size={12}>
<AutosaveTextField
fieldName={isSpouse ? 'spouseTaxesPercentage' : 'taxesPercentage'}
schema={schema}
label={isSpouse ? t('Spouse Taxes') : t('Taxes')}
type="number"
inputProps={{ min: 0, max: 100, step: 1 }}
InputProps={{
endAdornment: <PercentageAdornment />,
}}
/>
</Grid>

<Grid size={12}>
<TextField
value={data?.goalCalculation[secaField]?.toString() ?? ''}
onChange={(event) => {
saveField({ [secaField]: event.target.value === 'true' });
}}
fullWidth
size="small"
select
disabled={!data || isReadOnly}

Check warning on line 93 in src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Large Method

InformationCategoryFinancialForm:React.FC<InformationCategoryFinancialFormProps> increases from 154 to 156 lines of code, threshold = 120 Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
label={
isSpouse
? t('Spouse SECA (Social Security) Status')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,99 +42,105 @@
const { t } = useTranslation();
const {
goalCalculationResult: { data },
isReadOnly,
setRightPanelContent,
} = useGoalCalculator();
const { goalGeographicConstantMap, goalBenefitsPlans } =
useGoalCalculatorConstants();
const { geographicLocation, familySize, benefitsPlan } =
data?.goalCalculation || {};

const locations = useMemo(
() => Array.from(goalGeographicConstantMap.keys()),
[goalGeographicConstantMap],
);

const familySizeOptions = useMemo(() => {
const familySize = new Map<MpdGoalBenefitsConstantSizeEnum, string>();
goalBenefitsPlans.forEach((benefits) => {
familySize.set(benefits.size, benefits.sizeDisplayName);
});

return Array.from(familySize.entries());
}, [goalBenefitsPlans]);

const saveField = useSaveField();

const planOptions = useMemo(() => {
const plans = new Map<MpdGoalBenefitsConstantPlanEnum, string>();
goalBenefitsPlans.forEach((benefits) => {
// Only include plans that match the selected family size
if (benefits.size === familySize) {
plans.set(benefits.plan, benefits.planDisplayName);
}
});
return Array.from(plans.entries());
}, [goalBenefitsPlans, familySize]);

useEffect(() => {
// Read-only goals reject mutations, so don't try to fix an incompatible plan
if (isReadOnly) {
return;
}

// Clear benefits plan if it's not compatible with selected family size
if (familySize && benefitsPlan) {
const isPlanValid = planOptions.some(([plan]) => plan === benefitsPlan);
if (!isPlanValid) {
saveField({ benefitsPlan: null });
}
}
}, [familySize, benefitsPlan, planOptions]);

return (
<>
<Typography variant="h6" gutterBottom>
{isSpouse
? t("Spouse's Personal Information")
: t('Personal Information')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{isSpouse
? t('Review spouse personal details and preferences here.')
: t('Review your personal details and preferences here.')}
</Typography>
<Grid container spacing={3}>
<Grid
size={{
xs: 12,
sm: isSpouse ? 12 : 6,
}}
>
<AutosaveTextField
fieldName={isSpouse ? 'spouseFirstName' : 'firstName'}
schema={schema}
label={isSpouse ? t('Spouse First Name') : t('First Name')}
/>
</Grid>
{!isSpouse && (
<Grid
size={{
xs: 12,
sm: 6,
}}
>
<AutosaveTextField
fieldName="lastName"
schema={schema}
label={t('Last Name')}
/>
</Grid>
)}

{!isSpouse && (
<Grid size={12}>
<Autocomplete
options={locations}
value={geographicLocation ?? null}
onChange={(_, newValue) =>
saveField({ geographicLocation: newValue })
}
disabled={!data}
disabled={!data || isReadOnly}

Check warning on line 143 in src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

InformationCategoryPersonalForm:React.FC<InformationCategoryPersonalFormProps> increases in cyclomatic complexity from 24 to 26, threshold = 20 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.
size="small"
renderInput={(params) => (
<TextField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const goalCalculationMock = gqlMock<
goalCalculation: {
id: 'goal-calculation-1',
name: 'Initial Goal Name',
readOnly: false,
firstName: 'John',
spouseFirstName: 'Jane',
lastName: 'Doe',
Expand Down Expand Up @@ -254,12 +255,13 @@ export const constantsMock = gqlMock<
interface GoalCalculatorTestWrapperProps {
onCall?: MockLinkCallHandler;
noMocks?: boolean;
readOnly?: boolean;
children?: React.ReactNode;
}

export const GoalCalculatorTestWrapper: React.FC<
GoalCalculatorTestWrapperProps
> = ({ onCall, noMocks = false, children }) => {
> = ({ onCall, noMocks = false, readOnly = false, children }) => {
const content = <GoalCalculatorProvider>{children}</GoalCalculatorProvider>;
return (
<ThemeProvider theme={theme}>
Expand All @@ -281,7 +283,7 @@ export const GoalCalculatorTestWrapper: React.FC<
}>
mocks={{
GoalCalculation: {
goalCalculation: goalCalculationMock,
goalCalculation: { ...goalCalculationMock, readOnly },
},
GoalCalculatorConstants: {
constant: constantsMock,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { MpdGoalCard } from './MpdGoalCard';

const mutationSpy = jest.fn();

const TestComponent: React.FC = () => (
interface TestComponentProps {
readOnly?: boolean;
}

const TestComponent: React.FC<TestComponentProps> = ({ readOnly = false }) => (
<TestRouter>
<ThemeProvider theme={theme}>
<GqlMockedProvider<{
Expand All @@ -27,7 +31,9 @@ const TestComponent: React.FC = () => (
},
}}
>
<MpdGoalCard goal={{ ...goalCalculationMock, id: 'goal-1' }} />
<MpdGoalCard
goal={{ ...goalCalculationMock, id: 'goal-1', readOnly }}
/>
</GqlMockedProvider>
</ThemeProvider>
</TestRouter>
Expand Down Expand Up @@ -70,4 +76,16 @@ describe('MpdGoalCard', () => {
getByTestId('goal-amount-value').querySelector('.MuiSkeleton-root'),
).toBeInTheDocument();
});

describe('read-only goal', () => {
it('hides the Delete button and shows the Read-Only badge', () => {
const { getByText, getByRole, queryByRole } = render(
<TestComponent readOnly />,
);

expect(queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
expect(getByText('Read-Only')).toBeInTheDocument();
expect(getByRole('link', { name: 'View' })).toBeInTheDocument();
});
});
});
11 changes: 10 additions & 1 deletion src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React, { useMemo } from 'react';
import { Chip } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { GoalCard } from 'src/components/Reports/Shared/GoalCard/GoalCard';
import { useAccountListId } from 'src/hooks/useAccountListId';
import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants';
Expand All @@ -13,6 +15,7 @@ export interface MpdGoalCardProps {
}

export const MpdGoalCard: React.FC<MpdGoalCardProps> = ({ goal }) => {
const { t } = useTranslation();
const accountListId = useAccountListId();
const constants = useGoalCalculatorConstants();
const [deleteGoalCalculation] = useDeleteGoalCalculationMutation();
Expand Down Expand Up @@ -43,7 +46,13 @@ export const MpdGoalCard: React.FC<MpdGoalCardProps> = ({ goal }) => {
loading={constants.loading}
updatedAt={goal.updatedAt}
viewHref={`/accountLists/${accountListId}/hrTools/goalCalculator/${goal.id}`}
onDelete={handleDelete}
// Read-only goals reject deletion, so don't offer it
onDelete={goal.readOnly ? undefined : handleDelete}
badge={
goal.readOnly ? (
<Chip label={t('Read-Only')} size="small" variant="outlined" />
) : undefined
}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
fragment ListGoalCalculation on GoalCalculation {
id
name
readOnly
role
familySize
benefitsPlan
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
fragment GoalCalculationSettings on GoalCalculation {
name
readOnly
firstName
spouseFirstName
lastName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
goalCalculationResult: ReturnType<typeof useGoalCalculationQuery>;
goalTotals: GoalTotals;

/** Whether the goal is read-only and all inputs should be disabled */
isReadOnly: boolean;

/** Whether any mutations are currently in progress */
isMutating: boolean;
/** Call with the mutation promise to track the start and end of mutations */
Expand Down Expand Up @@ -91,130 +94,135 @@
},
});

const isReadOnly =
goalCalculationResult.data?.goalCalculation?.readOnly ?? false;

const role = goalCalculationResult.data?.goalCalculation?.role ?? null;
const familySize =
goalCalculationResult.data?.goalCalculation?.familySize ?? null;

const isMarried =
familySize === MpdGoalBenefitsConstantSizeEnum.MarriedNoChildren ||
familySize === MpdGoalBenefitsConstantSizeEnum.MarriedOneToTwoChildren ||
familySize === MpdGoalBenefitsConstantSizeEnum.MarriedThreeOrMoreChildren;

const defaultType = getDefaultType(role, isMarried);

// Track when defaultType changes (not on initial load)
const previousDefaultTypeRef = useRef<DefaultTypeEnum | null>(null);
const [defaultTypeChanged, setDefaultTypeChanged] = useState(false);

useEffect(() => {
const previousDefaultType = previousDefaultTypeRef.current;

if (previousDefaultType && previousDefaultType !== defaultType) {
setDefaultTypeChanged(true);
}

previousDefaultTypeRef.current = defaultType;
}, [defaultType]);

const clearDefaultTypeChanged = useCallback(() => {
setDefaultTypeChanged(false);
}, []);

const constants = useGoalCalculatorConstants();

const steps = useSteps();
const percentComplete = useMemo(
() => completionPercentage(goalCalculationResult.data?.goalCalculation),
[goalCalculationResult.data],
);
const goalTotals = useMemo(
() =>
calculateGoalTotals(
goalCalculationResult.data?.goalCalculation ?? null,
constants,
),
[goalCalculationResult.data, constants],
);
const [stepIndex, setStepIndex] = useState(0);
const [selectedReport, setSelectedReport] =
useState<GoalCalculatorReportEnum>(GoalCalculatorReportEnum.MpdGoal);
const [rightPanelContent, setRightPanelContent] =
useState<JSX.Element | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(true);
const { trackMutation, isMutating } = useTrackMutation();

const currentStep = steps[stepIndex];

const handleStepChange = useCallback(
(newStep: GoalCalculatorStepEnum) => {
const newIndex = steps.findIndex((step) => step.step === newStep);
if (newIndex !== -1) {
setStepIndex(newIndex);
} else {
enqueueSnackbar(t('The selected step does not exist.'), {
variant: 'error',
});
}
},
[steps, enqueueSnackbar, t],
);

const handleContinue = useCallback(() => {
if (stepIndex < steps.length - 1) {
setStepIndex(stepIndex + 1);
} else {
enqueueSnackbar(t('You have reached the end of the goal calculator.'), {
variant: 'info',
});
}
}, [steps, stepIndex, enqueueSnackbar, t]);

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

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

const contextValue = useMemo(
(): GoalCalculatorType => ({
steps,
currentStep,
rightPanelContent,
isDrawerOpen,
handleStepChange,
handleContinue,
setRightPanelContent,
closeRightPanel,
toggleDrawer,
setDrawerOpen: setIsDrawerOpen,
selectedReport,
setSelectedReport,
goalCalculationResult,
isReadOnly,
isMutating,
trackMutation,
percentComplete,
goalTotals,
defaultType,
isMarried,
defaultTypeChanged,
clearDefaultTypeChanged,
}),
[
steps,
currentStep,
rightPanelContent,
isDrawerOpen,
handleStepChange,
handleContinue,
setRightPanelContent,
closeRightPanel,
toggleDrawer,
setIsDrawerOpen,
selectedReport,
setSelectedReport,
goalCalculationResult,
isReadOnly,

Check warning on line 225 in src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Large Method

GoalCalculatorProvider:React.FC<Props> increases from 136 to 140 lines of code, threshold = 120 Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
isMutating,
trackMutation,
percentComplete,
Expand Down
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/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.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 336 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 @@ -356,4 +356,59 @@
await findByText('Only the portion not reimbursed as ministry expense.'),
).toBeInTheDocument();
});

describe('read-only goal', () => {
it('disables the mode toggle and add buttons and hides delete actions', async () => {
const { findByText, getByRole, queryByLabelText } = render(
<GoalCalculatorTestWrapper readOnly>
<TestComponent />
</GoalCalculatorTestWrapper>,
);

const otherMinistryRow = (await findByText('Other Ministry')).closest(
'[role="row"]',
);

expect(getByRole('button', { name: 'Lump Sum' })).toBeDisabled();
expect(getByRole('button', { name: 'Line Item' })).toBeDisabled();
expect(getByRole('button', { name: 'Add Line Item' })).toBeDisabled();

userEvent.hover(otherMinistryRow!);
expect(queryByLabelText('Delete')).not.toBeInTheDocument();
});

it('does not open the cell editor', async () => {
const { findByText, queryByDisplayValue } = render(
<GoalCalculatorTestWrapper readOnly>
<TestComponent />
</GoalCalculatorTestWrapper>,
);

const nameCell = await findByText('Other Ministry');
userEvent.dblClick(nameCell);

expect(queryByDisplayValue('Other Ministry')).not.toBeInTheDocument();
});

it('still displays the calculated total', async () => {
const { findByText } = render(
<GoalCalculatorTestWrapper readOnly>
<TestComponent />
</GoalCalculatorTestWrapper>,
);

expect(await findByText('Total')).toBeInTheDocument();
expect(await findByText('$1,450')).toBeInTheDocument();
});

it('disables the lump sum total field', async () => {
const { findByLabelText } = render(
<GoalCalculatorTestWrapper readOnly>
<TestComponent primaryBudgetCategoryIndex={1} />
</GoalCalculatorTestWrapper>,
);

expect(await findByLabelText('Total')).toBeDisabled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({
defaultType,
defaultTypeChanged,
clearDefaultTypeChanged,
isReadOnly,
} = useGoalCalculator();

const categoryType = category.category;
Expand Down Expand Up @@ -226,7 +227,8 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({

// Update line item defaults when defaultType changes (user changed role/family size)
useEffect(() => {
if (!hasDefaultsForType) {
// Read-only goals reject mutations, and their role/family size can't change anyways
if (isReadOnly || !hasDefaultsForType) {
return;
}

Expand Down Expand Up @@ -259,6 +261,7 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({

clearDefaultTypeChanged();
}, [
isReadOnly,
hasDefaultsForType,
defaultTypeChanged,
directInput,
Expand All @@ -276,6 +279,7 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({
saveValue: updateDirectInput,
fieldName: 'amount',
schema: directInputSchema,
disabled: isReadOnly,
saveOnChange: false,
});
const addExpense = () => {
Expand Down Expand Up @@ -497,8 +501,8 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({
headerName: '',
width: 60,
getActions: (params) => {
// Don't show delete action for total row
if (params.id === 'total') {
// Don't show delete action for total row, or at all when the goal is read-only
if (params.id === 'total' || isReadOnly) {
return [];
}

Expand Down Expand Up @@ -538,6 +542,7 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({
size="small"
onClick={() => handleDirectInputToggle(true)}
startIcon={<FunctionsIcon />}
disabled={isReadOnly}
>
{t('Lump Sum')}
</Button>
Expand All @@ -547,6 +552,7 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({
variant={!directInput ? 'contained' : 'outlined'}
onClick={() => handleDirectInputToggle(false)}
startIcon={<ViewHeadlineIcon />}
disabled={isReadOnly}
>
{t('Line Item')}
</Button>
Expand Down Expand Up @@ -577,6 +583,7 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({
onClick={addExpense}
size="small"
startIcon={<AddIcon />}
disabled={isReadOnly}
>
{t('Add Line Item')}
</Button>
Expand All @@ -586,6 +593,10 @@ export const GoalCalculatorGrid: React.FC<GoalCalculatorGridProps> = ({
columns={columns}
processRowUpdate={processRowUpdate}
isCellEditable={(params) => {
// Read-only goals reject all edits
if (isReadOnly) {
return false;
}
// Don't allow editing the total row or label field when canDelete is false
if (params.id === 'total') {
return false;
Expand Down
7 changes: 7 additions & 0 deletions src/components/Reports/Shared/GoalCard/GoalCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,11 @@ describe('GoalCard', () => {

expect(queryByText('Default')).not.toBeInTheDocument();
});

it('hides the Delete button when onDelete is not provided', () => {
const { getByRole, queryByRole } = renderCard({ onDelete: undefined });

expect(queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
expect(getByRole('link', { name: 'View' })).toBeInTheDocument();
});
});
Loading
Loading