UNPKG

react-formstate-fp

Version:

An API to build, query, update, and bind formstate in React. Develop forms DRYly in React.

369 lines (277 loc) 12.3 kB
import React, { useState, useRef, useEffect} from 'react'; import { exists, hasProp, isNonEmptyString, addScope, normalizeModelKey } from './helperFunctions.js'; import { convertToRootModelKey, getId, isScope, buildValidationSchema, createNestedScope } from './schemaAndLookup.js'; // // create RffAdaptor (to make TypeScript happy) // // typescript version // // export function createRffAdaptor<Props, Model>( // Component: React.FunctionComponent<Props & RffProps<Model>> // ): React.FunctionComponent<Props & FormFieldName> // { // return (props) => { // // rff used React.mapChildren to add form, formstate, and modelKey to render props // // have to do some casting to keep typescript happy... // const { form, formstate, modelKey, ...other } = props as Props & RffProps<Model>; // const unadaptedProps = (other as unknown) as Props; // return <Component form={form} formstate={formstate} modelKey={modelKey} {...unadaptedProps}/>; // }; // }; // javascript version // // Because this isn't typescript, you don't have to do any casting. // Despite appearances this function does accomplish something. // It's the typescript type signature for this function that does the work. export function createRffAdaptor(Component) { return Component; } export function createRffNestedFormAdaptor(Component) { return Component; } // // useFormstate // export function useFormstate(initialFormstate, options) { const [formstate, setFormstateHook] = useState(initialFormstate); // Prevent React from complaining about async validation trying to update formstate for an unmounted form: // --> react-dom.development.js:530 Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. // I do not believe asynchronous validation completing after the form is unmounted constitutes a real problem to be concerned about, // but to prevent spurious warnings from popping up in the console, you can make the form instance a ref... const formRef = useRef(null); const formstateRef = useRef(formstate); // So that people can use await if they want. if (formRef.current === null) { formRef.current = { getFormstate: () => formstateRef.current, setFormstate: (p) => { formstateRef.current = ( typeof(p) === 'function' ? p(formstateRef.current) : p ); setFormstateHook(formstateRef.current); }, ...options }; } useEffect(() => { return () => { // Return a cleanup function that will run when the form is unmounted. formRef.current.setFormstate = () => {}; // Suppress further attempts to update form state. }; }, []); // Only create this cleanup function once. return [formstate, formRef.current]; } // // bindToSetStateComponent // export function bindToSetStateComponent(component, statePropertyName = 'formstate') { return { getFormstate: () => component.state[statePropertyName], // So that people can use await if they want. setFormstate: (p) => { const updates = {}; updates[statePropertyName] = ( typeof(p) === 'function' ? p(component.state[statePropertyName]) : p ); component.setState(updates); } }; } // // Form, FormScope, FormField // // export function Form(props) { // return createElement('Form', props); // } export function FormScope(props) { return createElement('FormScope', props); } export function FormField(props) { return createElement('FormField', props); } function createElement(elementName, props) { const isScopeElement = (elementName !== 'FormField'); const {formstate, form, activeModelKey, name, required, validate, validateAsync, children, ...otherProps} = props; if (!formstate || !form) { throw new Error(`An RFF ${elementName} element requires "formstate" and "form" props.`); } if (typeof(form.setFormstate) !== 'function') { throw new Error(`The "form" prop provided to an RFF ${elementName} element must contain a "setFormstate" function.`); } // if (elementName === 'Form' && (activeModelKey || formstate.nestedScopeId)) { // This won't catch all cases. // throw new Error('An RFF Form element should not be nested underneath another Form or FormScope element, even across components.'); // } const _name = exists(name) ? String(name) : ''; const newModelKey = addScope(activeModelKey, normalizeModelKey(_name)); const rootModelKey = convertToRootModelKey(formstate, newModelKey); const id = formstate.lookup.idsByRootModelKey[rootModelKey]; if (!id) { throw new Error(`The RFF ${elementName} element with name "${_name}" mapped to root model key "${rootModelKey}" does not correspond to anything in your model. Did you provide an initial model to initializeFormstate?`); } if ((isScopeElement && !isScope(formstate, id)) || (!isScopeElement && isScope(formstate, id))) { throw new Error(`The RFF ${elementName} element with name "${_name}" mapped to root model key "${rootModelKey}" conflicts with your initial model, or with a validation schema provided to initializeFormstate, which defines that model key as a ${isScopeElement ? 'field' : 'scope'}, not a ${isScopeElement ? 'scope' : 'field'}.`); } if (required || validate || validateAsync) { // This is really just to satisfy eslint wrt unused variables. const jsxValidationSchema = buildValidationSchema(props, newModelKey, isScopeElement); if (Object.keys(jsxValidationSchema).length > 0) { // Do not mutate the form unless the user decides to use jsx validation configuration... if (!form.validationSchemas) { form.validationSchemas = {}; let fm = form; while (fm.parentForm) { fm.parentForm.validationSchemas = form.validationSchemas; fm = fm.parentForm; } } if (formstate.validationSchemas[id]) { throw new Error(`You cannot define validation in your JSX if a validation schema was already provided to initializeFormstate or addModelKey. Pick one approach please. See the RFF ${elementName} element for root model key ${rootModelKey}.`); } const priorSchema = form.validationSchemas[id]; if (priorSchema && priorSchema.nestedScopeId !== formstate.nestedScopeId) { throw new Error(`Validation is defined for root model key "${rootModelKey}" in two different components? This is not supported by RFF.`); } form.validationSchemas[id] = {...jsxValidationSchema, nestedScopeId: formstate.nestedScopeId}; } } return React.createElement( React.Fragment, //elementName === 'Form' ? 'form' : React.Fragment, otherProps, children && React.Children.map(children, (child) => addProps(formstate, form, newModelKey, child)) ); } // // validateNameRequired // function validateNameRequired(props, isFormScope) { if (!hasProp(props, 'name') || (!isNonEmptyString(props.name) && typeof(props.name) !== 'number')) { throw new Error(`A ${isFormScope ? 'non-root FormScope' : 'FormField'} element requires a non-empty "name" prop.`); } } // // addProps // function addProps(formstate, form, activeModelKey, element) { if (!element || !element.props) { return element; } // An example to help understand React.Children.map: //<Contact nestedForm> // <FormScope name='address'> // <Address nestedForm> // <FormField name='anAddressChild'> // <Input type='text'/> // </FormField> // </Address> // </FormScope> // <FormField name='anotherContactChild'> // <Input type='text'/> // </FormField> //</Contact> // The 'anAddressChild' input is not a direct child of this element so it is not in element.props.children here. // It will be processed by a React.Children.map call when addProps is called *for the Address child*. // Also, note that the FormScope and FormField components are contained in this library, so they can be coded to do their own work. // For anything else you have to take a look at the children via this method (and recursively take a look at their children via this method). // For instance, the Contact component isn't going to walk its children to add rff props. You have to do that for it here. // if (element.type === Form) { // throw new Error("A Form element should never be a child of another Form or FormScope element."); // } if (element.type === FormScope) { // For example: //<Form formstate={formstate} form={form}> // <FormField name='email'> // <Input type='text'/> // </FormField> // <FormScope name='address'> // <FormField name='line1'> // <Input type='text'/> // </FormField> // </FormScope> //</Form> validateNameRequired(element.props, true); // let the FormScope component do the work. return React.cloneElement( element, {formstate, form, activeModelKey}, // Merge in the props it needs. element.props.children ); } if (element.type === FormField) { // For example: //<Form formstate={formstate} form={form}> // <FormField name='email'> // <Input type='text'/> // </FormField> //</Form> validateNameRequired(element.props, false); // let the FormField component do the work. return React.cloneElement( element, {formstate, form, activeModelKey}, // Merge in the props it needs. element.props.children ); } function addPropsToChildren(children) { return children && React.Children.map(children, (child) => addProps(formstate, form, activeModelKey, child)); } if (form.adaptors) { if (!Array.isArray(form.adaptors)) { throw new Error('The "form.adaptors" option must be an array.'); } for (let i = 0, len = form.adaptors.length; i < len; i++) { if (form.adaptors[i] === element.type) { // For example: //<Form formstate={formstate} form={form}> // <FormField name='email'> // <FormGroup> // <Form.Label>Email</Form.Label> // <Input type='text'/> // <InputFeedback/> // </FormGroup> // </FormField> //</Form> // Let the user override the "activeModelKey" if necessary... let modelKey = activeModelKey; if (hasProp(element.props, 'name')) { // as of version 1.2 prefer name prop for adaptors modelKey = normalizeModelKey(element.props.name); } else if (hasProp(element.props, 'modelKey')) { modelKey = normalizeModelKey(element.props.modelKey); } return React.cloneElement( element, {formstate, form, modelKey}, // Merge in additional props to pass to the adaptor. addPropsToChildren(element.props.children) ); } } } if (hasProp(element.props, 'nestedForm')) { // For example: //<FormScope name='homeContact'> // <Contact nestedForm> // <FormScope name='address'> // <Address nestedForm/> // </FormScope> // </Contact> //</FormScope> // In the Contact component: //<FormScope formstate={props.formstate} form={props.form}> // <FormField name='name'> // <Input type='text'/> // </FormField> // {children} //</FormScope> // The nested component should operate within a nested component scope that is completely ignorant of anything above it. const newNestedScopeId = getId(formstate, activeModelKey); const [nestedFormstate, nestedForm] = createNestedScope(newNestedScopeId, formstate, form); return React.cloneElement( element, {formstate: nestedFormstate, form: nestedForm}, // Merge in additional props to pass to the nested component. addPropsToChildren(element.props.children) ); } // else if (element.props.children) { return React.cloneElement( element, {}, // Don't need to pass rff-related props to something like a div, but we do need to look through its children. addPropsToChildren(element.props.children) ); } return element; }