Skip to content

Commit 2146774

Browse files
feat(component-store): add imperative reads (#2614)
1 parent 4d00bda commit 2146774

File tree

3 files changed

+197
-14
lines changed

3 files changed

+197
-14
lines changed

modules/component-store/spec/component-store.spec.ts

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
map,
1616
tap,
1717
finalize,
18-
observeOn,
1918
} from 'rxjs/operators';
2019

2120
describe('Component Store', () => {
@@ -63,12 +62,24 @@ describe('Component Store', () => {
6362
const componentStore = new ComponentStore();
6463

6564
m.expect(componentStore.state$).toBeObservable(
66-
m.hot('#', {}, new Error('ComponentStore has not been initialized'))
65+
m.hot(
66+
'#',
67+
{},
68+
new Error(
69+
'ComponentStore has not been initialized yet. ' +
70+
'Please make sure it is initialized before updating/getting.'
71+
)
72+
)
6773
);
6874

6975
expect(() => {
7076
componentStore.setState(() => ({ setState: 'new state' }));
71-
}).toThrow(new Error('ComponentStore has not been initialized'));
77+
}).toThrow(
78+
new Error(
79+
'ComponentStore has not been initialized yet. ' +
80+
'Please make sure it is initialized before updating/getting.'
81+
)
82+
);
7283
})
7384
);
7485

@@ -78,14 +89,26 @@ describe('Component Store', () => {
7889
const componentStore = new ComponentStore();
7990

8091
m.expect(componentStore.state$).toBeObservable(
81-
m.hot('#', {}, new Error('ComponentStore has not been initialized'))
92+
m.hot(
93+
'#',
94+
{},
95+
new Error(
96+
'ComponentStore has not been initialized yet. ' +
97+
'Please make sure it is initialized before updating/getting.'
98+
)
99+
)
82100
);
83101

84102
expect(() => {
85103
componentStore.updater((state, value: object) => value)({
86104
updater: 'new state',
87105
});
88-
}).toThrow(new Error('ComponentStore has not been initialized'));
106+
}).toThrow(
107+
new Error(
108+
'ComponentStore has not been initialized yet. ' +
109+
'Please make sure it is initialized before updating/getting.'
110+
)
111+
);
89112
})
90113
);
91114

@@ -99,14 +122,26 @@ describe('Component Store', () => {
99122
});
100123

101124
m.expect(componentStore.state$).toBeObservable(
102-
m.hot('#', {}, new Error('ComponentStore has not been initialized'))
125+
m.hot(
126+
'#',
127+
{},
128+
new Error(
129+
'ComponentStore has not been initialized yet. ' +
130+
'Please make sure it is initialized before updating/getting.'
131+
)
132+
)
103133
);
104134

105135
expect(() => {
106136
componentStore.updater<object>((state, value) => value)(
107137
syncronousObservable$
108138
);
109-
}).toThrow(new Error('ComponentStore has not been initialized'));
139+
}).toThrow(
140+
new Error(
141+
'ComponentStore has not been initialized yet. ' +
142+
'Please make sure it is initialized before updating/getting.'
143+
)
144+
);
110145
})
111146
);
112147

