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..a6273b4c36 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,66 @@ describe('InformationCategory', () => { ); }); + it('does not save while the Geographic Location is cleared by typing', async () => { + mutationSpy.mockClear(); + 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('saves null when the cleared Geographic Location loses focus', async () => { + mutationSpy.mockClear(); + const { getByRole } = render( + , + ); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + await waitFor(() => expect(input).toHaveValue('Orlando, FL')); + + userEvent.clear(input); + userEvent.tab(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateGoalCalculation', { + input: { + accountListId: 'account-list-1', + attributes: { + id: 'goal-calculation-1', + geographicLocation: null, + }, + }, + }), + ); + }); + + it('defaults to None and does not save when cleared and blurred without a saved location', async () => { + mutationSpy.mockClear(); + 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('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..511634d9e6 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx @@ -1,17 +1,11 @@ import React, { useEffect, useMemo } from 'react'; import InfoIcon from '@mui/icons-material/Info'; -import { - Autocomplete, - Grid, - IconButton, - MenuItem, - TextField, - Typography, -} from '@mui/material'; +import { Grid, IconButton, MenuItem, Typography } from '@mui/material'; import { range } from 'lodash'; import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; import { useGoalCalculator } from 'src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext'; +import { DeferredClearAutocomplete } from 'src/components/HrTools/Shared/DeferredClearAutocomplete'; import { GoalCalculationAge, GoalCalculationRole, @@ -148,22 +142,19 @@ export const InformationCategoryPersonalForm: React.FC< {!isSpouse && ( - - saveField({ geographicLocation: newValue }) + // While the constants load the options are empty, so any + // non-null value (including the 'None' fallback) would trigger + // MUI's "value not in options" dev warning + value={constantsLoading ? null : geographicLocation ?? 'None'} + onSave={(geographicLocation) => + saveField({ geographicLocation }) } - disabled={!data || isReadOnly} - size="small" - renderInput={(params) => ( - + disabled={!data || constantsLoading || isReadOnly} + label={t('Geographic Location')} + helperText={t( + 'Do you live within 50 miles of one of these major cities?', )} /> diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.test.ts b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.test.ts index 7fc4b104e1..8a7c218221 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.test.ts +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.test.ts @@ -97,16 +97,23 @@ describe('calculationToFormValues', () => { ...baseCalculation, age: null, annualRequestedSalary: null, - geographicLocation: null, calculationsYear: null, }); expect(values.age).toBe(''); expect(values.annualRequestedSalary).toBe(''); - expect(values.geographicLocation).toBe(''); expect(values.calculationsYear).toBe(''); }); + it('defaults geographic location to None when unset', () => { + const values = calculationToFormValues({ + ...baseCalculation, + geographicLocation: null, + }); + + expect(values.geographicLocation).toBe('None'); + }); + describe('403(b) contribution default', () => { const noContribution = { ...baseCalculation, @@ -268,6 +275,17 @@ describe('formValuesToAttributes', () => { expect(attributes.maritalStatus).toBeNull(); }); + it('normalizes a geographic location of None to null on submit', () => { + // 'None' and null both mean no multiplier; null is the canonical stored + // form, so the display default of 'None' must not be persisted as-is. + const attributes = formValuesToAttributes({ + ...coupleValues, + geographicLocation: 'None', + }); + + expect(attributes.geographicLocation).toBeNull(); + }); + it('preserves a numeric 0 instead of dropping it to null', () => { const attributes = formValuesToAttributes({ ...coupleValues, diff --git a/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.ts b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.ts index f6fbedac12..f1689f9d7e 100644 --- a/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.ts +++ b/src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsApiMapping.ts @@ -69,7 +69,8 @@ export const calculationToFormValues = ( staffConferenceTransfer: toNumberInput(calc.staffConferenceTransfer), accountTransfers: toNumberInput(calc.accountTransfers), advocacyTransfers: toNumberInput(calc.advocacyTransfers), - geographicLocation: calc.geographicLocation ?? '', + // An unset location defaults to the 'None' (0 multiplier) constant option + geographicLocation: calc.geographicLocation ?? 'None', studentLoanMonthlyPayment: toNumberInput(calc.studentLoanMonthlyPayment), carLoanMonthlyPayment: toNumberInput(calc.carLoanMonthlyPayment), creditCardDebtMonthlyPayment: toNumberInput( @@ -162,7 +163,12 @@ export const formValuesToAttributes = ( ? toNumberOrNull(values.advocacyTransfers) : null, - geographicLocation: values.geographicLocation || null, + // 'None' and null are equivalent (both mean no multiplier); null is the + // canonical stored form for NS, so normalize the display default back. + geographicLocation: + values.geographicLocation === 'None' + ? null + : values.geographicLocation || null, studentLoanMonthlyPayment: toNumberOrNull(values.studentLoanMonthlyPayment), carLoanMonthlyPayment: toNumberOrNull(values.carLoanMonthlyPayment), diff --git a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx index 56edf3fe59..d3912a7e77 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx @@ -5,10 +5,16 @@ import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { MinistryDetails } from './MinistryDetails'; -const TestComponent: React.FC<{ onCall?: MockLinkCallHandler }> = ({ - onCall, -}) => ( - +const TestComponent: React.FC<{ + onCall?: MockLinkCallHandler; + questionnaire?: React.ComponentProps< + typeof NsoMpdQuestionnaireTestWrapper + >['newStaffQuestionnaire']; +}> = ({ onCall, questionnaire }) => ( + ); @@ -101,6 +107,75 @@ describe('MinistryDetails', () => { expect(getByRole('option', { name: 'Miami, FL' })).toBeInTheDocument(); }); + it('defaults the city question to None and does not require an answer', async () => { + const { findByRole } = render( + , + ); + + const cityCombobox = await findByRole('combobox', { + name: 'Is your ministry assignment location within 50 miles of one of these cities?', + }); + await waitFor(() => + expect(cityCombobox).not.toHaveAttribute('aria-disabled', 'true'), + ); + + expect(cityCombobox).toHaveTextContent('None'); + expect(cityCombobox).not.toBeRequired(); + }); + + it('shows no validation error when the city question is blurred without an answer', async () => { + const { findByRole, getByText, queryByText } = render( + , + ); + + const cityCombobox = await findByRole('combobox', { + name: 'Is your ministry assignment location within 50 miles of one of these cities?', + }); + await waitFor(() => + expect(cityCombobox).not.toHaveAttribute('aria-disabled', 'true'), + ); + + cityCombobox.focus(); + userEvent.tab(); + + expect(queryByText(/select an answer/i)).not.toBeInTheDocument(); + // The guidance helper text remains in place of a validation error + expect( + getByText('If none of the locations apply, leave it as "None".'), + ).toBeInTheDocument(); + }); + + it('saves None when it is explicitly selected', async () => { + const mutationSpy = jest.fn(); + const { findByRole } = render( + , + ); + + const cityCombobox = await findByRole('combobox', { + name: 'Is your ministry assignment location within 50 miles of one of these cities?', + }); + await waitFor(() => + expect(cityCombobox).not.toHaveAttribute('aria-disabled', 'true'), + ); + userEvent.click(cityCombobox); + userEvent.click(await findByRole('option', { name: 'None' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { geographicLocation: 'None' }, + }, + }, + ), + ); + }); + it('offers Field and Office assignment types', () => { const { getByRole } = render(); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx index d024ead23e..69a7c495bb 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx @@ -29,13 +29,8 @@ export const MinistryDetails: React.FC = () => { ministryLocation: yup .string() .required(t('Please enter an assignment location')), - geographicLocation: yup - .string() - .required( - t( - 'Please select an answer. If none of the cities apply, select "None."', - ), - ), + // Optional: an unanswered question displays and calculates as "None" + geographicLocation: yup.string().nullable(), assignmentType: yup .string() .required(t('Please select an assignment type')), @@ -100,8 +95,10 @@ export const MinistryDetails: React.FC = () => { )} placeholder={t('Select a city')} startAdornment={} - helperText={t('If none of the locations apply, select "None."')} + helperText={t('If none of the locations apply, leave it as "None".')} options={cities.map((city) => ({ value: city, label: city }))} + required={false} + emptyValue="None" /> )} diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.test.tsx index 7f5daed7b9..01ca0ed85e 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.test.tsx @@ -18,7 +18,8 @@ const TestComponent: React.FC<{ helperText?: string; errorText?: string; disabled?: boolean; -}> = ({ helperText, errorText, disabled }) => ( + emptyValue?: string; +}> = ({ helperText, errorText, disabled, emptyValue }) => ( ); @@ -54,6 +56,21 @@ describe('SelectQuestion', () => { expect(getByRole('option', { name: 'Select a city' })).toBeInTheDocument(); }); + it('hides the placeholder when emptyValue is provided', async () => { + const { getByRole, findByRole, queryByRole } = render( + , + ); + + userEvent.click(getByRole('combobox', { name: 'Nearest city' })); + + expect( + await findByRole('option', { name: 'Atlanta, GA' }), + ).toBeInTheDocument(); + expect( + queryByRole('option', { name: 'Select a city' }), + ).not.toBeInTheDocument(); + }); + it('shows the guidance helper text', () => { const { getByText } = render( , diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.tsx index cfdbd0cc32..c3858120e9 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/SelectQuestion.tsx @@ -17,7 +17,10 @@ interface SelectQuestionProps { schema: yup.Schema; label: string; options: SelectOption[]; - /** Text for the disabled, empty placeholder option. */ + /** + * Text for the disabled, empty placeholder option. Not rendered when {@link emptyValue} is set, + * since the select then always displays a real option. + */ placeholder: string; /** Optional leading adornment rendered inside the field. */ startAdornment?: React.ReactNode; @@ -30,6 +33,13 @@ interface SelectQuestionProps { * the field renders in its error state with this message. */ errorText?: string; + /** Whether an answer is required. Defaults to true. */ + required?: boolean; + /** + * Option displayed when the field has no saved value (e.g. 'None'). Must match one of the + * options' values. Selecting it explicitly still saves it; the fallback itself saves nothing. + */ + emptyValue?: string; } /** @@ -47,10 +57,13 @@ export const SelectQuestion: React.FC = ({ disabled = false, helperText, errorText, + required = true, + emptyValue, }) => { const { error: fieldError, helperText: fieldErrorText, + value, ...fieldProps } = useQuestionnaireAutoSave({ fieldName, schema, saveOnChange: true }); @@ -61,14 +74,14 @@ export const SelectQuestion: React.FC = ({ return ( {(aria) => ( = ({ : undefined, }} {...fieldProps} + value={value || emptyValue || ''} disabled={disabled} > - - - {placeholder} - - + {!emptyValue && ( + + + {placeholder} + + + )} {options.map((option) => ( {option.label} diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.test.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.test.ts index 2b38e578ee..1b28071c8d 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.test.ts +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.test.ts @@ -107,6 +107,15 @@ describe('isStepComplete', () => { ).toBe(false); }); + it('treats geographic location as optional for the Ministry step', () => { + expect( + isStepComplete(NsoMpdQuestionnaireStepEnum.MinistryInformation, { + ...completeSingle, + geographicLocation: null, + }), + ).toBe(true); + }); + it('is incomplete when a required field is missing', () => { expect( isStepComplete(NsoMpdQuestionnaireStepEnum.MinistryInformation, { diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.ts index 42fdfeb5de..dcaf71bba7 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.ts +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/stepCompletion.ts @@ -18,10 +18,11 @@ const steps: NsoMpdQuestionnaireStepEnum[] = [ const stepRequiredFields: Record = { // Personal Information is a read-only review step, so it has no fields the user must fill in. [NsoMpdQuestionnaireStepEnum.PersonalInformation]: [], + // geographicLocation is intentionally absent: it is optional and an + // unanswered question displays and calculates as "None". [NsoMpdQuestionnaireStepEnum.MinistryInformation]: [ 'ministryName', 'ministryLocation', - 'geographicLocation', 'assignmentType', ], [NsoMpdQuestionnaireStepEnum.FinancialInformation]: [ diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index ee43cbd9fc..2b7e762c46 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -344,6 +344,79 @@ describe('SetupStep', () => { ); }); + it('does not save while the Geographic Multiplier is cleared by typing', async () => { + mutationSpy.mockClear(); + 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('saves null when the cleared Geographic Multiplier loses focus', async () => { + mutationSpy.mockClear(); + 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(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + id: 'goal-1', + geographicLocation: null, + }, + }), + ); + }); + + it('does not save when a Geographic Multiplier of None is cleared and loses focus', async () => { + mutationSpy.mockClear(); + 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..515ba79257 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -1,8 +1,6 @@ import React, { useMemo } from 'react'; import CalculateIcon from '@mui/icons-material/Calculate'; import { - Autocomplete, - AutocompleteRenderInputParams, Avatar, Box, Button, @@ -21,6 +19,7 @@ import { CurrencyAdornment, PercentageAdornment, } from 'src/components/HrTools/Shared/Adornments'; +import { DeferredClearAutocomplete } from 'src/components/HrTools/Shared/DeferredClearAutocomplete'; import { useGetUserQuery } from 'src/components/User/GetUser.generated'; import { DesignationSupportFormType, @@ -279,21 +278,17 @@ export const SetupStep: React.FC = () => { )} - - saveField({ geographicLocation: newValue }) + onSave={(geographicLocation) => + saveField({ geographicLocation }) } disabled={!calculation} - size="small" - renderInput={(params: AutocompleteRenderInputParams) => ( - + label={t('Geographic Multiplier')} + helperText={t( + 'Do you live within 50 miles of one of these major cities?', )} /> diff --git a/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx b/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx index fa88ff40f7..e36d69a7e7 100644 --- a/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx +++ b/src/components/HrTools/SalaryCalculator/Autosave/AutosaveAutocomplete.tsx @@ -1,57 +1,43 @@ import React from 'react'; -import { - Autocomplete, - AutocompleteProps, - TextField, - TextFieldProps, -} from '@mui/material'; +import { TextFieldProps } from '@mui/material'; +import { DeferredClearAutocomplete } from 'src/components/HrTools/Shared/DeferredClearAutocomplete'; import { useSalaryCalculator } from '../SalaryCalculatorContext/SalaryCalculatorContext'; import { useSaveField } from './useSaveField'; -export interface AutosaveAutocompleteProps - extends Omit< - AutocompleteProps, - 'renderInput' | 'onChange' | 'value' - > { +export interface AutosaveAutocompleteProps { fieldName: string; label: string; + options: string[]; textFieldProps?: Partial; + /** Option displayed when the field has no saved value, e.g. 'None'. */ + emptyValue?: string; } +/** + * Autocomplete that autosaves its value via `useSaveField`. Note that + * clearing the input and blurring saves `null` for `fieldName`, so only use + * this component for fields where the server accepts a `null` value. + */ export const AutosaveAutocomplete: React.FC = ({ fieldName, label, options, textFieldProps, - ...props + emptyValue, }) => { const saveField = useSaveField(); const { calculation } = useSalaryCalculator(); - const value = calculation?.[fieldName] ?? null; + const value = calculation?.[fieldName] ?? emptyValue ?? null; return ( - saveField({ [fieldName]: newValue })} + onSave={(newValue) => saveField({ [fieldName]: newValue })} disabled={!calculation} - size="small" - renderInput={(params) => ( - - )} - {...props} + label={label} + textFieldProps={textFieldProps} /> ); }; diff --git a/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.test.tsx b/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.test.tsx index 84988ddd95..013f68b226 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,44 @@ 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 saves null 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(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateSalaryCalculation', { + input: { + attributes: { + location: null, + }, + }, + }), + ); }); 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..568ac33575 100644 --- a/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.tsx +++ b/src/components/HrTools/SalaryCalculator/YourInformation/PersonalInformationSection/PersonalInformationSection.tsx @@ -60,7 +60,7 @@ export const PersonalInformationSection: React.FC = () => { {t( - 'If you live within 50 miles of one of the following metropolitan areas, please select it from the list. If not, select "None."', + 'If you live within 50 miles of one of the following metropolitan areas, please select it from the list. Otherwise, leave it as "None".', )} @@ -69,6 +69,7 @@ export const PersonalInformationSection: React.FC = () => { label={t('Nearest Geographic Multiplier Location')} fieldName="location" options={locations} + emptyValue="None" textFieldProps={{ InputLabelProps: { sx: { fontSize: theme.typography.body2.fontSize }, diff --git a/src/components/HrTools/Shared/DeferredClearAutocomplete.test.tsx b/src/components/HrTools/Shared/DeferredClearAutocomplete.test.tsx new file mode 100644 index 0000000000..48a0152ca6 --- /dev/null +++ b/src/components/HrTools/Shared/DeferredClearAutocomplete.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DeferredClearAutocomplete } from './DeferredClearAutocomplete'; + +const onSave = jest.fn(); + +const options = ['None', 'Orlando, FL', 'Miami, FL']; + +interface TestComponentProps { + value?: string | null; +} + +const TestComponent: React.FC = ({ value = 'None' }) => ( + +); + +describe('DeferredClearAutocomplete', () => { + beforeEach(() => { + onSave.mockClear(); + }); + + it('saves immediately when an option is selected', async () => { + const { getByRole, findByRole } = render(); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + userEvent.type(input, 'Orlando'); + userEvent.click(await findByRole('option', { name: 'Orlando, FL' })); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith('Orlando, FL'); + }); + + it('does not save while the value is cleared by typing', () => { + const { getByRole } = render(); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + userEvent.clear(input); + + expect(onSave).not.toHaveBeenCalled(); + // The field stays empty instead of resetting mid-edit + expect(input).toHaveValue(''); + }); + + it('saves null when the cleared input loses focus', () => { + const { getByRole } = render(); + + const input = getByRole('combobox', { name: 'Geographic Location' }); + userEvent.clear(input); + userEvent.tab(); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/components/HrTools/Shared/DeferredClearAutocomplete.tsx b/src/components/HrTools/Shared/DeferredClearAutocomplete.tsx new file mode 100644 index 0000000000..23a9e5c6ec --- /dev/null +++ b/src/components/HrTools/Shared/DeferredClearAutocomplete.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Autocomplete, TextField, TextFieldProps } from '@mui/material'; + +export interface DeferredClearAutocompleteProps { + options: string[]; + /** The saved value, or a fallback option to display (e.g. 'None'). */ + value: string | null; + /** + * Called with the new value when an option is selected, and with `null` + * when the emptied input loses focus. + */ + onSave: (newValue: string | null) => void; + label: string; + helperText?: React.ReactNode; + disabled?: boolean; + getOptionLabel?: (option: string) => string; + textFieldProps?: Partial; +} + +/** + * Controlled single-value Autocomplete that saves selections immediately but + * defers clear-saves until the field loses focus. Note that clearing the + * input and blurring saves `null`, so only use this component for fields + * where the server accepts a `null` value. + */ +export const DeferredClearAutocomplete: React.FC< + DeferredClearAutocompleteProps +> = ({ + options, + value, + onSave, + label, + helperText, + disabled, + getOptionLabel, + textFieldProps, +}) => ( + { + // Emptying the input fires onChange(null, 'clear') while the user may + // still be typing a new value. Defer that save to blur so mid-edit + // keystrokes don't mutate and reset the field. + if (reason === 'clear') { + return; + } + onSave(newValue); + }} + disabled={disabled} + size="small" + renderInput={(params) => ( + { + if (event.target.value === '') { + onSave(null); + } + }} + InputProps={{ + ...params.InputProps, + ...textFieldProps?.InputProps, + }} + InputLabelProps={{ + ...params.InputLabelProps, + ...textFieldProps?.InputLabelProps, + }} + /> + )} + /> +); diff --git a/src/hooks/useGoalCalculatorConstants.test.tsx b/src/hooks/useGoalCalculatorConstants.test.tsx index 0806f9d183..e5282d957f 100644 --- a/src/hooks/useGoalCalculatorConstants.test.tsx +++ b/src/hooks/useGoalCalculatorConstants.test.tsx @@ -169,6 +169,46 @@ 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('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..fdf6c4eb10 100644 --- a/src/hooks/useGoalCalculatorConstants.ts +++ b/src/hooks/useGoalCalculatorConstants.ts @@ -48,6 +48,13 @@ export const formatConstants = ( }); const goalGeographicConstantMap: GoalGeographicConstantMap = new Map(); + if (constant) { + // The 'None' (0 multiplier) option is load-bearing for every geographic + // dropdown: it is the only way to answer that no city applies. Seed it + // first so it survives a curated year dataset that omits it; a + // server-provided 'None' row overwrites the value but keeps the position. + goalGeographicConstantMap.set('None', 0); + } constant?.mpdGoalGeographicConstants.forEach((constant) => { const { location, percentageMultiplier } = constant; goalGeographicConstantMap.set(location, percentageMultiplier);