diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx index 054f95aa7f..3ad08bb55b 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx @@ -23,12 +23,14 @@ interface TestComponentProps { single?: boolean; readOnly?: boolean; benefitsPlan?: MpdGoalBenefitsConstantPlanEnum; + geographicLocation?: string | null; } const TestComponent: React.FC = ({ single = false, readOnly = false, benefitsPlan = MpdGoalBenefitsConstantPlanEnum.Base, + geographicLocation = null, }) => ( = ({ ? MpdGoalBenefitsConstantSizeEnum.Single : MpdGoalBenefitsConstantSizeEnum.MarriedNoChildren, benefitsPlan, + geographicLocation, }, }, GoalCalculatorConstants: { @@ -324,6 +327,80 @@ describe('InformationCategory', () => { ); }); + it('does not save while the Geographic Location is cleared by typing', async () => { + const { getByRole } = render( + , + ); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + await waitFor(() => expect(input).toHaveValue('Orlando, FL')); + + userEvent.clear(input); + + // Yield to the microtask queue so any pending mutation would have fired. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdateGoalCalculation'); + // The field stays empty instead of resetting mid-edit + expect(input).toHaveValue(''); + }); + + it('reverts to the saved Geographic Location when the cleared field loses focus', async () => { + const { getByRole } = render( + , + ); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + await waitFor(() => expect(input).toHaveValue('Orlando, FL')); + + userEvent.clear(input); + userEvent.tab(); + + // The field is not clearable, so blurring restores the saved value + // without firing a mutation + await waitFor(() => expect(input).toHaveValue('Orlando, FL')); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdateGoalCalculation'); + }); + + it('defaults to None and does not save when cleared and blurred without a saved location', async () => { + const { getByRole } = render(); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + await waitFor(() => expect(input).not.toBeDisabled()); + // An unset location displays as None + await waitFor(() => expect(input).toHaveValue('None')); + + userEvent.clear(input); + userEvent.tab(); + + // Yield to the microtask queue so any pending mutation would have fired. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdateGoalCalculation'); + }); + + it('saves None when it is explicitly selected', async () => { + const { getByRole, findByRole } = render( + , + ); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + await waitFor(() => expect(input).toHaveValue('Orlando, FL')); + + userEvent.click(input); + userEvent.click(await findByRole('option', { name: 'None' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateGoalCalculation', { + input: { + accountListId: 'account-list-1', + attributes: { + id: 'goal-calculation-1', + geographicLocation: 'None', + }, + }, + }), + ); + }); + it('shows errors and does not save when input is invalid', async () => { const { getByRole } = render(); 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 0e117a1cb7..dfbe6f6792 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx @@ -18,6 +18,7 @@ import { MpdGoalBenefitsConstantPlanEnum, MpdGoalBenefitsConstantSizeEnum, } from 'src/graphql/types.generated'; +import { GEOGRAPHIC_LOCATION_NONE } from 'src/hooks/useGoalCalculatorConstants'; import { getLocalizedAge } from 'src/lib/functions/getLocalizedAge'; import { getLocalizedRole } from 'src/lib/functions/getLocalizedRole'; import { AutosaveTextField } from '../../Autosave/AutosaveTextField'; @@ -150,7 +151,10 @@ export const InformationCategoryPersonalForm: React.FC< saveField({ geographicLocation: newValue }) } diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index ee43cbd9fc..8a736d7956 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -344,6 +344,99 @@ describe('SetupStep', () => { ); }); + it('does not save while the Geographic Multiplier is cleared by typing', async () => { + const { findByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + geographicLocation: 'Orlando, FL', + }, + onCall: mutationSpy, + }); + + const input = await findByRole('combobox', { + name: 'Geographic Multiplier', + }); + await waitFor(() => expect(input).toHaveValue('Orlando, FL (6%)')); + + userEvent.clear(input); + + // Yield to the microtask queue so any pending mutation would have fired. + await new Promise((r) => setTimeout(r, 0)); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdatePdsGoalCalculation'); + // The field stays empty instead of resetting to None mid-edit + expect(input).toHaveValue(''); + }); + + it('reverts to the saved Geographic Multiplier when the cleared field loses focus', async () => { + const { findByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + geographicLocation: 'Orlando, FL', + }, + onCall: mutationSpy, + }); + + const input = await findByRole('combobox', { + name: 'Geographic Multiplier', + }); + await waitFor(() => expect(input).toHaveValue('Orlando, FL (6%)')); + + userEvent.clear(input); + userEvent.tab(); + + // The field is not clearable, so blurring restores the saved value + // without firing a mutation + await waitFor(() => expect(input).toHaveValue('Orlando, FL (6%)')); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdatePdsGoalCalculation'); + }); + + it('saves None when it is explicitly selected', async () => { + const { findByRole } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + geographicLocation: 'Orlando, FL', + }, + onCall: mutationSpy, + }); + + const input = await findByRole('combobox', { + name: 'Geographic Multiplier', + }); + await waitFor(() => expect(input).toHaveValue('Orlando, FL (6%)')); + + userEvent.click(input); + userEvent.click(await findByRole('option', { name: 'None' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + id: 'goal-1', + geographicLocation: 'None', + }, + }), + ); + }); + + it('does not save when a Geographic Multiplier of None is cleared and loses focus', async () => { + const { findByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + onCall: mutationSpy, + }); + + const input = await findByRole('combobox', { + name: 'Geographic Multiplier', + }); + await waitFor(() => expect(input).not.toBeDisabled()); + await waitFor(() => expect(input).toHaveValue('None')); + + userEvent.clear(input); + userEvent.tab(); + + // Yield to the microtask queue so any pending mutation would have fired. + await new Promise((r) => setTimeout(r, 0)); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdatePdsGoalCalculation'); + }); + it('renders the Calculate my average hours button next to Hours Worked', async () => { const { findByRole } = renderSetup({ calculationMock: fullTimeHourlyMock, diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index 0e690a2013..592b79040d 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -27,7 +27,10 @@ import { DesignationSupportSalaryType, DesignationSupportStatus, } from 'src/graphql/types.generated'; -import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; +import { + GEOGRAPHIC_LOCATION_NONE, + useGoalCalculatorConstants, +} from 'src/hooks/useGoalCalculatorConstants'; import { useLocale } from 'src/hooks/useLocale'; import { percentageFormat } from 'src/lib/intlFormat'; import { AutosaveTextField } from '../Shared/Autosave/AutosaveTextField'; @@ -281,9 +284,14 @@ export const SetupStep: React.FC = () => { + value={ + calculation?.geographicLocation ?? GEOGRAPHIC_LOCATION_NONE + } + onChange={(_, newValue) => saveField({ geographicLocation: newValue }) } disabled={!calculation} diff --git a/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx b/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx index fa88ff40f7..9f02f44ada 100644 --- a/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx +++ b/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx @@ -10,12 +10,21 @@ import { useSaveField } from './useSaveField'; export interface AutosaveAutocompleteProps extends Omit< - AutocompleteProps, - 'renderInput' | 'onChange' | 'value' + AutocompleteProps, + // disableClearable is owned by the component: it is derived from + // emptyValue, and letting a caller re-enable clearing would restore the + // mid-typing null save this component exists to prevent + 'renderInput' | 'onChange' | 'value' | 'disableClearable' > { fieldName: string; label: string; textFieldProps?: Partial; + /** + * Option displayed when the field has no saved value, e.g. 'None'. Must be + * one of the options. When set, the field is not clearable — selecting the + * empty-value option takes the place of clearing. + */ + emptyValue?: string; } export const AutosaveAutocomplete: React.FC = ({ @@ -23,16 +32,21 @@ export const AutosaveAutocomplete: React.FC = ({ label, options, textFieldProps, + emptyValue, ...props }) => { const saveField = useSaveField(); const { calculation } = useSalaryCalculator(); - const value = calculation?.[fieldName] ?? null; + const value = calculation?.[fieldName] ?? emptyValue ?? null; return ( saveField({ [fieldName]: newValue })} disabled={!calculation} diff --git a/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.test.tsx b/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.test.tsx index 84988ddd95..b88afd420a 100644 --- a/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.test.tsx +++ b/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.test.tsx @@ -12,11 +12,13 @@ const TestComponent: React.FC<{ hasSpouse?: boolean; requestMock?: SalaryRequestMock; payrollDates?: SalaryCalculatorTestWrapperProps['payrollDates']; -}> = ({ hasSpouse, requestMock, payrollDates }) => ( + onCall?: SalaryCalculatorTestWrapperProps['onCall']; +}> = ({ hasSpouse, requestMock, payrollDates, onCall }) => ( @@ -68,10 +70,10 @@ describe('PersonalInformationSection', () => { }); it('should display married personal information values correctly', async () => { - // Explicitly clear the saved location so the combobox is empty. (MUI v7's - // Autocomplete renders a controlled `value` even when it isn't one of the - // `options`, so relying on the auto-generated mock string would populate - // the field.) + // Explicitly clear the saved location so the combobox falls back to None. + // (MUI v7's Autocomplete renders a controlled `value` even when it isn't + // one of the `options`, so relying on the auto-generated mock string would + // populate the field.) const { findByRole } = render( , ); @@ -80,7 +82,7 @@ describe('PersonalInformationSection', () => { name: 'Nearest Geographic Multiplier Location', }); await waitFor(() => { - expect(locationCombobox).toHaveValue(''); + expect(locationCombobox).toHaveValue('None'); }); expect(await findByRole('cell', { name: '4 years' })).toBeInTheDocument(); @@ -112,7 +114,65 @@ describe('PersonalInformationSection', () => { userEvent.click(await findByRole('button', { name: 'Open' })); - expect(await findAllByRole('option')).toHaveLength(2); + // The two mocked locations plus the guaranteed None option + expect(await findAllByRole('option')).toHaveLength(3); + }); + + it('does not save while the location is cleared by typing and reverts on blur', async () => { + const mutationSpy = jest.fn(); + const { findByRole } = render( + , + ); + + const locationCombobox = await findByRole('combobox', { + name: 'Nearest Geographic Multiplier Location', + }); + await waitFor(() => { + expect(locationCombobox).toHaveValue('Miami, FL'); + }); + + userEvent.clear(locationCombobox); + + // Yield to the microtask queue so any pending mutation would have fired. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdateSalaryCalculation'); + expect(locationCombobox).toHaveValue(''); + + userEvent.tab(); + + // The field is not clearable, so blurring restores the saved value + // without firing a mutation + await waitFor(() => expect(locationCombobox).toHaveValue('Miami, FL')); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdateSalaryCalculation'); + }); + + it('saves the location when an option is explicitly selected', async () => { + const mutationSpy = jest.fn(); + const { findByRole } = render( + , + ); + + const locationCombobox = await findByRole('combobox', { + name: 'Nearest Geographic Multiplier Location', + }); + await waitFor(() => { + expect(locationCombobox).toHaveValue('Miami, FL'); + }); + + userEvent.click(await findByRole('button', { name: 'Open' })); + userEvent.click(await findByRole('option', { name: 'None' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateSalaryCalculation', { + input: { attributes: { location: 'None' } }, + }), + ); }); it('should render the effective paycheck note when payroll dates match', async () => { diff --git a/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.tsx b/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.tsx index 50e465d86b..a275b8d14b 100644 --- a/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.tsx +++ b/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.tsx @@ -11,7 +11,10 @@ import { } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { Trans, useTranslation } from 'react-i18next'; -import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; +import { + GEOGRAPHIC_LOCATION_NONE, + useGoalCalculatorConstants, +} from 'src/hooks/useGoalCalculatorConstants'; import { AutosaveAutocomplete } from '../../Autosave/AutosaveAutocomplete'; import { useSalaryCalculator } from '../../SalaryCalculatorContext/SalaryCalculatorContext'; import { EffectiveDateNote } from '../../Shared/EffectiveDateNote'; @@ -69,6 +72,7 @@ export const PersonalInformationSection: React.FC = () => { label={t('Nearest Geographic Multiplier Location')} fieldName="location" options={locations} + emptyValue={GEOGRAPHIC_LOCATION_NONE} textFieldProps={{ InputLabelProps: { sx: { fontSize: theme.typography.body2.fontSize }, diff --git a/src/hooks/useGoalCalculatorConstants.test.tsx b/src/hooks/useGoalCalculatorConstants.test.tsx index 0806f9d183..ebc2977188 100644 --- a/src/hooks/useGoalCalculatorConstants.test.tsx +++ b/src/hooks/useGoalCalculatorConstants.test.tsx @@ -121,7 +121,9 @@ describe('useGoalCalculatorConstants', () => { expect(result.current).toEqual({ goalBenefitsPlans: [], goalMiscConstants: {}, - goalGeographicConstantMap: new Map(), + // The None geographic option is seeded even before the data loads so + // that non-clearable dropdowns always have a valid option + goalGeographicConstantMap: new Map([['None', 0]]), loading: true, error: undefined, unavailable: false, @@ -169,6 +171,89 @@ describe('useGoalCalculatorConstants', () => { expect(result.current.loading).toBe(false); }); + it('guarantees a leading None geographic option when the data lacks one', async () => { + const { result } = renderHook(() => useGoalCalculatorConstants(), { + wrapper: ({ children }: { children: ReactElement }) => ( + + mocks={{ + GoalCalculatorConstants: { + constant: { + ...mockData.constant, + mpdGoalGeographicConstants: [ + { + __typename: 'MpdGoalGeographicConstant' as const, + id: '32818f68-59f7-4a06-83c6-6d286ec29bbf', + location: 'Atlanta, GA', + percentageMultiplier: 0.12, + }, + ], + }, + }, + }} + > + {children} + + ), + }); + + await waitFor(() => + expect(result.current.goalGeographicConstantMap).toEqual( + new Map([ + ['None', 0], + ['Atlanta, GA', 0.12], + ]), + ), + ); + expect(Array.from(result.current.goalGeographicConstantMap.keys())[0]).toBe( + 'None', + ); + }); + + it('lets a server None row overwrite the seeded value without moving it', async () => { + const { result } = renderHook(() => useGoalCalculatorConstants(), { + wrapper: ({ children }: { children: ReactElement }) => ( + + mocks={{ + GoalCalculatorConstants: { + constant: { + ...mockData.constant, + mpdGoalGeographicConstants: [ + { + __typename: 'MpdGoalGeographicConstant' as const, + id: '32818f68-59f7-4a06-83c6-6d286ec29bbf', + location: 'Atlanta, GA', + percentageMultiplier: 0.12, + }, + { + __typename: 'MpdGoalGeographicConstant' as const, + id: 'd1097a97-2a16-4c48-9ab5-5121d8ca129e', + location: 'None', + percentageMultiplier: 0.05, + }, + ], + }, + }, + }} + > + {children} + + ), + }); + + // The server's multiplier wins over the seeded 0 even though its row + // arrives second, and None keeps the leading position from the seed + await waitFor(() => + expect(result.current.goalGeographicConstantMap.get('None')).toBe(0.05), + ); + expect(Array.from(result.current.goalGeographicConstantMap.keys())).toEqual( + ['None', 'Atlanta, GA'], + ); + }); + it('should format data correctly', async () => { const { result } = renderHook(() => useGoalCalculatorConstants(), { wrapper: ({ children }: { children: ReactElement }) => ( diff --git a/src/hooks/useGoalCalculatorConstants.ts b/src/hooks/useGoalCalculatorConstants.ts index f70c67477a..13d35ef3d3 100644 --- a/src/hooks/useGoalCalculatorConstants.ts +++ b/src/hooks/useGoalCalculatorConstants.ts @@ -31,6 +31,14 @@ export type GoalMiscConstants = Partial< >; export type GoalGeographicConstantMap = Map; +/** + * The zero-multiplier geographic option meaning "no city applies". Must + * byte-match the server's constants row: the dropdowns are not clearable, so + * this exact string is both the guaranteed option and the value saved when a + * user declines a city. + */ +export const GEOGRAPHIC_LOCATION_NONE = 'None'; + export interface FormattedConstants { goalBenefitsPlans: GoalCalculatorConstantsQuery['constant']['mpdGoalBenefitsConstants']; goalMiscConstants: GoalMiscConstants; @@ -48,6 +56,8 @@ export const formatConstants = ( }); const goalGeographicConstantMap: GoalGeographicConstantMap = new Map(); + // Ensure the None option always exists + goalGeographicConstantMap.set(GEOGRAPHIC_LOCATION_NONE, 0); constant?.mpdGoalGeographicConstants.forEach((constant) => { const { location, percentageMultiplier } = constant; goalGeographicConstantMap.set(location, percentageMultiplier);