formula-one
Advanced tools
Comparing version 0.2.1 to 0.3.0
# Changelog | ||
### v0.3.0 | ||
- Add a way to pass additional information to `onSubmit` as a second argument. | ||
- Add `onChange` prop to `<Form>`. Make `onChange` and `onSubmit` optional, since they probably won't co-occur. | ||
- Add additional information to render functions for `<Form>` and `Field`s as a third argument. | ||
### v0.2.1 | ||
@@ -4,0 +10,0 @@ |
@@ -157,2 +157,3 @@ "use strict"; | ||
var formState = this.props.link.formState; | ||
var shouldShowError = this.props.formContext.shouldShowError; | ||
@@ -165,2 +166,10 @@ | ||
moveField: this.moveChildField | ||
}, { | ||
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), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: (0, _formState3.isValid)(formState), | ||
value: formState[0] | ||
}); | ||
@@ -167,0 +176,0 @@ } |
@@ -31,3 +31,3 @@ "use strict"; | ||
var _formState2 = require("./formState"); | ||
var _formState3 = require("./formState"); | ||
@@ -72,3 +72,3 @@ 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)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } | ||
_this.props.link.onChange((0, _formState2.setChanged)((0, _formState2.validate)(_this.props.validation, [newValue, oldTree]))); | ||
_this.props.link.onChange((0, _formState3.setChanged)((0, _formState3.validate)(_this.props.validation, [newValue, oldTree]))); | ||
}, _this.onBlur = function () { | ||
@@ -81,3 +81,3 @@ var _this$props$link$form2 = _slicedToArray(_this.props.link.formState, 2), | ||
// TODO(zach): Not sure if we should blow away server errors here | ||
(0, _shapedTree.mapRoot)(_formState2.setExtrasTouched, tree)); | ||
(0, _shapedTree.mapRoot)(_formState3.setExtrasTouched, tree)); | ||
}, _temp), _possibleConstructorReturn(_this, _ret); | ||
@@ -98,3 +98,3 @@ } | ||
var _getExtras = (0, _formState2.getExtras)(formState), | ||
var _getExtras = (0, _formState3.getExtras)(formState), | ||
errors = _getExtras.errors; | ||
@@ -114,11 +114,25 @@ | ||
value: function render() { | ||
var _props$link$formState = _slicedToArray(this.props.link.formState, 1), | ||
value = _props$link$formState[0]; | ||
var formState = this.props.link.formState; | ||
var _getExtras2 = (0, _formState2.getExtras)(this.props.link.formState), | ||
var _formState2 = _slicedToArray(formState, 1), | ||
value = _formState2[0]; | ||
var _getExtras2 = (0, _formState3.getExtras)(formState), | ||
meta = _getExtras2.meta, | ||
errors = _getExtras2.errors; | ||
var shouldShowError = this.props.formContext.shouldShowError; | ||
var flatErrors = this.props.formContext.shouldShowError(meta) ? getErrors(errors) : []; | ||
return this.props.children(value, flatErrors, this.onChange, this.onBlur); | ||
return this.props.children(value, flatErrors, this.onChange, this.onBlur, { | ||
touched: meta.touched, | ||
changed: meta.changed, | ||
shouldShowErrors: shouldShowError(meta), | ||
unfilteredErrors: getErrors(errors), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: (0, _formState3.isValid)(formState), | ||
value: value | ||
}); | ||
} | ||
@@ -125,0 +139,0 @@ }]); |
@@ -20,3 +20,3 @@ "use strict"; | ||
require("./formState"); | ||
var _formState2 = require("./formState"); | ||
@@ -132,5 +132,5 @@ var _shapedTree = require("./shapedTree"); | ||
_this.onSubmit = function () { | ||
_this.onSubmit = function (extraData) { | ||
_this.setState({ submitted: true }); | ||
_this.props.onSubmit(_this.state.formState[0]); | ||
_this.props.onSubmit(_this.state.formState[0], extraData); | ||
}; | ||
@@ -140,2 +140,3 @@ | ||
_this.setState({ formState: newState, pristine: false }); | ||
_this.props.onChange(newState[0]); | ||
}; | ||
@@ -208,3 +209,11 @@ | ||
onValidation: this.updateTreeForValidation | ||
}, this.onSubmit) | ||
}, this.onSubmit, { | ||
touched: (0, _formState2.getExtras)(formState).meta.touched, | ||
changed: (0, _formState2.getExtras)(formState).meta.changed, | ||
shouldShowErrors: getShouldShowError(this.props.feedbackStrategy)((0, _formState2.getExtras)(formState).meta), | ||
unfilteredErrors: (0, _formState2.flatRootErrors)(formState), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: (0, _formState2.isValid)(formState), | ||
value: formState[0] | ||
}) | ||
); | ||
@@ -217,2 +226,6 @@ } | ||
Form.defaultProps = { | ||
onChange: function onChange() {}, | ||
onSubmit: function onSubmit() {} | ||
}; | ||
exports.default = Form; |
@@ -12,2 +12,3 @@ "use strict"; | ||
exports.getExtras = getExtras; | ||
exports.flatRootErrors = flatRootErrors; | ||
exports.objectChild = objectChild; | ||
@@ -25,2 +26,3 @@ exports.arrayChild = arrayChild; | ||
exports.replaceServerErrors = replaceServerErrors; | ||
exports.isValid = isValid; | ||
@@ -44,2 +46,15 @@ var _shapedTree = require("./shapedTree"); | ||
function flatRootErrors(formState) { | ||
var errors = (0, _shapedTree.getRootData)(formState[1]).errors; | ||
var flatErrors = []; | ||
if (errors.client !== "pending") { | ||
flatErrors = flatErrors.concat(errors.client); | ||
} | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
} | ||
return flatErrors; | ||
} | ||
function objectChild(key, formState) { | ||
@@ -222,2 +237,16 @@ var _formState = _slicedToArray(formState, 2), | ||
return [formState[0], (0, _shapedTree.shapedZipWith)(replaceServerErrorsExtra, serverErrors, formState[1])]; | ||
} | ||
// Is whole tree client valid? | ||
// TODO(zach): This will have to change with asynchronous validations. We will | ||
// need a "pending" value as well as an "unchecked" value. | ||
// Currently, things in the tree which are not reflected in the React tree are | ||
// marked "pending", which means they can be valid :grimace:. | ||
function isValid(formState) { | ||
return (0, _shapedTree.foldMapShapedTree)(function (_ref6) { | ||
var client = _ref6.errors.client; | ||
return client === "pending" || client.length === 0; | ||
}, true, function (l, r) { | ||
return l && r; | ||
}, formState[1]); | ||
} |
@@ -125,4 +125,16 @@ "use strict"; | ||
value: function render() { | ||
var formState = this.props.link.formState; | ||
var shouldShowError = this.props.formContext.shouldShowError; | ||
var links = makeLinks(this.props.link.formState, this.onChildChange, this.onChildBlur, this.onChildValidation); | ||
return this.props.children(links); | ||
return 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), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: (0, _formState3.isValid)(formState), | ||
value: formState[0] | ||
}); | ||
} | ||
@@ -129,0 +141,0 @@ }]); |
@@ -25,2 +25,4 @@ "use strict"; | ||
exports.mapShapedTree = mapShapedTree; | ||
exports.foldMapShapedTree = foldMapShapedTree; | ||
exports.getRootData = getRootData; | ||
@@ -263,2 +265,11 @@ var _tree = require("./tree"); | ||
return (0, _tree.mapTree)(f, tree); | ||
} | ||
// Fold a tree inorder | ||
function foldMapShapedTree(mapper, mempty, mappend, tree) { | ||
return (0, _tree.foldMapTree)(mapper, mempty, mappend, tree); | ||
} | ||
function getRootData(tree) { | ||
return tree.data; | ||
} |
@@ -101,2 +101,32 @@ "use strict"; | ||
}); | ||
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; | ||
}); | ||
_reactTestRenderer2.default.create(React.createElement( | ||
_ArrayField2.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"] | ||
})); | ||
}); | ||
}); | ||
@@ -255,3 +285,3 @@ }); | ||
addField: expect.any(Function) | ||
})); | ||
}), expect.anything()); | ||
}); | ||
@@ -305,3 +335,3 @@ it("validates after entry is added", function () { | ||
removeField: expect.any(Function) | ||
})); | ||
}), expect.anything()); | ||
}); | ||
@@ -355,3 +385,3 @@ it("validates after entry is removed", function () { | ||
moveField: expect.any(Function) | ||
})); | ||
}), expect.anything()); | ||
}); | ||
@@ -358,0 +388,0 @@ it("validates after the entry is moved", function () { |
@@ -15,2 +15,6 @@ "use strict"; | ||
var _Field = require("../Field"); | ||
var _Field2 = _interopRequireDefault(_Field); | ||
var _tools = require("./tools"); | ||
@@ -86,2 +90,3 @@ | ||
}); | ||
it("calls the link onChange with new values and correct meta", function () { | ||
@@ -111,2 +116,3 @@ var formState = (0, _tools.mockFormState)("Hello world."); | ||
}); | ||
it("calls the link onBlur with correct meta", function () { | ||
@@ -132,2 +138,3 @@ var formState = (0, _tools.mockFormState)(""); | ||
}); | ||
it("flattens errors for the inner component", function () { | ||
@@ -150,2 +157,32 @@ var formState = (0, _tools.mockFormState)(""); | ||
}); | ||
it("Passes additional information to its render function", function () { | ||
var formState = (0, _tools.mockFormState)(10); | ||
// $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; | ||
}); | ||
_reactTestRenderer2.default.create(React.createElement( | ||
_Field2.default, | ||
{ link: link }, | ||
renderFn | ||
)); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), 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: 10 | ||
})); | ||
}); | ||
}); |
@@ -535,2 +535,30 @@ "use strict"; | ||
}); | ||
it("Passes additional information to its render function", function () { | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
_reactTestRenderer2.default.create(React.createElement( | ||
_Form2.default, | ||
{ | ||
initialValue: 1, | ||
feedbackStrategy: "OnFirstTouch", | ||
onSubmit: jest.fn(), | ||
serverErrors: { "/": ["Server error", "Another server error"] } | ||
}, | ||
renderFn | ||
)); | ||
expect(renderFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: false, | ||
unfilteredErrors: expect.arrayContaining(["Server error", "Another server error"]), | ||
// Currently, only care about client errors | ||
valid: true, | ||
asyncValidationInFlight: false, | ||
value: 1 | ||
})); | ||
}); | ||
}); | ||
@@ -567,4 +595,70 @@ | ||
expect(onSubmit).toHaveBeenCalledTimes(1); | ||
expect(onSubmit).toHaveBeenLastCalledWith(1); | ||
expect(onSubmit).toHaveBeenLastCalledWith(1, undefined); | ||
}); | ||
it("Calls onSubmit with extra info when submitted", function () { | ||
var onSubmit = jest.fn(); | ||
var renderFn = jest.fn(); | ||
_reactTestRenderer2.default.create(React.createElement( | ||
_Form2.default, | ||
{ | ||
initialValue: 1, | ||
feedbackStrategy: "OnFirstTouch", | ||
onSubmit: onSubmit, | ||
serverErrors: { "/": ["Server error", "Another server error"] } | ||
}, | ||
renderFn | ||
)); | ||
expect(onSubmit).toHaveBeenCalledTimes(0); | ||
var linkOnSubmit = renderFn.mock.calls[0][1]; | ||
linkOnSubmit("extra"); | ||
expect(onSubmit).toHaveBeenCalledTimes(1); | ||
expect(onSubmit).toHaveBeenLastCalledWith(expect.anything(), "extra"); | ||
}); | ||
it("Enforces types on onSubmit", function () { | ||
var onSubmit = function onSubmit() {}; | ||
_reactTestRenderer2.default.create(React.createElement( | ||
_Form2.default, | ||
{ | ||
initialValue: 1, | ||
feedbackStrategy: "OnFirstTouch", | ||
onSubmit: onSubmit, | ||
serverErrors: { "/": ["Server error", "Another server error"] } | ||
}, | ||
function (_, onSubmit) { | ||
// $ExpectError | ||
onSubmit(); | ||
// $ExpectError | ||
onSubmit("hello"); | ||
onSubmit("extra"); | ||
} | ||
)); | ||
}); | ||
it("Calls onChange when the value is changed", function () { | ||
var onChange = jest.fn(); | ||
var renderFn = jest.fn(function () { | ||
return null; | ||
}); | ||
_reactTestRenderer2.default.create(React.createElement( | ||
_Form2.default, | ||
{ | ||
initialValue: 1, | ||
feedbackStrategy: "OnFirstTouch", | ||
onChange: onChange, | ||
serverErrors: { "/": ["Server error", "Another server error"] } | ||
}, | ||
renderFn | ||
)); | ||
var link = renderFn.mock.calls[0][0]; | ||
var nextFormState = (0, _tools.mockFormState)(2); | ||
link.onChange(nextFormState); | ||
expect(onChange).toHaveBeenCalledWith(2); | ||
}); | ||
}); |
@@ -101,2 +101,32 @@ "use strict"; | ||
}); | ||
it("Passes additional information to its render function", function () { | ||
var formState = (0, _tools.mockFormState)({ 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; | ||
}); | ||
_reactTestRenderer2.default.create(React.createElement( | ||
_ObjectField2.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" } | ||
})); | ||
}); | ||
}); | ||
@@ -103,0 +133,0 @@ }); |
@@ -14,2 +14,3 @@ "use strict"; | ||
exports.mapTree = mapTree; | ||
exports.foldMapTree = foldMapTree; | ||
@@ -124,2 +125,19 @@ var _set = require("./utils/set"); | ||
} | ||
} | ||
// Fold a tree preorder | ||
function foldMapTree(mapper, mempty, mappend, tree) { | ||
if (tree.type === "leaf") { | ||
return mapper(tree.data); | ||
} else if (tree.type === "array") { | ||
var foldedChildren = tree.children.reduce(function (memo, childTree) { | ||
return mappend(memo, foldMapTree(mapper, mempty, mappend, childTree)); | ||
}, mempty); | ||
return mappend(mapper(tree.data), foldedChildren); | ||
} else { | ||
var _foldedChildren = Object.keys(tree.children).reduce(function (memo, key) { | ||
return mappend(memo, foldMapTree(mapper, mempty, mappend, tree.children[key])); | ||
}, mempty); | ||
return mappend(mapper(tree.data), _foldedChildren); | ||
} | ||
} |
{ | ||
"name": "formula-one", | ||
"version": "0.2.1", | ||
"version": "0.3.0", | ||
"description": "Strongly-typed React form state management", | ||
@@ -5,0 +5,0 @@ "author": "Zach Gotsch", |
@@ -5,3 +5,9 @@ // @flow strict | ||
import type {FieldLink, Validation, Extras, ClientErrors} from "./types"; | ||
import type { | ||
FieldLink, | ||
Validation, | ||
Extras, | ||
ClientErrors, | ||
AdditionalRenderInfo, | ||
} from "./types"; | ||
import {cleanErrors, cleanMeta} from "./types"; | ||
@@ -28,2 +34,4 @@ import { | ||
getExtras, | ||
flatRootErrors, | ||
isValid, | ||
} from "./formState"; | ||
@@ -40,7 +48,8 @@ | ||
links: Links<E>, | ||
{ | ||
arrayOperations: { | ||
addField: (index: number, value: E) => void, | ||
removeField: (index: number) => void, | ||
moveField: (oldIndex: number, newIndex: number) => void, | ||
} | ||
}, | ||
additionalInfo: AdditionalRenderInfo<Array<E>> | ||
) => React.Node, | ||
@@ -193,2 +202,3 @@ |}; | ||
const {formState} = this.props.link; | ||
const {shouldShowError} = this.props.formContext; | ||
@@ -201,7 +211,19 @@ const links = makeLinks( | ||
); | ||
return this.props.children(links, { | ||
addField: this.addChildField, | ||
removeField: this.removeChildField, | ||
moveField: this.moveChildField, | ||
}); | ||
return this.props.children( | ||
links, | ||
{ | ||
addField: this.addChildField, | ||
removeField: this.removeChildField, | ||
moveField: this.moveChildField, | ||
}, | ||
{ | ||
touched: getExtras(formState).meta.touched, | ||
changed: getExtras(formState).meta.changed, | ||
shouldShowErrors: shouldShowError(getExtras(formState).meta), | ||
unfilteredErrors: flatRootErrors(formState), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: isValid(formState), | ||
value: formState[0], | ||
} | ||
); | ||
} | ||
@@ -208,0 +230,0 @@ } |
// @flow strict | ||
import * as React from "react"; | ||
import type {FieldLink, Validation, Err} from "./types"; | ||
import type {FieldLink, Validation, Err, AdditionalRenderInfo} from "./types"; | ||
import {mapRoot} from "./shapedTree"; | ||
import {FormContext, type FormContextPayload} from "./Form"; | ||
import {setExtrasTouched, getExtras, setChanged, validate} from "./formState"; | ||
import { | ||
setExtrasTouched, | ||
getExtras, | ||
setChanged, | ||
validate, | ||
isValid, | ||
} from "./formState"; | ||
@@ -17,3 +23,4 @@ type Props<T> = {| | ||
onChange: (T) => void, | ||
onBlur: () => void | ||
onBlur: () => void, | ||
additionalInfo: AdditionalRenderInfo<T> | ||
) => React.Node, | ||
@@ -72,8 +79,20 @@ |}; | ||
render() { | ||
const [value] = this.props.link.formState; | ||
const {meta, errors} = getExtras(this.props.link.formState); | ||
const {formState} = this.props.link; | ||
const [value] = formState; | ||
const {meta, errors} = getExtras(formState); | ||
const {shouldShowError} = this.props.formContext; | ||
const flatErrors = this.props.formContext.shouldShowError(meta) | ||
? getErrors(errors) | ||
: []; | ||
return this.props.children(value, flatErrors, this.onChange, this.onBlur); | ||
return this.props.children(value, flatErrors, this.onChange, this.onBlur, { | ||
touched: meta.touched, | ||
changed: meta.changed, | ||
shouldShowErrors: shouldShowError(meta), | ||
unfilteredErrors: getErrors(errors), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: isValid(formState), | ||
value, | ||
}); | ||
} | ||
@@ -80,0 +99,0 @@ } |
@@ -12,5 +12,6 @@ // @flow strict | ||
ClientErrors, | ||
AdditionalRenderInfo, | ||
} from "./types"; | ||
import {cleanMeta, cleanErrors} from "./types"; | ||
import {type FormState} from "./formState"; | ||
import {type FormState, isValid, getExtras, flatRootErrors} from "./formState"; | ||
import { | ||
@@ -100,3 +101,3 @@ type ShapedTree, | ||
function getShouldShowError(strategy: FeedbackStrategy) { | ||
function getShouldShowError(strategy: FeedbackStrategy): MetaField => boolean { | ||
switch (strategy) { | ||
@@ -114,9 +115,14 @@ case "Always": | ||
type Props<T> = { | ||
type Props<T, ExtraSubmitData> = { | ||
// This is *only* used to intialize the form. Further changes will be ignored | ||
+initialValue: T, | ||
+feedbackStrategy: FeedbackStrategy, | ||
+onSubmit: T => void, | ||
+onSubmit: (T, ExtraSubmitData) => void, | ||
+onChange: T => void, | ||
+serverErrors: null | {[path: string]: Array<string>}, | ||
+children: (link: FieldLink<T>, onSubmit: () => void) => React.Node, | ||
+children: ( | ||
link: FieldLink<T>, | ||
onSubmit: (ExtraSubmitData) => void, | ||
additionalInfo: AdditionalRenderInfo<T> | ||
) => React.Node, | ||
}; | ||
@@ -129,4 +135,15 @@ type State<T> = { | ||
}; | ||
export default class Form<T> extends React.Component<Props<T>, State<T>> { | ||
static getDerivedStateFromProps(props: Props<T>, state: State<T>) { | ||
export default class Form<T, ExtraSubmitData> extends React.Component< | ||
Props<T, ExtraSubmitData>, | ||
State<T> | ||
> { | ||
static defaultProps = { | ||
onChange: () => {}, | ||
onSubmit: () => {}, | ||
}; | ||
static getDerivedStateFromProps( | ||
props: Props<T, ExtraSubmitData>, | ||
state: State<T> | ||
) { | ||
if (props.serverErrors !== state.oldServerErrors) { | ||
@@ -145,3 +162,3 @@ const newFormState = applyServerErrorsToFormState( | ||
constructor(props: Props<T>) { | ||
constructor(props: Props<T, ExtraSubmitData>) { | ||
super(props); | ||
@@ -165,5 +182,7 @@ | ||
onSubmit = () => { | ||
onSubmit: (extraData: ExtraSubmitData) => void = ( | ||
extraData: ExtraSubmitData | ||
) => { | ||
this.setState({submitted: true}); | ||
this.props.onSubmit(this.state.formState[0]); | ||
this.props.onSubmit(this.state.formState[0], extraData); | ||
}; | ||
@@ -175,2 +194,3 @@ | ||
this.setState({formState: newState, pristine: false}); | ||
this.props.onChange(newState[0]); | ||
}; | ||
@@ -219,3 +239,14 @@ | ||
}, | ||
this.onSubmit | ||
this.onSubmit, | ||
{ | ||
touched: getExtras(formState).meta.touched, | ||
changed: getExtras(formState).meta.changed, | ||
shouldShowErrors: getShouldShowError(this.props.feedbackStrategy)( | ||
getExtras(formState).meta | ||
), | ||
unfilteredErrors: flatRootErrors(formState), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: isValid(formState), | ||
value: formState[0], | ||
} | ||
)} | ||
@@ -222,0 +253,0 @@ </FormContext.Provider> |
@@ -13,2 +13,4 @@ // @flow strict | ||
shapedZipWith, | ||
foldMapShapedTree, | ||
getRootData, | ||
} from "./shapedTree"; | ||
@@ -26,2 +28,15 @@ import type {Extras, ClientErrors, Validation, ServerErrors} from "./types"; | ||
export function flatRootErrors<T>(formState: FormState<T>): Array<string> { | ||
const errors = getRootData(formState[1]).errors; | ||
let flatErrors = []; | ||
if (errors.client !== "pending") { | ||
flatErrors = flatErrors.concat(errors.client); | ||
} | ||
if (errors.server !== "unchecked") { | ||
flatErrors = flatErrors.concat(errors.server); | ||
} | ||
return flatErrors; | ||
} | ||
export function objectChild<T: {}, V>( | ||
@@ -239,1 +254,15 @@ key: string, | ||
} | ||
// Is whole tree client valid? | ||
// TODO(zach): This will have to change with asynchronous validations. We will | ||
// need a "pending" value as well as an "unchecked" value. | ||
// Currently, things in the tree which are not reflected in the React tree are | ||
// marked "pending", which means they can be valid :grimace:. | ||
export function isValid<T>(formState: FormState<T>): boolean { | ||
return foldMapShapedTree( | ||
({errors: {client}}) => client === "pending" || client.length === 0, | ||
true, | ||
(l, r) => l && r, | ||
formState[1] | ||
); | ||
} |
@@ -5,3 +5,9 @@ // @flow strict | ||
import type {FieldLink, Validation, Extras, ClientErrors} from "./types"; | ||
import type { | ||
FieldLink, | ||
Validation, | ||
Extras, | ||
ClientErrors, | ||
AdditionalRenderInfo, | ||
} from "./types"; | ||
import {type FormContextPayload} from "./Form"; | ||
@@ -17,2 +23,4 @@ import {FormContext} from "./Form"; | ||
getExtras, | ||
flatRootErrors, | ||
isValid, | ||
} from "./formState"; | ||
@@ -33,3 +41,6 @@ import { | ||
+validation: Validation<T>, | ||
+children: (links: Links<T>) => React.Node, | ||
+children: ( | ||
links: Links<T>, | ||
additionalInfo: AdditionalRenderInfo<T> | ||
) => React.Node, | ||
|}; | ||
@@ -130,2 +141,5 @@ | ||
render() { | ||
const {formState} = this.props.link; | ||
const {shouldShowError} = this.props.formContext; | ||
const links = makeLinks( | ||
@@ -137,3 +151,11 @@ this.props.link.formState, | ||
); | ||
return this.props.children(links); | ||
return this.props.children(links, { | ||
touched: getExtras(formState).meta.touched, | ||
changed: getExtras(formState).meta.changed, | ||
shouldShowErrors: shouldShowError(getExtras(formState).meta), | ||
unfilteredErrors: flatRootErrors(formState), | ||
asyncValidationInFlight: false, // no validations on Form | ||
valid: isValid(formState), | ||
value: formState[0], | ||
}); | ||
} | ||
@@ -140,0 +162,0 @@ } |
// @flow strict | ||
import {type Tree, type Path, leaf, strictZipWith, mapTree} from "./tree"; | ||
import { | ||
type Tree, | ||
type Path, | ||
leaf, | ||
strictZipWith, | ||
mapTree, | ||
foldMapTree, | ||
} from "./tree"; | ||
import invariant from "./utils/invariant"; | ||
@@ -302,1 +309,15 @@ import {replaceAt} from "./utils/array"; | ||
} | ||
// Fold a tree inorder | ||
export function foldMapShapedTree<T, Node, Folded>( | ||
mapper: Node => Folded, | ||
mempty: Folded, | ||
mappend: (Folded, Folded) => Folded, | ||
tree: ShapedTree<T, Node> | ||
): Folded { | ||
return foldMapTree(mapper, mempty, mappend, tree); | ||
} | ||
export function getRootData<T, Node>(tree: ShapedTree<T, Node>): Node { | ||
return tree.data; | ||
} |
@@ -66,2 +66,33 @@ // @flow | ||
}); | ||
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"], | ||
}) | ||
); | ||
}); | ||
}); | ||
@@ -184,3 +215,4 @@ }); | ||
addField: expect.any(Function), | ||
}) | ||
}), | ||
expect.anything() | ||
); | ||
@@ -229,3 +261,4 @@ }); | ||
removeField: expect.any(Function), | ||
}) | ||
}), | ||
expect.anything() | ||
); | ||
@@ -269,3 +302,4 @@ }); | ||
moveField: expect.any(Function), | ||
}) | ||
}), | ||
expect.anything() | ||
); | ||
@@ -272,0 +306,0 @@ }); |
@@ -6,2 +6,3 @@ // @flow | ||
import Field from "../Field"; | ||
import {mockFormState, mockLink} from "./tools"; | ||
@@ -56,2 +57,3 @@ import TestField, {TestInput} from "./TestField"; | ||
}); | ||
it("calls the link onChange with new values and correct meta", () => { | ||
@@ -78,2 +80,3 @@ const formState = mockFormState("Hello world."); | ||
}); | ||
it("calls the link onBlur with correct meta", () => { | ||
@@ -99,2 +102,3 @@ const formState = mockFormState(""); | ||
}); | ||
it("flattens errors for the inner component", () => { | ||
@@ -125,2 +129,35 @@ let formState = mockFormState(""); | ||
}); | ||
it("Passes additional information to its render function", () => { | ||
const formState = mockFormState(10); | ||
// $FlowFixMe | ||
formState[1].data.errors = { | ||
server: ["A server error"], | ||
client: ["A client error"], | ||
}; | ||
const link = mockLink(formState); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create(<Field link={link}>{renderFn}</Field>); | ||
expect(renderFn).toHaveBeenCalled(); | ||
expect(renderFn).toHaveBeenCalledWith( | ||
expect.anything(), | ||
expect.anything(), | ||
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: 10, | ||
}) | ||
); | ||
}); | ||
}); |
@@ -428,2 +428,35 @@ // @flow | ||
}); | ||
it("Passes additional information to its render function", () => { | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create( | ||
<Form | ||
initialValue={1} | ||
feedbackStrategy="OnFirstTouch" | ||
onSubmit={jest.fn()} | ||
serverErrors={{"/": ["Server error", "Another server error"]}} | ||
> | ||
{renderFn} | ||
</Form> | ||
); | ||
expect(renderFn).toHaveBeenCalledWith( | ||
expect.anything(), | ||
expect.anything(), | ||
expect.objectContaining({ | ||
touched: false, | ||
changed: false, | ||
shouldShowErrors: false, | ||
unfilteredErrors: expect.arrayContaining([ | ||
"Server error", | ||
"Another server error", | ||
]), | ||
// Currently, only care about client errors | ||
valid: true, | ||
asyncValidationInFlight: false, | ||
value: 1, | ||
}) | ||
); | ||
}); | ||
}); | ||
@@ -454,4 +487,68 @@ | ||
expect(onSubmit).toHaveBeenCalledTimes(1); | ||
expect(onSubmit).toHaveBeenLastCalledWith(1); | ||
expect(onSubmit).toHaveBeenLastCalledWith(1, undefined); | ||
}); | ||
it("Calls onSubmit with extra info when submitted", () => { | ||
const onSubmit = jest.fn(); | ||
const renderFn = jest.fn(); | ||
TestRenderer.create( | ||
<Form | ||
initialValue={1} | ||
feedbackStrategy="OnFirstTouch" | ||
onSubmit={onSubmit} | ||
serverErrors={{"/": ["Server error", "Another server error"]}} | ||
> | ||
{renderFn} | ||
</Form> | ||
); | ||
expect(onSubmit).toHaveBeenCalledTimes(0); | ||
const linkOnSubmit = renderFn.mock.calls[0][1]; | ||
linkOnSubmit("extra"); | ||
expect(onSubmit).toHaveBeenCalledTimes(1); | ||
expect(onSubmit).toHaveBeenLastCalledWith(expect.anything(), "extra"); | ||
}); | ||
it("Enforces types on onSubmit", () => { | ||
const onSubmit: (value: number, extra: "extra") => void = () => {}; | ||
TestRenderer.create( | ||
<Form | ||
initialValue={1} | ||
feedbackStrategy="OnFirstTouch" | ||
onSubmit={onSubmit} | ||
serverErrors={{"/": ["Server error", "Another server error"]}} | ||
> | ||
{(_, onSubmit) => { | ||
// $ExpectError | ||
onSubmit(); | ||
// $ExpectError | ||
onSubmit("hello"); | ||
onSubmit("extra"); | ||
}} | ||
</Form> | ||
); | ||
}); | ||
it("Calls onChange when the value is changed", () => { | ||
const onChange = jest.fn(); | ||
const renderFn = jest.fn(() => null); | ||
TestRenderer.create( | ||
<Form | ||
initialValue={1} | ||
feedbackStrategy="OnFirstTouch" | ||
onChange={onChange} | ||
serverErrors={{"/": ["Server error", "Another server error"]}} | ||
> | ||
{renderFn} | ||
</Form> | ||
); | ||
const link = renderFn.mock.calls[0][0]; | ||
const nextFormState = mockFormState(2); | ||
link.onChange(nextFormState); | ||
expect(onChange).toHaveBeenCalledWith(2); | ||
}); | ||
}); |
@@ -66,2 +66,32 @@ // @flow | ||
}); | ||
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"}, | ||
}) | ||
); | ||
}); | ||
}); | ||
@@ -68,0 +98,0 @@ }); |
@@ -142,1 +142,27 @@ // @flow strict | ||
} | ||
// Fold a tree preorder | ||
export function foldMapTree<T, Folded>( | ||
mapper: T => Folded, | ||
mempty: Folded, | ||
mappend: (Folded, Folded) => Folded, | ||
tree: Tree<T> | ||
): Folded { | ||
if (tree.type === "leaf") { | ||
return mapper(tree.data); | ||
} else if (tree.type === "array") { | ||
const foldedChildren = tree.children.reduce( | ||
(memo, childTree) => | ||
mappend(memo, foldMapTree(mapper, mempty, mappend, childTree)), | ||
mempty | ||
); | ||
return mappend(mapper(tree.data), foldedChildren); | ||
} else { | ||
const foldedChildren = Object.keys(tree.children).reduce( | ||
(memo, key) => | ||
mappend(memo, foldMapTree(mapper, mempty, mappend, tree.children[key])), | ||
mempty | ||
); | ||
return mappend(mapper(tree.data), foldedChildren); | ||
} | ||
} |
@@ -42,2 +42,12 @@ // @flow strict | ||
export type AdditionalRenderInfo<T> = {| | ||
+touched: boolean, | ||
+changed: boolean, | ||
+shouldShowErrors: boolean, | ||
+unfilteredErrors: $ReadOnlyArray<string>, | ||
+valid: boolean, | ||
+asyncValidationInFlight: boolean, | ||
+value: T, | ||
|}; | ||
export type OnChange<T> = (FormState<T>) => void; | ||
@@ -44,0 +54,0 @@ export type OnBlur<T> = (ShapedTree<T, Extras>) => void; |
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
231072
59
5959