Table of contents
  1. React Hook Forms Components
  2. Standard Form Setup
  3. Higher Order Component Helper
  4. Form Context / useFormContext
  5. Fields Array/ useFieldArray
  6. fire submit from parent
  7. using a controller component with select and autocomplete
  8. Testing
    1. Test and Render React Hook Form
    2. helper function
      1. renderWithReactHookForm / render with a React hook form
      2. Render with React Hook Form Extended
        1. example renderWithReactHookFormExtended.test.js / test that a React Hook Form element displays error messages, without submitting the form
        2. used in app
    3. Test Metadata




React Hook Forms Components


Standard Form Setup

import React from "react";
import {useForm} from "react-hook-form";

export default function App() {
    const {register, handleSubmit, watch, errors} = useForm();
    const onSubmit = data => console.log(data);
    return (<form onSubmit={handleSubmit(onSubmit)}>
        <input name="firstName" defaultValue="Zoe" ref={register}/>
        <input type="submit"/>
    </form>);

}

Higher Order Component Helper

/**
 * Higher order helper function which wraps a component w/ React Hook Form
 * @param {React Component} WrappedComponent to pass into
 * @param {*} restProps any other remaining props
 * @returns {React Component}
 */
export function withReactHookForm(WrappedComponent, restProps) {
    const HOC = () => {
        const methods = useForm();

        return (<FormProvider {...methods}>
            <WrappedComponent {...restProps} />
        </FormProvider>);
    };

    return HOC;
}

Form Context / useFormContext

// Updated Form
import React from "react";
import {FormProvider, useForm} from "react-hook-form";

export default function App() {
    const methods = useForm();
    const onSubmit = data => console.log(data);
    return (<FormProvider {...methods}>
        <form onSubmit={handleSubmit(onSubmit)}>
            <CoreMetadata {...PASS_WHATEVER_PROPS_HERE} />
            <ControlledTerms/>
            <DescriptiveMetadata/>
        </form>
    </FormProvider>);

}

This approach flows with React Context/Provider patterns, and any child component in the ancestry tree can grab React Hook Form Context if it needs
it.
Mid-level components which don’t care about
register or error are set free and liberated from baggage props.

import React from "react";
import {useFormContext} from "react-hook-form";

