UNPKG

@rx-angular/state

Version:

@rx-angular/state is a light-weight, flexible, strongly typed and tested tool dedicated to reduce the complexity of managing component state and side effects in angular

430 lines (418 loc) 15.6 kB
import { merge, EMPTY, Subject, BehaviorSubject, queueScheduler, noop } from 'rxjs'; import { observeOn, distinctUntilChanged, mergeAll, withLatestFrom, scan, tap, catchError, publish, publishReplay, filter, map, shareReplay } from 'rxjs/operators'; const defaultAccumulator = (st, sl) => { return { ...st, ...sl }; }; function createAccumulationObservable(stateObservables = new Subject(), stateSlices = new Subject(), accumulatorObservable = new BehaviorSubject(defaultAccumulator), scheduler = queueScheduler) { const observeStateOn = () => scheduler ? observeOn(scheduler) : (o$) => o$; const signal$ = merge(stateObservables.pipe(distinctUntilChanged(), mergeAll(), observeStateOn()), stateSlices.pipe(observeStateOn())).pipe(withLatestFrom(accumulatorObservable.pipe(observeStateOn())), scan((state, [slice, stateAccumulator]) => stateAccumulator(state, slice), {}), tap((newState) => (compositionObservable.state = newState), (error) => console.error(error)), // @Notice We catch the error here as it get lost in between `publish` and `publishReplay`. We return empty to catchError((e) => EMPTY), publish()); const state$ = signal$.pipe(publishReplay(1)); const compositionObservable = { state: {}, signal$, state$, nextSlice, nextSliceObservable, nextAccumulator, subscribe, }; // ====== return compositionObservable; // ====== function nextAccumulator(accumulatorFn) { accumulatorObservable.next(accumulatorFn); } function nextSlice(stateSlice) { stateSlices.next(stateSlice); } function nextSliceObservable(stateObservable) { stateObservables.next(stateObservable); } function subscribe() { const sub = compositionObservable.signal$.connect(); sub.add(compositionObservable.state$.connect()); sub.add(() => { accumulatorObservable.complete(); stateObservables.complete(); stateSlices.complete(); }); return sub; } } function isPromiseGuard(value) { return (value !== null && value !== undefined && typeof value.subscribe !== 'function' && typeof value.then === 'function'); } function isOperateFnArrayGuard(op) { if (!Array.isArray(op)) { return false; } return op.length > 0 && op.every((i) => typeof i === 'function'); } function isStringArrayGuard(op) { if (!Array.isArray(op)) { return false; } return op.length > 0 && op.every((i) => typeof i === 'string'); } function isIterableGuard(obj) { if (obj === null || obj === undefined) { return false; } return typeof obj[Symbol.iterator] === 'function'; } function isKeyOf(k) { const typeofK = typeof k; return (k !== null && k !== undefined && ['string', 'symbol', 'number'].includes(typeofK)); } function isObjectGuard(obj) { return (obj !== null && obj !== undefined && typeof obj === 'object' && !Array.isArray(obj)); } function isDefined(val) { return val !== null && val !== undefined; } function isStringAndFunctionTupleGuard(op) { return typeof op[0] === 'string' && typeof op[1] === 'function'; } function isStringArrayFunctionAndOptionalObjectTupleGuard(op) { return (isStringArrayGuard(op[0]) && (typeof op[1] === 'function' || typeof op[1] === 'undefined') && (op[2] === undefined || typeof op[2] === 'object')); } function safePluck(stateObject, keys) { // needed to match null and undefined conventions of RxAngular core components // safePluck(null) -> return null // safePluck(undefined) -> return undefined // safePluck(obj, ['wrongKey']) -> return undefined // safePluck(obj, ['correctKey']) -> return value of key // safePluck(obj, '') -> return undefined // safePluck(obj, null) -> return undefined if (!isDefined(stateObject)) { return stateObject; } if (!isDefined(keys)) { return undefined; } // sanitize keys -> keep only valid keys (string, number, symbol) const keysArr = (Array.isArray(keys) ? keys : [keys]).filter(k => isKeyOf(k)); if (keysArr.length === 0 || !isObjectGuard(stateObject) || Object.keys(stateObject).length === 0) { return undefined; } let prop = stateObject[keysArr.shift()]; keysArr.forEach(key => { if (isObjectGuard(prop) && isKeyOf(key)) { prop = prop[key]; } }); return prop; } /** * @internal */ function defaultCompare(oldVal, newVal) { return oldVal === newVal; } /** * @description * * Returns an Observable that emits all items emitted by the source Observable that are distinct by comparison from * the previous item. Comparison will be done for each set key in the `keys` array. * * You can fine grain your distinct checks by providing a `KeyCompareMap` with those keys you want to compute * explicitly different * * The name `distinctUntilSomeChanged` was picked since it internally iterates over the `keys` and utilizes the * [some](https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Array/some) method in order to * compute if values are distinct or not. * * @example * * import { of } from 'rxjs'; * import { distinctUntilSomeChanged } from 'rx-angular/state'; * * interface Person { * age: number; * name: string; * } * * of( * { age: 4, name: 'Hans'}, * { age: 7, name: 'Sophie'}, * { age: 5, name: 'Han Solo'}, * { age: 5, name: 'HanSophie'}, * ).pipe( * distinctUntilSomeChanged(['age', 'name']), * ) * .subscribe(x => console.log(x)); * * // displays: * // { age: 4, name: 'Hans'} * // { age: 7, name: 'Sophie'} * // { age: 5, name: 'Han Solo'} * // { age: 5, name: 'HanSophie'} * * @example * // An example with `KeyCompareMap` * import { of } from 'rxjs'; * import { distinctUntilSomeChanged } from 'rxjs/operators'; * * interface Person { * age: number; * name: string; * } * const customComparison: KeyCompareMap<Person> = { * name: (oldName, newName) => oldName.substring(0, 2) === newName.substring(0, 2) * }; * * of( * { age: 4, name: 'Hans'}, * { age: 7, name: 'Sophie'}, * { age: 5, name: 'Han Solo'}, * { age: 5, name: 'HanSophie'}, * ).pipe( * distinctUntilSomeChanged(['age', 'name'], customComparison), * ) * .subscribe(x => console.log(x)); * * // displays: * // { age: 4, name: 'Hans' } * // { age: 7, name: 'Sophie' } * // { age: 5, name: 'Han Solo' } * * @param {K[]} keys String key for object property lookup on each item. * @param {KeyCompareMap<T>} [keyCompareMap] Optional KeyCompareMap to explicitly define comparisons for some of the keys * @docsPage distinctUntilSomeChanged * @docsCategory operators */ function distinctUntilSomeChanged(keys, keyCompareMap) { // default compare function applying === to every key let distinctCompare = (oldState, newState) => keys.some((key) => !defaultCompare(safePluck(oldState, [key]), safePluck(newState, [key]))); // generate compare function respecting every case of provided keyCompareMap if (keyCompareMap !== undefined) { const compare = (key) => { return keyCompareMap.hasOwnProperty(key) && keyCompareMap[key] !== undefined ? keyCompareMap[key] : defaultCompare; }; distinctCompare = (oldState, newState) => { return keys.some((key) => !compare(key)(safePluck(oldState, [key]), safePluck(newState, [key]))); }; } return distinctUntilChanged((oldV, newV) => !distinctCompare(oldV, newV)); } function pipeFromArray(fns) { if (!fns) { return noop; } if (fns.length === 1) { return fns[0]; } return function piped(input) { return fns.reduce((prev, fn) => fn(prev), input); }; } /** * @description * * Returns an Observable that emits only the provided `keys` emitted by the source Observable. Each key will get * filtered to only emit _defined_ values as well as checked for distinct emissions. * Comparison will be done for each set key in the `keys` array. * * `selectSlice` will only emit _valid_ selections. A selection is _valid_ if every * selected key exists and is defined in the source Observable. This ensures that the `selectSlice` * operator will always return a complete slice with all values defined. * * You can fine grain your distinct checks by providing a `KeyCompareMap` with those keys you want to compute * explicitly different * * @example * * // An example with a custom comparison applied to each key * import { of } from 'rxjs'; * import { selectSlice } from 'rx-angular/state'; * * * const state$: Observable<MyState> = of( * { title: 'myTitle', panelOpen: true}, * { title: 'myTitle2', panelOpen: true}, * { title: 'newTitle', panelOpen: true}, * { title: 'newTitle', panelOpen: false} * ) * .pipe( * selectSlice(['title', 'panelOpen']), * ) * .subscribe(x => console.log(x)); * * // displays: * // { title: 'myTitle', panelOpen: true }, * // { title: 'myTitle2', panelOpen: true }, * // { title: 'newTitle', panelOpen: true }, * // { title: 'newTitle', panelOpen: false } * * @example * * import { of, Observable } from 'rxjs'; * import { tap } from 'rxjs/operators'; * import { selectSlice } from 'rx-angular/state'; * * interface MyState { * title: string; * items: string[]; * panelOpen: boolean; * } * // Select items and title. * // apply custom compare logic for the items array * const customComparison: KeyCompareMap<MyState> = { * items: (oldItems, newItems) => compareItems(oldItems, newItems) * }; * const state$: Observable<MyState> = of( * { title: 'myTitle', items: ['foo', 'bar'], panelOpen: true }, * { title: 'myTitle', items: ['foo', 'bar'], panelOpen: false }, * { title: 'nextTitle', items: ['foo', 'baR'], panelOpen: true }, * { title: 'nextTitle', items: ['fooRz', 'boo'], panelOpen: false }, * ); * const slice$ = state$.pipe(selectSlice(['title', 'items'], customComparison), tap(console.log)).subscribe(); * * // displays: * // { title: 'myTitle', items: ['foo', 'bar'] } * // { title: 'nextTitle', items: ['foo', 'baR'] } * // { title: 'nextTitle', items: ['fooRz', 'boo'] } * * @param {(K)[]} keys - the array of keys which should be selected * @param {KeyCompareMap<{ [P in K]: T[P] }>} [keyCompareMap] Optional KeyCompareMap to provide custom compare logic * for some the keys * @docsPage selectSlice * @docsCategory operators */ function selectSlice(keys, keyCompareMap) { return (o$) => o$.pipe(filter((state) => state !== undefined), map((state) => { // forward null if (state === null) { return null; } // an array of all keys which exist and are _defined_ in the state object const definedKeys = keys // filter out undefined properties e. g. {}, { str: undefined } .filter((k) => state.hasOwnProperty(k) && state[k] !== undefined); // we want to ensure to only emit _valid_ selections // a selection is _valid_ if every selected key exists and has a value: // {} => selectSlice(['foo']) => no emission // {str: 'test'} => selectSlice([]) => no emission // {str: 'test'} => selectSlice(['notPresent']) => no emission // {str: 'test'} => state.select(selectSlice([])) => no emission // {str: 'test'} => state.select(selectSlice(['notPresent'])) => no emission // {str: undefined} => state.select(selectSlice(['str'])) => no emission // {str: 'test', foo: undefined } => state.select(selectSlice(['foo'])) => no emission if (definedKeys.length < keys.length) { return undefined; } // create the selected slice return definedKeys.reduce((vm, key) => { vm[key] = state[key]; return vm; }, {}); }), filter((v) => v !== undefined), distinctUntilSomeChanged(keys, keyCompareMap)); } /** * @description * * As it acts like the Observables `pipe` method, it accepts one or many RxJS operators as params. * * @example * import { Observable } from 'rxjs'; * import { map } from 'rxjs/operators'; * import { stateful } from 'rx-angular/state'; * * const state$: Observable<{ name: string; items: string[] }>; * const derivation$ = state$.pipe( * stateful( * map(state => state.list.length), * filter(length => length > 3) * ) * ); * * @param {OperatorFunction<T, A>} optionalDerive - one or multiple passed operator comma separated * * @docsPage stateful * @docsCategory operators */ function stateful(...optionalDerive) { return (s) => { return s.pipe( // distinct same base-state objects (e.g. a default emission of default switch cases, incorrect mutable handling // of data) @TODO evaluate benefits vs. overhead distinctUntilChanged(), // CUSTOM LOGIC HERE (o) => { if (isOperateFnArrayGuard(optionalDerive)) { return o.pipe(pipeFromArray(optionalDerive)); } return o; }, // initial emissions, undefined is no base-state, pollution with skip(1) filter((v) => v !== undefined), // distinct same derivation value distinctUntilChanged(), // reuse custom operations result for multiple subscribers and reemit the last calculated value. shareReplay({ bufferSize: 1, refCount: true })); }; } /** * @internal */ function select(...opOrMapFn) { return (state$) => { if (!opOrMapFn || opOrMapFn.length === 0) { return state$.pipe(stateful()); } else if (isStringAndFunctionTupleGuard(opOrMapFn)) { return state$.pipe(stateful(map((s) => opOrMapFn[1](s[opOrMapFn[0]])))); } else if (isStringArrayFunctionAndOptionalObjectTupleGuard(opOrMapFn)) { const selectedState$ = state$.pipe(selectSlice(opOrMapFn[0], opOrMapFn[2])); return typeof opOrMapFn[1] === 'undefined' ? selectedState$ : selectedState$.pipe(stateful(map(opOrMapFn[1]))); } else if (isStringArrayGuard(opOrMapFn)) { return state$.pipe(stateful(map((state) => opOrMapFn.reduce((acc, key) => acc?.[key], state)))); } else if (isOperateFnArrayGuard(opOrMapFn)) { return state$.pipe(stateful(pipeFromArray(opOrMapFn))); } else { throw new Error('wrong params passed to select'); } }; } function createSideEffectObservable(stateObservables = new Subject(), scheduler = queueScheduler) { const effects$ = merge(stateObservables.pipe(mergeAll(), scheduler ? observeOn(scheduler) : (o$) => o$)); function nextEffectObservable(effect$) { stateObservables.next(effect$); } function subscribe() { return effects$.subscribe(); } return { effects$, nextEffectObservable, subscribe, }; } /** * Generated bundle index. Do not edit. */ export { createAccumulationObservable, createSideEffectObservable, defaultAccumulator, distinctUntilSomeChanged, isDefined, isKeyOf, isObjectGuard, isOperateFnArrayGuard, isStringAndFunctionTupleGuard, isStringArrayFunctionAndOptionalObjectTupleGuard, isStringArrayGuard, pipeFromArray, safePluck, select, selectSlice, stateful }; //# sourceMappingURL=state-selections.mjs.map