Skip to content

Commit ba0818e

Browse files
feat(component-store): make library compatible with ViewEngine (#2580)
1 parent ed28449 commit ba0818e

File tree

2 files changed

+381
-2
lines changed

2 files changed

+381
-2
lines changed
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
import { Component, Type, Injectable } from '@angular/core';
2+
import { ComponentStore } from '../src';
3+
import { TestBed, ComponentFixture } from '@angular/core/testing';
4+
import { CommonModule } from '@angular/common';
5+
import { interval, Observable } from 'rxjs';
6+
import { fakeSchedulers } from 'rxjs-marbles/jest';
7+
import { tap } from 'rxjs/operators';
8+
import { By } from '@angular/platform-browser';
9+
10+
describe('ComponentStore integration', () => {
11+
jest.useFakeTimers();
12+
13+
// The same set of tests is run against different versions of how
14+
// ComponentStore may be used - making sure all of them work.
15+
function testWith(setup: () => Promise<SetupData<Child>>) {
16+
it('does not emit until state is initialized', async () => {
17+
const state = await setup();
18+
19+
expect(state.parent.isChildVisible).toBe(true);
20+
expect(state.hasChild()).toBe(true);
21+
22+
state.fixture.detectChanges();
23+
// No values emitted, 👇 no initial state
24+
expect(state.propChanges).toEqual([]);
25+
expect(state.prop2Changes).toEqual([]);
26+
});
27+
28+
it('gets initial value when state is initialized', async () => {
29+
const state = await setup();
30+
31+
state.child.init();
32+
// init state👇
33+
expect(state.propChanges).toEqual(['initial Value']);
34+
expect(state.prop2Changes).toEqual([undefined]);
35+
});
36+
37+
it(
38+
'effect updates values',
39+
fakeSchedulers(async (advance) => {
40+
const state = await setup();
41+
42+
state.child.init();
43+
44+
advance(40);
45+
// New value pushed every 10 ms.
46+
expect(state.prop2Changes).toEqual([undefined, 0, 1, 2, 3]);
47+
})
48+
);
49+
50+
it('updates values imperatively', async () => {
51+
const state = await setup();
52+
53+
state.child.init();
54+
55+
state.child.updateProp('new value');
56+
state.child.updateProp('yay!!!');
57+
58+
expect(state.propChanges).toContain('new value');
59+
expect(state.propChanges).toContain('yay!!!');
60+
});
61+
62+
it(
63+
'stops observables when destroyed',
64+
fakeSchedulers(async (advance) => {
65+
const state = await setup();
66+
67+
state.child.init();
68+
69+
advance(40);
70+
// New value pushed every 10 ms.
71+
expect(state.prop2Changes).toEqual([undefined, 0, 1, 2, 3]);
72+
73+
state.parent.isChildVisible = false;
74+
state.fixture.detectChanges();
75+
76+
advance(20);
77+
// Still at the same values, so effect stopped running
78+
expect(state.prop2Changes).toEqual([undefined, 0, 1, 2, 3]);
79+
})
80+
);
81+
82+
it('ComponentStore is destroyed', async () => {
83+
const state = await setup();
84+
85+
state.child.init();
86+
87+
state.parent.isChildVisible = false;
88+
state.fixture.detectChanges();
89+
90+
expect(state.componentStoreDestroySpy).toHaveBeenCalled();
91+
});
92+
}
93+
94+
describe('Component uses ComponentStore directly in providers', () => {
95+
testWith(setupComponentProvidesComponentStore);
96+
});
97+
98+
describe('Component uses ComponentStore directly by extending it', () => {
99+
testWith(setupComponentExtendsComponentStore);
100+
});
101+
102+
describe('Component provides a Service that extends ComponentStore', () => {
103+
testWith(setupComponentProvidesService);
104+
});
105+
106+
describe('Component extends a Service that extends ComponentStore', () => {
107+
testWith(setupComponentExtendsService);
108+
});
109+
110+
interface State {
111+
prop: string;
112+
prop2?: number;
113+
}
114+
115+
interface Parent {
116+
isChildVisible: boolean;
117+
}
118+
119+
interface Child {
120+
prop$: Observable<string>;
121+
prop2$: Observable<number | undefined>;
122+
init: () => void;
123+
updateProp(value: string): void;
124+
}
125+
126+
interface SetupData<T extends Child> {
127+
fixture: ComponentFixture<Parent>;
128+
parent: Parent;
129+
child: T;
130+
hasChild: () => boolean;
131+
propChanges: string[];
132+
prop2Changes: Array<number | undefined>;
133+
componentStoreDestroySpy: jest.SpyInstance;
134+
}
135+
136+
@Component({
137+
selector: 'body',
138+
template: '<child *ngIf="isChildVisible"></child>',
139+
})
140+
class ParentComponent implements Parent {
141+
isChildVisible = true;
142+
}
143+
144+
async function setupTestBed<T extends Child>(
145+
childClass: Type<T>
146+
): Promise<Omit<SetupData<T>, 'componentStoreDestroySpy'>> {
147+
await TestBed.configureTestingModule({
148+
declarations: [ParentComponent, childClass],
149+
imports: [CommonModule],
150+
}).compileComponents();
151+
152+
const fixture = TestBed.createComponent(ParentComponent);
153+
fixture.detectChanges();
154+
155+
function getChild(): T | undefined {
156+
const debugEl = fixture.debugElement.query(By.css('child'));
157+
if (debugEl) {
158+
return debugEl.componentInstance as T;
159+
}
160+
return undefined;
161+
}
162+
163+
const propChanges: string[] = [];
164+
const prop2Changes: Array<number | undefined> = [];
165+
const child = getChild()!;
166+
child.prop$.subscribe((v) => propChanges.push(v));
167+
child.prop2$.subscribe((v) => prop2Changes.push(v));
168+
169+
return {
170+
fixture,
171+
parent: fixture.componentInstance,
172+
child,
173+
hasChild: () => !!getChild(),
174+
propChanges,
175+
prop2Changes,
176+
};
177+
}
178+
179+
async function setupComponentProvidesComponentStore() {
180+
@Component({
181+
selector: 'child',
182+
template: '<div>{{prop$ | async}}</div>',
183+
providers: [ComponentStore],
184+
})
185+
class ChildComponent implements Child {
186+
prop$ = this.componentStore.select((state) => state.prop);
187+
prop2$ = this.componentStore.select((state) => state.prop2);
188+
189+
intervalToProp2Effect = this.componentStore.effect(
190+
(numbers$: Observable<number>) =>
191+
numbers$.pipe(
192+
tap((n) => {
193+
this.componentStore.setState((state) => ({
194+
...state,
195+
prop2: n,
196+
}));
197+
})
198+
)
199+
);
200+
interval$ = interval(10);
201+
202+
constructor(readonly componentStore: ComponentStore<State>) {}
203+
204+
init() {
205+
this.componentStore.setState({ prop: 'initial Value' });
206+
this.intervalToProp2Effect(this.interval$);
207+
}
208+
209+
updateProp(value: string): void {
210+
this.componentStore.setState((state) => ({ ...state, prop: value }));
211+
}
212+
}
213+
214+
const setup = await setupTestBed(ChildComponent);
215+
const componentStoreDestroySpy = jest.spyOn(
216+
setup.child.componentStore,
217+
'ngOnDestroy'
218+
);
219+
return {
220+
...setup,
221+
componentStoreDestroySpy,
222+
};
223+
}
224+
225+
async function setupComponentExtendsComponentStore() {
226+
@Component({
227+
selector: 'child',
228+
template: '<div>{{prop$ | async}}</div>',
229+
})
230+
class ChildComponent extends ComponentStore<State> implements Child {
231+
prop$ = this.select((state) => state.prop);
232+
prop2$ = this.select((state) => state.prop2);
233+
234+
intervalToProp2Effect = this.effect((numbers$: Observable<number>) =>
235+
numbers$.pipe(
236+
tap((n) => {
237+
this.setState((state) => ({
238+
...state,
239+
prop2: n,
240+
}));
241+
})
242+
)
243+
);
244+
interval$ = interval(10);
245+
246+
init() {
247+
this.setState({ prop: 'initial Value' });
248+
this.intervalToProp2Effect(this.interval$);
249+
}
250+
251+
updateProp(value: string): void {
252+
this.setState((state) => ({ ...state, prop: value }));
253+
}
254+
}
255+
256+
const setup = await setupTestBed(ChildComponent);
257+
const componentStoreDestroySpy = jest.spyOn(setup.child, 'ngOnDestroy');
258+
return {
259+
...setup,
260+
componentStoreDestroySpy,
261+
};
262+
}
263+
264+
async function setupComponentProvidesService() {
265+
@Injectable()
266+
class PropsStore extends ComponentStore<State> {
267+
prop$ = this.select((state) => state.prop);
268+
prop2$ = this.select((state) => state.prop2);
269+
270+
propUpdater = this.updater((state, value: string) => ({
271+
...state,
272+
prop: value,
273+
}));
274+
prop2Updater = this.updater((state, value: number) => ({
275+
...state,
276+
prop2: value,
277+
}));
278+
279+
intervalToProp2Effect = this.effect((numbers$: Observable<number>) =>
280+
numbers$.pipe(
281+
tap((n) => {
282+
this.prop2Updater(n);
283+
})
284+
)
285+
);
286+
}
287+
288+
@Component({
289+
selector: 'child',
290+
template: '<div>{{prop$ | async}}</div>',
291+
providers: [PropsStore],
292+
})
293+
class ChildComponent implements Child {
294+
prop$ = this.propsStore.prop$;
295+
prop2$ = this.propsStore.prop2$;
296+
interval$ = interval(10);
297+
298+
constructor(readonly propsStore: PropsStore) {}
299+
300+
init() {
301+
this.propsStore.setState({ prop: 'initial Value' });
302+
this.propsStore.intervalToProp2Effect(this.interval$);
303+
}
304+
305+
updateProp(value: string): void {
306+
this.propsStore.propUpdater(value);
307+
}
308+
}
309+
310+
const setup = await setupTestBed(ChildComponent);
311+
const componentStoreDestroySpy = jest.spyOn(
312+
setup.child.propsStore,
313+
'ngOnDestroy'
314+
);
315+
return {
316+
...setup,
317+
componentStoreDestroySpy,
318+
};
319+
}
320+
321+
async function setupComponentExtendsService() {
322+
@Injectable()
323+
class PropsStore extends ComponentStore<State> {
324+
prop$ = this.select((state) => state.prop);
325+
prop2$ = this.select((state) => state.prop2);
326+
327+
propUpdater = this.updater((state, value: string) => ({
328+
...state,
329+
prop: value,
330+
}));
331+
prop2Updater = this.updater((state, value: number) => ({
332+
...state,
333+
prop2: value,
334+
}));
335+
336+
intervalToProp2Effect = this.effect((numbers$: Observable<number>) =>
337+
numbers$.pipe(
338+
tap((n) => {
339+
this.prop2Updater(n);
340+
})
341+
)
342+
);
343+
}
344+
345+
@Component({
346+
selector: 'child',
347+
template: '<div>{{prop$ | async}}</div>',
348+
})
349+
class ChildComponent extends PropsStore implements Child {
350+
interval$ = interval(10);
351+
352+
init() {
353+
this.setState({ prop: 'initial Value' });
354+
this.intervalToProp2Effect(this.interval$);
355+
}
356+
357+
updateProp(value: string): void {
358+
this.propUpdater(value);
359+
}
360+
}
361+
362+
const setup = await setupTestBed(ChildComponent);
363+
const componentStoreDestroySpy = jest.spyOn(setup.child, 'ngOnDestroy');
364+
return {
365+
...setup,
366+
componentStoreDestroySpy,
367+
};
368+
}
369+
});

0 commit comments

Comments
 (0)