diff --git a/README.md b/README.md index 774c6ef..781c365 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ npm install --save dendriform - [Rendering](#rendering) - [Rendering arrays](#rendering-arrays) - [Setting data](#setting-data) +- [Readonly forms](#readonly-forms) - [Updating from props](#updating-from-props) - [ES6 classes](#es6-classes) - [ES6 maps](#es6-maps) @@ -579,6 +580,23 @@ form.done(); // form.value will update to become 3 ``` +### Readonly forms + +You may want to allow subscribers to a form, while also preventing them from making any changes. For this use case, the `readonly()` method returns a version of the form that cannot be set and cannot navigate history. Any forms branched off a readonly form will also be unable to set or navigate history. + +```js +const form = new Dendriform(0); +const readonlyForm = form.readonly(); + +// readonlyForm can have its .value and .useValue read +// can subscribe to changes with .onChange() etc. and can render, +// but calling .set(), .go() or any derivatives +// will cause an error to be thrown + +readonlyForm.set(1); // throws error + +``` + ### Updating from props The `useDendriform` hook can automatically update when props change. If a `dependencies` array is passed as an option, the dependencies are checked using `Object.is()` equality to determine if the form should update. If an update is required, the `value` function is called again and the form is set to the result. diff --git a/packages/dendriform/.size-limit.js b/packages/dendriform/.size-limit.js index a5aeafa..f48126c 100644 --- a/packages/dendriform/.size-limit.js +++ b/packages/dendriform/.size-limit.js @@ -9,7 +9,7 @@ module.exports = [ name: 'Dendriform', path: "dist/dendriform.esm.js", import: "{ Dendriform }", - limit: "9.0 KB", + limit: "9.1 KB", ignore: ['react', 'react-dom'] } ]; diff --git a/packages/dendriform/README.md b/packages/dendriform/README.md index 774c6ef..781c365 100644 --- a/packages/dendriform/README.md +++ b/packages/dendriform/README.md @@ -141,6 +141,7 @@ npm install --save dendriform - [Rendering](#rendering) - [Rendering arrays](#rendering-arrays) - [Setting data](#setting-data) +- [Readonly forms](#readonly-forms) - [Updating from props](#updating-from-props) - [ES6 classes](#es6-classes) - [ES6 maps](#es6-maps) @@ -579,6 +580,23 @@ form.done(); // form.value will update to become 3 ``` +### Readonly forms + +You may want to allow subscribers to a form, while also preventing them from making any changes. For this use case, the `readonly()` method returns a version of the form that cannot be set and cannot navigate history. Any forms branched off a readonly form will also be unable to set or navigate history. + +```js +const form = new Dendriform(0); +const readonlyForm = form.readonly(); + +// readonlyForm can have its .value and .useValue read +// can subscribe to changes with .onChange() etc. and can render, +// but calling .set(), .go() or any derivatives +// will cause an error to be thrown + +readonlyForm.set(1); // throws error + +``` + ### Updating from props The `useDendriform` hook can automatically update when props change. If a `dependencies` array is passed as an option, the dependencies are checked using `Object.is()` equality to determine if the form should update. If an update is required, the `value` function is called again and the form is set to the result. diff --git a/packages/dendriform/src/Dendriform.tsx b/packages/dendriform/src/Dendriform.tsx index 0fc67d7..e3c906d 100644 --- a/packages/dendriform/src/Dendriform.tsx +++ b/packages/dendriform/src/Dendriform.tsx @@ -290,15 +290,15 @@ export class Core { history: (_id) => this.state.historyState }; - createForm = (id: string): Dendriform => { + createForm = (id: string, readonly: boolean): Dendriform => { const __branch = {core: this, id}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const form = new Dendriform({__branch}); - this.dendriforms.set(id, form); + this.dendriforms.set(`${readonly ? 'r' : 'w'}${id}`, form); return form; }; - getFormAt = (path: Path|undefined): Dendriform => { + getFormAt = (path: Path|undefined, readonly: boolean): Dendriform => { let node: NodeAny|undefined; if(path) { @@ -309,11 +309,13 @@ export class Core { } const id = node ? node.id : 'notfound'; - return this.getFormById(id); + return this.getFormById(id, readonly); }; - getFormById = (id: string): Dendriform => { - return this.dendriforms.get(id) || this.createForm(id); + getFormById = (id: string, readonly: boolean): Dendriform => { + const form = this.dendriforms.get(`${readonly ? 'r' : 'w'}${id}`) || this.createForm(id, readonly); + form._readonly = readonly; + return form; }; // @@ -758,6 +760,7 @@ export class Dendriform { core: Core; id: string; + _readonly = false; constructor(initialValue: V|DendriformBranch

, options: Options

= {}) { @@ -821,12 +824,14 @@ export class Dendriform { } set = (toProduce: ToProduce, options: SetOptions = {}): void => { + if(this._readonly) die(9); this.core.setWithDebounce(this.id, toProduce, options); }; setParent = (childToProduce: ChildToProduce, options: SetOptions = {}): void => { + if(this._readonly) die(9); const basePath = this.core.getPathOrError(this.id); - const parent = this.core.getFormAt(basePath.slice(0,-1)); + const parent = this.core.getFormAt(basePath.slice(0,-1), this._readonly); this.core.setWithDebounce(parent.id, childToProduce(basePath[basePath.length - 1]), options); }; @@ -869,11 +874,14 @@ export class Dendriform { return () => void this.core.changeCallbackRefs.delete(changeCallback); } - undo = (): void => this.core.go(-1); + undo = (): void => this.go(-1); - redo = (): void => this.core.go(1); + redo = (): void => this.go(1); - go = (offset: number): void => this.core.go(offset); + go = (offset: number): void => { + if(this._readonly) die(9); + this.core.go(offset); + }; replace = (replace = true): void => this.core.replace(replace); @@ -933,7 +941,7 @@ export class Dendriform { branch(pathOrKey: any): any { const appendPath = ([] as Path).concat(pathOrKey ?? []); const basePath = this.core.getPath(this.id); - return this.core.getFormAt(basePath?.concat(appendPath)); + return this.core.getFormAt(basePath?.concat(appendPath), this._readonly); } branchAll, K2 extends keyof Val, K3 extends keyof Val,K2>, K4 extends keyof Val,K2>,K3>, W extends Val,K2>,K3>[K4]>(path: [K1, K2, K3, K4]): Dendriform,P>[]; @@ -990,6 +998,10 @@ export class Dendriform { return ; } + + readonly(): Dendriform { + return this.core.getFormById(this.id, true) as Dendriform; + } } // diff --git a/packages/dendriform/src/errors.ts b/packages/dendriform/src/errors.ts index 736af75..eae1b16 100644 --- a/packages/dendriform/src/errors.ts +++ b/packages/dendriform/src/errors.ts @@ -9,7 +9,8 @@ const errors = { 5: `sync() forms must have the same maximum number of history items configured`, 6: (msg: string) => `onDerive() callback must not throw errors on first call. Threw: ${msg}`, 7: `Cannot call .set() on an element of an es6 Set`, - 8: `Plugin must be passed into a Dendriform instance before this operation can be called` + 8: `Plugin must be passed into a Dendriform instance before this operation can be called`, + 9: `Cannot call .set() or .go() on a readonly form` } as const; export type ErrorKey = keyof typeof errors; diff --git a/packages/dendriform/src/plugins/PluginSubmit.ts b/packages/dendriform/src/plugins/PluginSubmit.ts index c021a38..c449332 100644 --- a/packages/dendriform/src/plugins/PluginSubmit.ts +++ b/packages/dendriform/src/plugins/PluginSubmit.ts @@ -101,12 +101,12 @@ export class PluginSubmit extends Plugin { // eslint-disable-next-line @typescript-eslint/no-explicit-any private getForm(): Dendriform { - return this.getState().form.core.getFormAt(this.path); + return this.getState().form.core.getFormAt(this.path, true); } // eslint-disable-next-line @typescript-eslint/no-explicit-any get previous(): Dendriform { - return this.getState().previous.core.getFormAt(this.path); + return this.getState().previous.core.getFormAt(this.path, true); } get submitting(): Dendriform { diff --git a/packages/dendriform/test/Dendriform.test.tsx b/packages/dendriform/test/Dendriform.test.tsx index ba7fa0d..6913f69 100644 --- a/packages/dendriform/test/Dendriform.test.tsx +++ b/packages/dendriform/test/Dendriform.test.tsx @@ -3254,4 +3254,24 @@ describe(`Dendriform`, () => { }); }); }); + + describe(`readonly`, () => { + + test(`should create readonly form`, () => { + const form = new Dendriform(123); + + expect(() => form.readonly().set(456)).toThrow(`[Dendriform] Cannot call .set() or .go() on a readonly form`); + expect(() => form.readonly().undo()).toThrow(`[Dendriform] Cannot call .set() or .go() on a readonly form`); + }); + + test(`should create readonly forms branched from a readonly form`, () => { + const form = new Dendriform({foo: 123}); + + expect(() => form.readonly().branch('foo').set(456)).toThrow(`[Dendriform] Cannot call .set() or .go() on a readonly form`); + expect(() => form.branch('foo').set(456)).not.toThrow(); + expect(() => form.readonly().branch('foo').set(456)).toThrow(`[Dendriform] Cannot call .set() or .go() on a readonly form`); + // @ts-ignore + expect(() => form.readonly().branch('foo').setParent({foo: 456})).toThrow(`[Dendriform] Cannot call .set() or .go() on a readonly form`); + }); + }); });