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

Allow to pass array of units for spread operator #333

Merged
81 changes: 44 additions & 37 deletions src/spread/index.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,71 @@
import {
createEvent,
EventCallable,
is,
sample,
Tuple,
Unit,
UnitTargetable,
} from 'effector';

type TargetUnits<T> =
| UnitTargetable<T | void>
| Tuple<UnitTargetable<T | void>>
| ReadonlyArray<UnitTargetable<T | void>>;

const hasPropBase = {}.hasOwnProperty;
const hasOwnProp = <O extends { [k: string]: unknown }>(object: O, key: string) =>
hasPropBase.call(object, key);

type NoInfer<T> = [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<Payload>(config: {
targets: {
[Key in keyof Payload]?: UnitTargetable<Payload[Key]>;
[Key in keyof Payload]?: TargetUnits<Payload[Key]>;
};
}): EventCallable<Partial<Payload>>;

export function spread<
Source,
Payload extends Source extends Unit<infer S> ? S : never,
>(config: {
source: Source;
targets: {
[Key in keyof Payload]?:
| EventCallable<Partial<Payload[Key]>>
| UnitTargetable<NoInfer<Payload[Key]>>;
};
}): Source;
Targets extends {
[Key in keyof Payload]?: Targets[Key] extends TargetUnits<infer TargetType>
? Payload[Key] extends TargetType
? TargetUnits<TargetType>
: TargetUnits<Payload[Key]>
: TargetUnits<Payload[Key]>;
},
>(config: { source: Source; targets: Targets }): Source;

export function spread<Payload>(targets: {
[Key in keyof Payload]?: UnitTargetable<Payload[Key]>;
[Key in keyof Payload]?: TargetUnits<Payload[Key]>;
}): EventCallable<Partial<Payload>>;

/**
* @example
* spread({ source: dataObject, targets: { first: targetA, second: targetB } })
* sample({
* target: spread({targets: { first: targetA, second: targetB } })
* })
*/
export function spread<P>(
args:
| {
targets: {
[Key in keyof P]?: Unit<P[Key]>;
[Key in keyof P]?: TargetUnits<P[Key]>;
};
source?: Unit<P>;
}
| {
[Key in keyof P]?: Unit<P[Key]>;
[Key in keyof P]?: TargetUnits<P[Key]>;
},
): EventCallable<P> {
const argsShape = isTargets(args) ? { targets: args } : args;
Expand All @@ -60,18 +74,14 @@ export function spread<P>(
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<any>,
batch: false,
});
}
}
Expand All @@ -83,18 +93,15 @@ function isTargets<P>(
args:
| {
targets: {
[Key in keyof P]?: Unit<P[Key]>;
[Key in keyof P]?: TargetUnits<P[Key]>;
};
source?: Unit<P>;
}
| {
[Key in keyof P]?: Unit<P[Key]>;
[Key in keyof P]?: TargetUnits<P[Key]>;
},
): args is {
[Key in keyof P]?: Unit<P[Key]>;
[Key in keyof P]?: TargetUnits<P[Key]>;
} {
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));
}
58 changes: 58 additions & 0 deletions src/spread/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,61 @@ source = spread({ targets: { field: target, ... } })
### Returns

- `source` `(Event<T>)` — 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<string>();

const $roomMessage = createStore('');
const $currentRoomId = createStore<string | null>(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'
```
82 changes: 50 additions & 32 deletions src/spread/spread.fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,84 @@ import { spread } from './index';

test('works in forked scope', async () => {
const app = createDomain();
const source = app.createEvent<{ first: string; second: number }>();
const source = app.createEvent<{ first: string; second: number; third: string }>();
const first = app.createEvent<string>();
const second = app.createEvent<number>();

const _$first = app.createStore('').on(first, (_, p) => p);
const _$second = restore(second, 0);
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 },
targets: { first, second, third: [$thirdA, $thirdB] },
earthspacon marked this conversation as resolved.
Show resolved Hide resolved
});

const scope = fork();

await allSettled(source, { scope, params: { first: 'sergey', second: 26 } });
expect(serialize(scope)).toMatchInlineSnapshot(`
{
"-xihhjw": 26,
"nln5hw": "sergey",
}
`);
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('do not affects original store state', async () => {
const app = createDomain();
const source = app.createEvent<{ first: string; second: number }>();
const source = app.createEvent<{ first: string; second: number; third: string }>();
const first = app.createEvent<string>();
const second = app.createEvent<number>();

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 },
targets: { first, second, third: [$thirdA, $thirdB] },
});

const scope = fork();

await allSettled(source, { scope, params: { first: 'sergey', second: 26 } });
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 }>();
const source = app.createEvent<{ first: string; second: number; third: string }>();
const first = app.createEvent<string>();
const second = app.createEvent<number>();

const _$first = app.createStore('').on(first, (_, p) => p);
const _$second = restore(second, 0);
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 },
targets: { first, second, third: [$thirdA, $thirdB] },
});

const scope1 = fork();
Expand All @@ -70,23 +90,21 @@ test('do not affects another scope', async () => {
await Promise.all([
allSettled(source, {
scope: scope1,
params: { first: 'sergey', second: 26 },
params: { first: 'sergey', second: 26, third: '30' },
}),
allSettled(source, {
scope: scope2,
params: { first: 'Anon', second: 90 },
params: { first: 'Anon', second: 90, third: '154' },
}),
]);
expect(serialize(scope1)).toMatchInlineSnapshot(`
{
"-w3pd79": 26,
"f2h7kg": "sergey",
}
`);
expect(serialize(scope2)).toMatchInlineSnapshot(`
{
"-w3pd79": 90,
"f2h7kg": "Anon",
}
`);

expect(scope1.getState($first)).toBe('sergey');
expect(scope1.getState($second)).toBe(26);
expect(scope1.getState($thirdA)).toBe('30');
expect(scope1.getState($thirdB)).toBe('30');

expect(scope2.getState($first)).toBe('Anon');
expect(scope2.getState($second)).toBe(90);
expect(scope2.getState($thirdA)).toBe('154');
expect(scope2.getState($thirdB)).toBe('154');
});
Loading