diff --git a/INTRODUCTION.md b/INTRODUCTION.md index 9ae6f325f..35509c5df 100644 --- a/INTRODUCTION.md +++ b/INTRODUCTION.md @@ -884,6 +884,7 @@ MyComponentUsingUniformsContext.contextTypes = { state: PropTypes.shape({ changed: PropTypes.bool.isRequired, changedMap: PropTypes.object.isRequired, + submitting: PropTypes.bool.isRequired, label: PropTypes.bool.isRequired, disabled: PropTypes.bool.isRequired, @@ -948,8 +949,8 @@ import filterDOMProps from 'uniforms/filterDOMProps'; // This field works as follows: render standard submit field and disable it, when // the form is invalid. It's a simplified version of a default SubmitField from // uniforms-unstyled. -const SubmitField = (props, {uniforms: {error, state: {disabled}}}) => - +const SubmitField = (props, {uniforms: {error, state: {disabled, submitting, validating}}}) => + ; SubmitField.contextTypes = BaseField.contextTypes; diff --git a/demo/imports/components/ApplicationPreviewField.js b/demo/imports/components/ApplicationPreviewField.js index dd8324a9d..3ae1d2812 100644 --- a/demo/imports/components/ApplicationPreviewField.js +++ b/demo/imports/components/ApplicationPreviewField.js @@ -32,8 +32,14 @@ class ApplicationPreview extends Component { const Form = themes[this.props.theme].AutoForm; const link = styles[this.props.theme]; - const props = {...this.props.value}; + const {asyncOnSubmit, asyncOnValidate, ...props} = {...this.props.value}; props.schema = this._schema; + if (asyncOnSubmit) { + props.onSubmit = () => new Promise(resolve => setTimeout(resolve, 1000)); + } + if (asyncOnValidate) { + props.onValidate = (model, error, next) => setTimeout(() => next(), 1000); + } return (
@@ -42,7 +48,7 @@ class ApplicationPreview extends Component { {this.props.errorMessage ? ( ) : ( -
+ )} {this.state.model !== undefined &&
} diff --git a/demo/imports/components/ApplicationPropsField.js b/demo/imports/components/ApplicationPropsField.js index 2d1e3b4b6..36ffbeefe 100644 --- a/demo/imports/components/ApplicationPropsField.js +++ b/demo/imports/components/ApplicationPropsField.js @@ -31,6 +31,8 @@ const ApplicationProps = ({onChange, schema, theme, value}) => { + + diff --git a/demo/imports/lib/schema.js b/demo/imports/lib/schema.js index 39ba96284..633d9cc9e 100644 --- a/demo/imports/lib/schema.js +++ b/demo/imports/lib/schema.js @@ -49,7 +49,9 @@ const schema = new SimpleSchema2({ label: true, placeholder: false, schema: presets[Object.keys(presets)[0]], - showInlineError: false + showInlineError: false, + asyncOnSubmit: false, + asyncOnValidate: false }, uniforms: { schema: new SimpleSchema2({ @@ -59,6 +61,8 @@ const schema = new SimpleSchema2({ label: {optional: true, type: Boolean}, placeholder: {optional: true, type: Boolean}, showInlineError: {optional: true, type: Boolean}, + asyncOnSubmit: {optional: true, type: Boolean, label: 'Async onSubmit (1 sec)'}, + asyncOnValidate: {optional: true, type: Boolean, label: 'Async onValidate (1 sec)'}, schema: { optional: true, diff --git a/packages/uniforms-antd/__tests__/_createContext.js b/packages/uniforms-antd/__tests__/_createContext.js index b6c4a5f5e..7c44b7d8e 100644 --- a/packages/uniforms-antd/__tests__/_createContext.js +++ b/packages/uniforms-antd/__tests__/_createContext.js @@ -20,6 +20,7 @@ const createContext = (schema, context) => ({ changedMap: {}, changed: false, + submitting: false, disabled: false, label: false, placeholder: false, diff --git a/packages/uniforms-bootstrap3/__tests__/_createContext.js b/packages/uniforms-bootstrap3/__tests__/_createContext.js index b6c4a5f5e..7c44b7d8e 100644 --- a/packages/uniforms-bootstrap3/__tests__/_createContext.js +++ b/packages/uniforms-bootstrap3/__tests__/_createContext.js @@ -20,6 +20,7 @@ const createContext = (schema, context) => ({ changedMap: {}, changed: false, + submitting: false, disabled: false, label: false, placeholder: false, diff --git a/packages/uniforms-bootstrap4/__tests__/_createContext.js b/packages/uniforms-bootstrap4/__tests__/_createContext.js index b6c4a5f5e..7c44b7d8e 100644 --- a/packages/uniforms-bootstrap4/__tests__/_createContext.js +++ b/packages/uniforms-bootstrap4/__tests__/_createContext.js @@ -20,6 +20,7 @@ const createContext = (schema, context) => ({ changedMap: {}, changed: false, + submitting: false, disabled: false, label: false, placeholder: false, diff --git a/packages/uniforms-material/__tests__/_createContext.js b/packages/uniforms-material/__tests__/_createContext.js index d0eec02e5..3b6726839 100644 --- a/packages/uniforms-material/__tests__/_createContext.js +++ b/packages/uniforms-material/__tests__/_createContext.js @@ -23,6 +23,7 @@ const createContext = (schema, context) => ({ changed: false, disabled: false, + submitting: false, label: false, placeholder: false, showInlineError: false, diff --git a/packages/uniforms-semantic/__tests__/_createContext.js b/packages/uniforms-semantic/__tests__/_createContext.js index b6c4a5f5e..7c44b7d8e 100644 --- a/packages/uniforms-semantic/__tests__/_createContext.js +++ b/packages/uniforms-semantic/__tests__/_createContext.js @@ -20,6 +20,7 @@ const createContext = (schema, context) => ({ changedMap: {}, changed: false, + submitting: false, disabled: false, label: false, placeholder: false, diff --git a/packages/uniforms-unstyled/__tests__/_createContext.js b/packages/uniforms-unstyled/__tests__/_createContext.js index b6c4a5f5e..7c44b7d8e 100644 --- a/packages/uniforms-unstyled/__tests__/_createContext.js +++ b/packages/uniforms-unstyled/__tests__/_createContext.js @@ -20,6 +20,7 @@ const createContext = (schema, context) => ({ changedMap: {}, changed: false, + submitting: false, disabled: false, label: false, placeholder: false, diff --git a/packages/uniforms/__tests__/BaseField.js b/packages/uniforms/__tests__/BaseField.js index aa7b5ed42..d0e24444d 100644 --- a/packages/uniforms/__tests__/BaseField.js +++ b/packages/uniforms/__tests__/BaseField.js @@ -30,7 +30,15 @@ describe('BaseField', () => { const model = {a: {b: {c: 'example'}}}; const onChange = jest.fn(); const randomId = randomIds(); - const state = {changed: !1, changedMap: {}, label: !0, disabled: !1, placeholder: !0, showInlineError: !0}; + const state = { + changed: false, + changedMap: {}, + submitting: false, + label: true, + disabled: false, + placeholder: true, + showInlineError: true + }; const schema = createSchemaBridge({ getDefinition (name) { // Simulate SimpleSchema. diff --git a/packages/uniforms/__tests__/BaseForm.js b/packages/uniforms/__tests__/BaseForm.js index 414037f71..94174e9a2 100644 --- a/packages/uniforms/__tests__/BaseForm.js +++ b/packages/uniforms/__tests__/BaseForm.js @@ -30,6 +30,8 @@ describe('BaseForm', () => { afterEach(() => { onChange.mockReset(); onSubmit.mockReset(); + onSubmitSuccess.mockReset(); + onSubmitFailure.mockReset(); }); describe('child context', () => { @@ -64,6 +66,7 @@ describe('BaseForm', () => { expect(context.uniforms).toHaveProperty('state', expect.any(Object)); expect(context.uniforms.state).toHaveProperty('changed', false); expect(context.uniforms.state).toHaveProperty('changedMap', {}); + expect(context.uniforms.state).toHaveProperty('submitting', false); expect(context.uniforms.state).toHaveProperty('label', true); expect(context.uniforms.state).toHaveProperty('disabled', false); expect(context.uniforms.state).toHaveProperty('placeholder', false); @@ -254,7 +257,7 @@ describe('BaseForm', () => { expect(onSubmit).toHaveBeenLastCalledWith(model); }); - it('calls `onSubmit` with correct model (`modelTransform`)', () => { + it('calls `onSubmit` with the correctly `modelTransform`ed model', () => { wrapper.setProps({ modelTransform (mode, model) { if (mode === 'submit') { @@ -272,15 +275,39 @@ describe('BaseForm', () => { wrapper.setProps({modelTransform: undefined}); }); - it('does nothing without `onSubmit`', () => { - wrapper.setProps({onSubmit: undefined}); + it('without `onSubmit` calls only `onSubmitSuccess`', async () => { + wrapper.setProps({onSubmit: undefined, onSubmitSuccess, onSubmitFailure}); wrapper.find('form').simulate('submit'); + await new Promise(resolve => process.nextTick(resolve)); expect(onSubmit).not.toBeCalled(); + expect(onSubmitSuccess).toBeCalledTimes(1); + expect(onSubmitFailure).not.toBeCalled(); + }); + + it('sets `submitting` state while submitting', async () => { + let resolveSubmit = null; + wrapper.setProps({onSubmit: () => new Promise(resolve => resolveSubmit = resolve)}); + + const context1 = wrapper.instance().getChildContext().uniforms.state; + expect(context1).toHaveProperty('submitting', false); + + wrapper.find('form').simulate('submit'); + await new Promise(resolve => process.nextTick(resolve)); + + const context2 = wrapper.instance().getChildContext().uniforms.state; + expect(context2).toHaveProperty('submitting', true); + + resolveSubmit(); + await new Promise(resolve => process.nextTick(resolve)); + + const context3 = wrapper.instance().getChildContext().uniforms.state; + expect(context3).toHaveProperty('submitting', false); }); - it('calls `onSubmitSuccess` when `onSubmit` resolves', async () => { - onSubmit.mockReturnValueOnce(Promise.resolve()); + it('calls `onSubmitSuccess` with the returned value when `onSubmit` resolves', async () => { + const onSubmitValue = 'value'; + onSubmit.mockReturnValueOnce(Promise.resolve(onSubmitValue)); const wrapper = mount( @@ -291,10 +318,12 @@ describe('BaseForm', () => { await new Promise(resolve => process.nextTick(resolve)); expect(onSubmitSuccess).toHaveBeenCalledTimes(1); + expect(onSubmitSuccess).toHaveBeenLastCalledWith(onSubmitValue); }); - it('calls `onSubmitFailure` when `onSubmit` rejects', async () => { - onSubmit.mockReturnValueOnce(Promise.reject()); + it('calls `onSubmitFailure` with the thrown error when `onSubmit` rejects', async () => { + const onSubmitError = 'error'; + onSubmit.mockReturnValueOnce(Promise.reject(onSubmitError)); const wrapper = mount( @@ -305,6 +334,7 @@ describe('BaseForm', () => { await new Promise(resolve => process.nextTick(resolve)); expect(onSubmitFailure).toHaveBeenCalledTimes(1); + expect(onSubmitFailure).toHaveBeenLastCalledWith(onSubmitError); }); }); }); diff --git a/packages/uniforms/__tests__/ValidatedForm.js b/packages/uniforms/__tests__/ValidatedForm.js index c788ae129..f0878da7e 100644 --- a/packages/uniforms/__tests__/ValidatedForm.js +++ b/packages/uniforms/__tests__/ValidatedForm.js @@ -100,6 +100,20 @@ describe('ValidatedForm', () => { expect(wrapper.instance().getChildContext()).not.toHaveProperty('uniforms.error', error); }); + it('has `validating` context variable, default `false`', () => { + expect(wrapper.instance().getChildContext()).toHaveProperty('uniforms.state.validating', false); + }); + + it('sets `validating` `true` while validating', async () => { + onValidate.mockImplementationOnce(() => {}); + form.validate(); + expect(wrapper.instance().getChildContext()).toHaveProperty('uniforms.state.validating', true); + + // Resolve the async validation by calling the third argument of the first call to onValidate. + expect(onValidate).toHaveBeenCalledTimes(1); + onValidate.mock.calls[0][2](); + expect(wrapper.instance().getChildContext()).toHaveProperty('uniforms.state.validating', false); + }); it('uses `modelTransform`s `validate` mode', () => { const transformedModel = {b: 1}; @@ -114,7 +128,9 @@ describe('ValidatedForm', () => { describe('when submitted', () => { let wrapper; beforeEach(() => { - wrapper = mount(); + wrapper = mount( + + ); }); it('calls `onSubmit` when validation succeeds', async () => { @@ -142,6 +158,27 @@ describe('ValidatedForm', () => { expect(onSubmit).toHaveBeenCalled(); expect(wrapper.instance().getChildContext()).toHaveProperty('uniforms.error', error); }); + + it('sets `submitting` `true` while validating, before `BaseForm#onSubmit`', async () => { + onValidate.mockImplementationOnce(() => {}); + wrapper.find('form').simulate('submit'); + await new Promise(resolve => process.nextTick(resolve)); + expect(wrapper.instance().getChildContext()).toHaveProperty('uniforms.state.submitting', true); + }); + + it('sets `submitting` back to `false` after sync `onSubmit`', async () => { + onValidate.mockImplementationOnce(() => {}); + onSubmit.mockImplementationOnce(() => {}); + wrapper.find('form').simulate('submit'); + await new Promise(resolve => process.nextTick(resolve)); + + expect(onValidate).toHaveBeenCalledTimes(1); + // Resolve the async validation by calling the third argument of the first call to onValidate. + onValidate.mock.calls[0][2](); + + await new Promise(resolve => process.nextTick(resolve)); + expect(wrapper.instance().getChildContext()).toHaveProperty('uniforms.state.submitting', false); + }); }); describe('on change', () => { diff --git a/packages/uniforms/__tests__/connectField.js b/packages/uniforms/__tests__/connectField.js index 2deea6232..05f392af8 100644 --- a/packages/uniforms/__tests__/connectField.js +++ b/packages/uniforms/__tests__/connectField.js @@ -13,7 +13,15 @@ describe('connectField', () => { const error = new Error(); const onChange = jest.fn(); const randomId = randomIds(); - const state = {changed: !1, changedMap: {}, label: !0, disabled: !1, placeholder: !1, showInlineError: !0}; + const state = { + changed: false, + changedMap: {}, + submitting: false, + label: true, + disabled: false, + placeholder: false, + showInlineError: true + }; const schema = createSchemaBridge({ getDefinition (name) { return { diff --git a/packages/uniforms/__tests__/injectName.js b/packages/uniforms/__tests__/injectName.js index b9c50a5c3..9fb9c3cff 100644 --- a/packages/uniforms/__tests__/injectName.js +++ b/packages/uniforms/__tests__/injectName.js @@ -14,7 +14,15 @@ describe('injectName', () => { const error = new Error(); const onChange = () => {}; const randomId = randomIds(); - const state = {changed: !1, changedMap: {}, label: !0, disabled: !1, placeholder: !1, showInlineError: !0}; + const state = { + changed: false, + changedMap: {}, + submitting: false, + label: true, + disabled: false, + placeholder: false, + showInlineError: true + }; const schema = createSchemaBridge({ getDefinition (name) { return { diff --git a/packages/uniforms/src/BaseForm.js b/packages/uniforms/src/BaseForm.js index e6b50fdce..11f0b7153 100644 --- a/packages/uniforms/src/BaseForm.js +++ b/packages/uniforms/src/BaseForm.js @@ -1,14 +1,55 @@ import PropTypes from 'prop-types'; import React from 'react'; import cloneDeep from 'lodash/cloneDeep'; +import mapValues from 'lodash/mapValues'; import get from 'lodash/get'; import set from 'lodash/set'; +import isFunction from 'lodash/isFunction'; +import isPlainObject from 'lodash/isPlainObject'; import {Component} from 'react'; import changedKeys from './changedKeys'; import createSchemaBridge from './createSchemaBridge'; import randomIds from './randomIds'; +export const __childContextTypes = { + name: PropTypes.arrayOf(PropTypes.string).isRequired, + + error: PropTypes.object, + model: PropTypes.object.isRequired, + + schema: { + getError: PropTypes.func.isRequired, + getErrorMessage: PropTypes.func.isRequired, + getErrorMessages: PropTypes.func.isRequired, + getField: PropTypes.func.isRequired, + getInitialValue: PropTypes.func.isRequired, + getProps: PropTypes.func.isRequired, + getSubfields: PropTypes.func.isRequired, + getType: PropTypes.func.isRequired, + getValidator: PropTypes.func.isRequired + }, + + state: { + changed: PropTypes.bool.isRequired, + changedMap: PropTypes.object.isRequired, + submitting: PropTypes.bool.isRequired, + + label: PropTypes.bool.isRequired, + disabled: PropTypes.bool.isRequired, + placeholder: PropTypes.bool.isRequired, + showInlineError: PropTypes.bool.isRequired + }, + + onChange: PropTypes.func.isRequired, + randomId: PropTypes.func.isRequired +}; + +export const __childContextTypesBuild = type => + isPlainObject(type) + ? PropTypes.shape(mapValues(type, __childContextTypesBuild)).isRequired + : type; + export default class BaseForm extends Component { static displayName = 'Form'; @@ -44,43 +85,19 @@ export default class BaseForm extends Component { }; static childContextTypes = { - uniforms: PropTypes.shape({ - name: PropTypes.arrayOf(PropTypes.string).isRequired, - - error: PropTypes.object, - model: PropTypes.object.isRequired, - - schema: PropTypes.shape({ - getError: PropTypes.func.isRequired, - getErrorMessage: PropTypes.func.isRequired, - getErrorMessages: PropTypes.func.isRequired, - getField: PropTypes.func.isRequired, - getInitialValue: PropTypes.func.isRequired, - getProps: PropTypes.func.isRequired, - getSubfields: PropTypes.func.isRequired, - getType: PropTypes.func.isRequired, - getValidator: PropTypes.func.isRequired - }).isRequired, - - state: PropTypes.shape({ - changed: PropTypes.bool.isRequired, - changedMap: PropTypes.object.isRequired, - - label: PropTypes.bool.isRequired, - disabled: PropTypes.bool.isRequired, - placeholder: PropTypes.bool.isRequired, - showInlineError: PropTypes.bool.isRequired - }).isRequired, - - onChange: PropTypes.func.isRequired, - randomId: PropTypes.func.isRequired - }).isRequired + uniforms: __childContextTypesBuild(__childContextTypes) }; constructor () { super(...arguments); - this.state = {bridge: createSchemaBridge(this.props.schema), changed: null, changedMap: {}, resetCount: 0}; + this.state = { + bridge: createSchemaBridge(this.props.schema), + changed: null, + changedMap: {}, + resetCount: 0, + submitting: false + }; this.delayId = false; this.randomId = randomIds(this.props.id); @@ -128,6 +145,7 @@ export default class BaseForm extends Component { return { changed: !!this.state.changed, changedMap: this.state.changedMap, + submitting: this.state.submitting, label: !!this.props.label, disabled: !!this.props.disabled, @@ -225,7 +243,7 @@ export default class BaseForm extends Component { } __reset (state) { - return {changed: false, changedMap: {}, resetCount: state.resetCount + 1}; + return {changed: false, changedMap: {}, submitting: false, resetCount: state.resetCount + 1}; } onReset () { @@ -238,10 +256,18 @@ export default class BaseForm extends Component { event.stopPropagation(); } - return Promise.resolve( - this.props.onSubmit && - this.props.onSubmit(this.getModel('submit')) - ).then( + const result = this.props.onSubmit && this.props.onSubmit(this.getModel('submit')); + + // Set the `submitting` state only if onSubmit is async so we don't cause an unnecessary re-render + let submitting; + if (result && isFunction(result.then)) { + this.setState({submitting: true}); + submitting = result.finally(() => this.setState({submitting: false})); + } else { + submitting = Promise.resolve(result); + } + + return submitting.then( this.props.onSubmitSuccess, this.props.onSubmitFailure ); diff --git a/packages/uniforms/src/ValidatedForm.js b/packages/uniforms/src/ValidatedForm.js index 21f2db79b..764de9169 100644 --- a/packages/uniforms/src/ValidatedForm.js +++ b/packages/uniforms/src/ValidatedForm.js @@ -1,9 +1,17 @@ import PropTypes from 'prop-types'; import cloneDeep from 'lodash/cloneDeep'; +import merge from 'lodash/merge'; import isEqual from 'lodash/isEqual'; import set from 'lodash/set'; -import BaseForm from './BaseForm'; +import BaseForm from './BaseForm'; +import {__childContextTypesBuild} from './BaseForm'; +import {__childContextTypes} from './BaseForm'; + +const childContextTypes = __childContextTypesBuild(merge( + {state: {validating: PropTypes.bool.isRequired}}, + __childContextTypes +)); const Validated = parent => class extends parent { static Validated = Validated; @@ -33,6 +41,11 @@ const Validated = parent => class extends parent { ]).isRequired }; + static childContextTypes = { + ...parent.childContextTypes || {}, + uniforms: childContextTypes + }; + constructor () { super(...arguments); @@ -41,6 +54,7 @@ const Validated = parent => class extends parent { error: null, validate: false, + validating: false, validator: this .getChildContextSchema() .getValidator(this.props.validator) @@ -54,6 +68,14 @@ const Validated = parent => class extends parent { return super.getChildContextError() || this.state.error; } + getChildContextState () { + return { + ...super.getChildContextState(), + + validating: this.state.validating + }; + } + getNativeFormProps () { const { onValidate, // eslint-disable-line no-unused-vars @@ -74,12 +96,12 @@ const Validated = parent => class extends parent { validator: bridge.getValidator(validator) }), () => { if (validate === 'onChange' || validate === 'onChangeAfterSubmit' && this.state.validate) { - this.onValidate(); + this.onValidate().catch(() => {}); } }); } else if (!isEqual(this.props.model, model)) { if (validate === 'onChange' || validate === 'onChangeAfterSubmit' && this.state.validate) { - this.onValidateModel(model); + this.onValidateModel(model).catch(() => {}); } } } @@ -99,7 +121,7 @@ const Validated = parent => class extends parent { } __reset (state) { - return {...super.__reset(state), error: null, validate: false}; + return {...super.__reset(state), error: null, validate: false, validating: false}; } onSubmit (event) { @@ -109,7 +131,7 @@ const Validated = parent => class extends parent { } const promise = new Promise((resolve, reject) => { - this.setState(() => ({validate: true}), () => { + this.setState(() => ({submitting: true, validate: true}), () => { this.onValidate().then( () => { super.onSubmit().then( @@ -125,8 +147,14 @@ const Validated = parent => class extends parent { }); }); - // NOTE: It's okay for this Promise to reject. - promise.catch(() => {}); + promise + .catch(() => { + // `onSubmit` should never reject, so we ignore this rejection. + }) + .then(() => { + // If validation fails, or `super.onSubmit` doesn't touch `submitting`, we need to reset it. + this.setState(state => state.submitting ? {submitting: false} : null); + }); return promise; } @@ -150,10 +178,11 @@ const Validated = parent => class extends parent { catched = error; } + this.setState({validating: true}); return new Promise((resolve, reject) => { this.props.onValidate(model, catched, (error = catched) => { // Do not copy error from props to state. - this.setState(() => ({error: error === this.props.error ? null : error}), () => { + this.setState(() => ({error: error === this.props.error ? null : error, validating: false}), () => { if (error) { reject(error); } else { diff --git a/packages/uniforms/src/filterDOMProps.js b/packages/uniforms/src/filterDOMProps.js index 255387ab0..96e8f96ba 100644 --- a/packages/uniforms/src/filterDOMProps.js +++ b/packages/uniforms/src/filterDOMProps.js @@ -20,7 +20,9 @@ const unwantedProps = [ 'parent', 'placeholder', 'showInlineError', + 'submitting', 'transform', + 'validating', 'value', // These are used by AutoField