diff --git a/src/split-map/index.ts b/src/split-map/index.ts index 32bf9818..7fa3266f 100644 --- a/src/split-map/index.ts +++ b/src/split-map/index.ts @@ -1,36 +1,145 @@ -import { Event, is, Store, Unit } from 'effector'; +import { Event, Tuple, Unit, UnitTargetable, is, sample } from 'effector'; + +type TargetUnits = + | UnitTargetable + | Tuple> + | ReadonlyArray>; + +const hasPropBase = {}.hasOwnProperty; +const hasOwnProp = (object: O, key: string) => + hasPropBase.call(object, key); + +/** + * Split `source` unit into multiple events based on the provided `cases`. + * + * @param source - Source unit, data from this unit is passed to each function in cases object and `__` event in shape as is + * @param cases - Object of functions. Function receives one argument is a payload from `source`, should return any value or `undefined`. + * If `undefined` is returned from the case function, update will be skipped (the event will not be triggered). + * + * @param targets (optional) - Object of units to trigger on corresponding event from cases object + * @returns Object of events, with the same structure as `cases`, but with the default event `__`, that will be triggered when each other function returns `undefined` + * + * @example + * ```ts + * const dataFetched = createEvent() + * + * const dataReceived = splitMap({ + * source: dataFetched, + * cases: { + * isString: (payload) => { + * if (typeof payload === 'string') return payload + * }, + * isNumber: (payload) => { + * if (typeof payload === 'number') return payload + * }, + * } + * }) + + * dataReceived.isString // Event + * dataReceived.isNumber // Event + * dataReceived.__ // Event + * ``` + * + * @example + * ```ts + * const dataFetched = createEvent() + * const stringReceived = createEvent() + * const numberReceived = createEvent() + * const unknownReceived = createEvent() + * const notifyError = createEvent() + * + * const dataReceived = splitMap({ + * source: dataFetched, + * cases: { + * isString: (payload) => { + * if (typeof payload === 'string') return payload + * }, + * isNumber: (payload) => { + * if (typeof payload === 'number') return payload + * }, + * }, + * targets: { + * isString: stringReceived, + * isNumber: numberReceived, + * __: [unknownReceived, notifyError], + * }, + * }) + * + * dataFetched('string') + * // => stringReceived('string') + * + * dataFetched(42) + * // => numberReceived(42) + * + * dataFetched(null) + * // => unknownReceived(null) + * // => notifyError() + * ``` + */ export function splitMap< S, Cases extends Record any | undefined>, + Targets extends { + [K in keyof Cases]?: Cases[K] extends (s: S) => infer R + ? Targets[K] extends TargetUnits + ? Exclude extends TargetType + ? TargetUnits + : TargetUnits> + : TargetUnits> + : never; + } & { __?: TargetUnits }, >({ source, cases, + targets, }: { source: Unit; cases: Cases; + targets?: Targets; }): { [K in keyof Cases]: Cases[K] extends (p: S) => infer R ? Event> : never; } & { __: Event } { - const result: Record | Store> = {}; + const result: Record> = {}; let current = is.store(source) ? source.updates : (source as Event); for (const key in cases) { - if (key in cases) { + if (hasOwnProp(cases, key)) { const fn = cases[key]; - result[key] = current.filterMap(fn); + const caseEvent = current.filterMap(fn); + + result[key] = caseEvent; + current = current.filter({ fn: (data) => !fn(data), }); + + if (targets && hasOwnProp(targets, key)) { + const currentTarget = targets[key]; + + sample({ + clock: caseEvent, + target: currentTarget as UnitTargetable, + }); + } } } // eslint-disable-next-line no-underscore-dangle result.__ = current; + if (targets && hasOwnProp(targets, '__')) { + const defaultCaseTarget = targets.__; + + sample({ + clock: current, + target: defaultCaseTarget as UnitTargetable, + }); + } + return result as any; } diff --git a/src/split-map/readme.md b/src/split-map/readme.md index 27aacdef..689d1451 100644 --- a/src/split-map/readme.md +++ b/src/split-map/readme.md @@ -117,3 +117,120 @@ websocketEventReceived({ type: 'increment', name: 'demo', count: 5 }); websocketEventReceived({ type: 'bang', random: 'unknown' }); // => Unknown type: 'bang' ``` + +## `splitMap({ source, cases, targets? })` + +### Motivation + +Often we need the same behavior as in split — react to derived events received from `cases` and trigger the corresponding units. In `targets` field we can pass object with the same keys as in `cases` and values with `units` to trigger on the corresponding event. + +### Formulae + +```ts +splitMap({ source, cases, targets }); +``` + +- On each `source` trigger, call each function in `cases` object one after another until function returns non undefined, and call event in `shape` with the same name as function in `cases` object. +- Trigger the corresponding `unit`(s) from `targets` object +- If no function returned value, event `__` in `shape` should be triggered, same for `targets` field — trigger `unit`(s) provided in `__` key + +### Arguments + +1. `source` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — Source unit, data from this unit passed to each function in `cases` object and `__` event in `shape` as is +2. `cases` (`{ [key: string]: (payload: T) => any | void }`) — Object of functions. Function receives one argument is a payload from `source`, should return any value or `undefined` +3. `targets` (`{ [key: string]?: Unit | Unit[]; __?: Unit | Unit[] }`) — Object of units to trigger on corresponding event from `cases` object + +### Returns + +- `shape` (`{ [key: string]: Event; __: Event }`) — Object of events, with the same structure as `cases`, but with the _default_ event `__`, that will be triggered when each other function returns `undefined` + +[_`event`_]: https://effector.dev/docs/api/effector/event +[_`effect`_]: https://effector.dev/docs/api/effector/effect +[_`store`_]: https://effector.dev/docs/api/effector/store + +### Examples + +#### Split WebSocket events to effector events with targets + +```ts +import { createEvent } from 'effector'; +import { splitMap } from 'patronum/split-map'; +import { spread } from 'patronum/spread'; + +type WSEvent = + | { type: 'init'; key: string } + | { type: 'increment'; count: number } + | { type: 'reset' }; + +export const websocketEventReceived = createEvent(); +const notifyError = createEvent(); + +const $isInitialized = createStore(false); +const $count = createStore(null); + +const getInitialDataFx = createEffect(); + +splitMap({ + source: websocketEventReceived, + cases: { + init: (event) => { + if (event.type === 'init') return { init: true, dataId: event.key }; + }, + increment: (payload) => { + if (payload.type === 'increment') return payload.count; + }, + reset: ({ type }) => { + if (type === 'reset') return null; + }, + }, + targets: { + // EventCallable<{ init?: boolean; dataId?: string }> + init: spread({ init: $isInitialized, dataId: getInitialDataFx }), + increment: $count, + reset: [$count.reinit, $isInitialized.reinit], + __: notifyError, + }, +}); + +websocketEventReceived({ type: 'init', key: 'key' }); +// => $isInitialized: true, getInitialDataFx: 'key' + +websocketEventReceived({ type: 'increment', count: 2 }); +// => $count: 2 + +websocketEventReceived({ type: 'reset' }); +// => $count: null, $isInitialized: false +``` + +#### We can still use returned events to do some other logic + +```ts +const { init } = splitMap({ + source: websocketEventReceived, + cases: { + init: (event) => { + if (event.type === 'init') return { init: true, dataId: event.key }; + }, + increment: (payload) => { + if (payload.type === 'increment') return payload.count; + }, + reset: ({ type }) => { + if (type === 'reset') return null; + }, + }, + targets: { + // EventCallable<{ init?: boolean; dataId?: string }> + init: spread({ init: $isInitialized, dataId: getInitialDataFx }), + increment: $count, + reset: [$count.reinit, $isInitialized.reinit], + }, +}); + +sample({ + clock: init, + // some other logic + source: ..., + filter: ..., + target: ..., +}) +``` diff --git a/src/split-map/split-map.fork.test.ts b/src/split-map/split-map.fork.test.ts index 3778c09b..7db526ae 100644 --- a/src/split-map/split-map.fork.test.ts +++ b/src/split-map/split-map.fork.test.ts @@ -1,5 +1,13 @@ import 'regenerator-runtime/runtime'; -import { createDomain, fork, serialize, allSettled } from 'effector'; +import { + createDomain, + fork, + allSettled, + createEvent, + createStore, + createApi, + serialize, +} from 'effector'; import { splitMap } from './index'; @@ -111,3 +119,117 @@ test('do not affect original store value', async () => { `); expect($data.getState()).toBe($data.defaultState); }); + +describe('splitMap with targets', () => { + test('works in forked scope', async () => { + const $target = createStore(0); + + const source = createEvent<{ first?: number; another?: boolean }>(); + const out = splitMap({ + source, + cases: { + first: (payload) => payload.first, + }, + targets: { + first: $target, + __: createApi($target, { another: (state) => -state }).another, + }, + }); + + const $data = createStore(0); + + $data + .on(out.first, (state, payload) => state + payload) + .on(out.__, (state, payload) => (payload ? -state : 0)); + + const scope = fork(); + + await allSettled(source, { + scope, + params: { first: 15 }, + }); + expect(scope.getState($data)).toBe(15); + expect(scope.getState($target)).toBe(15); + + await allSettled(source, { + scope, + params: { another: true }, + }); + expect(scope.getState($data)).toBe(-15); + expect(scope.getState($target)).toBe(-15); + }); + + test('do not affect another fork', async () => { + const $target = createStore(0); + + const app = createDomain(); + const source = app.createEvent<{ first?: number; another?: boolean }>(); + const out = splitMap({ + source, + cases: { + first: (payload) => payload.first, + }, + targets: { + first: $target, + }, + }); + + const $data = app.createStore(0); + + $data + .on(out.first, (state, payload) => state + payload) + .on(out.__, (state, payload) => (payload ? -state : 0)); + + const scopeA = fork(); + const scopeB = fork(); + + await allSettled(source, { + scope: scopeA, + params: { first: 200 }, + }); + expect(scopeA.getState($data)).toBe(200); + expect(scopeA.getState($target)).toBe(200); + + await allSettled(source, { + scope: scopeB, + params: { first: -5 }, + }); + expect(scopeB.getState($data)).toBe(-5); + expect(scopeB.getState($target)).toBe(-5); + }); + + test('do not affect original store value', async () => { + const $target = createStore(0); + + const app = createDomain(); + const source = app.createEvent<{ first?: number; another?: boolean }>(); + const out = splitMap({ + source, + cases: { + first: (payload) => payload.first, + }, + targets: { + first: $target, + }, + }); + + const $data = app.createStore(0); + + $data + .on(out.first, (state, payload) => state + payload) + .on(out.__, (state, payload) => (payload ? -state : 0)); + + const scope = fork(); + + await allSettled(source, { + scope, + params: { first: 15 }, + }); + + expect(scope.getState($data)).toBe(15); + expect($data.getState()).toBe($data.defaultState); + + expect(scope.getState($target)).toBe(15); + expect($target.getState()).toBe($target.defaultState); + }); +}); diff --git a/src/split-map/split-map.test.ts b/src/split-map/split-map.test.ts index 83019434..a19cb08d 100644 --- a/src/split-map/split-map.test.ts +++ b/src/split-map/split-map.test.ts @@ -1,4 +1,4 @@ -import { createEvent, createStore, is } from 'effector'; +import { createEffect, createEvent, createStore, is } from 'effector'; import { argumentHistory } from '../../test-library'; import { splitMap } from './index'; @@ -202,3 +202,90 @@ test('from readme', () => { watchUpdate.mockClear(); watchDefault.mockClear(); }); + +describe('with targets', () => { + it('should trigger units in targets', () => { + const source = createEvent<{ name?: string; age?: number }>(); + + const ageTarget = createEvent(); + const $nameTarget1 = createStore(''); + const nameTarget2 = createEvent(); + const defaultCaseTarget = createEffect(); + + const { nameStringed, ageNumbered, __ } = splitMap({ + source, + cases: { + ageNumbered: ({ age }) => age, + nameStringed: ({ name }) => name, + }, + targets: { + ageNumbered: ageTarget, + nameStringed: [$nameTarget1, nameTarget2], + __: defaultCaseTarget, + }, + }); + + const fnTargetAge = jest.fn(); + const fnTargetName1 = jest.fn(); + const fnTargetName2 = jest.fn(); + const fnTargetDefault = jest.fn(); + + const fnAgeNumbered = jest.fn(); + const fnNameStringed = jest.fn(); + const fnDefaultCase = jest.fn(); + + ageTarget.watch(fnTargetAge); + $nameTarget1.updates.watch(fnTargetName1); + nameTarget2.watch(fnTargetName2); + defaultCaseTarget.watch(fnTargetDefault); + + ageNumbered.watch(fnAgeNumbered); + nameStringed.watch(fnNameStringed); + __.watch(fnDefaultCase); + + source({ age: 100 }); + + expect(fnAgeNumbered).toHaveBeenNthCalledWith(1, 100); + expect(fnTargetAge).toHaveBeenNthCalledWith(1, 100); + + source({ name: 'John' }); + + expect(fnNameStringed).toHaveBeenNthCalledWith(1, 'John'); + expect(fnTargetName1).toHaveBeenNthCalledWith(1, 'John'); + + source({}); + + expect(fnDefaultCase).toBeCalledTimes(1); + expect(fnTargetDefault).toBeCalledTimes(1); + }); + + it('should trigger all units in default case', () => { + const source = createEvent(); + + const target1 = createEvent(); + const $target2 = createStore(''); + const target3 = createEffect(); + + splitMap({ + source, + cases: {}, + targets: { + __: [target1, $target2, target3], + }, + }); + + const fnTarget1 = jest.fn(); + const fnTarget2 = jest.fn(); + const fnTarget3 = jest.fn(); + + target1.watch(fnTarget1); + $target2.updates.watch(fnTarget2); + target3.watch(fnTarget3); + + source('Demo'); + + expect(fnTarget1).toHaveBeenNthCalledWith(1, 'Demo'); + expect(fnTarget2).toHaveBeenNthCalledWith(1, 'Demo'); + expect(fnTarget3).toHaveBeenNthCalledWith(1, 'Demo'); + }); +}); diff --git a/test-typings/split-map.ts b/test-typings/split-map.ts index 1894292a..b16bb76b 100644 --- a/test-typings/split-map.ts +++ b/test-typings/split-map.ts @@ -68,6 +68,50 @@ import { splitMap } from '../dist/split-map'; ); } +// Allow any unit as source (with targets) +{ + const event = createEvent(); + const $store = createStore(0); + const effect = createEffect(); + + expectType<{ demo: Event; __: Event }>( + splitMap({ + source: event, + cases: { + demo: () => true, + }, + targets: { + demo: createEvent(), + __: createStore(0), + }, + }), + ); + expectType<{ demo: Event; __: Event }>( + splitMap({ + source: $store, + cases: { + demo: () => true, + }, + targets: { + demo: createEffect(), + __: createEvent(), + }, + }), + ); + expectType<{ demo: Event; __: Event }>( + splitMap({ + source: effect, + cases: { + demo: () => true, + }, + targets: { + demo: createStore(true), + __: createEffect(), + }, + }), + ); +} + // Has default case { expectType<{ __: Event }>( @@ -76,6 +120,26 @@ import { splitMap } from '../dist/split-map'; expectType<{ __: Event<{ demo: number }> }>( splitMap({ source: createEvent<{ demo: number }>(), cases: {} }), ); + + // with targets + expectType<{ __: Event }>( + splitMap({ + source: createEvent(), + cases: {}, + targets: { + __: createEvent(), + }, + }), + ); + expectType<{ __: Event<{ demo: number }> }>( + splitMap({ + source: createEvent<{ demo: number }>(), + cases: {}, + targets: { + __: createEvent<{ demo: number }>(), + }, + }), + ); } // Omit undefined from object type @@ -93,4 +157,147 @@ import { splitMap } from '../dist/split-map'; }, }), ); + + expectType<{ example: Event; __: Event }>( + splitMap({ + source, + cases: { + example: (object) => object.key, + }, + targets: { + example: createEvent(), + __: createEvent(), + }, + }), + ); +} + +// Allow void units in targets +{ + splitMap({ + source: createEvent<{ name?: string; age?: number }>(), + cases: { + ageNumbered: ({ age }) => age, + nameStringed: ({ name }) => name, + doSome: () => true, + doAnother: () => null, + }, + targets: { + ageNumbered: createEvent(), + nameStringed: [createStore(''), createEffect()], + doSome: [createEvent(), createEvent()], + doAnother: createEvent(), + __: createEvent(), + }, + }); +} + +// Allow array of units in targets default case +{ + splitMap({ + source: createEvent(), + cases: {}, + targets: { + __: [ + createEvent(), + createStore(''), + createEffect(), + ], + }, + }); + + // Allow void units in targets default case + splitMap({ + source: createEvent(), + cases: {}, + targets: { + __: [createStore(''), createEvent(), createEffect()], + }, + }); +} + +// Expect matching targets types with cases +{ + splitMap({ + source: createEvent<{ name?: string; age?: number }>(), + cases: { + ageNumbered: ({ age }) => age, + nameStringed: ({ name }) => name, + doSome: () => true, + doAnother: () => null, + foo: () => '', + }, + targets: { + // @ts-expect-error + ageNumbered: createEvent(), + nameStringed: [ + // @ts-expect-error + createStore(''), + // @ts-expect-error + createEffect(), + ], + doSome: [ + // @ts-expect-error + createEvent(), + createEvent(), + ], + doAnother: [ + // @ts-expect-error + createEvent(), + // @ts-expect-error + createEffect(), + ], + foo: [ + // @ts-expect-error + createEvent(), + // @ts-expect-error + createEvent(), + ], + }, + }); + + splitMap({ + source: createEvent(), + cases: {}, + targets: { + // @ts-expect-error + __: createEvent(), + }, + }); + + splitMap({ + source: createEvent>(), + cases: {}, + targets: { + __: [ + // @ts-expect-error + createStore(''), + // @ts-expect-error + createEvent(), + // @ts-expect-error + createEffect(), + + createEvent(), + ], + }, + }); +} + +// case payloads should extend target units +{ + splitMap({ + source: createEvent<{ first?: string; second?: number }>(), + cases: { + first: ({ first }) => first, // will be string + second: ({ second }) => second, // will be number + }, + targets: { + // string should extend string | null + first: createEvent(), + + // number should extend number | null + // TODO: but not string, should expect error + second: [createEvent(), createEvent()], + }, + }); }