Skip to content

Commit

Permalink
feat(component-store): Add SelectorObject to select (#3629)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Oct 28, 2022
1 parent 96c5bdd commit f8d0241
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 67 deletions.
130 changes: 108 additions & 22 deletions modules/component-store/spec/component-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,43 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
Inject,
Injectable,
InjectionToken,
Injector,
Provider,
} from '@angular/core';
import { fakeAsync, flushMicrotasks } from '@angular/core/testing';
import {
ComponentStore,
OnStateInit,
OnStoreInit,
provideComponentStore,
} from '@ngrx/component-store';
import { fakeSchedulers, marbles } from 'rxjs-marbles/jest';
import { createSelector } from '@ngrx/store';
import {
of,
Subscription,
asyncScheduler,
ConnectableObservable,
from,
interval,
timer,
Observable,
from,
scheduled,
of,
queueScheduler,
asyncScheduler,
scheduled,
Subscription,
throwError,
timer,
} from 'rxjs';
import { fakeSchedulers, marbles } from 'rxjs-marbles/jest';
import {
concatMap,
delay,
delayWhen,
finalize,
map,
publishReplay,
take,
map,
tap,
finalize,
delay,
concatMap,
} from 'rxjs/operators';
import { createSelector } from '@ngrx/store';
import {
Inject,
Injectable,
InjectionToken,
Injector,
Provider,
} from '@angular/core';
import { fakeAsync, flushMicrotasks } from '@angular/core/testing';