@@ -123,7 +158,14 @@ describe('Component Store', () => {
123158
let subscription: Subscription | undefined;
124159

125160
m.expect(componentStore.state$).toBeObservable(
126-
m.hot('-#', {}, new Error('ComponentStore has not been initialized'))
161+
m.hot(
162+
'-#',
163+
{},
164+
new Error(
165+
'ComponentStore has not been initialized yet. ' +
166+
'Please make sure it is initialized before updating/getting.'
167+
)
168+
)
127169
);
128170

129171
expect(() => {
@@ -1221,4 +1263,60 @@ describe('Component Store', () => {
12211263
});
12221264
});
12231265
});
1266+
1267+
describe('get', () => {
1268+
interface State {
1269+
value: string;
1270+
}
1271+
1272+
class ExposedGetComponentStore extends ComponentStore<State> {
1273+
get = super.get;
1274+
}
1275+
1276+
let componentStore: ExposedGetComponentStore;
1277+
1278+
it('throws an Error if called before the state is initialized', () => {
1279+
componentStore = new ExposedGetComponentStore();
1280+
1281+
expect(() => {
1282+
componentStore.get((state) => state.value);
1283+
}).toThrow(
1284+
new Error(
1285+
'ExposedGetComponentStore has not been initialized yet. ' +
1286+
'Please make sure it is initialized before updating/getting.'
1287+
)
1288+
);
1289+
});
1290+
1291+
it('does not throw an Error when initialized', () => {
1292+
componentStore = new ExposedGetComponentStore();
1293+
componentStore.setState({ value: 'init' });
1294+
1295+
expect(() => {
1296+
componentStore.get((state) => state.value);
1297+
}).not.toThrow();
1298+
});
1299+
1300+
it('provides values from the state', () => {
1301+
componentStore = new ExposedGetComponentStore();
1302+
componentStore.setState({ value: 'init' });
1303+
1304+
expect(componentStore.get((state) => state.value)).toBe('init');
1305+
1306+
componentStore.updater((state, value: string) => ({ value }))('updated');
1307+
1308+
expect(componentStore.get((state) => state.value)).toBe('updated');
1309+
});
1310+
1311+
it('provides the entire state when projector fn is not provided', () => {
1312+
componentStore = new ExposedGetComponentStore();
1313+
componentStore.setState({ value: 'init' });
1314+
1315+
expect(componentStore.get()).toEqual({ value: 'init' });
1316+
1317+
componentStore.updater((state, value: string) => ({ value }))('updated');
1318+
1319+
expect(componentStore.get()).toEqual({ value: 'updated' });
1320+
});
1321+
});
12241322
});

modules/component-store/spec/integration.spec.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
tick,
99
} from '@angular/core/testing';
1010
import { CommonModule } from '@angular/common';
11-
import { interval, Observable } from 'rxjs';
12-
import { tap } from 'rxjs/operators';
11+
import { interval, Observable, of, EMPTY } from 'rxjs';
12+
import { tap, concatMap, catchError } from 'rxjs/operators';
1313
import { By } from '@angular/platform-browser';
1414

