Skip to content

Instantly share code, notes, and snippets.

@sawyerh
Last active April 5, 2020 15:56
Show Gist options
  • Save sawyerh/8318b09ef1b73052cbf92c2ce594c13a to your computer and use it in GitHub Desktop.
Save sawyerh/8318b09ef1b73052cbf92c2ce594c13a to your computer and use it in GitHub Desktop.
Reusable components for complex multi-page forms
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;
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);
});
});
});
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);
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
});
});
});
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);
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);
});
});
});
/* 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);
/* 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);
});
});
});
});
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;
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