const UIFormInput = ({name, required, ...passedInProps}) => {
// All these values are from the component's parent <form />
    const {control, errors, register} = useFormContext();
    return (<>
    <input
        name={name}
        ref={register({required})}
        className={`input ${errors[name]
                            ? "is-danger"
                            : ""}`
        {...passedInProps}
            />
        {errors[name] && (
            <p data-testid="input-errors" className="help is-danger">
        {label || name} field is required
    </p>)
}
</>
)
    ;
}

Fields Array/ useFieldArray

import React from "react";
import PropTypes from "prop-types";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {useFieldArray, useFormContext} from "react-hook-form";
import UIFormSelect from "./Select";
import {isUrlValid} from "../../../services/helpers";
import {Button} from "@nulib/admin-react-components";

const UIFormRelatedURL = ({
    codeLists = [], label, name, required, ...passedInProps
}) => {
    const {control, errors, register} = useFormContext();
    const {fields, append, remove} = useFieldArray({
        control, name, // Metadata item form name
        keyName: "useFieldArrayId",
    });

    return (<div data-testid="related-url-wrapper">
        <ul className="mb-3">
            {fields.map((item, index) => {
                // Metadata item name combined with it's index in the array of multiple entries
                const itemName = `${name}[${index}]`;

                return (<li
                    key={item.useFieldArrayId}
                    data-testid={`related-url-list-item`}
                >
                    <fieldset>
                        <legend
                            className="has-text-grey has-text-weight-light"
                            data-testid="legend"
                        >{`${label} #${index + 1}`}</legend>

                        {/* Existing values are NOT editable, so we save form data needed in the POST update, in hidden fields here */}
                        {!item.new && (<div data-testid="related-url-existing-value">
                            <p>
                                {item.url}
                                {item.label && `, ${item.label.label}`}
                            </p>
                            <input
                                type="hidden"
                                name={`${itemName}.url`}
                                ref={register()}
                            />
                            <input
                                type="hidden"
                                name={`${itemName}.label`}
                                ref={register()}
                            />
                        </div>)}

                        {/* New form entries */}
                        {item.new && (<div data-testid="related-url-form-item">
                            <div className="field">
                                <label className="label">URL</label>
                                <input
                                    type="text"
                                    name={`${itemName}.url`}
                                    className={`input ${errors[name] && errors[name][index].url
                                                        ? "is-danger"
                                                        : ""}`}
                                    ref={register({
                                        required: "Related URL is required", validate: (value) => isUrlValid(value) || "Please enter a valid URL",
                                    })}
                                    defaultValue=""
                                    data-testid={`related-url-url-input`}
                                />
                                {errors[name] && errors[name][index].url && (<p
                                    data-testid={`relatedURL-input-errors-${index}`}
                                    className="help is-danger"
                                >
                                    {errors[name][index].url.message}
                                </p>)}
                            </div>
                            <div className="field">
                                <UIFormSelect
                                    isReactHookForm
                                    name={`${itemName}.label`}
                                    label="Label"
                                    showHelper={true}
                                    data-testid={`related-url-select`}
                                    options={codeLists}
                                    hasErrors={!!(errors[name] && errors[name][index].label)}
                                    required
                                />
                            </div>
                        </div>)}

                        <Button
                            type="button"
                            className="button is-light is-small mt-3"
                            onClick={() => remove(index)}
                            data-testid={`button-related-url-remove`}
                        >
                  <span className="icon">
                    <FontAwesomeIcon icon="trash"/>
                  </span>
                            <span>Remove</span>
                        </Button>
                    </fieldset>
                </li>);
            })}
        </ul>

        <Button
            type="button"
            className="button is-text is-small"
            onClick={() => {
                append({new: true, url: "", label: ""});
            }}
            data-testid="button-add-field-array-row"
        >
        <span className="icon">
          <FontAwesomeIcon icon="plus"/>
        </span>
            <span>Add {fields.length > 0 && "another"}</span>
        </Button>
    </div>);
};

UIFormRelatedURL.propTypes = {
    codeLists: PropTypes.array, label: PropTypes.string.isRequired, name: PropTypes.string.isRequired, roleDropdownOptions: PropTypes.array,
};

export default UIFormRelatedURL;

fire submit from parent

const submitMyForm = (data) => {
    formRef.current.dispatchEvent(new Event("submit", {
        cancelable: true, bubbles: true,
    }),);
};

using a controller component with select and autocomplete

<Controller
    name={"users"}
    control={control}
    rules={{
        required: "One user is required",
        validate: (value) => value.length < 11
                             ? true
                             : "Max of 10 results may be shared at a time. ",
    }}
    render={({onChange, value, ref, ...props}) => (<Autocomplete
        multiple
        id="tags-standard"
        options={userIds}
        onChange={(e, data) => onChange(data)}
        getOptionLabel={(option) => `${userOptions[option].firstName} ${userOptions[option].lastName}`}
        filterSelectedOptions
        renderTags={(value, getTagProps) => value.map((option, index) => (<Chip
            color="primary"
            label={`${userOptions[option].firstName} ${userOptions[option].lastName}`}
            {...getTagProps({index})}
        />))}
        renderInput={(params) => (<TextField
            {...params}
            variant="outlined"
            label="Search Users"
            placeholder="Enter first name and/or last name"
            InputLabelProps={{
                shrink: true, "data-qa": "score-sheet-result-statement-label", style: {
                    fontSize: "20px", color: "black", display: "block", fontFamily: "Open Sans, sans-serif", fontWeight: 700,
                },
            }}
            error={!!errors?.users}
            helperText={errors?.users?.message}
        />)}
        {...props}
    />)}
/>
<Controller
    name={"testSelect"}
    control={control}
    defaultValue={currency}
    render={({onChange, ...props}) => (<TextField
        id="standard-select-currency"
        select
        label="Select"
        onChange={(e, data) => onChange(data.props.value)}
        helperText="Please select your currency"
        {...props}
    >
        {currencies.map((option) => (<MenuItem key={option.value} value={option.value}>
            {option.label}
        </MenuItem>))}
    </TextField>)}
/>

Testing

Test and Render React Hook Form

import React from "react";
import {screen} from "@testing-library/react";
import {renderWithReactHookForm} from "./services/testing-helpers";
import UIFormRelatedURL from "./RelatedURL";

const props = {
    name: "relatedUrl", label: "Related URL",
};

describe("standard component behavior", () => {
    beforeEach(() => {
        renderWithReactHookForm(<UIFormRelatedURL {...props} />, {
            // Add some default values to our form state, using Reach Hook Form's "defaultValues" param
            defaultValues: {
                relatedUrl: [{
                    url: "http://www.northwestern.edu", label: {
                        id: "HATHI_TRUST_DIGITAL_LIBRARY", label: "Hathi Trust Digital Library", scheme: "RELATED_URL",
                    },
                },],
            },
        });
    });

    it("renders component and an add button", () => {
        expect(screen.getByTestId("related-url-wrapper"));
        expect(screen.getByTestId("button-add-field-array-row"));
    });

    it("renders existing related url values", () => {
        expect(screen.getAllByTestId("related-url-existing-value")).toHaveLength(1);
        expect(screen.getByText("http://www.northwestern.edu, Hathi Trust Digital Library"));
    });

    it("renders form elements when adding a new related url value", () => {
        const addButton = screen.getByTestId("button-add-field-array-row");
        fireEvent.click(addButton);
        fireEvent.click(addButton);
        expect(screen.getAllByTestId("related-url-form-item")).toHaveLength(2);
    ...
    });
});

helper function

  • we wrap the component we’re testing with React Hook Form’s and can initialize the form with some default values.

renderWithReactHookForm / render with a React hook form

/**
 * Testing Library utility function to wrap tested component in React Hook Form
 * @param {ReactElement} ui A React component
 * @param objectParameters
 * @param {Object} objectParameters.defaultValues Initial form values to pass into
 * React Hook Form, which you can then assert against
 */
export function renderWithReactHookForm(ui, {defaultValues = {}} = {}) {
    let reactHookFormMethods = {};

    const Wrapper = ({children}) => {
        const methods = useForm({defaultValues});
        return <FormProvider {...methods}>{children}</FormProvider>;
    };

    return {
        ...render(ui, {wrapper: Wrapper})
    };
}

Render with React Hook Form Extended


import React from "react";
import {render} from "@testing-library/react";
import {FormProvider, useForm} from "react-hook-form";

/**
 * Testing Library utility function to wrap tested component in React Hook Form
 * @param {ReactElement} ui A React component
 * @param objectParameters
 * @param {Object} objectParameters.defaultValues Initial form values to pass into
 * React Hook Form, which you can then assert against
 * @param {Array} objectParameters.toPassBack React Hook Form method names which we'd
 * like to pass back and use in tests.  A primary use case is sending back 'setError',
 * so we can manually setErrors on React Hook Form components and test error handling
 */
export function renderWithReactHookForm(ui, {defaultValues = {}, toPassBack = []} = {}) {
    let reactHookFormMethods = {};

    const Wrapper = ({children}) => {
        const methods = useForm({defaultValues});
        for (let reactHookFormItem of toPassBack) {
            reactHookFormMethods[reactHookFormItem] = methods[reactHookFormItem];
        }
        return <FormProvider {...methods}>{children}</FormProvider>;
    };

    return {
        ...render(ui, {wrapper: Wrapper}), reactHookFormMethods,
    };
}
  • example renderWithReactHookFormExtended.test.js / test that a React Hook Form element displays error messages, without submitting the form

      import React                     from "react";
      import {waitFor}                 from "@testing-library/react";
      import UIFormRelatedURL          from "./RelatedURL";
      import {relatedUrlSchemeMock}    from "../../Work/controlledVocabulary.gql.mock";
      import {renderWithReactHookForm} from "../../../services/testing-helpers";
      import userEvent                 from "@testing-library/user-event";
        
      const props = {
          codeLists: relatedUrlSchemeMock, name: "relatedUrl", label: "Related URL",
      };
        
      describe("Test component,  error handling", () => {
          // Here's an example of how to test that a React Hook Form element
          // displays error messages, without submitting the form.
          it("renders appropriate error messages with invalid url or select values", async () => {
              const {
                        findByText, getByTestId, reactHookFormMethods,
                    } = renderWithReactHookForm(<UIFormRelatedURL {...props} />, {
                  toPassBack: ["setError"],
              });
        
              userEvent.click(getByTestId("button-add-field-array-row"));
        
              await waitFor(() => {
                  // Here we manually manipulate the form, setting an error the same way React Hook Form does
                  reactHookFormMethods.setError("relatedUrl[0].url", {
                      type: "validate", message: "Please enter a valid url",
                  });
              });
              expect(await findByText("Please enter a valid url"));
          });
      });
        
    
    • And what the input being tested may look like.

        <input
            type="text"
            name={`${itemName}.url`}
            className={`input ${errors[name] && errors[name][index].url
                                ? "is-danger"
                                : ""}`}
            ref={register({
                required: "Related URL is required", validate: (value) => isUrlValid(value) || "Please enter a valid URL",
            })}
            defaultValue=""
            data-testid={`related-url-url-input`}
        />
      
  • used in app

      describe("BasicSearch", () => {
          beforeEach(() => {
              renderWithReactHookForm(<BasicSearch/>, {
                  defaultValues: {
                      firstName: "Vic", lastName: "Vinegar",
                  },
              });
          });
        
          it("BasicSearch should render withouth crashing", async () => {
              expect(screen.getByTestId("group-first-name-search-input"));
          });
        
          it("first and last name inputs should have value", async () => {
              expect(screen.getByTestId("group-first-name-search-input").value).toEqual("Vic",);
              expect(screen.getByTestId("group-last-name-search-input").value).toEqual("Vinegar",);
          });
      });
    

Test Metadata

import React from "react";
import {screen} from "@testing-library/react";
import {
    renderWithRouterApollo, withReactHookForm,
} from "../../../services/testing-helpers";
import ControlledMetadata from "./ControlledMetadata";

describe("Some component", () => {
    beforeEach(() => {
        // Wrap with React Hook Form's Provider
        const Wrapped = withReactHookForm(ControlledMetadata);

        // Wrap with any other Providers you may be using, like ApolloProvider, React Router, etc.
        return renderWithRouterApollo(<Wrapped/>, {
            mocks: [],
        });
    });

    // Your tested component will be wrapped with React Hook Form's provider (and others) 
    it("renders controlled metadata component", async () => {
        expect(await screen.findByTestId("controlled-metadata"));
    });

...
});