diff --git a/example/src/App.js b/example/src/App.js index 4482b92b..a85059a1 100644 --- a/example/src/App.js +++ b/example/src/App.js @@ -27,6 +27,7 @@ import { InputTime, Typography, Modal, + Form, // IMPORT_INJECTOR } from '@cision/rover-ui'; @@ -424,6 +425,10 @@ const App = () => { + + + + setIsModalOpen(true)}> diff --git a/rover-ui-wendigolabs.iml b/rover-ui-wendigolabs.iml new file mode 100644 index 00000000..8021953e --- /dev/null +++ b/rover-ui-wendigolabs.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx new file mode 100644 index 00000000..12756a90 --- /dev/null +++ b/src/components/Form/Form.test.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; + +import Form from './index'; +import { FormProps, FormContext } from './Form'; + +const defaultProps = { + className: 'some_classname', + initialValues: { + textInput: '', + radioInput: false, + checkboxInput: false, + }, + validationSchema: {}, + onSubmit: jest.fn(), +}; + +interface DefaultPropsType extends FormProps { + onCustom?: { + fieldName: string; + callback: () => string; + }; +} + +const renderForm = (props: DefaultPropsType = defaultProps) => + render( + + + {(context) => { + const { + formState, + values, + handleChange, + handleBlur, + handleCustom, + } = context as FormContext; + return ( + <> + + {JSON.stringify({ values, formState })} + + {formState?.submitError && ( + {formState.submitError.message} + )} + + + + {props.onCustom && ( + + Evolve it! + + )} + + Submit + + > + ); + }} + + + ); + +describe('', () => { + it.skip('handles inputs', () => { + const { getByTestId } = renderForm(); + const someText = { target: { value: 'some text' } }; + const textInput = getByTestId('text-input') as HTMLInputElement; + const radioInput = getByTestId('radio-input') as HTMLInputElement; + const checkboxInput = getByTestId('checkbox-input') as HTMLInputElement; + + fireEvent.change(textInput, someText); + fireEvent.click(radioInput); + fireEvent.click(checkboxInput); + + const { values } = JSON.parse(getByTestId('form-data').innerHTML); + + expect(textInput.value).toEqual(someText.target.value); + expect(values.textInput).toEqual(someText.target.value); + + expect(radioInput.checked).toEqual(true); + expect(values.radioInput).toEqual('on'); // this is odd + + expect(checkboxInput.checked).toEqual(true); + expect(values.checkboxInput).toEqual(true); + }); + + it.skip('handles blur events', () => { + const { getByTestId } = renderForm(); + const textInput = getByTestId('text-input'); + + fireEvent.blur(textInput); + + const { formState } = JSON.parse(getByTestId('form-data').innerHTML); + + expect(formState?.touched.textInput).toEqual(true); + }); + + it.skip('handles custom events', () => { + const { getByTestId } = renderForm({ + onCustom: { + fieldName: 'textInput', + callback: () => 'raichu', + }, + }); + + const someText = { target: { value: 'pikachu' } }; + const textInput = getByTestId('text-input') as HTMLInputElement; + fireEvent.change(textInput, someText); + + const customBtn = getByTestId('custom-button'); + fireEvent.click(customBtn); + + const { values } = JSON.parse(getByTestId('form-data').innerHTML); + + expect(textInput.value).toEqual('raichu'); + expect(values.textInput).toEqual('raichu'); + }); + + it.skip('validates inputs', async () => { + const { getByTestId } = renderForm({ + validationSchema: { + textInput: { + nonBidoof: { + message: 'anything but bidoof', + validator: (value) => value !== 'bidoof', + }, + }, + }, + }); + const textInput = getByTestId('text-input'); + + fireEvent.change(textInput, { target: { value: 'bidoof' } }); + + const { formState } = JSON.parse(getByTestId('form-data').innerHTML); + + expect(formState?.validationErrors.textInput).toEqual( + 'anything but bidoof' + ); + }); + + it.skip('handles successful submit with all form values', async () => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const { getByTestId } = renderForm({ onSubmit }); + const submitBtn = getByTestId('submit-button'); + const formData = getByTestId('form-data'); + + // assert isSubmitting false + const { formState: initialFormState } = JSON.parse(formData.innerHTML); + expect(initialFormState.isSubmitting).toEqual(false); + + await act(async () => { + fireEvent.click(submitBtn); + }); + + // assert isSubmitting false when done + const { formState: finalFormState } = JSON.parse(formData.innerHTML); + expect(finalFormState.isSubmitting).toEqual(false); + + // using hoisting to get access to getByTestId and onSubmit + async function onSubmit(values) { + // assert isSubmitting true while running + const { formState: intermediateFormState } = JSON.parse( + formData.innerHTML + ); + expect(values).toEqual(defaultProps.initialValues); + await Promise.resolve(() => + expect(intermediateFormState.isSubmitting).toEqual(true) + ); + } + }); + + it.skip('handles errors in onSubmit prop', async () => { + const { getByTestId, queryByText } = renderForm({ + onSubmit: () => { + throw new Error('Error in user defined onSubmit prop'); + }, + }); + const submitBtn = getByTestId('submit-button'); + const formData = getByTestId('form-data'); + + const { formState: initialFormState } = JSON.parse(formData.innerHTML); + expect(initialFormState.submitError).toEqual(null); + + await act(async () => { + fireEvent.click(submitBtn); + }); + + expect(queryByText('Error in user defined onSubmit prop')).toBeTruthy(); + }); +}); diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx new file mode 100644 index 00000000..528d8d6d --- /dev/null +++ b/src/components/Form/Form.tsx @@ -0,0 +1,271 @@ +import React, { useState, useEffect, createContext } from 'react'; +import _isEmpty from 'lodash/isEmpty'; +import _set from 'lodash/set'; + +type ValidationSchema = { + [key: string]: Record; +}; + +interface SchemaElement { + message: ((element?: HTMLInputElement) => string) | string; + validator?: (value: string, element?: HTMLInputElement) => boolean | string; +} + +export interface FormProps { + id?: string; + children?: React.ComponentClass | React.FunctionComponent | React.ReactNode; + initialValues?: Record; + validationSchema?: ValidationSchema; + onSubmit?: Function; + className?: string; +} + +export interface FormContext { + formState: FormState; + values: Record; + handleChange: ({ target: t }: { target: HTMLInputElement }) => void; + handleBlur: ({ target: t }: { target: HTMLInputElement }) => void; + handleCustom: (name: string, callback: (value: string) => void) => void; +} + +export interface FormState { + touched: Record; + validationErrors: Record; + isSubmitting: boolean; + submitError; +} + +const NATIVE_VALIDATORS = [ + 'badInput', + 'customError', + 'patternMismatch', + 'rangeOverflow', + 'rangeUnderflow', + 'stepMismatch', + 'tooLong', + 'tooShort', + 'typeMismatch', + 'valueMissing', +]; + +const getValidityKeys = (validity) => { + const validityKeys: string[] = []; + Object.keys(validity).forEach((validityKey) => { + validityKeys.push(validityKey); + }); + return validityKeys; +}; + +export const FormContext = createContext({}); + +const Form: React.FC = ({ + id = 'a_form', + children, + initialValues = {}, + validationSchema = {}, + onSubmit = () => {}, + className = '', +}) => { + const [values, setValues] = useState(initialValues); + const [touched, setTouched] = useState({}); + const [validationErrors, setValidationErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const [validatingElement, setValidatingElement] = useState< + HTMLInputElement + >(); + + const [customValidations, setCustomValidations] = useState( + {} as SchemaElement + ); + + const isNativeError = (element) => { + return ( + NATIVE_VALIDATORS.filter((v) => v !== 'customError').find( + (v) => element.validity[v] + ) !== undefined + ); + }; + + useEffect(() => { + // Save off the custom validators + // + Object.keys(validationSchema).forEach((elementName) => { + Object.keys(validationSchema[elementName]).forEach((validationType) => { + if (!NATIVE_VALIDATORS.includes(validationType)) { + const customValidator = validationSchema[elementName][validationType]; + if (customValidator) { + setCustomValidations((prevState) => { + return { + ...prevState, + [elementName]: customValidator, + }; + }); + } + } + }); + }); + }, [validationSchema]); + + const handleBlur = ({ target: t }: { target: HTMLInputElement }) => { + const { name } = t; + setTouched((prevState) => ({ + ...prevState, + [name]: true, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (_isEmpty(validationErrors)) { + setIsSubmitting(true); + } + }; + + const handleCustom = (fieldName: string, callback: Function) => ( + e: React.FormEvent + ) => { + e.preventDefault(); // TODO: maybe call this conditionally + setValues((prevValues) => ({ + ...prevValues, + [fieldName]: callback(prevValues[fieldName]), + })); + }; + + const handleChange = ({ target: t }: { target: HTMLInputElement }) => { + const { name, type, value, checked } = t; + const newValue = type === 'checkbox' ? checked : value; + setValidatingElement(t); + setValues((prevValues) => _set({ ...prevValues }, name, newValue)); + }; + + useEffect(() => { + if (!validatingElement) { + return; + } + + const validateInputs = () => { + const errors = Object.entries(values).reduce( + (errorsObject, [fieldName]) => { + const fieldValidation = validationSchema[fieldName]; + if (!fieldValidation) { + return errorsObject; + } + + const customValidation = customValidations[fieldName]; + const isValidFromNativeValidators = !isNativeError(validatingElement); + + // If the native validator -- valueMissing, patternMismatch, etc. -- reports valid, then any + // custom validator gets run. + // + let errorMessage: ((elem) => string) | string = ''; + + if (isValidFromNativeValidators && customValidation) { + if (values) { + const validator = customValidation.validator || (() => true); + const validationReturnValue = validator( + values[fieldName], + validatingElement + ); + + // A custom error message, to be displayed as a tooltip or whatnot, that the validator itself returns. + // Useful for error messages whose composition depend on other values. + // + if (typeof validationReturnValue === 'string') { + errorMessage = validationReturnValue; + } + // A custom error message, whose value is static. + // + else if (validationReturnValue === false) { + errorMessage = customValidation.message; + } + } + } + + // Reset custom validation errors, if any, and allow native errors to display on the element and Form's + // agglomeration of error messages. + // + else if (!isValidFromNativeValidators) { + let nativeErrorName = ''; + getValidityKeys(validatingElement.validity).some((key) => { + if (validatingElement.validity[key]) { + nativeErrorName = key; + return true; + } + return false; + }); + + errorMessage = fieldValidation[nativeErrorName]?.message || ''; + } + + if (errorMessage) { + const errorText = + typeof errorMessage === 'function' + ? errorMessage(validatingElement) + : errorMessage; + validatingElement.setCustomValidity(errorText); + validatingElement?.reportValidity(); + errorsObject = { + ...errorsObject, + [fieldName]: errorText, + }; + } else { + validatingElement.setCustomValidity(''); + validatingElement?.reportValidity(); + } + + return errorsObject; + }, + {} + ); + + setValidationErrors(errors); + }; + + validateInputs(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values, validationSchema]); + + useEffect(() => { + if (!isSubmitting) return; + + (async () => { + try { + await onSubmit(values); + setIsSubmitting(false); + if (submitError) { + setSubmitError(null); + } + } catch (error) { + setIsSubmitting(false); + setSubmitError(error); + } + })(); + }, [isSubmitting, submitError, values, onSubmit]); + + return children ? ( + + + {children} + + + ) : null; +}; + +export default Form; diff --git a/src/components/Form/README.md b/src/components/Form/README.md new file mode 100644 index 00000000..e002b70a --- /dev/null +++ b/src/components/Form/README.md @@ -0,0 +1,176 @@ +# \ + +**Validate form inputs and handle validation events.** + +The `Form` component wraps one or more `input` elements and provides a validation structure for data entered therein. +Given a validation schema set a prop on `Form`, the components apply the validation logic to any contained input. +Form state, i.e. the results of the validation, are returned to children of `Form` for use, e.g. in displaying (or +removing) an error message. + +### ValidityState API + +`Form` utilizes the `ValidityState API`, information about which can be found in the [HTML RFC][1] and its [MDN +Documentation][2]. `ValidityState` sets its state on a handful of properties, +most of which correspond to `input` elements of various types: + +- [patternMismatch][3] +- [rangeOverflow][4] +- [rangeUnderflow][5] +- [stepMismatch][6] +- [tooLong][7] +- [tooShort][8] +- [typeMismatch][9] +- valueMissing + +### ValidationSchema (property) + +The passed-in `validationSchema` is an object whose keys match the `name` property of `input` (or of the `Input` +component, if used). Each of these name keys point to an object which contains configuration keys for each validator, +which may or may not match a built-in `ValidityState` key. + +The validator itself can contain a `validator` property (runs on input change and/or DOM initialization), and a +`message` property to use in displaying a static error message or explanatory text: + +**Simple example with built-in validators:** + +```jsx + + + + +``` + +When setting up a custom message for a built-in validator, if the `validator` property is included, it's ignored +since built-in criteria are used for validation. + +### ValidationSchema: message + +Also, for built-in and custom validators, the `message` property can either be a string or a function returning a +string, whose first argument is injected as the `input` in question. For instance, the `AgeInput` validator above +could be more robustly defined as: + +**Dynamic error message:** + +```jsx +validationSchema={{ + ... + AgeInput: { + rangeUnderflow: { + message: elem => 'You gotta be ' + elem.min + ' to rent a car, son.', + }, + }, +``` + +### ValidationSchema: validator + +Custom validators use arbitrary key validator names, and can specify either or both of `message` and `validator`: + +**Custom validator example:** + +```jsx + /^([a-zA-Z]+)\.?(\s+[a-zA-Z]+\.?)?\s+([a-zA-Z]+)$/.test( + value + ); + }, + }, + }} +> + + +``` + +If `validator` is not specified, the element value will always be considered valid, so this property is essentially +required. +If specified, the `validator` function receives the element value, and the element as its two injected arguments, +and returns either `true`, `false`, or a string. + +Returning `false` triggers the invalid UX on the field whose +error message is specified in the `message` attribute, which as above is either a function or a string. If +`message` isn't specified, a generic error message will be used in the error case. + +Additionally, the `validator` function itself can return a string (which is considered a failed validation), to be +used as the error message which is functionally equivalent to a defined `message` property, and if the validator +does return a string, the `message` attribute, if any, will not be used or invoked. + +If an `input's` configuration specifies both built-in and custom validator(s), the latter will only be run if the +built-in ones +pass. Multiple custom validators are not currently supported, and if more than one is specified, a random one in +the schema data structure for that `input` will be used. + +### InputValues (property) + +This is a simple object keyed on an `input's` `name` property, containing initial values for however many `inputs` +are specified in the structure. + +### onSubmit (property) + +The `onSubmit` property specifies a function which executes whenever the `submit` event fires. The function runs +with `await`, so asynchronous actions and dispatches can be executed in the `onSubmit`. + +The firing of `onSubmit` +uses `preventDefault` to intercept the natural `submit` behavior, so if `POST` behavior is desired as a result of +the `onSubmit` executing, that must be specifically implemented in the function. + +If a validation error is encountered, `onSubmit` should `throw` a string as an error. + +### Form Context + +Form surrounds its children containing these properties accessible by context consumers as `FormContext`: + +- `touched`: Object (keyed on form control name) consisting of booleans indicated whether form control has been + interacted with in + any way by a user +- `validationErrors`: Object (keyed on name) consisting of error message strings for validated form controls (if a + control is in an error state), +- `isSubmitting`: Boolean indicating whether or not a `Form` is in the process of being submitted, i.e. is the state + of the `Form` in the + interval between the initial calling of the `handleSubmit` handler, and the return of the `onSubmit` callback prop + indicating validation success or failure (which halts the submission), +- `submitError`: Any string indicating an error thrown by `onSubmit` +- `values`: Object (keyed on form control name) consisting of the current values of the controls contained in `Form` +- `handleChange`: A function to attach to the `onChange` event of an `input` or `Input` to be fired when the + `change` event occurs. + Of + course, if more needs to + happen `onChange`, `handleChange` (with `event` as its single argument) should be called within the wrapper function. +- `handleBlur`: As `handleChange`, but pertaining to the `blur` event. +- `handleCustom`: A function that can be attached to a form control's event handler, and which receives the control', + and a callback as its arguments. The callback receives + the named control's previous value, and gives the callback an opportunity to modify that control's value. A + common example of such a callback would be one that capitalizes an `input` or `Inputs` data on the click of a + `button` element. + +[1]: https://html.spec.whatwg.org/multipage/form-control-infrastructure. +[2]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState +[3]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/patternMismatch +[4]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/rangeOverflow +[5]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/rangeUnderflow +[6]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/stepMismatch +[7]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/tooLong +[8]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/tooShort +[9]: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/typeMismatch diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 00000000..8bc7b770 --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1 @@ +export { default } from './Form'; diff --git a/src/components/Form/story.module.css b/src/components/Form/story.module.css new file mode 100644 index 00000000..3af29bbe --- /dev/null +++ b/src/components/Form/story.module.css @@ -0,0 +1,33 @@ +.inputContainer { + display: grid; + grid-template-columns: 200px 75px; + grid-template-rows: auto; + padding: 20px; +} + +.personName { + grid-column: 1; + grid-row: 1; +} + +.personAge { + grid-column: 2; + grid-row: 1; +} + +[name=age] { + width: 60px; +} + +.actions { + grid-column: 1 / 3; + grid-row: 2; +} + +label { + font-size: 14px ! important; +} + +.nameInputErrorMessage:required { + border-color: black; +} diff --git a/src/components/Form/story.tsx b/src/components/Form/story.tsx new file mode 100644 index 00000000..aa32b7ce --- /dev/null +++ b/src/components/Form/story.tsx @@ -0,0 +1,148 @@ +import React, { createRef } from 'react'; +import { storiesOf } from '@storybook/react'; + +import Form from '.'; +import Readme from './README.md'; +import Button from '../Button'; +import Input from '../Input'; +import styles from './story.module.css'; + +import { FormContext } from './Form'; + +storiesOf('Galaxies/Form', module) + .addParameters({ + readme: { + sidebar: Readme, + }, + }) + .add( + 'Overview', + () => { + const ref = createRef(); + + return ( + alert(JSON.stringify(values))} + validationSchema={{ + ageInput: { + rangeUnderflow: { + message: (elem) => `Age must be great than ${elem?.min}, son`, + }, + }, + nameInput: { + // This is a ValidityState key. + // + valueMissing: { + message: () => "Hey, you got a name, don't you? Share it!", + }, + // This validator is basically providing a custom error message for a: + // + // + // + // validator. If the key were named 'patternMismatch' instead, and the nameInput element + // had the attribute: + // + // pattern="^([a-zA-Z]+)\.?(\s+[a-zA-Z]+\.?)?\s+([a-zA-Z]+)$" + // + // then the effect would be the same -- the 'message' below would be used as the + // custom error message (and the validator field would be ignored, since the 'patternMismatch' + // key turns it into a native validator. + // + nameMisformatted: { + message: + 'Name field requires a first name, optional middle name, and last name', + validator: (value) => { + // This likely isn't a super-bulletproof way of doing this validation. + // + return /^([a-zA-Z]+)\.?(\s+[a-zA-Z]+\.?)?\s+([a-zA-Z]+)$/.test( + value + ); + }, + }, + }, + }} + > + + {(context) => { + const { + formState, + values, + handleChange, + handleBlur, + handleCustom, + } = context as FormContext; + return ( + + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + Name + + + {formState.validationErrors.nameInput} + + + + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + Age + + + + + + + + name.toUpperCase() + ) as any + } + style={{ margin: '20px 15px 15px 0' }} + > + Capitalize + + + Submit + + + + ); + }} + + + ); + }, + { + info: { + inline: true, + source: true, + }, + } + ); diff --git a/src/stories/index.js b/src/stories/index.js index 01e96f87..0babffba 100644 --- a/src/stories/index.js +++ b/src/stories/index.js @@ -56,6 +56,7 @@ import '../components/EasyPill/story'; import '../components/SideTray/story'; import '../components/Accordion/story'; import '../components/Modal/story'; +import '../components/Form/story'; /* * DARK MATTER
+ {JSON.stringify({ values, formState })} +