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 + + )}