describe('Component Store', () => {
describe('initialization', () => {
Expand All @@ -53,6 +53,18 @@ describe('Component Store', () => {
})
);

it(
'supports an array state',
marbles((m) => {
const INIT_STATE = [1, 2, 3];
const componentStore = new ComponentStore(INIT_STATE);

m.expect(componentStore.state$).toBeObservable(
m.hot('i', { i: INIT_STATE })
);
})
);

it(
'stays uninitialized if initial state is not provided',
marbles((m) => {
Expand Down Expand Up @@ -372,7 +384,7 @@ describe('Component Store', () => {
},
]);

// New subsriber gets the latest value only.
// New subscriber gets the latest value only.
m.expect(componentStore.state$).toBeObservable(
m.hot('s', {
s: {
Expand Down Expand Up @@ -432,7 +444,7 @@ describe('Component Store', () => {
});

describe('cancels updater Observable', () => {
beforeEach(() => jest.useFakeTimers());
beforeEach(() => jest.useFakeTimers({ legacyFakeTimers: true }));

interface State {
value: string;
Expand Down Expand Up @@ -809,6 +821,80 @@ describe('Component Store', () => {
]);
});

it('can combine into an object through selectorObject', () => {
const selector1 = componentStore.select((s) => s.value);
const selector2 = componentStore.select((s) => s.updated);
const selector3 = componentStore.select({
s1: selector1,
s2: selector2,
});

const selectorResults: Array<{
s1: string;
s2: boolean | undefined;
}> = [];
selector3.subscribe((s3) => {
selectorResults.push(s3);
});

componentStore.setState(() => ({ value: 'new value', updated: true }));

expect(selectorResults).toEqual([
{ s1: 'init', s2: undefined },
{ s1: 'new value', s2: undefined }, // not debounced
{ s1: 'new value', s2: true },
]);
});

it('can combine into an object through a single selectorObject', () => {
const selector1 = componentStore.select((s) => s.value);

const selector2 = componentStore.select({
s1: selector1,
});

const selectorResults: Array<{
s1: string;
}> = [];
selector2.subscribe((s2) => {
selectorResults.push(s2);
});

componentStore.setState(() => ({ value: 'new value', updated: true }));

expect(selectorResults).toEqual([{ s1: 'init' }, { s1: 'new value' }]);
});

it('can combine into an object through selectorObject with debounce', fakeAsync(() => {
const selector1 = componentStore.select((s) => s.value);
const selector2 = componentStore.select((s) => s.updated);
const selector3 = componentStore.select(
{
s1: selector1,
s2: selector2,
},
{ debounce: true }
);

const selectorResults: Array<{
s1: string;
s2: boolean | undefined;
}> = [];
selector3.subscribe((s3) => {
selectorResults.push(s3);
});
flushMicrotasks();

componentStore.setState(() => ({ value: 'new value', updated: true }));
flushMicrotasks();

expect(selectorResults).toEqual([
{ s1: 'init', s2: undefined },
// debounced, so new value for both
{ s1: 'new value', s2: true },
]);
}));

it(
'can combine with other Observables',
marbles((m) => {
Expand Down
134 changes: 89 additions & 45 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
scheduled,
asapScheduler,
EMPTY,
ObservedValueOf,
} from 'rxjs';
import {
takeUntil,
Expand Down Expand Up @@ -225,44 +226,53 @@ export class ComponentStore<T extends object> implements OnDestroy {
projector: (s: T) => Result,
config?: SelectConfig
): Observable<Result>;
select<SelectorsObject extends Record<string, Observable<unknown>>>(
selectorsObject: SelectorsObject,
config?: SelectConfig
): Observable<{
[K in keyof SelectorsObject]: ObservedValueOf<SelectorsObject[K]>;
}>;
select<Selectors extends Observable<unknown>[], Result>(
...args: [...selectors: Selectors, projector: Projector<Selectors, Result>]
...selectorsWithProjector: [
...selectors: Selectors,
projector: Projector<Selectors, Result>
]
): Observable<Result>;
select<Selectors extends Observable<unknown>[], Result>(
...args: [
...selectorsWithProjectorAndConfig: [
...selectors: Selectors,
projector: Projector<Selectors, Result>,
config: SelectConfig
]
): Observable<Result>;
select<
Selectors extends Array<Observable<unknown> | SelectConfig | ProjectorFn>,
Selectors extends Array<
Observable<unknown> | SelectConfig | ProjectorFn | SelectorsObject
>,
Result,
ProjectorFn extends (...a: unknown[]) => Result
ProjectorFn extends (...a: unknown[]) => Result,
SelectorsObject extends Record<string, Observable<unknown>>
>(...args: Selectors): Observable<Result> {
const { observables, projector, config } = processSelectorArgs<
Selectors,
Result,
ProjectorFn
>(args);

let observable$: Observable<Result>;
// If there are no Observables to combine, then we'll just map the value.
if (observables.length === 0) {
observable$ = this.stateSubject$.pipe(
config.debounce ? debounceSync() : (source$) => source$,
map((state) => projector(state))
);
} else {
// If there are multiple arguments, then we're aggregating selectors, so we need
// to take the combineLatest of them before calling the map function.
observable$ = combineLatest(observables).pipe(
config.debounce ? debounceSync() : (source$) => source$,
map((projectorArgs) => projector(...projectorArgs))
const { observablesOrSelectorsObject, projector, config } =
processSelectorArgs<Selectors, Result, ProjectorFn, SelectorsObject>(
args
);
}

return observable$.pipe(
const source$ = hasProjectFnOnly(observablesOrSelectorsObject, projector)
? this.stateSubject$
: combineLatest(observablesOrSelectorsObject as any);

return source$.pipe(
config.debounce ? debounceSync() : noopOperator(),
(projector
? map((projectorArgs) =>
// projectorArgs could be an Array in case where the entire state is an Array, so adding this check
observablesOrSelectorsObject.length > 0 &&
Array.isArray(projectorArgs)
? projector(...projectorArgs)
: projector(projectorArgs)
)
: noopOperator()) as () => Observable<Result>,
distinctUntilChanged(),
shareReplay({
refCount: true,
Expand Down Expand Up @@ -357,36 +367,70 @@ export class ComponentStore<T extends object> implements OnDestroy {
}

function processSelectorArgs<
Selectors extends Array<Observable<unknown> | SelectConfig | ProjectorFn>,
Selectors extends Array<
Observable<unknown> | SelectConfig | ProjectorFn | SelectorsObject
>,
Result,
ProjectorFn extends (...a: unknown[]) => Result
ProjectorFn extends (...a: unknown[]) => Result,
SelectorsObject extends Record<string, Observable<unknown>>
>(
args: Selectors
): {
observables: Observable<unknown>[];
projector: ProjectorFn;
config: Required<SelectConfig>;
} {
):
| {
observablesOrSelectorsObject: Observable<unknown>[];
projector: ProjectorFn;
config: Required<SelectConfig>;
}
| {
observablesOrSelectorsObject: SelectorsObject;
projector: undefined;
config: Required<SelectConfig>;
} {
const selectorArgs = Array.from(args);
// Assign default values.
let config: Required<SelectConfig> = { debounce: false };
let projector: ProjectorFn;
// Last argument is either projector or config
const projectorOrConfig = selectorArgs.pop() as ProjectorFn | SelectConfig;

if (typeof projectorOrConfig !== 'function') {
// We got the config as the last argument, replace any default values with it.
config = { ...config, ...projectorOrConfig };
// Pop the next args, which would be the projector fn.
projector = selectorArgs.pop() as ProjectorFn;
} else {
projector = projectorOrConfig;

// Last argument is either config or projector or selectorsObject
if (isSelectConfig(selectorArgs[selectorArgs.length - 1])) {
config = { ...config, ...selectorArgs.pop() };
}
// The Observables to combine, if there are any.

// At this point selectorArgs is either projector, selectors with projector or selectorsObject
if (selectorArgs.length === 1 && typeof selectorArgs[0] !== 'function') {
// this is a selectorsObject
return {
observablesOrSelectorsObject: selectorArgs[0] as SelectorsObject,
projector: undefined,
config,
};
}

const projector = selectorArgs.pop() as ProjectorFn;

// The Observables to combine, if there are any left.
const observables = selectorArgs as Observable<unknown>[];
return {
observables,
observablesOrSelectorsObject: observables,
projector,
config,
};
}

function isSelectConfig(arg: SelectConfig | unknown): arg is SelectConfig {
return typeof (arg as SelectConfig).debounce !== 'undefined';
}

function hasProjectFnOnly(
observablesOrSelectorsObject: unknown[] | Record<string, unknown>,
projector: unknown
) {
return (
Array.isArray(observablesOrSelectorsObject) &&
observablesOrSelectorsObject.length === 0 &&
projector
);
}

function noopOperator(): <T>(source$: Observable<T>) => typeof source$ {
return (source$) => source$;
}

0 comments on commit f8d0241

Please sign in to comment.