@shopify/react-form-state
Version:
Manage React forms tersely and type-safely with no magic
489 lines (412 loc) • 11.8 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var _rollupPluginBabelHelpers = require('./_virtual/_rollupPluginBabelHelpers.js');
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);
class FormState extends React__default['default'].PureComponent {
constructor(...args) {
var _this;
super(...args);
_this = this;
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 = /*#__PURE__*/function () {
var _ref = _rollupPluginBabelHelpers.asyncToGenerator(function* (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) {
yield _this.validateForm();
const clientErrors = _this.clientErrors;
if (clientErrors.length > 0) {
_this.setState({
submitting: false,
errors: clientErrors
});
return;
}
}
const errors = (yield onSubmit(formData)) || [];
if (!_this.mounted) {
return;
}
if (errors.length > 0) {
_this.updateRemoteErrors(errors);
_this.setState({
submitting: false
});
} else {
_this.setState({
submitting: false,
errors
});
}
});
return function (_x) {
return _ref.apply(this, arguments);
};
}();
this.fieldWithHandlers = (field, fieldPath) => {
if (this.fieldsWithHandlers.has(field)) {
return this.fieldsWithHandlers.get(field);
}
const result = _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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(_rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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 : _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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 _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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: _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, state.fields), {}, {
[fieldPath]: _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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 _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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 _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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 _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, field), {}, {
error: runValidator(validators[path], field.value, fields)
});
});
return _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, 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;