formsy-react
Version:
A form input builder and validator for React
319 lines (263 loc) • 10 kB
text/typescript
import PropTypes from 'prop-types';
import React from 'react';
import FormsyContext from './FormsyContext';
import {
ComponentWithStaticAttributes,
FormsyContextInterface,
RequiredValidation,
ValidationError,
Validations,
WrappedComponentClass,
} from './interfaces';
import * as utils from './utils';
import { isString } from './utils';
import { isDefaultRequiredValue } from './validationRules';
/* eslint-disable react/default-props-match-prop-types */
const convertValidationsToObject = <V>(validations: false | Validations<V>): Validations<V> => {
if (isString(validations)) {
return validations.split(/,(?![^{[]*[}\]])/g).reduce((validationsAccumulator, validation) => {
let args: string[] = validation.split(':');
const validateMethod: string = args.shift();
args = args.map((arg) => {
try {
return JSON.parse(arg);
} catch (e) {
return arg; // It is a string if it can not parse it
}
});
if (args.length > 1) {
throw new Error(
'Formsy does not support multiple args on string validations. Use object format of validations instead.',
);
}
// Avoid parameter reassignment
const validationsAccumulatorCopy: Validations<V> = { ...validationsAccumulator };
validationsAccumulatorCopy[validateMethod] = args.length ? args[0] : true;
return validationsAccumulatorCopy;
}, {});
}
return validations || {};
};
export const propTypes = {
innerRef: PropTypes.func,
name: PropTypes.string.isRequired,
required: PropTypes.oneOfType([PropTypes.bool, PropTypes.object, PropTypes.string]),
validations: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
};
export interface WrapperProps<V> {
innerRef?: (ref: React.Ref<any>) => void;
name: string;
required?: RequiredValidation<V>;
validationError?: ValidationError;
validationErrors?: { [key: string]: ValidationError };
validations?: Validations<V>;
value?: V;
}
export interface WrapperState<V> {
[key: string]: unknown;
formSubmitted: boolean;
isPristine: boolean;
isRequired: boolean;
isValid: boolean;
pristineValue: V;
validationError: ValidationError[];
value: V;
}
export interface InjectedProps<V> {
errorMessage: ValidationError;
errorMessages: ValidationError[];
hasValue: boolean;
isFormDisabled: boolean;
isFormSubmitted: boolean;
isPristine: boolean;
isRequired: boolean;
isValid: boolean;
isValidValue: (value: V) => boolean;
ref?: React.Ref<any>;
resetValue: () => void;
setValidations: (validations: Validations<V>, required: RequiredValidation<V>) => void;
setValue: (value: V, validate?: boolean) => void;
showError: boolean;
showRequired: boolean;
}
export interface WrapperInstanceMethods<V> {
getErrorMessage: () => null | ValidationError;
getErrorMessages: () => ValidationError[];
getValue: () => V;
isFormDisabled: () => boolean;
isFormSubmitted: () => boolean;
isValid: () => boolean;
isValidValue: (value: V) => boolean;
setValue: (value: V, validate?: boolean) => void;
}
export type PassDownProps<V> = WrapperProps<V> & InjectedProps<V>;
function getDisplayName(component: WrappedComponentClass) {
return component.displayName || component.name || (utils.isString(component) ? component : 'Component');
}
export default function withFormsy<T, V>(
WrappedComponent: React.ComponentType<T & PassDownProps<V>>,
): React.ComponentType<Omit<T & WrapperProps<V>, keyof InjectedProps<V>>> {
class WithFormsyWrapper
extends React.Component<T & WrapperProps<V> & FormsyContextInterface, WrapperState<V>>
implements WrapperInstanceMethods<V>
{
public validations?: Validations<V>;
public requiredValidations?: Validations<V>;
public static displayName = `Formsy(${getDisplayName(WrappedComponent)})`;
public static propTypes: any = propTypes;
public static defaultProps: any = {
innerRef: null,
required: false,
validationError: '',
validationErrors: {},
validations: null,
value: (WrappedComponent as ComponentWithStaticAttributes).defaultValue,
};
public constructor(props) {
super(props);
const { runValidation, validations, required, value } = props;
this.state = { value } as any;
this.setValidations(validations, required);
this.state = {
formSubmitted: false,
isPristine: true,
pristineValue: props.value,
value: props.value,
...runValidation(this, props.value),
};
}
public componentDidMount() {
const { name, attachToForm } = this.props;
if (!name) {
throw new Error('Form Input requires a name property when used');
}
attachToForm(this);
}
public shouldComponentUpdate(nextProps, nextState) {
const { props, state } = this;
const isChanged = (a: object, b: object): boolean => Object.keys(a).some((k) => a[k] !== b[k]);
const isPropsChanged = isChanged(props, nextProps);
const isStateChanged = isChanged(state, nextState);
return isPropsChanged || isStateChanged;
}
public componentDidUpdate(prevProps) {
const { value, validations, required, validate } = this.props;
// If the value passed has changed, set it. If value is not passed it will
// internally update, and this will never run
if (!utils.isSame(value, prevProps.value)) {
this.setValue(value);
}
// If validations or required is changed, run a new validation
if (!utils.isSame(validations, prevProps.validations) || !utils.isSame(required, prevProps.required)) {
this.setValidations(validations, required);
validate(this);
}
}
// Detach it when component unmounts
public componentWillUnmount() {
const { detachFromForm } = this.props;
detachFromForm(this);
}
public getErrorMessage = (): ValidationError | null => {
const messages = this.getErrorMessages();
return messages.length ? messages[0] : null;
};
public getErrorMessages = (): ValidationError[] => {
const { validationError } = this.state;
if (!this.isValid() || this.showRequired()) {
return validationError || [];
}
return [];
};
// eslint-disable-next-line react/destructuring-assignment
public getValue = (): V => this.state.value;
public setValidations = (validations: Validations<V>, required: RequiredValidation<V>): void => {
// Add validations to the store itself as the props object can not be modified
this.validations = convertValidationsToObject(validations) || {};
this.requiredValidations =
required === true ? { isDefaultRequiredValue: required } : convertValidationsToObject(required);
};
// By default, we validate after the value has been set.
// A user can override this and pass a second parameter of `false` to skip validation.
public setValue = (value: V, validate = true): void => {
const { validate: validateForm } = this.props;
if (!validate) {
this.setState({ value });
} else {
this.setState(
{
value,
isPristine: false,
},
() => {
validateForm(this);
},
);
}
};
// eslint-disable-next-line react/destructuring-assignment
public hasValue = () => {
const { value } = this.state;
return isDefaultRequiredValue(value);
};
// eslint-disable-next-line react/destructuring-assignment
public isFormDisabled = (): boolean => this.props.isFormDisabled;
// eslint-disable-next-line react/destructuring-assignment
public isFormSubmitted = (): boolean => this.state.formSubmitted;
// eslint-disable-next-line react/destructuring-assignment
public isPristine = (): boolean => this.state.isPristine;
// eslint-disable-next-line react/destructuring-assignment
public isRequired = (): boolean => !!this.props.required;
// eslint-disable-next-line react/destructuring-assignment
public isValid = (): boolean => this.state.isValid;
// eslint-disable-next-line react/destructuring-assignment
public isValidValue = (value: V) => this.props.isValidValue(this, value);
public resetValue = () => {
const { pristineValue } = this.state;
const { validate } = this.props;
this.setState(
{
value: pristineValue,
isPristine: true,
},
() => {
validate(this);
},
);
};
public showError = (): boolean => !this.showRequired() && !this.isValid();
// eslint-disable-next-line react/destructuring-assignment
public showRequired = (): boolean => this.state.isRequired;
public render() {
const { innerRef } = this.props;
const propsForElement: T & PassDownProps<V> = {
...this.props,
errorMessage: this.getErrorMessage(),
errorMessages: this.getErrorMessages(),
hasValue: this.hasValue(),
isFormDisabled: this.isFormDisabled(),
isFormSubmitted: this.isFormSubmitted(),
isPristine: this.isPristine(),
isRequired: this.isRequired(),
isValid: this.isValid(),
isValidValue: this.isValidValue,
resetValue: this.resetValue,
setValidations: this.setValidations,
setValue: this.setValue,
showError: this.showError(),
showRequired: this.showRequired(),
value: this.getValue(),
};
if (innerRef) {
propsForElement.ref = innerRef;
}
return React.createElement(WrappedComponent, propsForElement);
}
}
// eslint-disable-next-line react/display-name
return (props) =>
React.createElement(FormsyContext.Consumer, null, ((contextValue) => {
return React.createElement(WithFormsyWrapper, { ...props, ...contextValue });
}) as any);
}