Skip to content

Commit

Permalink
refactor(core/presentation): Refactor FormField components using hooks (
Browse files Browse the repository at this point in the history
  • Loading branch information
christopherthielen committed Jun 28, 2019
1 parent 65795ca commit d23ee1c
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 198 deletions.
157 changes: 62 additions & 95 deletions app/scripts/modules/core/src/presentation/forms/FormField.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -30,106 +28,75 @@ export type IFormFieldProps = IFormFieldValidationProps &
IFieldLayoutPropsWithoutInput &
IValidationProps;

interface IFormFieldState {
validationMessage: IValidationProps['validationMessage'];
validationStatus: IValidationProps['validationStatus'];
internalValidators: IValidator[];
function firstDefined<T>(...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<IFormFieldProps, IFormFieldState> implements IFormFieldApi {
public static defaultProps: Partial<IFormFieldProps> = {
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<IValidatorResultRaw>),
[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 (
<WatchValue onChange={x => this.value$.next(x)} value={value}>
<LayoutConsumer>
{contextLayout =>
renderContent(layout || contextLayout, {
...fieldLayoutPropsWithoutInput,
...validationProps,
input: inputElement,
})
}
</LayoutConsumer>
</WatchValue>
);
}
// 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,
})}
</>
);
}
164 changes: 63 additions & 101 deletions app/scripts/modules/core/src/presentation/forms/FormikFormField.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -34,103 +28,68 @@ export interface IFormikFieldProps<T> {
onChange?: (value: T, prevValue: T) => void;
}

export interface IFormikFormFieldImplState {
internalValidators: IValidator[];
}

export type IFormikFormFieldProps<T> = IFormikFieldProps<T> & ICommonFormFieldProps & IFieldLayoutPropsWithoutInput;
type IFormikFormFieldImplProps<T> = IFormikFormFieldProps<T> & { formik: FormikContext<T> };

const ifString = (val: any): string => (isString(val) ? val : undefined);
const firstDefinedNode = (...values: React.ReactNode[]): React.ReactNode => values.find(val => !isNil(val));

export class FormikFormFieldImpl<T = any>
extends React.Component<IFormikFormFieldImplProps<T>, IFormikFormFieldImplState>
implements IFormFieldApi {
public static defaultProps: Partial<IFormikFormFieldProps<any>> = {
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<T>(...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<any>) => {
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 (
<WatchValue onChange={onChange} value={field.value}>
<LayoutConsumer>
{contextLayout =>
renderContent(layout || contextLayout, {
...fieldLayoutPropsWithoutInput,
...validationProps,
input: inputElement,
})
}
</LayoutConsumer>
</WatchValue>
);
const { useCallback, useContext, useState } = React;

function FormikFormFieldImpl<T = any>(props: IFormikFormFieldImplProps<T>) {
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<any>) => {
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 (
<WatchValue onChange={onChange} value={field.value}>
{renderContent(layout || fieldLayoutFromContext, {
...fieldLayoutPropsWithoutInput,
...validationProps,
input: inputElement,
})}
</WatchValue>
);
};

if (this.props.fastField) {
return <FastField name={name} validate={validator} render={renderField} />;
}
const validator = createFieldValidator(label, required, [].concat(validate).concat(internalValidators));

return <Field name={name} validate={validator} render={renderField} />;
if (fastField) {
return <FastField name={name} validate={validator} render={renderField} />;
}

return <Field name={name} validate={validator} render={renderField} />;
}

/** Returns a Validator composed of all the `validate` functions (and `isRequired` if `required` is truthy) */
Expand All @@ -139,7 +98,8 @@ export function createFieldValidator<T>(
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;
Expand All @@ -149,4 +109,6 @@ export function createFieldValidator<T>(
return (value: any) => validator(value, labelString);
}

export const FormikFormField = connect(FormikFormFieldImpl);
export function FormikFormField<T = any>(props: IFormikFormFieldProps<T>) {
return <FormikConsumer>{formik => <FormikFormFieldImpl {...props} formik={formik} />}</FormikConsumer>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as React from 'react';
* - Render Function
* - JSX.Element or string
*/
export function renderContent<T>(Content: string | JSX.Element | React.ComponentType<T>, props: T) {
export function renderContent<T>(Content: string | JSX.Element | React.ComponentType<T>, props: T): React.ReactNode {
const prototype = typeof Content === 'function' && Content.prototype;

if (prototype && (prototype.isReactComponent || typeof prototype.render === 'function')) {
Expand Down
Loading

0 comments on commit d23ee1c

Please sign in to comment.