diff --git a/README.md b/README.md index 364618c..484d8da 100644 --- a/README.md +++ b/README.md @@ -1175,15 +1175,16 @@ The `onSubmit` function passes the same `details` object as `onChange` does, so import {PluginSubmit} from 'dendriform'; const plugins = { - submit: new PluginSubmit({ - onSubmit: async (newValue, details) => { + submit: new PluginSubmit({ + onSubmit: async (newValue, details): void => { // trigger save action here // any errors will be eligible for resubmission // diff(details) can be used in here to diff changes since last submit }, - onError: (error) => { - // optional function, will be called - // if an error occurs in onSubmit + onError: (error: any): E|undefined => { + // optional function, will be called if an error occurs in onSubmit + // anything returned will be stored in state in form.plugins.submit.error + // the error state is cleared on next submit } }) }; @@ -1204,12 +1205,12 @@ form.branch('foo').plugins.submit.changed; PluginSubmit has the following properties and methods. - `submit(): void` - submits the form if there are changes, calling `onSubmit`. If the value of the form has not changed then this has no effect. -- `previous: V` - the inital value / the value of the previous submit at the current branch. -- `usePrevous(): V` - a React hook returning the inital value / the value of the previous submit at the current branch. -- `dirty: boolean` - a boolean indicating if the value at the current branch is dirty i.e. has changed. -- `useDirty(): boolean` - a React hook returning a boolean indicating if the value at the current branch is dirty i.e. has changed. -- `submitting: boolean` - a boolean stating if the plugin is currently waiting for an async `onSubmit` call to complete. -- `useSubmitting(): boolean` - a React hook returning a boolean stating if the plugin is currently waiting for an async `onSubmit` call to complete. +- `previous: Dendriform` - a dendriform containing the inital value / the value of the previous submit at the current branch. +- `submitting: Dendriform` - a dendriform containing a boolean stating if the plugin is currently waiting for an async `onSubmit` call to complete. +- `submitting: Dendriform` - a dendriform containing the most recent result of `onError`. This is changed to `undefined` on submit. +- `dirty.value: boolean` - a boolean indicating if the value at the current branch is dirty i.e. has changed. +- `dirty.useValue(): boolean` - a React hook returning a boolean indicating if the value at the current branch is dirty i.e. has changed. + ## Advanced usage diff --git a/packages/dendriform-demo/components/Demos.tsx b/packages/dendriform-demo/components/Demos.tsx index 6c6eaf1..4293854 100644 --- a/packages/dendriform-demo/components/Demos.tsx +++ b/packages/dendriform-demo/components/Demos.tsx @@ -1489,13 +1489,18 @@ type SubmitValue = { }; type SubmitPlugins = { - submit: PluginSubmit; + submit: PluginSubmit; }; +const causeAnErrorForm = new Dendriform(false); + async function fakeSave(value: SubmitValue): Promise { // eslint-disable-next-line no-console console.log('saving', value); await new Promise(r => setTimeout(r, 1000)); + if(causeAnErrorForm.value) { + throw new Error('Error!'); + } // eslint-disable-next-line no-console console.log('saved'); } @@ -1511,7 +1516,8 @@ function PluginSubmitExample(): React.ReactElement { submit: new PluginSubmit({ onSubmit: async (newValue: SubmitValue) => { await fakeSave(newValue); - } + }, + onError: e => e.message }) }); @@ -1525,30 +1531,49 @@ function PluginSubmitExample(): React.ReactElement { return
{form.render('firstName', form => { - const hasChanged = form.plugins.submit.useDirty(); + const hasChanged = form.plugins.submit.dirty.useValue(); return first name: {hasChanged ? '*' : ''}; })} {form.render('lastName', form => { - const hasChanged = form.plugins.submit.useDirty(); + const hasChanged = form.plugins.submit.dirty.useValue(); return last name: {hasChanged ? '*' : ''}; })} {form.render(form => { - const submitting = form.plugins.submit.useSubmitting(); + const submitting = form.plugins.submit.submitting.useValue(); return - + {submitting && Saving...} ; })} + + {form.plugins.submit.error.render(form => { + const error = form.useValue(); + return + {error && {error}} + ; + })}
+ + {causeAnErrorForm.render(form => ( + + cause an error on submit + + + ))}
; } const PluginSubmitExampleCode = ` +const causeAnErrorForm = new Dendriform(false); + async function fakeSave(value) { console.log('saving', value); await new Promise(r => setTimeout(r, 1000)); + if(causeAnErrorForm.value) { + throw new Error('Error!'); + } console.log('saved'); } @@ -1574,25 +1599,39 @@ function PluginSubmitExample() { form.plugins.submit.submit(); }, []); - return
- {form.render('firstName', form => { - const hasChanged = form.plugins.submit.useDirty(); - return ; - })} + return <> + + {form.render('firstName', form => { + const hasChanged = form.plugins.submit.dirty.useValue(); + return ; + })} - {form.render('lastName', form => { - const hasChanged = form.plugins.submit.useDirty(); - return ; - })} + {form.render('lastName', form => { + const hasChanged = form.plugins.submit.dirty.useValue(); + return ; + })} - {form.render(form => { - const submitting = form.plugins.submit.useSubmitting(); - return <> - - {submitting && Saving...} - ; - })} -
; + {form.render(form => { + const submitting = form.plugins.submit.useSubmitting(); + return <> + + {submitting && Saving...} + ; + })} + + {form.plugins.submit.error.render(form => { + const error = form.useValue(); + return <>{error && {error}}; + })} + + + {causeAnErrorForm.render(form => ( + + ))} + ; } `; @@ -2585,7 +2624,10 @@ const DEMOS: DemoObject[] = [ description: `An example of how one might implement drag and drop with react-beautiful-dnd. Dendriform's .renderAll() function, and its automatic id management on array elements simplifies this greatly.`, anchor: 'draganddrop', more: 'drag-and-drop' - }, + } +]; + +const PLUGIN_DEMOS: DemoObject[] = [ { title: 'Submit Plugin (PluginSubmit)', Demo: PluginSubmitExample, @@ -2667,6 +2709,12 @@ export function Demos(): React.ReactElement { ; } +export function PluginDemos(): React.ReactElement { + return + {PLUGIN_DEMOS.map(demo => )} + ; +} + export function AdvancedDemos(): React.ReactElement { return {ADVANCED_DEMOS.map(demo => )} diff --git a/packages/dendriform-demo/pages/index.tsx b/packages/dendriform-demo/pages/index.tsx index de99d9a..88fd5c2 100644 --- a/packages/dendriform-demo/pages/index.tsx +++ b/packages/dendriform-demo/pages/index.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components'; import {Box, Flex, Wrapper, FloatZone} from '../components/Layout'; import {Text, H1, Link} from '../components/Text'; -import {Demos, AdvancedDemos} from '../components/Demos'; +import {Demos, PluginDemos, AdvancedDemos} from '../components/Demos'; import type {ThemeProps} from '../pages/_app'; export default function Main(): React.ReactElement { @@ -36,6 +36,13 @@ export default function Main(): React.ReactElement {
+ +

Plugin Demos

+
+ + + +

Advanced Demos

diff --git a/packages/dendriform/README.md b/packages/dendriform/README.md index 364618c..484d8da 100644 --- a/packages/dendriform/README.md +++ b/packages/dendriform/README.md @@ -1175,15 +1175,16 @@ The `onSubmit` function passes the same `details` object as `onChange` does, so import {PluginSubmit} from 'dendriform'; const plugins = { - submit: new PluginSubmit({ - onSubmit: async (newValue, details) => { + submit: new PluginSubmit({ + onSubmit: async (newValue, details): void => { // trigger save action here // any errors will be eligible for resubmission // diff(details) can be used in here to diff changes since last submit }, - onError: (error) => { - // optional function, will be called - // if an error occurs in onSubmit + onError: (error: any): E|undefined => { + // optional function, will be called if an error occurs in onSubmit + // anything returned will be stored in state in form.plugins.submit.error + // the error state is cleared on next submit } }) }; @@ -1204,12 +1205,12 @@ form.branch('foo').plugins.submit.changed; PluginSubmit has the following properties and methods. - `submit(): void` - submits the form if there are changes, calling `onSubmit`. If the value of the form has not changed then this has no effect. -- `previous: V` - the inital value / the value of the previous submit at the current branch. -- `usePrevous(): V` - a React hook returning the inital value / the value of the previous submit at the current branch. -- `dirty: boolean` - a boolean indicating if the value at the current branch is dirty i.e. has changed. -- `useDirty(): boolean` - a React hook returning a boolean indicating if the value at the current branch is dirty i.e. has changed. -- `submitting: boolean` - a boolean stating if the plugin is currently waiting for an async `onSubmit` call to complete. -- `useSubmitting(): boolean` - a React hook returning a boolean stating if the plugin is currently waiting for an async `onSubmit` call to complete. +- `previous: Dendriform` - a dendriform containing the inital value / the value of the previous submit at the current branch. +- `submitting: Dendriform` - a dendriform containing a boolean stating if the plugin is currently waiting for an async `onSubmit` call to complete. +- `submitting: Dendriform` - a dendriform containing the most recent result of `onError`. This is changed to `undefined` on submit. +- `dirty.value: boolean` - a boolean indicating if the value at the current branch is dirty i.e. has changed. +- `dirty.useValue(): boolean` - a React hook returning a boolean indicating if the value at the current branch is dirty i.e. has changed. + ## Advanced usage diff --git a/packages/dendriform/src/plugins/PluginSubmit.ts b/packages/dendriform/src/plugins/PluginSubmit.ts index d3eb39c..70579e3 100644 --- a/packages/dendriform/src/plugins/PluginSubmit.ts +++ b/packages/dendriform/src/plugins/PluginSubmit.ts @@ -9,34 +9,36 @@ const isPromise = (thing: any): thing is Promise => { }; export type PluginSubmitOnSubmit = (newValue: V, details: ChangeCallbackDetails) => void|Promise; -export type PluginSubmitOnError = (error: unknown) => void; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type PluginSubmitOnError = (error: any) => E|undefined; -export type PluginSubmitConfig = { +export type PluginSubmitConfig = { onSubmit: PluginSubmitOnSubmit; - onError?: PluginSubmitOnError; + onError?: PluginSubmitOnError; }; -type State = { +type State = { form: Dendriform; previous: Dendriform; submitting: Dendriform; + error: Dendriform; }; -export class PluginSubmit extends Plugin { +export class PluginSubmit extends Plugin { - protected config: PluginSubmitConfig; - state: State|undefined; + protected config: PluginSubmitConfig; + state: State|undefined; - constructor(config: PluginSubmitConfig) { + constructor(config: PluginSubmitConfig) { super(); this.config = config; } - protected clone(): PluginSubmit { - return new PluginSubmit(this.config); + protected clone(): PluginSubmit { + return new PluginSubmit(this.config); } - private getState(): State { + private getState(): State { const {state} = this; if(!state) die(8); return state; @@ -44,6 +46,7 @@ export class PluginSubmit extends Plugin { init(form: Dendriform): void { const submitting = new Dendriform(false); + const error = new Dendriform(undefined); const previous = new Dendriform(form.value, {history: 2}); previous.onChange((newValue, details) => { @@ -56,10 +59,12 @@ export class PluginSubmit extends Plugin { const error = (e: unknown) => { submitting.set(false); previous.undo(); - this.config.onError?.(e); + const errorResult = this.config.onError?.(e); + this.getState().error.set(errorResult); }; try { + this.getState().error.set(undefined); const result = this.config.onSubmit(newValue, details); if(!isPromise(result)) { return done(); @@ -75,7 +80,8 @@ export class PluginSubmit extends Plugin { this.state = { form, previous, - submitting + submitting, + error }; } @@ -92,37 +98,22 @@ export class PluginSubmit extends Plugin { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - get previousForm(): Dendriform { + get previous(): Dendriform { return this.getState().previous.core.getFormAt(this.path); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - get previous(): any { - return this.previousForm.value; - } - - /* istanbul ignore next */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - usePrevious(): any { - return this.previousForm.useValue(); - } - - get dirty(): boolean { - return this.getForm().value !== this.previousForm.value; - } - - /* istanbul ignore next */ - useDirty(): boolean { - return this.getForm().useValue() !== this.previousForm.useValue(); + get submitting(): Dendriform { + return this.getState().submitting; } - get submitting(): boolean { - return this.getState().submitting.value; + get error(): Dendriform { + return this.getState().error; } - /* istanbul ignore next */ - useSubmitting(): boolean { - return this.getState().submitting.useValue(); + get dirty(): {value: boolean, useValue: () => boolean} { + const value = !Object.is(this.getForm().value, this.previous.value); + /* istanbul ignore next */ + const useValue = () => !Object.is(this.getForm().useValue(), this.previous.useValue()); + return {value, useValue}; } - } \ No newline at end of file diff --git a/packages/dendriform/test/plugins/PluginSubmit.test.ts b/packages/dendriform/test/plugins/PluginSubmit.test.ts index a72a359..9efa39c 100644 --- a/packages/dendriform/test/plugins/PluginSubmit.test.ts +++ b/packages/dendriform/test/plugins/PluginSubmit.test.ts @@ -59,17 +59,17 @@ describe(`plugin submit`, () => { const form = new Dendriform(value, {plugins}); - expect(form.plugins.submit.dirty).toBe(false); - expect(form.branch('foo').plugins.submit.dirty).toBe(false); - expect(form.branch('bar').plugins.submit.dirty).toBe(false); + expect(form.plugins.submit.dirty.value).toBe(false); + expect(form.branch('foo').plugins.submit.dirty.value).toBe(false); + expect(form.branch('bar').plugins.submit.dirty.value).toBe(false); form.branch('foo').set(456); - expect(form.plugins.submit.previous).toEqual({foo: 123, bar: 456}); - expect(form.branch('foo').plugins.submit.previous).toBe(123); - expect(form.plugins.submit.dirty).toBe(true); - expect(form.branch('foo').plugins.submit.dirty).toBe(true); - expect(form.branch('bar').plugins.submit.dirty).toBe(false); + expect(form.plugins.submit.previous.value).toEqual({foo: 123, bar: 456}); + expect(form.branch('foo').plugins.submit.previous.value).toBe(123); + expect(form.plugins.submit.dirty.value).toBe(true); + expect(form.branch('foo').plugins.submit.dirty.value).toBe(true); + expect(form.branch('bar').plugins.submit.dirty.value).toBe(false); }); test(`should not submit value if not changed - this behaviour may change in future`, () => { @@ -153,7 +153,7 @@ describe(`plugin submit`, () => { draft.bar = 200; }); - expect(form.plugins.submit.previous).toEqual({ + expect(form.plugins.submit.previous.value).toEqual({ foo: 100 }); @@ -168,7 +168,7 @@ describe(`plugin submit`, () => { foo: 100 }); - expect(form.plugins.submit.previous).toEqual({ + expect(form.plugins.submit.previous.value).toEqual({ foo: 100, bar: 200 }); @@ -188,7 +188,7 @@ describe(`plugin submit`, () => { bar: 200 }); - expect(form.plugins.submit.previous).toEqual({ + expect(form.plugins.submit.previous.value).toEqual({ bar: 200 }); }); @@ -200,7 +200,7 @@ describe(`plugin submit`, () => { }; const mockSubmit = jest.fn(); - const onError = jest.fn(); + const onError = jest.fn(e => e.message); let called = 0; const plugins = { @@ -229,9 +229,12 @@ describe(`plugin submit`, () => { bar: 200 }); expect(onError).toHaveBeenCalledTimes(1); + + // error should contain error + expect(form.plugins.submit.error.value).toBe('!'); // previous should not be updated - expect(form.plugins.submit.previous).toEqual({ + expect(form.plugins.submit.previous.value).toEqual({ foo: 100 }); @@ -243,10 +246,11 @@ describe(`plugin submit`, () => { bar: 200 }); expect(onError).toHaveBeenCalledTimes(1); - expect(form.plugins.submit.previous).toEqual({ + expect(form.plugins.submit.previous.value).toEqual({ foo: 100, bar: 200 }); + expect(form.plugins.submit.error.value).toBe(undefined); }); test(`should use async onSubmit`, async () => { @@ -275,13 +279,13 @@ describe(`plugin submit`, () => { }); }); - expect(form.plugins.submit.submitting).toBe(false); + expect(form.plugins.submit.submitting.value).toBe(false); form.plugins.submit.submit(); // async, so not called yet expect(mockDiffed).toHaveBeenCalledTimes(0); - expect(form.plugins.submit.submitting).toBe(true); + expect(form.plugins.submit.submitting.value).toBe(true); // resolve promises await Promise.resolve(); @@ -289,7 +293,7 @@ describe(`plugin submit`, () => { expect(mockDiffed).toHaveBeenCalledTimes(1); expect(mockDiffed.mock.calls[0][0][0].length).toBe(1); - expect(form.plugins.submit.submitting).toBe(false); + expect(form.plugins.submit.submitting.value).toBe(false); }); test(`should use async onSubmit and reject`, async () => { @@ -300,7 +304,7 @@ describe(`plugin submit`, () => { } ]; - const onError = jest.fn(); + const onError = jest.fn(() => '!!!'); const plugins = { submit: new PluginSubmit({ @@ -324,6 +328,7 @@ describe(`plugin submit`, () => { await Promise.resolve(); expect(onError).toHaveBeenCalledTimes(1); - expect(form.plugins.submit.submitting).toBe(false); + expect(form.plugins.submit.error.value).toBe('!!!'); + expect(form.plugins.submit.submitting.value).toBe(false); }); }); \ No newline at end of file