diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index 26ae20c927..538c88cb68 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -15,7 +15,6 @@ import { map, tap, finalize, - observeOn, } from 'rxjs/operators'; describe('Component Store', () => { @@ -1221,4 +1220,46 @@ describe('Component Store', () => { }); }); }); + + describe('get', () => { + interface State { + value: string; + } + + class ExposedGetComponentStore extends ComponentStore { + get = super.get; + } + + let componentStore: ExposedGetComponentStore; + + it('throws an Error if called before the state is initialized', () => { + componentStore = new ExposedGetComponentStore(); + + expect(() => { + componentStore.get((state) => state.value); + }).toThrow( + new Error('ExposedGetComponentStore has not been initialized') + ); + }); + + it('does not throw an Error when initialized', () => { + componentStore = new ExposedGetComponentStore(); + componentStore.setState({ value: 'init' }); + + expect(() => { + componentStore.get((state) => state.value); + }).not.toThrow(); + }); + + it('provides values from the state', () => { + componentStore = new ExposedGetComponentStore(); + componentStore.setState({ value: 'init' }); + + expect(componentStore.get((state) => state.value)).toBe('init'); + + componentStore.updater((state, value: string) => ({ value }))('updated'); + + expect(componentStore.get((state) => state.value)).toBe('updated'); + }); + }); }); diff --git a/modules/component-store/spec/integration.spec.ts b/modules/component-store/spec/integration.spec.ts index d3c6732e89..c458fa01ac 100644 --- a/modules/component-store/spec/integration.spec.ts +++ b/modules/component-store/spec/integration.spec.ts @@ -8,8 +8,8 @@ import { tick, } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; -import { interval, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { interval, Observable, of, EMPTY } from 'rxjs'; +import { tap, concatMap, catchError } from 'rxjs/operators'; import { By } from '@angular/platform-browser'; describe('ComponentStore integration', () => { @@ -138,6 +138,34 @@ describe('ComponentStore integration', () => { testWith(setupComponentExtendsService); }); + describe('ComponentStore getter', () => { + let state: ReturnType extends Promise< + infer P + > + ? P + : never; + beforeEach(async () => { + state = await setupComponentProvidesService(); + }); + + it('provides correct instant values within effect', fakeAsync(() => { + state.child.init(); + + tick(40); // Prop2 should be at value '3' now + state.child.call('test one:'); + + expect(state.serviceCallSpy).toHaveBeenCalledWith('test one:3'); + + tick(20); // Prop2 should be at value '5' now + state.child.call('test two:'); + + expect(state.serviceCallSpy).toHaveBeenCalledWith('test two:5'); + + // clear "Periodic timers in queue" + state.destroy(); + })); + }); + interface State { prop: string; prop2?: number; @@ -305,10 +333,22 @@ describe('ComponentStore integration', () => { } async function setupComponentProvidesService() { + @Injectable({ providedIn: 'root' }) + class Service { + call(arg: string) { + return of('result'); + } + } + + function getProp2(state: State): number | undefined { + return state.prop2; + } + @Injectable() class PropsStore extends ComponentStore { prop$ = this.select((state) => state.prop); - prop2$ = this.select((state) => state.prop2); + // projector function 👇 reused in selector and getter + prop2$ = this.select(getProp2); propDebounce$ = this.select((state) => state.prop, { debounce: true }); propUpdater = this.updater((state, value: string) => ({ @@ -327,6 +367,28 @@ describe('ComponentStore integration', () => { }) ) ); + + callService = this.effect((strings$: Observable) => { + return strings$.pipe( + // getting value from State imperatively 👇 + concatMap((str) => + this.service.call(str + this.get(getProp2)).pipe( + tap({ + next: (v) => this.propUpdater(v), + error: () => { + /* handle error */ + }, + }), + // make sure to catch errors + catchError((e) => EMPTY) + ) + ) + ); + }); + + constructor(private readonly service: Service) { + super(); + } } @Component({ @@ -350,6 +412,10 @@ describe('ComponentStore integration', () => { updateProp(value: string): void { this.propsStore.propUpdater(value); } + + call(str: string) { + this.propsStore.callService(str); + } } const setup = await setupTestBed(ChildComponent); @@ -357,10 +423,13 @@ describe('ComponentStore integration', () => { setup.child.propsStore, 'ngOnDestroy' ); + + const serviceCallSpy = jest.spyOn(TestBed.get(Service), 'call'); return { ...setup, destroy: () => setup.child.propsStore.ngOnDestroy(), componentStoreDestroySpy, + serviceCallSpy, }; } diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 0a433d58c3..5b2481326d 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -17,6 +17,7 @@ import { map, distinctUntilChanged, shareReplay, + take, } from 'rxjs/operators'; import { debounceSync } from './debounce-sync'; import { @@ -51,6 +52,7 @@ export class ComponentStore implements OnDestroy { private readonly stateSubject$ = new ReplaySubject(1); private isInitialized = false; + private notInitializedErrorMessage = `${this.constructor.name} has not been initialized`; // Needs to be after destroy$ is declared because it's used in select. readonly state$: Observable = this.select((s) => s); @@ -102,9 +104,7 @@ export class ComponentStore implements OnDestroy { withLatestFrom(this.stateSubject$) ) : // If state was not initialized, we'll throw an error. - throwError( - new Error(`${this.constructor.name} has not been initialized`) - ) + throwError(new Error(this.notInitializedErrorMessage)) ), takeUntil(this.destroy$) ) @@ -152,6 +152,17 @@ export class ComponentStore implements OnDestroy { } } + protected get(projector: (s: T) => R): R { + if (!this.isInitialized) { + throw new Error(this.notInitializedErrorMessage); + } + let value: R; + this.stateSubject$.pipe(take(1)).subscribe((state) => { + value = projector(state); + }); + return value!; + } + /** * Creates a selector. *