Last active
April 5, 2020 15:56
-
-
Save sawyerh/8318b09ef1b73052cbf92c2ce594c13a to your computer and use it in GitHub Desktop.
Reusable components for complex multi-page forms
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import PropTypes from "prop-types"; | |
import React from "react"; | |
/** | |
* Conditionally displays the content passed into it, and clears any | |
* fields if they're hidden when the component unmounts | |
*/ | |
class ConditionalContent extends React.PureComponent { | |
componentWillUnmount() { | |
// Ensure all of the field values are cleared if they're not | |
// visible when this component unmounts | |
if (!this.props.visible && this.props.fieldNamesClearedWhenHidden) { | |
this.props.fieldNamesClearedWhenHidden.forEach(name => { | |
this.props.removeField(name); | |
}); | |
} | |
} | |
render() { | |
if (!this.props.visible) return null; | |
return this.props.children; | |
} | |
} | |
ConditionalContent.propTypes = { | |
// Fields and other markup to be conditionally displayed | |
children: PropTypes.node.isRequired, | |
// Field paths, to be used for clearing their state if they're hidden | |
fieldNamesClearedWhenHidden: PropTypes.arrayOf(PropTypes.string), | |
// Bound action creator (meaning, calling this method dispatches it) | |
removeField: PropTypes.func.isRequired, | |
// Should this component's children be visible? | |
visible: PropTypes.bool | |
}; | |
export default ConditionalContent; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import ConditionalContent from "./index"; | |
import React from "react"; | |
import { shallow } from "enzyme"; | |
describe("<ConditionalContent>", () => { | |
describe("given `visible` prop is set to true", () => { | |
it("renders the children", () => { | |
const wrapper = shallow( | |
<ConditionalContent removeField={jest.fn()} visible> | |
<h1>Hello</h1> | |
</ConditionalContent> | |
); | |
expect(wrapper.exists("h1")).toBe(true); | |
}); | |
it("does not clear the fields when component unmounts", () => { | |
const removeField = jest.fn(); | |
const wrapper = shallow( | |
<ConditionalContent | |
removeField={removeField} | |
fieldNamesClearedWhenHidden={["foo", "bar"]} | |
visible | |
> | |
<h1>Hello</h1> | |
</ConditionalContent> | |
); | |
wrapper.unmount(); | |
expect(removeField).toHaveBeenCalledTimes(0); | |
}); | |
}); | |
describe("given `visible` prop is set to false", () => { | |
it("does not render anything", () => { | |
const wrapper = shallow( | |
<ConditionalContent removeField={jest.fn()} visible={false}> | |
<h1>Hello</h1> | |
</ConditionalContent> | |
); | |
expect(wrapper.isEmptyRender()).toBe(true); | |
}); | |
it("clears all fields when component unmounts", () => { | |
const removeField = jest.fn(); | |
const wrapper = shallow( | |
<ConditionalContent | |
removeField={removeField} | |
fieldNamesClearedWhenHidden={["foo", "bar"]} | |
visible={false} | |
> | |
<h1>Hello</h1> | |
</ConditionalContent> | |
); | |
wrapper.unmount(); | |
expect(removeField).toHaveBeenCalledTimes(2); | |
expect(removeField.mock.calls[0][0]).toBe("foo"); | |
expect(removeField.mock.calls[1][0]).toBe("bar"); | |
}); | |
it("does not attempt clearing fields when component re-renders", () => { | |
const removeField = jest.fn(); | |
const wrapper = shallow( | |
<ConditionalContent | |
removeField={removeField} | |
fieldNamesClearedWhenHidden={["foo", "bar"]} | |
visible={false} | |
> | |
<h1>Hello</h1> | |
</ConditionalContent> | |
); | |
wrapper.update(); | |
expect(removeField).toHaveBeenCalledTimes(0); | |
}); | |
}); | |
describe("given fieldNamesClearedWhenHidden is not defined", () => { | |
it("does not attempting clearing fields when component unmounts", () => { | |
const removeField = jest.fn(); | |
const wrapper = shallow( | |
<ConditionalContent removeField={removeField} visible={false}> | |
<h1>Hello</h1> | |
</ConditionalContent> | |
); | |
wrapper.unmount(); | |
expect(removeField).toHaveBeenCalledTimes(0); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { addEntry, removeEntry } from "../../actions"; | |
import { getEntries, getErrors, getPeople } from "../../reducers"; | |
import PageForm from "../../components/PageForm"; | |
import PropTypes from "prop-types"; | |
import React from "react"; | |
import ReduxCollectionEntry from "../../../models/ReduxCollectionEntry"; | |
import RepeatableContent from "../../components/RepeatableContent"; | |
import SubmitButton from "../../components/SubmitButton"; | |
import collectionEnums from "../../../data/collectionEnums"; | |
import { connect } from "react-redux"; | |
import convertArrayIntoCollection from "../../../utils/convertArrayIntoCollection"; | |
/** | |
* Ask the applicant for more details about each collection entry matching the | |
* given `entryType`. Renders a UI to ask a repeating set of questions (`fieldsComponent`). | |
* For example, "Ask the applicant to enter info about each vehicle the own." | |
*/ | |
function EntriesPageForm(props) { | |
const FieldsComponent = props.fieldsComponent; | |
/** | |
* Convert our component's state back into a Redux-like state, | |
* which is also what our validation logic expects. We run this | |
* conversion only when the form needs to validate the data. | |
*/ | |
function createSchemaFriendlyData() { | |
return { | |
[props.collection]: convertArrayIntoCollection(props.entries), | |
// Most pages include a field to associate each entry to a person, | |
// in which case we want to ensure the person is also valid: | |
[collectionEnums.people]: convertArrayIntoCollection(props.people) | |
}; | |
} | |
/** | |
* Click event handler for the "Add" button | |
*/ | |
function handleAddClick() { | |
const entry = new ReduxCollectionEntry(undefined, { | |
type: props.entryType | |
}); | |
props.addEntry(props.collection, entry); | |
} | |
/** | |
* Click event handler for the "Remove" button | |
* @param {string} id - ID/key of the removed entry | |
*/ | |
function handleRemoveClick(id) { | |
props.removeEntry(props.collection, id); | |
} | |
return ( | |
<PageForm | |
formData={createSchemaFriendlyData} | |
nextRoute={props.nextRoute} | |
schema={props.schema} | |
> | |
{props.children} | |
<RepeatableContent | |
addButtonLabel={props.addButtonLabel} | |
entries={props.entries} | |
removeButtonLabel={props.removeButtonLabel} | |
render={(entry, index) => ( | |
<FieldsComponent errors={props.errors} entry={entry} index={index} /> | |
)} | |
onAddClick={handleAddClick} | |
onRemoveClick={handleRemoveClick} | |
/> | |
<SubmitButton /> | |
</PageForm> | |
); | |
} | |
EntriesPageForm.propTypes = { | |
addButtonLabel: RepeatableContent.propTypes.addButtonLabel, | |
addEntry: PropTypes.func.isRequired, // set through mapActionCreatorsToProps | |
// Pass the page title and body as the component's nested children | |
children: PropTypes.node, | |
// Name of the top-level collection we want to get entries from (i.e `resources`) | |
collection: PropTypes.string.isRequired, | |
// Type of entry to be rendered and added in this page form (i.e `vehicle`) | |
entryType: PropTypes.string.isRequired, | |
entries: PropTypes.array, // set through mapStateToProps | |
errors: PropTypes.array, // set through mapStateToProps | |
// Component to render for each entry | |
fieldsComponent: PropTypes.elementType.isRequired, | |
nextRoute: PropTypes.string.isRequired, | |
people: PropTypes.array.isRequired, // set through mapStateToProps | |
removeButtonLabel: RepeatableContent.propTypes.removeButtonLabel, | |
removeEntry: PropTypes.func.isRequired, // set through mapActionCreatorsToProps | |
// JSON Schema used for validating `formData` | |
schema: PropTypes.object | |
}; | |
const mapStateToProps = (state, ownProps) => ({ | |
errors: getErrors(state), | |
entries: getEntries(state, ownProps.collection, { | |
type: ownProps.entryType | |
}), | |
people: getPeople(state) | |
}); | |
const mapActionCreatorsToProps = { | |
addEntry, | |
removeEntry | |
}; | |
export default connect( | |
mapStateToProps, | |
mapActionCreatorsToProps | |
)(EntriesPageForm); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import EntriesPageForm from "./index"; | |
import { I18nextProvider } from "react-i18next"; | |
import React from "react"; | |
import Resource from "../../../models/Resource"; | |
import { createStore } from "redux"; | |
import i18n from "../../i18n"; | |
import reducer from "../../reducers"; | |
import { shallow } from "enzyme"; | |
import shallowUntilTarget from "../../test-helpers/shallowUntilTarget"; | |
// Test helper for rendering the component | |
function render(props, initialState = {}) { | |
props = Object.assign( | |
{ | |
addButtonLabel: "Add", | |
collection: "resources", | |
entryType: "property", | |
fieldsComponent: "div", | |
nextRoute: "/next-page", | |
removeButtonLabel: "Remove", | |
schema: {} | |
}, | |
props | |
); | |
const defaultPeople = { | |
__mock_filer__: { | |
isFiler: true | |
} | |
}; | |
const defaultResources = { | |
__mock_property__: { | |
type: Resource.types.property | |
}, | |
__mock_vehicle__: { | |
type: Resource.types.vehicle | |
} | |
}; | |
initialState = { | |
application: Object.assign( | |
{ people: defaultPeople, resources: defaultResources }, | |
initialState | |
) | |
}; | |
const store = createStore(reducer, initialState); | |
const providersWrapper = shallow( | |
<I18nextProvider i18n={i18n}> | |
<EntriesPageForm store={store} {...props} /> | |
</I18nextProvider> | |
); | |
const wrapper = shallowUntilTarget(providersWrapper, "EntriesPageForm"); | |
return { initialState, props, store, wrapper }; | |
} | |
describe("EntriesPageForm", () => { | |
it("renders RepeatableContent with only entries with the given type, from the given collection", () => { | |
const props = { | |
collection: "resources", | |
entryType: Resource.types.property | |
}; | |
const resources = { | |
__mock_property_1__: { | |
type: Resource.types.property | |
}, | |
__mock_property_2__: { | |
type: Resource.types.property | |
}, | |
__mock_vehicle__: { | |
type: Resource.types.vehicle | |
} | |
}; | |
const { wrapper } = render(props, { resources }); | |
const repeatableContent = wrapper.find("RepeatableContent"); | |
const entries = repeatableContent.prop("entries"); | |
expect(entries).toHaveLength(2); | |
expect(entries[0].type).toBe(props.entryType); | |
}); | |
it("renders fieldsComponent for each entry", () => { | |
function MockComponent() { | |
return <div />; | |
} | |
const props = { | |
fieldsComponent: MockComponent | |
}; | |
const resources = { | |
__mock_property_1__: { | |
type: Resource.types.property, | |
description: "One" | |
}, | |
__mock_property_2__: { | |
type: Resource.types.property, | |
description: "Two" | |
} | |
}; | |
const { wrapper } = render(props, { resources }); | |
const repeatableContent = wrapper.find("RepeatableContent").dive(); | |
expect(repeatableContent.find("MockComponent")).toHaveLength(2); | |
expect(repeatableContent.find("MockComponent")).toMatchSnapshot(); | |
}); | |
describe("when a new entry is added", () => { | |
it("creates a new entry in the given collection with the given type", () => { | |
const props = { | |
collection: "resources", | |
entryType: Resource.types.vehicle | |
}; | |
const { store, wrapper } = render(props, { resources: {} }); | |
const repeatableContent = wrapper.find("RepeatableContent"); | |
repeatableContent.simulate("addClick"); | |
const state = store.getState().application; | |
expect(Object.entries(state[props.collection])).toHaveLength(1); | |
expect(Object.values(state[props.collection])[0].type).toBe( | |
props.entryType | |
); | |
}); | |
}); | |
it("passes a function to PageForm for generating a JSON Schema-friendly representation of the entries", () => { | |
const resources = { | |
__mock_resource__: { | |
type: Resource.types.property | |
} | |
}; | |
const { initialState, wrapper } = render({}, { resources }); | |
// Execute the function | |
const results = wrapper.prop("formData")(); | |
expect(results).toMatchObject({ | |
people: initialState.application.people, | |
resources: initialState.application.resources | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useEffect } from "react"; | |
import PageForm from "../PageForm"; | |
import PropTypes from "prop-types"; | |
import SubmitButton from "../SubmitButton"; | |
import { connect } from "react-redux"; | |
import isEmpty from "lodash/isEmpty"; | |
import { updateFieldFromEvent } from "../../actions"; | |
/** | |
* This component is used for screens that ask whether a certain condition applies | |
* to the user's application, such as whether they have a spouse, resource, expense, | |
* etc. If the condition is met, this component handles the creation of the initial entry | |
* into the relevant collection. If they change their answer to not meet the condition, this | |
* handles the removal of the entries when the user proceeds to the next page. | |
* | |
* Nest all components (such as the page title and fields) as children of this component. | |
*/ | |
function IfThisThenPageForm(props) { | |
const { createInitialEntry, entries, condition } = props; | |
/** | |
* Create a the initial entry in a collection if one doesn't exist yet so that subsequent | |
* pages have an entry to display fields for. Removal of entries happens when the page | |
* advances so that we don't prematurely remove them, in case the user wants to change | |
* their answer(s) before advancing. | |
*/ | |
useEffect(() => { | |
if (condition && isEmpty(entries)) { | |
createInitialEntry(); | |
} | |
}, [createInitialEntry, entries, condition]); | |
/** | |
* If the user indicated not having the entry type in question, we need to | |
* check to see if any entries of this type previously existed, and remove | |
* those from the application before proceeding to the next page. | |
*/ | |
const handlePageAdvance = () => { | |
if (!condition && !isEmpty(entries)) { | |
props.removeEntries(); | |
} | |
}; | |
return ( | |
<PageForm | |
onPageAdvance={handlePageAdvance} | |
formData={props.formData} | |
nextRoute={props.nextRoute} | |
schema={props.schema} | |
> | |
{props.children} | |
<SubmitButton /> | |
</PageForm> | |
); | |
} | |
IfThisThenPageForm.propTypes = { | |
// Additional content to be rendered above the YesNoField, like a page title | |
children: PropTypes.node, | |
// Function to call when the user selects "Yes" for the first time | |
createInitialEntry: PropTypes.func.isRequired, | |
// The "This" in "IfThisThen". `true` triggers the creation of an initial entry, | |
// and `false` triggers the removal of existing entries when the page advances | |
condition: PropTypes.bool, | |
entries: PropTypes.any, | |
// Data to be validated/submitted | |
formData: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), | |
nextRoute: PropTypes.string.isRequired, | |
// Function to call when `condition` is not met previous entries need cleaned up | |
removeEntries: PropTypes.func.isRequired, | |
// JSON Schema used for validating `formData` | |
schema: PropTypes.object | |
}; | |
const mapActionCreatorsToProps = { | |
updateFieldFromEvent | |
}; | |
export default connect(null, mapActionCreatorsToProps)(IfThisThenPageForm); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { I18nextProvider } from "react-i18next"; | |
import IfThisThenPageForm from "./index"; | |
import { MemoryRouter } from "react-router-dom"; | |
import { Provider } from "react-redux"; | |
import React from "react"; | |
import { createStore } from "redux"; | |
import i18n from "../../i18n"; | |
import { mount } from "enzyme"; | |
import reducer from "../../reducers"; | |
function render(props = {}, children = null) { | |
const store = createStore(reducer, {}); | |
props = Object.assign( | |
{ | |
createInitialEntry: jest.fn(), | |
condition: false, | |
errors: [], | |
formData: {}, | |
nextRoute: "/next-page", | |
removeEntries: jest.fn(), | |
schema: {} | |
}, | |
props | |
); | |
const providersWrapper = mount( | |
<Provider store={store}> | |
<MemoryRouter> | |
<I18nextProvider i18n={i18n}> | |
<IfThisThenPageForm {...props}>{children}</IfThisThenPageForm> | |
</I18nextProvider> | |
</MemoryRouter> | |
</Provider> | |
); | |
const wrapper = providersWrapper.find("IfThisThenPageForm"); | |
return { props, store, wrapper }; | |
} | |
describe("IfThisThenPageForm", () => { | |
it("renders children", () => { | |
const { wrapper } = render({}, <p className="jest-el">Hello</p>); | |
expect(wrapper.find(".jest-el")).toHaveLength(1); | |
}); | |
describe("given no entries exist", () => { | |
it("calls createInitialEntry when `condition` is met", () => { | |
const { props } = render({ | |
condition: true, | |
entries: undefined | |
}); | |
expect(props.createInitialEntry).toHaveBeenCalledTimes(1); | |
expect(props.removeEntries).toHaveBeenCalledTimes(0); | |
}); | |
it("does NOT call createInitialEntry when `condition` is not met", () => { | |
const { props } = render({ | |
condition: false, | |
entries: undefined | |
}); | |
expect(props.createInitialEntry).toHaveBeenCalledTimes(0); | |
expect(props.removeEntries).toHaveBeenCalledTimes(0); | |
}); | |
it("does NOT call removeEntries when `condition` is not met and page advances", () => { | |
// ...because there weren't any entries to remove | |
const { props, wrapper } = render({ | |
condition: false | |
}); | |
wrapper.find("PageForm").prop("onPageAdvance")(); | |
expect(props.removeEntries).toHaveBeenCalledTimes(0); | |
}); | |
}); | |
describe("given entries already exist", () => { | |
it("does NOT call createInitialEntry when `condition` is met", () => { | |
// ...because entries already exist | |
const { props } = render({ | |
condition: true, | |
entries: [{ id: "foo" }] | |
}); | |
expect(props.createInitialEntry).toHaveBeenCalledTimes(0); | |
expect(props.removeEntries).toHaveBeenCalledTimes(0); | |
}); | |
it("does NOT call removeEntries when `condition` is met and the page advances", () => { | |
const { props, wrapper } = render({ | |
entries: [{ id: "foo" }], | |
condition: true | |
}); | |
wrapper.find("PageForm").prop("onPageAdvance")(); | |
expect(props.createInitialEntry).toHaveBeenCalledTimes(0); | |
expect(props.removeEntries).toHaveBeenCalledTimes(0); | |
}); | |
it("calls removeEntries when `condition` is not met and the page advances", () => { | |
const { props, wrapper } = render({ | |
entries: [{ id: "foo" }], | |
condition: false | |
}); | |
wrapper.find("PageForm").prop("onPageAdvance")(); | |
expect(props.createInitialEntry).toHaveBeenCalledTimes(0); | |
expect(props.removeEntries).toHaveBeenCalledTimes(1); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable react/no-unused-prop-types */ | |
import { updateErrors, updateUI } from "../../actions"; | |
import PropTypes from "prop-types"; | |
import React from "react"; | |
import Validator from "../../../services/Validator"; | |
import { connect } from "react-redux"; | |
import { getErrors } from "../../reducers"; | |
import submitForm from "../../utils/submitForm"; | |
import { useHistory } from "react-router-dom"; | |
const validator = new Validator(); | |
/** | |
* Submission logic. We export this as its own function so that we can | |
* more easily unit test it. (It's difficult to unit test async event | |
* handlers). | |
* @param {object} props | |
*/ | |
export async function submit({ | |
errors, | |
history, | |
isFinalSubmission, | |
formData, | |
nextRoute, | |
onPageAdvance, | |
schema, | |
updateErrors, | |
updateUI | |
}) { | |
let submissionErrors; | |
const data = typeof formData === "function" ? formData() : formData; | |
// Change submit button state to prevent duplicate submissions | |
updateUI("submitButtonState", "submitting"); | |
if (errors && errors.length) { | |
// Clear old errors, which may have since been resolved | |
updateErrors([]); | |
} | |
if (schema) { | |
const validationResults = validator.validate(schema, data); | |
if (!validationResults.valid) { | |
submissionErrors = validationResults.errors; | |
} | |
} | |
if (isFinalSubmission) { | |
// Submit the finalized application to the server | |
const response = await submitForm(data); | |
if (!response.success) { | |
submissionErrors = response.errors; | |
} | |
} | |
// Return submit button to default state | |
updateUI("submitButtonState", null); | |
if (submissionErrors && submissionErrors.length) { | |
return updateErrors(submissionErrors); | |
} | |
if (typeof onPageAdvance === "function") { | |
onPageAdvance(); | |
} | |
return history.push(nextRoute); | |
} | |
/** | |
* Our form container, where all fields should be nested within. | |
* This handles submission logic, such as validation and routing. | |
*/ | |
export function PageForm(props) { | |
const { children } = props; | |
const history = useHistory(); | |
const handleSubmit = async evt => { | |
// Skip submission to server | |
evt.preventDefault(); | |
await submit({ history, ...props }); | |
}; | |
return ( | |
<form name="page-form" onSubmit={handleSubmit}> | |
{children} | |
</form> | |
); | |
} | |
PageForm.propTypes = { | |
// `children` is just whatever gets nested inside of this component, | |
// you don't have to set it as a prop | |
children: PropTypes.node, | |
errors: PropTypes.array, // passed in automatically through `connect` | |
// Data to be validated/submitted. This can also be a function that transforms the data | |
// into a schema-friendly format | |
formData: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), | |
// Is this the final screen, where the full application should be submitted to the server? | |
isFinalSubmission: PropTypes.bool, | |
nextRoute: PropTypes.string.isRequired, | |
// Callback to be called after the form has been validated, but before | |
// the user is routed to nextRoute. Useful for cleaning up irrelevant state. | |
onPageAdvance: PropTypes.func, | |
// JSON Schema used for validating `formData` | |
schema: PropTypes.object, | |
updateErrors: PropTypes.func.isRequired, // passed in automatically through `connect` | |
updateUI: PropTypes.func.isRequired // passed in automatically through `connect` | |
}; | |
const mapStateToProps = state => ({ | |
errors: getErrors(state) | |
}); | |
const mapActionCreatorsToProps = { | |
updateErrors, | |
updateUI | |
}; | |
export default connect(mapStateToProps, mapActionCreatorsToProps)(PageForm); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable import/first */ | |
// Mock submitForm and history so we can test that it's called | |
jest.mock("../../utils/submitForm"); | |
const mockHistory = { push: jest.fn() }; | |
jest.mock("react-router-dom", () => ({ | |
useHistory: () => mockHistory | |
})); | |
import PageForm, { submit } from "./index"; | |
import ApplicationError from "../../../models/ApplicationError"; | |
import React from "react"; | |
import { createStore } from "redux"; | |
import reducer from "../../reducers"; | |
import { shallow } from "enzyme"; | |
import shallowUntilTarget from "../../test-helpers/shallowUntilTarget"; | |
// This is the mocked function since we're mocking it above: | |
import submitForm from "../../utils/submitForm"; | |
// Generate unique JSON schema IDs | |
import uuid from "uuid/v4"; | |
/** | |
* Helper method for rendering our component with all | |
* the providers needed for it to function | |
* @param {object} [props] | |
* @param {object} [initialState] | |
* @returns {object} | |
*/ | |
function render(props = {}, initialState = {}) { | |
const store = createStore(reducer, initialState); | |
const dispatch = jest.spyOn(store, "dispatch"); | |
props = Object.assign({ formData: {}, nextRoute: "/foo" }, props); | |
const connectedWrapper = shallow(<PageForm store={store} {...props} />); | |
const wrapper = shallowUntilTarget(connectedWrapper, "PageForm"); | |
return { dispatch, props, store, wrapper }; | |
} | |
describe("<PageForm>", () => { | |
beforeEach(() => { | |
mockHistory.push.mockClear(); | |
submitForm.mockClear(); | |
}); | |
it("renders nested children components", () => { | |
const { wrapper } = render({ | |
children: <p>Hello world</p> | |
}); | |
expect(wrapper.find("p")).toHaveLength(1); | |
}); | |
describe("when the form is submitted", () => { | |
it("disables and then re-enables the submit button", () => { | |
const { dispatch, store, wrapper } = render({ | |
nextRoute: "/next-route" | |
}); | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
// "Disable button" | |
expect(dispatch.mock.calls[0][0].type).toBe("UPDATE_UI"); | |
expect(dispatch.mock.calls[0][0].value).toBe("submitting"); | |
// Final button state is "enabled" | |
expect(store.getState().ui.submitButtonState).toBeNull(); | |
}); | |
describe("given schema validation requirements are met", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: uuid(), | |
type: "object", | |
required: ["foo"], | |
properties: { | |
foo: { | |
type: "string" | |
} | |
} | |
}; | |
const formData = { | |
foo: "I'm a required field" | |
}; | |
it("routes to nextRoute", () => { | |
const props = { | |
nextRoute: "/next-route", | |
schema, | |
formData | |
}; | |
const { wrapper } = render(props); | |
// Execute the test | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
expect(mockHistory.push).toHaveBeenCalledWith("/next-route"); | |
}); | |
it("calls onPageAdvance", () => { | |
const { props, wrapper } = render({ | |
onPageAdvance: jest.fn(), | |
schema, | |
formData | |
}); | |
// Execute the test | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
expect(props.onPageAdvance).toHaveBeenCalledTimes(1); | |
}); | |
}); | |
describe("given schema validation fails", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: uuid(), | |
type: "object", | |
required: ["foo"], | |
properties: { | |
foo: { | |
type: "string" | |
} | |
} | |
}; | |
// required field is missing on purpose from the data: | |
const formData = {}; | |
it("updates the errors state and does not change the route", () => { | |
const { store, wrapper } = render({ | |
nextRoute: "/next-route", | |
schema, | |
formData | |
}); | |
// Execute the test | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
expect(store.getState().errors).toHaveLength(1); | |
expect(mockHistory.push).not.toHaveBeenCalled(); | |
}); | |
it("does not call onPageAdvance", () => { | |
const { props, wrapper } = render({ | |
onPageAdvance: jest.fn(), | |
schema, | |
formData | |
}); | |
// Execute the test | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
expect(props.onPageAdvance).not.toHaveBeenCalled(); | |
}); | |
}); | |
describe("given errors already exist in our state", () => { | |
it("clears the errors", () => { | |
const initialState = { | |
errors: [ | |
new ApplicationError({ | |
messagePath: "foo.required" | |
}) | |
] | |
}; | |
const { store, wrapper } = render({}, initialState); | |
// Execute the test | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
expect(store.getState().errors).toHaveLength(0); | |
}); | |
}); | |
describe("given this is the final form submission", () => { | |
it("submits the form data", () => { | |
submitForm.mockResolvedValueOnce({ success: true }); | |
const formData = { | |
foo: "I'm a required field" | |
}; | |
const props = { | |
isFinalSubmission: true, | |
nextRoute: "/next-route", | |
formData | |
}; | |
const { wrapper } = render(props); | |
// Execute the test | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
expect(submitForm).toHaveBeenCalledTimes(1); | |
expect(submitForm).toHaveBeenCalledWith(formData); | |
}); | |
it("routes to nextRoute, if submission succeeds", async () => { | |
expect.assertions(2); | |
submitForm.mockResolvedValueOnce({ | |
success: true | |
}); | |
// Call submit() directly, rather than simulate a "submit" event, | |
// since we need to wait for its promise to resolve | |
await submit({ | |
errors: [], | |
history: mockHistory, | |
isFinalSubmission: true, | |
formData: {}, | |
nextRoute: "/next-route", | |
updateErrors: jest.fn(), | |
updateUI: jest.fn() | |
}); | |
expect(mockHistory.push).toHaveBeenCalledTimes(1); | |
expect(mockHistory.push).toHaveBeenCalledWith("/next-route"); | |
}); | |
it("updates errors state does not change the route, if submission fails", async () => { | |
expect.assertions(3); | |
submitForm.mockResolvedValueOnce({ | |
success: false, | |
errors: [new ApplicationError({ messagePath: "name.required" })] | |
}); | |
const updateErrors = jest.fn(); | |
// Call submit() directly, rather than simulate a "submit" event, | |
// since we need to wait for its promise to resolve | |
await submit({ | |
errors: [], | |
history: mockHistory, | |
isFinalSubmission: true, | |
formData: {}, | |
nextRoute: "/next-route", | |
updateErrors, | |
updateUI: jest.fn() | |
}); | |
expect(updateErrors).toHaveBeenCalledTimes(1); | |
expect(updateErrors.mock.calls[0][0]).toHaveLength(1); | |
expect(mockHistory.push).not.toHaveBeenCalled(); | |
}); | |
}); | |
describe("given formData is a function", () => { | |
it("calls the formData function to get the data to validate and submit", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: uuid(), | |
type: "object", | |
required: ["foo"], | |
properties: { | |
foo: { | |
type: "string" | |
} | |
} | |
}; | |
const formData = jest.fn(() => ({ | |
foo: "I'm a required field" | |
})); | |
const props = { | |
nextRoute: "/next-route", | |
formData, | |
schema | |
}; | |
const { wrapper } = render(props); | |
// Execute the test | |
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); | |
expect(formData).toHaveBeenCalledTimes(1); | |
// It should have routed to the next route if validation passes | |
expect(mockHistory.push).toHaveBeenCalledTimes(1); | |
}); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useEffect } from "react"; | |
import { Button } from "@cmsgov/design-system-core"; | |
import PlusIcon from "../PlusIcon"; | |
import PropTypes from "prop-types"; | |
import focusAndScrollTo from "../../utils/focusAndScrollTo"; | |
import usePrevious from "../../hooks/usePrevious"; | |
/** | |
* Used for rendering the same set of fields for each "entry" passed into it. | |
* @example <RepeatableContent entries={people} renderEntry={(id) => renderPersonCard(id)} ... /> | |
*/ | |
function RepeatableContent({ | |
addButtonLabel, | |
entries, | |
removeButtonLabel, | |
render, | |
nested, | |
onAddClick, | |
onRemoveClick | |
}) { | |
const previousEntriesLength = usePrevious(entries.length); | |
const lastEntryRef = React.createRef(); | |
const containerClassName = nested | |
? "c-repeatable-content--nested" | |
: "c-repeatable-content--top"; | |
useEffect(() => { | |
if (entries.length > previousEntriesLength) { | |
// A11y: We've added a new entry, so make sure it's | |
// focused and scrolled into view. | |
const focusableElement = lastEntryRef.current.querySelector( | |
"h2[tabIndex], label" | |
); | |
focusAndScrollTo(focusableElement); | |
} | |
}); | |
return ( | |
<section className={containerClassName}> | |
{entries.map((entry, index) => ( | |
<article | |
className="c-repeatable-content__card" | |
key={entry.id} | |
ref={index === entries.length - 1 ? lastEntryRef : null} | |
> | |
{render(entry, index)} | |
{(entries.length > 1 || index > 0) && ( | |
<div className="c-repeatable-content__remove-wrap"> | |
<button | |
className="c-repeatable-content__remove" | |
onClick={() => onRemoveClick(entry.id)} | |
> | |
{removeButtonLabel} | |
</button> | |
</div> | |
)} | |
</article> | |
))} | |
<div className="c-repeatable-content__add-wrap"> | |
<Button | |
className="c-repeatable-content__add" | |
onClick={onAddClick} | |
variation={nested ? "transparent" : null} | |
> | |
{nested && <PlusIcon className="c-repeatable-content__add-icon" />} | |
{addButtonLabel} | |
</Button> | |
</div> | |
</section> | |
); | |
} | |
RepeatableContent.propTypes = { | |
addButtonLabel: PropTypes.string.isRequired, | |
// Array of entries, each of which will have the content repeated for. | |
entries: PropTypes.arrayOf( | |
PropTypes.shape({ | |
id: PropTypes.string.isRequired | |
}) | |
).isRequired, | |
// Function used for rendering content for each entry | |
// https://reactjs.org/docs/render-props.html | |
render: PropTypes.func.isRequired, | |
removeButtonLabel: PropTypes.string.isRequired, | |
// Apply "nested" styling | |
nested: PropTypes.bool, | |
onAddClick: PropTypes.func.isRequired, | |
onRemoveClick: PropTypes.func.isRequired | |
}; | |
export default RepeatableContent; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { mount, shallow } from "enzyme"; | |
import React from "react"; | |
import RepeatableContent from "./index"; | |
describe("<RepeatableContent>", () => { | |
beforeEach(() => { | |
window.scrollTo = jest.fn(); | |
}); | |
function render(props = {}, shallowRender = true) { | |
props = Object.assign( | |
{ | |
addButtonLabel: "Add", | |
entries: [{ id: "foo" }], | |
render: jest.fn(), | |
removeButtonLabel: "Remove", | |
onAddClick: jest.fn(), | |
onRemoveClick: jest.fn() | |
}, | |
props | |
); | |
const component = <RepeatableContent {...props} />; | |
const wrapper = shallowRender ? shallow(component) : mount(component); | |
return { props, wrapper }; | |
} | |
describe("when initially mounted", () => { | |
it("calls render method for each entry", () => { | |
const entries = [{ id: "foo" }, { id: "bar" }]; | |
const { props } = render({ entries }); | |
expect(props.render).toHaveBeenCalledTimes(2); | |
// First argument is the entry | |
expect(props.render.mock.calls[0][0]).toBe(entries[0]); | |
expect(props.render.mock.calls[1][0]).toBe(entries[1]); | |
// Second argument is the array index | |
expect(props.render.mock.calls[0][1]).toBe(0); | |
expect(props.render.mock.calls[1][1]).toBe(1); | |
}); | |
it("renders a 'card' for each entry", () => { | |
const entries = [{ id: "foo" }, { id: "bar" }]; | |
const renderEntry = entry => <p>Card: {entry.id}</p>; | |
const { wrapper } = render({ entries, render: renderEntry }); | |
const cards = wrapper.find(".c-repeatable-content__card"); | |
expect(cards).toHaveLength(2); | |
expect(cards).toMatchSnapshot(); | |
}); | |
it("doesn't focus any element", () => { | |
const entries = [{ id: "foo" }]; | |
// Ensure a label with a tabIndex prop is included in each card: | |
// Note: normally a tabIndex isn't needed on labels, but it is for JS DOM (for now): | |
// https://github.com/jsdom/jsdom/issues/2586#issuecomment-561871527 | |
const renderEntry = () => ( | |
<div> | |
<label className="test-label" htmlFor="field" tabIndex="0"> | |
Hello | |
</label> | |
</div> | |
); | |
const props = { | |
entries, | |
render: renderEntry | |
}; | |
// Use `mount` so that the DOM selectors work as expected in our component | |
render(props, false); | |
expect(window.scrollTo).toHaveBeenCalledTimes(0); | |
}); | |
}); | |
describe("given only one entry exists", () => { | |
it("excludes a Remove button from the first entry card", () => { | |
const entries = [{ id: "foo" }]; | |
const { wrapper } = render({ entries }); | |
expect(wrapper.find(".c-repeatable-content__remove")).toHaveLength(0); | |
}); | |
}); | |
describe("given multiple entries exist", () => { | |
it("includes a Remove button on each entry card", () => { | |
const entries = [{ id: "foo" }, { id: "bar" }]; | |
const { wrapper } = render({ entries }); | |
expect(wrapper.find(".c-repeatable-content__remove")).toHaveLength(2); | |
}); | |
}); | |
describe("when an entry's remove button is clicked", () => { | |
it("calls onRemoveClick with the id of the entry", () => { | |
// Include multiple entries so the Remove button displays: | |
const entries = [{ id: "foo" }, { id: "bar" }]; | |
const { props, wrapper } = render({ entries }); | |
const removeButton = wrapper | |
.find(".c-repeatable-content__remove") | |
.first(); | |
removeButton.simulate("click"); | |
expect(props.onRemoveClick).toHaveBeenCalledTimes(1); | |
expect(props.onRemoveClick).toHaveBeenCalledWith(props.entries[0].id); | |
}); | |
}); | |
describe("when the add button is clicked", () => { | |
it("calls onAddClick", () => { | |
const { props, wrapper } = render(); | |
const addButton = wrapper.find("Button").last(); | |
addButton.simulate("click"); | |
expect(props.onAddClick).toHaveBeenCalledTimes(1); | |
}); | |
}); | |
describe("when a new entry is added", () => { | |
it("changes the focused element to the last card's h2", () => { | |
const entries = [{ id: "foo" }]; | |
// Ensure an h2 with a tabIndex prop is included in each card: | |
const renderEntry = () => ( | |
<div> | |
<h2 className="test-heading" tabIndex="-1"> | |
Card heading | |
</h2> | |
</div> | |
); | |
const props = { | |
entries, | |
render: renderEntry | |
}; | |
// Use `mount` so that the DOM selectors work as expected in our component | |
const { wrapper } = render(props, false); | |
// Pass in a longer list of entries | |
const newEntries = entries.concat([{ id: "bar" }]); | |
wrapper.setProps({ entries: newEntries }); | |
const heading = wrapper | |
.find(".test-heading") | |
.last() | |
.getDOMNode(); | |
expect(document.activeElement).toBe(heading); | |
expect(window.scrollTo).toHaveBeenCalledTimes(1); | |
}); | |
it("changes the focused element to the last card's label if no h2 exists", () => { | |
const entries = [{ id: "foo" }]; | |
// Ensure a label with a tabIndex prop is included in each card: | |
// Note: normally a tabIndex isn't needed on labels, but it is for JS DOM (for now): | |
// https://github.com/jsdom/jsdom/issues/2586#issuecomment-561871527 | |
const renderEntry = () => ( | |
<div> | |
<label className="test-label" htmlFor="field" tabIndex="0"> | |
Hello | |
</label> | |
</div> | |
); | |
const props = { | |
entries, | |
render: renderEntry | |
}; | |
// Use `mount` so that the DOM selectors work as expected in our component | |
const { wrapper } = render(props, false); | |
// Pass in a longer list of entries | |
const newEntries = entries.concat([{ id: "bar" }]); | |
wrapper.setProps({ entries: newEntries }); | |
wrapper.update(); | |
const label = wrapper | |
.find(".test-label") | |
.last() | |
.getDOMNode(); | |
expect(document.activeElement).toBe(label); | |
expect(window.scrollTo).toHaveBeenCalledTimes(1); | |
}); | |
}); | |
describe("given `nested` prop is undefined", () => { | |
it("applies `top` class to containing element", () => { | |
const { wrapper } = render(); | |
expect(wrapper.hasClass("c-repeatable-content--top")).toBe(true); | |
}); | |
it("renders transparent button variation", () => { | |
const { wrapper } = render({ nested: true }); | |
const button = wrapper.find(".c-repeatable-content__add"); | |
expect(button.prop("variation")).toBe("transparent"); | |
}); | |
}); | |
describe("given `nested` prop is true", () => { | |
it("applies `nested` class to containing element", () => { | |
const { wrapper } = render({ nested: true }); | |
expect(wrapper.hasClass("c-repeatable-content--nested")).toBe(true); | |
}); | |
it("renders transparent button variation", () => { | |
const { wrapper } = render({ nested: true }); | |
const button = wrapper.find(".c-repeatable-content__add"); | |
expect(button.prop("variation")).toBe("transparent"); | |
}); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment