diff --git a/packages/vest-utils/src/__tests__/bus.test.ts b/packages/vest-utils/src/__tests__/bus.test.ts index f5105a793..48d3980d7 100644 --- a/packages/vest-utils/src/__tests__/bus.test.ts +++ b/packages/vest-utils/src/__tests__/bus.test.ts @@ -78,4 +78,50 @@ describe('bus', () => { expect(spy1).not.toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); }); + + describe('"ANY" wildcard (*)', () => { + it('Should run the wildcard handler on any event', () => { + const bus = createBus(); + const spy1 = jest.fn(); + const spy2 = jest.fn(); + const spy3 = jest.fn(); + bus.on('t1', spy1); + bus.on('t2', spy2); + bus.on('*', spy3); + expect(spy1).not.toHaveBeenCalled(); + expect(spy2).not.toHaveBeenCalled(); + expect(spy3).not.toHaveBeenCalled(); + bus.emit('t1'); + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(0); + expect(spy3).toHaveBeenCalledTimes(1); + bus.emit('t2'); + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(1); + expect(spy3).toHaveBeenCalledTimes(2); + }); + + it('Should call the wildcard last, regardless of when it was defined', () => { + const bus = createBus(); + const spy1 = jest.fn(); + const spy2 = jest.fn(); + const spy3 = jest.fn(); + const spy4 = jest.fn(); + bus.on('t1', spy1); + bus.on('*', spy4); + bus.on('t1', spy2); + bus.on('t1', spy3); + bus.emit('t1'); + const invocations = [spy1, spy2, spy3, spy4] + .map(i => i.mock.invocationCallOrder[0]) + .sort(); + + expect(invocations).toEqual([ + spy1.mock.invocationCallOrder[0], + spy2.mock.invocationCallOrder[0], + spy3.mock.invocationCallOrder[0], + spy4.mock.invocationCallOrder[0], + ]); + }); + }); }); diff --git a/packages/vest-utils/src/bus.ts b/packages/vest-utils/src/bus.ts index 3c04c3482..875e37007 100644 --- a/packages/vest-utils/src/bus.ts +++ b/packages/vest-utils/src/bus.ts @@ -1,27 +1,31 @@ import type { CB } from 'utilityTypes'; +const EVENT_WILDCARD = '*'; + export function createBus(): BusType { const listeners: Record = {}; return { emit(event: string, data?: any) { - listener(event).forEach(handler => { - handler(data); - }); + getListeners(event) + .concat(getListeners(EVENT_WILDCARD)) + .forEach(handler => { + handler(data); + }); }, on(event: string, handler: CB): OnReturn { - listeners[event] = listener(event).concat(handler); + listeners[event] = getListeners(event).concat(handler); return { off() { - listeners[event] = listener(event).filter(h => h !== handler); + listeners[event] = getListeners(event).filter(h => h !== handler); }, }; }, }; - function listener(event: string): CB[] { + function getListeners(event: string): CB[] { return listeners[event] || []; } } diff --git a/packages/vest/src/core/VestBus/VestBus.ts b/packages/vest/src/core/VestBus/VestBus.ts index 98343771e..4299347bf 100644 --- a/packages/vest/src/core/VestBus/VestBus.ts +++ b/packages/vest/src/core/VestBus/VestBus.ts @@ -1,3 +1,4 @@ +import { CB } from 'vest-utils'; import { Bus } from 'vestjs-runtime'; import { Events } from 'BusEvents'; @@ -13,7 +14,7 @@ import { VestTest } from 'VestTest'; import { useOmitOptionalFields } from 'omitOptionalFields'; import { useRunDoneCallbacks, useRunFieldCallbacks } from 'runCallbacks'; -// eslint-disable-next-line max-statements +// eslint-disable-next-line max-statements, max-lines-per-function export function useInitVestBus() { const VestBus = Bus.useBus(); @@ -74,9 +75,17 @@ export function useInitVestBus() { useResetSuite(); }); - return VestBus; + return { + subscribe, + }; - function on(event: Events, cb: (...args: any[]) => void) { + function subscribe(cb: CB) { + return VestBus.on('*', () => { + cb(); + }).off; + } + + function on(event: Events | '*', cb: (...args: any[]) => void) { VestBus.on(event, (...args: any[]) => { // This is more concise, but it might be an overkill // if we're adding events that don't need to invalidate the cache diff --git a/packages/vest/src/suite/SuiteTypes.ts b/packages/vest/src/suite/SuiteTypes.ts index 859e33cfb..1178960f2 100644 --- a/packages/vest/src/suite/SuiteTypes.ts +++ b/packages/vest/src/suite/SuiteTypes.ts @@ -23,5 +23,6 @@ export type SuiteMethods = { reset: CB; remove: CB; resetField: CB; + subscribe: (cb: CB) => CB; } & TTypedMethods & SuiteSelectors; diff --git a/packages/vest/src/suite/__tests__/subscribe.test.ts b/packages/vest/src/suite/__tests__/subscribe.test.ts new file mode 100644 index 000000000..a463fe71c --- /dev/null +++ b/packages/vest/src/suite/__tests__/subscribe.test.ts @@ -0,0 +1,73 @@ +import { enforce } from 'n4s'; +import wait from 'wait'; + +import { SuiteSerializer } from 'SuiteSerializer'; +import * as vest from 'vest'; + +describe('suite.subscribe', () => { + it('Should be a function', () => { + const suite = vest.create('suite', () => {}); + + expect(typeof suite.subscribe).toBe('function'); + }); + + it('Should call the callback on suite updates', async () => { + const cb = jest.fn(() => { + dumps.push(SuiteSerializer.serialize(suite)); + }); + let callCount = cb.mock.calls.length; + + const suite = vest.create('suite', () => { + expect(cb.mock.calls.length).toBeGreaterThan(callCount); + callCount = cb.mock.calls.length; + vest.test('field', () => {}); + expect(cb.mock.calls.length).toBeGreaterThan(callCount); + callCount = cb.mock.calls.length; + vest.test('field2', () => {}); + expect(cb.mock.calls.length).toBeGreaterThan(callCount); + callCount = cb.mock.calls.length; + vest.test('field3', () => false); + expect(cb.mock.calls.length).toBeGreaterThan(callCount); + callCount = cb.mock.calls.length; + vest.test('field4', async () => Promise.reject()); + expect(cb.mock.calls.length).toBeGreaterThan(callCount); + callCount = cb.mock.calls.length; + }); + + const dumps: string[] = []; + + suite.subscribe(cb); + expect(cb.mock.calls).toHaveLength(0); + suite(); + expect(cb.mock.calls.length).toBeGreaterThan(callCount); + callCount = cb.mock.calls.length; + + // expect some of the dumps to be different + expect(dumps.some((dump, i) => dump !== dumps[i - 1])).toBe(true); + + await wait(10); + + // now also after resolving the async test + expect(cb.mock.calls.length).toBeGreaterThan(callCount); + }); + + describe('unsubscribe', () => { + it('Should unsubscribe future events', () => { + const cb = jest.fn(); + const suite = vest.create('suite', () => { + vest.test('field', () => {}); + }); + + const unsubscribe = suite.subscribe(cb); + suite(); + let callCount = cb.mock.calls.length; + enforce(callCount).greaterThan(1); + suite(); + enforce(cb.mock.calls.length).greaterThan(callCount); + callCount = cb.mock.calls.length; + unsubscribe(); + suite(); + enforce(cb.mock.calls.length).equals(callCount); + }); + }); +}); diff --git a/packages/vest/src/suite/createSuite.ts b/packages/vest/src/suite/createSuite.ts index b01644b86..30d7c43c1 100644 --- a/packages/vest/src/suite/createSuite.ts +++ b/packages/vest/src/suite/createSuite.ts @@ -65,7 +65,8 @@ function createSuite< // We do this within the VestRuntime so that the suite methods // will be bound to the suite's stateRef and be able to access it. return VestRuntime.Run(stateRef, () => { - useInitVestBus(); + // @vx-allow use-use + const VestBus = useInitVestBus(); return assign( // We're also binding the suite to the stateRef, so that the suite @@ -80,6 +81,7 @@ function createSuite< reset: Bus.usePrepareEmitter(Events.RESET_SUITE), resetField: Bus.usePrepareEmitter(Events.RESET_FIELD), resume: VestRuntime.persist(useLoadSuite), + subscribe: VestBus.subscribe, ...bindSuiteSelectors(VestRuntime.persist(useCreateSuiteResult)), ...getTypedMethods(), } diff --git a/website/docs/api_reference.md b/website/docs/api_reference.md index 79ea419d7..80258d487 100644 --- a/website/docs/api_reference.md +++ b/website/docs/api_reference.md @@ -44,6 +44,7 @@ keywords: promisify, compose, staticSuite, + subscrib, ] --- @@ -59,6 +60,7 @@ Below is a list of all the API functions exposed by Vest. - [suite.remove](./writing_your_suite/vests_suite.md#removing-a-single-field-from-the-suite-state) - Removes a single field from the suite. - [suite.reset](./writing_your_suite/vests_suite.md#cleaning-up-our-validation-state) - Resets the suite to its initial state. - [suite.resetField](./writing_your_suite/vests_suite.md#cleaning-up-our-validation-state) - Resets a single field to an untested state. + - [suite.subscribe](./writing_your_suite/vests_suite.md#subscribing-to-suite-state-changes) - Subscribes to suite state changes. - [staticSuite](./server_side_validations.md) - creates a stateless suite that is used for server side validations. diff --git a/website/docs/writing_your_suite/vests_suite.md b/website/docs/writing_your_suite/vests_suite.md index a521b3312..9c5b54bd3 100644 --- a/website/docs/writing_your_suite/vests_suite.md +++ b/website/docs/writing_your_suite/vests_suite.md @@ -87,3 +87,22 @@ To reset the validity of a single field, you can call `suite.resetField(fieldNam In some cases, you may want to remove a field from the suite state. For example, when the user removes a dynamically added field. In this case, you can call `suite.remove(fieldName)` to remove the field from the state and cancel any pending async validations that might still be running. Note that you don't need to use `suite.remove` very often, as most users can simply use `reset` and `omitWhen`. + +## Subscribing to Suite State Changes + +You can subscribe to changes in the suite state by calling `suite.subscribe(callback)`. The callback will be called whenever the suite state changes internally. + +```js +suite.subscribe(() => { + const result = suite.get(); + // ... Do something with the result +}); +``` + +### Unsubscribing from Suite State Changes + +The `subscribe` method returns a function that you can call to unsubscribe from the suite state changes: + +```js +const unsubscribe = suite.subscribe(); +```