1515
describe('ComponentStore integration', () => {
@@ -138,6 +138,34 @@ describe('ComponentStore integration', () => {
138138
testWith(setupComponentExtendsService);
139139
});
140140

141+
describe('ComponentStore getter', () => {
142+
let state: ReturnType<typeof setupComponentProvidesService> extends Promise<
143+
infer P
144+
>
145+
? P
146+
: never;
147+
beforeEach(async () => {
148+
state = await setupComponentProvidesService();
149+
});
150+
151+
it('provides correct instant values within effect', fakeAsync(() => {
152+
state.child.init();
153+
154+
tick(40); // Prop2 should be at value '3' now
155+
state.child.call('test one:');
156+
157+
expect(state.serviceCallSpy).toHaveBeenCalledWith('test one:3');
158+
159+
tick(20); // Prop2 should be at value '5' now
160+
state.child.call('test two:');
161+
162+
expect(state.serviceCallSpy).toHaveBeenCalledWith('test two:5');
163+
164+
// clear "Periodic timers in queue"
165+
state.destroy();
166+
}));
167+
});
168+
141169
interface State {
142170
prop: string;
143171
prop2?: number;
@@ -305,10 +333,22 @@ describe('ComponentStore integration', () => {
305333
}
306334

307335
async function setupComponentProvidesService() {
336+
@Injectable({ providedIn: 'root' })
337+
class Service {
338+
call(arg: string) {
339+
return of('result');
340+
}
341+
}
342+
343+
function getProp2(state: State): number | undefined {
344+
return state.prop2;
345+
}
346+
308347
@Injectable()
309348
class PropsStore extends ComponentStore<State> {
310349
prop$ = this.select((state) => state.prop);
311-
prop2$ = this.select((state) => state.prop2);
350+
// projector function 👇 reused in selector and getter
351+
prop2$ = this.select(getProp2);
312352
propDebounce$ = this.select((state) => state.prop, { debounce: true });
313353

314354
propUpdater = this.updater((state, value: string) => ({
@@ -327,6 +367,28 @@ describe('ComponentStore integration', () => {
327367
})
328368
)
329369
);
370+
371+
callService = this.effect((strings$: Observable<string>) => {
372+
return strings$.pipe(
373+
// getting value from State imperatively 👇
374+
concatMap((str) =>
375+
this.service.call(str + this.get(getProp2)).pipe(
376+
tap({
377+
next: (v) => this.propUpdater(v),
378+
error: () => {
379+
/* handle error */
380+
},
381+
}),
382+
// make sure to catch errors
383+
catchError((e) => EMPTY)
384+
)
385+
)
386+
);
387+
});
388+
389+
constructor(private readonly service: Service) {
390+
super();
391+
}
330392
}
331393

332394
@Component({
@@ -350,17 +412,24 @@ describe('ComponentStore integration', () => {
350412
updateProp(value: string): void {
351413
this.propsStore.propUpdater(value);
352414
}
415+
416+
call(str: string) {
417+
this.propsStore.callService(str);
418+
}
353419
}
354420

355421
const setup = await setupTestBed(ChildComponent);
356422
const componentStoreDestroySpy = jest.spyOn(
357423
setup.child.propsStore,
358424
'ngOnDestroy'
359425
);
426+
427+
const serviceCallSpy = jest.spyOn(TestBed.get(Service), 'call');
360428
return {
361429
...setup,
362430
destroy: () => setup.child.propsStore.ngOnDestroy(),
363431
componentStoreDestroySpy,
432+
serviceCallSpy,
364433
};
365434
}
366435

modules/component-store/src/component-store.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
map,
1818
distinctUntilChanged,
1919
shareReplay,
20+
take,
2021
} from 'rxjs/operators';
2122
import { debounceSync } from './debounce-sync';
2223
import {
@@ -51,6 +52,9 @@ export class ComponentStore<T extends object> implements OnDestroy {
5152

5253
private readonly stateSubject$ = new ReplaySubject<T>(1);
5354
private isInitialized = false;
55+
private notInitializedErrorMessage =
56+
`${this.constructor.name} has not been initialized yet. ` +
57+
`Please make sure it is initialized before updating/getting.`;
5458
// Needs to be after destroy$ is declared because it's used in select.
5559
readonly state$: Observable<T> = this.select((s) => s);
5660

@@ -102,9 +106,7 @@ export class ComponentStore<T extends object> implements OnDestroy {
102106
withLatestFrom(this.stateSubject$)
103107
)
104108
: // If state was not initialized, we'll throw an error.
105-
throwError(
106-
new Error(`${this.constructor.name} has not been initialized`)
107-
)
109+
throwError(new Error(this.notInitializedErrorMessage))
108110
),
109111
takeUntil(this.destroy$)
110112
)
@@ -152,6 +154,20 @@ export class ComponentStore<T extends object> implements OnDestroy {
152154
}
153155
}
154156

157+
protected get(): T;
158+
protected get<R>(projector: (s: T) => R): R;
159+
protected get<R>(projector?: (s: T) => R): R | T {
160+
if (!this.isInitialized) {
161+
throw new Error(this.notInitializedErrorMessage);
162+
}
163+
let value: R | T;
164+
165+
this.stateSubject$.pipe(take(1)).subscribe((state) => {
166+
value = projector ? projector(state) : state;
167+
});
168+
return value!;
169+
}
170+
155171
/**
156172
* Creates a selector.
157173
*

0 commit comments

Comments
 (0)