From d23ee1c8fddb7bfb72ca818fdb1f1fde3e9a877c Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Fri, 28 Jun 2019 13:52:08 -0700 Subject: [PATCH] refactor(core/presentation): Refactor FormField components using hooks (#7148) --- .../core/src/presentation/forms/FormField.tsx | 157 +++++++---------- .../presentation/forms/FormikFormField.tsx | 164 +++++++----------- .../forms/fields/renderContent.tsx | 2 +- .../forms/layouts/LayoutContext.tsx | 4 +- .../presentation/forms/useLatestPromise.ts | 61 +++++++ 5 files changed, 190 insertions(+), 198 deletions(-) create mode 100644 app/scripts/modules/core/src/presentation/forms/useLatestPromise.ts diff --git a/app/scripts/modules/core/src/presentation/forms/FormField.tsx b/app/scripts/modules/core/src/presentation/forms/FormField.tsx index d056deb1aaa..b2bd14ba224 100644 --- a/app/scripts/modules/core/src/presentation/forms/FormField.tsx +++ b/app/scripts/modules/core/src/presentation/forms/FormField.tsx @@ -1,20 +1,18 @@ import * as React from 'react'; -import { Subject } from 'rxjs'; -import { isNil, isString } from 'lodash'; - -import { noop } from 'core/utils'; +import { isNil } from 'lodash'; +import { IPromise } from 'angular'; +import { $q } from 'ngimport'; +import { noop } from '../../utils'; +import { LayoutContext } from './layouts'; +import { useLatestPromise } from './useLatestPromise'; import { createFieldValidator } from './FormikFormField'; import { renderContent } from './fields/renderContent'; -import { LayoutConsumer } from './layouts/index'; -import { IValidator } from './validation'; -import { WatchValue } from '../WatchValue'; +import { IValidator, IValidatorResultRaw } from './validation'; import { ICommonFormFieldProps, IControlledInputProps, IFieldLayoutPropsWithoutInput, - IFieldValidationStatus, - IFormFieldApi, IValidationProps, } from './interface'; @@ -30,106 +28,75 @@ export type IFormFieldProps = IFormFieldValidationProps & IFieldLayoutPropsWithoutInput & IValidationProps; -interface IFormFieldState { - validationMessage: IValidationProps['validationMessage']; - validationStatus: IValidationProps['validationStatus']; - internalValidators: IValidator[]; +function firstDefined(...values: T[]): T { + return values.find(val => !isNil(val)); } -const ifString = (val: any): string => (isString(val) ? val : undefined); -const firstDefinedNode = (...values: React.ReactNode[]): React.ReactNode => values.find(val => !isNil(val)); +const { useState, useCallback, useContext, useMemo } = React; -export class FormField extends React.Component implements IFormFieldApi { - public static defaultProps: Partial = { - validate: noop, - onBlur: noop, - onChange: noop, - name: null, - }; +export function FormField(props: IFormFieldProps) { + const { input, layout } = props; // ICommonFormFieldProps + const { label, help, required, actions } = props; // IFieldLayoutPropsWithoutInput + const { validationMessage: messageProp, validationStatus: statusProp, touched: touchedProp } = props; + const { value } = props; - public state: IFormFieldState = { - validationMessage: undefined, - validationStatus: undefined, - internalValidators: [], - }; + const fieldLayoutPropsWithoutInput: IFieldLayoutPropsWithoutInput = { label, help, required, actions }; - private destroy$ = new Subject(); - private value$ = new Subject(); + // Internal validators are defined by an Input component + const [internalValidators, setInternalValidators] = useState([]); + const addValidator = useCallback((v: IValidator) => setInternalValidators(list => list.concat(v)), []); + const removeValidator = useCallback((v: IValidator) => setInternalValidators(list => list.filter(x => x !== v)), []); - public name = () => this.props.name; + const fieldLayoutFromContext = useContext(LayoutContext); - public label = () => ifString(this.props.label); + const validate = useMemo(() => props.validate, []); + const fieldValidator = useMemo( + () => createFieldValidator(label, required, [].concat(validate || noop).concat(internalValidators)), + [label, required, validate], + ); - public value = () => this.props.value; + const [errorMessage] = useLatestPromise( + // TODO: remove the following cast when we remove async validation from our API + () => $q.resolve((fieldValidator(value) as any) as IPromise), + [fieldValidator, value], + ); - public touched = () => this.props.touched; + const validationMessage = firstDefined(messageProp, errorMessage ? errorMessage : undefined); + const validationStatus = firstDefined(statusProp, errorMessage ? 'error' : undefined); - public validationMessage = () => firstDefinedNode(this.props.validationMessage, this.state.validationMessage); + const [hasBlurred, setHasBlurred] = useState(false); - public validationStatus = () => this.props.validationStatus || this.state.validationStatus; + const touched = firstDefined(touchedProp, hasBlurred); - private addValidator = (internalValidator: IValidator) => { - this.setState(prevState => ({ - internalValidators: prevState.internalValidators.concat(internalValidator), - })); + const validationProps: IValidationProps = { + touched, + validationMessage, + validationStatus, + addValidator, + removeValidator, }; - private removeValidator = (internalValidator: IValidator) => { - this.setState(prevState => ({ - internalValidators: prevState.internalValidators.filter(x => x !== internalValidator), - })); + const controlledInputProps: IControlledInputProps = { + value: props.value, + name: props.name || noop, + onChange: props.onChange || noop, + onBlur: (e: React.FocusEvent) => { + setHasBlurred(true); + props.onBlur && props.onBlur(e); + }, }; - public componentDidMount() { - this.value$ - .distinctUntilChanged() - .takeUntil(this.destroy$) - .subscribe(value => { - const { label, required, validate } = this.props; - const { internalValidators } = this.state; - const validator = createFieldValidator(label, required, [].concat(validate).concat(internalValidators)); - Promise.resolve(validator(value)).then(error => { - const validationMessage: string = error ? error : undefined; - const validationStatus: IFieldValidationStatus = validationMessage ? 'error' : undefined; - this.setState({ validationMessage, validationStatus }); - }); - }); - } - - public componentWillUnmount() { - this.destroy$.next(); - } - - public render() { - const { input, layout } = this.props; // ICommonFormFieldProps - const { label, help, required, actions } = this.props; // IFieldLayoutPropsWithoutInput - const { onChange, onBlur, value, name } = this.props; // IControlledInputProps - - const fieldLayoutPropsWithoutInput: IFieldLayoutPropsWithoutInput = { label, help, required, actions }; - const controlledInputProps: IControlledInputProps = { onChange, onBlur, value, name }; - - const validationProps: IValidationProps = { - touched: this.touched(), - validationMessage: this.validationMessage(), - validationStatus: this.validationStatus(), - addValidator: this.addValidator, - removeValidator: this.removeValidator, - }; - - const inputElement = renderContent(input, { ...controlledInputProps, validation: validationProps }); - - return ( - this.value$.next(x)} value={value}> - - {contextLayout => - renderContent(layout || contextLayout, { - ...fieldLayoutPropsWithoutInput, - ...validationProps, - input: inputElement, - }) - } - - - ); - } + // Render the input + const inputElement = renderContent(input, { ...controlledInputProps, validation: validationProps }); + + // Render the layout passing the rendered input in + return ( + <> + {renderContent(layout || fieldLayoutFromContext, { + ...fieldLayoutPropsWithoutInput, + ...validationProps, + input: inputElement, + })} + + ); } diff --git a/app/scripts/modules/core/src/presentation/forms/FormikFormField.tsx b/app/scripts/modules/core/src/presentation/forms/FormikFormField.tsx index c0cebbd9f70..ee3d3fa76ec 100644 --- a/app/scripts/modules/core/src/presentation/forms/FormikFormField.tsx +++ b/app/scripts/modules/core/src/presentation/forms/FormikFormField.tsx @@ -1,16 +1,10 @@ import * as React from 'react'; -import { isNil, isString, isUndefined } from 'lodash'; -import { Field, FastField, FieldProps, getIn, connect, FormikContext } from 'formik'; - -import { - ICommonFormFieldProps, - IFieldLayoutPropsWithoutInput, - IFieldValidationStatus, - IFormFieldApi, - IValidationProps, -} from './interface'; +import { isNil, isString } from 'lodash'; +import { Field, FastField, FieldProps, getIn, FormikContext, FormikConsumer } from 'formik'; + +import { ICommonFormFieldProps, IFieldLayoutPropsWithoutInput, IValidationProps } from './interface'; import { WatchValue } from '../WatchValue'; -import { LayoutConsumer } from './layouts/index'; +import { LayoutContext } from './layouts/index'; import { composeValidators, IValidator, Validators } from './validation'; import { renderContent } from './fields/renderContent'; @@ -34,103 +28,68 @@ export interface IFormikFieldProps { onChange?: (value: T, prevValue: T) => void; } -export interface IFormikFormFieldImplState { - internalValidators: IValidator[]; -} - export type IFormikFormFieldProps = IFormikFieldProps & ICommonFormFieldProps & IFieldLayoutPropsWithoutInput; type IFormikFormFieldImplProps = IFormikFormFieldProps & { formik: FormikContext }; -const ifString = (val: any): string => (isString(val) ? val : undefined); -const firstDefinedNode = (...values: React.ReactNode[]): React.ReactNode => values.find(val => !isNil(val)); - -export class FormikFormFieldImpl - extends React.Component, IFormikFormFieldImplState> - implements IFormFieldApi { - public static defaultProps: Partial> = { - fastField: true, - }; - - public state: IFormikFormFieldImplState = { - internalValidators: [], - }; - - private addValidator = (internalValidator: IValidator) => { - this.setState(prevState => ({ - internalValidators: prevState.internalValidators.concat(internalValidator), - })); - }; - - private removeValidator = (internalValidator: IValidator) => { - this.setState(prevState => ({ - internalValidators: prevState.internalValidators.filter(x => x !== internalValidator), - })); - }; - - public name = () => this.props.name; - - public label = () => ifString(this.props.label); - - public value = () => getIn(this.props.formik.values, this.props.name); - - public touched = () => { - const { formik, name, touched } = this.props; - return !isUndefined(touched) ? touched : getIn(formik.touched, name); - }; - - public validationMessage = () => { - const { name, formik, validationMessage } = this.props; - return firstDefinedNode(validationMessage, getIn(formik.errors, name)); - }; - - public validationStatus = () => { - return (this.props.validationStatus || (this.validationMessage() ? 'error' : null)) as IFieldValidationStatus; - }; +function firstDefined(...values: T[]): T { + return values.find(val => !isNil(val)); +} - public render() { - const { internalValidators } = this.state; - const { name, validate, onChange } = this.props; // IFormikFieldProps - const { input, layout } = this.props; // ICommonFieldProps - const { label, help, required, actions } = this.props; // IFieldLayoutPropsWithoutInput - - const fieldLayoutPropsWithoutInput: IFieldLayoutPropsWithoutInput = { label, help, required, actions }; - - const renderField = (props: FieldProps) => { - const { field } = props; - - const validationProps: IValidationProps = { - touched: this.touched(), - validationMessage: this.validationMessage(), - validationStatus: this.validationStatus(), - addValidator: this.addValidator, - removeValidator: this.removeValidator, - }; - - const inputElement = renderContent(input, { ...field, validation: validationProps }); - - return ( - - - {contextLayout => - renderContent(layout || contextLayout, { - ...fieldLayoutPropsWithoutInput, - ...validationProps, - input: inputElement, - }) - } - - - ); +const { useCallback, useContext, useState } = React; + +function FormikFormFieldImpl(props: IFormikFormFieldImplProps) { + const { formik } = props; + const { name, validate, onChange } = props; // IFormikFieldProps + const { input, layout } = props; // ICommonFieldProps + const { label, help, required, actions } = props; // IFieldLayoutPropsWithoutInput + const { + validationMessage: messageProp, + validationStatus: statusProp, + touched: touchedProp, + fastField: fastFieldProp, + } = props; + + const validationMessage = firstDefined(messageProp, getIn(formik.errors, props.name) as string); + const validationStatus = firstDefined(statusProp, validationMessage ? 'error' : null); + const touched = firstDefined(touchedProp, getIn(formik.touched, name) as boolean); + const fastField = firstDefined(fastFieldProp, true); + + const fieldLayoutPropsWithoutInput: IFieldLayoutPropsWithoutInput = { label, help, required, actions }; + const fieldLayoutFromContext = useContext(LayoutContext); + + const [internalValidators, setInternalValidators] = useState([]); + const addValidator = useCallback((v: IValidator) => setInternalValidators(list => list.concat(v)), []); + const removeValidator = useCallback((v: IValidator) => setInternalValidators(list => list.filter(x => x !== v)), []); + + const renderField = ({ field }: FieldProps) => { + const validationProps: IValidationProps = { + touched, + validationMessage, + validationStatus, + addValidator, + removeValidator, }; - const validator = createFieldValidator(label, required, [].concat(validate).concat(internalValidators)); + const inputElement = renderContent(input, { ...field, validation: validationProps }); + + return ( + + {renderContent(layout || fieldLayoutFromContext, { + ...fieldLayoutPropsWithoutInput, + ...validationProps, + input: inputElement, + })} + + ); + }; - if (this.props.fastField) { - return ; - } + const validator = createFieldValidator(label, required, [].concat(validate).concat(internalValidators)); - return ; + if (fastField) { + return ; } + + return ; } /** Returns a Validator composed of all the `validate` functions (and `isRequired` if `required` is truthy) */ @@ -139,7 +98,8 @@ export function createFieldValidator( required: boolean, validate: IValidator[], ): IValidator { - const validator = composeValidators([!!required && Validators.isRequired()].concat(validate)); + const validators = [!!required && Validators.isRequired()].concat(validate); + const validator = composeValidators(validators); if (!validator) { return null; @@ -149,4 +109,6 @@ export function createFieldValidator( return (value: any) => validator(value, labelString); } -export const FormikFormField = connect(FormikFormFieldImpl); +export function FormikFormField(props: IFormikFormFieldProps) { + return {formik => }; +} diff --git a/app/scripts/modules/core/src/presentation/forms/fields/renderContent.tsx b/app/scripts/modules/core/src/presentation/forms/fields/renderContent.tsx index ba390736949..7990418cc13 100644 --- a/app/scripts/modules/core/src/presentation/forms/fields/renderContent.tsx +++ b/app/scripts/modules/core/src/presentation/forms/fields/renderContent.tsx @@ -10,7 +10,7 @@ import * as React from 'react'; * - Render Function * - JSX.Element or string */ -export function renderContent(Content: string | JSX.Element | React.ComponentType, props: T) { +export function renderContent(Content: string | JSX.Element | React.ComponentType, props: T): React.ReactNode { const prototype = typeof Content === 'function' && Content.prototype; if (prototype && (prototype.isReactComponent || typeof prototype.render === 'function')) { diff --git a/app/scripts/modules/core/src/presentation/forms/layouts/LayoutContext.tsx b/app/scripts/modules/core/src/presentation/forms/layouts/LayoutContext.tsx index 1b64bad2922..3ced3e15500 100644 --- a/app/scripts/modules/core/src/presentation/forms/layouts/LayoutContext.tsx +++ b/app/scripts/modules/core/src/presentation/forms/layouts/LayoutContext.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; +import { IFieldLayoutProps } from '../interface'; import { StandardFieldLayout } from './StandardFieldLayout'; -export const { Provider: LayoutProvider, Consumer: LayoutConsumer } = React.createContext(StandardFieldLayout); +export const LayoutContext = React.createContext>(StandardFieldLayout); +export const { Provider: LayoutProvider, Consumer: LayoutConsumer } = LayoutContext; diff --git a/app/scripts/modules/core/src/presentation/forms/useLatestPromise.ts b/app/scripts/modules/core/src/presentation/forms/useLatestPromise.ts new file mode 100644 index 00000000000..22c9a8525fa --- /dev/null +++ b/app/scripts/modules/core/src/presentation/forms/useLatestPromise.ts @@ -0,0 +1,61 @@ +import { DependencyList, useEffect, useRef, useState } from 'react'; +import { IPromise } from 'angular'; + +export type IRequestStatus = 'NONE' | 'PENDING' | 'REJECTED' | 'RESOLVED'; + +/** + * A react hook which invokes a callback that returns a promise. + * If multiple requests are made concurrently, only returns data from the latest request. + * + * This can be useful when fetching data based on a users keyboard input, for example. + * This behavior is similar to RxJS switchMap. + * + * @param callback a callback that returns an IPromise + * @param deps array of dependencies, which (when changed) cause the callback to be invoked again + */ +export function useLatestPromise( + callback: () => IPromise, + deps: DependencyList, +): [T, IRequestStatus, any, number] { + const requestInFlight = useRef>(); + const [status, setStatus] = useState('NONE'); + const [result, setResult] = useState(); + const [error, setError] = useState(); + const [requestId, setRequestId] = useState(0); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + useEffect(() => { + const promise = callback(); + // If no promise is returned from the callback, noop this effect. + if (!promise) { + return; + } + + setStatus('PENDING'); + setRequestId(requestId + 1); + requestInFlight.current = promise; + + const resolve = (newResult: T) => { + if (mounted && promise === requestInFlight.current) { + setResult(newResult); + setStatus('RESOLVED'); + } + }; + + const reject = (rejection: any) => { + if (mounted && promise === requestInFlight.current) { + setError(rejection); + setStatus('REJECTED'); + } + }; + + promise.then(resolve, reject); + }, deps); + + return [result, status, error, requestId]; +}