Skip to content

Commit

Permalink
Adds inline validation to Milestones (#3953)
Browse files Browse the repository at this point in the history
  • Loading branch information
tbolt authored May 12, 2022
1 parent 4d76ad8 commit 8eb68c1
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 500 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,10 @@ describe('APD Basics', { tags: ['@apd', '@default'] }, () => {

cy.get(`.ds-c-field--day`).click().type(element.dateDay);

cy.get(`.ds-c-field--year`).click().type(element.dateYear);
cy.get(`.ds-c-field--year`)
.click()
.type(element.dateYear)
.blur();

cy.findByRole('button', { name: /Save/i }).click();

Expand Down
107 changes: 68 additions & 39 deletions web/src/pages/apd/activities/oms/Milestone/MilestoneForm.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,87 @@
import { TextField } from '@cmsgov/design-system';
import PropTypes from 'prop-types';
import React, { useReducer, forwardRef } from 'react';
import React, { useEffect, forwardRef } from 'react';
import { connect } from 'react-redux';

import { useForm, Controller } from 'react-hook-form';
import { joiResolver } from '@hookform/resolvers/joi';

import DateField from '../../../../../components/DateField';

import milestonesSchema from '../../../../../static/schemas/milestones';

import { saveMilestone as actualSaveMilestone } from '../../../../../redux/actions/editActivity';

const MilestoneForm = forwardRef(
({ activityIndex, index, item, saveMilestone }, ref) => {
({ activityIndex, index, item, saveMilestone, setFormValid }, ref) => {
MilestoneForm.displayName = 'MilestoneForm';

function reducer(state, action) {
switch (action.type) {
case 'updateField':
return {
...state,
[action.field]: action.value
};
default:
throw new Error(
'Unrecognized action type provided to OutcomesAndMetricForm reducer'
);
}
}

const [state, dispatch] = useReducer(reducer, item);

const changeDate = (_, dateStr) =>
dispatch({ type: 'updateField', field: 'endDate', value: dateStr });
const {
control,
formState: { errors, isValid },
getFieldState,
trigger,
getValues
} = useForm({
defaultValues: {
...item
},
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: joiResolver(milestonesSchema)
});

const changeName = ({ target: { value } }) =>
dispatch({ type: 'updateField', field: 'milestone', value });
useEffect(() => {
console.log("something changed")
setFormValid(isValid);
}, [isValid, errors]); // eslint-disable-line react-hooks/exhaustive-deps

const handleSubmit = e => {
const onSubmit = e => {
e.preventDefault();
saveMilestone(activityIndex, index, state);
saveMilestone(activityIndex, index, getValues());
};

return (
<form index={index} onSubmit={handleSubmit}>
<form index={index} onSubmit={onSubmit}>
<h6 className="ds-h4">Milestone {index + 1}:</h6>
<TextField
data-cy={`milestone-${index}`}
label="Name"
name="name"
value={state.milestone}
className="remove-clearfix textfield__container"
onChange={changeName}
<Controller
control={control}
name="milestone"
render={({ field: { value, ...props } }) => (
<TextField
{...props}
data-cy={`milestone-${index}`}
label="Name"
name="milestone"
value={value}
className="remove-clearfix textfield__container"
errorMessage={errors?.milestone?.message}
errorPlacement="bottom"
/>
)}
/>
<DateField
label="Target completion date"
hint=""
value={state.endDate}
onChange={changeDate}
<Controller
name="endDate"
control={control}
render={({
field: { onChange, onBlur, ...props },
formState: { isTouched }
}) => (
<DateField
{...props}
isTouched={isTouched}
label="Target completion date"
onChange={(e, dateStr) => onChange(dateStr)}
onComponentBlur={() => {
onBlur();
if (getFieldState('end').isTouched) {
trigger('end');
}
}}
errorMessage={errors?.endDate?.message}
errorPlacement="bottom"
/>
)}
/>
<input
className="ds-u-visibility--hidden"
Expand All @@ -73,7 +101,8 @@ MilestoneForm.propTypes = {
endDate: PropTypes.string.isRequired,
milestone: PropTypes.string.isRequired
}).isRequired,
saveMilestone: PropTypes.func.isRequired
saveMilestone: PropTypes.func.isRequired,
setFormValid: PropTypes.func.isRequired
};

const mapDispatchToProps = {
Expand All @@ -84,4 +113,4 @@ export default connect(null, mapDispatchToProps, null, { forwardRef: true })(
MilestoneForm
);

export { MilestoneForm as plain, mapDispatchToProps };
export { MilestoneForm as plain, mapDispatchToProps };
127 changes: 94 additions & 33 deletions web/src/pages/apd/activities/oms/Milestone/MilestoneForm.test.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,107 @@
import { mount } from 'enzyme';
import React from 'react';
import {
renderWithConnection,
act,
screen,
within,
waitFor
} from 'apd-testing-library';
import userEvent from '@testing-library/user-event';

import { plain as MilestoneForm, mapDispatchToProps } from './MilestoneForm';
import { plain as MilestoneForm } from './MilestoneForm';

import { saveMilestone as actualSaveMilestone } from '../../../../../redux/actions/editActivity';
const defaultProps = {
activityIndex: 42,
index: 1,
item: {
// On 1 September 1939, Germany invaded Poland after having staged
// several false flag border incidents as a pretext to initiate the
// invasion.
endDate: '1939-9-01',
milestone: 'Milestone name'
},
saveMilestone: jest.fn(),
setFormValid: jest.fn()
};

describe('the MilestoneForm component', () => {
const saveMilestone = jest.fn();
const setup = async (props = {}) => {
// eslint-disable-next-line testing-library/no-unnecessary-act
const renderUtils = await act(async () => {
renderWithConnection(<MilestoneForm {...defaultProps} {...props} />);
});
return renderUtils;
};

const component = mount(
<MilestoneForm
activityIndex={225}
index={3252}
item={{
// Operation Torch, the Allied invasion of North Africa, is launched
// to relieve pressure on Egypt and provide an invasion route into
// southern Europe,
endDate: '1942-8-16',
milestone: 'Milestone name'
}}
saveMilestone={saveMilestone}
/>
);
const verifyDateField = (text, expectValue) => {
const fieldset = within(screen.getByText(text).closest('fieldset')); // eslint-disable-line testing-library/no-node-access
expect(fieldset.getByLabelText('Month')).toHaveValue(expectValue.month);
expect(fieldset.getByLabelText('Day')).toHaveValue(expectValue.day);
expect(fieldset.getByLabelText('Year')).toHaveValue(expectValue.year);
};

describe('the ContractorResourceForm component', () => {
beforeEach(() => {
saveMilestone.mockClear();
jest.resetAllMocks();
});

test('renders correctly', () => {
expect(component).toMatchSnapshot();

test('renders correctly with default props', async () => {
await setup();
expect(screen.getByLabelText(/Name/i)).toHaveValue(defaultProps.item.milestone);
verifyDateField('Target completion date', {
month: '9',
day: '1',
year: '1939'
});
});

describe('events', () => {
test('handles saving the milestone', () => {
component.find('form').simulate('submit');
expect(saveMilestone).toHaveBeenCalled();

test('renders error when no name is provided', async () => {
await setup({});

const input = screen.getByLabelText(/Name/i);

userEvent.clear(input);
await waitFor(() => {
expect(input).toHaveFocus();
});
userEvent.tab();

expect(defaultProps.setFormValid).toHaveBeenLastCalledWith(false);

const error = await screen.findByText(
/Milestone is required./i
);
expect(error).toBeInTheDocument();
});

it('maps dispatch actions to props', () => {
expect(mapDispatchToProps).toEqual({
saveMilestone: actualSaveMilestone

test('renders error when no date is provided', async () => {
await setup({});

// start date - month, day, year
const endFieldset = within(
// eslint-disable-next-line testing-library/no-node-access
screen.getByText(/Target completion date/i).closest('fieldset')
);

// first tab to skip over the name
userEvent.tab();

userEvent.tab();
await waitFor(() => {
expect(endFieldset.getByLabelText('Month')).toHaveFocus();
});
userEvent.tab();
await waitFor(() => {
expect(endFieldset.getByLabelText('Day')).toHaveFocus();
});
userEvent.tab();
await waitFor(() => {
expect(endFieldset.getByLabelText('Year')).toHaveFocus();
});
userEvent.tab();

expect(defaultProps.setFormValid).toHaveBeenLastCalledWith(false);

const error = await screen.findByRole('alert', 'Provide a completion date.');
expect(error).toBeInTheDocument();
});
});
});
Loading

0 comments on commit 8eb68c1

Please sign in to comment.