Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add suite.subscribe method #1080

Merged
merged 2 commits into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/vest-utils/src/__tests__/bus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]);
});
});
});
16 changes: 10 additions & 6 deletions packages/vest-utils/src/bus.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import type { CB } from 'utilityTypes';

const EVENT_WILDCARD = '*';

export function createBus(): BusType {
const listeners: Record<string, CB[]> = {};

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] || [];
}
}
Expand Down
15 changes: 12 additions & 3 deletions packages/vest/src/core/VestBus/VestBus.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CB } from 'vest-utils';
import { Bus } from 'vestjs-runtime';

import { Events } from 'BusEvents';
Expand All @@ -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();

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/vest/src/suite/SuiteTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ export type SuiteMethods<F extends TFieldName, G extends TGroupName> = {
reset: CB<void>;
remove: CB<void, [fieldName: F]>;
resetField: CB<void, [fieldName: F]>;
subscribe: (cb: CB) => CB<void>;
} & TTypedMethods<F, G> &
SuiteSelectors<F, G>;
73 changes: 73 additions & 0 deletions packages/vest/src/suite/__tests__/subscribe.test.ts
Original file line number Diff line number Diff line change
@@ -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<undefined>());
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);
});
});
});
4 changes: 3 additions & 1 deletion packages/vest/src/suite/createSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -80,6 +81,7 @@ function createSuite<
reset: Bus.usePrepareEmitter(Events.RESET_SUITE),
resetField: Bus.usePrepareEmitter<string>(Events.RESET_FIELD),
resume: VestRuntime.persist(useLoadSuite),
subscribe: VestBus.subscribe,
...bindSuiteSelectors<F, G>(VestRuntime.persist(useCreateSuiteResult)),
...getTypedMethods<F, G>(),
}
Expand Down
2 changes: 2 additions & 0 deletions website/docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ keywords:
promisify,
compose,
staticSuite,
subscrib,
]
---

Expand All @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions website/docs/writing_your_suite/vests_suite.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```
Loading