@shopify/react-form-state
Version:
Manage React forms tersely and type-safely with no magic
481 lines (409 loc) • 10.8 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var React = require('react');
var utilities = require('./utilities.js');
var List = require('./components/List.js');
var Nested = require('./components/Nested.js');
var isEqual = require('fast-deep-equal');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
var isEqual__default = /*#__PURE__*/_interopDefaultLegacy(isEqual);
/* eslint-disable no-case-declarations */
class FormState extends React__default["default"].PureComponent {
constructor(...args) {
super(...args);
this.state = createFormState(this.props.initialValues, this.props.externalErrors);
this.mounted = false;
this.fieldsWithHandlers = new WeakMap();
this.reset = () => {
return new Promise(resolve => {
this.setState((_state, props) => createFormState(props.initialValues, props.externalErrors), () => resolve());
});
};
this.submit = async event => {
const {
onSubmit,
validateOnSubmit
} = this.props;
const {
formData
} = this;
if (!this.mounted) {
return;
}
if (event && event.preventDefault && !event.defaultPrevented) {
event.preventDefault();
}
if (onSubmit == null) {
return;
}
this.setState({
submitting: true
});
if (validateOnSubmit) {
await this.validateForm();
const clientErrors = this.clientErrors;
if (clientErrors.length > 0) {
this.setState({
submitting: false,
errors: clientErrors
});
return;
}
}
const errors = (await onSubmit(formData)) || [];
if (!this.mounted) {
return;
}
if (errors.length > 0) {
this.updateRemoteErrors(errors);
this.setState({
submitting: false
});
} else {
this.setState({
submitting: false,
errors
});
}
};
this.fieldWithHandlers = (field, fieldPath) => {
if (this.fieldsWithHandlers.has(field)) {
return this.fieldsWithHandlers.get(field);
}
const result = { ...field,
name: String(fieldPath),
onChange: this.updateField.bind(this, fieldPath),
onBlur: this.blurField.bind(this, fieldPath)
};
this.fieldsWithHandlers.set(field, result);
return result;
};
}
static getDerivedStateFromProps(newProps, oldState) {
const {
initialValues,
onInitialValuesChange,
externalErrors = []
} = newProps;
const externalErrorsChanged = !isEqual__default["default"](externalErrors, oldState.externalErrors);
const updatedExternalErrors = externalErrorsChanged ? {
externalErrors,
fields: fieldsWithErrors(oldState.fields, [...externalErrors, ...oldState.errors])
} : null;
switch (onInitialValuesChange) {
case 'ignore':
return updatedExternalErrors;
case 'reset-where-changed':
return reconcileFormState(initialValues, oldState, externalErrors);
case 'reset-all':
default:
const oldInitialValues = initialValuesFromFields(oldState.fields);
const valuesMatch = isEqual__default["default"](oldInitialValues, initialValues);
if (valuesMatch) {
return updatedExternalErrors;
}
return createFormState(initialValues, externalErrors);
}
}
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
render() {
const {
children
} = this.props;
const {
submitting
} = this.state;
const {
submit,
reset,
formData
} = this;
return children({ ...formData,
submit,
reset,
submitting
});
} // eslint-disable-next-line @shopify/react-prefer-private-members
validateForm() {
return new Promise(resolve => {
this.setState(runAllValidators, () => resolve());
});
} // eslint-disable-next-line @shopify/react-prefer-private-members
get formData() {
const {
errors
} = this.state;
const {
externalErrors = []
} = this.props;
const {
fields,
dirty,
valid
} = this;
return {
dirty,
valid,
errors: [...errors, ...externalErrors],
fields
};
}
get dirty() {
return this.state.dirtyFields.length > 0;
}
get valid() {
const {
errors,
externalErrors
} = this.state;
return !this.hasClientErrors && errors.length === 0 && externalErrors.length === 0;
}
get hasClientErrors() {
const {
fields
} = this.state;
return Object.keys(fields).some(fieldPath => {
const field = fields[fieldPath];
return field.error != null;
});
}
get clientErrors() {
const {
fields
} = this.state;
return utilities.flatMap(Object.values(fields), ({
error
}) => collectErrors(error));
}
get fields() {
const {
fields
} = this.state;
const fieldDescriptors = utilities.mapObject(fields, this.fieldWithHandlers);
return fieldDescriptors;
}
updateField(fieldPath, value) {
this.setState(({
fields,
dirtyFields
}) => {
const field = fields[fieldPath];
const newValue = typeof value === 'function' ? value(field.value) : value;
const dirty = !isEqual__default["default"](newValue, field.initialValue);
const updatedField = this.getUpdatedField({
fieldPath,
field,
value: newValue,
dirty
});
return {
dirtyFields: this.getUpdatedDirtyFields({
fieldPath,
dirty,
dirtyFields
}),
fields: updatedField === field ? fields : { // FieldStates<Fields> is not spreadable due to a TS bug
// https://github.com/Microsoft/TypeScript/issues/13557
...fields,
[fieldPath]: updatedField
}
};
});
}
getUpdatedDirtyFields({
fieldPath,
dirty,
dirtyFields
}) {
const dirtyFieldsSet = new Set(dirtyFields);
if (dirty) {
dirtyFieldsSet.add(fieldPath);
} else {
dirtyFieldsSet.delete(fieldPath);
}
const newDirtyFields = Array.from(dirtyFieldsSet);
return dirtyFields.length === newDirtyFields.length ? dirtyFields : newDirtyFields;
}
getUpdatedField({
fieldPath,
field,
value,
dirty
}) {
// We only want to update errors as the user types if they already have an error.
// https://polaris.shopify.com/patterns/error-messages#section-form-validation
const skipValidation = field.error == null;
const error = skipValidation ? field.error : this.validateFieldValue(fieldPath, {
value,
dirty
});
if (value === field.value && error === field.error) {
return field;
}
return { ...field,
value,
dirty,
error
};
}
blurField(fieldPath) {
const {
fields
} = this.state;
const field = fields[fieldPath];
const error = this.validateFieldValue(fieldPath, field);
if (error == null) {
return;
}
this.setState(state => ({
fields: { // FieldStates<Fields> is not spreadable due to a TS bug
// https://github.com/Microsoft/TypeScript/issues/13557
...state.fields,
[fieldPath]: { ...state.fields[fieldPath],
error
}
}
}));
}
validateFieldValue(fieldPath, {
value,
dirty
}) {
if (!dirty) {
return;
}
const {
validators = {}
} = this.props;
const {
fields
} = this.state;
return runValidator(validators[fieldPath], value, fields);
}
updateRemoteErrors(errors) {
this.setState(({
fields,
externalErrors
}) => ({
errors,
fields: fieldsWithErrors(fields, [...errors, ...externalErrors])
}));
}
}
FormState.List = List["default"];
FormState.Nested = Nested["default"];
function fieldsWithErrors(fields, errors) {
const errorDictionary = errors.reduce((accumulator, {
field,
message
}) => {
if (field == null) {
return accumulator;
}
return utilities.set(accumulator, field, message);
}, {});
return utilities.mapObject(fields, (field, path) => {
if (!errorDictionary[path]) {
return field;
}
return { ...field,
error: errorDictionary[path]
};
});
}
function reconcileFormState(values, oldState, externalErrors = []) {
const {
fields: oldFields
} = oldState;
const dirtyFields = new Set(oldState.dirtyFields);
const fields = utilities.mapObject(values, (value, key) => {
const oldField = oldFields[key];
if (isEqual__default["default"](value, oldField.initialValue)) {
return oldField;
}
dirtyFields.delete(key);
return {
value,
initialValue: value,
dirty: false
};
});
return { ...oldState,
dirtyFields: Array.from(dirtyFields),
fields: fieldsWithErrors(fields, externalErrors)
};
}
function createFormState(values, externalErrors = []) {
const fields = utilities.mapObject(values, value => {
return {
value,
initialValue: value,
dirty: false
};
});
return {
dirtyFields: [],
errors: [],
submitting: false,
externalErrors,
fields: fieldsWithErrors(fields, externalErrors)
};
}
function initialValuesFromFields(fields) {
return utilities.mapObject(fields, ({
initialValue
}) => initialValue);
}
function runValidator(validate = () => {}, value, fields) {
if (typeof validate === 'function') {
return validate(value, fields);
}
if (!Array.isArray(validate)) {
return;
}
const errors = validate.map(validator => validator(value, fields)).filter(input => input != null);
if (errors.length === 0) {
return;
}
return errors;
}
function runAllValidators(state, props) {
const {
fields
} = state;
const {
validators
} = props;
if (!validators) {
return null;
}
const updatedFields = utilities.mapObject(fields, (field, path) => {
return { ...field,
error: runValidator(validators[path], field.value, fields)
};
});
return { ...state,
fields: updatedFields
};
}
function collectErrors(message) {
if (!message) {
return [];
}
if (typeof message === 'string') {
return [{
message
}];
}
if (Array.isArray(message)) {
return utilities.flatMap(message, itemError => collectErrors(itemError));
}
return utilities.flatMap(Object.values(message), nestedError => collectErrors(nestedError));
}
exports["default"] = FormState;