formula-one
Advanced tools
Comparing version 0.9.0-alpha.9 to 0.9.0-rc.1
# Changelog | ||
### v0.9.0-alpha | ||
### v0.9.0 | ||
- Add `customChange` prop to `ObjectField` and `ArrayField`. This allows changes in one part of the object to affect other parts of the form. Currently, no metadata is preserved (all fields are marked **changed** and **touched**) if a `customChange` function is used. This will be addressed in a future API. | ||
#### Breaking changes | ||
**Warning**: Rendering a component which calls `onChange` during mount under a non-null returning `customChange` will result in an infinite render loop. | ||
- Rename `serverErrors` to `externalErrors`. Validation errors from outside Formula One aren't necessarily from a server. This prop is also no longer a required parameter to `Form`, so only set it if you need it. | ||
- Bump internal flow version to 0.95.1. | ||
**Warning**: returning non-null from `customChange` forces a remount of all children. This can cause unintended consequences such as loss of focus on inputs. This will be fixed in a future 0.9 release. | ||
#### New features | ||
- Add `customChange` prop to `ObjectField` and `ArrayField`. This allows changes in one part of the object to affect other parts of the form. Currently, no metadata is preserved (all fields are marked **changed**, **touched**, and **succeeded** loses history) if a `customChange` function is used. This will be addressed in a future API. | ||
The API is: | ||
```js | ||
// Override nextValue by returning a non-null result | ||
customChange: <T>(prevValue: T, nextValue: T) => null | T; | ||
``` | ||
- Add `addFields`, `filterFields`, and `modifyFields` array manipulators. These are currently necessary due to the non-atomic nature of the current `addField` and `removeField` manipulators. They will be made atomic in a future version. | ||
@@ -32,5 +42,8 @@ | ||
- Fix flow types for ErrorHelper. | ||
- Bump internal flow version to 0.95.1 | ||
- `Form`'s `feedbackStrategy` prop now defaults to `Always`, which is convenient while building a form, though you'll likely want to pick another option for better UX in production. | ||
#### Minor changes | ||
- Fix flow types for `ErrorHelper`. | ||
### v0.8.2 | ||
@@ -93,3 +106,3 @@ | ||
- Reworked how server errors are updated. Fixes a bug where an exception would be thrown if the tree shapes didn't match. This could happen if you have a field which creates an object or array, which are not translated to leaf nodes internally. | ||
- Reworked how external errors are updated. Fixes a bug where an exception would be thrown if the tree shapes didn't match. This could happen if you have a field which creates an object or array, which are not translated to leaf nodes internally. | ||
- Added CHANGELOG |
@@ -18,3 +18,3 @@ "use strict"; | ||
var _formState3 = require("./formState"); | ||
var _formState2 = require("./formState"); | ||
@@ -25,10 +25,2 @@ function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = Object.defineProperty && Object.getOwnPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : {}; if (desc.get || desc.set) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } } newObj.default = obj; return newObj; } } | ||
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } | ||
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } | ||
function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } | ||
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } | ||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } | ||
@@ -52,2 +44,10 @@ | ||
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } | ||
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } | ||
function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } | ||
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } | ||
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } | ||
@@ -61,3 +61,3 @@ | ||
function makeLinks(formState, onChildChange, onChildBlur, onChildValidation) { | ||
function makeLinks(path, formState, onChildChange, onChildBlur) { | ||
var _formState = _slicedToArray(formState, 1), | ||
@@ -68,3 +68,3 @@ oldValue = _formState[0]; | ||
return { | ||
formState: (0, _formState3.arrayChild)(i, formState), | ||
formState: (0, _formState2.arrayChild)(i, formState), | ||
onChange: function onChange(childFormState) { | ||
@@ -76,5 +76,6 @@ onChildChange(i, childFormState); | ||
}, | ||
onValidation: function onValidation(childPath, clientErrors) { | ||
onChildValidation(i, childPath, clientErrors); | ||
} | ||
path: [].concat(_toConsumableArray(path), [{ | ||
type: "array", | ||
index: i | ||
}]) | ||
}; | ||
@@ -102,8 +103,6 @@ }); | ||
_defineProperty(_assertThisInitialized(_this), "state", { | ||
nonce: 0 | ||
}); | ||
_defineProperty(_assertThisInitialized(_this), "validationFnOps", (0, _Form.validationFnNoOps)()); | ||
_defineProperty(_assertThisInitialized(_this), "_handleChildChange", function (index, newChild) { | ||
var newFormState = (0, _formState3.replaceArrayChild)(index, newChild, _this.props.link.formState); | ||
var newFormState = (0, _formState2.replaceArrayChild)(index, newChild, _this.props.link.formState); | ||
var oldValue = _this.props.link.formState[0]; | ||
@@ -114,18 +113,13 @@ var newValue = newFormState[0]; | ||
var nextFormState; | ||
var validatedFormState; | ||
if (customValue) { | ||
// Create a fresh form state for the new value. | ||
// TODO(zach): It's kind of gross that this is happening outside of Form. | ||
nextFormState = (0, _formState3.changedFormState)(customValue); | ||
// A custom change occurred, which means the whole array needs to be | ||
// revalidated. | ||
validatedFormState = _this.context.updateTreeAtPath(_this.props.link.path, [customValue, newFormState[1]]); | ||
} else { | ||
nextFormState = newFormState; | ||
validatedFormState = _this.context.updateNodeAtPath(_this.props.link.path, newFormState); | ||
} | ||
_this.props.link.onChange((0, _formState3.setChanged)((0, _formState3.validate)(_this.props.validation, nextFormState))); // Need to remount children so they will run validations | ||
if (customValue) { | ||
_this.forceChildRemount(); | ||
} | ||
_this.props.link.onChange(validatedFormState); | ||
}); | ||
@@ -138,12 +132,9 @@ | ||
_this.props.link.onBlur((0, _shapedTree.mapRoot)(_formState3.setExtrasTouched, (0, _shapedTree.dangerouslyReplaceArrayChild)(index, childTree, tree))); | ||
_this.props.link.onBlur((0, _shapedTree.mapRoot)(_formState2.setExtrasTouched, (0, _shapedTree.dangerouslyReplaceArrayChild)(index, childTree, tree))); | ||
}); | ||
_defineProperty(_assertThisInitialized(_this), "_handleChildValidation", function (index, childPath, errors) { | ||
var extendedPath = [{ | ||
type: "array", | ||
index: index | ||
}].concat(_toConsumableArray(childPath)); | ||
_defineProperty(_assertThisInitialized(_this), "_validateThenApplyChange", function (formState) { | ||
var validatedFormState = _this.context.updateNodeAtPath(_this.props.link.path, formState); | ||
_this.props.link.onValidation(extendedPath, errors); | ||
_this.props.link.onChange(validatedFormState); | ||
}); | ||
@@ -163,3 +154,3 @@ | ||
_this.props.link.onChange((0, _formState3.validate)(_this.props.validation, (0, _formState3.setChanged)((0, _formState3.setTouched)([newValue, newTree])))); | ||
_this._validateThenApplyChange([newValue, newTree]); | ||
}); | ||
@@ -188,3 +179,3 @@ | ||
_this.props.link.onChange((0, _formState3.validate)(_this.props.validation, (0, _formState3.setChanged)((0, _formState3.setTouched)([newValue, newTree])))); | ||
_this._validateThenApplyChange([newValue, newTree]); | ||
}); | ||
@@ -216,3 +207,3 @@ | ||
_this.props.link.onChange((0, _formState3.validate)(_this.props.validation, (0, _formState3.setChanged)((0, _formState3.setTouched)([newValue, newTree])))); | ||
_this._validateThenApplyChange([newValue, newTree]); | ||
}); | ||
@@ -270,3 +261,3 @@ | ||
_this.props.link.onChange((0, _formState3.validate)(_this.props.validation, (0, _formState3.setChanged)((0, _formState3.setTouched)([newValue, newTree])))); | ||
_this._validateThenApplyChange([newValue, newTree]); | ||
}); | ||
@@ -282,3 +273,3 @@ | ||
_this.props.link.onChange((0, _formState3.validate)(_this.props.validation, (0, _formState3.setChanged)((0, _formState3.setTouched)([newValue, newTree])))); | ||
_this._validateThenApplyChange([newValue, newTree]); | ||
}); | ||
@@ -294,3 +285,3 @@ | ||
_this.props.link.onChange((0, _formState3.validate)(_this.props.validation, (0, _formState3.setChanged)((0, _formState3.setTouched)([newValue, newTree])))); | ||
_this._validateThenApplyChange([newValue, newTree]); | ||
}); | ||
@@ -302,44 +293,28 @@ | ||
_createClass(ArrayField, [{ | ||
key: "initialValidate", | ||
value: function initialValidate() { | ||
var _this$props = this.props, | ||
_this$props$link = _this$props.link, | ||
formState = _this$props$link.formState, | ||
onValidation = _this$props$link.onValidation, | ||
validation = _this$props.validation; | ||
var _formState2 = _slicedToArray(formState, 1), | ||
value = _formState2[0]; | ||
var _getExtras = (0, _formState3.getExtras)(formState), | ||
errors = _getExtras.errors; | ||
if (errors.client === "pending") { | ||
onValidation([], validation(value)); | ||
} | ||
} | ||
}, { | ||
key: "componentDidMount", | ||
value: function componentDidMount() { | ||
this.initialValidate(); | ||
this.validationFnOps = this.context.registerValidation(this.props.link.path, this.props.validation); | ||
} | ||
}, { | ||
key: "forceChildRemount", | ||
value: function forceChildRemount() { | ||
this.setState(function (_ref14) { | ||
var nonce = _ref14.nonce; | ||
return { | ||
nonce: nonce + 1 | ||
}; | ||
}); | ||
key: "componentDidUpdate", | ||
value: function componentDidUpdate(prevProps) { | ||
if (prevProps.validation !== this.props.validation) { | ||
this.validationFnOps.replace(this.props.validation); | ||
} | ||
} | ||
}, { | ||
key: "componentWillUnmount", | ||
value: function componentWillUnmount() { | ||
this.validationFnOps.unregister(); | ||
this.validationFnOps = (0, _Form.validationFnNoOps)(); | ||
} | ||
}, { | ||
key: "render", | ||
value: function render() { | ||
var formState = this.props.link.formState; | ||
var _this$props$link = this.props.link, | ||
formState = _this$props$link.formState, | ||
path = _this$props$link.path; | ||
var shouldShowError = this.context.shouldShowError; | ||
var links = makeLinks(formState, this._handleChildChange, this._handleChildBlur, this._handleChildValidation); | ||
return React.createElement(React.Fragment, { | ||
key: this.state.nonce | ||
}, this.props.children(links, { | ||
var links = makeLinks(path, formState, this._handleChildChange, this._handleChildBlur); | ||
return React.createElement(React.Fragment, null, this.props.children(links, { | ||
addField: this._addChildField, | ||
@@ -352,9 +327,9 @@ removeField: this._removeChildField, | ||
}, { | ||
touched: (0, _formState3.getExtras)(formState).meta.touched, | ||
changed: (0, _formState3.getExtras)(formState).meta.changed, | ||
shouldShowErrors: shouldShowError((0, _formState3.getExtras)(formState).meta), | ||
unfilteredErrors: (0, _formState3.flatRootErrors)(formState), | ||
touched: (0, _formState2.getExtras)(formState).meta.touched, | ||
changed: (0, _formState2.getExtras)(formState).meta.changed, | ||
shouldShowErrors: shouldShowError((0, _formState2.getExtras)(formState).meta), | ||
unfilteredErrors: (0, _formState2.flatRootErrors)(formState), | ||
asyncValidationInFlight: false, | ||
// no validations on Form | ||
valid: (0, _formState3.isValid)(formState), | ||
valid: (0, _formState2.isValid)(formState), | ||
value: formState[0] | ||
@@ -361,0 +336,0 @@ })); |
@@ -23,4 +23,4 @@ "use strict"; | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
if (errors.external !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.external); | ||
} | ||
@@ -42,5 +42,5 @@ | ||
client: errors.client, | ||
server: errors.server, | ||
external: errors.external, | ||
flattened: flattened | ||
}); | ||
} |
@@ -14,3 +14,3 @@ "use strict"; | ||
var _formState3 = require("./formState"); | ||
var _formState2 = require("./formState"); | ||
@@ -54,4 +54,4 @@ function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = Object.defineProperty && Object.getOwnPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : {}; if (desc.get || desc.set) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } } newObj.default = obj; return newObj; } } | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
if (errors.external !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.external); | ||
} | ||
@@ -80,8 +80,15 @@ | ||
_defineProperty(_assertThisInitialized(_this), "validationFnOps", (0, _Form.validationFnNoOps)()); | ||
_defineProperty(_assertThisInitialized(_this), "onChange", function (newValue) { | ||
var _this$props$link$form = _slicedToArray(_this.props.link.formState, 2), | ||
var _this$props$link = _this.props.link, | ||
path = _this$props$link.path, | ||
_this$props$link$form = _slicedToArray(_this$props$link.formState, 2), | ||
_ = _this$props$link$form[0], | ||
oldTree = _this$props$link$form[1]; | ||
oldTree = _this$props$link$form[1], | ||
onChange = _this$props$link.onChange; | ||
_this.props.link.onChange((0, _formState3.setChanged)((0, _formState3.validate)(_this.props.validation, [newValue, oldTree]))); | ||
var newFormState = _this.context.updateNodeAtPath(path, [newValue, oldTree]); | ||
onChange(newFormState); | ||
}); | ||
@@ -94,4 +101,4 @@ | ||
_this.props.link.onBlur( // TODO(zach): Not sure if we should blow away server errors here | ||
(0, _shapedTree.mapRoot)(_formState3.setExtrasTouched, tree)); | ||
_this.props.link.onBlur( // TODO(zach): Not sure if we should blow away external errors here | ||
(0, _shapedTree.mapRoot)(_formState2.setExtrasTouched, tree)); | ||
}); | ||
@@ -103,24 +110,18 @@ | ||
_createClass(Field, [{ | ||
key: "initialValidate", | ||
value: function initialValidate() { | ||
var _this$props = this.props, | ||
_this$props$link = _this$props.link, | ||
formState = _this$props$link.formState, | ||
onValidation = _this$props$link.onValidation, | ||
validation = _this$props.validation; | ||
var _formState = _slicedToArray(formState, 1), | ||
value = _formState[0]; | ||
var _getExtras = (0, _formState3.getExtras)(formState), | ||
errors = _getExtras.errors; | ||
if (errors.client === "pending") { | ||
onValidation([], validation(value)); | ||
key: "componentDidMount", | ||
value: function componentDidMount() { | ||
this.validationFnOps = this.context.registerValidation(this.props.link.path, this.props.validation); | ||
} | ||
}, { | ||
key: "componentDidUpdate", | ||
value: function componentDidUpdate(prevProps) { | ||
if (prevProps.validation !== this.props.validation) { | ||
this.validationFnOps.replace(this.props.validation); | ||
} | ||
} | ||
}, { | ||
key: "componentDidMount", | ||
value: function componentDidMount() { | ||
this.initialValidate(); | ||
key: "componentWillUnmount", | ||
value: function componentWillUnmount() { | ||
this.validationFnOps.unregister(); | ||
this.validationFnOps = (0, _Form.validationFnNoOps)(); | ||
} | ||
@@ -132,8 +133,8 @@ }, { | ||
var _formState2 = _slicedToArray(formState, 1), | ||
value = _formState2[0]; | ||
var _formState = _slicedToArray(formState, 1), | ||
value = _formState[0]; | ||
var _getExtras2 = (0, _formState3.getExtras)(formState), | ||
meta = _getExtras2.meta, | ||
errors = _getExtras2.errors; | ||
var _getExtras = (0, _formState2.getExtras)(formState), | ||
meta = _getExtras.meta, | ||
errors = _getExtras.errors; | ||
@@ -149,3 +150,3 @@ var shouldShowError = this.context.shouldShowError; | ||
// no validations on Form | ||
valid: (0, _formState3.isValid)(formState), | ||
valid: (0, _formState2.isValid)(formState), | ||
value: value | ||
@@ -152,0 +153,0 @@ }); |
384
dist/Form.js
@@ -6,2 +6,3 @@ "use strict"; | ||
}); | ||
exports.validationFnNoOps = validationFnNoOps; | ||
exports.default = exports.FormContext = void 0; | ||
@@ -11,2 +12,6 @@ | ||
var _invariant = _interopRequireDefault(require("./utils/invariant")); | ||
var _array = require("./utils/array"); | ||
var _formState2 = require("./formState"); | ||
@@ -18,2 +23,4 @@ | ||
var _EncodedPath = require("./EncodedPath"); | ||
var _feedbackStrategies = _interopRequireDefault(require("./feedbackStrategies")); | ||
@@ -25,4 +32,2 @@ | ||
function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } | ||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } | ||
@@ -44,2 +49,14 @@ | ||
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } | ||
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } | ||
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } | ||
function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } | ||
function _toArray(arr) { return _arrayWithHoles(arr) || _iterableToArray(arr) || _nonIterableRest(); } | ||
function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } | ||
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } | ||
@@ -57,2 +74,12 @@ | ||
var noOps = { | ||
unregister: function unregister() {}, | ||
replace: function replace() {} | ||
}; // noOps can't be used directly because Flow doesn't typecheck a constant as | ||
// being parametric in T. | ||
function validationFnNoOps() { | ||
return noOps; | ||
} | ||
var FormContext = React.createContext({ | ||
@@ -63,7 +90,19 @@ shouldShowError: function shouldShowError() { | ||
pristine: false, | ||
submitted: true | ||
submitted: true, | ||
registerValidation: function registerValidation() { | ||
return { | ||
replace: function replace() {}, | ||
unregister: function unregister() {} | ||
}; | ||
}, | ||
updateTreeAtPath: function updateTreeAtPath(path, formState) { | ||
return formState; | ||
}, | ||
updateNodeAtPath: function updateNodeAtPath(path, formState) { | ||
return formState; | ||
} | ||
}); | ||
exports.FormContext = FormContext; | ||
function applyServerErrorsToFormState(serverErrors, formState) { | ||
function applyExternalErrorsToFormState(externalErrors, formState) { | ||
var _formState = _slicedToArray(formState, 2), | ||
@@ -75,3 +114,3 @@ value = _formState[0], | ||
if (serverErrors !== null) { | ||
if (externalErrors !== null) { | ||
// If keys do not appear, no errors | ||
@@ -83,3 +122,3 @@ tree = (0, _shapedTree.mapShapedTree)(function (_ref) { | ||
errors: _objectSpread({}, errors, { | ||
server: [] | ||
external: [] | ||
}), | ||
@@ -89,4 +128,4 @@ meta: meta | ||
}, oldTree); | ||
Object.keys(serverErrors).forEach(function (key) { | ||
var newErrors = serverErrors[key]; | ||
Object.keys(externalErrors).forEach(function (key) { | ||
var newErrors = externalErrors[key]; | ||
var path = (0, _shapedTree.shapePath)(value, (0, _tree.pathFromPathString)(key)); | ||
@@ -101,3 +140,3 @@ | ||
errors: _objectSpread({}, errors, { | ||
server: newErrors | ||
external: newErrors | ||
}), | ||
@@ -123,3 +162,3 @@ meta: meta | ||
errors: _objectSpread({}, errors, { | ||
server: [] | ||
external: [] | ||
}), | ||
@@ -134,2 +173,158 @@ meta: meta | ||
function getValueAtPath(path, value) { | ||
if (path.length === 0) { | ||
return value; | ||
} | ||
var _path = _toArray(path), | ||
p = _path[0], | ||
rest = _path.slice(1); | ||
if (p.type === "array") { | ||
(0, _invariant.default)(Array.isArray(value), "Path/value shape mismatch: expected array"); | ||
return getValueAtPath(rest, value[p.index]); | ||
} else if (p.type === "object") { | ||
(0, _invariant.default)(_typeof(value) === "object" && value !== null && !Array.isArray(value), "Path/value shape mismatch: expected object"); | ||
return getValueAtPath(rest, value[p.key]); | ||
} | ||
throw new Error("Path is too long"); | ||
} | ||
function pathSegmentEqual(a, b) { | ||
return a.type === "array" && b.type === "array" && a.index === b.index || a.type === "object" && b.type === "object" && a.key === b.key; | ||
} | ||
function getRelativePath(path, prefix) { | ||
for (var i = 0; i < prefix.length; i++) { | ||
(0, _invariant.default)(pathSegmentEqual(path[i], prefix[i]), "Expect prefix to be a prefix of path"); | ||
} | ||
return path.slice(prefix.length); | ||
} | ||
/** | ||
* Deeply updates the FormState tree to reflect a new value. Similar to | ||
* applyValidationToTree, but in response to a deep update, so updates all child | ||
* paths too. Used in response to a custom change. | ||
*/ | ||
function _updateTreeAtPath(prefix, _ref4, validations) { | ||
var _ref5 = _slicedToArray(_ref4, 2), | ||
initialValue = _ref5[0], | ||
_initialTree = _ref5[1]; | ||
// Create a fresh form state for the new value. Note that this overwrites any | ||
// existing `succeeded` values with false. `succeeded` will only be set back | ||
// to true if and only if the current validation passes. We lose history. For | ||
// most use cases this is likely desirible behaviour, but there could be uses | ||
// cases for which it isn't desired. We'll fix it if we encounter one of those | ||
var _changedFormState = (0, _formState2.changedFormState)(initialValue), | ||
_changedFormState2 = _slicedToArray(_changedFormState, 2), | ||
value = _changedFormState2[0], | ||
changedTree = _changedFormState2[1]; | ||
var validatedTree = _toConsumableArray(validations.entries()).filter(function (_ref6) { | ||
var _ref7 = _slicedToArray(_ref6, 1), | ||
path = _ref7[0]; | ||
return (0, _EncodedPath.startsWith)(path, prefix); | ||
}).map(function (_ref8) { | ||
var _ref9 = _slicedToArray(_ref8, 2), | ||
path = _ref9[0], | ||
validationsMap = _ref9[1]; | ||
// Note that value is not the root value, it's the value at this path. | ||
// So convert absolute validation paths to relative before attempting to | ||
// pull out the value on which to run them. | ||
var relativePath = getRelativePath((0, _EncodedPath.decodePath)(path), prefix); | ||
var valueAtPath = getValueAtPath(relativePath, value); // Run all validation functions on x | ||
var errors = _toConsumableArray(validationsMap.values()).reduce(function (errors, validationFn) { | ||
return errors.concat(validationFn(valueAtPath)); | ||
}, []); | ||
return [relativePath, errors]; | ||
}).reduce(function (tree, _ref10) { | ||
var _ref11 = _slicedToArray(_ref10, 2), | ||
path = _ref11[0], | ||
newErrors = _ref11[1]; | ||
return (// Here we don't reset `errors: {external}` or set `meta: {touched: true, | ||
// changed: true}`. This is because we already called changedFormState | ||
// above. | ||
(0, _shapedTree.updateAtPath)(path, function (_ref12) { | ||
var errors = _ref12.errors, | ||
meta = _ref12.meta; | ||
return { | ||
errors: _objectSpread({}, errors, { | ||
client: newErrors | ||
}), | ||
meta: _objectSpread({}, meta, { | ||
succeeded: meta.succeeded || newErrors.length === 0 | ||
}) | ||
}; | ||
}, tree) | ||
); | ||
}, changedTree); | ||
return [value, validatedTree]; | ||
} // Unique id for each field so that errors can be tracked by the fields that | ||
// produced them. This is necessary because it's possible for multiple fields | ||
// to reference the same link "aliasing". | ||
var _nextFieldId = 0; | ||
function nextFieldId() { | ||
return _nextFieldId++; | ||
} // TODO(dmnd): This function is confusing to use because pathToValue and | ||
// validations are conceptually "absolute" (i.e. they are defined with respect | ||
// to the root), but valueAtPath is *not* absolute: it's the value deeper in the | ||
// tree, defined respective to pathToValue. | ||
function validateAtPath(pathToValue, valueAtPath, // TODO(dmnd): Better typechecking with ShapedPath? | ||
validations) { | ||
var map = validations.get((0, _EncodedPath.encodePath)(pathToValue)); | ||
if (!map) { | ||
return []; | ||
} | ||
return _toConsumableArray(map.values()).reduce(function (errors, validationFn) { | ||
return errors.concat(validationFn(valueAtPath)); | ||
}, []); | ||
} | ||
/** | ||
* Updates the FormState tree to reflect a new value: | ||
* - run validations at path (but not child paths) | ||
* - remove existing, now obsolete errors | ||
* - calculate & write new client side errors | ||
* - ensure that meta reflects that the value has changed | ||
*/ | ||
function _updateNodeAtPath(path, _ref13, validations) { | ||
var _ref14 = _slicedToArray(_ref13, 2), | ||
value = _ref14[0], | ||
tree = _ref14[1]; | ||
var errors = validateAtPath(path, value, validations); | ||
return [value, (0, _shapedTree.mapRoot)(function (_ref15) { | ||
var meta = _ref15.meta; | ||
return { | ||
errors: { | ||
client: errors, | ||
external: "unchecked" | ||
}, | ||
meta: _objectSpread({}, meta, { | ||
succeeded: meta.succeeded || errors.length === 0, | ||
touched: true, | ||
changed: true | ||
}) | ||
}; | ||
}, tree)]; | ||
} | ||
var Form = | ||
@@ -143,7 +338,7 @@ /*#__PURE__*/ | ||
value: function getDerivedStateFromProps(props, state) { | ||
if (props.serverErrors !== state.oldServerErrors) { | ||
var newFormState = applyServerErrorsToFormState(props.serverErrors, state.formState); | ||
if (props.externalErrors !== state.oldExternalErrors) { | ||
var newTree = applyExternalErrorsToFormState(props.externalErrors, state.formState); | ||
return { | ||
formState: newFormState, | ||
oldServerErrors: props.serverErrors | ||
formState: newTree, | ||
oldExternalErrors: props.externalErrors | ||
}; | ||
@@ -163,2 +358,6 @@ } | ||
_defineProperty(_assertThisInitialized(_this), "validations", void 0); | ||
_defineProperty(_assertThisInitialized(_this), "initialValidationComplete", false); | ||
_defineProperty(_assertThisInitialized(_this), "_handleSubmit", function (extraData) { | ||
@@ -194,33 +393,98 @@ _this.setState({ | ||
_defineProperty(_assertThisInitialized(_this), "_handleValidation", function (path, errors) { | ||
// TODO(zach): Move this into formState.js, it is gross | ||
var updater = function updater(newErrors) { | ||
return function (_ref4) { | ||
var errors = _ref4.errors, | ||
meta = _ref4.meta; | ||
return { | ||
errors: _objectSpread({}, errors, { | ||
client: newErrors | ||
}), | ||
meta: _objectSpread({}, meta, { | ||
succeeded: newErrors.length === 0 ? true : meta.succeeded | ||
_defineProperty(_assertThisInitialized(_this), "recomputeErrorsAtPathAndRender", function (path) { | ||
_this.setState(function (_ref16) { | ||
var _ref16$formState = _slicedToArray(_ref16.formState, 2), | ||
rootValue = _ref16$formState[0], | ||
tree = _ref16$formState[1]; | ||
var value = getValueAtPath(path, rootValue); | ||
var errors = validateAtPath(path, value, _this.validations); | ||
var updatedTree = (0, _shapedTree.updateAtPath)(path, function (extras) { | ||
return _objectSpread({}, extras, { | ||
errors: _objectSpread({}, extras.errors, { | ||
client: errors | ||
}) | ||
}; | ||
}); | ||
}, tree); | ||
return { | ||
formState: [rootValue, updatedTree] | ||
}; | ||
}); | ||
}); | ||
_defineProperty(_assertThisInitialized(_this), "handleRegisterValidation", function (path, fn) { | ||
var encodedPath = (0, _EncodedPath.encodePath)(path); | ||
var fieldId = nextFieldId(); | ||
var map = _this.validations.get(encodedPath) || new Map(); | ||
map.set(fieldId, fn); | ||
_this.validations.set(encodedPath, map); | ||
if (_this.initialValidationComplete) { | ||
// Form validates all Fields at once during mount. When fields are added | ||
// after the Form has already mounted, their initial values need to be | ||
// validated. | ||
_this.recomputeErrorsAtPathAndRender(path); | ||
} | ||
return { | ||
replace: function replace(fn) { | ||
return _this.replaceValidation(path, fieldId, fn); | ||
}, | ||
unregister: function unregister() { | ||
return _this.unregisterValidation(path, fieldId); | ||
} | ||
}; | ||
}); | ||
_this.setState(function (_ref5) { | ||
var _ref5$formState = _slicedToArray(_ref5.formState, 2), | ||
value = _ref5$formState[0], | ||
tree = _ref5$formState[1]; | ||
_defineProperty(_assertThisInitialized(_this), "replaceValidation", function (path, fieldId, fn) { | ||
var encodedPath = (0, _EncodedPath.encodePath)(path); | ||
return { | ||
formState: [value, (0, _shapedTree.updateAtPath)(path, updater(errors), tree)] | ||
}; | ||
}, function () { | ||
_this.props.onValidation((0, _formState2.isValid)(_this.state.formState)); | ||
}); | ||
var map = _this.validations.get(encodedPath); | ||
(0, _invariant.default)(map != null, "Expected to find handler map"); | ||
var oldFn = map.get(fieldId); | ||
(0, _invariant.default)(oldFn != null, "Expected to find previous validation function"); | ||
map.set(fieldId, fn); // Now that the old validation is gone, make sure there are no left over | ||
// errors from it. | ||
var value = getValueAtPath(path, _this.state.formState[0]); | ||
if ((0, _array.equals)(oldFn(value), fn(value))) { | ||
// The errors haven't changed, so don't bother calling setState. | ||
// You might think this is a silly performance optimization but actually | ||
// we need this for annoying React reasons: | ||
// If the validation function is an inline function, its identity changes | ||
// every render. This means replaceValidation gets called every time | ||
// componentDidUpdate runs (i.e. each render). Then when setState is | ||
// called from recomputeErrorsAtPathAndRender, it'll cause another render, | ||
// which causes another componentDidUpdate, and so on. So, take care to | ||
// avoid an infinite loop by returning early here. | ||
return; | ||
} // The new validation function returns different errors, so re-render. | ||
_this.recomputeErrorsAtPathAndRender(path); | ||
}); | ||
var formState = applyServerErrorsToFormState(props.serverErrors, (0, _formState2.freshFormState)(props.initialValue)); | ||
_defineProperty(_assertThisInitialized(_this), "unregisterValidation", function (path, fieldId) { | ||
var encodedPath = (0, _EncodedPath.encodePath)(path); | ||
var map = _this.validations.get(encodedPath); | ||
(0, _invariant.default)(map != null, "Couldn't find handler map during unregister"); | ||
map.delete(fieldId); // If the entire path was deleted from the tree, any left over errors are | ||
// already gone. For example, this happens when an array child is removed. | ||
if (!(0, _shapedTree.pathExistsInTree)(path, _this.state.formState[1])) { | ||
return; | ||
} // now that the validation is gone, make sure there are no left over | ||
// errors from it | ||
_this.recomputeErrorsAtPathAndRender(path); | ||
}); | ||
_this.validations = new Map(); | ||
var formState = applyExternalErrorsToFormState(props.externalErrors, (0, _formState2.freshFormState)(props.initialValue)); | ||
_this.state = { | ||
@@ -230,9 +494,32 @@ formState: formState, | ||
submitted: false, | ||
oldServerErrors: props.serverErrors | ||
oldExternalErrors: props.externalErrors | ||
}; | ||
return _this; | ||
} // Public API: submit from the outside | ||
} | ||
_createClass(Form, [{ | ||
key: "componentDidMount", | ||
value: function componentDidMount() { | ||
var _this2 = this; | ||
_createClass(Form, [{ | ||
// After the the Form mounts, all validations get ran as a batch. Note that | ||
// this is different from how initial validations get run on all | ||
// subsequently mounted Fields. When a Field is mounted after the Form, its | ||
// validation gets run individually. | ||
// TODO(dmnd): It'd be nice to consolidate validation to a single code path. | ||
// Take care to use an updater to avoid clobbering changes from fields that | ||
// call onChange during cDM. | ||
this.setState(function (_ref17) { | ||
var formState = _ref17.formState; | ||
return { | ||
formState: _updateTreeAtPath([], formState, _this2.validations) | ||
}; | ||
}, function () { | ||
_this2.initialValidationComplete = true; | ||
_this2.props.onValidation((0, _formState2.isValid)(_this2.state.formState)); | ||
}); | ||
} // Public API: submit from the outside | ||
}, { | ||
key: "submit", | ||
@@ -246,2 +533,4 @@ value: function submit(extraData) { | ||
value: function render() { | ||
var _this3 = this; | ||
var formState = this.state.formState; | ||
@@ -254,3 +543,10 @@ var metaForm = { | ||
value: _objectSpread({ | ||
shouldShowError: this.props.feedbackStrategy.bind(null, metaForm) | ||
shouldShowError: this.props.feedbackStrategy.bind(null, metaForm), | ||
registerValidation: this.handleRegisterValidation, | ||
updateTreeAtPath: function updateTreeAtPath(path, formState) { | ||
return _updateTreeAtPath(path, formState, _this3.validations); | ||
}, | ||
updateNodeAtPath: function updateNodeAtPath(path, formState) { | ||
return _updateNodeAtPath(path, formState, _this3.validations); | ||
} | ||
}, metaForm) | ||
@@ -261,3 +557,3 @@ }, this.props.children({ | ||
onBlur: this._handleBlur, | ||
onValidation: this._handleValidation | ||
path: [] | ||
}, this._handleSubmit, { | ||
@@ -286,3 +582,3 @@ touched: (0, _formState2.getExtras)(formState).meta.touched, | ||
feedbackStrategy: _feedbackStrategies.default.Always, | ||
serverErrors: null | ||
externalErrors: null | ||
}); |
@@ -12,12 +12,5 @@ "use strict"; | ||
exports.arrayChild = arrayChild; | ||
exports.validate = validate; | ||
exports.setChanged = setChanged; | ||
exports.setTouched = setTouched; | ||
exports.setClientErrors = setClientErrors; | ||
exports.setExtrasTouched = setExtrasTouched; | ||
exports.replaceObjectChild = replaceObjectChild; | ||
exports.replaceArrayChild = replaceArrayChild; | ||
exports.replaceArrayChildren = replaceArrayChildren; | ||
exports.monoidallyCombineFormStatesForValidation = monoidallyCombineFormStatesForValidation; | ||
exports.replaceServerErrors = replaceServerErrors; | ||
exports.isValid = isValid; | ||
@@ -31,6 +24,2 @@ | ||
var _invariant = _interopRequireDefault(require("./utils/invariant")); | ||
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } | ||
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } | ||
@@ -79,4 +68,4 @@ | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
if (errors.external !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.external); | ||
} | ||
@@ -103,65 +92,5 @@ | ||
function validate(validation, formState) { | ||
var _formState3 = _slicedToArray(formState, 2), | ||
value = _formState3[0], | ||
tree = _formState3[1]; | ||
var newErrors = validation(value); | ||
return [value, (0, _shapedTree.mapRoot)(function (_ref) { | ||
var meta = _ref.meta; | ||
return { | ||
errors: { | ||
client: newErrors, | ||
server: "unchecked" | ||
}, | ||
meta: _objectSpread({}, meta, { | ||
succeeded: meta.succeeded || newErrors.length === 0 | ||
}) | ||
}; | ||
}, tree)]; | ||
} | ||
function setChanged(formState) { | ||
return [formState[0], (0, _shapedTree.mapRoot)(function (_ref2) { | ||
var errors = _ref2.errors, | ||
meta = _ref2.meta; | ||
return { | ||
errors: errors, | ||
meta: _objectSpread({}, meta, { | ||
touched: true, | ||
changed: true | ||
}) | ||
}; | ||
}, formState[1])]; | ||
} | ||
function setTouched(formState) { | ||
return [formState[0], (0, _shapedTree.mapRoot)(function (_ref3) { | ||
var errors = _ref3.errors, | ||
meta = _ref3.meta; | ||
return { | ||
errors: errors, | ||
meta: _objectSpread({}, meta, { | ||
touched: true | ||
}) | ||
}; | ||
}, formState[1])]; | ||
} | ||
function setClientErrors(newErrors, formState) { | ||
return [formState[0], (0, _shapedTree.mapRoot)(function (_ref4) { | ||
var errors = _ref4.errors, | ||
meta = _ref4.meta; | ||
return { | ||
errors: _objectSpread({}, errors, { | ||
client: newErrors | ||
}), | ||
meta: meta | ||
}; | ||
}, formState[1])]; | ||
} | ||
function setExtrasTouched(_ref5) { | ||
var errors = _ref5.errors, | ||
meta = _ref5.meta; | ||
function setExtrasTouched(_ref) { | ||
var errors = _ref.errors, | ||
meta = _ref.meta; | ||
return { | ||
@@ -176,5 +105,5 @@ errors: errors, | ||
function replaceObjectChild(key, child, formState) { | ||
var _formState4 = _slicedToArray(formState, 2), | ||
value = _formState4[0], | ||
tree = _formState4[1]; | ||
var _formState3 = _slicedToArray(formState, 2), | ||
value = _formState3[0], | ||
tree = _formState3[1]; | ||
@@ -189,5 +118,5 @@ var _child = _slicedToArray(child, 2), | ||
function replaceArrayChild(index, child, formState) { | ||
var _formState5 = _slicedToArray(formState, 2), | ||
value = _formState5[0], | ||
tree = _formState5[1]; | ||
var _formState4 = _slicedToArray(formState, 2), | ||
value = _formState4[0], | ||
tree = _formState4[1]; | ||
@@ -199,80 +128,2 @@ var _child2 = _slicedToArray(child, 2), | ||
return [(0, _array.replaceAt)(index, childValue, value), (0, _shapedTree.dangerouslyReplaceArrayChild)(index, childTree, tree)]; | ||
} | ||
function replaceArrayChildren(children, formState) { | ||
var _formState6 = _slicedToArray(formState, 2), | ||
_ = _formState6[0], | ||
tree = _formState6[1]; | ||
var _children$reduce = children.reduce(function (memo, child) { | ||
var _child3 = _slicedToArray(child, 2), | ||
childValue = _child3[0], | ||
childTree = _child3[1]; | ||
return [memo[0].concat([childValue]), memo[1].concat([childTree])]; | ||
}, [[], []]), | ||
_children$reduce2 = _slicedToArray(_children$reduce, 2), | ||
childValues = _children$reduce2[0], | ||
childTrees = _children$reduce2[1]; | ||
return [childValues, (0, _shapedTree.dangerouslySetChildren)(childTrees, tree)]; | ||
} | ||
function combineExtrasForValidation(oldExtras, newExtras) { | ||
var oldMeta = oldExtras.meta, | ||
oldErrors = oldExtras.errors; | ||
var newMeta = newExtras.meta, | ||
newErrors = newExtras.errors; // Only asyncValidationInFlight + succeeded may change | ||
(0, _invariant.default)(oldMeta.touched === newMeta.touched, "Recieved a new meta.touched when monoidally combining errors"); | ||
(0, _invariant.default)(oldMeta.changed === newMeta.changed, "Recieved a new meta.changed when monoidally combining errors"); // No combination is possible if the old client errors are not pending | ||
if (oldErrors.client !== "pending") { | ||
return oldExtras; | ||
} // No combination is possible if the new client errors are pending | ||
if (newErrors.client === "pending") { | ||
return oldExtras; | ||
} | ||
return { | ||
meta: { | ||
touched: oldMeta.touched, | ||
changed: oldMeta.changed, | ||
succeeded: newMeta.succeeded, | ||
asyncValidationInFlight: oldMeta.asyncValidationInFlight || newMeta.asyncValidationInFlight | ||
}, | ||
errors: { | ||
client: newErrors.client, | ||
server: newErrors.server | ||
} | ||
}; | ||
} | ||
function monoidallyCombineTreesForValidation(oldTree, newTree) { | ||
return (0, _shapedTree.shapedZipWith)(combineExtrasForValidation, oldTree, newTree); | ||
} // Also sets asyncValidationInFlight | ||
function monoidallyCombineFormStatesForValidation(oldState, newState) { | ||
// Value should never change when combining errors | ||
(0, _invariant.default)(oldState[0] === newState[0], "Received a new value when monoidally combining errors"); | ||
return [oldState[0], monoidallyCombineTreesForValidation(oldState[1], newState[1])]; | ||
} | ||
function replaceServerErrorsExtra(newErrors, oldExtras) { | ||
var meta = oldExtras.meta, | ||
errors = oldExtras.errors; | ||
return { | ||
meta: meta, | ||
errors: { | ||
client: errors.client, | ||
server: newErrors | ||
} | ||
}; | ||
} | ||
function replaceServerErrors(serverErrors, formState) { | ||
return [formState[0], (0, _shapedTree.shapedZipWith)(replaceServerErrorsExtra, serverErrors, formState[1])]; | ||
} // Is whole tree client valid? | ||
@@ -286,4 +137,4 @@ // TODO(zach): This will have to change with asynchronous validations. We will | ||
function isValid(formState) { | ||
return (0, _shapedTree.foldMapShapedTree)(function (_ref6) { | ||
var client = _ref6.errors.client; | ||
return (0, _shapedTree.foldMapShapedTree)(function (_ref2) { | ||
var client = _ref2.errors.client; | ||
return client === "pending" || client.length === 0; | ||
@@ -290,0 +141,0 @@ }, true, function (l, r) { |
@@ -12,3 +12,3 @@ "use strict"; | ||
var _formState3 = require("./formState"); | ||
var _formState2 = require("./formState"); | ||
@@ -21,10 +21,2 @@ var _shapedTree = require("./shapedTree"); | ||
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } | ||
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } | ||
function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } | ||
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } | ||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } | ||
@@ -50,2 +42,10 @@ | ||
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } | ||
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } | ||
function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } | ||
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } | ||
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } | ||
@@ -59,3 +59,3 @@ | ||
function makeLinks(formState, onChildChange, onChildBlur, onChildValidation) { | ||
function makeLinks(path, formState, onChildChange, onChildBlur) { | ||
var _formState = _slicedToArray(formState, 1), | ||
@@ -66,3 +66,3 @@ value = _formState[0]; | ||
var l = { | ||
formState: (0, _formState3.objectChild)(k, formState), | ||
formState: (0, _formState2.objectChild)(k, formState), | ||
onChange: function onChange(childFormState) { | ||
@@ -74,5 +74,6 @@ onChildChange(k, childFormState); | ||
}, | ||
onValidation: function onValidation(path, errors) { | ||
onChildValidation(k, path, errors); | ||
} | ||
path: [].concat(_toConsumableArray(path), [{ | ||
type: "object", | ||
key: k | ||
}]) | ||
}; | ||
@@ -102,8 +103,6 @@ memo[k] = l; | ||
_defineProperty(_assertThisInitialized(_this), "state", { | ||
nonce: 0 | ||
}); | ||
_defineProperty(_assertThisInitialized(_this), "validationFnOps", (0, _Form.validationFnNoOps)()); | ||
_defineProperty(_assertThisInitialized(_this), "_handleChildChange", function (key, newChild) { | ||
var newFormState = (0, _formState3.replaceObjectChild)(key, newChild, _this.props.link.formState); | ||
var newFormState = (0, _formState2.replaceObjectChild)(key, newChild, _this.props.link.formState); | ||
var oldValue = _this.props.link.formState[0]; | ||
@@ -114,18 +113,13 @@ var newValue = newFormState[0]; | ||
var nextFormState; | ||
var validatedFormState; | ||
if (customValue) { | ||
// Create a fresh form state for the new value. | ||
// TODO(zach): It's kind of gross that this is happening outside of Form. | ||
nextFormState = (0, _formState3.changedFormState)(customValue); | ||
// A custom change occurred, which means the whole object needs to be | ||
// revalidated. | ||
validatedFormState = _this.context.updateTreeAtPath(_this.props.link.path, [customValue, newFormState[1]]); | ||
} else { | ||
nextFormState = newFormState; | ||
validatedFormState = _this.context.updateNodeAtPath(_this.props.link.path, newFormState); | ||
} | ||
_this.props.link.onChange((0, _formState3.setChanged)((0, _formState3.validate)(_this.props.validation, nextFormState))); // Need to remount children so they will run validations | ||
if (customValue) { | ||
_this.forceChildRemount(); | ||
} | ||
_this.props.link.onChange(validatedFormState); | ||
}); | ||
@@ -138,14 +132,5 @@ | ||
_this.props.link.onBlur((0, _shapedTree.mapRoot)(_formState3.setExtrasTouched, (0, _shapedTree.dangerouslyReplaceObjectChild)(key, childTree, tree))); | ||
_this.props.link.onBlur((0, _shapedTree.mapRoot)(_formState2.setExtrasTouched, (0, _shapedTree.dangerouslyReplaceObjectChild)(key, childTree, tree))); | ||
}); | ||
_defineProperty(_assertThisInitialized(_this), "_handleChildValidation", function (key, childPath, errors) { | ||
var extendedPath = [{ | ||
type: "object", | ||
key: key | ||
}].concat(_toConsumableArray(childPath)); | ||
_this.props.link.onValidation(extendedPath, errors); | ||
}); | ||
return _this; | ||
@@ -155,36 +140,20 @@ } | ||
_createClass(ObjectField, [{ | ||
key: "_initialValidate", | ||
value: function _initialValidate() { | ||
var _this$props = this.props, | ||
_this$props$link = _this$props.link, | ||
formState = _this$props$link.formState, | ||
onValidation = _this$props$link.onValidation, | ||
validation = _this$props.validation; | ||
var _formState2 = _slicedToArray(formState, 1), | ||
value = _formState2[0]; | ||
var _getExtras = (0, _formState3.getExtras)(formState), | ||
errors = _getExtras.errors; | ||
if (errors.client === "pending") { | ||
onValidation([], validation(value)); | ||
} | ||
} | ||
}, { | ||
key: "componentDidMount", | ||
value: function componentDidMount() { | ||
this._initialValidate(); | ||
this.validationFnOps = this.context.registerValidation(this.props.link.path, this.props.validation); | ||
} | ||
}, { | ||
key: "forceChildRemount", | ||
value: function forceChildRemount() { | ||
this.setState(function (_ref) { | ||
var nonce = _ref.nonce; | ||
return { | ||
nonce: nonce + 1 | ||
}; | ||
}); | ||
key: "componentDidUpdate", | ||
value: function componentDidUpdate(prevProps) { | ||
if (prevProps.validation !== this.props.validation) { | ||
this.validationFnOps.replace(this.props.validation); | ||
} | ||
} | ||
}, { | ||
key: "componentWillUnmount", | ||
value: function componentWillUnmount() { | ||
this.validationFnOps.unregister(); | ||
this.validationFnOps = (0, _Form.validationFnNoOps)(); | ||
} | ||
}, { | ||
key: "render", | ||
@@ -194,13 +163,11 @@ value: function render() { | ||
var shouldShowError = this.context.shouldShowError; | ||
var links = makeLinks(this.props.link.formState, this._handleChildChange, this._handleChildBlur, this._handleChildValidation); | ||
return React.createElement(React.Fragment, { | ||
key: this.state.nonce | ||
}, this.props.children(links, { | ||
touched: (0, _formState3.getExtras)(formState).meta.touched, | ||
changed: (0, _formState3.getExtras)(formState).meta.changed, | ||
shouldShowErrors: shouldShowError((0, _formState3.getExtras)(formState).meta), | ||
unfilteredErrors: (0, _formState3.flatRootErrors)(formState), | ||
var links = makeLinks(this.props.link.path, this.props.link.formState, this._handleChildChange, this._handleChildBlur); | ||
return React.createElement(React.Fragment, null, this.props.children(links, { | ||
touched: (0, _formState2.getExtras)(formState).meta.touched, | ||
changed: (0, _formState2.getExtras)(formState).meta.changed, | ||
shouldShowErrors: shouldShowError((0, _formState2.getExtras)(formState).meta), | ||
unfilteredErrors: (0, _formState2.flatRootErrors)(formState), | ||
asyncValidationInFlight: false, | ||
// no validations on Form | ||
valid: (0, _formState3.isValid)(formState), | ||
valid: (0, _formState2.isValid)(formState), | ||
value: formState[0] | ||
@@ -207,0 +174,0 @@ })); |
@@ -8,4 +8,4 @@ "use strict"; | ||
exports.shapePath = shapePath; | ||
exports.pathExistsInTree = pathExistsInTree; | ||
exports.updateAtPath = updateAtPath; | ||
exports.checkShape = checkShape; | ||
exports.shapedArrayChild = shapedArrayChild; | ||
@@ -19,4 +19,2 @@ exports.shapedArrayChildren = shapedArrayChildren; | ||
exports.dangerouslySetChildren = dangerouslySetChildren; | ||
exports.shapedLeaf = shapedLeaf; | ||
exports.shapedZipWith = shapedZipWith; | ||
exports.mapShapedTree = mapShapedTree; | ||
@@ -35,6 +33,2 @@ exports.foldMapShapedTree = foldMapShapedTree; | ||
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } | ||
function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } | ||
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } | ||
@@ -124,4 +118,51 @@ | ||
function pathExistsInTree(path, tree) { | ||
// This function is equivalent to: | ||
// try { | ||
// updateAtPath(path, x => x, tree); | ||
// } catch { | ||
// return false; | ||
// } | ||
// return true; | ||
// } | ||
if (path.length === 0 && tree.type != null) { | ||
return true; | ||
} | ||
var _path2 = _toArray(path), | ||
first = _path2[0], | ||
rest = _path2.slice(1); | ||
if (tree.type === "leaf") { | ||
return false; | ||
} | ||
if (tree.type === "array") { | ||
if (first.type !== "array") { | ||
return false; | ||
} | ||
if (!(0 <= first.index && first.index < tree.children.length)) { | ||
return false; | ||
} | ||
return pathExistsInTree(rest, tree.children[first.index]); | ||
} | ||
if (tree.type === "object") { | ||
if (first.type !== "object") { | ||
return false; | ||
} | ||
if (!(first.key in tree.children)) { | ||
return false; | ||
} | ||
return pathExistsInTree(rest, tree.children[first.key]); | ||
} | ||
throw new Error("unreachable"); | ||
} | ||
function updateAtPath(path, updater, tree) { | ||
// console.log("updateAtPath()", path, tree); | ||
if (path.length === 0) { | ||
@@ -150,5 +191,5 @@ if (tree.type === "object") { | ||
var _path2 = _toArray(path), | ||
firstStep = _path2[0], | ||
restStep = _path2.slice(1); | ||
var _path3 = _toArray(path), | ||
firstStep = _path3[0], | ||
restStep = _path3.slice(1); | ||
@@ -161,2 +202,3 @@ if (tree.type === "leaf") { | ||
(0, _invariant.default)(firstStep.type === "array", "Trying to take a non-array path into an array"); | ||
(0, _invariant.default)(0 <= firstStep.index && firstStep.index < tree.children.length, "Tried to take path index ".concat(firstStep.index, " but array from tree has length ").concat(tree.children.length)); | ||
var newChild = updateAtPath(restStep, updater, tree.children[firstStep.index]); // $FlowFixMe(zach): I think this is safe, might need GADTs for the type checker to understand why | ||
@@ -169,4 +211,6 @@ | ||
(0, _invariant.default)(firstStep.type === "object", "Trying to take a non-object path into an object"); | ||
var nextTree = tree.children[firstStep.key]; | ||
(0, _invariant.default)(nextTree !== undefined, "Tried to take path key ".concat(firstStep.key, " but it isn't present on object in tree")); | ||
var _newChild = updateAtPath(restStep, updater, tree.children[firstStep.key]); // $FlowFixMe(zach): I think this is safe, might need GADTs for the type checker to understand why | ||
var _newChild = updateAtPath(restStep, updater, nextTree); // $FlowFixMe(zach): I think this is safe, might need GADTs for the type checker to understand why | ||
@@ -180,30 +224,2 @@ | ||
function checkShape(value, tree) { | ||
if (tree.type === "array") { | ||
(0, _invariant.default)(Array.isArray(value), "value isn't an array"); | ||
(0, _invariant.default)(value.length === tree.children.length, "value and tree children have different lengths"); | ||
tree.children.forEach(function (child, i) { | ||
checkShape(value[i], child); | ||
}); | ||
} | ||
if (tree.type === "object") { | ||
(0, _invariant.default)(value instanceof Object, "value isn't an object in checkTree"); | ||
var valueEntries = Object.entries(value); | ||
var childrenKeys = new Set(Object.keys(tree.children)); | ||
(0, _invariant.default)(valueEntries.length === childrenKeys.size, "value doesn't have the right number of keys"); | ||
valueEntries.forEach(function (_ref) { | ||
var _ref2 = _slicedToArray(_ref, 2), | ||
key = _ref2[0], | ||
value = _ref2[1]; | ||
(0, _invariant.default)(childrenKeys.has(key)); | ||
checkShape(value, tree.children[key]); | ||
}); | ||
} // leaves are allowed to stand in for complex types in T | ||
return tree; | ||
} | ||
function shapedArrayChild(index, tree) { | ||
@@ -285,12 +301,2 @@ (0, _invariant.default)(tree.type === "array", "Tried to get an array child of a non-array node"); | ||
}; | ||
} // A leaf matches any shape | ||
function shapedLeaf(node) { | ||
return (0, _tree.leaf)(node); | ||
} | ||
function shapedZipWith(f, left, right) { | ||
// Don't actually need the checks here if our invariant holds | ||
return (0, _tree.strictZipWith)(f, left, right); | ||
} // Mapping doesn't change the shape | ||
@@ -297,0 +303,0 @@ |
@@ -7,2 +7,4 @@ "use strict"; | ||
var _Form = _interopRequireDefault(require("../Form")); | ||
var _ArrayField = _interopRequireDefault(require("../ArrayField")); | ||
@@ -12,2 +14,4 @@ | ||
var _TestForm = _interopRequireDefault(require("./TestForm")); | ||
var _tools = require("./tools"); | ||
@@ -28,121 +32,104 @@ | ||
describe("ArrayField", function () { | ||
describe("ArrayField is a field", function () { | ||
describe("validates on mount", function () { | ||
it("ensures that the link inner type matches the type of the validation", function () { | ||
var formState = (0, _tools.mockFormState)(["one", "two", "three"]); | ||
var link = (0, _tools.mockLink)(formState); // $ExpectError | ||
describe("is a field", function () { | ||
it("ensures that the link inner type matches the type of the validation", function () { | ||
var formState = (0, _tools.mockFormState)(["one", "two", "three"]); | ||
var link = (0, _tools.mockLink)(formState); // $ExpectError | ||
React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are no errors", function () { | ||
var validation = jest.fn(function () { | ||
React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
}); | ||
var formState = (0, _tools.mockFormState)([]); | ||
var link = (0, _tools.mockLink)(formState); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: validation | ||
}, jest.fn(function () { | ||
return null; | ||
}))); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var _link$onValidation$mo = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo[0], | ||
errors = _link$onValidation$mo[1]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are errors", function () { | ||
var validation = jest.fn(function () { | ||
return ["This is an error", "another error"]; | ||
}); | ||
var formState = (0, _tools.mockFormState)([]); | ||
var link = (0, _tools.mockLink)(formState); | ||
}); | ||
it("Registers and unregisters for validation", function () { | ||
var formState = (0, _tools.mockFormState)([]); | ||
var link = (0, _tools.mockLink)(formState); | ||
var unregister = jest.fn(); | ||
var registerValidation = jest.fn(function () { | ||
return { | ||
replace: jest.fn(), | ||
unregister: unregister | ||
}; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: validation | ||
}, jest.fn(function () { | ||
return null; | ||
}))); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_TestForm.default, { | ||
registerValidation: registerValidation | ||
}, React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: function validation() { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}))); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var _link$onValidation$mo2 = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo2[0], | ||
errors = _link$onValidation$mo2[1]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual(["This is an error", "another error"]); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.unmount(); | ||
expect(unregister).toBeCalledTimes(1); | ||
}); | ||
it("calls replace when changing the validation function", function () { | ||
var replace = jest.fn(); | ||
var registerValidation = jest.fn(function () { | ||
return { | ||
replace: replace, | ||
unregister: jest.fn() | ||
}; | ||
}); | ||
it("Treats no validation as always passing", function () { | ||
var formState = (0, _tools.mockFormState)([]); | ||
var link = (0, _tools.mockLink)(formState); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link | ||
}, jest.fn(function () { | ||
function Component() { | ||
return React.createElement(_TestForm.default, { | ||
registerValidation: registerValidation | ||
}, React.createElement(_ArrayField.default, { | ||
link: (0, _tools.mockLink)((0, _tools.mockFormState)(["hello", "world"])), | ||
validation: function validation() { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}))); | ||
})); | ||
} | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(Component, null)); | ||
var _link$onValidation$mo3 = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo3[0], | ||
errors = _link$onValidation$mo3[1]; | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.update(React.createElement(Component, null)); | ||
expect(replace).toBeCalledTimes(1); | ||
}); | ||
it("Passes additional information to its render function", function () { | ||
var formState = (0, _tools.mockFormState)(["value"]); // $FlowFixMe | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
formState[1].data.errors = { | ||
external: ["An external error"], | ||
client: ["A client error"] | ||
}; | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
it("Passes additional information to its render function", function () { | ||
var formState = (0, _tools.mockFormState)(["value"]); // $FlowFixMe | ||
formState[1].data.errors = { | ||
server: ["A server error"], | ||
client: ["A client error"] | ||
}; | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link | ||
}, renderFn)); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining(["A server error", "A client error"]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: ["value"] | ||
})); | ||
}); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining(["An external error", "A client error"]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: ["value"] | ||
})); | ||
}); | ||
@@ -184,5 +171,4 @@ }); | ||
}); | ||
it("calls onChange when a child changes", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
it("validates new values from children and passes result to onChange", function () { | ||
var formState = (0, _tools.mockFormState)(["one", "two", "three"]); | ||
var link = (0, _tools.mockLink)(formState); | ||
@@ -192,18 +178,22 @@ var renderFn = jest.fn(function () { | ||
}); | ||
var updateNodeAtPath = jest.fn(function (path, formState) { | ||
return formState; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
_reactTestRenderer.default.create(React.createElement(_TestForm.default, { | ||
updateNodeAtPath: updateNodeAtPath | ||
}, React.createElement(_ArrayField.default, { | ||
link: link | ||
}, renderFn)); | ||
}, renderFn))); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(0); | ||
expect(link.onChange).toHaveBeenCalledTimes(0); // call a child's onChange | ||
var arrayLinks = renderFn.mock.calls[0][0]; | ||
var newElementFormState = (0, _tools.mockFormState)("newTwo"); | ||
arrayLinks[1].onChange(newElementFormState); | ||
expect(link.onChange).toHaveBeenCalled(); | ||
var newArrayFormState = link.onChange.mock.calls[0][0]; | ||
expect(newArrayFormState[0]).toEqual(["one", "newTwo", "three"]); | ||
expect(newArrayFormState[1].data.meta).toMatchObject({ | ||
touched: true, | ||
changed: true | ||
}); | ||
expect(newArrayFormState[1].children[1]).toBe(newElementFormState[1]); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(1); | ||
expect(updateNodeAtPath).toHaveBeenCalledWith([], [["one", "newTwo", "three"], expect.anything()]); | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([["one", "newTwo", "three"], formState[1]]); | ||
}); | ||
@@ -233,32 +223,3 @@ it("calls onBlur when a child is blurred", function () { | ||
}); | ||
it("calls onValidation when a child initially validates", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link | ||
}, renderFn)); | ||
var arrayLinks = renderFn.mock.calls[0][0]; | ||
arrayLinks[2].onValidation([], ["These are", "some errors"]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(2); // Important: the first call to onValidation is for the initial render validation | ||
var _link$onValidation$mo4 = _slicedToArray(link.onValidation.mock.calls[1], 2), | ||
path = _link$onValidation$mo4[0], | ||
errors = _link$onValidation$mo4[1]; | ||
expect(path).toEqual([{ | ||
type: "array", | ||
index: 2 | ||
}]); | ||
expect(errors).toEqual(["These are", "some errors"]); | ||
}); | ||
it("calls its validation when a child changes", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -271,6 +232,10 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: validation | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: validation | ||
}, renderFn); | ||
})); | ||
@@ -304,5 +269,2 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
it("validates after entry is added", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -315,6 +277,10 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn); | ||
})); | ||
@@ -350,8 +316,14 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
it("validates after entry is removed", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
var elementValidation = jest.fn(function () { | ||
return ["an element error"]; | ||
}); | ||
var renderFn = jest.fn(function (links) { | ||
return links.map(function (link, i) { | ||
return React.createElement(_TestField.default, { | ||
key: i, | ||
link: link, | ||
validation: elementValidation | ||
}); | ||
}); | ||
}); | ||
var validation = jest.fn(function () { | ||
@@ -361,6 +333,10 @@ return ["an error"]; | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn); | ||
})); | ||
@@ -396,5 +372,2 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
it("validates after the entry is moved", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -407,6 +380,10 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn); | ||
})); | ||
@@ -442,5 +419,2 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
it("validates after fields are added", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -453,6 +427,10 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn); | ||
})); | ||
@@ -488,5 +466,2 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
it("validates after fields are filtered", function () { | ||
var formStateValue = ["one", "two", "three", "four", "five"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -499,6 +474,10 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn); | ||
})); | ||
@@ -537,5 +516,2 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
it("validates after fields are modified", function () { | ||
var formStateValue = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateValue); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -548,6 +524,10 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link | ||
}, renderFn); | ||
})); | ||
@@ -572,3 +552,3 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
describe("customChange", function () { | ||
it("allows the default change behavior to be overwritten with customChange", function () { | ||
it("allows sibling fields to be overwritten", function () { | ||
var formStateInner = ["one", "two", "three"]; | ||
@@ -587,7 +567,7 @@ var formState = (0, _tools.mockFormState)(formStateInner); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
_reactTestRenderer.default.create(React.createElement(_TestForm.default, null, React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: validation, | ||
customChange: customChange | ||
}, renderFn)); | ||
}, renderFn))); | ||
@@ -603,11 +583,8 @@ var arrayLinks = renderFn.mock.calls[0][0]; // call the child onChange | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([["uno", "dos", "tres"], expect.anything()]); // Validated the result of customChange | ||
expect(validation).toHaveBeenCalledTimes(2); | ||
expect(validation.mock.calls[1][0]).toEqual(["uno", "dos", "tres"]); | ||
expect(link.onChange).toHaveBeenCalledWith([["uno", "dos", "tres"], expect.anything()]); | ||
}); | ||
it("can return null to signal there was no custom change", function () { | ||
var formStateInner = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
return null; | ||
}); | ||
var renderFn = jest.fn(function () { | ||
@@ -617,13 +594,14 @@ return null; | ||
var validation = jest.fn(function () { | ||
return ["This is an error"]; | ||
return ["an error"]; | ||
}); | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
return null; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link, | ||
validation: validation, | ||
customChange: customChange | ||
}, renderFn)); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["one", "two", "three"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
validation: validation, | ||
link: link, | ||
customChange: customChange | ||
}, renderFn); | ||
})); | ||
@@ -636,4 +614,4 @@ var arrayLinks = renderFn.mock.calls[0][0]; // call the child onChange | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([["one", "zwei", "three"], expect.anything()]); // Validated the result of customChange | ||
var link = renderer.root.findByType(_ArrayField.default).instance.props.link; | ||
expect(link.formState).toEqual([["one", "zwei", "three"], expect.anything()]); // Validated the result of customChange | ||
@@ -644,5 +622,2 @@ expect(validation).toHaveBeenCalledTimes(2); | ||
it("doesn't break validations for child fields", function () { | ||
var formStateInner = ["one", "two", "three"]; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
@@ -654,93 +629,66 @@ return ["1", "2"]; | ||
}); | ||
var parentValidation = jest.fn(function () { | ||
return ["This is an error from the parent"]; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: ["1", "2"] | ||
}, function (link) { | ||
return React.createElement(_ArrayField.default, { | ||
link: link, | ||
customChange: customChange, | ||
validation: parentValidation | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, links.map(function (link, i) { | ||
return React.createElement(_TestField.default, { | ||
key: i, | ||
link: link, | ||
validation: childValidation | ||
}); | ||
})); | ||
}); | ||
})); // after mount, validate everything | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(2); // Now change one of the values | ||
parentValidation.mockClear(); | ||
childValidation.mockClear(); | ||
var inner = renderer.root.findAllByType(_TestField.TestInput)[0]; | ||
inner.instance.change("zach"); // Validate the whole subtree due to the customChange child validates | ||
// once. Note that child validation will be called 3 times. Once after the | ||
// change, then twice more after the customChange triggers a validation fo | ||
// the entire subtree. | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(1 + 2); | ||
var link = renderer.root.findByType(_ArrayField.default).instance.props.link; | ||
expect(link.formState).toEqual([["1", "2"], expect.anything()]); | ||
}); | ||
it("doesn't create a new instance (i.e. remount)", function () { | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
return ["uno", "dos"]; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_ArrayField.default, { | ||
link: link, | ||
link: (0, _tools.mockLink)((0, _tools.mockFormState)(["1", "2"])), | ||
customChange: customChange | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, links.map(function (link, i) { | ||
return React.createElement(_TestField.default, { | ||
key: i, | ||
link: link, | ||
validation: childValidation | ||
}); | ||
})); | ||
})); // 6 validations: | ||
// 1) Child initial validation x3 | ||
// 2) Parent initial validation | ||
// 3) Child validation on remount x3 | ||
// (No parent onValidation call, because it will use onChange) | ||
// 1) and 2) | ||
return React.createElement(_TestField.default, { | ||
link: links[0] | ||
}); | ||
})); | ||
var testInstance = renderer.root.findAllByType(_TestField.TestInput)[0].instance; // now trigger a customChange, which used to cause a remount | ||
expect(link.onValidation).toHaveBeenCalledTimes(4); | ||
link.onValidation.mockClear(); | ||
var inner = renderer.root.findAllByType(_TestField.TestInput)[0]; | ||
inner.instance.change("zach"); // 3) | ||
testInstance.change("hi"); | ||
expect(customChange).toHaveBeenCalledTimes(1); // but we no longer cause a remount, so the instances should be the same | ||
expect(link.onValidation).toHaveBeenCalledTimes(3); | ||
expect(link.onValidation).toHaveBeenCalledWith([{ | ||
type: "array", | ||
index: 0 | ||
}], ["This is an error"]); | ||
expect(link.onValidation).toHaveBeenCalledWith([{ | ||
type: "array", | ||
index: 1 | ||
}], ["This is an error"]); // NOTE(zach): This may be surprising since there are only two values in | ||
// the new value, but there is no guarantee that the next commit will | ||
// have occurred yet. | ||
var nextTestInstance = renderer.root.findAllByType(_TestField.TestInput)[0].instance; // Using Object.is here because toBe hangs as the objects are | ||
// self-referential and thus not printable | ||
expect(link.onValidation).toHaveBeenCalledWith([{ | ||
type: "array", | ||
index: 2 | ||
}], ["This is an error"]); // onChange should be called with the result of customChange | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([["1", "2"], { | ||
type: "array", | ||
data: { | ||
errors: { | ||
client: [], | ||
server: "unchecked" | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: true, | ||
asyncValidationInFlight: false | ||
} | ||
}, | ||
children: [{ | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked" | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false | ||
} | ||
} | ||
}, { | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked" | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false | ||
} | ||
} | ||
}] | ||
}]); | ||
expect(Object.is(testInstance, nextTestInstance)).toBe(true); | ||
}); | ||
}); | ||
}); |
@@ -13,2 +13,4 @@ "use strict"; | ||
var _TestForm = _interopRequireDefault(require("./TestForm")); | ||
var _shapedTree = require("../shapedTree"); | ||
@@ -24,120 +26,99 @@ | ||
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } | ||
function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } | ||
function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } | ||
function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } | ||
describe("Field", function () { | ||
describe("validates on mount", function () { | ||
it("ensures that the link inner type matches the type of the validation", function () { | ||
var formState = (0, _tools.mockFormState)("Hello world."); | ||
var link = (0, _tools.mockLink)(formState); // $ExpectError | ||
it("ensures that the link inner type matches the type of the validation", function () { | ||
var formState = (0, _tools.mockFormState)("Hello world."); | ||
var link = (0, _tools.mockLink)(formState); // $ExpectError | ||
React.createElement(_Field.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
React.createElement(_Field.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
React.createElement(_Field.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are no errors", function () { | ||
var formState = (0, _tools.mockFormState)("Hello world."); | ||
var link = (0, _tools.mockLink)(formState); | ||
var validation = jest.fn(function () { | ||
React.createElement(_Field.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_TestField.default, { | ||
link: link, | ||
validation: validation | ||
})); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var _link$onValidation$mo = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo[0], | ||
errors = _link$onValidation$mo[1]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are errors", function () { | ||
var formState = (0, _tools.mockFormState)("Hello world."); | ||
var link = (0, _tools.mockLink)(formState); | ||
var validation = jest.fn(function () { | ||
return ["This is an error"]; | ||
}); | ||
}); | ||
it("registers and unregisters for validation", function () { | ||
var formState = (0, _tools.mockFormState)("Hello world."); | ||
var link = (0, _tools.mockLink)(formState); | ||
var unregister = jest.fn(); | ||
var registerValidation = jest.fn(function () { | ||
return { | ||
replace: jest.fn(), | ||
unregister: unregister | ||
}; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_TestField.default, { | ||
link: link, | ||
validation: validation | ||
})); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_TestForm.default, { | ||
registerValidation: registerValidation | ||
}, React.createElement(_Field.default, { | ||
link: link, | ||
validation: jest.fn(function () { | ||
return []; | ||
}) | ||
}, jest.fn(function () { | ||
return null; | ||
})))); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var _link$onValidation$mo2 = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo2[0], | ||
errors = _link$onValidation$mo2[1]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual(["This is an error"]); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.unmount(); | ||
expect(unregister).toBeCalledTimes(1); | ||
}); | ||
it("calls replace when changing the validation function", function () { | ||
var replace = jest.fn(); | ||
var registerValidation = jest.fn(function () { | ||
return { | ||
replace: replace, | ||
unregister: jest.fn() | ||
}; | ||
}); | ||
it("Counts as successfully validated if there is no validation", function () { | ||
var formState = (0, _tools.mockFormState)("Hello world."); | ||
var link = (0, _tools.mockLink)(formState); | ||
_reactTestRenderer.default.create(React.createElement(_TestField.default, { | ||
link: link | ||
function Component() { | ||
return React.createElement(_TestForm.default, { | ||
registerValidation: registerValidation | ||
}, React.createElement(_Field.default, { | ||
link: (0, _tools.mockLink)((0, _tools.mockFormState)("Hello world.")), | ||
validation: function validation() { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
})); | ||
} | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(Component, null)); | ||
var _link$onValidation$mo3 = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo3[0], | ||
errors = _link$onValidation$mo3[1]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.update(React.createElement(Component, null)); | ||
expect(replace).toBeCalledTimes(1); | ||
}); | ||
it("calls the link onChange with new values and correct meta", function () { | ||
it("validates new values and passes result to onChange", function () { | ||
var formState = (0, _tools.mockFormState)("Hello world."); | ||
var link = (0, _tools.mockLink)(formState); | ||
var updateNodeAtPath = jest.fn(function (path, formState) { | ||
return formState; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_TestField.default, { | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_TestForm.default, { | ||
updateNodeAtPath: updateNodeAtPath | ||
}, React.createElement(_TestField.default, { | ||
link: link | ||
})); | ||
}))); | ||
var inner = renderer.root.findByType(_TestField.TestInput); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(0); | ||
expect(link.onChange).toHaveBeenCalledTimes(0); | ||
inner.instance.change("You've got mail"); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(1); | ||
expect(updateNodeAtPath).toHaveBeenCalledWith([], ["You've got mail", expect.anything()]); | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
var _link$onChange$mock$c = _slicedToArray(link.onChange.mock.calls[0][0], 2), | ||
value = _link$onChange$mock$c[0], | ||
tree = _link$onChange$mock$c[1]; | ||
expect(value).toBe("You've got mail"); | ||
expect(tree.data).toMatchObject({ | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: true | ||
} | ||
}); | ||
expect(link.onChange).toHaveBeenCalledWith(["You've got mail", formState[1]]); | ||
}); | ||
@@ -171,3 +152,3 @@ it("calls the link onBlur with correct meta", function () { | ||
client: ["Some", "client", "errors"], | ||
server: ["Server errors", "go here"] | ||
external: ["External errors", "go here"] | ||
} | ||
@@ -183,3 +164,3 @@ }); | ||
var inner = renderer.root.findByType(_TestField.TestInput); | ||
expect(inner.props.errors).toEqual(["Some", "client", "errors", "Server errors", "go here"]); | ||
expect(inner.props.errors).toEqual(["Some", "client", "errors", "External errors", "go here"]); | ||
}); | ||
@@ -224,3 +205,3 @@ it("Passes value of the right type to its render function", function () { | ||
formState[1].data.errors = { | ||
server: ["A server error"], | ||
external: ["An external error"], | ||
client: ["A client error"] | ||
@@ -242,3 +223,3 @@ }; | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining(["A server error", "A client error"]), | ||
unfilteredErrors: expect.arrayContaining(["An external error", "A client error"]), | ||
valid: false, | ||
@@ -245,0 +226,0 @@ asyncValidationInFlight: false, |
@@ -21,2 +21,4 @@ "use strict"; | ||
var _LinkTap = _interopRequireDefault(require("../testutils/LinkTap")); | ||
var _shapedTree = require("../shapedTree"); | ||
@@ -28,2 +30,10 @@ | ||
function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } | ||
function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } | ||
function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } | ||
function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } } | ||
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } | ||
@@ -93,4 +103,268 @@ | ||
describe("Form", function () { | ||
describe("validations", function () { | ||
it("runs validations", function () { | ||
var objectValidation = jest.fn(function () { | ||
return []; | ||
}); | ||
var arrayValidation = jest.fn(function () { | ||
return []; | ||
}); | ||
var arrayElValidation = jest.fn(function () { | ||
return []; | ||
}); | ||
var fieldValidation = jest.fn(function () { | ||
return []; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
a: ["1", "2"], | ||
s: "string" | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: objectValidation | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, React.createElement(_ArrayField.default, { | ||
link: links.a, | ||
validation: arrayValidation | ||
}, function (links) { | ||
return links.map(function (link, i) { | ||
return React.createElement(_TestField.default, { | ||
key: i, | ||
link: link, | ||
validation: arrayElValidation | ||
}); | ||
}); | ||
}), React.createElement(_TestField.default, { | ||
link: links.s, | ||
validation: fieldValidation | ||
})); | ||
}); | ||
})); | ||
expect(objectValidation).toHaveBeenCalledTimes(1); | ||
expect(objectValidation).toHaveBeenCalledWith({ | ||
a: ["1", "2"], | ||
s: "string" | ||
}); | ||
expect(arrayValidation).toHaveBeenCalledTimes(1); | ||
expect(arrayValidation).toHaveBeenCalledWith(["1", "2"]); | ||
expect(arrayElValidation).toHaveBeenCalledTimes(2); | ||
expect(arrayElValidation).toHaveBeenCalledWith("1"); | ||
expect(arrayElValidation).toHaveBeenCalledWith("2"); | ||
expect(fieldValidation).toHaveBeenCalledTimes(1); | ||
expect(fieldValidation).toHaveBeenCalledWith("string"); | ||
}); | ||
it("sets validation information on formState", function () { | ||
var objectValidation = jest.fn(function () { | ||
return ["object error"]; | ||
}); | ||
var arrayValidation = jest.fn(function () { | ||
return ["array", "error"]; | ||
}); | ||
var arrayElValidation = jest.fn(function (s) { | ||
return ["error ".concat(s)]; | ||
}); | ||
var fieldValidation = jest.fn(function () { | ||
return []; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
a: ["1", "2"], | ||
s: "string" | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: objectValidation | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, React.createElement(_ArrayField.default, { | ||
link: links.a, | ||
validation: arrayValidation | ||
}, function (links) { | ||
return links.map(function (link, i) { | ||
return React.createElement(_TestField.default, { | ||
key: i, | ||
link: link, | ||
validation: arrayElValidation | ||
}); | ||
}); | ||
}), React.createElement(_TestField.default, { | ||
link: links.s, | ||
validation: fieldValidation | ||
})); | ||
}); | ||
})); | ||
var formState = renderer.root.findByType(_ObjectField.default).instance.props.link.formState; | ||
var node = formState[1]; | ||
expect(node.data.errors.client).toEqual(["object error"]); | ||
expect(node.data.meta.succeeded).toBe(false); | ||
node = node.children.a; | ||
expect(node.data.errors.client).toEqual(["array", "error"]); | ||
expect(node.data.meta.succeeded).toBe(false); | ||
var child0 = node.children[0]; | ||
expect(child0.data.errors.client).toEqual(["error 1"]); | ||
expect(child0.data.meta.succeeded).toBe(false); | ||
var child1 = node.children[1]; | ||
expect(child1.data.errors.client).toEqual(["error 2"]); | ||
expect(child1.data.meta.succeeded).toBe(false); | ||
node = formState[1].children.s; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
}); | ||
it("treats no validation as always passing", function () { | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
a: ["1", "2"], | ||
s: "string" | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, React.createElement(_ArrayField.default, { | ||
link: links.a | ||
}, function (links) { | ||
return links.map(function (link, i) { | ||
return React.createElement(_TestField.default, { | ||
key: i, | ||
link: link | ||
}); | ||
}); | ||
}), React.createElement(_TestField.default, { | ||
link: links.s | ||
})); | ||
}); | ||
})); | ||
var formState = renderer.root.findByType(_ObjectField.default).instance.props.link.formState; | ||
var node = formState[1]; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
node = node.children.a; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
var child0 = node.children[0]; | ||
expect(child0.data.errors.client).toEqual([]); | ||
expect(child0.data.meta.succeeded).toBe(true); | ||
var child1 = node.children[1]; | ||
expect(child1.data.errors.client).toEqual([]); | ||
expect(child1.data.meta.succeeded).toBe(true); | ||
node = formState[1].children.s; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
}); | ||
it("validates newly mounted Fields", function () { | ||
function expectClientErrors(link) { | ||
var tree = link.formState[1]; | ||
return expect((0, _shapedTree.forgetShape)(tree).data.errors.client); | ||
} | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
var validationA = jest.fn(function () { | ||
return ["error a"]; | ||
}); | ||
var validationB = jest.fn(function () { | ||
return ["error b"]; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
key: "hello" | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link | ||
}, function (link) { | ||
return React.createElement(React.Fragment, null, React.createElement(_LinkTap.default, { | ||
link: link.key | ||
}, renderFn), React.createElement(_TestField.default, { | ||
link: link.key, | ||
validation: validationA | ||
})); | ||
}); | ||
})); | ||
expect(renderFn).toHaveBeenCalledTimes(2); // on the initial render, we expose "pending" as part of our API | ||
// TODO(dmnd): It'd be nice if we could avoid this. | ||
expectClientErrors(renderFn.mock.calls[0][0]).toEqual("pending"); // After the second render the error arrives. | ||
expectClientErrors(renderFn.mock.calls[1][0]).toEqual(["error a"]); | ||
expect(validationA).toHaveBeenCalledTimes(1); | ||
expect(validationA).toHaveBeenCalledWith("hello"); // When a new Field is mounted, we expect the errors to show up. | ||
renderFn.mockClear(); | ||
validationA.mockClear(); | ||
renderer.update(React.createElement(_Form.default, { | ||
initialValue: { | ||
key: "hello" | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link | ||
}, function (link) { | ||
return React.createElement(React.Fragment, null, React.createElement(_LinkTap.default, { | ||
link: link.key | ||
}, renderFn), React.createElement(_TestField.default, { | ||
link: link.key, | ||
validation: validationA | ||
}), React.createElement(_TestField.default, { | ||
link: link.key, | ||
validation: validationB | ||
})); | ||
}); | ||
})); | ||
expect(renderFn).toHaveBeenCalledTimes(2); // There's an initial render where the field hasn't yet been validated | ||
// TODO(dmnd): It'd be nice if we could avoid this. | ||
expectClientErrors(renderFn.mock.calls[0][0]).toEqual(["error a"]); // After the update, the new error should be present. | ||
expectClientErrors(renderFn.mock.calls[1][0]).toEqual(["error a", "error b"]); // Validation functions should receive the correct parameters. These | ||
// assertions protect against bugs that confuse relative and absolute | ||
// paths/values. | ||
expect(validationA).toHaveBeenCalledTimes(1); | ||
expect(validationA).toHaveBeenCalledWith("hello"); | ||
expect(validationB).toHaveBeenCalledTimes(1); | ||
expect(validationB).toHaveBeenCalledWith("hello"); | ||
}); | ||
it("updates errors when a new validation function is provided via props", function () { | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: "hello" | ||
}, function (link) { | ||
return React.createElement(_TestField.default, { | ||
link: link, | ||
validation: function validation() { | ||
return ["error 1"]; | ||
} | ||
}); | ||
})); | ||
var link = renderer.root.findAllByType(_TestField.default)[0].instance.props.link; | ||
var errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 1"]); | ||
renderer.update(React.createElement(_Form.default, { | ||
initialValue: "hello" | ||
}, function (link) { | ||
return React.createElement(_TestField.default, { | ||
link: link, | ||
validation: function validation() { | ||
return ["error 2"]; | ||
} | ||
}); | ||
})); | ||
link = renderer.root.findAllByType(_TestField.default)[0].instance.props.link; | ||
errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 2"]); | ||
}); | ||
}); | ||
describe("Form manages form state", function () { | ||
it("creates the initial formState from initialValue and serverErrors", function () { | ||
it("creates the initial formState from initialValue and externalErrors", function () { | ||
var onSubmit = jest.fn(); | ||
@@ -104,4 +378,4 @@ var renderFn = jest.fn(function () { | ||
onSubmit: onSubmit, | ||
serverErrors: { | ||
"/": ["Server error", "Another server error"] | ||
externalErrors: { | ||
"/": ["External error", "Another external error"] | ||
} | ||
@@ -129,7 +403,7 @@ }, renderFn)); | ||
client: "pending", | ||
server: ["Server error", "Another server error"] | ||
external: ["External error", "Another external error"] | ||
} | ||
}); | ||
}); | ||
it("parses and sets complex server errors", function () { | ||
it("parses and sets complex external errors", function () { | ||
var onSubmit = jest.fn(); | ||
@@ -150,3 +424,3 @@ var renderFn = jest.fn(function () { | ||
onSubmit: onSubmit, | ||
serverErrors: { | ||
externalErrors: { | ||
"/": ["Root error"], | ||
@@ -169,13 +443,13 @@ "/simple": ["One", "level", "down"], | ||
var root = tree; | ||
expect(root.data.errors.server).toEqual(["Root error"]); | ||
expect(root.data.errors.external).toEqual(["Root error"]); | ||
var simple = root.children.simple; | ||
expect(simple.data.errors.server).toEqual(["One", "level", "down"]); | ||
expect(simple.data.errors.external).toEqual(["One", "level", "down"]); | ||
var complex = root.children.complex; | ||
expect(complex.data.errors.server).toEqual([]); | ||
expect(complex.data.errors.external).toEqual([]); | ||
var complex0 = complex.children[0]; | ||
expect(complex0.data.errors.server).toEqual(["in an", "array"]); | ||
expect(complex0.data.errors.external).toEqual(["in an", "array"]); | ||
var complex1 = complex.children[1]; | ||
expect(complex1.data.errors.server).toEqual([]); | ||
expect(complex1.data.errors.external).toEqual([]); | ||
}); | ||
it("updates the server errors", function () { | ||
it("updates the external errors", function () { | ||
var onSubmit = jest.fn(); | ||
@@ -191,3 +465,3 @@ var renderFn = jest.fn(function () { | ||
onSubmit: onSubmit, | ||
serverErrors: { | ||
externalErrors: { | ||
"/array": ["Cannot be empty"] | ||
@@ -211,3 +485,3 @@ } | ||
onSubmit: onSubmit, | ||
serverErrors: { | ||
externalErrors: { | ||
"/array": [], | ||
@@ -226,8 +500,22 @@ "/array/0": ["inner error"] | ||
var root = tree; | ||
expect(root.data.errors.server).toEqual([]); | ||
expect(root.data.errors.external).toEqual([]); | ||
var array = root.children.array; | ||
expect(array.data.errors.server).toEqual([]); | ||
expect(array.data.errors.external).toEqual([]); | ||
var array0 = array.children[0]; | ||
expect(array0.data.errors.server).toEqual(["inner error"]); | ||
expect(array0.data.errors.external).toEqual(["inner error"]); | ||
}); | ||
it("doesn't cause an infinite loop when using inline validation function", function () { | ||
expect(function () { | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: "hello" | ||
}, function (link) { | ||
return React.createElement(_TestField.default, { | ||
link: link, | ||
validation: function validation() { | ||
return []; | ||
} | ||
}); | ||
})); | ||
}).not.toThrow(/Maximum update depth exceeded/); | ||
}); | ||
it("collects the initial validations", function () { | ||
@@ -456,4 +744,4 @@ // This test is not very unit-y, but that's okay! It's more useful to | ||
onSubmit: jest.fn(), | ||
serverErrors: { | ||
"/": ["Server error", "Another server error"] | ||
externalErrors: { | ||
"/": ["External error", "Another external error"] | ||
} | ||
@@ -466,3 +754,3 @@ }, renderFn)); | ||
shouldShowErrors: false, | ||
unfilteredErrors: expect.arrayContaining(["Server error", "Another server error"]), | ||
unfilteredErrors: expect.arrayContaining(["External error", "Another external error"]), | ||
// Currently, only care about client errors | ||
@@ -474,2 +762,157 @@ valid: true, | ||
}); | ||
it("removes errors when a child unmounts", function () { | ||
var validation1 = jest.fn(function () { | ||
return ["error 1"]; | ||
}); | ||
var validation2 = jest.fn(function () { | ||
return ["error 2"]; | ||
}); | ||
var TestForm = | ||
/*#__PURE__*/ | ||
function (_React$Component2) { | ||
_inherits(TestForm, _React$Component2); | ||
function TestForm() { | ||
_classCallCheck(this, TestForm); | ||
return _possibleConstructorReturn(this, _getPrototypeOf(TestForm).apply(this, arguments)); | ||
} | ||
_createClass(TestForm, [{ | ||
key: "render", | ||
value: function render() { | ||
var _this = this; | ||
return React.createElement(_ObjectField.default, { | ||
link: this.props.link | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, React.createElement(_TestField.default, { | ||
key: "1", | ||
link: links.string1, | ||
validation: validation1 | ||
}), _this.props.hideSecondField ? null : React.createElement(_TestField.default, { | ||
key: "2", | ||
link: links.string2, | ||
validation: validation2 | ||
})); | ||
}); | ||
} | ||
}]); | ||
return TestForm; | ||
}(React.Component); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
string1: "hello", | ||
string2: "world" | ||
} | ||
}, function (link) { | ||
return React.createElement(TestForm, { | ||
link: link, | ||
hideSecondField: false | ||
}); | ||
})); | ||
expect(validation1).toHaveBeenCalledTimes(1); | ||
expect(validation2).toHaveBeenCalledTimes(1); | ||
var rootFormState = renderer.root.findByType(TestForm).instance.props.link.formState[1]; | ||
var string1Errors = rootFormState.children.string1.data.errors.client; | ||
expect(string1Errors).toEqual(["error 1"]); | ||
var string2Errors = rootFormState.children.string2.data.errors.client; | ||
expect(string2Errors).toEqual(["error 2"]); // now hide the second field, causing it to unmount and unregister the | ||
// validation handler | ||
renderer.update(React.createElement(_Form.default, { | ||
initialValue: { | ||
string1: "hello", | ||
string2: "world" | ||
} | ||
}, function (link) { | ||
return React.createElement(TestForm, { | ||
link: link, | ||
hideSecondField: true | ||
}); | ||
})); // no addition validation calls | ||
expect(validation1).toHaveBeenCalledTimes(1); | ||
expect(validation2).toHaveBeenCalledTimes(1); | ||
rootFormState = renderer.root.findByType(TestForm).instance.props.link.formState[1]; // error for string1 remains | ||
string1Errors = rootFormState.children.string1.data.errors.client; | ||
expect(string1Errors).toEqual(["error 1"]); // string2's error is gone | ||
string2Errors = rootFormState.children.string2.data.errors.client; | ||
expect(string2Errors).toEqual([]); | ||
}); | ||
it("runs all validations when a link has multiple fields", function () { | ||
var validation1 = jest.fn(function () { | ||
return ["error 1"]; | ||
}); | ||
var validation2 = jest.fn(function () { | ||
return ["error 2"]; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: "hello" | ||
}, function (link) { | ||
return React.createElement(React.Fragment, null, React.createElement(_TestField.default, { | ||
key: "1", | ||
link: link, | ||
validation: validation1 | ||
}), React.createElement(_TestField.default, { | ||
key: "2", | ||
link: link, | ||
validation: validation2 | ||
})); | ||
})); | ||
expect(validation1).toHaveBeenCalledTimes(1); | ||
expect(validation2).toHaveBeenCalledTimes(1); | ||
renderer.root.findAllByType(_TestField.TestInput)[0].instance.change("dmnd"); | ||
expect(validation1).toHaveBeenCalledTimes(2); | ||
expect(validation2).toHaveBeenCalledTimes(2); | ||
renderer.root.findAllByType(_TestField.TestInput)[1].instance.change("zach"); | ||
expect(validation1).toHaveBeenCalledTimes(3); | ||
expect(validation2).toHaveBeenCalledTimes(3); | ||
}); | ||
it("only removes errors from validation that was unmounted", function () { | ||
var validation1 = jest.fn(function () { | ||
return ["error 1"]; | ||
}); | ||
var validation2 = jest.fn(function () { | ||
return ["error 2"]; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: "hello" | ||
}, function (link) { | ||
return React.createElement(React.Fragment, null, React.createElement(_TestField.default, { | ||
key: "1", | ||
link: link, | ||
validation: validation1 | ||
}), React.createElement(_TestField.default, { | ||
key: "2", | ||
link: link, | ||
validation: validation2 | ||
})); | ||
})); | ||
var link = renderer.root.findAllByType(_TestField.default)[0].instance.props.link; | ||
var errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 1", "error 2"]); | ||
renderer.update(React.createElement(_Form.default, { | ||
initialValue: "hello" | ||
}, function (link) { | ||
return React.createElement(React.Fragment, null, React.createElement(_TestField.default, { | ||
key: "1", | ||
link: link, | ||
validation: validation1 | ||
})); | ||
})); | ||
link = renderer.root.findAllByType(_TestField.default)[0].instance.props.link; | ||
errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 1"]); | ||
}); | ||
}); | ||
@@ -571,2 +1014,84 @@ it("Calls onSubmit with the value when submitted", function () { | ||
}); | ||
describe("performance", function () { | ||
it("batches setState calls when unmounting components", function () { | ||
// Record the number of render and commit phases | ||
var renders = 0; | ||
var commits = 0; | ||
var RenderCalls = | ||
/*#__PURE__*/ | ||
function (_React$Component3) { | ||
_inherits(RenderCalls, _React$Component3); | ||
function RenderCalls() { | ||
_classCallCheck(this, RenderCalls); | ||
return _possibleConstructorReturn(this, _getPrototypeOf(RenderCalls).apply(this, arguments)); | ||
} | ||
_createClass(RenderCalls, [{ | ||
key: "componentDidUpdate", | ||
value: function componentDidUpdate() { | ||
commits += 1; | ||
} | ||
}, { | ||
key: "render", | ||
value: function render() { | ||
renders += 1; | ||
return React.createElement("div", null, "Rendered ", renders, " times, committed ", commits, " times."); | ||
} | ||
}]); | ||
return RenderCalls; | ||
}(React.Component); | ||
var validation = jest.fn(); // N in the O(N) sense for this perf test. | ||
var N = 10; | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: "A string" | ||
}, function (link) { | ||
return React.createElement(React.Fragment, null, React.createElement(RenderCalls, null), React.createElement(React.Fragment, null, _toConsumableArray(Array(N).keys()).map(function (i) { | ||
return React.createElement(_Field.default, { | ||
key: i, | ||
link: link, | ||
validation: validation | ||
}, function () { | ||
return React.createElement("div", null, "input"); | ||
}); | ||
}))); | ||
})); // One render for initial mount, and another after Form validates | ||
// everything from componentDidMount. | ||
// TODO(dmnd): Can we adjust implementation to make this only render once? | ||
expect(renders).toBe(1 + 1); | ||
expect(commits).toBe(1); | ||
expect(validation).toBeCalledTimes(N); | ||
renders = 0; | ||
commits = 0; | ||
validation.mockClear(); // now unmount all the fields | ||
renderer.update(React.createElement(_Form.default, { | ||
initialValue: "A string" | ||
}, function () { | ||
return React.createElement(React.Fragment, null, React.createElement(RenderCalls, null)); | ||
})); // We expect only two renders. The first to build the VDOM without Fields. | ||
// Then during reconciliation React realizes the Fields have to unmount, | ||
// so it calls componentWillUnmount on each Field, which then causes a | ||
// setState for each Field. But then we expect that React batches all | ||
// these setStates into a single render, not one render per each Field. | ||
expect(renders).toBe(1 + 1); | ||
expect(renders).not.toBe(1 + N); // Similarly, we expect only 2 commits, not one for each Field. You might | ||
// expect only a single commit, but componentWillUnmount happens during | ||
// the commit phase, so when setState is called React enqueues another | ||
// render phase which commits separately. Oh well. At least the number of | ||
// commits is constant! | ||
expect(commits).toBe(1 + 1); | ||
expect(commits).not.toBe(1 + N); | ||
}); | ||
}); | ||
}); |
@@ -7,3 +7,3 @@ "use strict"; | ||
var _Form = require("../Form"); | ||
var _Form = _interopRequireWildcard(require("../Form")); | ||
@@ -16,2 +16,6 @@ var _ObjectField = _interopRequireDefault(require("../ObjectField")); | ||
var _TestForm = _interopRequireDefault(require("./TestForm")); | ||
var _LinkTap = _interopRequireDefault(require("../testutils/LinkTap")); | ||
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } | ||
@@ -21,10 +25,2 @@ | ||
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } | ||
function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } | ||
function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } | ||
function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } | ||
describe("ObjectField", function () { | ||
@@ -36,144 +32,125 @@ describe("Sneaky hacks", function () { | ||
}); | ||
describe("ObjectField is a field", function () { | ||
describe("validates on mount", function () { | ||
it("ensures that the link inner type matches the type of the validation", function () { | ||
var formStateInner = { | ||
string: "hello", | ||
number: 42 | ||
}; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); // $ExpectError | ||
describe("is a field", function () { | ||
it("ensures that the link inner type matches the type of the validation", function () { | ||
var formStateInner = { | ||
string: "hello", | ||
number: 42 | ||
}; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); // $ExpectError | ||
React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); // $ExpectError | ||
React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); // $ExpectError | ||
React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are no errors", function () { | ||
var validation = jest.fn(function () { | ||
React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: function validation(_e) { | ||
return []; | ||
}); | ||
var formState = (0, _tools.mockFormState)({ | ||
inner: "value" | ||
}); | ||
var link = (0, _tools.mockLink)(formState); | ||
} | ||
}, function () { | ||
return null; | ||
}); | ||
}); | ||
it("Registers and unregisters for validation", function () { | ||
var formState = (0, _tools.mockFormState)({ | ||
inner: "value" | ||
}); | ||
var link = (0, _tools.mockLink)(formState); | ||
var unregister = jest.fn(); | ||
var registerValidation = jest.fn(function () { | ||
return { | ||
replace: jest.fn(), | ||
unregister: unregister | ||
}; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: validation | ||
}, jest.fn(function () { | ||
return null; | ||
}))); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_TestForm.default, { | ||
registerValidation: registerValidation | ||
}, React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: jest.fn(function () { | ||
return []; | ||
}) | ||
}, jest.fn(function () { | ||
return null; | ||
})))); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var _link$onValidation$mo = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo[0], | ||
errors = _link$onValidation$mo[1]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.unmount(); | ||
expect(unregister).toBeCalledTimes(1); | ||
}); | ||
it("calls replace when changing the validation function", function () { | ||
var replace = jest.fn(); | ||
var registerValidation = jest.fn(function () { | ||
return { | ||
replace: replace, | ||
unregister: jest.fn() | ||
}; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are errors", function () { | ||
var validation = jest.fn(function () { | ||
return ["This is an error"]; | ||
}); | ||
var formState = (0, _tools.mockFormState)({ | ||
inner: "value" | ||
}); | ||
var link = (0, _tools.mockLink)(formState); | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: validation | ||
}, jest.fn(function () { | ||
function Component() { | ||
return React.createElement(_TestForm.default, { | ||
registerValidation: registerValidation | ||
}, React.createElement(_ObjectField.default, { | ||
link: (0, _tools.mockLink)((0, _tools.mockFormState)({ | ||
hello: "world" | ||
})), | ||
validation: function validation() { | ||
return []; | ||
} | ||
}, function () { | ||
return null; | ||
}))); | ||
})); | ||
} | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(Component, null)); | ||
var _link$onValidation$mo2 = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo2[0], | ||
errors = _link$onValidation$mo2[1]; | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.update(React.createElement(Component, null)); | ||
expect(replace).toBeCalledTimes(1); | ||
}); | ||
it("Passes additional information to its render function", function () { | ||
var formState = (0, _tools.mockFormState)({ | ||
inner: "value" | ||
}); // $FlowFixMe | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual(["This is an error"]); | ||
formState[1].data.errors = { | ||
external: ["An external error"], | ||
client: ["A client error"] | ||
}; | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
it("Treats no validation as always passing", function () { | ||
var formState = (0, _tools.mockFormState)({ | ||
inner: "value" | ||
}); | ||
var link = (0, _tools.mockLink)(formState); | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link | ||
}, jest.fn(function () { | ||
return null; | ||
}))); | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link | ||
}, renderFn)); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
var _link$onValidation$mo3 = _slicedToArray(link.onValidation.mock.calls[0], 2), | ||
path = _link$onValidation$mo3[0], | ||
errors = _link$onValidation$mo3[1]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
it("Passes additional information to its render function", function () { | ||
var formState = (0, _tools.mockFormState)({ | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining(["An external error", "A client error"]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: { | ||
inner: "value" | ||
}); // $FlowFixMe | ||
formState[1].data.errors = { | ||
server: ["A server error"], | ||
client: ["A client error"] | ||
}; | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link | ||
}, renderFn)); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining(["A server error", "A client error"]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: { | ||
inner: "value" | ||
} | ||
})); | ||
}); | ||
} | ||
})); | ||
}); | ||
@@ -232,8 +209,7 @@ }); | ||
}); | ||
it("calls onChange when a child changes", function () { | ||
var formStateInner = { | ||
it("validates new values from children and passes result to onChange", function () { | ||
var formState = (0, _tools.mockFormState)({ | ||
string: "hello", | ||
number: 42 | ||
}; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
}); | ||
var link = (0, _tools.mockLink)(formState); | ||
@@ -243,19 +219,28 @@ var renderFn = jest.fn(function () { | ||
}); | ||
var updateNodeAtPath = jest.fn(function (path, formState) { | ||
return formState; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
_reactTestRenderer.default.create(React.createElement(_TestForm.default, { | ||
updateNodeAtPath: updateNodeAtPath | ||
}, React.createElement(_ObjectField.default, { | ||
link: link | ||
}, renderFn)); | ||
}, renderFn))); | ||
var objectLinks = renderFn.mock.calls[0][0]; // call the child onChange | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(0); | ||
expect(link.onChange).toHaveBeenCalledTimes(0); // call the child onChange | ||
var objectLinks = renderFn.mock.calls[0][0]; | ||
var newChildMeta = (0, _tools.mockFormState)("newString"); | ||
objectLinks.string.onChange(newChildMeta); | ||
expect(link.onChange).toHaveBeenCalled(); | ||
var newObjectFormState = link.onChange.mock.calls[0][0]; | ||
expect(newObjectFormState[0]).toHaveProperty("string", "newString"); | ||
expect(newObjectFormState[1].data.meta).toMatchObject({ | ||
touched: true, | ||
changed: true | ||
}); | ||
expect(newObjectFormState[1].children.string).toBe(newChildMeta[1]); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(1); | ||
expect(updateNodeAtPath).toHaveBeenCalledWith([], [{ | ||
string: "newString", | ||
number: 42 | ||
}, expect.anything()]); | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([{ | ||
string: "newString", | ||
number: 42 | ||
}, formState[1]]); | ||
}); | ||
@@ -289,39 +274,3 @@ it("calls onBlur when a child is blurred", function () { | ||
}); | ||
it("calls onValidation when a child runs validations", function () { | ||
var formStateInner = { | ||
string: "hello", | ||
number: 42 | ||
}; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link | ||
}, renderFn)); | ||
var objectLinks = renderFn.mock.calls[0][0]; // call the child onValidation | ||
objectLinks.string.onValidation([], ["Some", "errors"]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(2); // Important: the first call to onValidation is for the initial render validation | ||
var _link$onValidation$mo4 = _slicedToArray(link.onValidation.mock.calls[1], 2), | ||
path = _link$onValidation$mo4[0], | ||
errors = _link$onValidation$mo4[1]; | ||
expect(path).toEqual([{ | ||
type: "object", | ||
key: "string" | ||
}]); | ||
expect(errors).toEqual(["Some", "errors"]); | ||
}); | ||
it("calls its own validation when a child changes", function () { | ||
var formStateInner = { | ||
string: "hello", | ||
number: 42 | ||
}; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -334,6 +283,13 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: validation | ||
}, renderFn)); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
string: "hello", | ||
number: 42 | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: validation | ||
}, renderFn); | ||
})); | ||
@@ -353,3 +309,3 @@ expect(validation).toHaveBeenCalledTimes(1); | ||
describe("customChange", function () { | ||
it("allows the default change behavior to be overwritten with customChange", function () { | ||
it("allows sibling fields to be overwritten", function () { | ||
var formStateInner = { | ||
@@ -364,5 +320,2 @@ string: "hello", | ||
}); | ||
var validation = jest.fn(function () { | ||
return ["This is an error"]; | ||
}); | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
@@ -375,7 +328,9 @@ return { | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
_reactTestRenderer.default.create(React.createElement(_TestForm.default, null, React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: validation, | ||
validation: jest.fn(function () { | ||
return ["This is an error"]; | ||
}), | ||
customChange: customChange | ||
}, renderFn)); | ||
}, renderFn))); | ||
@@ -400,17 +355,5 @@ var objectLinks = renderFn.mock.calls[0][0]; // call the child onChange | ||
number: 0 | ||
}, expect.anything()]); // Validated the result of customChange | ||
expect(validation).toHaveBeenCalledTimes(2); | ||
expect(validation.mock.calls[1][0]).toEqual({ | ||
string: "A whole new value", | ||
number: 0 | ||
}); | ||
}, expect.anything()]); | ||
}); | ||
it("can return null to signal there was no custom change", function () { | ||
var formStateInner = { | ||
string: "hello", | ||
number: 42 | ||
}; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); | ||
var renderFn = jest.fn(function () { | ||
@@ -426,7 +369,14 @@ return null; | ||
_reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: validation, | ||
customChange: customChange | ||
}, renderFn)); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
string: "hello", | ||
number: 42 | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link, | ||
validation: validation, | ||
customChange: customChange | ||
}, renderFn); | ||
})); | ||
@@ -437,6 +387,6 @@ var objectLinks = renderFn.mock.calls[0][0]; // call the child onChange | ||
objectLinks.string.onChange(newChildMeta); | ||
expect(customChange).toHaveBeenCalledTimes(1); // onChange should be called with the result of customChange | ||
expect(customChange).toHaveBeenCalledTimes(1); | ||
var link = renderer.root.findByType(_ObjectField.default).instance.props.link; // the value we get out is as if customChange didn't exist | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([{ | ||
expect(link.formState).toEqual([{ | ||
string: "newString", | ||
@@ -453,8 +403,2 @@ number: 42 | ||
it("doesn't break validations for child fields", function () { | ||
var formStateInner = { | ||
string: "hello", | ||
string2: "goodbye" | ||
}; | ||
var formState = (0, _tools.mockFormState)(formStateInner); | ||
var link = (0, _tools.mockLink)(formState); | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
@@ -469,92 +413,129 @@ return { | ||
}); | ||
var parentValidation = jest.fn(function () { | ||
return ["This is an error from the parent"]; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
string: "hello", | ||
string2: "goodbye" | ||
} | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link, | ||
customChange: customChange, | ||
validation: parentValidation | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, React.createElement(_TestField.default, { | ||
link: links.string, | ||
validation: childValidation | ||
}), React.createElement(_TestField.default, { | ||
link: links.string2, | ||
validation: childValidation | ||
})); | ||
}); | ||
})); // after mount, validate everything | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(2); // Now change one of the values | ||
parentValidation.mockClear(); | ||
childValidation.mockClear(); | ||
var inner = renderer.root.findAllByType(_TestField.TestInput)[0]; | ||
inner.instance.change("zach"); // Validate the whole subtree due to the customChange child validates | ||
// once. Note that child validation will be called 3 times. Once after the | ||
// change, then twice more after the customChange triggers a validation fo | ||
// the entire subtree. | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(1 + 2); | ||
var link = renderer.root.findByType(_ObjectField.default).instance.props.link; | ||
expect(link.formState).toEqual([{ | ||
string: "a whole new value", | ||
string2: "modified sibling value" | ||
}, expect.anything()]); | ||
}); | ||
it("doesn't create a new instance (i.e. remount)", function () { | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
return { | ||
string: "A whole new value", | ||
number: 0 | ||
}; | ||
}); | ||
var renderer = _reactTestRenderer.default.create(React.createElement(_ObjectField.default, { | ||
link: link, | ||
link: (0, _tools.mockLink)((0, _tools.mockFormState)({ | ||
string: "hello", | ||
number: 42 | ||
})), | ||
customChange: customChange | ||
}, function (links) { | ||
return React.createElement(React.Fragment, null, React.createElement(_TestField.default, { | ||
link: links.string, | ||
validation: childValidation | ||
}), React.createElement(_TestField.default, { | ||
link: links.string2, | ||
validation: childValidation | ||
})); | ||
})); // 5 validations: | ||
// 1) Child initial validation x2 | ||
// 2) Parent initial validation | ||
// 3) Child validation on remount x2 | ||
// (No parent onValidation call, because it will use onChange) | ||
// 1) and 2) | ||
return React.createElement(_TestField.default, { | ||
link: links.string | ||
}); | ||
})); | ||
var testInstance = renderer.root.findAllByType(_TestField.TestInput)[0].instance; // now trigger a customChange, which used to cause a remount | ||
expect(link.onValidation).toHaveBeenCalledTimes(3); | ||
link.onValidation.mockClear(); | ||
var inner = renderer.root.findAllByType(_TestField.TestInput)[0]; | ||
inner.instance.change("zach"); // 3) | ||
testInstance.change("hi"); | ||
expect(customChange).toHaveBeenCalledTimes(1); // but we no longer cause a remount, so the instances should be the same | ||
expect(link.onValidation).toHaveBeenCalledTimes(2); | ||
expect(link.onValidation).toHaveBeenCalledWith([{ | ||
type: "object", | ||
key: "string" | ||
}], ["This is an error"]); | ||
expect(link.onValidation).toHaveBeenCalledWith([{ | ||
type: "object", | ||
key: "string2" | ||
}], ["This is an error"]); // onChange should be called with the result of customChange | ||
var nextTestInstance = renderer.root.findAllByType(_TestField.TestInput)[0].instance; // Using Object.is here because toBe hangs as the objects are | ||
// self-referential and thus not printable | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([{ | ||
string: "a whole new value", | ||
string2: "modified sibling value" | ||
}, { | ||
type: "object", | ||
data: { | ||
errors: { | ||
client: [], | ||
server: "unchecked" | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: true, | ||
asyncValidationInFlight: false | ||
expect(Object.is(testInstance, nextTestInstance)).toBe(true); | ||
}); | ||
it("works fine even if not at the root of the form", function () { | ||
var customChange = jest.fn(function (_oldValue, _newValue) { | ||
return { | ||
string: "A whole new value", | ||
number: 0 | ||
}; | ||
}); | ||
var objectRenderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
var linkTapFn = jest.fn(function () { | ||
return null; | ||
}); | ||
_reactTestRenderer.default.create(React.createElement(_Form.default, { | ||
initialValue: { | ||
uncle: "Bob", | ||
nested: { | ||
string: "hello", | ||
number: 42 | ||
} | ||
}, | ||
children: { | ||
string: { | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked" | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false | ||
} | ||
} | ||
}, | ||
string2: { | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked" | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false | ||
} | ||
} | ||
} | ||
} | ||
}]); | ||
}, function (link) { | ||
return React.createElement(_ObjectField.default, { | ||
link: link | ||
}, function (link) { | ||
return React.createElement(React.Fragment, null, React.createElement(_TestField.default, { | ||
link: link.uncle | ||
}), React.createElement(_LinkTap.default, { | ||
link: link.nested | ||
}, linkTapFn), React.createElement(_ObjectField.default, { | ||
link: link.nested, | ||
validation: jest.fn(function () { | ||
return ["This is an error"]; | ||
}), | ||
customChange: customChange | ||
}, objectRenderFn)); | ||
}); | ||
})); | ||
linkTapFn.mockClear(); // call the child onChange to trigger a customChange | ||
var objectLinks = objectRenderFn.mock.calls[0][0]; | ||
objectLinks.string.onChange((0, _tools.mockFormState)("newString")); // onChange should be called with the result of customChange | ||
expect(linkTapFn).toHaveBeenCalledTimes(1); | ||
expect(linkTapFn.mock.calls[0][0].formState).toEqual([{ | ||
string: "A whole new value", | ||
number: 0 | ||
}, expect.anything()]); | ||
}); | ||
}); | ||
}); |
@@ -32,10 +32,10 @@ "use strict"; | ||
} : _ref$registerValidati, | ||
_ref$applyValidationT = _ref.applyValidationToTreeAtPath, | ||
applyValidationToTreeAtPath = _ref$applyValidationT === void 0 ? function (path, formState) { | ||
_ref$updateTreeAtPath = _ref.updateTreeAtPath, | ||
updateTreeAtPath = _ref$updateTreeAtPath === void 0 ? function (path, formState) { | ||
return formState; | ||
} : _ref$applyValidationT, | ||
_ref$applyValidationA = _ref.applyValidationAtPath, | ||
applyValidationAtPath = _ref$applyValidationA === void 0 ? function (path, formState) { | ||
} : _ref$updateTreeAtPath, | ||
_ref$updateNodeAtPath = _ref.updateNodeAtPath, | ||
updateNodeAtPath = _ref$updateNodeAtPath === void 0 ? function (path, formState) { | ||
return formState; | ||
} : _ref$applyValidationA; | ||
} : _ref$updateNodeAtPath; | ||
@@ -48,6 +48,6 @@ return React.createElement(_Form.FormContext.Provider, { | ||
registerValidation: registerValidation, | ||
applyValidationToTreeAtPath: applyValidationToTreeAtPath, | ||
applyValidationAtPath: applyValidationAtPath | ||
updateTreeAtPath: updateTreeAtPath, | ||
updateNodeAtPath: updateNodeAtPath | ||
} | ||
}, children); | ||
} |
@@ -23,6 +23,12 @@ "use strict"; | ||
expect(link).toEqual(expect.objectContaining({ | ||
// TODO(dmnd): Would be nice if we could do something like | ||
// path: expect.arrayContaining( | ||
// expect.objectContaining({ | ||
// type: expect.stringMatching(/(object)|(array)/), | ||
// }) | ||
// ), | ||
path: expect.anything(), | ||
formState: expect.anything(), | ||
onChange: expect.any(Function), | ||
onBlur: expect.any(Function), | ||
onValidation: expect.any(Function) | ||
onBlur: expect.any(Function) | ||
})); | ||
@@ -34,7 +40,7 @@ expect(Object.keys(link).length).toBe(4); | ||
return { | ||
path: [], | ||
formState: formState, | ||
onChange: jest.fn(), | ||
onBlur: jest.fn(), | ||
onValidation: jest.fn() | ||
onBlur: jest.fn() | ||
}; | ||
} |
@@ -66,10 +66,2 @@ "use strict"; | ||
_defineProperty(_assertThisInitialized(_this), "handleValidation", function (path, errors) { | ||
if (_this.props.onValidation) { | ||
_this.props.onValidation(path, errors); | ||
} | ||
_this.props.link.onValidation(path, errors); | ||
}); | ||
return _this; | ||
@@ -83,6 +75,6 @@ } | ||
var tappedLink = { | ||
path: link.path, | ||
formState: link.formState, | ||
onChange: this.handleChange, | ||
onBlur: this.handleBlur, | ||
onValidation: this.handleValidation | ||
onBlur: this.handleBlur | ||
}; | ||
@@ -89,0 +81,0 @@ return this.props.children(tappedLink); |
@@ -16,4 +16,4 @@ "use strict"; | ||
client: "pending", | ||
server: "unchecked" | ||
external: "unchecked" | ||
}; | ||
exports.cleanErrors = cleanErrors; |
@@ -15,2 +15,3 @@ "use strict"; | ||
exports.unzip = unzip; | ||
exports.equals = equals; | ||
@@ -155,2 +156,16 @@ function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } | ||
return ret; | ||
} | ||
function equals(a, b) { | ||
if (a.length !== b.length) { | ||
return false; | ||
} | ||
for (var i = 0; i < a.length; i++) { | ||
if (a[i] !== b[i]) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
} |
{ | ||
"name": "formula-one", | ||
"version": "0.9.0-alpha.9", | ||
"version": "0.9.0-rc.1", | ||
"description": "Strongly-typed React form state management", | ||
@@ -5,0 +5,0 @@ "author": "Zach Gotsch", |
200
README.md
@@ -18,3 +18,3 @@ # formula-one | ||
age: string, | ||
side: "Empire" | "Rebels", | ||
faction: "Empire" | "Rebels", | ||
}; | ||
@@ -25,3 +25,3 @@ | ||
age: "", | ||
side: "Empire", | ||
faction: "Empire", | ||
}; | ||
@@ -35,5 +35,2 @@ | ||
onSubmit={person => console.log("Submitted", person)} | ||
// TODO(dmnd): Remove following props after new version is published | ||
serverErrors={null} | ||
feedbackStrategy={FeedbackStrategies.Always} | ||
> | ||
@@ -68,6 +65,6 @@ {(link, onSubmit) => ( | ||
</Field> | ||
<Field link={links.side}> | ||
<Field link={links.faction}> | ||
{(value, errors, onChange) => ( | ||
<label> | ||
<div>Side</div> | ||
<div>Faction</div> | ||
<select | ||
@@ -158,3 +155,3 @@ onChange={e => onChange(e.target.value)} | ||
| ---------------------------------------------- | ------------------------------------------------------------------------------------ | | ||
| `FeedbackStrategies.Always` | Always show errors | | ||
| `FeedbackStrategies.Always` | Always show errors (default) | | ||
| `FeedbackStrategies.Touched` | Show errors for fields which have been touched (changed or blurred) | | ||
@@ -192,4 +189,10 @@ | `FeedbackStrategies.Changed` | Show errors for fields which have been changed | | ||
## Arrays in forms | ||
### Complex inputs | ||
Even inputs which are complex (e.g. a datepicker) can be wrapped in a `<Field>` wrapper, but validations are tracked at the field level, so you won't be able to use **formula-one** to track changes and validations below the field level. For example, you can't represent a validation error for _just the day part_ of a date if you only have a single `<Field>` wrapping a datepicker. Instead, the error will be associated with the entire date. | ||
## Common use cases | ||
### Arrays in forms | ||
Often, you may want to edit a list of items in a form. **formula-one** exposes an aggregator called `<ArrayField>`, which allows you to manipulate a list of *Field*s. | ||
@@ -220,5 +223,2 @@ | ||
onSubmit={p => console.log("Submitted", p)} | ||
// TODO(dmnd): Remove following props after new version is published | ||
serverErrors={null} | ||
feedbackStrategy={FeedbackStrategies.Always} | ||
> | ||
@@ -288,14 +288,13 @@ {(link, onSubmit) => ( | ||
- `addField(index: number, value: T)`: Add a field at a position in the array | ||
- `addField(index: number, value: E)`: Add a field at a position in the array | ||
- `addFields(spans: $ReadOnlyArray<Span<E>>)`: Add multiple fields to an array | ||
- `removeField(index: number)`: Remove a field at a position in array | ||
- `filterFields(predicate: (item: E, index: number) => boolean)`: Remove multiple fields from an array | ||
- `modifyFields({insertSpans: $ReadOnlyArray<Span<E>>, filterPredicate: (item: E, index: number) => boolean})`: Simultaneously add and remove fields from an array | ||
- `moveField(fromIndex: number, toIndex: number)`: Move a field in an array (preserves metadata for the field) | ||
## Complex inputs | ||
where `Span<E>` is a range to be inserted at an index: `[number, $ReadOnlyArray<E>]`. | ||
Even inputs which are complex can be wrapped in a `<Field>` wrapper, but validations are tracked at the field level, so you won't be able to use **formula-one** to track changes and validations below the field level. | ||
<!-- ### Form state vs actual model --> | ||
## Common use cases | ||
### Form in a modal | ||
@@ -317,5 +316,84 @@ | ||
### Custom changes | ||
Sometimes a change in one field has to be reflected in another field. `<ObjectField>` and `<ArrayField>` have a prop `customChange` to allow this. It will be called when a child Field changes, and by returning a non-null result you can override the whole Currently, no metadata is preserved (all fields are marked **changed**, **touched**, and not **succeeded**) if a `customChange` function is used. | ||
The API is: | ||
```jsx | ||
// Override nextValue by returning a non-null result | ||
customChange: <T>(prevValue: T, nextValue: T) => null | T; | ||
``` | ||
[Edit the working example on CodeSandbox](https://codesandbox.io/s/x7nw8w3p34?module=%2Fsrc%2FCustomChange.js) | ||
```jsx | ||
const SHIPS = { | ||
"X-Wing": {faction: "Rebels", name: "X-Wing"}, | ||
"Y-Wing": {faction: "Rebels", name: "Y-Wing"}, | ||
"TIE Fighter": {faction: "Empire", name: "TIE Fighter"}, | ||
"TIE Defender": {faction: "Empire", name: "TIE Defender"}, | ||
}; | ||
type Person = { | ||
name: string, | ||
age: string, | ||
faction: "Empire" | "Rebels", | ||
ship: string, | ||
}; | ||
function ensureFactionShipConsistency( | ||
prevPerson: Person, | ||
person: Person | ||
): null | Person { | ||
const ship = SHIPS[person.ship]; | ||
if (person.faction !== ship.faction) { | ||
// person's ship is inconsistent with their faction: need an update | ||
if (prevPerson.ship !== person.ship) { | ||
// ship changed; update faction to match | ||
return {...person, faction: ship.faction}; | ||
} else if (prevPerson.faction !== person.faction) { | ||
// faction changed; give them a ship from their new faction | ||
const newShip = Object.keys(SHIPS).find( | ||
x => SHIPS[x].faction === person.faction | ||
); | ||
return {...person, ship: newShip}; | ||
} else { | ||
throw new Error("unreachable"); | ||
} | ||
} else { | ||
return null; | ||
} | ||
} | ||
export default function CustomChange() { | ||
return ( | ||
<div className="App"> | ||
<Form | ||
initialValue={EMPTY_PERSON} | ||
onSubmit={person => console.log("Submitted", person)} | ||
> | ||
{(link, onSubmit) => ( | ||
<ObjectField link={link} customChange={ensureFactionShipConsistency}> | ||
{links => ( | ||
<> | ||
<Field link={links.faction}>{/*...*/}</Field> | ||
<Field link={links.ship}>{/*...*/}</Field> | ||
<div> | ||
<button onClick={onSubmit}>Submit</button> | ||
</div> | ||
</> | ||
)} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
</div> | ||
); | ||
} | ||
``` | ||
### External validation | ||
Oftentimes, you will want to show errors from an external source (such as the server) in your form alongside any client-side validation errors. These can be passed into your `<Form>` component using the `serverErrors` (TODO(zach): change to `externalErrors`?) prop. | ||
Oftentimes, you will want to show errors from an external source (such as the server) in your form alongside any client-side validation errors. These can be passed into your `<Form>` component using the `externalErrors` prop. | ||
@@ -325,3 +403,3 @@ These errors must be in an object with keys representing the path to the field they should be associated with. For example, the errors: | ||
```js | ||
const serverErrors = { | ||
const externalErrors = { | ||
"/": "User failed to save!", | ||
@@ -335,15 +413,15 @@ "/email": "A user with this email already exists!", | ||
```jsx | ||
<Form serverErrors={serverErrors}> | ||
<Form externalErrors={externalErrors}> | ||
{(link, handleSubmit) => ( | ||
<> | ||
<ObjectField link={link}> | ||
{links => ( | ||
<> | ||
<StringField link={links.name} /> | ||
<StringField link={links.email} /> | ||
</> | ||
)} | ||
</ObjectField> | ||
<button onClick={handleSubmit}>Submit</button> | ||
</> | ||
<> | ||
<ObjectField link={link}> | ||
{links => ( | ||
<> | ||
<StringField link={links.name} /> | ||
<StringField link={links.email} /> | ||
</> | ||
)} | ||
</ObjectField> | ||
<button onClick={handleSubmit}>Submit</button> | ||
</> | ||
)} | ||
@@ -374,21 +452,21 @@ </Form> | ||
{(link, handleSubmit, {valid}) => ( | ||
<> | ||
<Field link={link}> | ||
{(value, errors, onChange, onBlur, {changed}) => ( | ||
<label> | ||
Name | ||
<input | ||
type="text" | ||
value={value} | ||
onChange={onChange} | ||
onBlur={onBlur} | ||
/> | ||
{changed ? "(Modified)" : null} | ||
</label> | ||
)} | ||
</Field> | ||
<button disabled={!valid} onClick={() => handleSubmit()}> | ||
Submit | ||
</button> | ||
</> | ||
<> | ||
<Field link={link}> | ||
{(value, errors, onChange, onBlur, {changed}) => ( | ||
<label> | ||
Name | ||
<input | ||
type="text" | ||
value={value} | ||
onChange={onChange} | ||
onBlur={onBlur} | ||
/> | ||
{changed ? "(Modified)" : null} | ||
</label> | ||
)} | ||
</Field> | ||
<button disabled={!valid} onClick={() => handleSubmit()}> | ||
Submit | ||
</button> | ||
</> | ||
)} | ||
@@ -415,9 +493,9 @@ </Form> | ||
{(link, handleSubmit) => ( | ||
<> | ||
<UserField link={link} /> | ||
<div> | ||
<button onClick={() => handleSubmit("save")}>Save</button> | ||
<button onClick={() => handleSubmit("submit")}>Submit</button> | ||
</div> | ||
</> | ||
<> | ||
<UserField link={link} /> | ||
<div> | ||
<button onClick={() => handleSubmit("save")}>Save</button> | ||
<button onClick={() => handleSubmit("submit")}>Submit</button> | ||
</div> | ||
</> | ||
)} | ||
@@ -432,3 +510,5 @@ </Form>; | ||
```jsx | ||
function handleSubmit(value) { /* ... */ } | ||
function handleSubmit(value) { | ||
/* ... */ | ||
} | ||
@@ -459,3 +539,3 @@ class MyExternalButtonExample extends React.Component<Props> { | ||
> | ||
{link => (<UserField link={link} />)} | ||
{link => <UserField link={link} />} | ||
</Form> | ||
@@ -462,0 +542,0 @@ <button onClick={this.handleSubmitClick}>Submit</button> |
@@ -9,3 +9,2 @@ // @flow strict | ||
Extras, | ||
ClientErrors, | ||
AdditionalRenderInfo, | ||
@@ -17,3 +16,2 @@ CustomChange, | ||
type ShapedTree, | ||
type ShapedPath, | ||
treeFromValue, | ||
@@ -34,16 +32,13 @@ dangerouslyReplaceArrayChild, | ||
} from "./utils/array"; | ||
import {FormContext} from "./Form"; | ||
import {FormContext, type ValidationOps, validationFnNoOps} from "./Form"; | ||
import { | ||
type FormState, | ||
replaceArrayChild, | ||
setTouched, | ||
setChanged, | ||
setExtrasTouched, | ||
arrayChild, | ||
validate, | ||
getExtras, | ||
flatRootErrors, | ||
isValid, | ||
changedFormState, | ||
} from "./formState"; | ||
import type {Path} from "./tree"; | ||
@@ -77,6 +72,6 @@ type ToFieldLink = <T>(T) => FieldLink<T>; | ||
function makeLinks<E>( | ||
path: Path, | ||
formState: FormState<Array<E>>, | ||
onChildChange: (number, FormState<E>) => void, | ||
onChildBlur: (number, ShapedTree<E, Extras>) => void, | ||
onChildValidation: (number, ShapedPath<E>, ClientErrors) => void | ||
onChildBlur: (number, ShapedTree<E, Extras>) => void | ||
): Links<E> { | ||
@@ -93,5 +88,3 @@ const [oldValue] = formState; | ||
}, | ||
onValidation: (childPath, clientErrors) => { | ||
onChildValidation(i, childPath, clientErrors); | ||
}, | ||
path: [...path, {type: "array", index: i}], | ||
}; | ||
@@ -101,7 +94,3 @@ }); | ||
type State = {| | ||
nonce: number, | ||
|}; | ||
export default class ArrayField<E> extends React.Component<Props<E>, State> { | ||
export default class ArrayField<E> extends React.Component<Props<E>, void> { | ||
static defaultProps = { | ||
@@ -112,27 +101,22 @@ validation: () => [], | ||
state = { | ||
nonce: 0, | ||
}; | ||
validationFnOps: ValidationOps<Array<E>> = validationFnNoOps(); | ||
initialValidate() { | ||
const { | ||
link: {formState, onValidation}, | ||
validation, | ||
} = this.props; | ||
const [value] = formState; | ||
const {errors} = getExtras(formState); | ||
componentDidMount() { | ||
this.validationFnOps = this.context.registerValidation( | ||
this.props.link.path, | ||
this.props.validation | ||
); | ||
} | ||
if (errors.client === "pending") { | ||
onValidation([], validation(value)); | ||
componentDidUpdate(prevProps: Props<E>) { | ||
if (prevProps.validation !== this.props.validation) { | ||
this.validationFnOps.replace(this.props.validation); | ||
} | ||
} | ||
componentDidMount() { | ||
this.initialValidate(); | ||
componentWillUnmount() { | ||
this.validationFnOps.unregister(); | ||
this.validationFnOps = validationFnNoOps(); | ||
} | ||
forceChildRemount() { | ||
this.setState(({nonce}) => ({nonce: nonce + 1})); | ||
} | ||
_handleChildChange: (number, FormState<E>) => void = ( | ||
@@ -150,23 +134,20 @@ index: number, | ||
const newValue = newFormState[0]; | ||
const customValue = | ||
this.props.customChange && this.props.customChange(oldValue, newValue); | ||
let nextFormState: FormState<Array<E>>; | ||
let validatedFormState: FormState<Array<E>>; | ||
if (customValue) { | ||
// Create a fresh form state for the new value. | ||
// TODO(zach): It's kind of gross that this is happening outside of Form. | ||
nextFormState = changedFormState(customValue); | ||
// A custom change occurred, which means the whole array needs to be | ||
// revalidated. | ||
validatedFormState = this.context.updateTreeAtPath(this.props.link.path, [ | ||
customValue, | ||
newFormState[1], | ||
]); | ||
} else { | ||
nextFormState = newFormState; | ||
validatedFormState = this.context.updateNodeAtPath( | ||
this.props.link.path, | ||
newFormState | ||
); | ||
} | ||
this.props.link.onChange( | ||
setChanged(validate(this.props.validation, nextFormState)) | ||
); | ||
// Need to remount children so they will run validations | ||
if (customValue) { | ||
this.forceChildRemount(); | ||
} | ||
this.props.link.onChange(validatedFormState); | ||
}; | ||
@@ -187,15 +168,8 @@ | ||
_handleChildValidation: (number, ShapedPath<E>, ClientErrors) => void = ( | ||
index, | ||
childPath, | ||
errors | ||
) => { | ||
const extendedPath = [ | ||
{ | ||
type: "array", | ||
index, | ||
}, | ||
...childPath, | ||
]; | ||
this.props.link.onValidation(extendedPath, errors); | ||
_validateThenApplyChange = <E>(formState: FormState<Array<E>>) => { | ||
const validatedFormState = this.context.updateNodeAtPath( | ||
this.props.link.path, | ||
formState | ||
); | ||
this.props.link.onChange(validatedFormState); | ||
}; | ||
@@ -219,9 +193,3 @@ | ||
); | ||
this.props.link.onChange( | ||
validate( | ||
this.props.validation, | ||
setChanged(setTouched([newValue, newTree])) | ||
) | ||
); | ||
this._validateThenApplyChange([newValue, newTree]); | ||
}; | ||
@@ -250,8 +218,3 @@ | ||
this.props.link.onChange( | ||
validate( | ||
this.props.validation, | ||
setChanged(setTouched([newValue, newTree])) | ||
) | ||
); | ||
this._validateThenApplyChange([newValue, newTree]); | ||
}; | ||
@@ -272,8 +235,3 @@ | ||
this.props.link.onChange( | ||
validate( | ||
this.props.validation, | ||
setChanged(setTouched([newValue, newTree])) | ||
) | ||
); | ||
this._validateThenApplyChange([newValue, newTree]); | ||
}; | ||
@@ -319,8 +277,3 @@ | ||
this.props.link.onChange( | ||
validate( | ||
this.props.validation, | ||
setChanged(setTouched([newValue, newTree])) | ||
) | ||
); | ||
this._validateThenApplyChange([newValue, newTree]); | ||
}; | ||
@@ -337,8 +290,3 @@ | ||
this.props.link.onChange( | ||
validate( | ||
this.props.validation, | ||
setChanged(setTouched([newValue, newTree])) | ||
) | ||
); | ||
this._validateThenApplyChange([newValue, newTree]); | ||
}; | ||
@@ -354,22 +302,18 @@ | ||
); | ||
this.props.link.onChange( | ||
validate( | ||
this.props.validation, | ||
setChanged(setTouched([newValue, newTree])) | ||
) | ||
); | ||
this._validateThenApplyChange([newValue, newTree]); | ||
}; | ||
render() { | ||
const {formState} = this.props.link; | ||
const {formState, path} = this.props.link; | ||
const {shouldShowError} = this.context; | ||
const links = makeLinks( | ||
path, | ||
formState, | ||
this._handleChildChange, | ||
this._handleChildBlur, | ||
this._handleChildValidation | ||
this._handleChildBlur | ||
); | ||
return ( | ||
<React.Fragment key={this.state.nonce}> | ||
<React.Fragment> | ||
{this.props.children( | ||
@@ -376,0 +320,0 @@ links, |
// @flow strict | ||
import * as React from "react"; | ||
import type {FieldLink, ClientErrors, ServerErrors, Err} from "./types"; | ||
import type {FieldLink, ClientErrors, ExternalErrors, Err} from "./types"; | ||
import {FormContext} from "./Form"; | ||
@@ -13,4 +13,4 @@ import {getExtras} from "./formState"; | ||
} | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
if (errors.external !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.external); | ||
} | ||
@@ -25,3 +25,3 @@ return flatErrors; | ||
client: ClientErrors, | ||
server: ServerErrors, | ||
external: ExternalErrors, | ||
flattened: Array<string>, | ||
@@ -38,5 +38,5 @@ }) => React.Node, | ||
client: errors.client, | ||
server: errors.server, | ||
external: errors.external, | ||
flattened, | ||
}); | ||
} |
@@ -6,10 +6,4 @@ // @flow strict | ||
import {mapRoot} from "./shapedTree"; | ||
import {FormContext} from "./Form"; | ||
import { | ||
setExtrasTouched, | ||
getExtras, | ||
setChanged, | ||
validate, | ||
isValid, | ||
} from "./formState"; | ||
import {FormContext, type ValidationOps, validationFnNoOps} from "./Form"; | ||
import {setExtrasTouched, getExtras, isValid} from "./formState"; | ||
@@ -33,4 +27,4 @@ type Props<T> = {| | ||
} | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
if (errors.external !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.external); | ||
} | ||
@@ -46,24 +40,33 @@ return flatErrors; | ||
initialValidate() { | ||
const { | ||
link: {formState, onValidation}, | ||
validation, | ||
} = this.props; | ||
const [value] = formState; | ||
const {errors} = getExtras(formState); | ||
validationFnOps: ValidationOps<T> = validationFnNoOps(); | ||
if (errors.client === "pending") { | ||
onValidation([], validation(value)); | ||
componentDidMount() { | ||
this.validationFnOps = this.context.registerValidation( | ||
this.props.link.path, | ||
this.props.validation | ||
); | ||
} | ||
componentDidUpdate(prevProps: Props<T>) { | ||
if (prevProps.validation !== this.props.validation) { | ||
this.validationFnOps.replace(this.props.validation); | ||
} | ||
} | ||
componentDidMount() { | ||
this.initialValidate(); | ||
componentWillUnmount() { | ||
this.validationFnOps.unregister(); | ||
this.validationFnOps = validationFnNoOps(); | ||
} | ||
onChange: T => void = (newValue: T) => { | ||
const [_, oldTree] = this.props.link.formState; | ||
this.props.link.onChange( | ||
setChanged(validate(this.props.validation, [newValue, oldTree])) | ||
); | ||
const { | ||
path, | ||
formState: [_, oldTree], | ||
onChange, | ||
} = this.props.link; | ||
const newFormState = this.context.updateNodeAtPath(path, [ | ||
newValue, | ||
oldTree, | ||
]); | ||
onChange(newFormState); | ||
}; | ||
@@ -75,3 +78,3 @@ | ||
this.props.link.onBlur( | ||
// TODO(zach): Not sure if we should blow away server errors here | ||
// TODO(zach): Not sure if we should blow away external errors here | ||
mapRoot(setExtrasTouched, tree) | ||
@@ -78,0 +81,0 @@ ); |
387
src/Form.js
// @flow strict | ||
import * as React from "react"; | ||
import invariant from "./utils/invariant"; | ||
import {equals as arrayEquals} from "./utils/array"; | ||
import {changedFormState} from "./formState"; | ||
@@ -8,8 +11,7 @@ import type { | ||
OnBlur, | ||
OnValidation, | ||
Extras, | ||
FieldLink, | ||
ClientErrors, | ||
AdditionalRenderInfo, | ||
} from "./types"; | ||
import {type Direction} from "./tree"; | ||
import { | ||
@@ -24,11 +26,34 @@ type FormState, | ||
type ShapedTree, | ||
type ShapedPath, | ||
shapePath, | ||
updateAtPath, | ||
mapShapedTree, | ||
mapRoot, | ||
pathExistsInTree, | ||
} from "./shapedTree"; | ||
import {pathFromPathString} from "./tree"; | ||
import {pathFromPathString, type Path} from "./tree"; | ||
import { | ||
startsWith, | ||
encodePath, | ||
decodePath, | ||
type EncodedPath, | ||
} from "./EncodedPath"; | ||
import FeedbackStrategies, {type FeedbackStrategy} from "./feedbackStrategies"; | ||
export type FormContextPayload = { | ||
export type ValidationOps<T> = { | ||
unregister: () => void, | ||
replace: (fn: (T) => Array<string>) => void, | ||
}; | ||
const noOps = { | ||
unregister() {}, | ||
replace() {}, | ||
}; | ||
// noOps can't be used directly because Flow doesn't typecheck a constant as | ||
// being parametric in T. | ||
export function validationFnNoOps<T>(): ValidationOps<T> { | ||
return noOps; | ||
} | ||
export type FormContextPayload = {| | ||
shouldShowError: (metaField: MetaField) => boolean, | ||
@@ -39,3 +64,11 @@ // These values are taken into account in shouldShowError, but are also | ||
submitted: boolean, | ||
}; | ||
registerValidation: ( | ||
path: Path, | ||
fn: (mixed) => Array<string> | ||
) => ValidationOps<mixed>, | ||
// TODO(dmnd): Try to get rid of * here by writing a type-level function of | ||
// Path and FormState<T> | ||
updateTreeAtPath: (Path, FormState<*>) => FormState<*>, | ||
updateNodeAtPath: (Path, FormState<*>) => FormState<*>, | ||
|}; | ||
export const FormContext: React.Context<FormContextPayload> = React.createContext( | ||
@@ -46,7 +79,10 @@ { | ||
submitted: true, | ||
registerValidation: () => ({replace: () => {}, unregister: () => {}}), | ||
updateTreeAtPath: (path, formState) => formState, | ||
updateNodeAtPath: (path, formState) => formState, | ||
} | ||
); | ||
function applyServerErrorsToFormState<T>( | ||
serverErrors: null | {[path: string]: Array<string>}, | ||
function applyExternalErrorsToFormState<T>( | ||
externalErrors: null | {[path: string]: Array<string>}, | ||
formState: FormState<T> | ||
@@ -57,7 +93,7 @@ ): FormState<T> { | ||
let tree: ShapedTree<T, Extras>; | ||
if (serverErrors !== null) { | ||
if (externalErrors !== null) { | ||
// If keys do not appear, no errors | ||
tree = mapShapedTree( | ||
({errors, meta}) => ({ | ||
errors: {...errors, server: []}, | ||
errors: {...errors, external: []}, | ||
meta, | ||
@@ -67,4 +103,4 @@ }), | ||
); | ||
Object.keys(serverErrors).forEach(key => { | ||
const newErrors: Array<string> = serverErrors[key]; | ||
Object.keys(externalErrors).forEach(key => { | ||
const newErrors: Array<string> = externalErrors[key]; | ||
const path = shapePath(value, pathFromPathString(key)); | ||
@@ -77,3 +113,3 @@ | ||
({errors, meta}) => ({ | ||
errors: {...errors, server: newErrors}, | ||
errors: {...errors, external: newErrors}, | ||
meta, | ||
@@ -96,3 +132,3 @@ }), | ||
({errors, meta}) => ({ | ||
errors: {...errors, server: []}, | ||
errors: {...errors, external: []}, | ||
meta, | ||
@@ -107,2 +143,166 @@ }), | ||
type Value = | ||
| mixed | ||
| number | ||
| string | ||
| null | ||
| void | ||
| Array<Value> | ||
| {[string]: Value}; | ||
function getValueAtPath(path: Path, value: Value) { | ||
if (path.length === 0) { | ||
return value; | ||
} | ||
const [p, ...rest] = path; | ||
if (p.type === "array") { | ||
invariant( | ||
Array.isArray(value), | ||
"Path/value shape mismatch: expected array" | ||
); | ||
return getValueAtPath(rest, value[p.index]); | ||
} else if (p.type === "object") { | ||
invariant( | ||
typeof value === "object" && value !== null && !Array.isArray(value), | ||
"Path/value shape mismatch: expected object" | ||
); | ||
return getValueAtPath(rest, value[p.key]); | ||
} | ||
throw new Error("Path is too long"); | ||
} | ||
function pathSegmentEqual(a: Direction, b: Direction) { | ||
return ( | ||
(a.type === "array" && b.type === "array" && a.index === b.index) || | ||
(a.type === "object" && b.type === "object" && a.key === b.key) | ||
); | ||
} | ||
function getRelativePath(path: Path, prefix: Path) { | ||
for (let i = 0; i < prefix.length; i++) { | ||
invariant( | ||
pathSegmentEqual(path[i], prefix[i]), | ||
"Expect prefix to be a prefix of path" | ||
); | ||
} | ||
return path.slice(prefix.length); | ||
} | ||
/** | ||
* Deeply updates the FormState tree to reflect a new value. Similar to | ||
* applyValidationToTree, but in response to a deep update, so updates all child | ||
* paths too. Used in response to a custom change. | ||
*/ | ||
function updateTreeAtPath<T>( | ||
prefix: Path, | ||
[initialValue, _initialTree]: FormState<T>, | ||
validations: Map<EncodedPath, Map<number, (mixed) => Array<string>>> | ||
): FormState<T> { | ||
// Create a fresh form state for the new value. Note that this overwrites any | ||
// existing `succeeded` values with false. `succeeded` will only be set back | ||
// to true if and only if the current validation passes. We lose history. For | ||
// most use cases this is likely desirible behaviour, but there could be uses | ||
// cases for which it isn't desired. We'll fix it if we encounter one of those | ||
// use cases. | ||
const [value, changedTree] = changedFormState(initialValue); | ||
const validatedTree = [...validations.entries()] | ||
.filter(([path]) => startsWith(path, prefix)) | ||
.map(([path, validationsMap]) => { | ||
// Note that value is not the root value, it's the value at this path. | ||
// So convert absolute validation paths to relative before attempting to | ||
// pull out the value on which to run them. | ||
const relativePath = getRelativePath(decodePath(path), prefix); | ||
const valueAtPath = getValueAtPath(relativePath, value); | ||
// Run all validation functions on x | ||
const errors = [...validationsMap.values()].reduce( | ||
(errors, validationFn) => errors.concat(validationFn(valueAtPath)), | ||
[] | ||
); | ||
return [relativePath, errors]; | ||
}) | ||
.reduce( | ||
(tree, [path, newErrors]) => | ||
// Here we don't reset `errors: {external}` or set `meta: {touched: true, | ||
// changed: true}`. This is because we already called changedFormState | ||
// above. | ||
updateAtPath( | ||
path, | ||
({errors, meta}) => ({ | ||
errors: {...errors, client: newErrors}, | ||
meta: { | ||
...meta, | ||
succeeded: meta.succeeded || newErrors.length === 0, | ||
}, | ||
}), | ||
tree | ||
), | ||
changedTree | ||
); | ||
return [value, validatedTree]; | ||
} | ||
// Unique id for each field so that errors can be tracked by the fields that | ||
// produced them. This is necessary because it's possible for multiple fields | ||
// to reference the same link "aliasing". | ||
let _nextFieldId = 0; | ||
function nextFieldId() { | ||
return _nextFieldId++; | ||
} | ||
// TODO(dmnd): This function is confusing to use because pathToValue and | ||
// validations are conceptually "absolute" (i.e. they are defined with respect | ||
// to the root), but valueAtPath is *not* absolute: it's the value deeper in the | ||
// tree, defined respective to pathToValue. | ||
function validateAtPath( | ||
pathToValue: Path, | ||
valueAtPath: mixed, // TODO(dmnd): Better typechecking with ShapedPath? | ||
validations: Map<EncodedPath, Map<number, (mixed) => Array<string>>> | ||
): Array<string> { | ||
const map = validations.get(encodePath(pathToValue)); | ||
if (!map) { | ||
return []; | ||
} | ||
return [...map.values()].reduce( | ||
(errors, validationFn) => errors.concat(validationFn(valueAtPath)), | ||
[] | ||
); | ||
} | ||
/** | ||
* Updates the FormState tree to reflect a new value: | ||
* - run validations at path (but not child paths) | ||
* - remove existing, now obsolete errors | ||
* - calculate & write new client side errors | ||
* - ensure that meta reflects that the value has changed | ||
*/ | ||
function updateNodeAtPath<T>( | ||
path: Path, | ||
[value, tree]: FormState<T>, | ||
validations: Map<EncodedPath, Map<number, (mixed) => Array<string>>> | ||
): FormState<T> { | ||
const errors = validateAtPath(path, value, validations); | ||
return [ | ||
value, | ||
mapRoot( | ||
({meta}) => ({ | ||
errors: { | ||
client: errors, | ||
external: "unchecked", | ||
}, | ||
meta: { | ||
...meta, | ||
succeeded: meta.succeeded || errors.length === 0, | ||
touched: true, | ||
changed: true, | ||
}, | ||
}), | ||
tree | ||
), | ||
]; | ||
} | ||
type Props<T, ExtraSubmitData> = {| | ||
@@ -115,3 +315,3 @@ // This is *only* used to intialize the form. Further changes will be ignored | ||
+onValidation: boolean => void, | ||
+serverErrors: null | {[path: string]: Array<string>}, | ||
+externalErrors: null | {[path: string]: Array<string>}, | ||
+children: ( | ||
@@ -127,3 +327,3 @@ link: FieldLink<T>, | ||
submitted: boolean, | ||
oldServerErrors: null | {[path: string]: Array<string>}, | ||
oldExternalErrors: null | {[path: string]: Array<string>}, | ||
}; | ||
@@ -139,3 +339,3 @@ export default class Form<T, ExtraSubmitData> extends React.Component< | ||
feedbackStrategy: FeedbackStrategies.Always, | ||
serverErrors: null, | ||
externalErrors: null, | ||
}; | ||
@@ -147,10 +347,10 @@ | ||
) { | ||
if (props.serverErrors !== state.oldServerErrors) { | ||
const newFormState = applyServerErrorsToFormState<T>( | ||
props.serverErrors, | ||
if (props.externalErrors !== state.oldExternalErrors) { | ||
const newTree = applyExternalErrorsToFormState<T>( | ||
props.externalErrors, | ||
state.formState | ||
); | ||
return { | ||
formState: newFormState, | ||
oldServerErrors: props.serverErrors, | ||
formState: newTree, | ||
oldExternalErrors: props.externalErrors, | ||
}; | ||
@@ -161,7 +361,12 @@ } | ||
validations: Map<EncodedPath, Map<number, (mixed) => Array<string>>>; | ||
initialValidationComplete = false; | ||
constructor(props: Props<T, ExtraSubmitData>) { | ||
super(props); | ||
const formState = applyServerErrorsToFormState( | ||
props.serverErrors, | ||
this.validations = new Map(); | ||
const formState = applyExternalErrorsToFormState( | ||
props.externalErrors, | ||
freshFormState(props.initialValue) | ||
@@ -173,6 +378,26 @@ ); | ||
submitted: false, | ||
oldServerErrors: props.serverErrors, | ||
oldExternalErrors: props.externalErrors, | ||
}; | ||
} | ||
componentDidMount() { | ||
// After the the Form mounts, all validations get ran as a batch. Note that | ||
// this is different from how initial validations get run on all | ||
// subsequently mounted Fields. When a Field is mounted after the Form, its | ||
// validation gets run individually. | ||
// TODO(dmnd): It'd be nice to consolidate validation to a single code path. | ||
// Take care to use an updater to avoid clobbering changes from fields that | ||
// call onChange during cDM. | ||
this.setState( | ||
({formState}) => ({ | ||
formState: updateTreeAtPath([], formState, this.validations), | ||
}), | ||
() => { | ||
this.initialValidationComplete = true; | ||
this.props.onValidation(isValid(this.state.formState)); | ||
} | ||
); | ||
} | ||
// Public API: submit from the outside | ||
@@ -210,24 +435,91 @@ submit(extraData: ExtraSubmitData) { | ||
_handleValidation: OnValidation<T> = ( | ||
path: ShapedPath<T>, | ||
errors: ClientErrors | ||
) => { | ||
// TODO(zach): Move this into formState.js, it is gross | ||
const updater = newErrors => ({errors, meta}) => ({ | ||
errors: {...errors, client: newErrors}, | ||
meta: { | ||
...meta, | ||
succeeded: newErrors.length === 0 ? true : meta.succeeded, | ||
}, | ||
/** | ||
* Keeps validation errors from becoming stale when validation functions of | ||
* children change. | ||
*/ | ||
recomputeErrorsAtPathAndRender = (path: Path) => { | ||
this.setState(({formState: [rootValue, tree]}) => { | ||
const value = getValueAtPath(path, rootValue); | ||
const errors = validateAtPath(path, value, this.validations); | ||
const updatedTree = updateAtPath( | ||
path, | ||
extras => ({...extras, errors: {...extras.errors, client: errors}}), | ||
tree | ||
); | ||
return {formState: [rootValue, updatedTree]}; | ||
}); | ||
this.setState( | ||
({formState: [value, tree]}) => ({ | ||
formState: [value, updateAtPath(path, updater(errors), tree)], | ||
}), | ||
() => { | ||
this.props.onValidation(isValid(this.state.formState)); | ||
} | ||
); | ||
}; | ||
handleRegisterValidation = (path: Path, fn: mixed => Array<string>) => { | ||
const encodedPath = encodePath(path); | ||
let fieldId = nextFieldId(); | ||
const map = this.validations.get(encodedPath) || new Map(); | ||
map.set(fieldId, fn); | ||
this.validations.set(encodedPath, map); | ||
if (this.initialValidationComplete) { | ||
// Form validates all Fields at once during mount. When fields are added | ||
// after the Form has already mounted, their initial values need to be | ||
// validated. | ||
this.recomputeErrorsAtPathAndRender(path); | ||
} | ||
return { | ||
replace: fn => this.replaceValidation(path, fieldId, fn), | ||
unregister: () => this.unregisterValidation(path, fieldId), | ||
}; | ||
}; | ||
replaceValidation = ( | ||
path: Path, | ||
fieldId: number, | ||
fn: mixed => Array<string> | ||
) => { | ||
const encodedPath = encodePath(path); | ||
const map = this.validations.get(encodedPath); | ||
invariant(map != null, "Expected to find handler map"); | ||
const oldFn = map.get(fieldId); | ||
invariant(oldFn != null, "Expected to find previous validation function"); | ||
map.set(fieldId, fn); | ||
// Now that the old validation is gone, make sure there are no left over | ||
// errors from it. | ||
const value = getValueAtPath(path, this.state.formState[0]); | ||
if (arrayEquals(oldFn(value), fn(value))) { | ||
// The errors haven't changed, so don't bother calling setState. | ||
// You might think this is a silly performance optimization but actually | ||
// we need this for annoying React reasons: | ||
// If the validation function is an inline function, its identity changes | ||
// every render. This means replaceValidation gets called every time | ||
// componentDidUpdate runs (i.e. each render). Then when setState is | ||
// called from recomputeErrorsAtPathAndRender, it'll cause another render, | ||
// which causes another componentDidUpdate, and so on. So, take care to | ||
// avoid an infinite loop by returning early here. | ||
return; | ||
} | ||
// The new validation function returns different errors, so re-render. | ||
this.recomputeErrorsAtPathAndRender(path); | ||
}; | ||
unregisterValidation = (path: Path, fieldId: number) => { | ||
const encodedPath = encodePath(path); | ||
const map = this.validations.get(encodedPath); | ||
invariant(map != null, "Couldn't find handler map during unregister"); | ||
map.delete(fieldId); | ||
// If the entire path was deleted from the tree, any left over errors are | ||
// already gone. For example, this happens when an array child is removed. | ||
if (!pathExistsInTree(path, this.state.formState[1])) { | ||
return; | ||
} | ||
// now that the validation is gone, make sure there are no left over | ||
// errors from it | ||
this.recomputeErrorsAtPathAndRender(path); | ||
}; | ||
render() { | ||
@@ -244,2 +536,7 @@ const {formState} = this.state; | ||
shouldShowError: this.props.feedbackStrategy.bind(null, metaForm), | ||
registerValidation: this.handleRegisterValidation, | ||
updateTreeAtPath: (path, formState) => | ||
updateTreeAtPath(path, formState, this.validations), | ||
updateNodeAtPath: (path, formState) => | ||
updateNodeAtPath(path, formState, this.validations), | ||
...metaForm, | ||
@@ -253,3 +550,3 @@ }} | ||
onBlur: this._handleBlur, | ||
onValidation: this._handleValidation, | ||
path: [], | ||
}, | ||
@@ -256,0 +553,0 @@ this._handleSubmit, |
@@ -5,10 +5,7 @@ // @flow strict | ||
type ShapedTree, | ||
mapRoot, | ||
dangerouslyReplaceObjectChild, | ||
dangerouslyReplaceArrayChild, | ||
forgetShape, | ||
dangerouslySetChildren, | ||
shapedObjectChild, | ||
shapedArrayChild, | ||
shapedZipWith, | ||
foldMapShapedTree, | ||
@@ -19,5 +16,4 @@ getRootData, | ||
import {cleanMeta, cleanErrors} from "./types"; | ||
import type {Extras, ClientErrors, Validation, ServerErrors} from "./types"; | ||
import type {Extras} from "./types"; | ||
import {replaceAt} from "./utils/array"; | ||
import invariant from "./utils/invariant"; | ||
@@ -63,4 +59,4 @@ // invariant, Tree is shaped like T | ||
} | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
if (errors.external !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.external); | ||
} | ||
@@ -86,65 +82,2 @@ return flatErrors; | ||
export function validate<T>( | ||
validation: Validation<T>, | ||
formState: FormState<T> | ||
): FormState<T> { | ||
const [value, tree] = formState; | ||
const newErrors = validation(value); | ||
return [ | ||
value, | ||
mapRoot( | ||
({meta}) => ({ | ||
errors: { | ||
client: newErrors, | ||
server: "unchecked", | ||
}, | ||
meta: { | ||
...meta, | ||
succeeded: meta.succeeded || newErrors.length === 0, | ||
}, | ||
}), | ||
tree | ||
), | ||
]; | ||
} | ||
export function setChanged<T>(formState: FormState<T>): FormState<T> { | ||
return [ | ||
formState[0], | ||
mapRoot( | ||
({errors, meta}) => ({ | ||
errors, | ||
meta: {...meta, touched: true, changed: true}, | ||
}), | ||
formState[1] | ||
), | ||
]; | ||
} | ||
export function setTouched<T>(formState: FormState<T>): FormState<T> { | ||
return [ | ||
formState[0], | ||
mapRoot( | ||
({errors, meta}) => ({errors, meta: {...meta, touched: true}}), | ||
formState[1] | ||
), | ||
]; | ||
} | ||
export function setClientErrors<T>( | ||
newErrors: ClientErrors, | ||
formState: FormState<T> | ||
): FormState<T> { | ||
return [ | ||
formState[0], | ||
mapRoot( | ||
({errors, meta}) => ({ | ||
errors: {...errors, client: newErrors}, | ||
meta, | ||
}), | ||
formState[1] | ||
), | ||
]; | ||
} | ||
export function setExtrasTouched({errors, meta}: Extras): Extras { | ||
@@ -180,106 +113,2 @@ return {errors, meta: {...meta, touched: true}}; | ||
export function replaceArrayChildren<E>( | ||
children: Array<FormState<E>>, | ||
formState: FormState<Array<E>> | ||
): FormState<Array<E>> { | ||
const [_, tree] = formState; | ||
const [childValues, childTrees]: [ | ||
Array<E>, | ||
Array<ShapedTree<E, Extras>>, | ||
] = children.reduce( | ||
(memo, child) => { | ||
const [childValue, childTree] = child; | ||
return [memo[0].concat([childValue]), memo[1].concat([childTree])]; | ||
}, | ||
[[], []] | ||
); | ||
return [childValues, dangerouslySetChildren(childTrees, tree)]; | ||
} | ||
function combineExtrasForValidation(oldExtras: Extras, newExtras: Extras) { | ||
const {meta: oldMeta, errors: oldErrors} = oldExtras; | ||
const {meta: newMeta, errors: newErrors} = newExtras; | ||
// Only asyncValidationInFlight + succeeded may change | ||
invariant( | ||
oldMeta.touched === newMeta.touched, | ||
"Recieved a new meta.touched when monoidally combining errors" | ||
); | ||
invariant( | ||
oldMeta.changed === newMeta.changed, | ||
"Recieved a new meta.changed when monoidally combining errors" | ||
); | ||
// No combination is possible if the old client errors are not pending | ||
if (oldErrors.client !== "pending") { | ||
return oldExtras; | ||
} | ||
// No combination is possible if the new client errors are pending | ||
if (newErrors.client === "pending") { | ||
return oldExtras; | ||
} | ||
return { | ||
meta: { | ||
touched: oldMeta.touched, | ||
changed: oldMeta.changed, | ||
succeeded: newMeta.succeeded, | ||
asyncValidationInFlight: | ||
oldMeta.asyncValidationInFlight || newMeta.asyncValidationInFlight, | ||
}, | ||
errors: { | ||
client: newErrors.client, | ||
server: newErrors.server, | ||
}, | ||
}; | ||
} | ||
function monoidallyCombineTreesForValidation<T>( | ||
oldTree: ShapedTree<T, Extras>, | ||
newTree: ShapedTree<T, Extras> | ||
): ShapedTree<T, Extras> { | ||
return shapedZipWith(combineExtrasForValidation, oldTree, newTree); | ||
} | ||
// Also sets asyncValidationInFlight | ||
export function monoidallyCombineFormStatesForValidation<T>( | ||
oldState: FormState<T>, | ||
newState: FormState<T> | ||
): FormState<T> { | ||
// Value should never change when combining errors | ||
invariant( | ||
oldState[0] === newState[0], | ||
"Received a new value when monoidally combining errors" | ||
); | ||
return [ | ||
oldState[0], | ||
monoidallyCombineTreesForValidation(oldState[1], newState[1]), | ||
]; | ||
} | ||
function replaceServerErrorsExtra( | ||
newErrors: ServerErrors, | ||
oldExtras: Extras | ||
): Extras { | ||
const {meta, errors} = oldExtras; | ||
return { | ||
meta, | ||
errors: { | ||
client: errors.client, | ||
server: newErrors, | ||
}, | ||
}; | ||
} | ||
export function replaceServerErrors<T>( | ||
serverErrors: ShapedTree<T, ServerErrors>, | ||
formState: FormState<T> | ||
): FormState<T> { | ||
return [ | ||
formState[0], | ||
shapedZipWith(replaceServerErrorsExtra, serverErrors, formState[1]), | ||
]; | ||
} | ||
// Is whole tree client valid? | ||
@@ -286,0 +115,0 @@ // TODO(zach): This will have to change with asynchronous validations. We will |
@@ -9,25 +9,21 @@ // @flow strict | ||
Extras, | ||
ClientErrors, | ||
AdditionalRenderInfo, | ||
CustomChange, | ||
} from "./types"; | ||
import {FormContext} from "./Form"; | ||
import {FormContext, type ValidationOps, validationFnNoOps} from "./Form"; | ||
import { | ||
type FormState, | ||
setChanged, | ||
replaceObjectChild, | ||
setExtrasTouched, | ||
objectChild, | ||
validate, | ||
getExtras, | ||
flatRootErrors, | ||
isValid, | ||
changedFormState, | ||
} from "./formState"; | ||
import { | ||
type ShapedTree, | ||
type ShapedPath, | ||
mapRoot, | ||
dangerouslyReplaceObjectChild, | ||
} from "./shapedTree"; | ||
import type {Path} from "./tree"; | ||
@@ -48,6 +44,6 @@ type ToFieldLink = <T>(T) => FieldLink<T>; | ||
function makeLinks<T: {}, V>( | ||
path: Path, | ||
formState: FormState<T>, | ||
onChildChange: (string, FormState<V>) => void, | ||
onChildBlur: (string, ShapedTree<V, Extras>) => void, | ||
onChildValidation: (string, ShapedPath<V>, ClientErrors) => void | ||
onChildBlur: (string, ShapedTree<V, Extras>) => void | ||
): Links<T> { | ||
@@ -64,5 +60,3 @@ const [value] = formState; | ||
}, | ||
onValidation: (path, errors) => { | ||
onChildValidation(k, path, errors); | ||
}, | ||
path: [...path, {type: "object", key: k}], | ||
}; | ||
@@ -77,9 +71,5 @@ memo[k] = l; | ||
type State = {| | ||
nonce: number, | ||
|}; | ||
export default class ObjectField<T: {}> extends React.Component< | ||
Props<T>, | ||
State | ||
void | ||
> { | ||
@@ -92,27 +82,22 @@ static contextType = FormContext; | ||
state = { | ||
nonce: 0, | ||
}; | ||
validationFnOps: ValidationOps<T> = validationFnNoOps(); | ||
_initialValidate() { | ||
const { | ||
link: {formState, onValidation}, | ||
validation, | ||
} = this.props; | ||
const [value] = formState; | ||
const {errors} = getExtras(formState); | ||
componentDidMount() { | ||
this.validationFnOps = this.context.registerValidation( | ||
this.props.link.path, | ||
this.props.validation | ||
); | ||
} | ||
if (errors.client === "pending") { | ||
onValidation([], validation(value)); | ||
componentDidUpdate(prevProps: Props<T>) { | ||
if (prevProps.validation !== this.props.validation) { | ||
this.validationFnOps.replace(this.props.validation); | ||
} | ||
} | ||
componentDidMount() { | ||
this._initialValidate(); | ||
componentWillUnmount() { | ||
this.validationFnOps.unregister(); | ||
this.validationFnOps = validationFnNoOps(); | ||
} | ||
forceChildRemount() { | ||
this.setState(({nonce}) => ({nonce: nonce + 1})); | ||
} | ||
_handleChildChange: <V>(string, FormState<V>) => void = <V>( | ||
@@ -130,23 +115,20 @@ key: string, | ||
const newValue = newFormState[0]; | ||
const customValue = | ||
this.props.customChange && this.props.customChange(oldValue, newValue); | ||
let nextFormState: FormState<T>; | ||
let validatedFormState: FormState<T>; | ||
if (customValue) { | ||
// Create a fresh form state for the new value. | ||
// TODO(zach): It's kind of gross that this is happening outside of Form. | ||
nextFormState = changedFormState(customValue); | ||
// A custom change occurred, which means the whole object needs to be | ||
// revalidated. | ||
validatedFormState = this.context.updateTreeAtPath(this.props.link.path, [ | ||
customValue, | ||
newFormState[1], | ||
]); | ||
} else { | ||
nextFormState = newFormState; | ||
validatedFormState = this.context.updateNodeAtPath( | ||
this.props.link.path, | ||
newFormState | ||
); | ||
} | ||
this.props.link.onChange( | ||
setChanged(validate(this.props.validation, nextFormState)) | ||
); | ||
// Need to remount children so they will run validations | ||
if (customValue) { | ||
this.forceChildRemount(); | ||
} | ||
this.props.link.onChange(validatedFormState); | ||
}; | ||
@@ -167,19 +149,2 @@ | ||
_handleChildValidation: <V>(string, ShapedPath<V>, ClientErrors) => void = < | ||
V | ||
>( | ||
key: string, | ||
childPath: ShapedPath<V>, | ||
errors: ClientErrors | ||
) => { | ||
const extendedPath = [ | ||
{ | ||
type: "object", | ||
key, | ||
}, | ||
...childPath, | ||
]; | ||
this.props.link.onValidation(extendedPath, errors); | ||
}; | ||
render() { | ||
@@ -190,9 +155,9 @@ const {formState} = this.props.link; | ||
const links = makeLinks( | ||
this.props.link.path, | ||
this.props.link.formState, | ||
this._handleChildChange, | ||
this._handleChildBlur, | ||
this._handleChildValidation | ||
this._handleChildBlur | ||
); | ||
return ( | ||
<React.Fragment key={this.state.nonce}> | ||
<React.Fragment> | ||
{this.props.children(links, { | ||
@@ -199,0 +164,0 @@ touched: getExtras(formState).meta.touched, |
// @flow strict | ||
import { | ||
type Tree, | ||
type Path, | ||
leaf, | ||
strictZipWith, | ||
mapTree, | ||
foldMapTree, | ||
} from "./tree"; | ||
import {type Tree, type Path, mapTree, foldMapTree} from "./tree"; | ||
import invariant from "./utils/invariant"; | ||
@@ -89,2 +82,50 @@ import {replaceAt} from "./utils/array"; | ||
export function pathExistsInTree<T, Node>( | ||
path: Path, | ||
tree: ShapedTree<T, Node> | ||
): boolean { | ||
// This function is equivalent to: | ||
// try { | ||
// updateAtPath(path, x => x, tree); | ||
// } catch { | ||
// return false; | ||
// } | ||
// return true; | ||
// } | ||
if (path.length === 0 && tree.type != null) { | ||
return true; | ||
} | ||
const [first, ...rest] = path; | ||
if (tree.type === "leaf") { | ||
return false; | ||
} | ||
if (tree.type === "array") { | ||
if (first.type !== "array") { | ||
return false; | ||
} | ||
if (!(0 <= first.index && first.index < tree.children.length)) { | ||
return false; | ||
} | ||
return pathExistsInTree(rest, tree.children[first.index]); | ||
} | ||
if (tree.type === "object") { | ||
if (first.type !== "object") { | ||
return false; | ||
} | ||
if (!(first.key in tree.children)) { | ||
return false; | ||
} | ||
return pathExistsInTree(rest, tree.children[first.key]); | ||
} | ||
throw new Error("unreachable"); | ||
} | ||
export function updateAtPath<T, Node>( | ||
@@ -95,3 +136,2 @@ path: ShapedPath<T>, | ||
): ShapedTree<T, Node> { | ||
// console.log("updateAtPath()", path, tree); | ||
if (path.length === 0) { | ||
@@ -128,2 +168,8 @@ if (tree.type === "object") { | ||
); | ||
invariant( | ||
0 <= firstStep.index && firstStep.index < tree.children.length, | ||
`Tried to take path index ${ | ||
firstStep.index | ||
} but array from tree has length ${tree.children.length}` | ||
); | ||
const newChild = updateAtPath( | ||
@@ -142,7 +188,10 @@ restStep, | ||
); | ||
const newChild = updateAtPath( | ||
restStep, | ||
updater, | ||
tree.children[firstStep.key] | ||
const nextTree = tree.children[firstStep.key]; | ||
invariant( | ||
nextTree !== undefined, | ||
`Tried to take path key ${ | ||
firstStep.key | ||
} but it isn't present on object in tree` | ||
); | ||
const newChild = updateAtPath(restStep, updater, nextTree); | ||
// $FlowFixMe(zach): I think this is safe, might need GADTs for the type checker to understand why | ||
@@ -154,33 +203,2 @@ return dangerouslyReplaceObjectChild(firstStep.key, newChild, tree); | ||
export function checkShape<T, Node>( | ||
value: T, | ||
tree: Tree<Node> | ||
): ShapedTree<T, Node> { | ||
if (tree.type === "array") { | ||
invariant(Array.isArray(value), "value isn't an array"); | ||
invariant( | ||
value.length === tree.children.length, | ||
"value and tree children have different lengths" | ||
); | ||
tree.children.forEach((child, i) => { | ||
checkShape(value[i], child); | ||
}); | ||
} | ||
if (tree.type === "object") { | ||
invariant(value instanceof Object, "value isn't an object in checkTree"); | ||
const valueEntries = Object.entries(value); | ||
const childrenKeys = new Set(Object.keys(tree.children)); | ||
invariant( | ||
valueEntries.length === childrenKeys.size, | ||
"value doesn't have the right number of keys" | ||
); | ||
valueEntries.forEach(([key, value]) => { | ||
invariant(childrenKeys.has(key)); | ||
checkShape(value, tree.children[key]); | ||
}); | ||
} | ||
// leaves are allowed to stand in for complex types in T | ||
return tree; | ||
} | ||
export function shapedArrayChild<E, Node>( | ||
@@ -300,16 +318,2 @@ index: number, | ||
// A leaf matches any shape | ||
export function shapedLeaf<T, Node>(node: Node): ShapedTree<T, Node> { | ||
return leaf(node); | ||
} | ||
export function shapedZipWith<T, A, B, C>( | ||
f: (A, B) => C, | ||
left: ShapedTree<T, A>, | ||
right: ShapedTree<T, B> | ||
): ShapedTree<T, C> { | ||
// Don't actually need the checks here if our invariant holds | ||
return strictZipWith(f, left, right); | ||
} | ||
// Mapping doesn't change the shape | ||
@@ -316,0 +320,0 @@ export function mapShapedTree<T, A, B>( |
@@ -5,5 +5,7 @@ // @flow | ||
import TestRenderer from "react-test-renderer"; | ||
import Form from "../Form"; | ||
import ArrayField from "../ArrayField"; | ||
import {type FieldLink} from "../types"; | ||
import TestField, {TestInput} from "./TestField"; | ||
import TestForm from "./TestForm"; | ||
@@ -13,103 +15,95 @@ import {expectLink, mockLink, mockFormState} from "./tools"; | ||
describe("ArrayField", () => { | ||
describe("ArrayField is a field", () => { | ||
describe("validates on mount", () => { | ||
it("ensures that the link inner type matches the type of the validation", () => { | ||
const formState = mockFormState(["one", "two", "three"]); | ||
const link = mockLink(formState); | ||
describe("is a field", () => { | ||
it("ensures that the link inner type matches the type of the validation", () => { | ||
const formState = mockFormState(["one", "two", "three"]); | ||
const link = mockLink(formState); | ||
// $ExpectError | ||
<ArrayField link={link} validation={(_e: empty) => []}> | ||
{() => null} | ||
</ArrayField>; | ||
// $ExpectError | ||
<ArrayField link={link} validation={(_e: empty) => []}> | ||
{() => null} | ||
</ArrayField>; | ||
<ArrayField link={link} validation={(_e: Array<string>) => []}> | ||
{() => null} | ||
</ArrayField>; | ||
}); | ||
<ArrayField link={link} validation={(_e: Array<string>) => []}> | ||
{() => null} | ||
</ArrayField>; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are no errors", () => { | ||
const validation = jest.fn(() => []); | ||
const formState = mockFormState([]); | ||
const link = mockLink(formState); | ||
it("Registers and unregisters for validation", () => { | ||
const formState = mockFormState([]); | ||
const link = mockLink(formState); | ||
const unregister = jest.fn(); | ||
const registerValidation = jest.fn(() => ({ | ||
replace: jest.fn(), | ||
unregister, | ||
})); | ||
TestRenderer.create( | ||
<ArrayField link={link} validation={validation}> | ||
{jest.fn(() => null)} | ||
const renderer = TestRenderer.create( | ||
<TestForm registerValidation={registerValidation}> | ||
<ArrayField link={link} validation={() => []}> | ||
{() => null} | ||
</ArrayField> | ||
); | ||
</TestForm> | ||
); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.unmount(); | ||
expect(unregister).toBeCalledTimes(1); | ||
}); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
it("calls replace when changing the validation function", () => { | ||
const replace = jest.fn(); | ||
const registerValidation = jest.fn(() => ({ | ||
replace, | ||
unregister: jest.fn(), | ||
})); | ||
it("Sets errors.client and meta.succeeded when there are errors", () => { | ||
const validation = jest.fn(() => ["This is an error", "another error"]); | ||
const formState = mockFormState([]); | ||
const link = mockLink(formState); | ||
TestRenderer.create( | ||
<ArrayField link={link} validation={validation}> | ||
{jest.fn(() => null)} | ||
</ArrayField> | ||
function Component() { | ||
return ( | ||
<TestForm registerValidation={registerValidation}> | ||
<ArrayField | ||
link={mockLink(mockFormState(["hello", "world"]))} | ||
validation={() => []} | ||
> | ||
{() => null} | ||
</ArrayField> | ||
</TestForm> | ||
); | ||
} | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
const renderer = TestRenderer.create(<Component />); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual(["This is an error", "another error"]); | ||
}); | ||
renderer.update(<Component />); | ||
expect(replace).toBeCalledTimes(1); | ||
}); | ||
it("Treats no validation as always passing", () => { | ||
const formState = mockFormState([]); | ||
const link = mockLink(formState); | ||
it("Passes additional information to its render function", () => { | ||
const formState = mockFormState(["value"]); | ||
// $FlowFixMe | ||
formState[1].data.errors = { | ||
external: ["An external error"], | ||
client: ["A client error"], | ||
}; | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create( | ||
<ArrayField link={link}>{jest.fn(() => null)}</ArrayField> | ||
); | ||
TestRenderer.create(<ArrayField link={link}>{renderFn}</ArrayField>); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
it("Passes additional information to its render function", () => { | ||
const formState = mockFormState(["value"]); | ||
// $FlowFixMe | ||
formState[1].data.errors = { | ||
server: ["A server error"], | ||
client: ["A client error"], | ||
}; | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create(<ArrayField link={link}>{renderFn}</ArrayField>); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith( | ||
expect.anything(), | ||
expect.anything(), | ||
expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining([ | ||
"A server error", | ||
"A client error", | ||
]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: ["value"], | ||
}) | ||
); | ||
}); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith( | ||
expect.anything(), | ||
expect.anything(), | ||
expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining([ | ||
"An external error", | ||
"A client error", | ||
]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: ["value"], | ||
}) | ||
); | ||
}); | ||
@@ -153,10 +147,19 @@ }); | ||
it("calls onChange when a child changes", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
it("validates new values from children and passes result to onChange", () => { | ||
const formState = mockFormState(["one", "two", "three"]); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create(<ArrayField link={link}>{renderFn}</ArrayField>); | ||
const updateNodeAtPath = jest.fn((path, formState) => formState); | ||
TestRenderer.create( | ||
<TestForm updateNodeAtPath={updateNodeAtPath}> | ||
<ArrayField link={link}>{renderFn}</ArrayField> | ||
</TestForm> | ||
); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(0); | ||
expect(link.onChange).toHaveBeenCalledTimes(0); | ||
// call a child's onChange | ||
const arrayLinks = renderFn.mock.calls[0][0]; | ||
@@ -166,10 +169,13 @@ const newElementFormState = mockFormState("newTwo"); | ||
expect(link.onChange).toHaveBeenCalled(); | ||
const newArrayFormState = link.onChange.mock.calls[0][0]; | ||
expect(newArrayFormState[0]).toEqual(["one", "newTwo", "three"]); | ||
expect(newArrayFormState[1].data.meta).toMatchObject({ | ||
touched: true, | ||
changed: true, | ||
}); | ||
expect(newArrayFormState[1].children[1]).toBe(newElementFormState[1]); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(1); | ||
expect(updateNodeAtPath).toHaveBeenCalledWith( | ||
[], | ||
[["one", "newTwo", "three"], expect.anything()] | ||
); | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([ | ||
["one", "newTwo", "three"], | ||
formState[1], | ||
]); | ||
}); | ||
@@ -198,24 +204,3 @@ | ||
it("calls onValidation when a child initially validates", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create(<ArrayField link={link}>{renderFn}</ArrayField>); | ||
const arrayLinks = renderFn.mock.calls[0][0]; | ||
arrayLinks[2].onValidation([], ["These are", "some errors"]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(2); | ||
// Important: the first call to onValidation is for the initial render validation | ||
const [path, errors] = link.onValidation.mock.calls[1]; | ||
expect(path).toEqual([{type: "array", index: 2}]); | ||
expect(errors).toEqual(["These are", "some errors"]); | ||
}); | ||
it("calls its validation when a child changes", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -225,5 +210,9 @@ const validation = jest.fn(() => ["This is an error"]); | ||
TestRenderer.create( | ||
<ArrayField link={link} validation={validation}> | ||
{renderFn} | ||
</ArrayField> | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField link={link} validation={validation}> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -261,5 +250,2 @@ | ||
it("validates after entry is added", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -269,5 +255,9 @@ const validation = jest.fn(() => ["an error"]); | ||
TestRenderer.create( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -308,12 +298,17 @@ | ||
it("validates after entry is removed", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
const elementValidation = jest.fn(() => ["an element error"]); | ||
const renderFn = jest.fn(links => | ||
links.map((link, i) => ( | ||
<TestField key={i} link={link} validation={elementValidation} /> | ||
)) | ||
); | ||
const validation = jest.fn(() => ["an error"]); | ||
TestRenderer.create( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -349,5 +344,2 @@ | ||
it("validates after the entry is moved", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -357,5 +349,9 @@ const validation = jest.fn(() => ["an error"]); | ||
TestRenderer.create( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -391,5 +387,2 @@ | ||
it("validates after fields are added", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -399,5 +392,9 @@ const validation = jest.fn(() => ["an error"]); | ||
TestRenderer.create( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -441,5 +438,2 @@ | ||
it("validates after fields are filtered", () => { | ||
const formStateValue = ["one", "two", "three", "four", "five"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -449,5 +443,9 @@ const validation = jest.fn(() => ["an error"]); | ||
TestRenderer.create( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -484,5 +482,2 @@ | ||
it("validates after fields are modified", () => { | ||
const formStateValue = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateValue); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -492,5 +487,9 @@ const validation = jest.fn(() => ["an error"]); | ||
TestRenderer.create( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField validation={validation} link={link}> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -519,3 +518,3 @@ | ||
describe("customChange", () => { | ||
it("allows the default change behavior to be overwritten with customChange", () => { | ||
it("allows sibling fields to be overwritten", () => { | ||
const formStateInner = ["one", "two", "three"]; | ||
@@ -534,9 +533,11 @@ const formState = mockFormState(formStateInner); | ||
TestRenderer.create( | ||
<ArrayField | ||
link={link} | ||
validation={validation} | ||
customChange={customChange} | ||
> | ||
{renderFn} | ||
</ArrayField> | ||
<TestForm> | ||
<ArrayField | ||
link={link} | ||
validation={validation} | ||
customChange={customChange} | ||
> | ||
{renderFn} | ||
</ArrayField> | ||
</TestForm> | ||
); | ||
@@ -562,25 +563,22 @@ | ||
]); | ||
// Validated the result of customChange | ||
expect(validation).toHaveBeenCalledTimes(2); | ||
expect(validation.mock.calls[1][0]).toEqual(["uno", "dos", "tres"]); | ||
}); | ||
it("can return null to signal there was no custom change", () => { | ||
const formStateInner = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateInner); | ||
const link = mockLink(formState); | ||
const customChange = jest.fn((_oldValue, _newValue) => null); | ||
const renderFn = jest.fn(() => null); | ||
const validation = jest.fn(() => ["This is an error"]); | ||
const validation = jest.fn(() => ["an error"]); | ||
const customChange = jest.fn((_oldValue, _newValue) => null); | ||
TestRenderer.create( | ||
<ArrayField | ||
link={link} | ||
validation={validation} | ||
customChange={customChange} | ||
> | ||
{renderFn} | ||
</ArrayField> | ||
const renderer = TestRenderer.create( | ||
<Form initialValue={["one", "two", "three"]}> | ||
{link => ( | ||
<ArrayField | ||
validation={validation} | ||
link={link} | ||
customChange={customChange} | ||
> | ||
{renderFn} | ||
</ArrayField> | ||
)} | ||
</Form> | ||
); | ||
@@ -596,4 +594,4 @@ | ||
// onChange should be called with the result of customChange | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([ | ||
const link = renderer.root.findByType(ArrayField).instance.props.link; | ||
expect(link.formState).toEqual([ | ||
["one", "zwei", "three"], | ||
@@ -609,109 +607,81 @@ expect.anything(), | ||
it("doesn't break validations for child fields", () => { | ||
const formStateInner = ["one", "two", "three"]; | ||
const formState = mockFormState(formStateInner); | ||
const link = mockLink(formState); | ||
const customChange = jest.fn((_oldValue, _newValue) => ["1", "2"]); | ||
const childValidation = jest.fn(() => ["This is an error"]); | ||
const parentValidation = jest.fn(() => [ | ||
"This is an error from the parent", | ||
]); | ||
const renderer = TestRenderer.create( | ||
<ArrayField link={link} customChange={customChange}> | ||
{links => ( | ||
<React.Fragment> | ||
{links.map((link, i) => ( | ||
<TestField key={i} link={link} validation={childValidation} /> | ||
))} | ||
</React.Fragment> | ||
<Form initialValue={["1", "2"]}> | ||
{link => ( | ||
<ArrayField | ||
link={link} | ||
customChange={customChange} | ||
validation={parentValidation} | ||
> | ||
{links => ( | ||
<React.Fragment> | ||
{links.map((link, i) => ( | ||
<TestField | ||
key={i} | ||
link={link} | ||
validation={childValidation} | ||
/> | ||
))} | ||
</React.Fragment> | ||
)} | ||
</ArrayField> | ||
)} | ||
</ArrayField> | ||
</Form> | ||
); | ||
// 6 validations: | ||
// 1) Child initial validation x3 | ||
// 2) Parent initial validation | ||
// 3) Child validation on remount x3 | ||
// (No parent onValidation call, because it will use onChange) | ||
// after mount, validate everything | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(2); | ||
// 1) and 2) | ||
expect(link.onValidation).toHaveBeenCalledTimes(4); | ||
link.onValidation.mockClear(); | ||
// Now change one of the values | ||
parentValidation.mockClear(); | ||
childValidation.mockClear(); | ||
const inner = renderer.root.findAllByType(TestInput)[0]; | ||
inner.instance.change("zach"); | ||
// 3) | ||
expect(link.onValidation).toHaveBeenCalledTimes(3); | ||
expect(link.onValidation).toHaveBeenCalledWith( | ||
[{type: "array", index: 0}], | ||
["This is an error"] | ||
// Validate the whole subtree due to the customChange child validates | ||
// once. Note that child validation will be called 3 times. Once after the | ||
// change, then twice more after the customChange triggers a validation fo | ||
// the entire subtree. | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(1 + 2); | ||
const link = renderer.root.findByType(ArrayField).instance.props.link; | ||
expect(link.formState).toEqual([["1", "2"], expect.anything()]); | ||
}); | ||
it("doesn't create a new instance (i.e. remount)", () => { | ||
const customChange = jest.fn((_oldValue, _newValue) => ["uno", "dos"]); | ||
const renderer = TestRenderer.create( | ||
<ArrayField | ||
link={mockLink(mockFormState(["1", "2"]))} | ||
customChange={customChange} | ||
> | ||
{links => <TestField link={links[0]} />} | ||
</ArrayField> | ||
); | ||
expect(link.onValidation).toHaveBeenCalledWith( | ||
[{type: "array", index: 1}], | ||
["This is an error"] | ||
); | ||
// NOTE(zach): This may be surprising since there are only two values in | ||
// the new value, but there is no guarantee that the next commit will | ||
// have occurred yet. | ||
expect(link.onValidation).toHaveBeenCalledWith( | ||
[{type: "array", index: 2}], | ||
["This is an error"] | ||
); | ||
// onChange should be called with the result of customChange | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([ | ||
["1", "2"], | ||
{ | ||
type: "array", | ||
data: { | ||
errors: { | ||
client: [], | ||
server: "unchecked", | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: true, | ||
asyncValidationInFlight: false, | ||
}, | ||
}, | ||
children: [ | ||
{ | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked", | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false, | ||
}, | ||
}, | ||
}, | ||
{ | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked", | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false, | ||
}, | ||
}, | ||
}, | ||
], | ||
}, | ||
]); | ||
const testInstance = renderer.root.findAllByType(TestInput)[0].instance; | ||
// now trigger a customChange, which used to cause a remount | ||
testInstance.change("hi"); | ||
expect(customChange).toHaveBeenCalledTimes(1); | ||
// but we no longer cause a remount, so the instances should be the same | ||
const nextTestInstance = renderer.root.findAllByType(TestInput)[0] | ||
.instance; | ||
// Using Object.is here because toBe hangs as the objects are | ||
// self-referential and thus not printable | ||
expect(Object.is(testInstance, nextTestInstance)).toBe(true); | ||
}); | ||
}); | ||
}); |
@@ -10,84 +10,98 @@ // @flow | ||
import TestField, {TestInput} from "./TestField"; | ||
import TestForm from "./TestForm"; | ||
import {mapRoot} from "../shapedTree"; | ||
describe("Field", () => { | ||
describe("validates on mount", () => { | ||
it("ensures that the link inner type matches the type of the validation", () => { | ||
const formState = mockFormState("Hello world."); | ||
const link = mockLink(formState); | ||
it("ensures that the link inner type matches the type of the validation", () => { | ||
const formState = mockFormState("Hello world."); | ||
const link = mockLink(formState); | ||
// $ExpectError | ||
<Field link={link} validation={(_e: empty) => []}> | ||
{() => null} | ||
</Field>; | ||
// $ExpectError | ||
<Field link={link} validation={(_e: empty) => []}> | ||
{() => null} | ||
</Field>; | ||
<Field link={link} validation={(_e: string) => []}> | ||
{() => null} | ||
</Field>; | ||
}); | ||
<Field link={link} validation={(_e: string) => []}> | ||
{() => null} | ||
</Field>; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are no errors", () => { | ||
const formState = mockFormState("Hello world."); | ||
const link = mockLink(formState); | ||
const validation = jest.fn(() => []); | ||
it("registers and unregisters for validation", () => { | ||
const formState = mockFormState("Hello world."); | ||
const link = mockLink(formState); | ||
const unregister = jest.fn(); | ||
const registerValidation = jest.fn(() => ({ | ||
replace: jest.fn(), | ||
unregister, | ||
})); | ||
TestRenderer.create(<TestField link={link} validation={validation} />); | ||
const renderer = TestRenderer.create( | ||
<TestForm registerValidation={registerValidation}> | ||
<Field link={link} validation={jest.fn(() => [])}> | ||
{jest.fn(() => null)} | ||
</Field> | ||
</TestForm> | ||
); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.unmount(); | ||
expect(unregister).toBeCalledTimes(1); | ||
}); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
it("calls replace when changing the validation function", () => { | ||
const replace = jest.fn(); | ||
const registerValidation = jest.fn(() => ({ | ||
replace, | ||
unregister: jest.fn(), | ||
})); | ||
it("Sets errors.client and meta.succeeded when there are errors", () => { | ||
const formState = mockFormState("Hello world."); | ||
const link = mockLink(formState); | ||
const validation = jest.fn(() => ["This is an error"]); | ||
function Component() { | ||
return ( | ||
<TestForm registerValidation={registerValidation}> | ||
<Field | ||
link={mockLink(mockFormState("Hello world."))} | ||
validation={() => []} | ||
> | ||
{() => null} | ||
</Field> | ||
</TestForm> | ||
); | ||
} | ||
TestRenderer.create(<TestField link={link} validation={validation} />); | ||
const renderer = TestRenderer.create(<Component />); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual(["This is an error"]); | ||
}); | ||
it("Counts as successfully validated if there is no validation", () => { | ||
const formState = mockFormState("Hello world."); | ||
const link = mockLink(formState); | ||
TestRenderer.create(<TestField link={link} />); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
renderer.update(<Component />); | ||
expect(replace).toBeCalledTimes(1); | ||
}); | ||
it("calls the link onChange with new values and correct meta", () => { | ||
it("validates new values and passes result to onChange", () => { | ||
const formState = mockFormState("Hello world."); | ||
const link = mockLink(formState); | ||
const renderer = TestRenderer.create(<TestField link={link} />); | ||
const updateNodeAtPath = jest.fn((path, formState) => formState); | ||
const renderer = TestRenderer.create( | ||
<TestForm updateNodeAtPath={updateNodeAtPath}> | ||
<TestField link={link} /> | ||
</TestForm> | ||
); | ||
const inner = renderer.root.findByType(TestInput); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(0); | ||
expect(link.onChange).toHaveBeenCalledTimes(0); | ||
inner.instance.change("You've got mail"); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(1); | ||
expect(updateNodeAtPath).toHaveBeenCalledWith( | ||
[], | ||
["You've got mail", expect.anything()] | ||
); | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
const [value, tree] = link.onChange.mock.calls[0][0]; | ||
expect(value).toBe("You've got mail"); | ||
expect(tree.data).toMatchObject({ | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: true, | ||
}, | ||
}); | ||
expect(link.onChange).toHaveBeenCalledWith([ | ||
"You've got mail", | ||
formState[1], | ||
]); | ||
}); | ||
@@ -123,3 +137,3 @@ | ||
client: ["Some", "client", "errors"], | ||
server: ["Server errors", "go here"], | ||
external: ["External errors", "go here"], | ||
}, | ||
@@ -138,3 +152,3 @@ }), | ||
"errors", | ||
"Server errors", | ||
"External errors", | ||
"go here", | ||
@@ -179,3 +193,3 @@ ]); | ||
formState[1].data.errors = { | ||
server: ["A server error"], | ||
external: ["An external error"], | ||
client: ["A client error"], | ||
@@ -199,3 +213,3 @@ }; | ||
unfilteredErrors: expect.arrayContaining([ | ||
"A server error", | ||
"An external error", | ||
"A client error", | ||
@@ -202,0 +216,0 @@ ]), |
@@ -10,5 +10,7 @@ // @flow | ||
import Field from "../Field"; | ||
import type {FieldLink} from "../types"; | ||
import {expectLink, mockFormState} from "./tools"; | ||
import TestField, {TestInput} from "./TestField"; | ||
import LinkTap from "../testutils/LinkTap"; | ||
import {forgetShape} from "../shapedTree"; | ||
@@ -45,4 +47,250 @@ | ||
describe("Form", () => { | ||
describe("validations", () => { | ||
it("runs validations", () => { | ||
const objectValidation = jest.fn(() => []); | ||
const arrayValidation = jest.fn(() => []); | ||
const arrayElValidation = jest.fn(() => []); | ||
const fieldValidation = jest.fn(() => []); | ||
TestRenderer.create( | ||
<Form initialValue={{a: ["1", "2"], s: "string"}}> | ||
{link => ( | ||
<ObjectField link={link} validation={objectValidation}> | ||
{links => ( | ||
<> | ||
<ArrayField link={links.a} validation={arrayValidation}> | ||
{links => | ||
links.map((link, i) => ( | ||
<TestField | ||
key={i} | ||
link={link} | ||
validation={arrayElValidation} | ||
/> | ||
)) | ||
} | ||
</ArrayField> | ||
<TestField link={links.s} validation={fieldValidation} /> | ||
</> | ||
)} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
expect(objectValidation).toHaveBeenCalledTimes(1); | ||
expect(objectValidation).toHaveBeenCalledWith({ | ||
a: ["1", "2"], | ||
s: "string", | ||
}); | ||
expect(arrayValidation).toHaveBeenCalledTimes(1); | ||
expect(arrayValidation).toHaveBeenCalledWith(["1", "2"]); | ||
expect(arrayElValidation).toHaveBeenCalledTimes(2); | ||
expect(arrayElValidation).toHaveBeenCalledWith("1"); | ||
expect(arrayElValidation).toHaveBeenCalledWith("2"); | ||
expect(fieldValidation).toHaveBeenCalledTimes(1); | ||
expect(fieldValidation).toHaveBeenCalledWith("string"); | ||
}); | ||
it("sets validation information on formState", () => { | ||
const objectValidation = jest.fn(() => ["object error"]); | ||
const arrayValidation = jest.fn(() => ["array", "error"]); | ||
const arrayElValidation = jest.fn(s => [`error ${s}`]); | ||
const fieldValidation = jest.fn(() => []); | ||
const renderer = TestRenderer.create( | ||
<Form initialValue={{a: ["1", "2"], s: "string"}}> | ||
{link => ( | ||
<ObjectField link={link} validation={objectValidation}> | ||
{links => ( | ||
<> | ||
<ArrayField link={links.a} validation={arrayValidation}> | ||
{links => | ||
links.map((link, i) => ( | ||
<TestField | ||
key={i} | ||
link={link} | ||
validation={arrayElValidation} | ||
/> | ||
)) | ||
} | ||
</ArrayField> | ||
<TestField link={links.s} validation={fieldValidation} /> | ||
</> | ||
)} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
const formState = renderer.root.findByType(ObjectField).instance.props | ||
.link.formState; | ||
let node = formState[1]; | ||
expect(node.data.errors.client).toEqual(["object error"]); | ||
expect(node.data.meta.succeeded).toBe(false); | ||
node = node.children.a; | ||
expect(node.data.errors.client).toEqual(["array", "error"]); | ||
expect(node.data.meta.succeeded).toBe(false); | ||
const child0 = node.children[0]; | ||
expect(child0.data.errors.client).toEqual(["error 1"]); | ||
expect(child0.data.meta.succeeded).toBe(false); | ||
const child1 = node.children[1]; | ||
expect(child1.data.errors.client).toEqual(["error 2"]); | ||
expect(child1.data.meta.succeeded).toBe(false); | ||
node = formState[1].children.s; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
}); | ||
it("treats no validation as always passing", () => { | ||
const renderer = TestRenderer.create( | ||
<Form initialValue={{a: ["1", "2"], s: "string"}}> | ||
{link => ( | ||
<ObjectField link={link}> | ||
{links => ( | ||
<> | ||
<ArrayField link={links.a}> | ||
{links => | ||
links.map((link, i) => <TestField key={i} link={link} />) | ||
} | ||
</ArrayField> | ||
<TestField link={links.s} /> | ||
</> | ||
)} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
const formState = renderer.root.findByType(ObjectField).instance.props | ||
.link.formState; | ||
let node = formState[1]; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
node = node.children.a; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
const child0 = node.children[0]; | ||
expect(child0.data.errors.client).toEqual([]); | ||
expect(child0.data.meta.succeeded).toBe(true); | ||
const child1 = node.children[1]; | ||
expect(child1.data.errors.client).toEqual([]); | ||
expect(child1.data.meta.succeeded).toBe(true); | ||
node = formState[1].children.s; | ||
expect(node.data.errors.client).toEqual([]); | ||
expect(node.data.meta.succeeded).toBe(true); | ||
}); | ||
it("validates newly mounted Fields", () => { | ||
function expectClientErrors(link) { | ||
const tree = link.formState[1]; | ||
return expect(forgetShape(tree).data.errors.client); | ||
} | ||
const renderFn = jest.fn(() => null); | ||
const validationA = jest.fn(() => ["error a"]); | ||
const validationB = jest.fn(() => ["error b"]); | ||
const renderer = TestRenderer.create( | ||
<Form initialValue={{key: "hello"}}> | ||
{link => ( | ||
<ObjectField link={link}> | ||
{link => ( | ||
<> | ||
<LinkTap link={link.key}>{renderFn}</LinkTap> | ||
<TestField link={link.key} validation={validationA} /> | ||
{/*<TestField link={link.key} validation={validationB} />*/} | ||
</> | ||
)} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
expect(renderFn).toHaveBeenCalledTimes(2); | ||
// on the initial render, we expose "pending" as part of our API | ||
// TODO(dmnd): It'd be nice if we could avoid this. | ||
expectClientErrors(renderFn.mock.calls[0][0]).toEqual("pending"); | ||
// After the second render the error arrives. | ||
expectClientErrors(renderFn.mock.calls[1][0]).toEqual(["error a"]); | ||
expect(validationA).toHaveBeenCalledTimes(1); | ||
expect(validationA).toHaveBeenCalledWith("hello"); | ||
// When a new Field is mounted, we expect the errors to show up. | ||
renderFn.mockClear(); | ||
validationA.mockClear(); | ||
renderer.update( | ||
<Form initialValue={{key: "hello"}}> | ||
{link => ( | ||
<ObjectField link={link}> | ||
{link => ( | ||
<> | ||
<LinkTap link={link.key}>{renderFn}</LinkTap> | ||
<TestField link={link.key} validation={validationA} /> | ||
<TestField link={link.key} validation={validationB} /> | ||
</> | ||
)} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
expect(renderFn).toHaveBeenCalledTimes(2); | ||
// There's an initial render where the field hasn't yet been validated | ||
// TODO(dmnd): It'd be nice if we could avoid this. | ||
expectClientErrors(renderFn.mock.calls[0][0]).toEqual(["error a"]); | ||
// After the update, the new error should be present. | ||
expectClientErrors(renderFn.mock.calls[1][0]).toEqual([ | ||
"error a", | ||
"error b", | ||
]); | ||
// Validation functions should receive the correct parameters. These | ||
// assertions protect against bugs that confuse relative and absolute | ||
// paths/values. | ||
expect(validationA).toHaveBeenCalledTimes(1); | ||
expect(validationA).toHaveBeenCalledWith("hello"); | ||
expect(validationB).toHaveBeenCalledTimes(1); | ||
expect(validationB).toHaveBeenCalledWith("hello"); | ||
}); | ||
it("updates errors when a new validation function is provided via props", () => { | ||
const renderer = TestRenderer.create( | ||
<Form initialValue="hello"> | ||
{link => <TestField link={link} validation={() => ["error 1"]} />} | ||
</Form> | ||
); | ||
let link = renderer.root.findAllByType(TestField)[0].instance.props.link; | ||
let errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 1"]); | ||
renderer.update( | ||
<Form initialValue="hello"> | ||
{link => <TestField link={link} validation={() => ["error 2"]} />} | ||
</Form> | ||
); | ||
link = renderer.root.findAllByType(TestField)[0].instance.props.link; | ||
errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 2"]); | ||
}); | ||
}); | ||
describe("Form manages form state", () => { | ||
it("creates the initial formState from initialValue and serverErrors", () => { | ||
it("creates the initial formState from initialValue and externalErrors", () => { | ||
const onSubmit = jest.fn(); | ||
@@ -54,3 +302,3 @@ const renderFn = jest.fn(() => null); | ||
onSubmit={onSubmit} | ||
serverErrors={{"/": ["Server error", "Another server error"]}} | ||
externalErrors={{"/": ["External error", "Another external error"]}} | ||
> | ||
@@ -78,3 +326,3 @@ {renderFn} | ||
client: "pending", | ||
server: ["Server error", "Another server error"], | ||
external: ["External error", "Another external error"], | ||
}, | ||
@@ -84,3 +332,3 @@ }); | ||
it("parses and sets complex server errors", () => { | ||
it("parses and sets complex external errors", () => { | ||
const onSubmit = jest.fn(); | ||
@@ -95,3 +343,3 @@ const renderFn = jest.fn(() => null); | ||
onSubmit={onSubmit} | ||
serverErrors={{ | ||
externalErrors={{ | ||
"/": ["Root error"], | ||
@@ -115,14 +363,14 @@ "/simple": ["One", "level", "down"], | ||
const root: any = tree; | ||
expect(root.data.errors.server).toEqual(["Root error"]); | ||
expect(root.data.errors.external).toEqual(["Root error"]); | ||
const simple = root.children.simple; | ||
expect(simple.data.errors.server).toEqual(["One", "level", "down"]); | ||
expect(simple.data.errors.external).toEqual(["One", "level", "down"]); | ||
const complex = root.children.complex; | ||
expect(complex.data.errors.server).toEqual([]); | ||
expect(complex.data.errors.external).toEqual([]); | ||
const complex0 = complex.children[0]; | ||
expect(complex0.data.errors.server).toEqual(["in an", "array"]); | ||
expect(complex0.data.errors.external).toEqual(["in an", "array"]); | ||
const complex1 = complex.children[1]; | ||
expect(complex1.data.errors.server).toEqual([]); | ||
expect(complex1.data.errors.external).toEqual([]); | ||
}); | ||
it("updates the server errors", () => { | ||
it("updates the external errors", () => { | ||
const onSubmit = jest.fn(); | ||
@@ -136,3 +384,3 @@ const renderFn = jest.fn(() => null); | ||
onSubmit={onSubmit} | ||
serverErrors={{ | ||
externalErrors={{ | ||
"/array": ["Cannot be empty"], | ||
@@ -158,3 +406,3 @@ }} | ||
onSubmit={onSubmit} | ||
serverErrors={{ | ||
externalErrors={{ | ||
"/array": [], | ||
@@ -175,9 +423,19 @@ "/array/0": ["inner error"], | ||
const root: any = tree; | ||
expect(root.data.errors.server).toEqual([]); | ||
expect(root.data.errors.external).toEqual([]); | ||
const array = root.children.array; | ||
expect(array.data.errors.server).toEqual([]); | ||
expect(array.data.errors.external).toEqual([]); | ||
const array0 = array.children[0]; | ||
expect(array0.data.errors.server).toEqual(["inner error"]); | ||
expect(array0.data.errors.external).toEqual(["inner error"]); | ||
}); | ||
it("doesn't cause an infinite loop when using inline validation function", () => { | ||
expect(() => { | ||
TestRenderer.create( | ||
<Form initialValue="hello"> | ||
{link => <TestField link={link} validation={() => []} />} | ||
</Form> | ||
); | ||
}).not.toThrow(/Maximum update depth exceeded/); | ||
}); | ||
it("collects the initial validations", () => { | ||
@@ -415,3 +673,3 @@ // This test is not very unit-y, but that's okay! It's more useful to | ||
onSubmit={jest.fn()} | ||
serverErrors={{"/": ["Server error", "Another server error"]}} | ||
externalErrors={{"/": ["External error", "Another external error"]}} | ||
> | ||
@@ -430,4 +688,4 @@ {renderFn} | ||
unfilteredErrors: expect.arrayContaining([ | ||
"Server error", | ||
"Another server error", | ||
"External error", | ||
"Another external error", | ||
]), | ||
@@ -441,2 +699,150 @@ // Currently, only care about client errors | ||
}); | ||
it("removes errors when a child unmounts", () => { | ||
const validation1 = jest.fn(() => ["error 1"]); | ||
const validation2 = jest.fn(() => ["error 2"]); | ||
class TestForm extends React.Component<{ | ||
hideSecondField: boolean, | ||
link: FieldLink<{string1: string, string2: string}>, | ||
}> { | ||
render() { | ||
return ( | ||
<ObjectField link={this.props.link}> | ||
{links => ( | ||
<> | ||
<TestField | ||
key={"1"} | ||
link={links.string1} | ||
validation={validation1} | ||
/> | ||
{this.props.hideSecondField ? null : ( | ||
<TestField | ||
key={"2"} | ||
link={links.string2} | ||
validation={validation2} | ||
/> | ||
)} | ||
</> | ||
)} | ||
</ObjectField> | ||
); | ||
} | ||
} | ||
const renderer = TestRenderer.create( | ||
<Form | ||
initialValue={{ | ||
string1: "hello", | ||
string2: "world", | ||
}} | ||
> | ||
{link => <TestForm link={link} hideSecondField={false} />} | ||
</Form> | ||
); | ||
expect(validation1).toHaveBeenCalledTimes(1); | ||
expect(validation2).toHaveBeenCalledTimes(1); | ||
let rootFormState = renderer.root.findByType(TestForm).instance.props.link | ||
.formState[1]; | ||
let string1Errors = rootFormState.children.string1.data.errors.client; | ||
expect(string1Errors).toEqual(["error 1"]); | ||
let string2Errors = rootFormState.children.string2.data.errors.client; | ||
expect(string2Errors).toEqual(["error 2"]); | ||
// now hide the second field, causing it to unmount and unregister the | ||
// validation handler | ||
renderer.update( | ||
<Form | ||
initialValue={{ | ||
string1: "hello", | ||
string2: "world", | ||
}} | ||
> | ||
{link => <TestForm link={link} hideSecondField={true} />} | ||
</Form> | ||
); | ||
// no addition validation calls | ||
expect(validation1).toHaveBeenCalledTimes(1); | ||
expect(validation2).toHaveBeenCalledTimes(1); | ||
rootFormState = renderer.root.findByType(TestForm).instance.props.link | ||
.formState[1]; | ||
// error for string1 remains | ||
string1Errors = rootFormState.children.string1.data.errors.client; | ||
expect(string1Errors).toEqual(["error 1"]); | ||
// string2's error is gone | ||
string2Errors = rootFormState.children.string2.data.errors.client; | ||
expect(string2Errors).toEqual([]); | ||
}); | ||
it("runs all validations when a link has multiple fields", () => { | ||
const validation1 = jest.fn(() => ["error 1"]); | ||
const validation2 = jest.fn(() => ["error 2"]); | ||
const renderer = TestRenderer.create( | ||
<Form initialValue="hello"> | ||
{link => ( | ||
<> | ||
{/* note both fields point to the same link!! */} | ||
<TestField key={"1"} link={link} validation={validation1} /> | ||
<TestField key={"2"} link={link} validation={validation2} /> | ||
</> | ||
)} | ||
</Form> | ||
); | ||
expect(validation1).toHaveBeenCalledTimes(1); | ||
expect(validation2).toHaveBeenCalledTimes(1); | ||
renderer.root.findAllByType(TestInput)[0].instance.change("dmnd"); | ||
expect(validation1).toHaveBeenCalledTimes(2); | ||
expect(validation2).toHaveBeenCalledTimes(2); | ||
renderer.root.findAllByType(TestInput)[1].instance.change("zach"); | ||
expect(validation1).toHaveBeenCalledTimes(3); | ||
expect(validation2).toHaveBeenCalledTimes(3); | ||
}); | ||
it("only removes errors from validation that was unmounted", () => { | ||
const validation1 = jest.fn(() => ["error 1"]); | ||
const validation2 = jest.fn(() => ["error 2"]); | ||
const renderer = TestRenderer.create( | ||
<Form initialValue="hello"> | ||
{link => ( | ||
<> | ||
{/* note both fields point to the same link!! */} | ||
<TestField key={"1"} link={link} validation={validation1} /> | ||
<TestField key={"2"} link={link} validation={validation2} /> | ||
</> | ||
)} | ||
</Form> | ||
); | ||
let link = renderer.root.findAllByType(TestField)[0].instance.props.link; | ||
let errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 1", "error 2"]); | ||
renderer.update( | ||
<Form initialValue="hello"> | ||
{link => ( | ||
<> | ||
<TestField key={"1"} link={link} validation={validation1} /> | ||
</> | ||
)} | ||
</Form> | ||
); | ||
link = renderer.root.findAllByType(TestField)[0].instance.props.link; | ||
errors = link.formState[1].data.errors.client; | ||
expect(errors).toEqual(["error 1"]); | ||
}); | ||
}); | ||
@@ -546,2 +952,84 @@ | ||
}); | ||
describe("performance", () => { | ||
it("batches setState calls when unmounting components", () => { | ||
// Record the number of render and commit phases | ||
let renders = 0; | ||
let commits = 0; | ||
class RenderCalls extends React.Component<{||}> { | ||
componentDidUpdate() { | ||
commits += 1; | ||
} | ||
render() { | ||
renders += 1; | ||
return ( | ||
<div> | ||
Rendered {renders} times, committed {commits} times. | ||
</div> | ||
); | ||
} | ||
} | ||
const validation = jest.fn(); | ||
// N in the O(N) sense for this perf test. | ||
const N = 10; | ||
const renderer = TestRenderer.create( | ||
<Form initialValue={"A string"}> | ||
{link => ( | ||
<> | ||
<RenderCalls /> | ||
<> | ||
{[...Array(N).keys()].map(i => ( | ||
<Field key={i} link={link} validation={validation}> | ||
{() => <div>input</div>} | ||
</Field> | ||
))} | ||
</> | ||
</> | ||
)} | ||
</Form> | ||
); | ||
// One render for initial mount, and another after Form validates | ||
// everything from componentDidMount. | ||
// TODO(dmnd): Can we adjust implementation to make this only render once? | ||
expect(renders).toBe(1 + 1); | ||
expect(commits).toBe(1); | ||
expect(validation).toBeCalledTimes(N); | ||
renders = 0; | ||
commits = 0; | ||
validation.mockClear(); | ||
// now unmount all the fields | ||
renderer.update( | ||
<Form initialValue={"A string"}> | ||
{() => ( | ||
<> | ||
<RenderCalls /> | ||
</> | ||
)} | ||
</Form> | ||
); | ||
// We expect only two renders. The first to build the VDOM without Fields. | ||
// Then during reconciliation React realizes the Fields have to unmount, | ||
// so it calls componentWillUnmount on each Field, which then causes a | ||
// setState for each Field. But then we expect that React batches all | ||
// these setStates into a single render, not one render per each Field. | ||
expect(renders).toBe(1 + 1); | ||
expect(renders).not.toBe(1 + N); | ||
// Similarly, we expect only 2 commits, not one for each Field. You might | ||
// expect only a single commit, but componentWillUnmount happens during | ||
// the commit phase, so when setState is called React enqueues another | ||
// render phase which commits separately. Oh well. At least the number of | ||
// commits is constant! | ||
expect(commits).toBe(1 + 1); | ||
expect(commits).not.toBe(1 + N); | ||
}); | ||
}); | ||
}); |
@@ -7,2 +7,3 @@ // @flow | ||
import ObjectField from "../ObjectField"; | ||
import Form from "../Form"; | ||
import {type FieldLink} from "../types"; | ||
@@ -12,2 +13,4 @@ | ||
import TestField, {TestInput} from "./TestField"; | ||
import TestForm from "./TestForm"; | ||
import LinkTap from "../testutils/LinkTap"; | ||
@@ -21,115 +24,107 @@ describe("ObjectField", () => { | ||
describe("ObjectField is a field", () => { | ||
describe("validates on mount", () => { | ||
it("ensures that the link inner type matches the type of the validation", () => { | ||
type TestObject = {| | ||
string: string, | ||
number: number, | ||
|}; | ||
const formStateInner: TestObject = { | ||
string: "hello", | ||
number: 42, | ||
}; | ||
const formState = mockFormState(formStateInner); | ||
const link: FieldLink<TestObject> = mockLink(formState); | ||
describe("is a field", () => { | ||
it("ensures that the link inner type matches the type of the validation", () => { | ||
type TestObject = {| | ||
string: string, | ||
number: number, | ||
|}; | ||
const formStateInner: TestObject = { | ||
string: "hello", | ||
number: 42, | ||
}; | ||
const formState = mockFormState(formStateInner); | ||
const link: FieldLink<TestObject> = mockLink(formState); | ||
// $ExpectError | ||
<ObjectField link={link} validation={(_e: empty) => []}> | ||
{() => null} | ||
</ObjectField>; | ||
// $ExpectError | ||
<ObjectField link={link} validation={(_e: empty) => []}> | ||
{() => null} | ||
</ObjectField>; | ||
// $ExpectError | ||
<ObjectField link={link} validation={(_e: {|string: string|}) => []}> | ||
{() => null} | ||
</ObjectField>; | ||
// $ExpectError | ||
<ObjectField link={link} validation={(_e: {|string: string|}) => []}> | ||
{() => null} | ||
</ObjectField>; | ||
<ObjectField link={link} validation={(_e: TestObject) => []}> | ||
{() => null} | ||
</ObjectField>; | ||
}); | ||
<ObjectField link={link} validation={(_e: TestObject) => []}> | ||
{() => null} | ||
</ObjectField>; | ||
}); | ||
it("Sets errors.client and meta.succeeded when there are no errors", () => { | ||
const validation = jest.fn(() => []); | ||
const formState = mockFormState({inner: "value"}); | ||
const link = mockLink(formState); | ||
it("Registers and unregisters for validation", () => { | ||
const formState = mockFormState({inner: "value"}); | ||
const link = mockLink(formState); | ||
const unregister = jest.fn(); | ||
const registerValidation = jest.fn(() => ({ | ||
replace: jest.fn(), | ||
unregister, | ||
})); | ||
TestRenderer.create( | ||
<ObjectField link={link} validation={validation}> | ||
const renderer = TestRenderer.create( | ||
<TestForm registerValidation={registerValidation}> | ||
<ObjectField link={link} validation={jest.fn(() => [])}> | ||
{jest.fn(() => null)} | ||
</ObjectField> | ||
); | ||
</TestForm> | ||
); | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
renderer.unmount(); | ||
expect(unregister).toBeCalledTimes(1); | ||
}); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
it("calls replace when changing the validation function", () => { | ||
const replace = jest.fn(); | ||
const registerValidation = jest.fn(() => ({ | ||
replace, | ||
unregister: jest.fn(), | ||
})); | ||
it("Sets errors.client and meta.succeeded when there are errors", () => { | ||
const validation = jest.fn(() => ["This is an error"]); | ||
const formState = mockFormState({inner: "value"}); | ||
const link = mockLink(formState); | ||
TestRenderer.create( | ||
<ObjectField link={link} validation={validation}> | ||
{jest.fn(() => null)} | ||
</ObjectField> | ||
function Component() { | ||
return ( | ||
<TestForm registerValidation={registerValidation}> | ||
<ObjectField | ||
link={mockLink(mockFormState({hello: "world"}))} | ||
validation={() => []} | ||
> | ||
{() => null} | ||
</ObjectField> | ||
</TestForm> | ||
); | ||
} | ||
expect(validation).toHaveBeenCalledTimes(1); | ||
expect(validation).toHaveBeenCalledWith(formState[0]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
const renderer = TestRenderer.create(<Component />); | ||
expect(registerValidation).toBeCalledTimes(1); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual(["This is an error"]); | ||
}); | ||
renderer.update(<Component />); | ||
expect(replace).toBeCalledTimes(1); | ||
}); | ||
it("Treats no validation as always passing", () => { | ||
const formState = mockFormState({inner: "value"}); | ||
const link = mockLink(formState); | ||
it("Passes additional information to its render function", () => { | ||
const formState = mockFormState({inner: "value"}); | ||
// $FlowFixMe | ||
formState[1].data.errors = { | ||
external: ["An external error"], | ||
client: ["A client error"], | ||
}; | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create( | ||
<ObjectField link={link}>{jest.fn(() => null)}</ObjectField> | ||
); | ||
TestRenderer.create(<ObjectField link={link}>{renderFn}</ObjectField>); | ||
expect(link.onValidation).toHaveBeenCalledTimes(1); | ||
const [path, errors] = link.onValidation.mock.calls[0]; | ||
expect(path).toEqual([]); | ||
expect(errors).toEqual([]); | ||
}); | ||
it("Passes additional information to its render function", () => { | ||
const formState = mockFormState({inner: "value"}); | ||
// $FlowFixMe | ||
formState[1].data.errors = { | ||
server: ["A server error"], | ||
client: ["A client error"], | ||
}; | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create(<ObjectField link={link}>{renderFn}</ObjectField>); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith( | ||
expect.anything(), | ||
expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining([ | ||
"A server error", | ||
"A client error", | ||
]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: {inner: "value"}, | ||
}) | ||
); | ||
}); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith( | ||
expect.anything(), | ||
expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: expect.anything(), | ||
unfilteredErrors: expect.arrayContaining([ | ||
"An external error", | ||
"A client error", | ||
]), | ||
valid: false, | ||
asyncValidationInFlight: false, | ||
value: {inner: "value"}, | ||
}) | ||
); | ||
}); | ||
@@ -196,26 +191,37 @@ }); | ||
it("calls onChange when a child changes", () => { | ||
const formStateInner = { | ||
it("validates new values from children and passes result to onChange", () => { | ||
const formState = mockFormState({ | ||
string: "hello", | ||
number: 42, | ||
}; | ||
const formState = mockFormState(formStateInner); | ||
}); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create(<ObjectField link={link}>{renderFn}</ObjectField>); | ||
const updateNodeAtPath = jest.fn((path, formState) => formState); | ||
TestRenderer.create( | ||
<TestForm updateNodeAtPath={updateNodeAtPath}> | ||
<ObjectField link={link}>{renderFn}</ObjectField> | ||
</TestForm> | ||
); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(0); | ||
expect(link.onChange).toHaveBeenCalledTimes(0); | ||
// call the child onChange | ||
const objectLinks = renderFn.mock.calls[0][0]; | ||
// call the child onChange | ||
const newChildMeta = mockFormState("newString"); | ||
objectLinks.string.onChange(newChildMeta); | ||
expect(link.onChange).toHaveBeenCalled(); | ||
const newObjectFormState = link.onChange.mock.calls[0][0]; | ||
expect(newObjectFormState[0]).toHaveProperty("string", "newString"); | ||
expect(newObjectFormState[1].data.meta).toMatchObject({ | ||
touched: true, | ||
changed: true, | ||
}); | ||
expect(newObjectFormState[1].children.string).toBe(newChildMeta[1]); | ||
expect(updateNodeAtPath).toHaveBeenCalledTimes(1); | ||
expect(updateNodeAtPath).toHaveBeenCalledWith( | ||
[], | ||
[{string: "newString", number: 42}, expect.anything()] | ||
); | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([ | ||
{string: "newString", number: 42}, | ||
formState[1], | ||
]); | ||
}); | ||
@@ -248,31 +254,3 @@ | ||
it("calls onValidation when a child runs validations", () => { | ||
const formStateInner = { | ||
string: "hello", | ||
number: 42, | ||
}; | ||
const formState = mockFormState(formStateInner); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create(<ObjectField link={link}>{renderFn}</ObjectField>); | ||
const objectLinks = renderFn.mock.calls[0][0]; | ||
// call the child onValidation | ||
objectLinks.string.onValidation([], ["Some", "errors"]); | ||
expect(link.onValidation).toHaveBeenCalledTimes(2); | ||
// Important: the first call to onValidation is for the initial render validation | ||
const [path, errors] = link.onValidation.mock.calls[1]; | ||
expect(path).toEqual([{type: "object", key: "string"}]); | ||
expect(errors).toEqual(["Some", "errors"]); | ||
}); | ||
it("calls its own validation when a child changes", () => { | ||
const formStateInner = { | ||
string: "hello", | ||
number: 42, | ||
}; | ||
const formState = mockFormState(formStateInner); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -282,5 +260,14 @@ const validation = jest.fn(() => ["This is an error"]); | ||
TestRenderer.create( | ||
<ObjectField link={link} validation={validation}> | ||
{renderFn} | ||
</ObjectField> | ||
<Form | ||
initialValue={{ | ||
string: "hello", | ||
number: 42, | ||
}} | ||
> | ||
{link => ( | ||
<ObjectField link={link} validation={validation}> | ||
{renderFn} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
@@ -304,3 +291,3 @@ | ||
describe("customChange", () => { | ||
it("allows the default change behavior to be overwritten with customChange", () => { | ||
it("allows sibling fields to be overwritten", () => { | ||
const formStateInner = { | ||
@@ -313,3 +300,2 @@ string: "hello", | ||
const renderFn = jest.fn(() => null); | ||
const validation = jest.fn(() => ["This is an error"]); | ||
@@ -322,9 +308,11 @@ const customChange = jest.fn((_oldValue, _newValue) => ({ | ||
TestRenderer.create( | ||
<ObjectField | ||
link={link} | ||
validation={validation} | ||
customChange={customChange} | ||
> | ||
{renderFn} | ||
</ObjectField> | ||
<TestForm> | ||
<ObjectField | ||
link={link} | ||
validation={jest.fn(() => ["This is an error"])} | ||
customChange={customChange} | ||
> | ||
{renderFn} | ||
</ObjectField> | ||
</TestForm> | ||
); | ||
@@ -359,18 +347,5 @@ | ||
]); | ||
// Validated the result of customChange | ||
expect(validation).toHaveBeenCalledTimes(2); | ||
expect(validation.mock.calls[1][0]).toEqual({ | ||
string: "A whole new value", | ||
number: 0, | ||
}); | ||
}); | ||
it("can return null to signal there was no custom change", () => { | ||
const formStateInner = { | ||
string: "hello", | ||
number: 42, | ||
}; | ||
const formState = mockFormState(formStateInner); | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
@@ -381,10 +356,19 @@ const validation = jest.fn(() => ["This is an error"]); | ||
TestRenderer.create( | ||
<ObjectField | ||
link={link} | ||
validation={validation} | ||
customChange={customChange} | ||
const renderer = TestRenderer.create( | ||
<Form | ||
initialValue={{ | ||
string: "hello", | ||
number: 42, | ||
}} | ||
> | ||
{renderFn} | ||
</ObjectField> | ||
{link => ( | ||
<ObjectField | ||
link={link} | ||
validation={validation} | ||
customChange={customChange} | ||
> | ||
{renderFn} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
@@ -399,5 +383,5 @@ | ||
// onChange should be called with the result of customChange | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([ | ||
const link = renderer.root.findByType(ObjectField).instance.props.link; | ||
// the value we get out is as if customChange didn't exist | ||
expect(link.formState).toEqual([ | ||
{ | ||
@@ -419,9 +403,2 @@ string: "newString", | ||
it("doesn't break validations for child fields", () => { | ||
const formStateInner = { | ||
string: "hello", | ||
string2: "goodbye", | ||
}; | ||
const formState = mockFormState(formStateInner); | ||
const link = mockLink(formState); | ||
const customChange = jest.fn((_oldValue, _newValue) => ({ | ||
@@ -433,41 +410,52 @@ string: "a whole new value", | ||
const childValidation = jest.fn(() => ["This is an error"]); | ||
const parentValidation = jest.fn(() => [ | ||
"This is an error from the parent", | ||
]); | ||
const renderer = TestRenderer.create( | ||
<ObjectField link={link} customChange={customChange}> | ||
{links => ( | ||
<React.Fragment> | ||
<TestField link={links.string} validation={childValidation} /> | ||
<TestField link={links.string2} validation={childValidation} /> | ||
</React.Fragment> | ||
<Form | ||
initialValue={{ | ||
string: "hello", | ||
string2: "goodbye", | ||
}} | ||
> | ||
{link => ( | ||
<ObjectField | ||
link={link} | ||
customChange={customChange} | ||
validation={parentValidation} | ||
> | ||
{links => ( | ||
<React.Fragment> | ||
<TestField link={links.string} validation={childValidation} /> | ||
<TestField | ||
link={links.string2} | ||
validation={childValidation} | ||
/> | ||
</React.Fragment> | ||
)} | ||
</ObjectField> | ||
)} | ||
</ObjectField> | ||
</Form> | ||
); | ||
// 5 validations: | ||
// 1) Child initial validation x2 | ||
// 2) Parent initial validation | ||
// 3) Child validation on remount x2 | ||
// (No parent onValidation call, because it will use onChange) | ||
// after mount, validate everything | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(2); | ||
// 1) and 2) | ||
expect(link.onValidation).toHaveBeenCalledTimes(3); | ||
link.onValidation.mockClear(); | ||
// Now change one of the values | ||
parentValidation.mockClear(); | ||
childValidation.mockClear(); | ||
const inner = renderer.root.findAllByType(TestInput)[0]; | ||
inner.instance.change("zach"); | ||
// 3) | ||
expect(link.onValidation).toHaveBeenCalledTimes(2); | ||
expect(link.onValidation).toHaveBeenCalledWith( | ||
[{type: "object", key: "string"}], | ||
["This is an error"] | ||
); | ||
expect(link.onValidation).toHaveBeenCalledWith( | ||
[{type: "object", key: "string2"}], | ||
["This is an error"] | ||
); | ||
// Validate the whole subtree due to the customChange child validates | ||
// once. Note that child validation will be called 3 times. Once after the | ||
// change, then twice more after the customChange triggers a validation fo | ||
// the entire subtree. | ||
expect(parentValidation).toHaveBeenCalledTimes(1); | ||
expect(childValidation).toHaveBeenCalledTimes(1 + 2); | ||
// onChange should be called with the result of customChange | ||
expect(link.onChange).toHaveBeenCalledTimes(1); | ||
expect(link.onChange).toHaveBeenCalledWith([ | ||
const link = renderer.root.findByType(ObjectField).instance.props.link; | ||
expect(link.formState).toEqual([ | ||
{ | ||
@@ -477,51 +465,93 @@ string: "a whole new value", | ||
}, | ||
expect.anything(), | ||
]); | ||
}); | ||
it("doesn't create a new instance (i.e. remount)", () => { | ||
const customChange = jest.fn((_oldValue, _newValue) => ({ | ||
string: "A whole new value", | ||
number: 0, | ||
})); | ||
const renderer = TestRenderer.create( | ||
<ObjectField | ||
link={mockLink( | ||
mockFormState({ | ||
string: "hello", | ||
number: 42, | ||
}) | ||
)} | ||
customChange={customChange} | ||
> | ||
{links => <TestField link={links.string} />} | ||
</ObjectField> | ||
); | ||
const testInstance = renderer.root.findAllByType(TestInput)[0].instance; | ||
// now trigger a customChange, which used to cause a remount | ||
testInstance.change("hi"); | ||
expect(customChange).toHaveBeenCalledTimes(1); | ||
// but we no longer cause a remount, so the instances should be the same | ||
const nextTestInstance = renderer.root.findAllByType(TestInput)[0] | ||
.instance; | ||
// Using Object.is here because toBe hangs as the objects are | ||
// self-referential and thus not printable | ||
expect(Object.is(testInstance, nextTestInstance)).toBe(true); | ||
}); | ||
it("works fine even if not at the root of the form", () => { | ||
const customChange = jest.fn((_oldValue, _newValue) => ({ | ||
string: "A whole new value", | ||
number: 0, | ||
})); | ||
const objectRenderFn = jest.fn(() => null); | ||
const linkTapFn = jest.fn(() => null); | ||
TestRenderer.create( | ||
<Form | ||
initialValue={{ | ||
uncle: "Bob", | ||
nested: { | ||
string: "hello", | ||
number: 42, | ||
}, | ||
}} | ||
> | ||
{link => ( | ||
<ObjectField link={link}> | ||
{link => ( | ||
<> | ||
<TestField link={link.uncle} /> | ||
<LinkTap link={link.nested}>{linkTapFn}</LinkTap> | ||
<ObjectField | ||
link={link.nested} | ||
validation={jest.fn(() => ["This is an error"])} | ||
customChange={customChange} | ||
> | ||
{objectRenderFn} | ||
</ObjectField> | ||
</> | ||
)} | ||
</ObjectField> | ||
)} | ||
</Form> | ||
); | ||
linkTapFn.mockClear(); | ||
// call the child onChange to trigger a customChange | ||
const objectLinks = objectRenderFn.mock.calls[0][0]; | ||
objectLinks.string.onChange(mockFormState("newString")); | ||
// onChange should be called with the result of customChange | ||
expect(linkTapFn).toHaveBeenCalledTimes(1); | ||
expect(linkTapFn.mock.calls[0][0].formState).toEqual([ | ||
{ | ||
type: "object", | ||
data: { | ||
errors: { | ||
client: [], | ||
server: "unchecked", | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: true, | ||
asyncValidationInFlight: false, | ||
}, | ||
}, | ||
children: { | ||
string: { | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked", | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false, | ||
}, | ||
}, | ||
}, | ||
string2: { | ||
type: "leaf", | ||
data: { | ||
errors: { | ||
// Validations happen after the initial onchange | ||
client: "pending", | ||
server: "unchecked", | ||
}, | ||
meta: { | ||
touched: true, | ||
changed: true, | ||
succeeded: false, | ||
asyncValidationInFlight: false, | ||
}, | ||
}, | ||
}, | ||
}, | ||
string: "A whole new value", | ||
number: 0, | ||
}, | ||
expect.anything(), | ||
]); | ||
@@ -528,0 +558,0 @@ }); |
@@ -21,6 +21,12 @@ // @flow | ||
expect.objectContaining({ | ||
// TODO(dmnd): Would be nice if we could do something like | ||
// path: expect.arrayContaining( | ||
// expect.objectContaining({ | ||
// type: expect.stringMatching(/(object)|(array)/), | ||
// }) | ||
// ), | ||
path: expect.anything(), | ||
formState: expect.anything(), | ||
onChange: expect.any(Function), | ||
onBlur: expect.any(Function), | ||
onValidation: expect.any(Function), | ||
}) | ||
@@ -33,7 +39,7 @@ ); | ||
return { | ||
path: [], | ||
formState, | ||
onChange: jest.fn(), | ||
onBlur: jest.fn(), | ||
onValidation: jest.fn(), | ||
}; | ||
} |
@@ -5,5 +5,5 @@ // @flow strict | ||
import type {FieldLink, Extras, ClientErrors} from "../types"; | ||
import type {FieldLink, Extras} from "../types"; | ||
import type {FormState} from "../formState"; | ||
import type {ShapedTree, ShapedPath} from "../shapedTree"; | ||
import type {ShapedTree} from "../shapedTree"; | ||
@@ -14,3 +14,2 @@ type Props<T> = {| | ||
+onBlur?: () => void, | ||
+onValidation?: (path: ShapedPath<T>, errors: ClientErrors) => void, | ||
+children: (link: FieldLink<T>) => React.Node, | ||
@@ -36,19 +35,9 @@ |}; | ||
handleValidation: (ShapedPath<T>, ClientErrors) => void = ( | ||
path: ShapedPath<T>, | ||
errors: ClientErrors | ||
) => { | ||
if (this.props.onValidation) { | ||
this.props.onValidation(path, errors); | ||
} | ||
this.props.link.onValidation(path, errors); | ||
}; | ||
render() { | ||
const {link} = this.props; | ||
const tappedLink: FieldLink<T> = { | ||
path: link.path, | ||
formState: link.formState, | ||
onChange: this.handleChange, | ||
onBlur: this.handleBlur, | ||
onValidation: this.handleValidation, | ||
}; | ||
@@ -55,0 +44,0 @@ |
// @flow strict | ||
import type {ShapedTree, ShapedPath} from "./shapedTree"; | ||
import type {Path} from "./tree"; | ||
import {type FormState} from "./formState"; | ||
export type ClientErrors = Array<string> | "pending"; | ||
export type ServerErrors = Array<string> | "unchecked"; | ||
export type ExternalErrors = Array<string> | "unchecked"; | ||
export type Err = { | ||
client: ClientErrors, | ||
server: ServerErrors, | ||
external: ExternalErrors, | ||
}; | ||
@@ -34,3 +35,3 @@ | ||
client: "pending", | ||
server: "unchecked", | ||
external: "unchecked", | ||
}; | ||
@@ -62,3 +63,3 @@ | ||
+onBlur: OnBlur<T>, | ||
+onValidation: OnValidation<T>, | ||
+path: Path, | ||
|}; | ||
@@ -68,2 +69,2 @@ | ||
export type CustomChange<T> = (oldValue: T, newValue: T) => null | T; | ||
export type CustomChange<T> = (prevValue: T, nextValue: T) => null | T; |
@@ -127,1 +127,13 @@ // @flow strict | ||
} | ||
export function equals<E>(a: $ReadOnlyArray<E>, b: $ReadOnlyArray<E>): boolean { | ||
if (a.length !== b.length) { | ||
return false; | ||
} | ||
for (let i = 0; i < a.length; i++) { | ||
if (a[i] !== b[i]) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
} |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
385386
66
9167
536
0