-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(component-store): make library compatible with ViewEngine (#2580)
- signals-18.0.0
- signals-18.0.0-rc.3
- signals-18.0.0-rc.2
- 19.0.0
- 19.0.0-rc.0
- 19.0.0-beta.0
- 18.1.1
- 18.1.0
- 18.0.2
- 18.0.1
- 18.0.0
- 18.0.0-rc.1
- 18.0.0-rc.0
- 18.0.0-beta.1
- 18.0.0-beta.0
- 17.2.0
- 17.1.1
- 17.1.0
- 17.0.1
- 17.0.0
- 17.0.0-rc.0
- 16.3.0
- 16.2.0
- 16.1.0
- 16.0.1
- 16.0.0
- 16.0.0-rc.1
- 16.0.0-rc.0
- 16.0.0-beta.0
- 15.4.0
- 15.3.0
- 15.2.1
- 15.2.0
- 15.1.0
- 15.0.0
- 15.0.0-rc.0
- 15.0.0-beta.1
- 15.0.0-beta.0
- 14.3.3
- 14.3.2
- 14.3.1
- 14.3.0
- 14.2.0
- 14.1.0
- 14.0.2
- 14.0.1
- 14.0.0
- 14.0.0-rc.0
- 14.0.0-beta.0
- 13.2.0
- 13.1.0
- 13.0.2
- 13.0.1
- 13.0.0
- 13.0.0-rc.0
- 13.0.0-beta.0
- 12.5.1
- 12.5.0
- 12.4.0
- 12.3.0
- 12.2.0
- 12.1.0
- 12.0.0
- 12.0.0-rc.0
- 12.0.0-beta.1
- 12.0.0-beta.0
- 11.1.1
- 11.1.0
- 11.0.1
- 11.0.0
- 11.0.0-rc.0
- 11.0.0-beta.2
- 11.0.0-beta.1
- 11.0.0-beta.0
- 10.1.2
- 10.1.1
- 10.1.0
- 10.0.1
- 10.0.0
- 10.0.0-rc.0
- 10.0.0-beta.1
- 10.0.0-beta.0
1 parent
ed28449
commit ba0818e
Showing
2 changed files
with
381 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters