Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,19 +1,61 @@
import React from 'react';
import { ThemeProvider } from '@mui/material';
import { render } from '@testing-library/react';
import { Form, Formik } from 'formik';
import { Form, Formik, FormikErrors, FormikTouched } from 'formik';
import { GqlMockedProvider } from '__tests__/util/graphqlMocking';
import { NewStaffGoalCalculationSalaryOverCapEnum } from 'src/graphql/types.generated';
import theme from 'src/theme';
import { defaultGoalCalculation } from '../../NsGoalCalculatorTestWrapper';
import { GoalSettingsPreviewProvider } from '../GoalSettingsPreviewContext';
import { calculationToFormValues } from '../goalSettingsApiMapping';
import { GoalSettingsFormValues } from '../goalSettingsFormValues';
import { GoalSettingsTextField } from './GoalSettingsTextField';
import { GoalSettingsFieldBaseProps } from './useGoalSettingsField';

interface TestComponentProps extends GoalSettingsFieldBaseProps {
initialErrors?: FormikErrors<GoalSettingsFormValues>;
initialTouched?: FormikTouched<GoalSettingsFormValues>;
savedSalaryOverCap?: boolean;
allowSalaryOverCap?: NewStaffGoalCalculationSalaryOverCapEnum;
secaExempt?: GoalSettingsFormValues['secaExempt'];
spouseSecaExempt?: GoalSettingsFormValues['spouseSecaExempt'];
}

// Exercises the hook through a real consumer (GoalSettingsTextField).
const TestComponent: React.FC<GoalSettingsFieldBaseProps> = (props) => (
const TestComponent: React.FC<TestComponentProps> = ({
initialErrors,
initialTouched,
savedSalaryOverCap = false,
allowSalaryOverCap = NewStaffGoalCalculationSalaryOverCapEnum.No,
secaExempt = 'false',
spouseSecaExempt = 'false',
...fieldProps
}) => (
<ThemeProvider theme={theme}>
<Formik initialValues={{ [props.name]: '' }} onSubmit={jest.fn()}>
<Form>
<GoalSettingsTextField {...props} />
</Form>
</Formik>
<GqlMockedProvider>
<Formik
initialValues={{
...calculationToFormValues(defaultGoalCalculation),
allowSalaryOverCap,
secaExempt,
spouseSecaExempt,
}}
initialErrors={initialErrors}
initialTouched={initialTouched}
onSubmit={jest.fn()}
>
<GoalSettingsPreviewProvider
accountListId="account-list-1"
calculationId={defaultGoalCalculation.id}
savedSalaryOverCap={savedSalaryOverCap}
savedDebtOverCap={false}
>
<Form>
<GoalSettingsTextField {...fieldProps} />
</Form>
</GoalSettingsPreviewProvider>
</Formik>
</GqlMockedProvider>
</ThemeProvider>
);

Expand Down Expand Up @@ -65,18 +107,12 @@ describe('useGoalSettingsField', () => {

it('surfaces the Formik error as helperText once the field is touched', () => {
const { getByRole, getByText } = render(
<ThemeProvider theme={theme}>
<Formik
initialValues={{ age: '' }}
initialErrors={{ age: 'Age is required' }}
initialTouched={{ age: true }}
onSubmit={jest.fn()}
>
<Form>
<GoalSettingsTextField name="age" label="Age" />
</Form>
</Formik>
</ThemeProvider>,
<TestComponent
name="age"
label="Age"
initialErrors={{ age: 'Age is required' }}
initialTouched={{ age: true }}
/>,
);

expect(getByRole('textbox', { name: 'Age' })).toBeInvalid();
Expand All @@ -85,20 +121,114 @@ describe('useGoalSettingsField', () => {

it('hides the error until the field is touched', () => {
const { getByRole, queryByText } = render(
<ThemeProvider theme={theme}>
<Formik
initialValues={{ age: '' }}
initialErrors={{ age: 'Age is required' }}
onSubmit={jest.fn()}
>
<Form>
<GoalSettingsTextField name="age" label="Age" />
</Form>
</Formik>
</ThemeProvider>,
<TestComponent
name="age"
label="Age"
initialErrors={{ age: 'Age is required' }}
/>,
);

expect(getByRole('textbox', { name: 'Age' })).toBeValid();
expect(queryByText('Age is required')).not.toBeInTheDocument();
});

it('outlines a contributing field in the warning color when allowed', () => {
const { container } = render(
<TestComponent
name="annualRequestedSalary"
label="Salary"
savedSalaryOverCap
allowSalaryOverCap={NewStaffGoalCalculationSalaryOverCapEnum.YesAny}
/>,
);

expect(
container.querySelector('.MuiOutlinedInput-notchedOutline'),
).toHaveStyle({ borderColor: theme.palette.warning.main });
});

it('outlines a contributing field in the error color when not allowed', () => {
const { container } = render(
<TestComponent
name="annualRequestedSalary"
label="Salary"
savedSalaryOverCap
/>,
);

expect(
container.querySelector('.MuiOutlinedInput-notchedOutline'),
).toHaveStyle({ borderColor: theme.palette.error.main });
});

it('leaves fields that do not feed the warning alone', () => {
const { container } = render(
<TestComponent name="tenure" label="Tenure" savedSalaryOverCap />,
);

expect(
container.querySelector('.MuiOutlinedInput-notchedOutline'),
).not.toHaveStyle({ borderColor: theme.palette.error.main });
});

it('does not outline anything while the cap is not exceeded', () => {
const { container } = render(
<TestComponent name="annualRequestedSalary" label="Salary" />,
);

expect(
container.querySelector('.MuiOutlinedInput-notchedOutline'),
).not.toHaveStyle({ borderColor: theme.palette.error.main });
});

it('lets a validation error take over the field', () => {
const { getByRole, getByText } = render(
<TestComponent
name="annualRequestedSalary"
label="Salary"
savedSalaryOverCap
initialErrors={{ annualRequestedSalary: 'Salary is required' }}
initialTouched={{ annualRequestedSalary: true }}
/>,
);

expect(getByRole('textbox', { name: 'Salary' })).toBeInvalid();
expect(getByText('Salary is required')).toBeInTheDocument();
});

it('outlines the exemption field the warning came from', () => {
const { container } = render(
<TestComponent name="secaExempt" label="SECA Exempt" secaExempt="true" />,
);

expect(
container.querySelector('.MuiOutlinedInput-notchedOutline'),
).toHaveStyle({ borderColor: theme.palette.warning.main });
});

it('leaves the spouse exemption field alone when only staff opted out', () => {
const { container } = render(
<TestComponent
name="spouseSecaExempt"
label="SECA Exempt"
secaExempt="true"
/>,
);

expect(
container.querySelector('.MuiOutlinedInput-notchedOutline'),
).not.toHaveStyle({ borderColor: theme.palette.warning.main });
});

it('does not mark a highlighted field invalid to assistive tech', () => {
const { getByRole } = render(
<TestComponent
name="annualRequestedSalary"
label="Salary"
savedSalaryOverCap
/>,
);

expect(getByRole('textbox', { name: 'Salary' })).toBeValid();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { TextFieldProps } from '@mui/material';
import { useField } from 'formik';
import { NewStaffGoalCalculationAttributesInput } from 'src/graphql/types.generated';
import { useGoalSettingsPreview } from '../GoalSettingsPreviewContext';
import { WarningSeverity } from '../goalSettingsWarnings';

export type GoalSettingsFieldBaseProps = Omit<TextFieldProps, 'name'> & {
/** Formik field key. Required here even though MUI's `name` is optional. */
Expand All @@ -17,6 +19,19 @@ export type GoalSettingsFieldBaseProps = Omit<TextFieldProps, 'name'> & {
showLabel?: boolean;
};

const outlineSx = (severity: WarningSeverity) => ({
'.MuiOutlinedInput-notchedOutline': {
borderColor: `${severity}.main`,
},
// Needs the ampersand: :hover applies to the root, not a descendant.
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: `${severity}.dark`,
},
'.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: `${severity}.main`,
},
});

/**
* Shared wiring for every Goal Settings field: Formik binding, consistent MUI
* chrome defaults, and accessible-name construction. Returns merged
Expand All @@ -38,6 +53,7 @@ export const useGoalSettingsField = ({
...props
}: GoalSettingsFieldBaseProps): TextFieldProps => {
const [field, meta] = useField(name);
const preview = useGoalSettingsPreview();

const accessibleName =
typeof label === 'string'
Expand All @@ -47,6 +63,8 @@ export const useGoalSettingsField = ({
: undefined;

const showError = Boolean(meta.touched && meta.error);
// A validation error is actionable, so it wins over the advisory outline.
const severity = showError ? undefined : preview?.fieldSeverity(name);

return {
id: name,
Expand All @@ -58,6 +76,10 @@ export const useGoalSettingsField = ({
...field,
error: showError,
helperText: showError ? meta.error : undefined,
sx: {
...(severity ? outlineSx(severity) : {}),
...props.sx,
},
inputProps: {
...(!showLabel && accessibleName ? { 'aria-label': accessibleName } : {}),
...inputProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,71 @@ describe('GoalSettingsForm', () => {
);
});

// Before the shared provider, each consumer ran its own preview request.
it('previews an edit exactly once with every consumer mounted', async () => {
const { findByRole } = render(<TestComponent />);

const salary = await findByRole('spinbutton', {
name: 'Annual Requested Salary — John',
});
userEvent.clear(salary);
userEvent.type(salary, '250000');

await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation(
'PreviewNewStaffGoalCalculation',
),
);

const previewCalls = mutationSpy.mock.calls.filter(
([{ operation }]) =>
operation.operationName === 'PreviewNewStaffGoalCalculation',
);
expect(previewCalls).toHaveLength(1);
});

it('shows the salary over-cap warning', async () => {
const { findByText, findByRole } = render(
<TestComponent
goalCalculationMock={{
newStaffGoalCalculation: {
...defaultMock.newStaffGoalCalculation,
calculations: {
...defaultGoalCalculation.calculations,
salaryOverCap: true,
},
},
}}
/>,
);

await findByRole('button', { name: 'Save & Share' });
expect(
await findByText('Total salary is over the standard cap'),
).toBeInTheDocument();
});

it('shows the debt over-cap warning', async () => {
const { findByText, findByRole } = render(
<TestComponent
goalCalculationMock={{
newStaffGoalCalculation: {
...defaultMock.newStaffGoalCalculation,
calculations: {
...defaultGoalCalculation.calculations,
debtOverCap: true,
},
},
}}
/>,
);

await findByRole('button', { name: 'Save & Share' });
expect(
await findByText('Annual debt is over the standard cap'),
).toBeInTheDocument();
});

it('clears spouse attributes when marital status changes from married to single', async () => {
const { findByRole, getByRole } = render(<TestComponent />);

Expand Down
Loading
Loading