diff --git a/README.md b/README.md index f7c6ab41..2720ff90 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,48 @@ Patronum had 3 breaking changes: 1) from `0.14` to `0.100`, 2) from `0.100` to ` We have [migration guide](https://patronum.effector.dev/docs/migration-guide). +## Cut + +[Method documentation & API](/src/cut) + +```ts +import { createEvent } from 'effector'; +import { cut } from 'patronum/cut'; + +type WSInitEvent = { type: 'init'; key: string }; +type WSIncrementEvent = { type: 'increment'; count: number; name: string }; +type WSResetEvent = { type: 'reset'; name: string }; +type WSEvent = + | WSInitEvent + | WSIncrementEvent + | WSResetEvent + +export const websocketEventReceived = createEvent(); + +const { init, increment, reset, __ } = cut({ + source: websocketEventReceived, + cases: { + init: (events) => events.filter((wsEvent: WSEvent): wsEvent is WSInitEvent => wsEvent.type === 'init'), + increment: (events) => events.filter((wsEvent: WSEvent): wsEvent is WSIncrementEvent => wsEvent.type === 'increment'), + reset: (events) => events.filter((wsEvent: WSEvent): wsEvent is WSResetEvent => wsEvent.type === 'reset'), + }, +}); + +init.watch(initEvents => { + console.info(`inited for ${initEvents.length}`); +}); + +increment.watch(incrementEvents => { + console.info('should be incremented', incrementEvents.map(wsEvent => wsEvent.count).reduce((a, b) => a + b)); +}); + +websocketEventReceived([{ type: 'increment', name: 'demo', count: 5 }, { type: 'increment', name: 'demo', count: 15 }]); +// => inited for 0 +// => should be incremented 20 +``` + +[Try it]() + # Development You can review [CONTRIBUTING.md](./CONTRIBUTING.md) diff --git a/src/cut/cut.fork.test.ts b/src/cut/cut.fork.test.ts new file mode 100644 index 00000000..bae76891 --- /dev/null +++ b/src/cut/cut.fork.test.ts @@ -0,0 +1,113 @@ +import 'regenerator-runtime/runtime'; +import { createDomain, fork, serialize, allSettled } from 'effector'; + +import { cut } from './index'; + +test('works in forked scope', async () => { + const app = createDomain(); + const source = app.createEvent<{ first?: number; another?: boolean }>(); + const out = cut({ + source, + cases: { + first: (payload) => payload.first, + }, + }); + + const $data = app.createStore(0); + + $data + .on(out.first, (state, payload) => state + payload) + .on(out.__, (state, payload) => (payload ? -state : 0)); + + const scope = fork(app); + + await allSettled(source, { + scope, + params: { first: 15 }, + }); + expect(serialize(scope)).toMatchInlineSnapshot(` + Object { + "-4r0u7g": 15, + } + `); + + await allSettled(source, { + scope, + params: { another: true }, + }); + expect(serialize(scope)).toMatchInlineSnapshot(` + Object { + "-4r0u7g": -15, + } + `); +}); + +test('do not affect another fork', async () => { + const app = createDomain(); + const source = app.createEvent<{ first?: number; another?: boolean }>(); + const out = cut({ + source, + cases: { + first: (payload) => payload.first, + }, + }); + + const $data = app.createStore(0); + + $data + .on(out.first, (state, payload) => state + payload) + .on(out.__, (state, payload) => (payload ? -state : 0)); + + const scopeA = fork(app); + const scopeB = fork(app); + + await allSettled(source, { + scope: scopeA, + params: { first: 200 }, + }); + expect(serialize(scopeA)).toMatchInlineSnapshot(` + Object { + "-hjldon": 200, + } + `); + + await allSettled(source, { + scope: scopeB, + params: { first: -5 }, + }); + expect(serialize(scopeB)).toMatchInlineSnapshot(` + Object { + "-hjldon": -5, + } + `); +}); + +test('do not affect original store value', async () => { + const app = createDomain(); + const source = app.createEvent<{ first?: number; another?: boolean }>(); + const out = cut({ + source, + cases: { + first: (payload) => payload.first, + }, + }); + + const $data = app.createStore(0); + + $data + .on(out.first, (state, payload) => state + payload) + .on(out.__, (state, payload) => (payload ? -state : 0)); + + const scope = fork(app); + + await allSettled(source, { + scope, + params: { first: 15 }, + }); + expect(serialize(scope)).toMatchInlineSnapshot(` + Object { + "-tv4arn": 15, + } + `); + expect($data.getState()).toBe($data.defaultState); +}); diff --git a/src/cut/cut.test.ts b/src/cut/cut.test.ts new file mode 100644 index 00000000..0394ca1e --- /dev/null +++ b/src/cut/cut.test.ts @@ -0,0 +1,273 @@ +import { createEvent, createStore, is } from 'effector'; +import { argumentHistory } from '../../test-library'; +import { cut } from './index'; + +test('map from event', () => { + const source = createEvent<{ first?: string; another?: boolean }>(); + const out = cut({ + source, + cases: { + first: (payload) => payload.first, + }, + }); + expect(is.event(out.first)).toBe(true); + + const fn = jest.fn(); + out.first.watch(fn); + + source({ first: 'Demo' }); + expect(fn).toBeCalledTimes(1); + expect(fn).toBeCalledWith('Demo'); + + source({ another: true }); + expect(fn).toBeCalledTimes(1); +}); + +test('default case from event', () => { + const source = createEvent<{ first?: string; another?: string }>(); + const out = cut({ + source, + cases: { + first: (payload) => payload.first, + }, + }); + expect(is.event(out.first)).toBe(true); + + const fnFirst = jest.fn(); + out.first.watch(fnFirst); + const fnDefault = jest.fn(); + // eslint-disable-next-line no-underscore-dangle + out.__.watch(fnDefault); + + source({ another: 'Demo' }); + expect(fnFirst).toBeCalledTimes(0); + + expect(fnDefault).toBeCalledTimes(1); + expect(argumentHistory(fnDefault)).toMatchInlineSnapshot(` + Array [ + Object { + "another": "Demo", + }, + ] + `); +}); + +test('fall through from event', () => { + const source = createEvent<{ + first?: string; + second?: boolean; + default?: number; + }>(); + const out = cut({ + source, + cases: { + first: (payload) => (payload.first ? 'first' : undefined), + second: (payload) => (payload.second ? 'second' : undefined), + }, + }); + expect(is.event(out.first)).toBe(true); + + const fn = jest.fn(); + out.first.watch(fn); + out.second.watch(fn); + // eslint-disable-next-line no-underscore-dangle + out.__.watch(fn); + + source({ first: 'Demo' }); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + Array [ + "first", + ] + `); + + source({ second: true }); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + Array [ + "first", + "second", + ] + `); + + source({ default: 1000 }); + expect(argumentHistory(fn)).toMatchInlineSnapshot(` + Array [ + "first", + "second", + Object { + "default": 1000, + }, + ] + `); +}); + +test('map from store', () => { + const change = createEvent(); + const $source = createStore('').on(change, (_, value) => value); + + const out = cut({ + source: $source, + cases: { + twoWords: (payload) => { + const pair = payload.split(' '); + return pair.length === 2 ? pair : undefined; + }, + firstWord: (payload) => { + const word = payload.match(/^[a-zA-Z]+/); + return word ? word[0] : undefined; + }, + }, + }); + expect(is.event(out.twoWords)).toBe(true); + expect(is.event(out.firstWord)).toBe(true); + + const twoWordsFn = jest.fn(); + const firstWordFn = jest.fn(); + out.twoWords.watch(twoWordsFn); + out.firstWord.watch(firstWordFn); + + change('Demo'); + + expect(argumentHistory(firstWordFn)).toMatchInlineSnapshot(` + Array [ + "Demo", + ] + `); + expect(argumentHistory(twoWordsFn)).toMatchInlineSnapshot(`Array []`); + + firstWordFn.mockClear(); + twoWordsFn.mockClear(); + + change('Hello World'); + expect(argumentHistory(firstWordFn)).toMatchInlineSnapshot(` + Array [ + "Hello", + ] + `); + expect(argumentHistory(twoWordsFn)).toMatchInlineSnapshot(` + Array [ + Array [ + "Hello", + "World", + ], + ] + `); +}); + +test('all case fired', async () => { + const watchFailed = jest.fn(); + const watchCompleted = jest.fn(); + + type Task = { + id: number; + status: 'failed' | 'completed'; + }; + type ActionResult = Task[]; + + const taskReceived = createEvent(); + + const received = cut({ + source: taskReceived, + cases: { + failed: (tasks) => tasks.filter((task) => task.status === 'failed'), + completed: (tasks) => tasks.filter((task) => task.status === 'completed'), + }, + }); + + received.failed.watch(watchFailed); + received.completed.watch(watchCompleted); + + taskReceived([ + { id: 1, status: 'completed' }, + { id: 2, status: 'failed' }, + ]); + expect(argumentHistory(watchFailed)).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "id": 2, + "status": "failed", + }, + ], + ] + `); + expect(argumentHistory(watchCompleted)).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "id": 1, + "status": "completed", + }, + ], + ] + `); +}); + +test('from readme', () => { + type WSInitEvent = { type: 'init'; key: string }; + type WSIncrementEvent = { type: 'increment'; count: number; name: string }; + type WSResetEvent = { type: 'reset'; name: string }; + type WSEvent = WSInitEvent | WSIncrementEvent | WSResetEvent; + + const websocketEventReceived = createEvent(); + + const received = cut({ + source: websocketEventReceived, + cases: { + init: (events) => + events.filter( + (wsEvent: WSEvent): wsEvent is WSInitEvent => wsEvent.type === 'init', + ), + increment: (events) => + events.filter( + (wsEvent: WSEvent): wsEvent is WSIncrementEvent => + wsEvent.type === 'increment', + ), + reset: (events) => + events.filter( + (wsEvent: WSEvent): wsEvent is WSResetEvent => wsEvent.type === 'reset', + ), + }, + }); + + const watchInit = jest.fn(); + const watchIncrement = jest.fn(); + const watchReset = jest.fn(); + + received.init.watch(watchInit); + received.increment.watch(watchIncrement); + received.reset.watch(watchReset); + + websocketEventReceived([ + { type: 'increment', name: 'demo', count: 5 }, + { type: 'increment', name: 'demo', count: 15 }, + ]); + expect(argumentHistory(watchIncrement)).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "count": 5, + "name": "demo", + "type": "increment", + }, + Object { + "count": 15, + "name": "demo", + "type": "increment", + }, + ], + ] +`); + expect(argumentHistory(watchInit)).toMatchInlineSnapshot(` + Array [ + Array [], + ] +`); + expect(argumentHistory(watchReset)).toMatchInlineSnapshot(` + Array [ + Array [], + ] +`); + watchInit.mockClear(); + watchIncrement.mockClear(); + watchReset.mockClear(); +}); diff --git a/src/cut/index.ts b/src/cut/index.ts new file mode 100644 index 00000000..0aa059d1 --- /dev/null +++ b/src/cut/index.ts @@ -0,0 +1,37 @@ +import { Event, is, Store, Unit } from 'effector'; + +export function cut< + S, + Cases extends Record any | undefined>, +>({ + source, + cases, +}: { + source: Unit; + cases: Cases; +}): { + [K in keyof Cases]: Cases[K] extends (p: S) => infer R + ? Event> + : never; +} & { __: Event } { + const result: Record | Store> = {}; + + const original = is.store(source) ? source.updates : (source as Event); + let current = original.map((_) => _); + + for (const key in cases) { + if (key in cases) { + const fn = cases[key]; + + result[key] = original.filterMap(fn); + current = current.filter({ + fn: (data) => fn(data) === undefined, + }); + } + } + + // eslint-disable-next-line no-underscore-dangle + result.__ = current; + + return result as any; +} diff --git a/src/cut/readme.md b/src/cut/readme.md new file mode 100644 index 00000000..be1460e6 --- /dev/null +++ b/src/cut/readme.md @@ -0,0 +1,111 @@ +# cut + +```ts +import { cut } from 'patronum/cut'; +``` + +## `shape = cut({ source, cases })` + +### Motivation + +The method is similar to [`split-map`], but do not stop on first case but processes them all. +It is useful when you want to have cut some event into smaller events. + +[`split-map`]: https://effector.dev/docs/api/effector/split-map + +### Formulae + +```ts +shape = cut({ source, cases }); +``` + +- On each `source` trigger, call each function in `cases` object one after another, and call event in `shape` with the same name as function in `cases` object if `case` function returns non undefined. +- If all functions returned value `undefined` event `__` in `shape` should be triggered + +### 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` + +### Returns + +- `shape` (`{ [key: string]: Event; __: Event }`) — Object of events, with the same structure as `cases`, but with the _default_ event `__`, that 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 + +#### Extract passed fields from optional object + +```ts +import { createEvent } from 'effector'; +import { cut } from 'patronum/cut'; + +const event = createEvent(); + +const shape = cut({ + source: event, + cases: { + getType: (input) => input.type, + getDemo: (input) => input.demo, + }, +}); + +shape.getType.watch((type) => console.log('TYPE', type)); +shape.getDemo.watch((demo) => console.log('DEMO', demo)); +shape.__.watch((other) => console.log('OTHER', other)); + +event({ type: 'demo' }); +// => TYPE demo + +event({ demo: 5 }); +// => DEMO 5 + +event({ type: 'demo', demo: 5 }); +// => TYPE demo +// => DEMO 5 + +event({}); +// => OTHER {} +``` + +#### Cut WebSocket events into effector events + +```ts +import { createEvent } from 'effector'; +import { cut } from 'patronum/cut'; + +type WSInitEvent = { type: 'init'; key: string }; +type WSIncrementEvent = { type: 'increment'; count: number; name: string }; +type WSResetEvent = { type: 'reset'; name: string }; +type WSEvent = + | WSInitEvent + | WSIncrementEvent + | WSResetEvent + +export const websocketEventReceived = createEvent(); + +const { init, increment, reset, __ } = cut({ + source: websocketEventReceived, + cases: { + init: (events) => events.filter((wsEvent: WSEvent): wsEvent is WSInitEvent => wsEvent.type === 'init'), + increment: (events) => events.filter((wsEvent: WSEvent): wsEvent is WSIncrementEvent => wsEvent.type === 'increment'), + reset: (events) => events.filter((wsEvent: WSEvent): wsEvent is WSResetEvent => wsEvent.type === 'reset'), + }, +}); + +init.watch(initEvents => { + console.info(`inited for ${initEvents.length}`); +}); + +increment.watch(incrementEvents => { + console.info('should be incremented', incrementEvents.map(wsEvent => wsEvent.count).reduce((a, b) => a + b)); +}); + +websocketEventReceived([{ type: 'increment', name: 'demo', count: 5 }, { type: 'increment', name: 'demo', count: 15 }]); +// => inited for 0 +// => should be incremented 20 +``` +