From 8f6f5e7e23ca565b124040e1bba7c805a989b958 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Tue, 21 Jul 2026 09:55:00 -0400 Subject: [PATCH] MPDX-9866 - Disable all inputs of read-only goal calculations Senior staff goals converted from new staff goals (MPDX-9865) are marked readOnly by the API and reject every mutation. Fetch the new readOnly field, expose isReadOnly through GoalCalculatorContext, and disable all calculator inputs (autosave fields, SECA select, geographic location autocomplete, budget grids' cell editing, add/delete line items, and lump sum/line item toggles) while still showing the inputs and calculated totals. Hide the delete button and show a Read-Only badge on read-only goal cards. Co-Authored-By: Claude Fable 5 --- .../Autosave/AutosaveTextField.test.tsx | 16 ++++++ .../Categories/Autosave/useGoalAutosave.ts | 3 +- .../InformationCategoryFinancialForm.tsx | 2 + .../InformationCategoryPersonalForm.tsx | 8 ++- .../GoalCalculatorTestWrapper.tsx | 6 +- .../GoalCard/MpdGoalCard.test.tsx | 22 +++++++- .../GoalCalculator/GoalCard/MpdGoalCard.tsx | 11 +++- .../GoalsList/GoalCalculations.graphql | 1 + .../Shared/GoalCalculation.graphql | 1 + .../Shared/GoalCalculatorContext.tsx | 8 +++ .../GoalCalculatorGrid.test.tsx | 55 +++++++++++++++++++ .../GoalCalculatorGrid/GoalCalculatorGrid.tsx | 17 +++++- .../Reports/Shared/GoalCard/GoalCard.test.tsx | 7 +++ .../Reports/Shared/GoalCard/GoalCard.tsx | 20 ++++--- 14 files changed, 160 insertions(+), 17 deletions(-) diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx index f9e5ded974..a539a14d5e 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx @@ -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( + + + , + ); + + 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(); diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts index 93ce4195ad..edb7ff89f8 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts @@ -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, }); }; diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx index 9818b89ddd..1dba70f7c2 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx @@ -30,6 +30,7 @@ export const InformationCategoryFinancialForm: React.FC< const { setRightPanelContent, goalCalculationResult: { data }, + isReadOnly, } = useGoalCalculator(); const saveField = useSaveField(); @@ -89,6 +90,7 @@ export const InformationCategoryFinancialForm: React.FC< fullWidth size="small" select + disabled={!data || isReadOnly} label={ isSpouse ? t('Spouse SECA (Social Security) Status') diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx index 092d3986ca..ca46daa184 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx @@ -42,6 +42,7 @@ export const InformationCategoryPersonalForm: React.FC< const { t } = useTranslation(); const { goalCalculationResult: { data }, + isReadOnly, setRightPanelContent, } = useGoalCalculator(); const { goalGeographicConstantMap, goalBenefitsPlans } = @@ -77,6 +78,11 @@ export const InformationCategoryPersonalForm: React.FC< }, [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); @@ -134,7 +140,7 @@ export const InformationCategoryPersonalForm: React.FC< onChange={(_, newValue) => saveField({ geographicLocation: newValue }) } - disabled={!data} + disabled={!data || isReadOnly} size="small" renderInput={(params) => ( = ({ onCall, noMocks = false, children }) => { +> = ({ onCall, noMocks = false, readOnly = false, children }) => { const content = {children}; return ( @@ -281,7 +283,7 @@ export const GoalCalculatorTestWrapper: React.FC< }> mocks={{ GoalCalculation: { - goalCalculation: goalCalculationMock, + goalCalculation: { ...goalCalculationMock, readOnly }, }, GoalCalculatorConstants: { constant: constantsMock, diff --git a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx index 3ff736be4f..49d9490813 100644 --- a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx +++ b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx @@ -14,7 +14,11 @@ import { MpdGoalCard } from './MpdGoalCard'; const mutationSpy = jest.fn(); -const TestComponent: React.FC = () => ( +interface TestComponentProps { + readOnly?: boolean; +} + +const TestComponent: React.FC = ({ readOnly = false }) => ( ( }, }} > - + @@ -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( + , + ); + + expect(queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + expect(getByText('Read-Only')).toBeInTheDocument(); + expect(getByRole('link', { name: 'View' })).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx index 91bcfb151c..808825d959 100644 --- a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx +++ b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx @@ -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'; @@ -13,6 +15,7 @@ export interface MpdGoalCardProps { } export const MpdGoalCard: React.FC = ({ goal }) => { + const { t } = useTranslation(); const accountListId = useAccountListId(); const constants = useGoalCalculatorConstants(); const [deleteGoalCalculation] = useDeleteGoalCalculationMutation(); @@ -43,7 +46,13 @@ export const MpdGoalCard: React.FC = ({ 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 ? ( + + ) : undefined + } /> ); }; diff --git a/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql b/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql index 8a8a2b2cac..4233ca766c 100644 --- a/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql +++ b/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql @@ -1,6 +1,7 @@ fragment ListGoalCalculation on GoalCalculation { id name + readOnly role familySize benefitsPlan diff --git a/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql b/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql index 6d36e14502..342712aefb 100644 --- a/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql +++ b/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql @@ -1,5 +1,6 @@ fragment GoalCalculationSettings on GoalCalculation { name + readOnly firstName spouseFirstName lastName diff --git a/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx b/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx index c48607e179..5269c2c9a8 100644 --- a/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx +++ b/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx @@ -48,6 +48,9 @@ export type GoalCalculatorType = { goalCalculationResult: ReturnType; 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 */ @@ -91,6 +94,9 @@ export const GoalCalculatorProvider: React.FC = ({ children }) => { }, }); + const isReadOnly = + goalCalculationResult.data?.goalCalculation?.readOnly ?? false; + const role = goalCalculationResult.data?.goalCalculation?.role ?? null; const familySize = goalCalculationResult.data?.goalCalculation?.familySize ?? null; @@ -192,6 +198,7 @@ export const GoalCalculatorProvider: React.FC = ({ children }) => { selectedReport, setSelectedReport, goalCalculationResult, + isReadOnly, isMutating, trackMutation, percentComplete, @@ -215,6 +222,7 @@ export const GoalCalculatorProvider: React.FC = ({ children }) => { selectedReport, setSelectedReport, goalCalculationResult, + isReadOnly, isMutating, trackMutation, percentComplete, diff --git a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx index f438905da8..0c0eb4f5ea 100644 --- a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx +++ b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx @@ -356,4 +356,59 @@ describe('GoalCalculatorGrid', () => { 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(await findByText('Total')).toBeInTheDocument(); + expect(await findByText('$1,450')).toBeInTheDocument(); + }); + + it('disables the lump sum total field', async () => { + const { findByLabelText } = render( + + + , + ); + + expect(await findByLabelText('Total')).toBeDisabled(); + }); + }); }); diff --git a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx index c8c7513240..538b44ab7d 100644 --- a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx +++ b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx @@ -93,6 +93,7 @@ export const GoalCalculatorGrid: React.FC = ({ defaultType, defaultTypeChanged, clearDefaultTypeChanged, + isReadOnly, } = useGoalCalculator(); const categoryType = category.category; @@ -226,7 +227,8 @@ export const GoalCalculatorGrid: React.FC = ({ // 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; } @@ -259,6 +261,7 @@ export const GoalCalculatorGrid: React.FC = ({ clearDefaultTypeChanged(); }, [ + isReadOnly, hasDefaultsForType, defaultTypeChanged, directInput, @@ -276,6 +279,7 @@ export const GoalCalculatorGrid: React.FC = ({ saveValue: updateDirectInput, fieldName: 'amount', schema: directInputSchema, + disabled: isReadOnly, saveOnChange: false, }); const addExpense = () => { @@ -497,8 +501,8 @@ export const GoalCalculatorGrid: React.FC = ({ 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 []; } @@ -538,6 +542,7 @@ export const GoalCalculatorGrid: React.FC = ({ size="small" onClick={() => handleDirectInputToggle(true)} startIcon={} + disabled={isReadOnly} > {t('Lump Sum')} @@ -547,6 +552,7 @@ export const GoalCalculatorGrid: React.FC = ({ variant={!directInput ? 'contained' : 'outlined'} onClick={() => handleDirectInputToggle(false)} startIcon={} + disabled={isReadOnly} > {t('Line Item')} @@ -577,6 +583,7 @@ export const GoalCalculatorGrid: React.FC = ({ onClick={addExpense} size="small" startIcon={} + disabled={isReadOnly} > {t('Add Line Item')} @@ -586,6 +593,10 @@ export const GoalCalculatorGrid: React.FC = ({ 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; diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx index 347c1d54f8..eee0489968 100644 --- a/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx +++ b/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx @@ -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(); + }); }); diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.tsx index 91fac0967d..42359fca88 100644 --- a/src/components/Reports/Shared/GoalCard/GoalCard.tsx +++ b/src/components/Reports/Shared/GoalCard/GoalCard.tsx @@ -61,7 +61,8 @@ export interface GoalCardProps { loading?: boolean; updatedAt: string; viewHref: string; - onDelete: () => Promise; + /** Omit to hide the delete button, e.g. for read-only goals */ + onDelete?: () => Promise; badge?: React.ReactNode; } @@ -81,7 +82,7 @@ export const GoalCard: React.FC = ({ const displayName = name ?? t('Unnamed Goal'); - const handleConfirmDelete = async () => onDelete(); + const handleConfirmDelete = async () => onDelete?.(); return ( <> @@ -158,11 +159,16 @@ export const GoalCard: React.FC = ({ - + {onDelete ? ( + + ) : ( + // Keep the View button on the right when there is no delete button + + )}