Skip to content

Commit

Permalink
Showing 2 changed files with 381 additions and 2 deletions.
369 changes: 369 additions & 0 deletions modules/component-store/spec/integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
import { Component, Type, Injectable } from '@angular/core';
import { ComponentStore } from '../src';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { interval, Observable } from 'rxjs';
import { fakeSchedulers } from 'rxjs-marbles/jest';
import { tap } from 'rxjs/operators';
import { By } from '@angular/platform-browser';

describe('ComponentStore integration', () => {
jest.useFakeTimers();

// The same set of tests is run against different versions of how
// ComponentStore may be used - making sure all of them work.
function testWith(setup: () => Promise<SetupData<Child>>) {
it('does not emit until state is initialized', async () => {
const state = await setup();

expect(state.parent.isChildVisible).toBe(true);
expect(state.hasChild()).toBe(true);

state.fixture.detectChanges();
// No values emitted, 👇 no initial state
expect(state.propChanges).toEqual([]);
expect(state.prop2Changes).toEqual([]);
});

it('gets initial value when state is initialized', async () => {
const state = await setup();

state.child.init();
// init state👇
expect(state.propChanges).toEqual(['initial Value']);
expect(state.prop2Changes).toEqual([undefined]);
});

it(
'effect updates values',
fakeSchedulers(async (advance) => {
const state = await setup();

state.child.init();

advance(40);
// New value pushed every 10 ms.
expect(state.prop2Changes).toEqual([undefined, 0, 1, 2, 3]);
})
);

it('updates values imperatively', async () => {
const state = await setup();

state.child.init();

state.child.updateProp('new value');
state.child.updateProp('yay!!!');

expect(state.propChanges).toContain('new value');
expect(state.propChanges).toContain('yay!!!');
});

it(
'stops observables when destroyed',
fakeSchedulers(async (advance) => {
const state = await setup();

state.child.init();

advance(40);
// New value pushed every 10 ms.
expect(state.prop2Changes).toEqual([undefined, 0, 1, 2, 3]);

state.parent.isChildVisible = false;
state.fixture.detectChanges();

advance(20);
// Still at the same values, so effect stopped running
expect(state.prop2Changes).toEqual([undefined, 0, 1, 2, 3]);
})
);

it('ComponentStore is destroyed', async () => {
const state = await setup();

state.child.init();

state.parent.isChildVisible = false;
state.fixture.detectChanges();

expect(state.componentStoreDestroySpy).toHaveBeenCalled();
});
}

describe('Component uses ComponentStore directly in providers', () => {
testWith(setupComponentProvidesComponentStore);
});

describe('Component uses ComponentStore directly by extending it', () => {
testWith(setupComponentExtendsComponentStore);
});

describe('Component provides a Service that extends ComponentStore', () => {
testWith(setupComponentProvidesService);
});

describe('Component extends a Service that extends ComponentStore', () => {
testWith(setupComponentExtendsService);
});

interface State {
prop: string;
prop2?: number;
}

interface Parent {
isChildVisible: boolean;
}

interface Child {
prop$: Observable<string>;
prop2$: Observable<number | undefined>;
init: () => void;
updateProp(value: string): void;
}

interface SetupData<T extends Child> {
fixture: ComponentFixture<Parent>;
parent: Parent;
child: T;
hasChild: () => boolean;
propChanges: string[];
prop2Changes: Array<number | undefined>;
componentStoreDestroySpy: jest.SpyInstance;
}

@Component({
selector: 'body',
template: '<child *ngIf="isChildVisible"></child>',
})
class ParentComponent implements Parent {
isChildVisible = true;
}

async function setupTestBed<T extends Child>(
childClass: Type<T>
): Promise<Omit<SetupData<T>, 'componentStoreDestroySpy'>> {
await TestBed.configureTestingModule({
declarations: [ParentComponent, childClass],
imports: [CommonModule],
}).compileComponents();

const fixture = TestBed.createComponent(ParentComponent);
fixture.detectChanges();

function getChild(): T | undefined {
const debugEl = fixture.debugElement.query(By.css('child'));
if (debugEl) {
return debugEl.componentInstance as T;
}
return undefined;
}

const propChanges: string[] = [];
const prop2Changes: Array<number | undefined> = [];
const child = getChild()!;
child.prop$.subscribe((v) => propChanges.push(v));
child.prop2$.subscribe((v) => prop2Changes.push(v));

return {
fixture,
parent: fixture.componentInstance,
child,
hasChild: () => !!getChild(),
propChanges,
prop2Changes,
};
}

async function setupComponentProvidesComponentStore() {
@Component({
selector: 'child',
template: '<div>{{prop$ | async}}</div>',
providers: [ComponentStore],
})
class ChildComponent implements Child {
prop$ = this.componentStore.select((state) => state.prop);
prop2$ = this.componentStore.select((state) => state.prop2);

intervalToProp2Effect = this.componentStore.effect(
(numbers$: Observable<number>) =>
numbers$.pipe(
tap((n) => {
this.componentStore.setState((state) => ({
...state,
prop2: n,
}));
})
)
);
interval$ = interval(10);

constructor(readonly componentStore: ComponentStore<State>) {}

init() {
this.componentStore.setState({ prop: 'initial Value' });
this.intervalToProp2Effect(this.interval$);
}

updateProp(value: string): void {
this.componentStore.setState((state) => ({ ...state, prop: value }));
}
}

const setup = await setupTestBed(ChildComponent);
const componentStoreDestroySpy = jest.spyOn(
setup.child.componentStore,
'ngOnDestroy'
);
return {
...setup,
componentStoreDestroySpy,
};
}

async function setupComponentExtendsComponentStore() {
@Component({
selector: 'child',
template: '<div>{{prop$ | async}}</div>',
})
class ChildComponent extends ComponentStore<State> implements Child {
prop$ = this.select((state) => state.prop);
prop2$ = this.select((state) => state.prop2);

intervalToProp2Effect = this.effect((numbers$: Observable<number>) =>
numbers$.pipe(
tap((n) => {
this.setState((state) => ({
...state,
prop2: n,
}));
})
)
);
interval$ = interval(10);

init() {
this.setState({ prop: 'initial Value' });
this.intervalToProp2Effect(this.interval$);
}

updateProp(value: string): void {
this.setState((state) => ({ ...state, prop: value }));
}
}

const setup = await setupTestBed(ChildComponent);
const componentStoreDestroySpy = jest.spyOn(setup.child, 'ngOnDestroy');
return {
...setup,
componentStoreDestroySpy,
};
}

async function setupComponentProvidesService() {
@Injectable()
class PropsStore extends ComponentStore<State> {
prop$ = this.select((state) => state.prop);
prop2$ = this.select((state) => state.prop2);

propUpdater = this.updater((state, value: string) => ({
...state,
prop: value,
}));
prop2Updater = this.updater((state, value: number) => ({
...state,
prop2: value,
}));

intervalToProp2Effect = this.effect((numbers$: Observable<number>) =>
numbers$.pipe(
tap((n) => {
this.prop2Updater(n);
})
)
);
}

@Component({
selector: 'child',
template: '<div>{{prop$ | async}}</div>',
providers: [PropsStore],
})
class ChildComponent implements Child {
prop$ = this.propsStore.prop$;
prop2$ = this.propsStore.prop2$;
interval$ = interval(10);

constructor(readonly propsStore: PropsStore) {}

init() {
this.propsStore.setState({ prop: 'initial Value' });
this.propsStore.intervalToProp2Effect(this.interval$);
}

updateProp(value: string): void {
this.propsStore.propUpdater(value);
}
}

const setup = await setupTestBed(ChildComponent);
const componentStoreDestroySpy = jest.spyOn(
setup.child.propsStore,
'ngOnDestroy'
);
return {
...setup,
componentStoreDestroySpy,
};
}

async function setupComponentExtendsService() {
@Injectable()
class PropsStore extends ComponentStore<State> {
prop$ = this.select((state) => state.prop);
prop2$ = this.select((state) => state.prop2);

propUpdater = this.updater((state, value: string) => ({
...state,
prop: value,
}));
prop2Updater = this.updater((state, value: number) => ({
...state,
prop2: value,
}));

intervalToProp2Effect = this.effect((numbers$: Observable<number>) =>
numbers$.pipe(
tap((n) => {
this.prop2Updater(n);
})
)
);
}

@Component({
selector: 'child',
template: '<div>{{prop$ | async}}</div>',
})
class ChildComponent extends PropsStore implements Child {
interval$ = interval(10);

init() {
this.setState({ prop: 'initial Value' });
this.intervalToProp2Effect(this.interval$);
}

updateProp(value: string): void {
this.propUpdater(value);
}
}

const setup = await setupTestBed(ChildComponent);
const componentStoreDestroySpy = jest.spyOn(setup.child, 'ngOnDestroy');
return {
...setup,
componentStoreDestroySpy,
};
}
});
14 changes: 12 additions & 2 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
@@ -17,6 +17,13 @@ import {
shareReplay,
} from 'rxjs/operators';
import { debounceSync } from './debounceSync';
import {
Injectable,
OnDestroy,
Optional,
InjectionToken,
Inject,
} from '@angular/core';

/**
* Return type of the effect, that behaves differently based on whether the
@@ -27,7 +34,10 @@ export interface EffectReturnFn<T> {
(t: T | Observable<T>): Subscription;
}

export class ComponentStore<T extends object> {
export const initialStateToken = new InjectionToken('ComponentStore InitState');

@Injectable()
export class ComponentStore<T extends object> implements OnDestroy {
// Should be used only in ngOnDestroy.
private readonly destroySubject$ = new ReplaySubject<void>(1);
// Exposed to any extending Store to be used for the teardowns.
@@ -38,7 +48,7 @@ export class ComponentStore<T extends object> {
// Needs to be after destroy$ is declared because it's used in select.
readonly state$: Observable<T> = this.select((s) => s);

constructor(defaultState?: T) {
constructor(@Optional() @Inject(initialStateToken) defaultState?: T) {
// State can be initialized either through constructor, or initState or
// setState.
if (defaultState) {

0 comments on commit ba0818e

Please sign in to comment.