diff --git a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx index 46783d8fe..1e5947697 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import theme from 'src/theme'; +import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; +import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { FinancialDetails } from './FinancialDetails'; -const TestComponent: React.FC = () => ( - +const TestComponent: React.FC<{ onCall?: MockLinkCallHandler }> = ({ + onCall, +}) => ( + - + ); const studentLoanQuestion = @@ -31,6 +33,15 @@ describe('FinancialDetails', () => { expect(getByRole('radio', { name: 'No' })).toBeInTheDocument(); }); + it('shows the debt-question error once the group is touched without a selection', () => { + const { getByRole, getByText } = render(); + + getByRole('radio', { name: 'Yes' }).focus(); + userEvent.tab(); + + expect(getByText('Please select an answer.')).toBeInTheDocument(); + }); + it('hides the payment fields until Yes is selected', () => { const { queryByRole } = render(); @@ -90,4 +101,27 @@ describe('FinancialDetails', () => { expect(queryByText(requiredError)).not.toBeInTheDocument(); }); + + it('saves zero for every debt field when the user has no debt', async () => { + const mutationSpy = jest.fn(); + const { getByRole } = render(); + + userEvent.click(getByRole('radio', { name: 'No' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { + studentLoanMonthlyPayment: 0, + carLoanMonthlyPayment: 0, + creditCardDebtMonthlyPayment: 0, + }, + }, + }, + ), + ); + }); }); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx index 30550d385..bee57be3a 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx @@ -15,14 +15,16 @@ import { import { TFunction, useTranslation } from 'react-i18next'; import * as yup from 'yup'; import { useOptionalAutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm'; +import { useNsoMpdQuestionnaire } from '../Shared/NsoMpdQuestionnaireContext'; import { NumberQuestion } from '../Shared/NumberQuestion'; import { getAmountSchema } from '../Shared/helpers/getAmountSchema'; +import { QuestionnaireField } from '../Shared/useQuestionnaireAutoSave'; export const getFinancialDetailsSchema = (t: TFunction) => yup.object({ - studentLoanPayment: getAmountSchema(t), - carPayment: getAmountSchema(t), - creditCardPayment: getAmountSchema(t), + studentLoanMonthlyPayment: getAmountSchema(t), + carLoanMonthlyPayment: getAmountSchema(t), + creditCardDebtMonthlyPayment: getAmountSchema(t), }); export const FinancialDetails: React.FC = () => { @@ -30,10 +32,26 @@ export const FinancialDetails: React.FC = () => { const schema = useMemo(() => getFinancialDetailsSchema(t), [t]); + const { saveField } = useNsoMpdQuestionnaire(); + // UI only toggle const [hasDebt, setHasDebt] = useState(''); + const [hasDebtTouched, setHasDebtTouched] = useState(false); const showDebtFields = hasDebt === 'Yes'; const hasDebtError = !hasDebt; + const showHasDebtError = hasDebtError && hasDebtTouched; + + const handleHasDebtChange = (event: React.ChangeEvent) => { + const value = event.target.value; + setHasDebt(value); + if (value === 'No') { + saveField({ + studentLoanMonthlyPayment: 0, + carLoanMonthlyPayment: 0, + creditCardDebtMonthlyPayment: 0, + }); + } + }; const { markValid, markInvalid } = useOptionalAutosaveForm() ?? {}; useEffect(() => { @@ -45,19 +63,23 @@ export const FinancialDetails: React.FC = () => { return () => markValid?.('hasDebt'); }, [hasDebtError, markValid, markInvalid]); - const debtFields = [ + const debtFields: { + fieldName: QuestionnaireField; + debtType: string; + icon: React.ReactNode; + }[] = [ { - fieldName: 'studentLoanPayment', + fieldName: 'studentLoanMonthlyPayment', debtType: t('student loan debt'), icon: , }, { - fieldName: 'carPayment', + fieldName: 'carLoanMonthlyPayment', debtType: t('car debt'), icon: , }, { - fieldName: 'creditCardPayment', + fieldName: 'creditCardDebtMonthlyPayment', debtType: t('credit card debt'), icon: , }, @@ -65,7 +87,7 @@ export const FinancialDetails: React.FC = () => { return ( - + {t('Do you have any student loan, car, or credit card debt?')} @@ -74,12 +96,13 @@ export const FinancialDetails: React.FC = () => { sx={{ paddingInline: 2 }} aria-labelledby="has-debt-label" value={hasDebt} - onChange={(event) => setHasDebt(event.target.value)} + onChange={handleHasDebtChange} + onBlur={() => setHasDebtTouched(true)} > } label={t('Yes')} /> } label={t('No')} /> - {hasDebtError && ( + {showHasDebtError && ( {t('Please select an answer.')} )} diff --git a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx index 290e7f20b..99117d6d2 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx @@ -1,31 +1,16 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { GoalCalculatorConstantsQuery } from 'src/hooks/goalCalculatorConstants.generated'; -import theme from 'src/theme'; +import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; +import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { MinistryDetails } from './MinistryDetails'; -const TestComponent: React.FC = () => ( - - - mocks={{ - GoalCalculatorConstants: { - constant: { - mpdGoalGeographicConstants: [ - { location: 'Atlanta, GA' }, - { location: 'Miami, FL' }, - ], - }, - }, - }} - > - - - +const TestComponent: React.FC<{ onCall?: MockLinkCallHandler }> = ({ + onCall, +}) => ( + + + ); describe('MinistryDetails', () => { @@ -103,6 +88,25 @@ describe('MinistryDetails', () => { expect(getByRole('radio', { name: 'Office' })).toBeInTheDocument(); }); + it('saves FIELD when the field assignment is chosen', async () => { + const mutationSpy = jest.fn(); + const { getByRole } = render(); + + userEvent.click(getByRole('radio', { name: 'Field' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { assignmentType: 'FIELD' }, + }, + }, + ), + ); + }); + it('shows a loading indicator in place of the city field until the constants load', () => { const { getByRole, queryByRole } = render(); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx index 0816599d1..c1fb5fe7e 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx @@ -36,11 +36,11 @@ export const MinistryDetails: React.FC = () => { const schema = useMemo( () => yup.object({ - ministry: yup.string().required(t('Please select a ministry')), - assignmentLocation: yup + ministryName: yup.string().required(t('Please select a ministry')), + ministryLocation: yup .string() .required(t('Please enter an assignment location')), - nearestCity: yup + geographicLocation: yup .string() .required( t( @@ -55,18 +55,18 @@ export const MinistryDetails: React.FC = () => { ); const ministryProps = useQuestionnaireAutoSave({ - fieldName: 'ministry', + fieldName: 'ministryName', schema, saveOnChange: true, }); const locationProps = useQuestionnaireAutoSave({ - fieldName: 'assignmentLocation', + fieldName: 'ministryLocation', schema, }); const cityProps = useQuestionnaireAutoSave({ - fieldName: 'nearestCity', + fieldName: 'geographicLocation', schema, saveOnChange: true, }); @@ -148,8 +148,8 @@ export const MinistryDetails: React.FC = () => { row label={t('What type of assignment are you expecting?')} options={[ - { value: 'Field', label: t('Field') }, - { value: 'Office', label: t('Office') }, + { value: 'FIELD', label: t('Field') }, + { value: 'OFFICE', label: t('Office') }, ]} /> diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx index 90ce6b067..47ce53733 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import theme from 'src/theme'; +import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; +import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { NsoDetails } from './NsoDetails'; -const TestComponent: React.FC = () => ( - +const TestComponent: React.FC<{ onCall?: MockLinkCallHandler }> = ({ + onCall, +}) => ( + - + ); describe('NsoDetails', () => { @@ -37,6 +39,50 @@ describe('NsoDetails', () => { ).toBeInTheDocument(); }); + it('saves the housing enum constant', async () => { + const mutationSpy = jest.fn(); + const { getByRole } = render(); + + userEvent.click(getByRole('radio', { name: 'Family in a hotel/room' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { nsoHousing: 'FAMILY_ROOM' }, + }, + }, + ), + ); + }); + + it('saves the childcare count as a number', async () => { + const mutationSpy = jest.fn(); + const { getByRole } = render(); + + userEvent.type( + getByRole('spinbutton', { + name: 'If you are a parent with children in Childcare, please enter how many.', + }), + '3', + ); + userEvent.tab(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { childcareChildrenCount: 3 }, + }, + }, + ), + ); + }); + it('renders the sessions question with both options', () => { const { getByRole } = render(); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx index 222f32f98..560c56806 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx @@ -11,8 +11,8 @@ export const getNsoDetailsSchema = (t: TFunction) => yup.object({ nsoHousing: yup.string().required(t('Please select an answer.')), nsoSessions: yup.string().required(t('Please select an answer.')), - specialNeedsSupport: getAmountSchema(t), - childcareChildren: getWholeNumberSchema( + nsoSpecialNeedsSupportReceived: getAmountSchema(t), + childcareChildrenCount: getWholeNumberSchema( t, t('Please enter a number, or 0 if you have none.'), ), @@ -24,24 +24,15 @@ export const NsoDetails: React.FC = () => { const schema = useMemo(() => getNsoDetailsSchema(t), [t]); const housingOptions: RadioOption[] = [ - { - value: 'Single in hotel/dorm room', - label: t('Single in hotel/dorm room'), - }, - { - value: 'Sharing 2 in hotel/dorm room', - label: t('Sharing 2 in hotel/dorm room'), - }, - { - value: 'Couple in hotel/dorm room', - label: t('Couple in hotel/dorm room'), - }, - { value: 'Family in a hotel/room', label: t('Family in a hotel/room') }, - { value: 'Local / Commuting', label: t('Local / Commuting') }, + { value: 'SINGLE_ROOM', label: t('Single in hotel/dorm room') }, + { value: 'SHARED_ROOM', label: t('Sharing 2 in hotel/dorm room') }, + { value: 'COUPLE_ROOM', label: t('Couple in hotel/dorm room') }, + { value: 'FAMILY_ROOM', label: t('Family in a hotel/room') }, + { value: 'LOCAL_COMMUTING', label: t('Local / Commuting') }, ]; const sessionOptions: RadioOption[] = [ - { value: 'IBS and NSO', label: t('IBS and NSO') }, + { value: 'IBS_AND_NSO', label: t('IBS and NSO') }, { value: 'NSO', label: t('NSO') }, ]; @@ -62,7 +53,7 @@ export const NsoDetails: React.FC = () => { /> { /> (HcmDocument, { @@ -58,13 +59,23 @@ export interface NsoMpdQuestionnaireTestWrapperProps { hcmUser?: DeepPartial; hcmSpouse?: DeepPartial; hasSpouse?: boolean; + newStaffQuestionnaire?: DeepPartial< + NewStaffQuestionnaireQuery['newStaffQuestionnaire'] + >; onCall?: MockLinkCallHandler; children?: React.ReactNode; } export const NsoMpdQuestionnaireTestWrapper: React.FC< NsoMpdQuestionnaireTestWrapperProps -> = ({ hcmUser, hcmSpouse, hasSpouse = true, onCall, children }) => { +> = ({ + hcmUser, + hcmSpouse, + hasSpouse = true, + newStaffQuestionnaire, + onCall, + children, +}) => { const hcmUserMerged = merge({}, hcmUserMock, hcmUser); const hcmSpouseMerged = merge({}, hcmSpouseMock, hcmSpouse); return ( @@ -74,11 +85,15 @@ export const NsoMpdQuestionnaireTestWrapper: React.FC< Hcm: HcmQuery; GetUser: GetUserQuery; GoalCalculatorConstants: GoalCalculatorConstantsQuery; + NewStaffQuestionnaire: NewStaffQuestionnaireQuery; }> mocks={{ GetUser: { user: { avatar: 'avatar.jpg', staffAccountId: '000123456' }, }, + NewStaffQuestionnaire: { + newStaffQuestionnaire: newStaffQuestionnaire ?? null, + }, Hcm: { hcm: hasSpouse ? [hcmUserMerged, hcmSpouseMerged] diff --git a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx index 944f0cb1d..1ee3683ee 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import theme from 'src/theme'; +import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; +import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { ContactInformation } from './ContactInformation'; -const TestComponent: React.FC = () => ( - +const TestComponent: React.FC<{ onCall?: MockLinkCallHandler }> = ({ + onCall, +}) => ( + - + ); describe('ContactInformation', () => { @@ -26,6 +28,39 @@ describe('ContactInformation', () => { expect(getByRole('textbox', { name: 'Cell Phone Number' })).toBeRequired(); }); + it('saves the cell phone number through the upsert on blur', async () => { + const mutationSpy = jest.fn(); + const { getByRole } = render(); + + const input = getByRole('textbox', { name: 'Cell Phone Number' }); + userEvent.type(input, '(123) 456-7890'); + userEvent.tab(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { phoneNumber: '(123) 456-7890' }, + }, + }, + ), + ); + }); + + it('seeds the cell phone number from the loaded questionnaire', async () => { + const { findByDisplayValue } = render( + + + , + ); + + expect(await findByDisplayValue('(305) 111-2222')).toBeInTheDocument(); + }); + it('strips disallowed characters as the user types', () => { const { getByRole } = render(); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx index 7633eca76..20df5a85f 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx @@ -14,13 +14,15 @@ export const ContactInformation: React.FC = () => { const schema = useMemo( () => yup.object({ - cellPhone: phoneNumber(t).required(t('Cell phone number is required')), + phoneNumber: phoneNumber(t).required( + t('Cell phone number is required'), + ), }), [t], ); const fieldProps = useQuestionnaireAutoSave({ - fieldName: 'cellPhone', + fieldName: 'phoneNumber', schema, }); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NewStaffQuestionnaire.graphql b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NewStaffQuestionnaire.graphql new file mode 100644 index 000000000..706f47094 --- /dev/null +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NewStaffQuestionnaire.graphql @@ -0,0 +1,21 @@ +fragment NewStaffQuestionnaireFields on NewStaffQuestionnaire { + id + phoneNumber + ministryName + ministryLocation + geographicLocation + assignmentType + nsoHousing + nsoSessions + nsoSpecialNeedsSupportReceived + childcareChildrenCount + studentLoanMonthlyPayment + carLoanMonthlyPayment + creditCardDebtMonthlyPayment +} + +query NewStaffQuestionnaire($accountListId: ID!) { + newStaffQuestionnaire(accountListId: $accountListId) { + ...NewStaffQuestionnaireFields + } +} diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.test.tsx index d33713cc0..3e6d1de95 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.test.tsx @@ -1,10 +1,19 @@ import React, { useEffect } from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { NsoMpdQuestionnaireStepEnum } from '../NsoMpdQuestionnaireHelper'; import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { useNsoMpdQuestionnaire } from './NsoMpdQuestionnaireContext'; +const SaveComponent: React.FC = () => { + const { saveField } = useNsoMpdQuestionnaire(); + return ( + + ); +}; + interface InnerComponentProps { initialStep?: NsoMpdQuestionnaireStepEnum; } @@ -102,4 +111,27 @@ describe('NsoMpdQuestionnaireContext', () => { userEvent.click(getByRole('button', { name: 'Toggle Drawer' })); expect(drawerState).toHaveAttribute('data-open', 'false'); }); + + it('fires the upsert mutation with coerced attributes', async () => { + const mutationSpy = jest.fn(); + const { getByRole } = render( + + + , + ); + + userEvent.click(getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { phoneNumber: '305-555-1234' }, + }, + }, + ), + ); + }); }); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.tsx index 564190184..34fe7e2be 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NsoMpdQuestionnaireContext.tsx @@ -1,10 +1,21 @@ import React, { createContext, useCallback, useMemo, useState } from 'react'; +import { NewStaffQuestionnaireAttributesInput } from 'src/graphql/types.generated'; +import { useAccountListId } from 'src/hooks/useAccountListId'; import { HcmQuery, useHcmQuery } from '../../Shared/HcmData/Hcm.generated'; import { NsoMpdQuestionnaireStepEnum } from '../NsoMpdQuestionnaireHelper'; +import { + NewStaffQuestionnaireDocument, + NewStaffQuestionnaireQuery, + useNewStaffQuestionnaireQuery, +} from './NewStaffQuestionnaire.generated'; +import { useUpdateNewStaffQuestionnaireMutation } from './UpdateNewStaffQuestionnaire.generated'; import { NsoMpdQuestionnaireStep, useSteps } from './useSteps'; export type HcmPerson = HcmQuery['hcm'][number]; +export type NewStaffQuestionnaire = + NewStaffQuestionnaireQuery['newStaffQuestionnaire']; + export type NsoMpdQuestionnaireType = { steps: NsoMpdQuestionnaireStep[]; currentStep: NsoMpdQuestionnaireStep; @@ -17,6 +28,11 @@ export type NsoMpdQuestionnaireType = { setDrawerOpen: (open: boolean) => void; hcmUser: HcmPerson | null; hcmSpouse: HcmPerson | null; + questionnaire: NewStaffQuestionnaire | null; + questionnaireLoading: boolean; + saveField: ( + attributes: Partial, + ) => Promise; }; const NsoMpdQuestionnaireContext = @@ -48,6 +64,48 @@ export const NsoMpdQuestionnaireProvider: React.FC = ({ children }) => { const hcmUser = hcmData?.hcm[0] ?? null; const hcmSpouse = hcmData?.hcm[1] ?? null; + const accountListId = useAccountListId(); + + const { data: questionnaireData, loading: questionnaireLoading } = + useNewStaffQuestionnaireQuery({ + variables: { accountListId: accountListId ?? '' }, + skip: !accountListId, + }); + const questionnaire = questionnaireData?.newStaffQuestionnaire ?? null; + + const [updateNewStaffQuestionnaire] = useUpdateNewStaffQuestionnaireMutation(); + + const saveField = useCallback( + async ( + attributes: Partial, + ): Promise => { + if (!accountListId) { + return; + } + await updateNewStaffQuestionnaire({ + variables: { + input: { accountListId, attributes }, + }, + // The mutation returns the full record, so Apollo merges updates into the cache by id + // automatically. The one case that isn't covered is the first save, which creates the + // record while the query field is still cached as null — point it at the new record so + // every step sees it without an extra refetch round-trip per save. + update: (cache, { data }) => { + const saved = data?.updateNewStaffQuestionnaire?.newStaffQuestionnaire; + if (!saved) { + return; + } + cache.writeQuery({ + query: NewStaffQuestionnaireDocument, + variables: { accountListId }, + data: { newStaffQuestionnaire: saved }, + }); + }, + }); + }, + [accountListId, updateNewStaffQuestionnaire], + ); + const toggleDrawer = useCallback(() => { setIsDrawerOpen((prev) => !prev); }, []); @@ -85,6 +143,9 @@ export const NsoMpdQuestionnaireProvider: React.FC = ({ children }) => { setDrawerOpen, hcmUser, hcmSpouse, + questionnaire, + questionnaireLoading, + saveField, }), [ steps, @@ -98,6 +159,9 @@ export const NsoMpdQuestionnaireProvider: React.FC = ({ children }) => { setDrawerOpen, hcmUser, hcmSpouse, + questionnaire, + questionnaireLoading, + saveField, ], ); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.test.tsx index 2cb646ad4..37a2b14f2 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.test.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as yup from 'yup'; -import theme from 'src/theme'; +import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { NumberQuestion } from './NumberQuestion'; const schema = yup.object({ - count: yup + childcareChildrenCount: yup .string() .matches(/^\d+$/, 'Please enter a whole number.') .required('Please enter a number.'), @@ -16,15 +15,15 @@ const schema = yup.object({ const TestComponent: React.FC<{ startAdornment?: React.ReactNode }> = ({ startAdornment, }) => ( - + - + ); describe('NumberQuestion', () => { diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.tsx index bdb0fc284..4bc4bf29e 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { TextField } from '@mui/material'; import * as yup from 'yup'; -import { useQuestionnaireAutoSave } from './useQuestionnaireAutoSave'; +import { + QuestionnaireField, + useQuestionnaireAutoSave, +} from './useQuestionnaireAutoSave'; interface NumberQuestionProps { - fieldName: string; + fieldName: QuestionnaireField; schema: yup.Schema; question: string; helperText: string; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx index 8cff296be..37de9b32a 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as yup from 'yup'; -import theme from 'src/theme'; +import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { RadioOption, RadioQuestion } from './RadioQuestion'; const schema = yup.object({ - choice: yup.string().required('Please select an answer.'), + geographicLocation: yup.string().required('Please select an answer.'), }); const options: RadioOption[] = [ { value: 'A', label: 'Option A' }, @@ -15,15 +14,15 @@ const options: RadioOption[] = [ ]; const TestComponent: React.FC<{ row?: boolean }> = ({ row }) => ( - + - + ); describe('RadioQuestion', () => { diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx index 4576adbc6..d16770d68 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx @@ -8,7 +8,10 @@ import { RadioGroup, } from '@mui/material'; import * as yup from 'yup'; -import { useQuestionnaireAutoSave } from './useQuestionnaireAutoSave'; +import { + QuestionnaireField, + useQuestionnaireAutoSave, +} from './useQuestionnaireAutoSave'; export interface RadioOption { value: string; @@ -16,7 +19,7 @@ export interface RadioOption { } interface RadioQuestionProps { - fieldName: string; + fieldName: QuestionnaireField; schema: yup.Schema; label: string; options: RadioOption[]; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/UpdateNewStaffQuestionnaire.graphql b/src/components/HrTools/NsoMpdQuestionnaire/Shared/UpdateNewStaffQuestionnaire.graphql new file mode 100644 index 000000000..082501708 --- /dev/null +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/UpdateNewStaffQuestionnaire.graphql @@ -0,0 +1,9 @@ +mutation UpdateNewStaffQuestionnaire( + $input: NewStaffQuestionnaireUpdateMutationInput! +) { + updateNewStaffQuestionnaire(input: $input) { + newStaffQuestionnaire { + ...NewStaffQuestionnaireFields + } + } +} diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.test.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.test.ts index cad80e521..d7327db45 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.test.ts +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.test.ts @@ -14,9 +14,9 @@ describe('getAmountSchema', () => { ); }); - it('accepts a whole-dollar amount and normalizes leading zeros', () => { - expect(getAmountSchema(i18n.t).validateSync('500')).toBe('500'); - expect(getAmountSchema(i18n.t).validateSync('007')).toBe('7'); - expect(getAmountSchema(i18n.t).validateSync('0')).toBe('0'); + it('accepts a whole-dollar amount and parses it to a number', () => { + expect(getAmountSchema(i18n.t).validateSync('500')).toBe(500); + expect(getAmountSchema(i18n.t).validateSync('007')).toBe(7); + expect(getAmountSchema(i18n.t).validateSync('0')).toBe(0); }); }); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.ts index 6e44135a1..c6a682869 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.ts +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.ts @@ -6,7 +6,7 @@ import { getWholeNumberSchema } from './getWholeNumberSchema'; * Yup schema for a required, non-negative whole-dollar amount (no cents). Built on * {@link getWholeNumberSchema} with dollar-specific validation copy. */ -export const getAmountSchema = (t: TFunction): yup.StringSchema => +export const getAmountSchema = (t: TFunction): yup.NumberSchema => getWholeNumberSchema(t, t('Please enter an amount, or 0 if you have none.'), { positive: t('Please enter a positive amount.'), whole: t('Please enter a whole dollar amount.'), diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.test.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.test.ts index 02467d70c..31a41fcad 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.test.ts +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.test.ts @@ -22,12 +22,12 @@ describe('getWholeNumberSchema', () => { ).toThrow('Please enter a whole number.'); }); - it('accepts a whole number and normalizes leading zeros', () => { + it('accepts a whole number and parses it to a number', () => { const schema = getWholeNumberSchema(i18n.t, requiredMessage); - expect(schema.validateSync('500')).toBe('500'); - expect(schema.validateSync('007')).toBe('7'); - expect(schema.validateSync('0')).toBe('0'); + expect(schema.validateSync('500')).toBe(500); + expect(schema.validateSync('007')).toBe(7); + expect(schema.validateSync('0')).toBe(0); }); it('uses overridden positive and whole messages', () => { diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.ts index 5a89f1542..eddcd54d5 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.ts +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.ts @@ -9,21 +9,17 @@ interface WholeNumberMessages { } /** - * yup schema for a required, non-negative whole number. Leading zeros - * are normalized. Callers supply the required message and may override the + * yup schema for a required, non-negative whole number. Callers supply the required message and may override the * positive/whole validation messages for domain-specific copy. */ export const getWholeNumberSchema = ( t: TFunction, requiredMessage: string, messages: WholeNumberMessages = {}, -): yup.StringSchema => +): yup.NumberSchema => yup - .string() - // Normalize leading zeros - .transform((value) => - typeof value === 'string' ? value.replace(/^0+(?=\d)/, '') : value, - ) - .matches(/^[^-]/, messages.positive ?? t('Please enter a positive number.')) - .matches(/^\d+$/, messages.whole ?? t('Please enter a whole number.')) + .number() + .typeError(messages.whole ?? t('Please enter a whole number.')) + .min(0, messages.positive ?? t('Please enter a positive number.')) + .integer(messages.whole ?? t('Please enter a whole number.')) .required(requiredMessage); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx index 48596eaa9..8cb6180c5 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx @@ -1,39 +1,52 @@ import React from 'react'; import { TextField } from '@mui/material'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as yup from 'yup'; +import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; import { useQuestionnaireAutoSave } from './useQuestionnaireAutoSave'; const TestComponent: React.FC = () => { const schema = yup.object({ - field: yup.string().min(7, 'Too short'), + phoneNumber: yup.string().min(7, 'Too short'), + }); + const props = useQuestionnaireAutoSave({ + fieldName: 'phoneNumber', + schema, }); - - const props = useQuestionnaireAutoSave({ fieldName: 'field', schema }); - return ; }; describe('useQuestionnaireAutoSave', () => { - it('starts empty without an error', () => { - const { getByRole, queryByText } = render(); + it('starts empty when there is no loaded questionnaire', () => { + const { getByRole } = render( + + + , + ); expect(getByRole('textbox', { name: 'Field' })).toHaveValue(''); - expect(queryByText('Too short')).not.toBeInTheDocument(); }); - it('stores typed values in local state', () => { - const { getByRole } = render(); + it('seeds the field from the loaded questionnaire', async () => { + const { findByDisplayValue } = render( + + + , + ); - const input = getByRole('textbox', { name: 'Field' }); - userEvent.type(input, '1234567890'); - - expect(input).toHaveValue('1234567890'); + expect(await findByDisplayValue('305-111-2222')).toBeInTheDocument(); }); it('surfaces the schema validation error for an invalid value once touched', () => { - const { getByRole, getByText } = render(); + const { getByRole, getByText } = render( + + + , + ); userEvent.type(getByRole('textbox', { name: 'Field' }), '123'); userEvent.tab(); @@ -41,12 +54,28 @@ describe('useQuestionnaireAutoSave', () => { expect(getByText('Too short')).toBeInTheDocument(); }); - it('clears the error once the value becomes valid', () => { - const { getByRole, queryByText } = render(); + it('saves a valid value through the upsert on blur', async () => { + const mutationSpy = jest.fn(); + const { getByRole } = render( + + + , + ); const input = getByRole('textbox', { name: 'Field' }); - userEvent.type(input, '1234567'); + userEvent.type(input, '305-555-1234'); + userEvent.tab(); - expect(queryByText('Too short')).not.toBeInTheDocument(); + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'UpdateNewStaffQuestionnaire', + { + input: { + accountListId: 'account-list-1', + attributes: { phoneNumber: '305-555-1234' }, + }, + }, + ), + ); }); }); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.ts index d4e864f96..3f372e3b7 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.ts +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.ts @@ -1,29 +1,34 @@ -import { useState } from 'react'; import * as yup from 'yup'; import { useAutoSave } from 'src/components/Shared/Autosave/useAutosave'; +import { NewStaffQuestionnaireAttributesInput } from 'src/graphql/types.generated'; +import { + NewStaffQuestionnaire, + useNsoMpdQuestionnaire, +} from './NsoMpdQuestionnaireContext'; + +export type QuestionnaireField = keyof NewStaffQuestionnaireAttributesInput & + keyof NonNullable; interface UseQuestionnaireAutoSaveOptions { - fieldName: string; + fieldName: QuestionnaireField; schema: yup.Schema; saveOnChange?: boolean; } /** - * Bridges questionnaire fields to the shared {@link useAutoSave} hook until the questionnaire has a - * backing API. + * Bridges a single questionnaire field to the shared {@link useAutoSave} hook: seeds the value + * from the loaded questionnaire and persists edits through the context's upsert mutation. */ export const useQuestionnaireAutoSave = ({ fieldName, ...options }: UseQuestionnaireAutoSaveOptions) => { - const [questionnaire, setQuestionnaire] = useState< - Record - >({}); + const { questionnaire, saveField } = useNsoMpdQuestionnaire(); return useAutoSave({ - value: questionnaire[fieldName], + value: questionnaire?.[fieldName], saveValue: async (value) => { - setQuestionnaire((prev) => ({ ...prev, [fieldName]: value })); + await saveField({ [fieldName]: value }); }, fieldName, ...options,