diff --git a/src/babel-plugin-factories.json b/src/babel-plugin-factories.json index 7a63c8a4..bb990ca6 100644 --- a/src/babel-plugin-factories.json +++ b/src/babel-plugin-factories.json @@ -27,7 +27,8 @@ "patronum/spread", "patronum/status", "patronum/throttle", - "patronum/time" + "patronum/time", + "patronum/xor" ], "mapping": { "and": "and", @@ -56,6 +57,7 @@ "spread": "spread", "status": "status", "throttle": "throttle", - "time": "time" + "time": "time", + "xor": "xor" } -} +} \ No newline at end of file diff --git a/src/xor/index.ts b/src/xor/index.ts new file mode 100644 index 00000000..f2adff8c --- /dev/null +++ b/src/xor/index.ts @@ -0,0 +1,19 @@ +import { combine, Store } from 'effector'; + +export function xor(...stores: Array>): Store { + return combine( + stores, + (values) => { + let trueCount = 0; + + for (const value of values) { + if (value) { + trueCount += 1; + } + } + + return trueCount === 1; + }, + { skipVoid: false }, + ) as Store; +} diff --git a/src/xor/readme.md b/src/xor/readme.md new file mode 100644 index 00000000..7419d060 --- /dev/null +++ b/src/xor/readme.md @@ -0,0 +1,54 @@ +--- +title: xor +slug: xor +description: Logical XOR for multiple stores +group: combination +--- + +```ts +import { xor } from 'patronum'; +// or +import { xor } from 'patronum/xor'; +``` + +### Motivation + +Combines multiple stores, returning `true` if exactly one of them is truthy. + +### Formulae + +```ts +$result = xor($first, $second); +``` + +- `$result` will be `true`, if exactly one of the stores is truthy +- `$result` will be `false`, if all stores are falsy or more than one store is truthy + +### Arguments + +- `stores: Array>` — Any number of stores to check through XOR + +### Returns + +- `result: Store` — Store with the result of the XOR operation + +### Example + +#### Basic usage + +```ts +import { createStore } from 'effector'; +import { xor } from 'patronum/xor'; + +const $isOnline = createStore(true); +const $isProcessing = createStore(false); + +const $isDisabled = xor($isOnline, $isProcessing); +console.assert(true === $isDisabled.getState()); +// $isDisabled === true, because $isOnline === true and $isProcessing === false + +const $hasError = createStore(true); +const $result = xor($isOnline, $isProcessing, $hasError); +console.assert(false === $result.getState()); +// $result === false, because multiple stores are truthy ($isOnline and $hasError) +``` diff --git a/src/xor/xor.fork.test.ts b/src/xor/xor.fork.test.ts new file mode 100644 index 00000000..a95cef0b --- /dev/null +++ b/src/xor/xor.fork.test.ts @@ -0,0 +1,39 @@ +import { createStore, createEvent, fork, allSettled } from 'effector'; +import { xor } from './index'; + +test('xor in forked scope', async () => { + const $a = createStore(false); + const $b = createStore(false); + const $c = createStore(false); + const changeA = createEvent(); + const changeB = createEvent(); + const changeC = createEvent(); + + $a.on(changeA, (_, value) => value); + $b.on(changeB, (_, value) => value); + $c.on(changeC, (_, value) => value); + + const $result = xor($a, $b, $c); + + const scope = fork(); + + expect(scope.getState($result)).toBe(false); + + await allSettled(changeA, { scope, params: true }); + expect(scope.getState($result)).toBe(true); + + await allSettled(changeB, { scope, params: true }); + expect(scope.getState($result)).toBe(false); + + await allSettled(changeB, { scope, params: false }); + expect(scope.getState($result)).toBe(true); + + await allSettled(changeC, { scope, params: true }); + expect(scope.getState($result)).toBe(false); + + await allSettled(changeA, { scope, params: false }); + expect(scope.getState($result)).toBe(true); + + await allSettled(changeC, { scope, params: false }); + expect(scope.getState($result)).toBe(false); +}); diff --git a/src/xor/xor.test.ts b/src/xor/xor.test.ts new file mode 100644 index 00000000..06795a1d --- /dev/null +++ b/src/xor/xor.test.ts @@ -0,0 +1,66 @@ +import { createStore, createEvent, fork, allSettled } from 'effector'; +import { xor } from './index'; + +describe('xor', () => { + test('returns true when exactly one store is truthy', () => { + const $a = createStore(true); + const $b = createStore(false); + const $c = createStore(false); + + const $result = xor($a, $b, $c); + + expect($result.getState()).toBe(true); + }); + + test('returns false when multiple stores are truthy', () => { + const $a = createStore(true); + const $b = createStore(true); + const $c = createStore(false); + + const $result = xor($a, $b, $c); + + expect($result.getState()).toBe(false); + }); + + test('returns false when no stores are truthy', () => { + const $a = createStore(false); + const $b = createStore(false); + const $c = createStore(false); + + const $result = xor($a, $b, $c); + + expect($result.getState()).toBe(false); + }); + + test('updates correctly when source store changes', () => { + const $a = createStore(true); + const $b = createStore(false); + const changeA = createEvent(); + const changeB = createEvent(); + $a.on(changeA, (_, value) => value); + $b.on(changeB, (_, value) => value); + + const $result = xor($a, $b); + + expect($result.getState()).toBe(true); + + changeA(false); + expect($result.getState()).toBe(false); + + changeB(true); + expect($result.getState()).toBe(true); + + changeA(true); + expect($result.getState()).toBe(false); + }); + + test('accepts non-boolean values and coerces them', () => { + const $a = createStore(0); + const $b = createStore(''); + const $c = createStore(1); + + const $result = xor($a, $b, $c); + + expect($result.getState()).toBe(true); + }); +}); diff --git a/test-typings/xor.ts b/test-typings/xor.ts new file mode 100644 index 00000000..5d3c591d --- /dev/null +++ b/test-typings/xor.ts @@ -0,0 +1,43 @@ +import { expectType } from 'tsd'; +import { + Store, + createStore, + createDomain, + createEvent, + createEffect, +} from 'effector'; +import { xor } from '../dist/xor'; + +// Returns always store with boolean +{ + const $a = createStore(1); + const $b = createStore('a'); + const $c = createStore(true); + const $d = createStore([]); + const $e = createStore({}); + const $f = createStore(() => {}); + const $g = createStore(Symbol.for('demo')); + + expectType>(xor($a, $b, $c, $d, $e, $f, $g)); +} + +// Doesn't allow to pass non-store as argument +{ + // @ts-expect-error + xor(createDomain()); + + // @ts-expect-error + xor(createEvent()); + + // @ts-expect-error + xor(createEffect()); +} + +// Allows to receive derived stores +{ + const $source = createStore(1); + const $derived = $source.map((i) => i * 100); + const fx = createEffect(); + + expectType>(xor($derived, fx.pending)); +}