From ca6f4178712d158d00f742eee980989499e4b0b8 Mon Sep 17 00:00:00 2001 From: smikhalevski Date: Thu, 9 Nov 2023 22:46:34 +0300 Subject: [PATCH] Fixed tests --- README.md | 48 +- .../src/main/constraintValidationPlugin.ts | 64 +-- .../test/constraintValidationPlugin.test.ts | 194 +++---- packages/doubter-plugin/README.md | 19 +- .../doubter-plugin/src/main/doubterPlugin.ts | 22 +- .../src/test/doubterPlugin.test-d.ts | 4 +- .../src/test/doubterPlugin.test.tsx | 192 +++---- packages/react/README.md | 4 +- packages/react/src/main/AccessorContext.ts | 4 +- packages/react/src/main/FieldRenderer.ts | 20 +- packages/react/src/main/useField.ts | 2 +- .../react/src/test/FieldRenderer.test.tsx | 4 +- packages/react/src/test/useField.test.ts | 2 +- .../ref-plugin/src/test/refPlugin.test.ts | 12 +- packages/reset-plugin/src/main/resetPlugin.ts | 11 +- .../reset-plugin/src/test/resetPlugin.test.ts | 52 +- packages/roqueform/src/main/composePlugins.ts | 4 +- packages/roqueform/src/main/createField.ts | 33 +- .../roqueform/src/main/naturalAccessor.ts | 12 +- packages/roqueform/src/main/typings.ts | 16 +- packages/roqueform/src/main/utils.ts | 47 +- .../roqueform/src/main/validationPlugin.ts | 19 +- .../src/test/composePlugins.test-d.ts | 8 +- .../roqueform/src/test/createField.test.ts | 423 +++++++-------- .../src/test/naturalAccessor.test.ts | 18 +- packages/roqueform/src/test/utils.test.ts | 57 +- .../src/test/validationPlugin.test-d.ts | 4 +- .../src/test/validationPlugin.test.tsx | 510 ++++++++++-------- packages/scroll-to-error-plugin/README.md | 12 +- .../src/test/scrollToErrorPlugin.test.tsx | 118 ++-- .../src/main/uncontrolledPlugin.ts | 11 +- .../test/createElementValueAccessor.test.ts | 14 +- .../src/test/uncontrolledPlugin.test.ts | 80 +-- packages/zod-plugin/README.md | 11 +- packages/zod-plugin/package.json | 3 +- packages/zod-plugin/src/main/zodPlugin.ts | 2 +- .../zod-plugin/src/test/zodPlugin.test-d.ts | 4 +- .../zod-plugin/src/test/zodPlugin.test.tsx | 135 ++--- typedoc.json | 1 + 39 files changed, 1087 insertions(+), 1109 deletions(-) diff --git a/README.md b/README.md index 2e2d3ab4..4754f813 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ const planetsField = universeField.at('planets'); // ⮕ Field ``` -`planetsField` is a derived field, and it is linked to its parent `universeField`. +`planetsField` is a child field, and it is linked to its parent `universeField`. ```ts planetsField.key; @@ -126,18 +126,18 @@ universeField.at('planets'); // ⮕ planetsField ``` -So most of the time you don't need to store a derived field in a variable if you already have a reference to a parent +So most of the time you don't need to store a child field in a variable if you already have a reference to a parent field. -The derived field has all the same functionality as its parent, so you can derive a new field from it as well: +The child field has all the same functionality as its parent, so you can derive a new field from it as well: ```ts planetsField.at(0).at('name'); // ⮕ Field ``` -When a value is set to a derived field, a parent field value is also updated. If parent field doesn't have a value yet, -Roqueform would infer its type from on the derived field key. +When a value is set to a child field, a parent field value is also updated. If parent field doesn't have a value yet, +Roqueform would infer its type from on the child field key. ```ts universeField.value; @@ -152,7 +152,7 @@ universeField.value; By default, for a string key a parent object is created, and for a number key a parent array is created. You can change this behaviour with [custom accessors](#accessors). -When a value is set to a parent field, derived fields are also updated: +When a value is set to a parent field, child fields are also updated: ```ts const nameField = universeField.at('planets').at(0).at('name'); @@ -171,19 +171,19 @@ nameField.value; You can subscribe a subscriber to a field updates. The returned callback would unsubscribe the subscriber. ```ts -const unsubscribe = planetsField.subscribe((updatedField, currentField) => { +const unsubscribe = planetsField.on('*', (updatedField, currentField) => { // Handle the update }); // ⮕ () => void ``` -Listeners are called with two arguments: +Subscribers are called with two arguments:
updatedField
-The field that initiated the update. In this example it can be `planetsField` itself, any of its derived fields, or any +The field that initiated the update. In this example it can be `planetsField` itself, any of its child fields, or any of its ancestor fields.
@@ -195,15 +195,15 @@ The field to which the subscriber is subscribed. In this example it is `planetsF
-Listeners are called when a field value is changed or [when a plugin mutates the field object](#authoring-a-plugin). -The root field and all derived fields are updated before subscribers are called, so it's safe to read field values in a +Subscribers are called when a field value is changed or [when a plugin mutates the field object](#authoring-a-plugin). +The root field and all child fields are updated before subscribers are called, so it's safe to read field values in a subscriber. Fields use [SameValueZero](https://262.ecma-international.org/7.0/#sec-samevaluezero) comparison to detect that the value has changed. ```ts -planetsField.at(0).at('name').subscribe(subscriber); +planetsField.at(0).at('name').on('*', subscriber); // ✅ The subscriber is called planetsField.at(0).at('name').setValue('Mercury'); @@ -215,10 +215,10 @@ planetsField.at(0).setValue({ name: 'Mercury' }); # Transient updates When you call [`setValue`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Field.html#setValue) on a field -then subscribers of its ancestors and its updated derived fields are triggered. To manually control the update propagation +then subscribers of its ancestors and its updated child fields are triggered. To manually control the update propagation to fields ancestors, you can use transient updates. -When a value of a derived field is set transiently, values of its ancestors _aren't_ immediately updated. +When a value of a child field is set transiently, values of its ancestors _aren't_ immediately updated. ```ts const avatarField = createField(); @@ -240,7 +240,7 @@ avatarField.at('eyeColor').isTransient; // ⮕ true ``` -To propagate the transient value contained by the derived field to its parent, use +To propagate the transient value contained by the child field to its parent, use the [`dispatch`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Field.html#dispatch) method: ```ts @@ -253,7 +253,7 @@ avatarField.value; `setTransientValue` can be called multiple times, but only the most recent update is propagated to the parent field after the `dispatch` call. -When a derived field is in a transient state, its value as observed from the parent may differ from the actual value: +When a child field is in a transient state, its value as observed from the parent may differ from the actual value: ```ts const planetsField = createField(['Mars', 'Pluto']); @@ -282,20 +282,20 @@ planetsField.at(1).value; # Accessors -[`Accessor`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html) creates, reads and updates +[`ValueAccessor`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html) creates, reads and updates field values. -- When the new field is derived via +- When the new field is child via [`Field.at`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Field.html#at) method, the field value is read from the value of the parent field using the - [`Accessor.get`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#get) method. + [`ValueAccessor.get`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#get) method. - When a field value is updated via [`Field.setValue`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Field.html#setValue), then the parent field value is updated with the value returned from the - [`Accessor.set`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#set) method. If the - updated field has derived fields, their values are updated with values returned from the - [`Accessor.get`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#get) method. + [`ValueAccessor.set`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#set) method. If the + updated field has child fields, their values are updated with values returned from the + [`ValueAccessor.get`](https://smikhalevski.github.io/roqueform/interfaces/roqueform.Accessor.html#get) method. You can explicitly provide a custom accessor along with the initial value. Be default, Roqueform uses [`naturalAccessor`](https://smikhalevski.github.io/roqueform/variables/roqueform.naturalAccessor.html): @@ -375,7 +375,7 @@ planetField.element; // ⮕ null ``` -The plugin would be called for each derived field when it is first accessed: +The plugin would be called for each child field when it is first accessed: ```ts planetField.at('name').element @@ -413,7 +413,7 @@ Now when `setElement` is called on a field, its subscribers would be invoked. ```ts const planetField = createField({ name: 'Mars' }, elementPlugin); -planetField.at('name').subscribe((updatedField, currentField) => { +planetField.at('name').on('*', (updatedField, currentField) => { // Handle the update currentField.element; // ⮕ document.body diff --git a/packages/constraint-validation-plugin/src/main/constraintValidationPlugin.ts b/packages/constraint-validation-plugin/src/main/constraintValidationPlugin.ts index a049f7cf..0c886de9 100644 --- a/packages/constraint-validation-plugin/src/main/constraintValidationPlugin.ts +++ b/packages/constraint-validation-plugin/src/main/constraintValidationPlugin.ts @@ -1,4 +1,4 @@ -import { dispatchEvents, Event as Event_, Field, PluginInjector, PluginOf, Subscriber, Unsubscribe } from 'roqueform'; +import { dispatchEvents, Event, Field, PluginInjector, PluginOf, Subscriber, Unsubscribe } from 'roqueform'; const EVENT_CHANGE_ERROR = 'change:error'; @@ -7,7 +7,7 @@ const EVENT_CHANGE_ERROR = 'change:error'; */ export interface ConstraintValidationPlugin { /** - * An error associated with the field, or `null` if there's no error. + * A non-empty error message associated with the field, or `null` if there's no error. */ error: string | null; @@ -17,7 +17,7 @@ export interface ConstraintValidationPlugin { element: Element | null; /** - * `true` if the field or any of its derived fields have {@link error an associated error}, or `false` otherwise. + * `true` if the field or any of its child fields have {@link error an associated error}, or `false` otherwise. */ readonly isInvalid: boolean; @@ -37,7 +37,7 @@ export interface ConstraintValidationPlugin { /** * The origin of the associated error: * - 0 if there's no associated error. - * - 1 if an error was set using Constraint Validation API; + * - 1 if an error was set by Constraint Validation API; * - 2 if an error was set using {@link ValidationPlugin.setError}; * * @protected @@ -52,24 +52,24 @@ export interface ConstraintValidationPlugin { /** * Associates an error with the field. * - * @param error The error to set. If `null` or `undefined` then an error is deleted. + * @param error The error to set. If `null`, `undefined`, or an empty string then an error is deleted. */ setError(error: string | null | undefined): void; /** * Deletes an error associated with this field. If this field has {@link element an associated element} that supports * [Constraint Validation API](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation) and the - * element has non-empty `validationMessage` then this message would be immediately set as an error for this field. + * element has a non-empty `validationMessage` then this message would be immediately set as an error for this field. */ deleteError(): void; /** - * Recursively deletes errors associated with this field and all of its derived fields. + * Recursively deletes errors associated with this field and all of its child fields. */ clearErrors(): void; /** - * Shows error message balloon for the first element that is associated with this field or any of its derived fields, + * Shows error message balloon for the first element that is associated with this field or any of its child fields, * that has an associated error via calling * {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/reportValidity reportValidity}. * @@ -104,14 +104,17 @@ export interface ConstraintValidationPlugin { */ export function constraintValidationPlugin(): PluginInjector { return field => { - field.error = field.element = field.validity = null; + field.error = null; + field.element = null; + field.validity = null; field.errorCount = 0; + field.errorOrigin = 0; Object.defineProperties(field, { - isInvalid: { get: () => field.errorCount !== 0 }, + isInvalid: { configurable: true, get: () => field.errorCount !== 0 }, }); - const changeListener = (event: Event): void => { + const changeListener: EventListener = event => { if (field.element === event.target && isValidatable(field.element)) { dispatchEvents(setError(field, field.element.validationMessage, 1, [])); } @@ -132,13 +135,13 @@ export function constraintValidationPlugin(): PluginInjector { @@ -172,7 +175,7 @@ export function constraintValidationPlugin(): PluginInjector reportValidity(field); - field.getErrors = () => getErrors(getInvalidFields(field, [])); + field.getErrors = () => getInvalidFields(field, []).map(field => field.error!); field.getInvalidFields = () => getInvalidFields(field, []); }; @@ -192,8 +195,8 @@ function setError( field: Field, error: string | null | undefined, errorOrigin: 1 | 2, - events: Event_[] -): Event_[] { + events: Event[] +): Event[] { if (error === null || error === undefined || error.length === 0) { return deleteError(field, errorOrigin, events); } @@ -228,7 +231,7 @@ function setError( return events; } -function deleteError(field: Field, errorOrigin: 1 | 2, events: Event_[]): Event_[] { +function deleteError(field: Field, errorOrigin: 1 | 2, events: Event[]): Event[] { const originalError = field.error; if (field.errorOrigin > errorOrigin || originalError === null) { @@ -263,7 +266,7 @@ function deleteError(field: Field, errorOrigin: 1 | return events; } -function clearErrors(field: Field, events: Event_[]): Event_[] { +function clearErrors(field: Field, events: Event[]): Event[] { deleteError(field, 2, events); if (field.children !== null) { @@ -300,17 +303,6 @@ function getInvalidFields( return batch; } -function getErrors(batch: Field[]): string[] { - const errors = []; - - for (const field of batch) { - if (field.error !== null) { - errors.push(field.error); - } - } - return errors; -} - function isValidatable(element: Element | null): element is ValidatableElement { return element instanceof Element && 'validity' in element && element.validity instanceof ValidityState; } diff --git a/packages/constraint-validation-plugin/src/test/constraintValidationPlugin.test.ts b/packages/constraint-validation-plugin/src/test/constraintValidationPlugin.test.ts index 4373d6e8..5001ed3a 100644 --- a/packages/constraint-validation-plugin/src/test/constraintValidationPlugin.test.ts +++ b/packages/constraint-validation-plugin/src/test/constraintValidationPlugin.test.ts @@ -14,153 +14,189 @@ describe('constraintValidationPlugin', () => { }); test('enhances the field', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + const field = createField({ aaa: 111 }, constraintValidationPlugin()); + expect(field.error).toBeNull(); + expect(field.element).toBeNull(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); - - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.validity).toBeNull(); + expect(field.errorCount).toBe(0); + expect(field.errorOrigin).toBe(0); + + expect(field.at('aaa').error).toBeNull(); + expect(field.at('aaa').element).toBeNull(); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').validity).toBeNull(); + expect(field.at('aaa').errorCount).toBe(0); + expect(field.at('aaa').errorOrigin).toBe(0); }); test('sets an error to the field that does not have an associated element', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + const field = createField({ aaa: 111 }, constraintValidationPlugin()); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('change:error', subscriberMock); + field.at('aaa').on('change:error', aaaSubscriberMock); - field.at('foo').setError('aaa'); + field.at('aaa').setError('222'); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe('aaa'); + expect(field.error).toBeNull(); + expect(field.errorCount).toBe(1); + expect(field.errorOrigin).toBe(0); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe('222'); + expect(field.at('aaa').errorCount).toBe(1); + expect(field.at('aaa').errorOrigin).toBe(2); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); }); test('setting an error to the parent field does not affect the child field', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + const field = createField({ aaa: 111 }, constraintValidationPlugin()); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('change:error', subscriberMock); + field.at('aaa').on('change:error', aaaSubscriberMock); - field.setError('aaa'); + field.setError('222'); expect(field.isInvalid).toBe(true); - expect(field.error).toBe('aaa'); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.error).toBe('222'); + expect(field.errorCount).toBe(1); + expect(field.errorOrigin).toBe(2); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); + expect(field.at('aaa').errorCount).toBe(0); + expect(field.at('aaa').errorOrigin).toBe(0); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(0); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(0); }); test('does not notify the field if the same error is set', () => { - const field = createField(0, constraintValidationPlugin()); + const field = createField(111, constraintValidationPlugin()); const subscriberMock = jest.fn(); - field.subscribe(subscriberMock); + field.on('*', subscriberMock); - field.setError('aaa'); - field.setError('aaa'); + field.setError('xxx'); + field.setError('xxx'); expect(subscriberMock).toHaveBeenCalledTimes(1); }); test('deletes an error from the field', () => { - const field = createField(0, constraintValidationPlugin()); + const field = createField(111, constraintValidationPlugin()); const subscriberMock = jest.fn(); - field.subscribe(subscriberMock); + field.on('*', subscriberMock); - field.setError('aaa'); + field.setError('xxx'); field.deleteError(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); + expect(field.errorCount).toBe(0); + expect(field.errorOrigin).toBe(0); expect(subscriberMock).toHaveBeenCalledTimes(2); }); - test('clears an error of a derived field', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + test('restores the constraint error on delete', () => { + const field = createField(111, constraintValidationPlugin()); + + element.required = true; + + field.ref(element); + + expect(field.error).toEqual('Constraints not satisfied'); + + field.setError('xxx'); + + expect(field.error).toEqual('xxx'); + + field.deleteError(); + + expect(field.error).toEqual('Constraints not satisfied'); + }); + + test('clears an error of a child field', () => { + const field = createField({ aaa: 111 }, constraintValidationPlugin()); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - field.at('foo').setError('aaa'); + field.at('aaa').setError('aaa'); field.clearErrors(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('reports validity of the root field', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + const field = createField({ aaa: 111 }, constraintValidationPlugin()); expect(field.reportValidity()).toBe(true); - expect(field.at('foo').reportValidity()).toBe(true); + expect(field.at('aaa').reportValidity()).toBe(true); - field.setError('aaa'); + field.setError('xxx'); expect(field.reportValidity()).toBe(false); - expect(field.at('foo').reportValidity()).toBe(true); + expect(field.at('aaa').reportValidity()).toBe(true); field.deleteError(); expect(field.reportValidity()).toBe(true); - expect(field.at('foo').reportValidity()).toBe(true); + expect(field.at('aaa').reportValidity()).toBe(true); }); - test('reports validity of the derived field', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + test('reports validity of the child field', () => { + const field = createField({ aaa: 111 }, constraintValidationPlugin()); - field.at('foo').setError('aaa'); + field.at('aaa').setError('xxx'); expect(field.reportValidity()).toBe(false); - expect(field.at('foo').reportValidity()).toBe(false); + expect(field.at('aaa').reportValidity()).toBe(false); - field.at('foo').deleteError(); + field.at('aaa').deleteError(); expect(field.reportValidity()).toBe(true); - expect(field.at('foo').reportValidity()).toBe(true); + expect(field.at('aaa').reportValidity()).toBe(true); }); test('uses a validationMessage as an error', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + const field = createField({ aaa: 111 }, constraintValidationPlugin()); element.required = true; - field.at('foo').ref(element); + field.at('aaa').ref(element); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual('Constraints not satisfied'); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual('Constraints not satisfied'); }); test('deletes an error when a ref is changed', () => { - const field = createField(0, constraintValidationPlugin()); + const field = createField(111, constraintValidationPlugin()); const subscriberMock = jest.fn(); - field.subscribe(subscriberMock); + field.on('*', subscriberMock); element.required = true; @@ -173,44 +209,18 @@ describe('constraintValidationPlugin', () => { field.ref(null); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); expect(subscriberMock).toHaveBeenCalledTimes(2); }); test('notifies the field when the value is changed', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); - - const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); - - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); - - element.value = 'aaa'; - element.required = true; - - expect(element.validationMessage).toBe(''); - expect(subscriberMock).not.toHaveBeenCalled(); - - field.at('foo').ref(element); - - expect(subscriberMock).toHaveBeenCalledTimes(0); - expect(field.at('foo').isInvalid).toBe(false); - - fireEvent.change(element, { target: { value: '' } }); - - expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); - }); - - test('does not notify an already invalid parent', () => { - const field = createField({ foo: 0 }, constraintValidationPlugin()); + const field = createField({ aaa: 111 }, constraintValidationPlugin()); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('change:error', subscriberMock); + field.at('aaa').on('change:error', aaaSubscriberMock); element.value = 'aaa'; element.required = true; @@ -218,14 +228,14 @@ describe('constraintValidationPlugin', () => { expect(element.validationMessage).toBe(''); expect(subscriberMock).not.toHaveBeenCalled(); - field.at('foo').ref(element); + field.at('aaa').ref(element); expect(subscriberMock).toHaveBeenCalledTimes(0); - expect(field.at('foo').isInvalid).toBe(false); + expect(field.at('aaa').isInvalid).toBe(false); fireEvent.change(element, { target: { value: '' } }); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/doubter-plugin/README.md b/packages/doubter-plugin/README.md index a1b92220..d60fc439 100644 --- a/packages/doubter-plugin/README.md +++ b/packages/doubter-plugin/README.md @@ -35,13 +35,12 @@ export const App = () => { event.preventDefault(); if (planetField.validate()) { - // Errors are associated with fields automatically - return; + // If your shapes transform the input, you can safely parse + // the field value after it was successfully validated. + const value = planetShape.parse(planetField.value); + } else { + // Errors are associated with fields automatically. } - - // If your shapes transform the input, you can safely parse - // the field value after it was successfully validated - const value = planetShape.parse(planetField.value); }; return ( @@ -96,8 +95,8 @@ const planetField = useField({ name: 'Mars' }, doubterPlugin(planetShape)); The type of the field value is inferred from the provided shape, so the field value is statically checked. -When you call the `validate` method, it triggers validation of the field and all of its derived fields. So if you call -`validate` on the derived field, it won't validate the parent field: +When you call the `validate` method, it triggers validation of the field and all of its child fields. So if you call +`validate` on the child field, it won't validate the parent field: ```ts planetField.at('name').validate(); @@ -110,7 +109,7 @@ In this example, `planetField.value` _is not_ validated, and `planetField.at('na > It's safe to trigger validation of a single text field on every keystroke, since validation doesn't have to process > the whole form state. -To detect whether the field, or any of its derived fields contain a validation error: +To detect whether the field, or any of its child fields contain a validation error: ```ts planetField.isInvalid; @@ -140,7 +139,7 @@ To delete an error for the particular field: planetField.at('name').deleteError(); ``` -Sometimes it is required to clear errors of the field itself and all of its derived fields: +Sometimes it is required to clear errors of the field itself and all of its child fields: ```ts planetField.clearErrors(); diff --git a/packages/doubter-plugin/src/main/doubterPlugin.ts b/packages/doubter-plugin/src/main/doubterPlugin.ts index c3cc482e..9b4416fc 100644 --- a/packages/doubter-plugin/src/main/doubterPlugin.ts +++ b/packages/doubter-plugin/src/main/doubterPlugin.ts @@ -33,7 +33,11 @@ export function doubterPlugin(shape: Shape): PluginInjector { - setError(typeof error === 'string' ? { message: error, path: getPath(field) } : error); + if (error === null || error === undefined) { + setError(error); + } else { + setError(prependPath(field, typeof error === 'string' ? { message: error, input: field.value } : error)); + } }; }; } @@ -60,14 +64,12 @@ const doubterValidator: Validator = { }, }; -function getPath(field: AnyField): any[] { - const path = []; - +function prependPath(field: AnyField, issue: Issue): Issue { while (field.parent !== null) { - path.unshift(field.key); + (issue.path ||= []).unshift(field.key); field = field.parent; } - return path; + return issue; } function applyResult(validation: Validation, result: Err | Ok): void { @@ -75,8 +77,6 @@ function applyResult(validation: Validation, result: Err | Ok): v return; } - const basePath = getPath(validation.root); - for (const issue of result.issues) { let child = validation.root; @@ -84,11 +84,7 @@ function applyResult(validation: Validation, result: Err | Ok): v for (const key of issue.path) { child = child.at(key); } - issue.path = basePath.concat(issue.path); - } else { - issue.path = basePath.slice(0); } - - child.setValidationError(validation, issue); + child.setValidationError(validation, prependPath(validation.root, issue)); } } diff --git a/packages/doubter-plugin/src/test/doubterPlugin.test-d.ts b/packages/doubter-plugin/src/test/doubterPlugin.test-d.ts index a3bb66ba..afd2d4fe 100644 --- a/packages/doubter-plugin/src/test/doubterPlugin.test-d.ts +++ b/packages/doubter-plugin/src/test/doubterPlugin.test-d.ts @@ -5,6 +5,4 @@ import { DoubterPlugin, doubterPlugin } from '@roqueform/doubter-plugin'; const shape = d.object({ foo: d.object({ bar: d.string() }) }); -expectType & DoubterPlugin>( - createField({ foo: { bar: 'aaa' } }, doubterPlugin(shape)) -); +expectType>(createField({ foo: { bar: 'aaa' } }, doubterPlugin(shape))); diff --git a/packages/doubter-plugin/src/test/doubterPlugin.test.tsx b/packages/doubter-plugin/src/test/doubterPlugin.test.tsx index 53fb726e..a2374e9c 100644 --- a/packages/doubter-plugin/src/test/doubterPlugin.test.tsx +++ b/packages/doubter-plugin/src/test/doubterPlugin.test.tsx @@ -3,147 +3,147 @@ import { doubterPlugin } from '../main'; import { createField } from 'roqueform'; describe('doubterPlugin', () => { - const fooShape = d.object({ - foo: d.number().gte(3), + const aaaShape = d.object({ + aaa: d.number().gte(3), }); - const fooBarShape = d.object({ - foo: d.number().gte(3), - bar: d.string().max(2), + const aaaBbbShape = d.object({ + aaa: d.number().gte(3), + bbb: d.string().max(2), }); test('enhances the field', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('sets an issue to the root field', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); - const issue = { code: 'aaa' }; + const issue = { code: 'xxx' }; field.setError(issue); expect(field.isInvalid).toBe(true); expect(field.error).toBe(issue); - expect(field.error).toEqual({ code: 'aaa', input: { foo: 0 } }); + expect(field.error).toEqual({ code: 'xxx' }); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('sets an issue to the child field', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); const issue = { code: 'aaa' }; - field.at('foo').setError(issue); + field.at('aaa').setError(issue); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(issue); - expect(field.at('foo').error).toEqual({ code: 'aaa', input: 0, path: ['foo'] }); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(issue); + expect(field.at('aaa').error).toEqual({ code: 'aaa', path: ['aaa'] }); }); test('converts string errors to issue messages', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); - field.at('foo').setError('aaa'); + field.at('aaa').setError('xxx'); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ message: 'aaa', input: 0, path: ['foo'] }); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ message: 'xxx', input: 0, path: ['aaa'] }); }); test('deletes an issue from the root field', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); field.setError({ code: 'aaa' }); field.deleteError(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('deletes an issue from the child field', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); - field.at('foo').setError({ code: 'aaa' }); - field.at('foo').deleteError(); + field.at('aaa').setError({ code: 'aaa' }); + field.at('aaa').deleteError(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('deletes an issue from the child field but parent remains invalid', () => { - const field = createField({ foo: 0, bar: 'qux' }, doubterPlugin(fooBarShape)); + const field = createField({ aaa: 0, bbb: 'ccc' }, doubterPlugin(aaaBbbShape)); const issue1 = { code: 'aaa' }; const issue2 = { code: 'bbb' }; - field.at('foo').setError(issue1); - field.at('bar').setError(issue2); + field.at('aaa').setError(issue1); + field.at('bbb').setError(issue2); - field.at('bar').deleteError(); + field.at('bbb').deleteError(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(issue1); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(issue1); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); }); test('clears all issues', () => { - const field = createField({ foo: 0, bar: 'qux' }, doubterPlugin(fooBarShape)); + const field = createField({ aaa: 0, bbb: 'ccc' }, doubterPlugin(aaaBbbShape)); const issue1 = { code: 'aaa' }; const issue2 = { code: 'bbb' }; - field.at('foo').setError(issue1); - field.at('bar').setError(issue2); + field.at('aaa').setError(issue1); + field.at('bbb').setError(issue2); field.clearErrors(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); }); test('validates the root field', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'number.gte', - path: ['foo'], + path: ['aaa'], input: 0, param: 3, message: 'Must be greater than or equal to 3', @@ -152,17 +152,17 @@ describe('doubterPlugin', () => { }); test('validates the child field', () => { - const field = createField({ foo: 0 }, doubterPlugin(fooShape)); + const field = createField({ aaa: 0 }, doubterPlugin(aaaShape)); - field.at('foo').validate(); + field.at('aaa').validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'number.gte', - path: ['foo'], + path: ['aaa'], input: 0, param: 3, message: 'Must be greater than or equal to 3', @@ -171,28 +171,28 @@ describe('doubterPlugin', () => { }); test('validates multiple fields', () => { - const field = createField({ foo: 0, bar: 'qux' }, doubterPlugin(fooBarShape)); + const field = createField({ aaa: 0, bbb: 'ccc' }, doubterPlugin(aaaBbbShape)); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'number.gte', - path: ['foo'], + path: ['aaa'], input: 0, param: 3, message: 'Must be greater than or equal to 3', meta: undefined, }); - expect(field.at('bar').isInvalid).toBe(true); - expect(field.at('bar').error).toEqual({ + expect(field.at('bbb').isInvalid).toBe(true); + expect(field.at('bbb').error).toEqual({ code: 'string.max', - path: ['bar'], - input: 'qux', + path: ['bbb'], + input: 'ccc', param: 2, message: 'Must have the maximum length of 2', meta: undefined, @@ -200,78 +200,78 @@ describe('doubterPlugin', () => { }); test('validate clears previous validation issues', () => { - const field = createField({ foo: 0, bar: 'qux' }, doubterPlugin(fooBarShape)); + const field = createField({ aaa: 0, bbb: 'ccc' }, doubterPlugin(aaaBbbShape)); field.validate(); - field.at('bar').setValue(''); + field.at('bbb').setValue(''); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'number.gte', - path: ['foo'], + path: ['aaa'], input: 0, param: 3, message: 'Must be greater than or equal to 3', meta: undefined, }); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); }); test('validate does not clear an issue set from userland', () => { - const field = createField({ foo: 0, bar: '' }, doubterPlugin(fooBarShape)); + const field = createField({ aaa: 0, bbb: '' }, doubterPlugin(aaaBbbShape)); const issue = { code: 'aaa' }; - field.at('bar').setError(issue); + field.at('bbb').setError(issue); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'number.gte', - path: ['foo'], + path: ['aaa'], input: 0, param: 3, message: 'Must be greater than or equal to 3', meta: undefined, }); - expect(field.at('bar').isInvalid).toBe(true); - expect(field.at('bar').error).toBe(issue); + expect(field.at('bbb').isInvalid).toBe(true); + expect(field.at('bbb').error).toBe(issue); }); test('validate does not raise issues for transient fields', () => { - const field = createField({ foo: 0, bar: 'qux' }, doubterPlugin(fooBarShape)); + const field = createField({ aaa: 0, bbb: 'ccc' }, doubterPlugin(aaaBbbShape)); - field.at('bar').setTransientValue('aaabbb'); + field.at('bbb').setTransientValue('aaabbb'); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'number.gte', - path: ['foo'], + path: ['aaa'], input: 0, param: 3, message: 'Must be greater than or equal to 3', meta: undefined, }); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); }); }); diff --git a/packages/react/README.md b/packages/react/README.md index 708dbc8f..ac0782e7 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -112,7 +112,7 @@ argument of the `children` render function is the field passed as a `field` prop ## Eager and lazy re-renders Let's consider the form with two `FieldRenderer` elements. One of them renders the value of the root field and the other -one renders an input that updates the derived field: +one renders an input that updates the child field: ```tsx const App = () => { @@ -139,7 +139,7 @@ const App = () => { ``` By default, `FieldRenderer` component re-renders only when the provided field was updated directly, so updates from -ancestors or derived fields would be ignored. Add the `eagerlyUpdated` property to force `FieldRenderer` to re-render +ancestors or child fields would be ignored. Add the `eagerlyUpdated` property to force `FieldRenderer` to re-render whenever its value was affected. ```diff diff --git a/packages/react/src/main/AccessorContext.ts b/packages/react/src/main/AccessorContext.ts index 0d2a0625..81155911 100644 --- a/packages/react/src/main/AccessorContext.ts +++ b/packages/react/src/main/AccessorContext.ts @@ -1,9 +1,9 @@ import { createContext, Context } from 'react'; -import { naturalAccessor, Accessor } from 'roqueform'; +import { naturalAccessor, ValueAccessor } from 'roqueform'; /** * The context that is used by {@link useField} to retrieve an accessor. */ -export const AccessorContext: Context = createContext(naturalAccessor); +export const AccessorContext: Context = createContext(naturalAccessor); AccessorContext.displayName = 'AccessorContext'; diff --git a/packages/react/src/main/FieldRenderer.ts b/packages/react/src/main/FieldRenderer.ts index 44de34c7..356aa54f 100644 --- a/packages/react/src/main/FieldRenderer.ts +++ b/packages/react/src/main/FieldRenderer.ts @@ -4,18 +4,18 @@ import { AnyField, callOrGet, ValueOf } from 'roqueform'; /** * Properties of the {@link FieldRenderer} component. * - * @template Field The rendered field. + * @template RenderedField The rendered field. */ -export interface FieldRendererProps { +export interface FieldRendererProps { /** - * The field to subscribe to. + * The field that triggers re-renders. */ - field: Field; + field: RenderedField; /** - * The render function that receive a field as an argument. + * The render function that receive a rendered field as an argument. */ - children: (field: Field) => ReactNode; + children: (field: RenderedField) => ReactNode; /** * If set to `true` then {@link FieldRenderer} is re-rendered whenever the {@link field} itself, its parent fields or @@ -31,19 +31,19 @@ export interface FieldRendererProps { * * @param value The new field value. */ - onChange?: (value: ValueOf) => void; + onChange?: (value: ValueOf) => void; } /** * The component that subscribes to the {@link Field} instance and re-renders its children when the field is notified. * - * @template Field The rendered field. + * @template RenderedField The rendered field. */ -export function FieldRenderer(props: FieldRendererProps): ReactElement { +export function FieldRenderer(props: FieldRendererProps): ReactElement { const { field, eagerlyUpdated } = props; const [, rerender] = useReducer(reduceCount, 0); - const handleChangeRef = useRef['onChange']>(); + const handleChangeRef = useRef['onChange']>(); handleChangeRef.current = props.onChange; diff --git a/packages/react/src/main/useField.ts b/packages/react/src/main/useField.ts index b2a96746..5e89af23 100644 --- a/packages/react/src/main/useField.ts +++ b/packages/react/src/main/useField.ts @@ -26,7 +26,7 @@ export function useField(initialValue: Value | (() => Value)): Field { expect(renderMock).toHaveBeenCalledTimes(2); }); - test('does not re-render if derived field value is changed externally', async () => { + test('does not re-render if child field value is changed externally', async () => { const renderMock = jest.fn(); let rootField!: Field<{ foo: number }>; @@ -79,7 +79,7 @@ describe('FieldRenderer', () => { expect(renderMock).toHaveBeenCalledTimes(1); }); - test('does not re-render if eagerlyUpdated and derived field value is changed externally', async () => { + test('does not re-render if eagerlyUpdated and child field value is changed externally', async () => { const renderMock = jest.fn(); let rootField!: Field<{ foo: number }>; diff --git a/packages/react/src/test/useField.test.ts b/packages/react/src/test/useField.test.ts index 5f1aa11d..6f10d787 100644 --- a/packages/react/src/test/useField.test.ts +++ b/packages/react/src/test/useField.test.ts @@ -5,7 +5,7 @@ describe('useField', () => { test('returns field with undefined initial value', () => { const hook = renderHook(() => useField()); - expect(hook.result.current.value).toBe(undefined); + expect(hook.result.current.value).toBeUndefined(); }); test('returns a field with a literal initial value', () => { diff --git a/packages/ref-plugin/src/test/refPlugin.test.ts b/packages/ref-plugin/src/test/refPlugin.test.ts index c55345d6..fa47b28f 100644 --- a/packages/ref-plugin/src/test/refPlugin.test.ts +++ b/packages/ref-plugin/src/test/refPlugin.test.ts @@ -3,14 +3,14 @@ import { refPlugin } from '../main'; describe('refPlugin', () => { test('adds an element property to the field', () => { - const field = createField({ bar: 111 }, refPlugin()); + const field = createField({ aaa: 111 }, refPlugin()); - expect(field.element).toBe(null); - expect(field.at('bar').element).toBe(null); + expect(field.element).toBeNull(); + expect(field.at('aaa').element).toBeNull(); }); test('ref updates an element property', () => { - const field = createField({ bar: 111 }, refPlugin()); + const field = createField({ aaa: 111 }, refPlugin()); const element = document.createElement('input'); field.ref(element); @@ -19,10 +19,10 @@ describe('refPlugin', () => { }); test('preserves the ref from preceding plugin', () => { - const refMock = jest.fn(() => undefined); + const refMock = jest.fn(); const field = createField( - { bar: 111 }, + { aaa: 111 }, composePlugins(field => Object.assign(field, { ref: refMock }), refPlugin()) ); diff --git a/packages/reset-plugin/src/main/resetPlugin.ts b/packages/reset-plugin/src/main/resetPlugin.ts index befbdb78..658497ed 100644 --- a/packages/reset-plugin/src/main/resetPlugin.ts +++ b/packages/reset-plugin/src/main/resetPlugin.ts @@ -51,6 +51,11 @@ export function resetPlugin( equalityChecker: (initialValue: any, value: any) => boolean = isDeepEqual ): PluginInjector { return field => { + Object.defineProperty(field, 'isDirty', { + configurable: true, + get: () => !field.equalityChecker(field.initialValue, field.value), + }); + field.equalityChecker = equalityChecker; field.setInitialValue = value => { @@ -60,8 +65,6 @@ export function resetPlugin( field.reset = () => { field.setValue(field.initialValue); }; - - Object.defineProperty(field, 'isDirty', { get: () => field.equalityChecker(field.initialValue, field.value) }); }; } @@ -73,7 +76,7 @@ function setInitialValue(field: Field, initialValue: unknown): void let root = field; while (root.parent !== null) { - initialValue = field.accessor.set(root.parent.value, root.key, initialValue); + initialValue = field.valueAccessor.set(root.parent.value, root.key, initialValue); root = root.parent; } @@ -92,7 +95,7 @@ function propagateInitialValue( if (field.children !== null) { for (const child of field.children) { - const childInitialValue = field.accessor.get(initialValue, child.key); + const childInitialValue = field.valueAccessor.get(initialValue, child.key); if (child !== target && isEqual(child.initialValue, childInitialValue)) { continue; } diff --git a/packages/reset-plugin/src/test/resetPlugin.test.ts b/packages/reset-plugin/src/test/resetPlugin.test.ts index 4d82c26e..9262d0e2 100644 --- a/packages/reset-plugin/src/test/resetPlugin.test.ts +++ b/packages/reset-plugin/src/test/resetPlugin.test.ts @@ -3,82 +3,82 @@ import { resetPlugin } from '../main'; describe('resetPlugin', () => { test('field is dirty if the field value is not equal to an initial value', () => { - const initialValue = { foo: 111 }; + const initialValue = { aaa: 111 }; const field = createField(initialValue, resetPlugin()); expect(field.initialValue).toBe(initialValue); - expect(field.at('foo').initialValue).toBe(111); + expect(field.at('aaa').initialValue).toBe(111); - field.at('foo').setValue(222); + field.at('aaa').setValue(222); - expect(field.at('foo').isDirty).toBe(true); + expect(field.at('aaa').isDirty).toBe(true); expect(field.isDirty).toBe(true); field.setValue(initialValue); - expect(field.at('foo').isDirty).toBe(false); + expect(field.at('aaa').isDirty).toBe(false); expect(field.isDirty).toBe(false); }); test('field is not dirty it has the value that is deeply equal to the initial value', () => { - const initialValue = { foo: 111 }; + const initialValue = { aaa: 111 }; const field = createField(initialValue, resetPlugin()); - field.at('foo').setValue(222); + field.at('aaa').setValue(222); - expect(field.at('foo').isDirty).toBe(true); + expect(field.at('aaa').isDirty).toBe(true); expect(field.isDirty).toBe(true); - field.setValue({ foo: 111 }); + field.setValue({ aaa: 111 }); - expect(field.at('foo').isDirty).toBe(false); + expect(field.at('aaa').isDirty).toBe(false); expect(field.isDirty).toBe(false); }); test('updates the initial value and notifies fields', () => { const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - const initialValue = { foo: 111 }; + const initialValue = { aaa: 111 }; const field = createField(initialValue, resetPlugin()); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('change:initialValue', subscriberMock); + field.at('aaa').on('change:initialValue', aaaSubscriberMock); - const initialValue2 = { foo: 222 }; + const initialValue2 = { aaa: 222 }; field.setInitialValue(initialValue2); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); - expect(field.at('foo').initialValue).toBe(222); - expect(field.at('foo').isDirty).toBe(true); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(field.at('aaa').initialValue).toBe(222); + expect(field.at('aaa').isDirty).toBe(true); expect(field.initialValue).toBe(initialValue2); expect(field.isDirty).toBe(true); }); - test('derived field is dirty if its value was updated before the Field instance was created', () => { - const field = createField({ foo: 111 }, resetPlugin()); + test('child field is dirty if its value was updated before the Field instance was created', () => { + const field = createField({ aaa: 111 }, resetPlugin()); - field.setValue({ foo: 222 }); + field.setValue({ aaa: 222 }); - expect(field.at('foo').isDirty).toBe(true); + expect(field.at('aaa').isDirty).toBe(true); }); test('resets to the initial value', () => { - const field = createField({ foo: 111 }, resetPlugin()); + const field = createField({ aaa: 111 }, resetPlugin()); - field.at('foo').setValue(222); + field.at('aaa').setValue(222); expect(field.isDirty).toBe(true); - expect(field.at('foo').isDirty).toBe(true); + expect(field.at('aaa').isDirty).toBe(true); field.reset(); - expect(field.at('foo').isDirty).toBe(false); + expect(field.at('aaa').isDirty).toBe(false); expect(field.isDirty).toBe(false); }); }); diff --git a/packages/roqueform/src/main/composePlugins.ts b/packages/roqueform/src/main/composePlugins.ts index 4312676a..5dd4c505 100644 --- a/packages/roqueform/src/main/composePlugins.ts +++ b/packages/roqueform/src/main/composePlugins.ts @@ -51,8 +51,8 @@ export function composePlugins( /** * Composes multiple plugin callbacks into a single callback. * - * @param plugins The array of plugins to compose. - * @returns The plugins callbacks that sequentially applies all provided plugins to a field. + * @param plugins The array of plugin injectors to compose. + * @returns The plugins injector that sequentially applies all provided injectors into a field. */ export function composePlugins(...plugins: PluginInjector[]): PluginInjector; diff --git a/packages/roqueform/src/main/createField.ts b/packages/roqueform/src/main/createField.ts index 0cd98746..bd1fd3df 100644 --- a/packages/roqueform/src/main/createField.ts +++ b/packages/roqueform/src/main/createField.ts @@ -1,4 +1,4 @@ -import { Accessor, Field, Event, PluginInjector } from './typings'; +import { ValueAccessor, Event, Field, PluginInjector, Subscriber } from './typings'; import { callOrGet, dispatchEvents, isEqual } from './utils'; import { naturalAccessor } from './naturalAccessor'; @@ -16,27 +16,27 @@ export function createField(): Field; * Creates the new field instance. * * @param initialValue The initial value assigned to the field. - * @param accessor Resolves values for derived fields. + * @param accessor Resolves values for child fields. * @template Value The root field value. */ -export function createField(initialValue: Value, accessor?: Accessor): Field; +export function createField(initialValue: Value, accessor?: ValueAccessor): Field; /** * Creates the new field instance. * * @param initialValue The initial value assigned to the field. - * @param plugin The plugin that enhances the field. - * @param accessor Resolves values for derived fields. + * @param plugin The plugin injected into the field. + * @param accessor Resolves values for child fields. * @template Value The root field initial value. * @template Plugin The plugin injected into the field. */ export function createField( initialValue: Value, plugin: PluginInjector>, - accessor?: Accessor + accessor?: ValueAccessor ): Field; -export function createField(initialValue?: unknown, plugin?: PluginInjector | Accessor, accessor?: Accessor) { +export function createField(initialValue?: unknown, plugin?: PluginInjector | ValueAccessor, accessor?: ValueAccessor) { if (typeof plugin !== 'function') { accessor = plugin; plugin = undefined; @@ -45,7 +45,7 @@ export function createField(initialValue?: unknown, plugin?: PluginInjector | Ac } function getOrCreateField( - accessor: Accessor, + accessor: ValueAccessor, parent: Field | null, key: unknown, initialValue: unknown, @@ -59,7 +59,7 @@ function getOrCreateField( child = { key, - value: null, + value: initialValue, initialValue, isTransient: false, root: null!, @@ -67,7 +67,7 @@ function getOrCreateField( children: null, childrenMap: null, subscribers: null, - accessor, + valueAccessor: accessor, plugin, setValue: value => { @@ -82,11 +82,14 @@ function getOrCreateField( setValue(child, child.value, false); }, - at: key => getOrCreateField(child.accessor, child, key, null, plugin), + at: key => getOrCreateField(child.valueAccessor, child, key, null, plugin), on: (type, subscriber) => { - let subscribers: unknown[]; - (subscribers = (child.subscribers ||= Object.create(null))[type] ||= []).push(subscriber); + const subscribers: Subscriber[] = ((child.subscribers ||= Object.create(null))[type] ||= []); + + if (!subscribers.includes(subscriber)) { + subscribers.push(subscriber); + } return () => { subscribers.splice(subscribers.indexOf(subscriber), 1); }; @@ -121,7 +124,7 @@ function setValue(field: Field, value: unknown, transient: boolean): void { let changeRoot = field; while (changeRoot.parent !== null && !changeRoot.isTransient) { - value = field.accessor.set(changeRoot.parent.value, changeRoot.key, value); + value = field.valueAccessor.set(changeRoot.parent.value, changeRoot.key, value); changeRoot = changeRoot.parent; } @@ -139,7 +142,7 @@ function propagateValue(origin: Field, field: Field, value: unknown, events: Eve continue; } - const childValue = field.accessor.get(value, child.key); + const childValue = field.valueAccessor.get(value, child.key); if (child !== origin && isEqual(child.value, childValue)) { continue; } diff --git a/packages/roqueform/src/main/naturalAccessor.ts b/packages/roqueform/src/main/naturalAccessor.ts index 41210c79..5e4a6452 100644 --- a/packages/roqueform/src/main/naturalAccessor.ts +++ b/packages/roqueform/src/main/naturalAccessor.ts @@ -1,10 +1,10 @@ -import { Accessor } from './typings'; +import { ValueAccessor } from './typings'; import { isEqual } from './utils'; /** * The accessor that reads and writes key-value pairs to well-known object instances. */ -export const naturalAccessor: Accessor = { +export const naturalAccessor: ValueAccessor = { get(obj, key) { if (isPrimitive(obj)) { return undefined; @@ -93,7 +93,7 @@ export const naturalAccessor: Accessor = { }; /** - * Converts `k` to a number if it represents a valid array index, or returns -1 if `k` isn't an index. + * Converts `k` to a non-negative integer if it represents a valid array index, or returns -1 if `k` isn't an index. */ function toArrayIndex(k: any): number { return (typeof k === 'number' || (typeof k === 'string' && k === '' + (k = +k))) && k >>> 0 === k ? k : -1; @@ -104,13 +104,13 @@ function isPrimitive(obj: any): boolean { obj === null || obj === undefined || typeof obj !== 'object' || + obj instanceof Date || + obj instanceof RegExp || obj instanceof String || obj instanceof Number || obj instanceof Boolean || obj instanceof BigInt || - obj instanceof Symbol || - obj instanceof Date || - obj instanceof RegExp + obj instanceof Symbol ); } diff --git a/packages/roqueform/src/main/typings.ts b/packages/roqueform/src/main/typings.ts index 11a062a1..04c36b15 100644 --- a/packages/roqueform/src/main/typings.ts +++ b/packages/roqueform/src/main/typings.ts @@ -58,7 +58,7 @@ export type Subscriber = (event: Event void; /** - * Infers the plugin that was used to enhance the field. + * Infers plugins that were injected into a field * * Use `PluginOf` in plugin interfaces to infer all plugin interfaces that were intersected with the field * controller. @@ -155,7 +155,7 @@ export interface FieldController { * @see {@link on} * @protected */ - ['subscribers']: Record[]> | null; + ['subscribers']: Record[] | undefined> | null; /** * The accessor that reads the field value from the value of the parent fields, and updates parent value. @@ -163,7 +163,7 @@ export interface FieldController { * @see [Accessors](https://github.com/smikhalevski/roqueform#accessors) * @protected */ - ['accessor']: Accessor; + ['valueAccessor']: ValueAccessor; /** * The plugin that is applied to this field and all child fields when they are accessed, or `null` field isn't @@ -239,7 +239,7 @@ export type PluginInjector = (field: Field = */ // prettier-ignore type ValueAt = - T extends { set(key: any, value: any): any, get(key: infer K): infer V } ? Key extends K ? V : never : - T extends { add(value: infer V): any, [Symbol.iterator]: Function } ? Key extends number ? V | undefined : never : - T extends readonly any[] ? Key extends number ? T[Key] : never : + T extends { set(key: any, value: any): any, get(key: infer K): infer V } ? Key extends K ? V : undefined : + T extends { add(value: infer V): any, [Symbol.iterator]: Function } ? Key extends number ? V | undefined : undefined : + T extends readonly any[] ? Key extends number ? T[Key] : undefined : Key extends keyof T ? T[Key] : - never + undefined diff --git a/packages/roqueform/src/main/utils.ts b/packages/roqueform/src/main/utils.ts index 1a29ed1c..0cb52e57 100644 --- a/packages/roqueform/src/main/utils.ts +++ b/packages/roqueform/src/main/utils.ts @@ -1,9 +1,5 @@ import { Event } from './typings'; -export function callOrGet(value: T | ((prevValue: A) => T), prevValue: A): T { - return typeof value === 'function' ? (value as Function)(prevValue) : value; -} - /** * [SameValueZero](https://262.ecma-international.org/7.0/#sec-samevaluezero) comparison operation. * @@ -15,26 +11,43 @@ export function isEqual(a: unknown, b: unknown): boolean { return a === b || (a !== a && b !== b); } +/** + * If value is a function then it is called with the given argument, otherwise the value is returned as is. + * + * @param value The value to return or a callback to call. + * @param arg The of argument to pass to the value callback. + * @returns The value or the call result. + * @template T The returned value. + * @template A The value callback argument. + */ +export function callOrGet(value: T | ((arg: A) => T), arg: A): T { + return typeof value === 'function' ? (value as Function)(arg) : value; +} + +/** + * Calls field subscribers that can handle given events. + * + * @param events The array of events to dispatch. + */ export function dispatchEvents(events: readonly Event[]): void { for (const event of events) { const { subscribers } = event.target; - if (subscribers !== null) { - callAll(subscribers[event.type], event); - callAll(subscribers['*'], event); + if (subscribers === null) { + continue; } - } -} -function callAll(subscribers: Array<(event: Event) => void> | undefined, event: Event): void { - if (subscribers !== undefined) { - for (const subscriber of subscribers) { - try { + const typeSubscribers = subscribers[event.type]; + const globSubscribers = subscribers['*']; + + if (typeSubscribers !== undefined) { + for (const subscriber of typeSubscribers) { + subscriber(event); + } + } + if (globSubscribers !== undefined) { + for (const subscriber of globSubscribers) { subscriber(event); - } catch (error) { - setTimeout(() => { - throw error; - }, 0); } } } diff --git a/packages/roqueform/src/main/validationPlugin.ts b/packages/roqueform/src/main/validationPlugin.ts index cbe3efab..55e9708a 100644 --- a/packages/roqueform/src/main/validationPlugin.ts +++ b/packages/roqueform/src/main/validationPlugin.ts @@ -88,7 +88,7 @@ export interface ValidationPlugin { deleteError(): void; /** - * Recursively deletes errors associated with this field and all of its derived fields. + * Recursively deletes errors associated with this field and all of its child fields. */ clearErrors(): void; @@ -194,13 +194,10 @@ export interface Validator { validate(field: Field>, options: Options | undefined): void; /** - * Applies validation rules to a field. - * - * Check that {@link Validation.abortController validation isn't aborted} before - * {@link ValidationPlugin.setValidationError setting a validation error}, otherwise stop validation as soon as - * possible. + * Applies validation rules to a field. If this callback is omitted, then {@link validate} would be called instead. * - * If this callback is omitted, then {@link validate} would be called instead. + * Set {@link ValidationPlugin.setValidationError validation errors} to invalid fields during validation. Refer to + * {@link ValidationPlugin.validation} to check that validation wasn't aborted. * * @param field The field where {@link ValidationPlugin.validateAsync} was called. * @param options The options passed to the {@link ValidationPlugin.validateAsync} method. @@ -229,11 +226,11 @@ export function validationPlugin( field.errorCount = 0; field.errorOrigin = 0; field.validator = typeof validator === 'function' ? { validate: validator } : validator; - field.validation = null; + field.validation = field.parent !== null ? field.parent.validation : null; Object.defineProperties(field, { - isInvalid: { get: () => field.errorCount !== 0 }, - isValidating: { get: () => field.validation !== null }, + isInvalid: { configurable: true, get: () => field.errorCount !== 0 }, + isValidating: { configurable: true, get: () => field.validation !== null }, }); const { setValue, setTransientValue } = field; @@ -279,7 +276,7 @@ export function validationPlugin( }; field.setValidationError = (validation, error) => { - if (validation !== null && field.validation !== validation && field.errorOrigin < 2) { + if (validation !== null && field.validation === validation && field.errorOrigin < 2) { dispatchEvents(setError(field, error, 1, [])); } }; diff --git a/packages/roqueform/src/test/composePlugins.test-d.ts b/packages/roqueform/src/test/composePlugins.test-d.ts index a771293d..df8ebc07 100644 --- a/packages/roqueform/src/test/composePlugins.test-d.ts +++ b/packages/roqueform/src/test/composePlugins.test-d.ts @@ -1,10 +1,10 @@ import { expectType } from 'tsd'; -import { composePlugins, createField, Plugin } from 'roqueform'; +import { composePlugins, createField, PluginInjector } from 'roqueform'; -declare const plugin1: Plugin<{ aaa: number }>; -declare const plugin2: Plugin<{ bbb: boolean }>; +declare const plugin1: PluginInjector<{ aaa: number }>; +declare const plugin2: PluginInjector<{ bbb: boolean }>; -expectType>(composePlugins(plugin1, plugin2)); +expectType>(composePlugins(plugin1, plugin2)); const field = createField({ foo: 111 }, composePlugins(plugin1, plugin2)); diff --git a/packages/roqueform/src/test/createField.test.ts b/packages/roqueform/src/test/createField.test.ts index 0c65157d..ef0ead25 100644 --- a/packages/roqueform/src/test/createField.test.ts +++ b/packages/roqueform/src/test/createField.test.ts @@ -1,4 +1,4 @@ -import { createField, naturalAccessor, Plugin } from '../main'; +import { createField, naturalAccessor } from '../main'; jest.useFakeTimers(); @@ -10,103 +10,172 @@ describe('createField', () => { test('creates a field without an initial value', () => { const field = createField(); - expect(field.parent).toBe(null); - expect(field.key).toBe(null); - expect(field.value).toBe(undefined); + expect(field.key).toBeNull(); + expect(field.value).toBeUndefined(); + expect(field.initialValue).toBeUndefined(); expect(field.isTransient).toBe(false); + expect(field.root).toBe(field); + expect(field.parent).toBeNull(); + expect(field.children).toBeNull(); + expect(field.childrenMap).toBeNull(); + expect(field.subscribers).toBeNull(); + expect(field.valueAccessor).toBe(naturalAccessor); + expect(field.plugin).toBeNull(); }); test('creates a field with the initial value', () => { const field = createField(111); expect(field.value).toBe(111); + expect(field.initialValue).toBe(111); }); test('returns a field at key', () => { - const field = createField({ foo: 111 }); + const field = createField({ aaa: 111 }); - expect(field.at('foo').parent).toBe(field); - expect(field.at('foo').key).toBe('foo'); - expect(field.at('foo').value).toBe(111); + const child = field.at('aaa'); + + expect(child.root).toBe(field); + expect(child.parent).toBe(field); + expect(child.key).toBe('aaa'); + expect(child.value).toBe(111); + expect(child.initialValue).toBe(111); + + expect(field.children).toEqual([child]); + expect(field.childrenMap).toEqual(new Map([['aaa', child]])); }); test('returns the same field for the same key', () => { - const field = createField({ foo: 111 }); + const field = createField({ aaa: 111 }); - expect(field.at('foo')).toBe(field.at('foo')); + expect(field.at('aaa')).toBe(field.at('aaa')); }); - test('dispatches a value to a root field', () => { + test('sets a value to a root field', () => { const field = createField(111); field.setValue(222); expect(field.value).toBe(222); + expect(field.initialValue).toBe(111); expect(field.isTransient).toBe(false); }); - test('dispatches a value to a derived field', () => { - const field = createField({ foo: 111 }); + test('sets a value to a child field', () => { + const initialValue = { aaa: 111 }; + + const field = createField(initialValue); - field.at('foo').setValue(222); + field.at('aaa').setValue(222); - expect(field.value).toEqual({ foo: 222 }); + expect(field.value).not.toBe(initialValue); + expect(field.value).toEqual({ aaa: 222 }); + expect(field.initialValue).toEqual(initialValue); expect(field.isTransient).toBe(false); - expect(field.at('foo').value).toBe(222); - expect(field.at('foo').isTransient).toBe(false); + expect(field.at('aaa').value).toBe(222); + expect(field.at('aaa').initialValue).toBe(111); + expect(field.at('aaa').isTransient).toBe(false); }); - test('invokes a subscriber when value is updated', () => { + test('calls a glob subscriber when value is updated', () => { + const rootSubscriberMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); + + const field = createField({ aaa: 111 }); + field.on('*', rootSubscriberMock); + + field.at('aaa').on('*', aaaSubscriberMock); + + field.at('aaa').setValue(222); + + expect(rootSubscriberMock).toHaveBeenCalledTimes(1); + expect(rootSubscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field.at('aaa'), + target: field, + data: { aaa: 111 }, + }); + + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field.at('aaa'), + target: field.at('aaa'), + data: 111, + }); + }); + + test('calls a type subscriber when value is updated', () => { const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - const field = createField({ foo: 111 }); - field.subscribe(subscriberMock); + const field = createField({ aaa: 111 }); + field.on('change:value', subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.at('aaa').on('change:value', aaaSubscriberMock); - field.at('foo').setValue(222); + field.at('aaa').setValue(222); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(subscriberMock).toHaveBeenNthCalledWith(1, field.at('foo'), field); + expect(subscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field.at('aaa'), + target: field, + data: { aaa: 111 }, + }); - expect(fooListenerMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenNthCalledWith(1, field.at('foo'), field.at('foo')); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field.at('aaa'), + target: field.at('aaa'), + data: 111, + }); }); test('does not invoke the subscriber of the unchanged sibling field', () => { - const fooListenerMock = jest.fn(); - const barListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); + const bbbSubscriberMock = jest.fn(); - const field = createField({ foo: 111, bar: 'aaa' }); + const field = createField({ aaa: 111, bbb: 'aaa' }); - field.at('foo').subscribe(fooListenerMock); - field.at('bar').subscribe(barListenerMock); + field.at('aaa').on('*', aaaSubscriberMock); + field.at('bbb').on('*', bbbSubscriberMock); - field.at('foo').setValue(222); + field.at('aaa').setValue(222); - expect(barListenerMock).not.toHaveBeenCalled(); + expect(bbbSubscriberMock).not.toHaveBeenCalled(); - expect(fooListenerMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenNthCalledWith(1, field.at('foo'), field.at('foo')); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field.at('aaa'), + target: field.at('aaa'), + data: 111, + }); }); - test('does not invoke the subscriber of the unchanged derived field', () => { - const fooListenerMock = jest.fn(); - const barListenerMock = jest.fn(); + test('does not invoke the subscriber of the unchanged child field', () => { + const aaaSubscriberMock = jest.fn(); + const bbbSubscriberMock = jest.fn(); - const field = createField({ foo: 111, bar: 'aaa' }); + const field = createField({ aaa: 111, bbb: 'aaa' }); - field.at('foo').subscribe(fooListenerMock); - field.at('bar').subscribe(barListenerMock); + field.at('aaa').on('*', aaaSubscriberMock); + field.at('bbb').on('*', bbbSubscriberMock); - field.setValue({ foo: 222, bar: 'aaa' }); + field.setValue({ aaa: 222, bbb: 'aaa' }); - expect(barListenerMock).not.toHaveBeenCalled(); + expect(bbbSubscriberMock).not.toHaveBeenCalled(); - expect(fooListenerMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenNthCalledWith(1, field, field.at('foo')); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field, + target: field.at('aaa'), + data: 111, + }); }); test('sets a value to a root field', () => { @@ -118,155 +187,141 @@ describe('createField', () => { expect(field.isTransient).toBe(true); }); - test('sets a value to a derived field', () => { - const initialValue = { foo: 111 }; + test('sets a value to a child field', () => { + const initialValue = { aaa: 111 }; const field = createField(initialValue); - field.at('foo').setTransientValue(222); + field.at('aaa').setTransientValue(222); expect(field.value).toBe(initialValue); expect(field.isTransient).toBe(false); - expect(field.at('foo').value).toBe(222); - expect(field.at('foo').isTransient).toBe(true); + expect(field.at('aaa').value).toBe(222); + expect(field.at('aaa').isTransient).toBe(true); }); - test('dispatches a value after it was set to a derived field', () => { - const field = createField({ foo: 111 }); + test('propagates a value after it was set to a child field', () => { + const field = createField({ aaa: 111 }); - field.at('foo').setTransientValue(222); - field.at('foo').dispatch(); + field.at('aaa').setTransientValue(222); + field.at('aaa').propagate(); - expect(field.value).toEqual({ foo: 222 }); + expect(field.value).toEqual({ aaa: 222 }); expect(field.isTransient).toBe(false); - expect(field.at('foo').value).toBe(222); - expect(field.at('foo').isTransient).toBe(false); + expect(field.at('aaa').value).toBe(222); + expect(field.at('aaa').isTransient).toBe(false); }); test('invokes a subscriber when a value is updated transiently', () => { const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - const field = createField({ foo: 111 }); - field.subscribe(subscriberMock); + const field = createField({ aaa: 111 }); + field.on('*', subscriberMock); - field.at('foo').subscribe(fooListenerMock); - field.at('foo').setTransientValue(222); + field.at('aaa').on('*', aaaSubscriberMock); + field.at('aaa').setTransientValue(222); expect(subscriberMock).toHaveBeenCalledTimes(0); - expect(fooListenerMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenNthCalledWith(1, field.at('foo'), field.at('foo')); - }); - - test('does not leave the form in an inconsistent state if a subscriber throws an error', () => { - const fooListenerMock = jest.fn(() => { - throw new Error('fooExpected'); - }); - const barListenerMock = jest.fn(() => { - throw new Error('barExpected'); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field.at('aaa'), + target: field.at('aaa'), + data: 111, }); - - const field = createField({ foo: 111, bar: 222 }); - - field.at('foo').subscribe(fooListenerMock); - field.at('bar').subscribe(barListenerMock); - - field.setValue({ foo: 333, bar: 444 }); - - expect(fooListenerMock).toHaveBeenCalledTimes(1); - expect(barListenerMock).toHaveBeenCalledTimes(1); - expect(field.at('foo').value).toBe(333); - expect(field.at('bar').value).toBe(444); - - expect(() => jest.runAllTimers()).toThrow(new Error('fooExpected')); - expect(() => jest.runAllTimers()).toThrow(new Error('barExpected')); }); - test('calls all subscribers and throws error asynchronously', () => { - const subscriberMock1 = jest.fn(() => { - throw new Error('expected1'); + test('does not leave fields in an inconsistent state if a subscriber throws an error', () => { + const aaaSubscriberMock = jest.fn(() => { + throw new Error('aaaExpected'); }); - const subscriberMock2 = jest.fn(() => { - throw new Error('expected2'); + const bbbSubscriberMock = jest.fn(() => { + throw new Error('bbbExpected'); }); - const field = createField({ foo: 111, bar: 222 }); - - field.at('foo').subscribe(subscriberMock1); - field.at('foo').subscribe(subscriberMock2); + const field = createField({ aaa: 111, bbb: 222 }); - field.setValue({ foo: 333, bar: 444 }); + field.at('aaa').on('*', aaaSubscriberMock); + field.at('bbb').on('*', bbbSubscriberMock); - expect(subscriberMock1).toHaveBeenCalledTimes(1); - expect(subscriberMock2).toHaveBeenCalledTimes(1); - expect(field.at('foo').value).toBe(333); - expect(field.at('bar').value).toBe(444); + try { + field.setValue({ aaa: 333, bbb: 444 }); + } catch {} - expect(() => jest.runAllTimers()).toThrow(new Error('expected1')); - expect(() => jest.runAllTimers()).toThrow(new Error('expected2')); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(bbbSubscriberMock).toHaveBeenCalledTimes(0); + expect(field.at('aaa').value).toBe(333); + expect(field.at('bbb').value).toBe(444); }); - test('propagates a new value to the derived field', () => { + test('propagates a new value to the child field', () => { const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - const field = createField({ foo: 111 }); - field.subscribe(subscriberMock); + const field = createField({ aaa: 111 }); + field.on('*', subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.at('aaa').on('*', aaaSubscriberMock); - const nextValue = { foo: 333 }; + const nextValue = { aaa: 333 }; field.setValue(nextValue); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); expect(field.value).toBe(nextValue); expect(field.isTransient).toBe(false); - expect(field.at('foo').value).toBe(333); - expect(field.at('foo').isTransient).toBe(false); + expect(field.at('aaa').value).toBe(333); + expect(field.at('aaa').isTransient).toBe(false); }); - test('does not propagate a new value to the transient derived field', () => { + test('does not propagate a new value to the transient child field', () => { const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - const field = createField({ foo: 111 }); - field.subscribe(subscriberMock); + const field = createField({ aaa: 111 }); + field.on('*', subscriberMock); - field.at('foo').subscribe(fooListenerMock); - field.at('foo').setTransientValue(222); + field.at('aaa').on('*', aaaSubscriberMock); + field.at('aaa').setTransientValue(222); - field.setValue({ foo: 333 }); + field.setValue({ aaa: 333 }); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); - expect(field.at('foo').value).toBe(222); - expect(field.at('foo').isTransient).toBe(true); + expect(field.at('aaa').value).toBe(222); + expect(field.at('aaa').isTransient).toBe(true); }); - test('does not notify subscribers if a value of the derived field did not change', () => { + test('does not notify subscribers if a value of the child field did not change', () => { const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - const fooValue = { bar: 111 }; + const aaaValue = { bbb: 111 }; + const initialValue = { aaa: aaaValue }; - const field = createField({ foo: fooValue }); - field.subscribe(subscriberMock); + const field = createField(initialValue); + field.on('*', subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.at('aaa').on('*', aaaSubscriberMock); - field.setValue({ foo: fooValue }); + field.setValue({ aaa: aaaValue }); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(subscriberMock).toHaveBeenNthCalledWith(1, field, field); + expect(subscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:value', + origin: field, + target: field, + data: initialValue, + }); - expect(fooListenerMock).toHaveBeenCalledTimes(0); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(0); }); test('applies a plugin to the root field', () => { @@ -275,96 +330,34 @@ describe('createField', () => { const field = createField(111, pluginMock); expect(pluginMock).toHaveBeenCalledTimes(1); - expect(pluginMock).toHaveBeenNthCalledWith(1, field, naturalAccessor, expect.any(Function)); + expect(pluginMock).toHaveBeenNthCalledWith(1, field); }); - test('returns a field if plugin returns undefined', () => { + test('applies a plugin to the child field', () => { const pluginMock = jest.fn(); - const field = createField(111, pluginMock); - - expect(field.value).toBe(111); + const field = createField({ aaa: 111 }, pluginMock); - expect(pluginMock).toHaveBeenCalledTimes(1); - expect(pluginMock).toHaveBeenNthCalledWith(1, field, naturalAccessor, expect.any(Function)); - }); - - test('applies a plugin to the derived field', () => { - const pluginMock = jest.fn(); - - const field = createField({ foo: 111 }, pluginMock); - - const fooField = field.at('foo'); + const aaaField = field.at('aaa'); expect(pluginMock).toHaveBeenCalledTimes(2); - expect(pluginMock).toHaveBeenNthCalledWith(1, field, naturalAccessor, expect.any(Function)); - expect(pluginMock).toHaveBeenNthCalledWith(2, fooField, naturalAccessor, expect.any(Function)); - }); - - test('plugin notifies field subscribers', () => { - let notifyCallback1!: () => void; - - const plugin: Plugin = jest.fn().mockImplementationOnce((_field, _accessor, notify) => { - notifyCallback1 = notify; - }); - const subscriberMock1 = jest.fn(); - const subscriberMock2 = jest.fn(); - - const field = createField({ foo: 111 }, plugin); - - field.subscribe(subscriberMock1); - field.at('foo').subscribe(subscriberMock2); - - expect(subscriberMock1).not.toHaveBeenCalled(); - expect(subscriberMock2).not.toHaveBeenCalled(); - - notifyCallback1(); - - expect(subscriberMock1).toHaveBeenCalledTimes(1); - expect(subscriberMock2).not.toHaveBeenCalled(); - }); - - test('plugin notifies derived field subscribers', () => { - let notifyCallback1!: () => void; - - const plugin: Plugin = jest - .fn() - .mockImplementationOnce(() => undefined) - .mockImplementationOnce((_field, _accessor, notify) => { - notifyCallback1 = notify; - }); - - const subscriberMock1 = jest.fn(); - const subscriberMock2 = jest.fn(); - - const field = createField({ foo: 111 }, plugin); - - field.subscribe(subscriberMock1); - field.at('foo').subscribe(subscriberMock2); - - expect(subscriberMock1).not.toHaveBeenCalled(); - expect(subscriberMock2).not.toHaveBeenCalled(); - - notifyCallback1(); - - expect(subscriberMock1).not.toHaveBeenCalled(); - expect(subscriberMock2).toHaveBeenCalledTimes(1); + expect(pluginMock).toHaveBeenNthCalledWith(1, field); + expect(pluginMock).toHaveBeenNthCalledWith(2, aaaField); }); - test('an actual parent value is visible in the derived field subscriber', done => { - const field = createField({ foo: 111 }); - const newValue = { foo: 222 }; + test('an actual parent value is visible in the child field subscriber', done => { + const field = createField({ aaa: 111 }); + const newValue = { aaa: 222 }; - field.at('foo').subscribe(updatedField => { - expect(updatedField).toBe(field); - expect(updatedField.value).toBe(newValue); + field.at('aaa').on('*', event => { + expect(event.origin.value).toBe(newValue); done(); }); field.setValue(newValue); }); - test('does not cache a derived field for which the plugin has thrown an error', () => { + test('does not cache a child field for which the plugin has thrown an error', () => { const pluginMock = jest.fn(); pluginMock.mockImplementationOnce(() => undefined); pluginMock.mockImplementationOnce(() => { @@ -374,10 +367,10 @@ describe('createField', () => { throw new Error('expected2'); }); - const field = createField({ foo: 111 }, pluginMock); + const field = createField({ aaa: 111 }, pluginMock); - expect(() => field.at('foo')).toThrow(new Error('expected1')); - expect(() => field.at('foo')).toThrow(new Error('expected2')); + expect(() => field.at('aaa')).toThrow(new Error('expected1')); + expect(() => field.at('aaa')).toThrow(new Error('expected2')); }); test('setting field value in a subscriber does not trigger an infinite loop', () => { @@ -387,7 +380,7 @@ describe('createField', () => { field.setValue(333); }); - field.subscribe(subscriberMock); + field.on('*', subscriberMock); field.setValue(222); @@ -395,18 +388,18 @@ describe('createField', () => { expect(subscriberMock).toHaveBeenCalledTimes(2); }); - test('setting field value in a derived field subscriber does not trigger an infinite loop', () => { - const field = createField({ foo: 111 }); + test('setting field value in a child field subscriber does not trigger an infinite loop', () => { + const field = createField({ aaa: 111 }); const subscriberMock = jest.fn(() => { - field.at('foo').setValue(333); + field.at('aaa').setValue(333); }); - field.subscribe(subscriberMock); + field.on('*', subscriberMock); - field.at('foo').setValue(222); + field.at('aaa').setValue(222); - expect(field.value.foo).toBe(333); + expect(field.value.aaa).toBe(333); expect(subscriberMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/roqueform/src/test/naturalAccessor.test.ts b/packages/roqueform/src/test/naturalAccessor.test.ts index cf32b545..de4dc33a 100644 --- a/packages/roqueform/src/test/naturalAccessor.test.ts +++ b/packages/roqueform/src/test/naturalAccessor.test.ts @@ -2,14 +2,14 @@ import { naturalAccessor } from '../main'; describe('naturalAccessor', () => { test('does not read value from primitive values', () => { - expect(naturalAccessor.get(null, 'aaa')).toBe(undefined); - expect(naturalAccessor.get(undefined, 'aaa')).toBe(undefined); - expect(naturalAccessor.get(111, 'toString')).toBe(undefined); - expect(naturalAccessor.get('aaa', 'toString')).toBe(undefined); - expect(naturalAccessor.get(true, 'toString')).toBe(undefined); - expect(naturalAccessor.get(() => undefined, 'length')).toBe(undefined); - expect(naturalAccessor.get(new Date(), 'now')).toBe(undefined); - expect(naturalAccessor.get(new RegExp(''), 'lastIndex')).toBe(undefined); + expect(naturalAccessor.get(null, 'aaa')).toBeUndefined(); + expect(naturalAccessor.get(undefined, 'aaa')).toBeUndefined(); + expect(naturalAccessor.get(111, 'toString')).toBeUndefined(); + expect(naturalAccessor.get('aaa', 'toString')).toBeUndefined(); + expect(naturalAccessor.get(true, 'toString')).toBeUndefined(); + expect(naturalAccessor.get(() => undefined, 'length')).toBeUndefined(); + expect(naturalAccessor.get(new Date(), 'now')).toBeUndefined(); + expect(naturalAccessor.get(new RegExp(''), 'lastIndex')).toBeUndefined(); }); test('reads value from an array', () => { @@ -106,6 +106,6 @@ describe('naturalAccessor', () => { expect(result).toEqual({ aaa: 111, bbb: 222 }); expect(result).not.toBe(obj); - expect(Object.getPrototypeOf(result)).toBe(null); + expect(Object.getPrototypeOf(result)).toBeNull(); }); }); diff --git a/packages/roqueform/src/test/utils.test.ts b/packages/roqueform/src/test/utils.test.ts index 78bd32b3..bce76d86 100644 --- a/packages/roqueform/src/test/utils.test.ts +++ b/packages/roqueform/src/test/utils.test.ts @@ -1,64 +1,19 @@ -import { callAll, callOrGet } from '../main'; - -jest.useFakeTimers(); +import { callOrGet } from '../main'; describe('callOrGet', () => { test('returns non function value as is', () => { const obj = {}; - expect(callOrGet(123)).toBe(123); - expect(callOrGet(null)).toBe(null); - expect(callOrGet(obj)).toBe(obj); + expect(callOrGet(111, undefined)).toBe(111); + expect(callOrGet(null, undefined)).toBeNull(); + expect(callOrGet(obj, undefined)).toBe(obj); }); test('returns the function call result', () => { - expect(callOrGet(() => 123)).toBe(123); + expect(callOrGet(() => 111, undefined)).toBe(111); }); test('passes arguments to a function', () => { - expect(callOrGet((arg1, arg2) => arg1 + arg2, [123, 456])).toBe(579); - }); -}); - -describe('callAll', () => { - test('calls all callbacks with the same set of arguments', () => { - const cbMock1 = jest.fn(); - const cbMock2 = jest.fn(); - const cbMock3 = jest.fn(); - - callAll([cbMock1, cbMock2, cbMock3], ['foo', 'bar']); - - expect(cbMock1).toHaveBeenCalledTimes(1); - expect(cbMock2).toHaveBeenCalledTimes(1); - expect(cbMock3).toHaveBeenCalledTimes(1); - - expect(cbMock1).toHaveBeenNthCalledWith(1, 'foo', 'bar'); - expect(cbMock2).toHaveBeenNthCalledWith(1, 'foo', 'bar'); - expect(cbMock3).toHaveBeenNthCalledWith(1, 'foo', 'bar'); - }); - - test('throws errors asynchronously', () => { - const cbMock1 = jest.fn(() => {}); - const cbMock2 = jest.fn(() => { - throw new Error('expected2'); - }); - const cbMock3 = jest.fn(() => { - throw new Error('expected3'); - }); - - callAll([cbMock1, cbMock2, cbMock3]); - - expect(cbMock1).toHaveBeenCalledTimes(1); - expect(cbMock2).toHaveBeenCalledTimes(1); - expect(cbMock3).toHaveBeenCalledTimes(1); - - expect(() => jest.runAllTimers()).toThrow(); - }); - - test('does not call the same callback twice', () => { - const cbMock1 = jest.fn(() => {}); - - callAll([cbMock1, cbMock1]); - expect(cbMock1).toHaveBeenCalledTimes(1); + expect(callOrGet(arg => arg, 111)).toBe(111); }); }); diff --git a/packages/roqueform/src/test/validationPlugin.test-d.ts b/packages/roqueform/src/test/validationPlugin.test-d.ts index 10e714b0..469f841d 100644 --- a/packages/roqueform/src/test/validationPlugin.test-d.ts +++ b/packages/roqueform/src/test/validationPlugin.test-d.ts @@ -1,4 +1,4 @@ import { expectType } from 'tsd'; -import { Plugin, ValidationPlugin, validationPlugin } from 'roqueform'; +import { PluginInjector, ValidationPlugin, validationPlugin } from 'roqueform'; -expectType>>(validationPlugin(() => undefined)); +expectType>>(validationPlugin(() => undefined)); diff --git a/packages/roqueform/src/test/validationPlugin.test.tsx b/packages/roqueform/src/test/validationPlugin.test.tsx index 70baae65..691a7502 100644 --- a/packages/roqueform/src/test/validationPlugin.test.tsx +++ b/packages/roqueform/src/test/validationPlugin.test.tsx @@ -6,297 +6,334 @@ describe('validationPlugin', () => { }; test('enhances the field', () => { - const field = createField({ foo: 0 }, validationPlugin(noopValidator)); + const field = createField({ aaa: 111 }, validationPlugin(noopValidator)); - expect(field.isValidating).toBe(false); - expect(field.isInvalid).toBe(false); expect(field.error).toBe(null); - - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.isInvalid).toBe(false); + expect(field.isValidating).toBe(false); + expect(field.errorCount).toBe(0); + expect(field.errorOrigin).toBe(0); + expect(field.validator).toBe(noopValidator); + expect(field.validation).toBeNull(); + + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('sets an error to the root field', () => { - const field = createField({ foo: 0 }, validationPlugin(noopValidator)); + const field = createField({ aaa: 111 }, validationPlugin(noopValidator)); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - field.setError(111); + field.setError(222); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(111); + expect(field.error).toBe(222); + expect(field.errorCount).toBe(1); + expect(field.errorOrigin).toBe(2); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).not.toHaveBeenCalled(); + expect(aaaSubscriberMock).not.toHaveBeenCalled(); }); test('sets an error to the child field', () => { - const field = createField({ foo: 0 }, validationPlugin(noopValidator)); + const field = createField({ aaa: 111 }, validationPlugin(noopValidator)); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - field.at('foo').setError(111); + field.at('aaa').setError(222); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); + expect(field.errorCount).toBe(1); + expect(field.errorOrigin).toBe(0); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(222); + expect(field.at('aaa').errorCount).toBe(1); + expect(field.at('aaa').errorOrigin).toBe(2); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); }); - test('sets null as an error to the root field', () => { - const field = createField({ foo: 0 }, validationPlugin(noopValidator)); + test('deletes an error if null is set', () => { + const field = createField({ aaa: 111 }, validationPlugin(noopValidator)); + field.setError(222); field.setError(null); - expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.isInvalid).toBe(false); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('deletes an error from the root field', () => { - const field = createField({ foo: 0 }, validationPlugin(noopValidator)); + const field = createField({ aaa: 111 }, validationPlugin(noopValidator)); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); field.setError(111); field.deleteError(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); expect(subscriberMock).toHaveBeenCalledTimes(2); - expect(fooListenerMock).not.toHaveBeenCalled(); + expect(aaaSubscriberMock).not.toHaveBeenCalled(); }); test('deletes an error from the child field', () => { - const field = createField({ foo: 0 }, validationPlugin(noopValidator)); + const field = createField({ aaa: 111 }, validationPlugin(noopValidator)); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - field.at('foo').setError(111); - field.at('foo').deleteError(); + field.at('aaa').setError(222); + field.at('aaa').deleteError(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); expect(subscriberMock).toHaveBeenCalledTimes(2); - expect(fooListenerMock).toHaveBeenCalledTimes(2); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(2); }); test('deletes an error from the child field but parent remains invalid', () => { - const field = createField({ foo: 0, bar: 'qux' }, validationPlugin(noopValidator)); + const field = createField({ aaa: 111, bbb: 222 }, validationPlugin(noopValidator)); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); - const barListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); + const bbbSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); - field.at('bar').subscribe(barListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); + field.at('bbb').on('*', bbbSubscriberMock); - field.at('foo').setError(111); - field.at('bar').setError(222); + field.at('aaa').setError(333); + field.at('bbb').setError(444); - field.at('bar').deleteError(); + field.at('bbb').deleteError(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(333); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); - expect(barListenerMock).toHaveBeenCalledTimes(2); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(1); + expect(bbbSubscriberMock).toHaveBeenCalledTimes(2); }); test('clears all errors', () => { - const field = createField({ foo: 0, bar: 'qux' }, validationPlugin(noopValidator)); + const field = createField({ aaa: 111, bbb: 222 }, validationPlugin(noopValidator)); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); - const barListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); + const bbbSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); - field.at('bar').subscribe(barListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); + field.at('bbb').on('*', bbbSubscriberMock); - field.at('foo').setError(111); - field.at('bar').setError(222); + field.at('aaa').setError(333); + field.at('bbb').setError(444); field.clearErrors(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); expect(subscriberMock).toHaveBeenCalledTimes(2); - expect(fooListenerMock).toHaveBeenCalledTimes(2); - expect(barListenerMock).toHaveBeenCalledTimes(2); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(2); + expect(bbbSubscriberMock).toHaveBeenCalledTimes(2); }); test('clears errors from nested fields', () => { const field = createField( { - foo: { - bar: { - baz: 'aaa', - qux: 'bbb', + aaa: { + bbb: { + ccc: 111, + ddd: 222, }, }, }, validationPlugin(noopValidator) ); - field.at('foo').at('bar').at('baz').setError(111); - field.at('foo').at('bar').at('qux').setError(111); + field.at('aaa').at('bbb').at('ccc').setError(333); + field.at('aaa').at('bbb').at('ddd').setError(444); field.clearErrors(); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').at('bar').isInvalid).toBe(false); - expect(field.at('foo').at('bar').at('baz').isInvalid).toBe(false); - expect(field.at('foo').at('bar').at('qux').isInvalid).toBe(false); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').at('bbb').isInvalid).toBe(false); + expect(field.at('aaa').at('bbb').at('ccc').isInvalid).toBe(false); + expect(field.at('aaa').at('bbb').at('ddd').isInvalid).toBe(false); }); test('synchronously validates the root field', () => { const field = createField( - { foo: 0 }, + { aaa: 111 }, validationPlugin({ - validate(field, setError) { - setError(field.at('foo'), 111); + validate(field) { + field.at('aaa').setValidationError(field.validation!, 222); }, }) ); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - expect(field.validate()).toEqual([111]); + expect(field.validate()).toBe(false); expect(field.isValidating).toBe(false); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(222); - expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(subscriberMock).toHaveBeenCalledTimes(3); + expect(subscriberMock).toHaveBeenNthCalledWith(1, { + type: 'validation:start', + origin: field, + target: field, + data: undefined, + }); + expect(subscriberMock).toHaveBeenNthCalledWith(2, { + type: 'change:error', + origin: field.at('aaa'), + target: field, + data: null, + }); + expect(subscriberMock).toHaveBeenNthCalledWith(3, { + type: 'validation:end', + origin: field, + target: field, + data: undefined, + }); + + expect(aaaSubscriberMock).toHaveBeenCalledTimes(3); }); test('synchronously validates the root field with a callback validator', () => { const field = createField( - { foo: 0 }, - validationPlugin((field, setError) => { - setError(field.at('foo'), 111); + { aaa: 111 }, + validationPlugin(field => { + field.at('aaa').setValidationError(field.validation!, 222); }) ); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - expect(field.validate()).toEqual([111]); + expect(field.validate()).toBe(false); expect(field.isValidating).toBe(false); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(222); - expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(subscriberMock).toHaveBeenCalledTimes(3); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(3); }); test('synchronously validates the child field', () => { const field = createField( - { foo: 0 }, + { aaa: 111 }, validationPlugin({ - validate(field, setError) { - setError(field, 111); + validate(field) { + field.setValidationError(field.validation!, 222); }, }) ); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - field.at('foo').validate(); + field.at('aaa').validate(); expect(field.isValidating).toBe(false); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(222); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(1); + expect(subscriberMock).toHaveBeenNthCalledWith(1, { + type: 'change:error', + origin: field.at('aaa'), + target: field, + data: null, + }); + + expect(aaaSubscriberMock).toHaveBeenCalledTimes(3); }); test('synchronously validates multiple fields', () => { const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 111, bbb: 222 }, validationPlugin({ - validate(field, setError) { - setError(field.at('foo'), 111); - setError(field.at('bar'), 222); + validate(field) { + field.at('aaa').setValidationError(field.validation!, 333); + field.at('bbb').setValidationError(field.validation!, 444); }, }) ); @@ -304,29 +341,29 @@ describe('validationPlugin', () => { field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(333); - expect(field.at('bar').isInvalid).toBe(true); - expect(field.at('bar').error).toBe(222); + expect(field.at('bbb').isInvalid).toBe(true); + expect(field.at('bbb').error).toBe(444); }); test('clears previous validation errors before validation', () => { const validateMock = jest.fn(); - validateMock.mockImplementationOnce((field, setError) => { - setError(field.at('foo'), 111); - setError(field.at('bar'), 222); + validateMock.mockImplementationOnce(field => { + field.at('aaa').setValidationError(field.validation, 111); + field.at('bbb').setValidationError(field.validation, 222); }); - validateMock.mockImplementationOnce((field, setError) => { - setError(field.at('foo'), 111); + validateMock.mockImplementationOnce(field => { + field.at('aaa').setValidationError(field.validation, 111); }); const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 0, bbb: 'ddd' }, validationPlugin({ validate: validateMock, }) @@ -338,143 +375,143 @@ describe('validationPlugin', () => { expect(validateMock).toHaveBeenCalledTimes(2); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(111); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); }); test('does not clear an error set by the user before validation', () => { const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 0, bbb: 'ddd' }, validationPlugin({ - validate(field, setError) { - setError(field.at('foo'), 111); + validate(field) { + field.at('aaa').setValidationError(field.validation!, 111); }, }) ); - field.at('bar').setError(222); + field.at('bbb').setError(222); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual(111); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual(111); - expect(field.at('bar').isInvalid).toBe(true); - expect(field.at('bar').error).toBe(222); + expect(field.at('bbb').isInvalid).toBe(true); + expect(field.at('bbb').error).toBe(222); }); - test('does not raise validation errors for transient fields', () => { + test('does not set validation errors for transient fields', () => { const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 0, bbb: 'ddd' }, validationPlugin({ - validate(field, setError) { - setError(field.at('foo'), 111); - setError(field.at('bar'), 222); + validate(field) { + field.at('aaa').setValidationError(field.validation!, 111); + field.at('bbb').setValidationError(field.validation!, 222); }, }) ); - field.at('bar').setTransientValue(''); + field.at('bbb').setTransientValue(''); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(111); - expect(field.at('bar').isInvalid).toBe(false); - expect(field.at('bar').error).toBe(null); + expect(field.at('bbb').isInvalid).toBe(false); + expect(field.at('bbb').error).toBeNull(); }); test('asynchronously validates the root field', async () => { const field = createField( - { foo: 0 }, + { aaa: 111 }, validationPlugin({ validate: () => undefined, - async validateAsync(field, setError) { - setError(field.at('foo'), 111); + async validateAsync(field) { + field.at('aaa').setValidationError(field.validation!, 222); }, }) ); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); const promise = field.validateAsync(); expect(promise).toBeInstanceOf(Promise); expect(field.isValidating).toBe(true); - expect(field.at('foo').isValidating).toBe(true); + expect(field.at('aaa').isValidating).toBe(true); - await expect(promise).resolves.toEqual([111]); + await expect(promise).resolves.toEqual(false); expect(field.isValidating).toBe(false); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(222); expect(subscriberMock).toHaveBeenCalledTimes(3); - expect(fooListenerMock).toHaveBeenCalledTimes(3); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(3); }); test('asynchronously validates the child field', async () => { const field = createField( - { foo: 0 }, + { aaa: 111 }, validationPlugin({ validate: () => undefined, - async validateAsync(field, setError) { - setError(field, 111); + async validateAsync(field) { + field.setValidationError(field.validation!, 222); }, }) ); const subscriberMock = jest.fn(); - const fooListenerMock = jest.fn(); + const aaaSubscriberMock = jest.fn(); - field.subscribe(subscriberMock); - field.at('foo').subscribe(fooListenerMock); + field.on('*', subscriberMock); + field.at('aaa').on('*', aaaSubscriberMock); - const promise = field.at('foo').validateAsync(); + const promise = field.at('aaa').validateAsync(); expect(field.isValidating).toBe(false); - expect(field.at('foo').isValidating).toBe(true); + expect(field.at('aaa').isValidating).toBe(true); - await expect(promise).resolves.toEqual([111]); + await expect(promise).resolves.toEqual(false); expect(field.isValidating).toBe(false); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toBe(111); + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toBe(222); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(fooListenerMock).toHaveBeenCalledTimes(3); + expect(aaaSubscriberMock).toHaveBeenCalledTimes(3); }); test('cleans up validation if a sync error is thrown', () => { const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 111, bbb: 222 }, validationPlugin({ validate() { throw new Error('expected'); @@ -482,22 +519,22 @@ describe('validationPlugin', () => { }) ); - field.at('foo'); + field.at('aaa'); expect(() => field.validate()).toThrow(new Error('expected')); expect(field.isValidating).toBe(false); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('cleans up validation if an async error is thrown', async () => { const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 111, bbb: 222 }, validationPlugin({ validate: () => undefined, @@ -507,7 +544,7 @@ describe('validationPlugin', () => { }) ); - field.at('foo'); + field.at('aaa'); const promise = field.validateAsync(); @@ -515,23 +552,23 @@ describe('validationPlugin', () => { expect(field.isValidating).toBe(false); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isValidating).toBe(false); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isValidating).toBe(false); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('aborts validation', async () => { let lastSignal: AbortSignal | undefined; const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 111, bbb: 222 }, validationPlugin({ validate: () => undefined, - async validateAsync(_field, _setError, _context, signal) { - lastSignal = signal; + async validateAsync(field) { + lastSignal = field.validation!.abortController!.signal; }, }) ); @@ -539,27 +576,27 @@ describe('validationPlugin', () => { const promise = field.validateAsync(); expect(field.isValidating).toBe(true); - expect(field.at('foo').isValidating).toBe(true); + expect(field.at('aaa').isValidating).toBe(true); field.abortValidation(); expect(lastSignal!.aborted).toBe(true); expect(field.isValidating).toBe(false); - expect(field.at('foo').isValidating).toBe(false); + expect(field.at('aaa').isValidating).toBe(false); await expect(promise).rejects.toEqual(new Error('Validation aborted')); }); - test('validation aborts pending validation when invoked on the same field', async () => { + test('aborts pending validation when invoked on the same field', async () => { const signals: AbortSignal[] = []; const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 111, bbb: 222 }, validationPlugin({ validate: () => undefined, - async validateAsync(_field, _setError, _context, signal) { - signals.push(signal); + async validateAsync(field) { + signals.push(field.validation!.abortController!.signal); }, }) ); @@ -574,40 +611,43 @@ describe('validationPlugin', () => { await expect(promise).rejects.toEqual(new Error('Validation aborted')); }); - test('derived field validation does not abort the parent validation', () => { + test('child field validation aborts the parent validation', async () => { const signals: AbortSignal[] = []; const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 111, bbb: 222 }, validationPlugin({ validate: () => undefined, - async validateAsync(_field, _setError, _context, signal) { - signals.push(signal); + async validateAsync(field) { + signals.push(field.validation!.abortController!.signal); }, }) ); - field.validateAsync(); - field.at('foo').validateAsync(); + const promise = field.validateAsync(); + const aaaPromise = field.at('aaa').validateAsync(); - expect(signals[0].aborted).toBe(false); + await expect(promise).rejects.toEqual(new Error('Validation aborted')); + await expect(aaaPromise).resolves.toBe(true); + + expect(signals[0].aborted).toBe(true); expect(signals[1].aborted).toBe(false); }); test('does not apply errors from the aborted validation', async () => { const validateAsyncMock = jest.fn(); - validateAsyncMock.mockImplementationOnce((field, setError) => { - return Promise.resolve().then(() => setError(field.at('foo'), 111)); + validateAsyncMock.mockImplementationOnce(async field => { + field.at('aaa').setValidationError(field.validation, 333); }); - validateAsyncMock.mockImplementationOnce((field, setError) => { - return Promise.resolve().then(() => setError(field.at('bar'), 222)); + validateAsyncMock.mockImplementationOnce(async field => { + field.at('bbb').setValidationError(field.validation, 444); }); const field = createField( - { foo: 0, bar: 'qux' }, + { aaa: 111, bbb: 222 }, validationPlugin({ validate: () => undefined, validateAsync: validateAsyncMock, @@ -618,22 +658,22 @@ describe('validationPlugin', () => { await field.validateAsync(); - expect(field.at('foo').error).toBe(null); - expect(field.at('bar').error).toBe(222); + expect(field.at('aaa').error).toBeNull(); + expect(field.at('bbb').error).toBe(444); await expect(promise).rejects.toEqual(new Error('Validation aborted')); }); - test('validation can be called in subscribe', () => { - const field = createField({ foo: 0 }, validationPlugin(noopValidator)); + test('validation can be called in value change subscriber', () => { + const field = createField({ aaa: 111 }, validationPlugin(noopValidator)); const subscriberMock = jest.fn(() => { field.validate(); }); - field.subscribe(subscriberMock); + field.on('change:value', subscriberMock); - field.at('foo').setValue(111); + field.at('aaa').setValue(222); expect(subscriberMock).toHaveBeenCalledTimes(1); }); diff --git a/packages/scroll-to-error-plugin/README.md b/packages/scroll-to-error-plugin/README.md index b6825135..cef1f233 100644 --- a/packages/scroll-to-error-plugin/README.md +++ b/packages/scroll-to-error-plugin/README.md @@ -43,13 +43,13 @@ export const App = () => { event.preventDefault(); if (planetField.validate()) { - // Scroll to the error that is closest to the top left conrner of the document + // The valid form value to submit. + planetField.value; + } else { + // Errors are associated with fields automatically. + // Scroll to the error that is closest to the top left conrner of the document. planetField.scrollToError(0, { behavior: 'smooth' }); - return; } - - // The form value to submit - planetField.value; }; return ( @@ -59,7 +59,7 @@ export const App = () => { {nameField => ( <> { diff --git a/packages/scroll-to-error-plugin/src/test/scrollToErrorPlugin.test.tsx b/packages/scroll-to-error-plugin/src/test/scrollToErrorPlugin.test.tsx index a5e801c0..1a40e429 100644 --- a/packages/scroll-to-error-plugin/src/test/scrollToErrorPlugin.test.tsx +++ b/packages/scroll-to-error-plugin/src/test/scrollToErrorPlugin.test.tsx @@ -26,7 +26,7 @@ class DOMRect { describe('scrollToErrorPlugin', () => { test('returns false if there are no errors', () => { const field = createField( - { foo: 111 }, + { aaa: 111 }, composePlugins( validationPlugin(() => undefined), scrollToErrorPlugin() @@ -38,118 +38,118 @@ describe('scrollToErrorPlugin', () => { test('scrolls to error at index with RTL text direction', async () => { const rootField = createField( - { foo: 111, bar: 'aaa' }, + { aaa: 111, bbb: 222 }, composePlugins( validationPlugin(() => undefined), scrollToErrorPlugin() ) ); - const fooElement = document.body.appendChild(document.createElement('input')); - const barElement = document.body.appendChild(document.createElement('input')); + const aaaElement = document.body.appendChild(document.createElement('input')); + const bbbElement = document.body.appendChild(document.createElement('input')); - rootField.at('foo').ref(fooElement); - rootField.at('bar').ref(barElement); + rootField.at('aaa').ref(aaaElement); + rootField.at('bbb').ref(bbbElement); - jest.spyOn(fooElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(100)); - jest.spyOn(barElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(200)); + jest.spyOn(aaaElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(100)); + jest.spyOn(bbbElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(200)); - const fooScrollIntoViewMock = (fooElement.scrollIntoView = jest.fn()); - const barScrollIntoViewMock = (barElement.scrollIntoView = jest.fn()); + const aaaScrollIntoViewMock = (aaaElement.scrollIntoView = jest.fn()); + const bbbScrollIntoViewMock = (bbbElement.scrollIntoView = jest.fn()); await act(() => { - rootField.at('foo').setError('error1'); - rootField.at('bar').setError('error2'); + rootField.at('aaa').setError('error1'); + rootField.at('bbb').setError('error2'); }); // Scroll to default index rootField.scrollToError(); - expect(fooScrollIntoViewMock).toHaveBeenCalledTimes(1); - expect(barScrollIntoViewMock).not.toHaveBeenCalled(); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).toHaveBeenCalledTimes(1); + expect(bbbScrollIntoViewMock).not.toHaveBeenCalled(); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); // Scroll to 0 rootField.scrollToError(0); - expect(fooScrollIntoViewMock).toHaveBeenCalledTimes(1); - expect(barScrollIntoViewMock).not.toHaveBeenCalled(); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).toHaveBeenCalledTimes(1); + expect(bbbScrollIntoViewMock).not.toHaveBeenCalled(); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); // Scroll to 1 rootField.scrollToError(1); - expect(fooScrollIntoViewMock).not.toHaveBeenCalled(); - expect(barScrollIntoViewMock).toHaveBeenCalledTimes(1); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).not.toHaveBeenCalled(); + expect(bbbScrollIntoViewMock).toHaveBeenCalledTimes(1); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); // Scroll to 2 rootField.scrollToError(2); - expect(fooScrollIntoViewMock).not.toHaveBeenCalled(); - expect(barScrollIntoViewMock).not.toHaveBeenCalled(); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).not.toHaveBeenCalled(); + expect(bbbScrollIntoViewMock).not.toHaveBeenCalled(); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); // Scroll to -1 rootField.scrollToError(1); - expect(fooScrollIntoViewMock).not.toHaveBeenCalled(); - expect(barScrollIntoViewMock).toHaveBeenCalledTimes(1); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).not.toHaveBeenCalled(); + expect(bbbScrollIntoViewMock).toHaveBeenCalledTimes(1); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); // Scroll to -2 rootField.scrollToError(-2); - expect(fooScrollIntoViewMock).toHaveBeenCalledTimes(1); - expect(barScrollIntoViewMock).not.toHaveBeenCalled(); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).toHaveBeenCalledTimes(1); + expect(bbbScrollIntoViewMock).not.toHaveBeenCalled(); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); // Scroll to -3 rootField.scrollToError(-3); - expect(fooScrollIntoViewMock).not.toHaveBeenCalled(); - expect(barScrollIntoViewMock).not.toHaveBeenCalled(); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).not.toHaveBeenCalled(); + expect(bbbScrollIntoViewMock).not.toHaveBeenCalled(); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); }); test('scrolls to error at index with LTR text direction', async () => { const rootField = createField( - { foo: 111, bar: 'aaa' }, + { aaa: 111, bbb: 222 }, composePlugins( validationPlugin(() => undefined), scrollToErrorPlugin() ) ); - const fooElement = document.body.appendChild(document.createElement('input')); - const barElement = document.body.appendChild(document.createElement('input')); + const aaaElement = document.body.appendChild(document.createElement('input')); + const bbbElement = document.body.appendChild(document.createElement('input')); - rootField.at('foo').ref(fooElement); - rootField.at('bar').ref(barElement); + rootField.at('aaa').ref(aaaElement); + rootField.at('bbb').ref(bbbElement); - jest.spyOn(fooElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(100)); - jest.spyOn(barElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(200)); + jest.spyOn(aaaElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(100)); + jest.spyOn(bbbElement, 'getBoundingClientRect').mockImplementation(() => new DOMRect(200)); - const fooScrollIntoViewMock = (fooElement.scrollIntoView = jest.fn()); - const barScrollIntoViewMock = (barElement.scrollIntoView = jest.fn()); + const aaaScrollIntoViewMock = (aaaElement.scrollIntoView = jest.fn()); + const bbbScrollIntoViewMock = (bbbElement.scrollIntoView = jest.fn()); await act(() => { - rootField.at('foo').setError('error1'); - rootField.at('bar').setError('error2'); + rootField.at('aaa').setError('error1'); + rootField.at('bbb').setError('error2'); }); // Scroll to 0 rootField.scrollToError(0, { direction: 'ltr' }); - expect(fooScrollIntoViewMock).not.toHaveBeenCalled(); - expect(barScrollIntoViewMock).toHaveBeenCalledTimes(1); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).not.toHaveBeenCalled(); + expect(bbbScrollIntoViewMock).toHaveBeenCalledTimes(1); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); // Scroll to 1 rootField.scrollToError(1, { direction: 'ltr' }); - expect(fooScrollIntoViewMock).toHaveBeenCalledTimes(1); - expect(barScrollIntoViewMock).not.toHaveBeenCalled(); - fooScrollIntoViewMock.mockClear(); - barScrollIntoViewMock.mockClear(); + expect(aaaScrollIntoViewMock).toHaveBeenCalledTimes(1); + expect(bbbScrollIntoViewMock).not.toHaveBeenCalled(); + aaaScrollIntoViewMock.mockClear(); + bbbScrollIntoViewMock.mockClear(); }); }); diff --git a/packages/uncontrolled-plugin/src/main/uncontrolledPlugin.ts b/packages/uncontrolled-plugin/src/main/uncontrolledPlugin.ts index c5e735ea..347e7131 100644 --- a/packages/uncontrolled-plugin/src/main/uncontrolledPlugin.ts +++ b/packages/uncontrolled-plugin/src/main/uncontrolledPlugin.ts @@ -1,4 +1,4 @@ -import { dispatchEvents, Event as Event_, PluginInjector, Subscriber, Unsubscribe } from 'roqueform'; +import { dispatchEvents, Event, PluginInjector, Subscriber, Unsubscribe } from 'roqueform'; import isDeepEqual from 'fast-deep-equal'; import { createElementValueAccessor, ElementValueAccessor } from './createElementValueAccessor'; @@ -15,7 +15,8 @@ const elementValueAccessor = createElementValueAccessor(); export interface UncontrolledPlugin { /** * The array of elements that are used to derive the field value. Update this array by calling {@link observe} method. - * Elements are observed by the {@link MutationObserver} and deleted from this array when they are removed from DOM. + * Elements are observed by the {@link !MutationObserver MutationObserver} and deleted from this array when they are + * removed from DOM. * * @protected */ @@ -72,7 +73,7 @@ export function uncontrolledPlugin(accessor = elementValueAccessor): PluginInjec field.elementValueAccessor = accessor; const mutationObserver = new MutationObserver(mutations => { - const events: Event_[] = []; + const events: Event[] = []; const { observedElements } = field; const [element] = observedElements; @@ -83,7 +84,9 @@ export function uncontrolledPlugin(accessor = elementValueAccessor): PluginInjec if (elementIndex === -1) { continue; } + const element = observedElements[elementIndex]; + element.removeEventListener('input', changeListener); element.removeEventListener('change', changeListener); @@ -102,7 +105,7 @@ export function uncontrolledPlugin(accessor = elementValueAccessor): PluginInjec dispatchEvents(events); }); - const changeListener = (event: Event): void => { + const changeListener: EventListener = event => { let value; if ( field.observedElements.indexOf(event.target as Element) !== -1 && diff --git a/packages/uncontrolled-plugin/src/test/createElementValueAccessor.test.ts b/packages/uncontrolled-plugin/src/test/createElementValueAccessor.test.ts index 80509c50..fee6be37 100644 --- a/packages/uncontrolled-plugin/src/test/createElementValueAccessor.test.ts +++ b/packages/uncontrolled-plugin/src/test/createElementValueAccessor.test.ts @@ -97,7 +97,7 @@ describe('createElementValueAccessor', () => { const accessor = createElementValueAccessor({ checkboxFormat: 'value' }); expect(accessor.get([createElement('input', { type: 'checkbox', checked: true, value: 'aaa' })])).toBe('aaa'); - expect(accessor.get([createElement('input', { type: 'checkbox', checked: false, value: 'aaa' })])).toBe(null); + expect(accessor.get([createElement('input', { type: 'checkbox', checked: false, value: 'aaa' })])).toBeNull(); }); test('returns an array of values for multiple checkboxes for "value" format', () => { @@ -149,7 +149,7 @@ describe('createElementValueAccessor', () => { }); test('returns null for empty number inputs', () => { - expect(accessor.get([createElement('input', { type: 'number' })])).toBe(null); + expect(accessor.get([createElement('input', { type: 'number' })])).toBeNull(); }); test('returns default value for empty range inputs', () => { @@ -167,11 +167,11 @@ describe('createElementValueAccessor', () => { }); test('returns null for empty date inputs by default', () => { - expect(accessor.get([createElement('input', { type: 'date' })])).toBe(null); + expect(accessor.get([createElement('input', { type: 'date' })])).toBeNull(); }); test('returns null for empty datetime-local inputs by default', () => { - expect(accessor.get([createElement('input', { type: 'date' })])).toBe(null); + expect(accessor.get([createElement('input', { type: 'date' })])).toBeNull(); }); test('returns input value for date inputs for "value" format', () => { @@ -235,7 +235,7 @@ describe('createElementValueAccessor', () => { }); test('returns null for empty time inputs by default', () => { - expect(accessor.get([createElement('input', { type: 'time' })])).toBe(null); + expect(accessor.get([createElement('input', { type: 'time' })])).toBeNull(); }); test('returns value for time inputs by default', () => { @@ -259,7 +259,7 @@ describe('createElementValueAccessor', () => { }); test('returns null for empty file inputs', () => { - expect(accessor.get([createElement('input', { type: 'file' })])).toBe(null); + expect(accessor.get([createElement('input', { type: 'file' })])).toBeNull(); }); test('returns an array for empty multi-file inputs', () => { @@ -281,7 +281,7 @@ describe('createElementValueAccessor', () => { accessor.set([element], 'aaa'); - expect(element.value).toBe(undefined); + expect(element.value).toBeUndefined(); }); test('sets checkboxes checked state from an array of booleans', () => { diff --git a/packages/uncontrolled-plugin/src/test/uncontrolledPlugin.test.ts b/packages/uncontrolled-plugin/src/test/uncontrolledPlugin.test.ts index 5620e377..a5341703 100644 --- a/packages/uncontrolled-plugin/src/test/uncontrolledPlugin.test.ts +++ b/packages/uncontrolled-plugin/src/test/uncontrolledPlugin.test.ts @@ -1,5 +1,5 @@ import { ElementValueAccessor, uncontrolledPlugin } from '../main'; -import { composePlugins, createField, naturalAccessor } from 'roqueform'; +import { composePlugins, createField } from 'roqueform'; import { fireEvent } from '@testing-library/dom'; describe('uncontrolledPlugin', () => { @@ -15,34 +15,34 @@ describe('uncontrolledPlugin', () => { test('updates field value on input change', () => { const subscriberMock = jest.fn(); - const field = createField({ foo: 0 }, uncontrolledPlugin()); + const field = createField({ aaa: 111 }, uncontrolledPlugin()); element.type = 'number'; - field.subscribe(subscriberMock); - field.at('foo').ref(element); + field.on('*', subscriberMock); + field.at('aaa').observe(element); - fireEvent.change(element, { target: { value: '111' } }); + fireEvent.change(element, { target: { value: '222' } }); expect(subscriberMock).toHaveBeenCalledTimes(1); - expect(field.value).toEqual({ foo: 111 }); + expect(field.value).toEqual({ aaa: 222 }); }); test('updates input value on field change', () => { - const field = createField({ foo: 0 }, uncontrolledPlugin()); + const field = createField({ aaa: 111 }, uncontrolledPlugin()); - field.at('foo').ref(element); - field.at('foo').setValue(111); + field.at('aaa').observe(element); + field.at('aaa').setValue(222); - expect(element.value).toBe('111'); + expect(element.value).toBe('222'); }); test('sets the initial value to the element', () => { - const field = createField({ foo: 111 }, uncontrolledPlugin()); + const field = createField({ aaa: 111 }, uncontrolledPlugin()); element.type = 'number'; - field.at('foo').ref(element); + field.at('aaa').observe(element); expect(element.value).toBe('111'); }); @@ -53,14 +53,14 @@ describe('uncontrolledPlugin', () => { field.ref = refMock; }); - const field = createField({ foo: 111 }, composePlugins(pluginMock, uncontrolledPlugin())); + const field = createField({ aaa: 111 }, composePlugins(pluginMock, uncontrolledPlugin())); expect(pluginMock).toHaveBeenCalledTimes(1); - expect(pluginMock).toHaveBeenNthCalledWith(1, field, naturalAccessor, expect.any(Function)); + expect(pluginMock).toHaveBeenNthCalledWith(1, field); expect(refMock).not.toHaveBeenCalled(); - field.at('foo').ref(element); + field.at('aaa').observe(element); expect(refMock).toHaveBeenCalledTimes(1); expect(refMock).toHaveBeenNthCalledWith(1, element); @@ -75,10 +75,10 @@ describe('uncontrolledPlugin', () => { const element1 = document.body.appendChild(document.createElement('input')); const element2 = document.body.appendChild(document.createElement('input')); - const field = createField({ foo: 111 }, composePlugins(plugin, uncontrolledPlugin())); + const field = createField({ aaa: 111 }, composePlugins(plugin, uncontrolledPlugin())); - field.at('foo').ref(element1); - field.at('foo').ref(element2); + field.at('aaa').observe(element1); + field.at('aaa').observe(element2); expect(refMock).toHaveBeenCalledTimes(1); expect(refMock).toHaveBeenNthCalledWith(1, element1); @@ -93,10 +93,10 @@ describe('uncontrolledPlugin', () => { const element1 = document.body.appendChild(document.createElement('input')); const element2 = document.body.appendChild(document.createElement('textarea')); - const field = createField({ foo: 111 }, composePlugins(plugin, uncontrolledPlugin())); + const field = createField({ aaa: 111 }, composePlugins(plugin, uncontrolledPlugin())); - field.at('foo').ref(element1); - field.at('foo').ref(element2); + field.at('aaa').observe(element1); + field.at('aaa').observe(element2); expect(refMock).toHaveBeenCalledTimes(1); expect(refMock).toHaveBeenNthCalledWith(1, element1); @@ -116,9 +116,9 @@ describe('uncontrolledPlugin', () => { field.ref = refMock; }; - const field = createField({ foo: 111 }, composePlugins(plugin, uncontrolledPlugin())); + const field = createField({ aaa: 111 }, composePlugins(plugin, uncontrolledPlugin())); - field.at('foo').ref(element); + field.at('aaa').observe(element); element.remove(); @@ -136,9 +136,9 @@ describe('uncontrolledPlugin', () => { field.ref = refMock; }; - const field = createField({ foo: 111 }, composePlugins(plugin, uncontrolledPlugin())); + const field = createField({ aaa: 111 }, composePlugins(plugin, uncontrolledPlugin())); - field.at('foo').ref(null); + field.at('aaa').observe(null); expect(refMock).not.toHaveBeenCalled(); }); @@ -153,7 +153,7 @@ describe('uncontrolledPlugin', () => { const setValueMock = (field.setValue = jest.fn(field.setValue)); - field.ref(element); + field.observe(element); expect(accessorMock.set).toHaveBeenCalledTimes(1); expect(accessorMock.set).toHaveBeenNthCalledWith(1, [element], 'aaa'); @@ -176,17 +176,17 @@ describe('uncontrolledPlugin', () => { set: jest.fn(), }; - const field = createField({ foo: 'aaa' }, uncontrolledPlugin(accessorMock)); + const field = createField({ aaa: 111 }, uncontrolledPlugin(accessorMock)); - field.at('foo').ref(element); + field.at('aaa').observe(element); expect(accessorMock.set).toHaveBeenCalledTimes(1); - expect(accessorMock.set).toHaveBeenNthCalledWith(1, [element], 'aaa'); + expect(accessorMock.set).toHaveBeenNthCalledWith(1, [element], 111); - field.at('foo').setValue('bbb'); + field.at('aaa').setValue(222); expect(accessorMock.set).toHaveBeenCalledTimes(2); - expect(accessorMock.set).toHaveBeenNthCalledWith(2, [element], 'bbb'); + expect(accessorMock.set).toHaveBeenNthCalledWith(2, [element], 222); }); test('does not call set accessor if there are no referenced elements', () => { @@ -195,9 +195,9 @@ describe('uncontrolledPlugin', () => { set: jest.fn(), }; - const field = createField({ foo: 'aaa' }, uncontrolledPlugin(accessorMock)); + const field = createField({ aaa: 111 }, uncontrolledPlugin(accessorMock)); - field.at('foo').setValue('bbb'); + field.at('aaa').setValue(222); expect(accessorMock.set).not.toHaveBeenCalled(); }); @@ -211,14 +211,14 @@ describe('uncontrolledPlugin', () => { const element1 = document.body.appendChild(document.createElement('input')); const element2 = document.body.appendChild(document.createElement('textarea')); - const field = createField({ foo: 111 }, uncontrolledPlugin(accessorMock)); + const field = createField({ aaa: 111 }, uncontrolledPlugin(accessorMock)); - field.at('foo').ref(element1); + field.at('aaa').observe(element1); expect(accessorMock.set).toHaveBeenCalledTimes(1); expect(accessorMock.set).toHaveBeenNthCalledWith(1, [element1], 111); - field.at('foo').ref(element2); + field.at('aaa').observe(element2); expect(accessorMock.set).toHaveBeenCalledTimes(2); expect(accessorMock.set).toHaveBeenNthCalledWith(2, [element1, element2], 111); @@ -232,9 +232,9 @@ describe('uncontrolledPlugin', () => { const element = document.createElement('input'); - const field = createField({ foo: 111 }, uncontrolledPlugin(accessorMock)); + const field = createField({ aaa: 111 }, uncontrolledPlugin(accessorMock)); - field.at('foo').ref(element); + field.at('aaa').observe(element); expect(accessorMock.set).toHaveBeenCalledTimes(0); }); @@ -242,9 +242,9 @@ describe('uncontrolledPlugin', () => { test('mutation observer disconnects after last element is removed', done => { const disconnectMock = jest.spyOn(MutationObserver.prototype, 'disconnect'); - const field = createField({ foo: 111 }, uncontrolledPlugin()); + const field = createField({ aaa: 111 }, uncontrolledPlugin()); - field.at('foo').ref(element); + field.at('aaa').observe(element); element.remove(); diff --git a/packages/zod-plugin/README.md b/packages/zod-plugin/README.md index 98f4262a..b318fd1b 100644 --- a/packages/zod-plugin/README.md +++ b/packages/zod-plugin/README.md @@ -27,13 +27,12 @@ export const App = () => { event.preventDefault(); if (planetField.validate()) { - // Errors are associated with fields automatically - return; + // If your shapes have transformations or refinements, you can safely parse + // the field value after it was successfully validated. + const value = planetSchema.parse(planetField.value); + } else { + // Errors are associated with fields automatically. } - - // If your shapes have transformations or refinements, you can safely parse - // the field value after it was successfully validated - const value = planetSchema.parse(planetField.value); }; return ( diff --git a/packages/zod-plugin/package.json b/packages/zod-plugin/package.json index eb1e90ed..4cc9aa7f 100644 --- a/packages/zod-plugin/package.json +++ b/packages/zod-plugin/package.json @@ -20,7 +20,8 @@ "scripts": { "build": "rollup --config ../../rollup.config.js", "clean": "rimraf lib", - "test": "jest --config ../../jest.config.js" + "test": "jest --config ../../jest.config.js", + "test:definitions": "tsd --files './src/test/**/*.test-d.ts'" }, "repository": { "type": "git", diff --git a/packages/zod-plugin/src/main/zodPlugin.ts b/packages/zod-plugin/src/main/zodPlugin.ts index 71a2c7db..1ff30b5f 100644 --- a/packages/zod-plugin/src/main/zodPlugin.ts +++ b/packages/zod-plugin/src/main/zodPlugin.ts @@ -66,7 +66,7 @@ function getValue(field: Field): unknown { while (field.parent !== null) { transient ||= field.isTransient; - value = transient ? field.accessor.set(field.parent.value, field.key, value) : field.parent.value; + value = transient ? field.valueAccessor.set(field.parent.value, field.key, value) : field.parent.value; field = field.parent; } return value; diff --git a/packages/zod-plugin/src/test/zodPlugin.test-d.ts b/packages/zod-plugin/src/test/zodPlugin.test-d.ts index 4ab0429e..3c7a2e1f 100644 --- a/packages/zod-plugin/src/test/zodPlugin.test-d.ts +++ b/packages/zod-plugin/src/test/zodPlugin.test-d.ts @@ -5,6 +5,4 @@ import { ZodPlugin, zodPlugin } from '@roqueform/zod-plugin'; const shape = z.object({ foo: z.object({ bar: z.string() }) }); -expectType & ZodPlugin>( - createField({ foo: { bar: 'aaa' } }, zodPlugin(shape)) -); +expectType>(createField({ foo: { bar: 'aaa' } }, zodPlugin(shape))); diff --git a/packages/zod-plugin/src/test/zodPlugin.test.tsx b/packages/zod-plugin/src/test/zodPlugin.test.tsx index 771d0a37..8cca31ec 100644 --- a/packages/zod-plugin/src/test/zodPlugin.test.tsx +++ b/packages/zod-plugin/src/test/zodPlugin.test.tsx @@ -3,204 +3,181 @@ import { zodPlugin } from '../main'; import { createField } from 'roqueform'; describe('zodPlugin', () => { - const fooType = z.object({ - foo: z.number().gte(3), + const aaaType = z.object({ + aaa: z.number().gte(3), }); - const fooBarType = z.object({ - foo: z.number().min(3), - bar: z.string().max(2), + const aaaBbbType = z.object({ + aaa: z.number().min(3), + bbb: z.string().max(2), }); test('enhances the field', () => { - const field = createField({ foo: 0 }, zodPlugin(fooType)); + const field = createField({ aaa: 111 }, zodPlugin(aaaType)); expect(field.isInvalid).toBe(false); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toBe(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toBeNull(); }); test('converts string errors to issue messages', () => { - const field = createField({ foo: 0 }, zodPlugin(fooType)); + const field = createField({ aaa: 111 }, zodPlugin(aaaType)); - field.setError('aaa'); + field.setError('xxx'); - expect(field.error).toEqual({ code: ZodIssueCode.custom, message: 'aaa', path: [] }); + expect(field.error).toEqual({ code: ZodIssueCode.custom, message: 'xxx', path: [] }); }); test('sets issue as an error', () => { - const field = createField({ foo: 0 }, zodPlugin(fooType)); + const field = createField({ aaa: 111 }, zodPlugin(aaaType)); const issue: ZodIssue = { code: ZodIssueCode.custom, path: ['bbb'], message: 'aaa' }; - field.at('foo').setError(issue); + field.at('aaa').setError(issue); - expect(field.at('foo').error).toBe(issue); - expect(field.at('foo').error).toEqual({ code: ZodIssueCode.custom, message: 'aaa', path: ['bbb'] }); + expect(field.at('aaa').error).toBe(issue); + expect(field.at('aaa').error).toEqual({ code: ZodIssueCode.custom, message: 'aaa', path: ['bbb'] }); }); test('validates the root field', () => { - const field = createField({ foo: 0 }, zodPlugin(fooType)); + const field = createField({ aaa: 0 }, zodPlugin(aaaType)); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'too_small', exact: false, inclusive: true, message: 'Number must be greater than or equal to 3', minimum: 3, - path: ['foo'], + path: ['aaa'], type: 'number', }); }); test('validates the child field', () => { - const field = createField({ foo: 0 }, zodPlugin(fooType)); + const field = createField({ aaa: 0 }, zodPlugin(aaaType)); - field.at('foo').validate(); + field.at('aaa').validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'too_small', exact: false, inclusive: true, message: 'Number must be greater than or equal to 3', minimum: 3, - path: ['foo'], + path: ['aaa'], type: 'number', }); }); test('validates multiple fields', () => { - const field = createField({ foo: 0, bar: 'qux' }, zodPlugin(fooBarType)); + const field = createField({ aaa: 0, bbb: 'ccc' }, zodPlugin(aaaBbbType)); field.validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'too_small', exact: false, inclusive: true, message: 'Number must be greater than or equal to 3', minimum: 3, - path: ['foo'], + path: ['aaa'], type: 'number', }); - expect(field.at('bar').isInvalid).toBe(true); - expect(field.at('bar').error).toEqual({ + expect(field.at('bbb').isInvalid).toBe(true); + expect(field.at('bbb').error).toEqual({ code: 'too_big', exact: false, inclusive: true, maximum: 2, message: 'String must contain at most 2 character(s)', - path: ['bar'], + path: ['bbb'], type: 'string', }); }); test('does not validate sibling fields', () => { - const field = createField({ foo: 0, bar: 'qux' }, zodPlugin(fooBarType)); + const field = createField({ aaa: 111, bbb: 'ccc' }, zodPlugin(aaaBbbType)); - field.at('bar').validate(); + field.at('bbb').validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toEqual(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toEqual(null); - expect(field.at('bar').isInvalid).toBe(true); - expect(field.at('bar').error).toEqual({ + expect(field.at('bbb').isInvalid).toBe(true); + expect(field.at('bbb').error).toEqual({ code: 'too_big', exact: false, inclusive: true, maximum: 2, message: 'String must contain at most 2 character(s)', - path: ['bar'], + path: ['bbb'], type: 'string', }); }); test('validates a transient value', () => { - const field = createField({ foo: 0, bar: '' }, zodPlugin(fooBarType)); + const field = createField({ aaa: 111, bbb: '' }, zodPlugin(aaaBbbType)); - field.at('bar').setTransientValue('qux'); - field.at('bar').validate(); + field.at('bbb').setTransientValue('ccc'); + field.at('bbb').validate(); expect(field.isInvalid).toBe(true); - expect(field.error).toBe(null); + expect(field.error).toBeNull(); - expect(field.at('foo').isInvalid).toBe(false); - expect(field.at('foo').error).toEqual(null); + expect(field.at('aaa').isInvalid).toBe(false); + expect(field.at('aaa').error).toEqual(null); - expect(field.at('bar').isInvalid).toBe(true); - expect(field.at('bar').error).toEqual({ + expect(field.at('bbb').isInvalid).toBe(true); + expect(field.at('bbb').error).toEqual({ code: 'too_big', exact: false, inclusive: true, maximum: 2, message: 'String must contain at most 2 character(s)', - path: ['bar'], + path: ['bbb'], type: 'string', }); }); - test('uses errorMap passed to createField', () => { - const errorMapMock: ZodErrorMap = jest.fn(() => { - return { message: 'aaa' }; - }); - - const field = createField({ foo: 0, bar: '' }, zodPlugin(fooBarType, errorMapMock)); - - field.validate(); - - expect(errorMapMock).toHaveBeenCalledTimes(1); - - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ - code: 'too_small', - exact: false, - inclusive: true, - message: 'aaa', - minimum: 3, - path: ['foo'], - type: 'number', - }); - }); - test('uses errorMap passed to validate method', () => { const errorMapMock: ZodErrorMap = jest.fn(() => { return { message: 'aaa' }; }); - const field = createField({ foo: 0, bar: '' }, zodPlugin(fooBarType)); + const field = createField({ aaa: 0, bbb: '' }, zodPlugin(aaaBbbType)); field.validate({ errorMap: errorMapMock }); expect(errorMapMock).toHaveBeenCalledTimes(1); - expect(field.at('foo').isInvalid).toBe(true); - expect(field.at('foo').error).toEqual({ + expect(field.at('aaa').isInvalid).toBe(true); + expect(field.at('aaa').error).toEqual({ code: 'too_small', exact: false, inclusive: true, message: 'aaa', minimum: 3, - path: ['foo'], + path: ['aaa'], type: 'number', }); }); diff --git a/typedoc.json b/typedoc.json index 2a9557b8..2fc8f2c6 100644 --- a/typedoc.json +++ b/typedoc.json @@ -34,6 +34,7 @@ "Promise": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise" }, "global": { + "MutationObserver": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/MutationObserver", "Date": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date", "File": "https://developer.mozilla.org/en-US/docs/Web/API/File" }