@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
JavaScript
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