Skip to content

Commit f8d0241

Browse files
alex-okrushkoaokrushkmarkostanimirovic
authored
feat(component-store): Add SelectorObject to select (#3629)
* feat(component-store): Add SelectorObject * use legacyFakeTimers * adjust based on the feedback * restrict to Observable * feat(store): make reducers arg of StoreModule.forRoot optional (#3632) * feat(schematics): drop support for TypeScript <4.8 (#3631) * add support for an array state obj Co-authored-by: Alex Okrushko <aokrushk@cisco.com> Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
1 parent 96c5bdd commit f8d0241

File tree

2 files changed

+197
-67
lines changed

2 files changed

+197
-67
lines changed

modules/component-store/spec/component-store.spec.ts

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,43 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import {
3+
Inject,
4+
Injectable,
5+
InjectionToken,
6+
Injector,
7+
Provider,
8+
} from '@angular/core';
9+
import { fakeAsync, flushMicrotasks } from '@angular/core/testing';
210
import {
311
ComponentStore,
412
OnStateInit,
513
OnStoreInit,
614
provideComponentStore,
715
} from '@ngrx/component-store';
8-
import { fakeSchedulers, marbles } from 'rxjs-marbles/jest';
16+
import { createSelector } from '@ngrx/store';
917
import {
10-
of,
11-
Subscription,
18+
asyncScheduler,
1219
ConnectableObservable,
20+
from,
1321
interval,
14-
timer,
1522
Observable,
16-
from,
17-
scheduled,
23+
of,
1824
queueScheduler,
19-
asyncScheduler,
25+
scheduled,
26+
Subscription,
2027
throwError,
28+
timer,
2129
} from 'rxjs';
30+
import { fakeSchedulers, marbles } from 'rxjs-marbles/jest';
2231
import {
32+
concatMap,
33+
delay,
2334
delayWhen,
35+
finalize,
36+
map,
2437
publishReplay,
2538
take,
26-
map,
2739
tap,
28-
finalize,
29-
delay,
30-
concatMap,
3140
} from 'rxjs/operators';
32-
import { createSelector } from '@ngrx/store';
33-
import {
34-
Inject,
35-
Injectable,
36-
InjectionToken,
37-
Injector,
38-
Provider,
39-
} from '@angular/core';
40-
import { fakeAsync, flushMicrotasks } from '@angular/core/testing';
4141

4242
describe('Component Store', () => {
4343
describe('initialization', () => {
@@ -53,6 +53,18 @@ describe('Component Store', () => {
5353
})
5454
);
5555

56+
it(
57+
'supports an array state',
58+
marbles((m) => {
59+
const INIT_STATE = [1, 2, 3];
60+
const componentStore = new ComponentStore(INIT_STATE);
61+
62+
m.expect(componentStore.state$).toBeObservable(
63+
m.hot('i', { i: INIT_STATE })
64+
);
65+
})
66+
);
67+
5668
it(
5769
'stays uninitialized if initial state is not provided',
5870
marbles((m) => {
@@ -372,7 +384,7 @@ describe('Component Store', () => {
372384
},
373385
]);
374386

375-
// New subsriber gets the latest value only.
387+
// New subscriber gets the latest value only.
376388
m.expect(componentStore.state$).toBeObservable(
377389
m.hot('s', {
378390
s: {
@@ -432,7 +444,7 @@ describe('Component Store', () => {
432444
});
433445

434446
describe('cancels updater Observable', () => {
435-
beforeEach(() => jest.useFakeTimers());
447+
beforeEach(() => jest.useFakeTimers({ legacyFakeTimers: true }));
436448

437449
interface State {
438450
value: string;
@@ -809,6 +821,80 @@ describe('Component Store', () => {
809821
]);
810822
});
811823

824+
it('can combine into an object through selectorObject', () => {
825+
const selector1 = componentStore.select((s) => s.value);
826+
const selector2 = componentStore.select((s) => s.updated);
827+
const selector3 = componentStore.select({
828+
s1: selector1,
829+
s2: selector2,
830+
});
831+
832+
const selectorResults: Array<{
833+
s1: string;
834+
s2: boolean | undefined;
835+
}> = [];
836+
selector3.subscribe((s3) => {
837+
selectorResults.push(s3);
838+
});
839+
840+
componentStore.setState(() => ({ value: 'new value', updated: true }));
841+
842+
expect(selectorResults).toEqual([
843+
{ s1: 'init', s2: undefined },
844+
{ s1: 'new value', s2: undefined }, // not debounced
845+
{ s1: 'new value', s2: true },
846+
]);
847+
});
848+
849+
it('can combine into an object through a single selectorObject', () => {
850+
const selector1 = componentStore.select((s) => s.value);
851+
852+
const selector2 = componentStore.select({
853+
s1: selector1,
854+
});
855+
856+
const selectorResults: Array<{
857+
s1: string;
858+
}> = [];
859+
selector2.subscribe((s2) => {
860+
selectorResults.push(s2);
861+
});
862+
863+
componentStore.setState(() => ({ value: 'new value', updated: true }));
864+
865+
expect(selectorResults).toEqual([{ s1: 'init' }, { s1: 'new value' }]);
866+
});
867+
868+
it('can combine into an object through selectorObject with debounce', fakeAsync(() => {
869+
const selector1 = componentStore.select((s) => s.value);
870+
const selector2 = componentStore.select((s) => s.updated);
871+
const selector3 = componentStore.select(
872+
{
873+
s1: selector1,
874+
s2: selector2,
875+
},
876+
{ debounce: true }
877+
);
878+
879+
const selectorResults: Array<{
880+
s1: string;
881+
s2: boolean | undefined;
882+
}> = [];
883+
selector3.subscribe((s3) => {
884+
selectorResults.push(s3);
885+
});
886+
flushMicrotasks();
887+
888+
componentStore.setState(() => ({ value: 'new value', updated: true }));
889+
flushMicrotasks();
890+
891+
expect(selectorResults).toEqual([
892+
{ s1: 'init', s2: undefined },
893+
// debounced, so new value for both
894+
{ s1: 'new value', s2: true },
895+
]);
896+
}));
897+
812898
it(
813899
'can combine with other Observables',
814900
marbles((m) => {

modules/component-store/src/component-store.ts

Lines changed: 89 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
scheduled,
1212
asapScheduler,
1313
EMPTY,
14+
ObservedValueOf,
1415
} from 'rxjs';
1516
import {
1617
takeUntil,
@@ -225,44 +226,53 @@ export class ComponentStore<T extends object> implements OnDestroy {
225226
projector: (s: T) => Result,
226227
config?: SelectConfig
227228
): Observable<Result>;
229+
select<SelectorsObject extends Record<string, Observable<unknown>>>(
230+
selectorsObject: SelectorsObject,
231+
config?: SelectConfig
232+
): Observable<{
233+
[K in keyof SelectorsObject]: ObservedValueOf<SelectorsObject[K]>;
234+
}>;
228235
select<Selectors extends Observable<unknown>[], Result>(
229-
...args: [...selectors: Selectors, projector: Projector<Selectors, Result>]
236+
...selectorsWithProjector: [
237+
...selectors: Selectors,
238+
projector: Projector<Selectors, Result>
239+
]
230240
): Observable<Result>;
231241
select<Selectors extends Observable<unknown>[], Result>(
232-
...args: [
242+
...selectorsWithProjectorAndConfig: [
233243
...selectors: Selectors,
234244
projector: Projector<Selectors, Result>,
235245
config: SelectConfig
236246
]
237247
): Observable<Result>;
238248
select<
239-
Selectors extends Array<Observable<unknown> | SelectConfig | ProjectorFn>,
249+
Selectors extends Array<
250+
Observable<unknown> | SelectConfig | ProjectorFn | SelectorsObject
251+
>,
240252
Result,
241-
ProjectorFn extends (...a: unknown[]) => Result
253+
ProjectorFn extends (...a: unknown[]) => Result,
254+
SelectorsObject extends Record<string, Observable<unknown>>
242255
>(...args: Selectors): Observable<Result> {
243-
const { observables, projector, config } = processSelectorArgs<
244-
Selectors,
245-
Result,
246-
ProjectorFn
247-
>(args);
248-
249-
let observable$: Observable<Result>;
250-
// If there are no Observables to combine, then we'll just map the value.
251-
if (observables.length === 0) {
252-
observable$ = this.stateSubject$.pipe(
253-
config.debounce ? debounceSync() : (source$) => source$,
254-
map((state) => projector(state))
255-
);
256-
} else {
257-
// If there are multiple arguments, then we're aggregating selectors, so we need
258-
// to take the combineLatest of them before calling the map function.
259-
observable$ = combineLatest(observables).pipe(
260-
config.debounce ? debounceSync() : (source$) => source$,
261-
map((projectorArgs) => projector(...projectorArgs))
256+
const { observablesOrSelectorsObject, projector, config } =
257+
processSelectorArgs<Selectors, Result, ProjectorFn, SelectorsObject>(
258+
args
262259
);
263-
}
264260

265-
return observable$.pipe(
261+
const source$ = hasProjectFnOnly(observablesOrSelectorsObject, projector)
262+
? this.stateSubject$
263+
: combineLatest(observablesOrSelectorsObject as any);
264+
265+
return source$.pipe(
266+
config.debounce ? debounceSync() : noopOperator(),
267+
(projector
268+
? map((projectorArgs) =>
269+
// projectorArgs could be an Array in case where the entire state is an Array, so adding this check
270+
observablesOrSelectorsObject.length > 0 &&
271+
Array.isArray(projectorArgs)
272+
? projector(...projectorArgs)
273+
: projector(projectorArgs)
274+
)
275+
: noopOperator()) as () => Observable<Result>,
266276
distinctUntilChanged(),
267277
shareReplay({
268278
refCount: true,
@@ -357,36 +367,70 @@ export class ComponentStore<T extends object> implements OnDestroy {
357367
}
358368

359369
function processSelectorArgs<
360-
Selectors extends Array<Observable<unknown> | SelectConfig | ProjectorFn>,
370+
Selectors extends Array<
371+
Observable<unknown> | SelectConfig | ProjectorFn | SelectorsObject
372+
>,
361373
Result,
362-
ProjectorFn extends (...a: unknown[]) => Result
374+
ProjectorFn extends (...a: unknown[]) => Result,
375+
SelectorsObject extends Record<string, Observable<unknown>>
363376
>(
364377
args: Selectors
365-
): {
366-
observables: Observable<unknown>[];
367-
projector: ProjectorFn;
368-
config: Required<SelectConfig>;
369-
} {
378+
):
379+
| {
380+
observablesOrSelectorsObject: Observable<unknown>[];
381+
projector: ProjectorFn;
382+
config: Required<SelectConfig>;
383+
}
384+
| {
385+
observablesOrSelectorsObject: SelectorsObject;
386+
projector: undefined;
387+
config: Required<SelectConfig>;
388+
} {
370389
const selectorArgs = Array.from(args);
371390
// Assign default values.
372391
let config: Required<SelectConfig> = { debounce: false };
373-
let projector: ProjectorFn;
374-
// Last argument is either projector or config
375-
const projectorOrConfig = selectorArgs.pop() as ProjectorFn | SelectConfig;
376-
377-
if (typeof projectorOrConfig !== 'function') {
378-
// We got the config as the last argument, replace any default values with it.
379-
config = { ...config, ...projectorOrConfig };
380-
// Pop the next args, which would be the projector fn.
381-
projector = selectorArgs.pop() as ProjectorFn;
382-
} else {
383-
projector = projectorOrConfig;
392+
393+
// Last argument is either config or projector or selectorsObject
394+
if (isSelectConfig(selectorArgs[selectorArgs.length - 1])) {
395+
config = { ...config, ...selectorArgs.pop() };
384396
}
385-
// The Observables to combine, if there are any.
397+
398+
// At this point selectorArgs is either projector, selectors with projector or selectorsObject
399+
if (selectorArgs.length === 1 && typeof selectorArgs[0] !== 'function') {
400+
// this is a selectorsObject
401+
return {
402+
observablesOrSelectorsObject: selectorArgs[0] as SelectorsObject,
403+
projector: undefined,
404+
config,
405+
};
406+
}
407+
408+
const projector = selectorArgs.pop() as ProjectorFn;
409+
410+
// The Observables to combine, if there are any left.
386411
const observables = selectorArgs as Observable<unknown>[];
387412
return {
388-
observables,
413+
observablesOrSelectorsObject: observables,
389414
projector,
390415
config,
391416
};
392417
}
418+
419+
function isSelectConfig(arg: SelectConfig | unknown): arg is SelectConfig {
420+
return typeof (arg as SelectConfig).debounce !== 'undefined';
421+
}
422+
423+
function hasProjectFnOnly(
424+
observablesOrSelectorsObject: unknown[] | Record<string, unknown>,
425+
projector: unknown
426+
) {
427+
return (
428+
Array.isArray(observablesOrSelectorsObject) &&
429+
observablesOrSelectorsObject.length === 0 &&
430+
projector
431+
);
432+
}
433+
434+
function noopOperator(): <T>(source$: Observable<T>) => typeof source$ {
435+
return (source$) => source$;
436+
}

0 commit comments

Comments
 (0)