diff --git a/src/spread/index.ts b/src/spread/index.ts index db7f1130..e8f0eed0 100644 --- a/src/spread/index.ts +++ b/src/spread/index.ts @@ -1,57 +1,71 @@ import { createEvent, EventCallable, - is, sample, + Tuple, Unit, UnitTargetable, } from 'effector'; +type TargetUnits = + | UnitTargetable + | Tuple> + | ReadonlyArray>; + const hasPropBase = {}.hasOwnProperty; const hasOwnProp = (object: O, key: string) => hasPropBase.call(object, key); -type NoInfer = [T][T extends any ? 0 : never]; +/** + * @example + * spread({ + * source: dataObject, + * targets: { first: targetA, second: [target1, target2] }, + * }) + * + * sample({ + * source: dataObject, + * target: spread({ targets: { first: targetA, second: [target1, target2] } }) + * }) + * + * sample({ + * source: dataObject, + * target: spread({ first: targetA, second: [target1, target2] }) + * }) + */ export function spread(config: { targets: { - [Key in keyof Payload]?: UnitTargetable; + [Key in keyof Payload]?: TargetUnits; }; }): EventCallable>; export function spread< Source, Payload extends Source extends Unit ? S : never, ->(config: { - source: Source; - targets: { - [Key in keyof Payload]?: - | EventCallable> - | UnitTargetable>; - }; -}): Source; + Targets extends { + [Key in keyof Payload]?: Targets[Key] extends TargetUnits + ? Payload[Key] extends TargetType + ? TargetUnits + : TargetUnits + : TargetUnits; + }, +>(config: { source: Source; targets: Targets }): Source; export function spread(targets: { - [Key in keyof Payload]?: UnitTargetable; + [Key in keyof Payload]?: TargetUnits; }): EventCallable>; -/** - * @example - * spread({ source: dataObject, targets: { first: targetA, second: targetB } }) - * sample({ - * target: spread({targets: { first: targetA, second: targetB } }) - * }) - */ export function spread

( args: | { targets: { - [Key in keyof P]?: Unit; + [Key in keyof P]?: TargetUnits; }; source?: Unit

; } | { - [Key in keyof P]?: Unit; + [Key in keyof P]?: TargetUnits; }, ): EventCallable

