diff --git a/integrationTests/cypress/integration/02-apd/01-apd-basics.spec.js b/integrationTests/cypress/integration/02-apd/01-apd-basics.spec.js index 6fafc80167..370f6c25c7 100644 --- a/integrationTests/cypress/integration/02-apd/01-apd-basics.spec.js +++ b/integrationTests/cypress/integration/02-apd/01-apd-basics.spec.js @@ -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(); diff --git a/web/src/pages/apd/activities/oms/Milestone/MilestoneForm.js b/web/src/pages/apd/activities/oms/Milestone/MilestoneForm.js index 516b6d116b..416b0aeb4d 100644 --- a/web/src/pages/apd/activities/oms/Milestone/MilestoneForm.js +++ b/web/src/pages/apd/activities/oms/Milestone/MilestoneForm.js @@ -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 ( -
+
Milestone {index + 1}:
- ( + + )} /> - ( + onChange(dateStr)} + onComponentBlur={() => { + onBlur(); + if (getFieldState('end').isTouched) { + trigger('end'); + } + }} + errorMessage={errors?.endDate?.message} + errorPlacement="bottom" + /> + )} /> { - const saveMilestone = jest.fn(); +const setup = async (props = {}) => { + // eslint-disable-next-line testing-library/no-unnecessary-act + const renderUtils = await act(async () => { + renderWithConnection(); + }); + return renderUtils; +}; - const component = mount( - - ); +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(); }); -}); +}); \ No newline at end of file diff --git a/web/src/pages/apd/activities/oms/Milestone/__snapshots__/MilestoneForm.test.js.snap b/web/src/pages/apd/activities/oms/Milestone/__snapshots__/MilestoneForm.test.js.snap deleted file mode 100644 index ae6eff68b3..0000000000 --- a/web/src/pages/apd/activities/oms/Milestone/__snapshots__/MilestoneForm.test.js.snap +++ /dev/null @@ -1,427 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`the MilestoneForm component renders correctly 1`] = ` - - -
- Milestone - 3253 - : -
- - -
- - - - - - -
-
-
- - - -
- - - - Target completion date - - - - -
- - -
- - - - - - -
-
-
- - / - - - -
- - - - - - -
-
-
- - / - - - -
- - - - - - -
-
-
-
-
-
-
-
-
- - -
-`; diff --git a/web/src/pages/apd/activities/oms/__snapshots__/Milestones.test.js.snap b/web/src/pages/apd/activities/oms/__snapshots__/Milestones.test.js.snap index 71d457734c..b304d1466d 100644 --- a/web/src/pages/apd/activities/oms/__snapshots__/Milestones.test.js.snap +++ b/web/src/pages/apd/activities/oms/__snapshots__/Milestones.test.js.snap @@ -28,6 +28,7 @@ exports[`the Milestones component renders correctly 1`] = ` "index": [Function], "item": [Function], "saveMilestone": [Function], + "setFormValid": [Function], }, "render": [Function], }, diff --git a/web/src/static/schemas/milestones.js b/web/src/static/schemas/milestones.js new file mode 100644 index 0000000000..abae8f0aca --- /dev/null +++ b/web/src/static/schemas/milestones.js @@ -0,0 +1,21 @@ +const Joi = require('joi').extend(require('@joi/date')); + +const milestonesSchema = Joi.object({ + key: Joi.any(), + milestone: Joi.string().required().messages({ + 'string.base': 'Milestone is required.', + 'string.empty': 'Milestone is required.' + }), + endDate: Joi.date() + .format('YYYY-MM-DD') + .iso() + .required() + .messages({ + 'date.required': 'Provide a completion date.', + 'date.base': 'Provide a completion date.', + 'date.empty': 'Provide a completion date.', + 'date.format': 'Provide a completion date.' + }) +}); + +export default milestonesSchema; \ No newline at end of file