{ const argsShape = isTargets(args) ? { targets: args } : args; @@ -60,18 +74,14 @@ export function spread

( if (hasOwnProp(targets, targetKey)) { const currentTarget = targets[targetKey]; - const hasTargetKey = sample({ - source, - batch: false, - filter: (object): object is any => - typeof object === 'object' && object !== null && targetKey in object, - }); - sample({ - batch: false, - clock: hasTargetKey, + source, + filter: (object): object is any => { + return typeof object === 'object' && object !== null && targetKey in object; + }, fn: (object: P) => object[targetKey], target: currentTarget as UnitTargetable, + batch: false, }); } } @@ -83,18 +93,15 @@ function isTargets

( args: | { targets: { - [Key in keyof P]?: Unit; + [Key in keyof P]?: TargetUnits; }; source?: Unit

; } | { - [Key in keyof P]?: Unit; + [Key in keyof P]?: TargetUnits; }, ): args is { - [Key in keyof P]?: Unit; + [Key in keyof P]?: TargetUnits; } { - return Object.keys(args).some( - (key) => - !['targets', 'source'].includes(key) && is.unit(args[key as keyof typeof args]), - ); + return !Object.keys(args).some((key) => ['targets', 'source'].includes(key)); } diff --git a/src/spread/readme.md b/src/spread/readme.md index 60f08567..64391115 100644 --- a/src/spread/readme.md +++ b/src/spread/readme.md @@ -216,3 +216,61 @@ source = spread({ targets: { field: target, ... } }) ### Returns - `source` `(Event)` — Source event, data passed to it should be an object with fields from `targets` + +## `source = spread({ targets: { field: Unit[] } })` + +### Motivation + +Multiple units can be passed for each target field + +### Formulae + +```ts +source = spread({ field: [target1, target2], ... }) + +source = spread({ targets: { field: [target1, target2], ... } }) + +spread({ source, targets: { field: [target1, target2], ... } }) +``` + +- When `source` is triggered with **object**, extract `field` from data, and trigger all targets of `target` +- `targets` can have multiple properties with multiple units +- If the `source` was triggered with non-object, nothing would be happening +- If `source` is triggered with object but without property `field`, no unit of the target for this `field` will be triggered + +### Example + +#### Trigger multiple units for each field of payload + +```ts +const roomEntered = createEvent<{ + roomId: string; + userId: string; + message: string; +}>(); +const userIdChanged = createEvent(); + +const $roomMessage = createStore(''); +const $currentRoomId = createStore(null); + +const getRoomFx = createEffect((roomId: string) => roomId); +const setUserIdFx = createEffect((userId: string) => userId); + +sample({ + clock: roomEntered, + target: spread({ + roomId: [getRoomFx, $currentRoomId], + userId: [setUserIdFx, userIdChanged], + message: $roomMessage, + }), +}); + +roomEntered({ + roomId: 'roomId', + userId: 'userId', + message: 'message', +}); +// => getRoomFx('roomId'), update $currentRoomId with 'roomId' +// => setUserIdFx('userId'), userIdChanged('userId') +// => update $roomMessage with 'message' +``` diff --git a/src/spread/spread.fork.test.ts b/src/spread/spread.fork.test.ts index 9af7a498..cbb17d1b 100644 --- a/src/spread/spread.fork.test.ts +++ b/src/spread/spread.fork.test.ts @@ -90,3 +90,117 @@ test('do not affects another scope', async () => { } `); }); + +describe('targets: array of units', () => { + test('works in forked scope', async () => { + const app = createDomain(); + const source = app.createEvent<{ + first: string; + second: number; + third: string; + }>(); + const first = app.createEvent(); + const second = app.createEvent(); + + const $thirdA = app.createStore(''); + const $thirdB = app.createStore(''); + + const $first = app.createStore('').on(first, (_, p) => p); + const $second = restore(second, 0); + + spread({ + source, + targets: { first, second, third: [$thirdA, $thirdB] }, + }); + + const scope = fork(); + + await allSettled(source, { + scope, + params: { first: 'sergey', second: 26, third: '30' }, + }); + + expect(scope.getState($first)).toBe('sergey'); + expect(scope.getState($second)).toBe(26); + expect(scope.getState($thirdA)).toBe('30'); + expect(scope.getState($thirdB)).toBe('30'); + }); + + test('does not affect original store state', async () => { + const app = createDomain(); + const source = app.createEvent<{ + first: string; + second: number; + third: string; + }>(); + const first = app.createEvent(); + const second = app.createEvent(); + + const $thirdA = app.createStore(''); + const $thirdB = app.createStore(''); + + const $first = app.createStore('').on(first, (_, p) => p); + const $second = restore(second, 0); + + spread({ + source, + targets: { first, second, third: [$thirdA, $thirdB] }, + }); + + const scope = fork(); + + await allSettled(source, { + scope, + params: { first: 'sergey', second: 26, third: '30' }, + }); + + expect(scope.getState($first)).toBe('sergey'); + expect(scope.getState($second)).toBe(26); + expect(scope.getState($thirdA)).toBe('30'); + expect(scope.getState($thirdB)).toBe('30'); + + expect($first.getState()).toBe(''); + expect($second.getState()).toBe(0); + expect($thirdA.getState()).toBe(''); + expect($thirdB.getState()).toBe(''); + }); + + test('do not affects another scope', async () => { + const app = createDomain(); + const source = app.createEvent<{ + first: string; + second: number; + third: string; + }>(); + const first = app.createEvent(); + const second = app.createEvent(); + + const $thirdA = app.createStore(''); + const $thirdB = app.createStore(''); + + const $first = app.createStore('').on(first, (_, p) => p); + const $second = restore(second, 0); + + spread({ + source, + targets: { first, second, third: [$thirdA, $thirdB] }, + }); + + const scope1 = fork(); + const scope2 = fork(); + + await Promise.all([ + allSettled(source, { + scope: scope1, + params: { first: 'sergey', second: 26, third: '30' }, + }), + allSettled(source, { + scope: scope2, + params: { first: 'Anon', second: 90, third: '154' }, + }), + ]); + + expect(scope1.getState($first)).toBe('sergey'); + expect(scope1.getState($second)).toBe(26); + }); +}); diff --git a/src/spread/spread.test.ts b/src/spread/spread.test.ts index b3fef538..04fc8347 100644 --- a/src/spread/spread.test.ts +++ b/src/spread/spread.test.ts @@ -1,4 +1,4 @@ -import { combine, createEvent, createStore, sample } from 'effector'; +import { combine, createEffect, createEvent, createStore, sample } from 'effector'; import { spread } from './index'; describe('spread(source, targets)', () => { @@ -105,6 +105,44 @@ describe('spread(source, targets)', () => { expect(fnA).toBeCalledWith('Hello'); expect(fnB).toBeCalledWith(200); }); + + test('unit to array of units', () => { + const source = createEvent<{ first: string; second: number; third: string }>(); + const targetA = createEvent(); + const targetA2 = createEffect(); + const targetB = createEvent(); + const targetB2 = createEvent(); + const targetC = createStore(''); + + const fnA = jest.fn(); + const fnB = jest.fn(); + const fnA1 = jest.fn(); + const fnB1 = jest.fn(); + const fnC = jest.fn(); + + targetA.watch(fnA); + targetB.watch(fnB); + targetA2.watch(fnA1); + targetB2.watch(fnB1); + targetC.watch(fnC); + + spread({ + source, + targets: { + first: [targetA, targetA2], + second: [targetB, targetB2], + third: targetC, + }, + }); + + source({ first: 'Hello', second: 200, third: 'third' }); + + expect(fnA).toBeCalledWith('Hello'); + expect(fnB).toBeCalledWith(200); + expect(fnA1).toBeCalledWith('Hello'); + expect(fnB1).toBeCalledWith(200); + expect(fnC).toBeCalledWith('third'); + }); }); describe('spread(targets)', () => { @@ -133,6 +171,7 @@ describe('spread(targets)', () => { expect(fnA).toBeCalledWith('Hello'); expect(fnB).toBeCalledWith(200); }); + test('event to events (shorthand)', () => { const source = createEvent<{ first: string; second: number }>(); const targetA = createEvent(); @@ -266,6 +305,84 @@ describe('spread(targets)', () => { expect(fnA).toBeCalledWith('Hello'); expect(fnB).toBeCalledWith(200); }); + + test('unit to array of units', () => { + const source = createEvent<{ first: string; second: number; third: string }>(); + const targetA = createEvent(); + const targetA2 = createEffect(); + const targetB = createEvent(); + const targetB2 = createEvent(); + const targetC = createStore(''); + + const fnA = jest.fn(); + const fnB = jest.fn(); + const fnA1 = jest.fn(); + const fnB1 = jest.fn(); + const fnC = jest.fn(); + + targetA.watch(fnA); + targetB.watch(fnB); + targetA2.watch(fnA1); + targetB2.watch(fnB1); + targetC.watch(fnC); + + sample({ + source, + target: spread({ + targets: { + first: [targetA, targetA2], + second: [targetB, targetB2], + third: targetC, + }, + }), + }); + + source({ first: 'Hello', second: 200, third: 'third' }); + + expect(fnA).toBeCalledWith('Hello'); + expect(fnB).toBeCalledWith(200); + expect(fnA1).toBeCalledWith('Hello'); + expect(fnB1).toBeCalledWith(200); + expect(fnC).toBeCalledWith('third'); + }); + + test('unit to array of units (shorthand)', () => { + const source = createEvent<{ first: string; second: number; third: string }>(); + const targetA = createEvent(); + const targetA2 = createEffect(); + const targetB = createEvent(); + const targetB2 = createEvent(); + const targetC = createStore(''); + + const fnA = jest.fn(); + const fnB = jest.fn(); + const fnA1 = jest.fn(); + const fnB1 = jest.fn(); + const fnC = jest.fn(); + + targetA.watch(fnA); + targetB.watch(fnB); + targetA2.watch(fnA1); + targetB2.watch(fnB1); + targetC.watch(fnC); + + sample({ + source, + target: spread({ + first: [targetA, targetA2], + second: [targetB, targetB2], + third: targetC, + }), + }); + + source({ first: 'Hello', second: 200, third: 'third' }); + + expect(fnA).toBeCalledWith('Hello'); + expect(fnB).toBeCalledWith(200); + expect(fnA1).toBeCalledWith('Hello'); + expect(fnB1).toBeCalledWith(200); + expect(fnC).toBeCalledWith('third'); + }); }); describe('edge', () => { @@ -389,7 +506,6 @@ describe('invalid', () => { targetA.watch(fnA); targetB.watch(fnB); - // @ts-expect-error Types do not allows extra targets spread({ source, targets: { diff --git a/test-typings/spread.ts b/test-typings/spread.ts index eafd304d..25b36029 100644 --- a/test-typings/spread.ts +++ b/test-typings/spread.ts @@ -24,6 +24,17 @@ import { spread } from '../dist/spread'; }, }), ); + + expectType>( + spread({ + source: createStore({ first: '', last: '', foo: 1 }), + targets: { + first, + last, + foo: [createEvent(), createStore(1)], + }, + }), + ); } // Expect matching object types @@ -54,6 +65,47 @@ import { spread } from '../dist/spread'; last: createEvent(), }, }); + + spread({ + source: createEvent<{ first: string; last: number }>(), + targets: { + first: createEvent(), + last: [ + createEvent(), + // TODO: should expect error + createEvent(), + ], + }, + }); + + spread({ + source: createEvent<{ first: string; last: number }>(), + targets: { + // @ts-expect-error + last: [createEvent(), createEvent()], + first: createEvent(), + }, + }); + + sample({ + // @ts-expect-error + source: createEvent<{ first: string; last: number }>(), + target: spread({ + first: createEvent(), + last: [createEvent(), createEvent()], + }), + }); + + sample({ + // @ts-expect-error + source: createEvent<{ first: string; last: number }>(), + target: spread({ + targets: { + first: createEvent(), + last: [createEvent(), createEvent()], + }, + }), + }); } // Check input source type with output @@ -89,6 +141,42 @@ import { spread } from '../dist/spread'; ); } +// Check input source type with output (targets: array of units) +{ + expectType>( + spread({ + source: createEvent<{ foo: string; bar: number; baz: boolean }>(), + targets: { + foo: createEvent(), + bar: createEvent(), + baz: [createEvent(), createEvent()], + }, + }), + ); + + expectType>( + spread({ + source: createStore({ random: '', bar: 5, baz: true }), + targets: { + random: createEvent(), + bar: createEvent(), + baz: [createEvent(), createEvent()], + }, + }), + ); + + expectType>( + spread({ + source: createEffect<{ foo: string; bar: number; baz: boolean }, void>(), + targets: { + foo: createEvent(), + bar: createEvent(), + baz: [createEvent(), createEvent()], + }, + }), + ); +} + // Check target different units { expectType>( @@ -122,16 +210,103 @@ import { spread } from '../dist/spread'; ); } -// Check target is prepended +// Check target different units (targets: array of units) { - const foo = createEvent(); + expectType>( + spread({ + source: createEvent<{ foo: string; bar: number; baz: boolean }>(), + targets: { + foo: createStore(''), + bar: createEffect(), + baz: [createEvent(), createStore(true)], + }, + }), + ); - expectType>( + expectType>( spread({ - source: createEvent<{ foo: string; bar: number }>(), + source: createStore({ foo: '', bar: 5, baz: true }), + targets: { + foo: createStore(''), + bar: createEffect(), + baz: [createEvent(), createEffect()], + }, + }), + ); + + expectType>( + spread({ + source: createEffect<{ foo: string; bar: number; baz: boolean }, void>(), + targets: { + foo: createStore(''), + bar: createEffect(), + baz: [createStore(true), createEffect()], + }, + }), + ); +} + +// Check target is prepended if type is given to generic or callback +{ + expectType>( + spread({ + source: createEvent<{ foo: string; bar: number; baz: string }>(), + targets: { + foo: createEvent().prepend((string) => string.length), + bar: createEvent(), + baz: createEvent().prepend((str: string) => Boolean(str)), + }, + }), + ); + + expectType>( + // @ts-expect-error + spread({ + source: createEvent<{ foo: string; bar: number; baz: string }>(), + targets: { + // @ts-expect-error (Type 'boolean' is not assignable to type 'number') + foo: createEvent().prepend((str) => str), + bar: createEvent(), + // @ts-expect-error (Type 'string' is not assignable to type 'boolean') + baz: createEvent().prepend((str: string) => str), + }, + }), + ); + + expectType>( + spread({ + source: createEvent<{ foo: string; bar: number; baz: boolean; zoo: string }>(), + targets: { + foo: createEvent().prepend((string: string) => string.length), + bar: createEvent(), + baz: [ + createEvent().prepend((bool) => + Boolean(expectType(bool)) ? 'true' : 'false', + ), + createEvent().prepend((bool: boolean) => + Boolean(expectType(bool)) ? 1 : 0, + ), + ], + zoo: [createEvent()], + }, + }), + ); + + expectType>( + // @ts-expect-error + spread({ + source: createEvent<{ foo: string; bar: number; baz: boolean; zoo: string }>(), targets: { - foo: foo.prepend((string) => string.length), + // @ts-expect-error + foo: createEvent().prepend((str) => str), bar: createEvent(), + baz: [ + // @ts-expect-error + createEvent().prepend((bool) => bool), + // @ts-expect-error + createEvent().prepend((bool: boolean) => bool), + ], + zoo: [createEvent()], }, }), ); @@ -148,6 +323,19 @@ import { spread } from '../dist/spread'; }); expectType>(spreadToStores); + + const spreadToUnits = spread({ + targets: { + foo: createStore(''), + bar: createEffect(), + baz: createEvent(), + last: [createEvent(), createStore(null)], + }, + }); + + expectType>( + spreadToUnits, + ); } { const spreadToStores = spread({ @@ -157,6 +345,16 @@ import { spread } from '../dist/spread'; }); expectType>(spreadToStores); + + const spreadToUnits = spread({ + foo: createStore(''), + baz: createEvent(), + last: [createEvent(), createStore(null)], + }); + + expectType>( + spreadToUnits, + ); } // Example from readme with nullability @@ -201,14 +399,36 @@ import { spread } from '../dist/spread'; first: createEvent(), }, }); + spread({ + source: createEffect<{ first: string; last: string }, void>(), + targets: { + first: [createEvent(), createStore('')], + }, + }); +} + +// Payload type should extend target type +{ + spread({ + source: createStore({ data: 0 }), + targets: { + // number should extend number | null + data: createStore(0), + }, + }); } // allows nested { const $source = createStore({ first: '', last: { nested: '', other: '' } }); + const $secondSource = createStore({ + first: '', + last: { nested: '', other: '', arr: 1 }, + }); const first = createEvent(); const nested = createEvent(); const other = createEvent(); + const arrayOfUnits = [createEvent(), createStore(1)]; // nested full match spread({ @@ -224,6 +444,20 @@ import { spread } from '../dist/spread'; }, }); + spread({ + source: $secondSource, + targets: { + first, + last: spread({ + targets: { + nested, + other, + arr: arrayOfUnits, + }, + }), + }, + }); + // nested partial match spread({ source: $source, @@ -263,6 +497,22 @@ import { spread } from '../dist/spread'; }, }); + const secondOut = spread({ + targets: { + nested, + other, + arr: arrayOfUnits, + }, + }); + + spread({ + source: $secondSource, + targets: { + first, + last: secondOut, + }, + }); + // nested partial match outer const outPart = spread({ targets: { @@ -304,6 +554,22 @@ import { spread } from '../dist/spread'; }), }); + sample({ + clock: $secondSource, + target: spread({ + targets: { + first, + last: spread({ + targets: { + nested, + other, + arr: arrayOfUnits, + }, + }), + }, + }), + }); + // sample wrong match sample({ // @ts-expect-error @@ -318,9 +584,14 @@ import { spread } from '../dist/spread'; } { const $source = createStore({ first: '', last: { nested: '', other: '' } }); + const $secondSource = createStore({ + first: '', + last: { nested: '', other: '', arr: 1 }, + }); const first = createEvent(); const nested = createEvent(); const other = createEvent(); + const arrayOfUnits = [createEvent(), createStore(1)]; // nested full match spread({ @@ -334,6 +605,18 @@ import { spread } from '../dist/spread'; }, }); + spread({ + source: $secondSource, + targets: { + first, + last: spread({ + nested, + other, + arr: arrayOfUnits, + }), + }, + }); + // nested partial match spread({ source: $source, @@ -367,6 +650,20 @@ import { spread } from '../dist/spread'; }, }); + const secondOut = spread({ + nested, + other, + arr: arrayOfUnits, + }); + + spread({ + source: $secondSource, + targets: { + first, + last: secondOut, + }, + }); + // nested partial match outer const outPart = spread({ nested }); @@ -396,6 +693,18 @@ import { spread } from '../dist/spread'; }), }); + sample({ + clock: $secondSource, + target: spread({ + first, + last: spread({ + nested, + other, + arr: arrayOfUnits, + }), + }), + }); + // sample wrong match sample({ // @ts-expect-error