Skip to content

Commit 42efccb

Browse files
feat(component): handle observable dictionaries (#3602)
Closes #3545
1 parent ea13560 commit 42efccb

File tree

14 files changed

+504
-71
lines changed

14 files changed

+504
-71
lines changed

modules/component/spec/core/potential-observable.spec.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,17 @@ describe('fromPotentialObservable', () => {
3232

3333
testNonObservableInput(true, 'boolean');
3434

35-
testNonObservableInput({ ngrx: 'component' }, 'object');
35+
testNonObservableInput({}, 'empty object');
36+
37+
testNonObservableInput(
38+
{ ngrx: 'component' },
39+
'object with non-observable values'
40+
);
41+
42+
testNonObservableInput(
43+
{ x: of(1), y: 2 },
44+
'object with at least one non-observable value'
45+
);
3646

3747
testNonObservableInput([1, 2, 3], 'array');
3848

@@ -52,4 +62,20 @@ describe('fromPotentialObservable', () => {
5262
const obs2$ = fromPotentialObservable(obs1$);
5363
expect(obs1$).toBe(obs2$);
5464
});
65+
66+
it('should combine observables and distinct same values from observable dictionary', () => {
67+
const { testScheduler } = setup();
68+
69+
testScheduler.run(({ cold, expectObservable }) => {
70+
const o1$ = cold('o--p-q', { o: 1, p: 2, q: 2 });
71+
const o2$ = cold('-xy-z-', { x: 3, y: 3, z: 4 });
72+
73+
const result$ = fromPotentialObservable({ o1: o1$, o2: o2$ });
74+
expectObservable(result$).toBe('-k-lm-', {
75+
k: { o1: 1, o2: 3 },
76+
l: { o1: 2, o2: 3 },
77+
m: { o1: 2, o2: 4 },
78+
});
79+
});
80+
});
5581
});

modules/component/spec/core/render-event/manager.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('createRenderEventManager', () => {
1919
const nextHandler = jest.fn();
2020
const errorHandler = jest.fn();
2121
const completeHandler = jest.fn();
22-
const renderEventManager = createRenderEventManager<T>({
22+
const renderEventManager = createRenderEventManager<Observable<T>>({
2323
suspense: suspenseHandler,
2424
next: nextHandler,
2525
error: errorHandler,

modules/component/spec/let/let.directive.spec.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
EMPTY,
2121
interval,
2222
NEVER,
23+
Observable,
2324
of,
2425
switchMap,
2526
take,
@@ -274,14 +275,28 @@ describe('LetDirective', () => {
274275
expect(componentNativeElement.textContent).toBe('true');
275276
});
276277

277-
it('should render initially passed object', () => {
278+
it('should render initially passed empty object', () => {
279+
letDirectiveTestComponent.value$ = {};
280+
fixtureLetDirectiveTestComponent.detectChanges();
281+
expect(stripSpaces(componentNativeElement.textContent)).toBe('{}');
282+
});
283+
284+
it('should render initially passed object with non-observable values', () => {
278285
letDirectiveTestComponent.value$ = { ngrx: 'component' };
279286
fixtureLetDirectiveTestComponent.detectChanges();
280287
expect(stripSpaces(componentNativeElement.textContent)).toBe(
281288
'{"ngrx":"component"}'
282289
);
283290
});
284291

292+
it('should render initially passed object with at least one non-observable value', () => {
293+
letDirectiveTestComponent.value$ = { ngrx: 'component', o$: of(1) };
294+
fixtureLetDirectiveTestComponent.detectChanges();
295+
expect(stripSpaces(componentNativeElement.textContent)).toBe(
296+
'{"ngrx":"component","o$":{}}'
297+
);
298+
});
299+
285300
it('should render initially passed array', () => {
286301
letDirectiveTestComponent.value$ = [1, 2, 3];
287302
fixtureLetDirectiveTestComponent.detectChanges();
@@ -512,4 +527,83 @@ describe('LetDirective', () => {
512527
expect(componentNativeElement.textContent).toBe('1');
513528
}));
514529
});
530+
531+
describe('with observable dictionary', () => {
532+
function withObservableDictionarySetup<
533+
O1 extends Observable<unknown>,
534+
O2 extends Observable<unknown>
535+
>(config: { o1$: O1; o2$: O2 }) {
536+
@Component({
537+
template: `
538+
<ng-container *ngrxLet="{ o1: o1$, o2: o2$ } as vm">{{
539+
vm.o1 + '-' + vm.o2
540+
}}</ng-container>
541+
`,
542+
})
543+
class LetDirectiveTestComponent {
544+
o1$ = config.o1$;
545+
o2$ = config.o2$;
546+
}
547+
548+
TestBed.configureTestingModule({
549+
declarations: [LetDirectiveTestComponent, LetDirective],
550+
providers: [
551+
{ provide: ChangeDetectorRef, useClass: MockChangeDetectorRef },
552+
{ provide: ErrorHandler, useClass: MockErrorHandler },
553+
TemplateRef,
554+
ViewContainerRef,
555+
],
556+
});
557+
558+
const fixture = TestBed.createComponent(LetDirectiveTestComponent);
559+
560+
return {
561+
fixture,
562+
nativeElement: fixture.nativeElement,
563+
};
564+
}
565+
566+
it('should not create embedded view until all observables from dictionary emit first value', fakeAsync(() => {
567+
const { fixture, nativeElement } = withObservableDictionarySetup({
568+
o1$: of(1).pipe(delay(10)),
569+
o2$: of(2).pipe(delay(20)),
570+
});
571+
572+
fixture.detectChanges();
573+
expect(nativeElement.textContent).toBe('');
574+
575+
tick(10);
576+
fixture.detectChanges();
577+
expect(nativeElement.textContent).toBe('');
578+
579+
tick(20);
580+
fixture.detectChanges();
581+
expect(nativeElement.textContent).toBe('1-2');
582+
}));
583+
584+
it('should update embedded view when any observable from dictionary emits value', () => {
585+
const o1$ = new BehaviorSubject(1);
586+
const o2$ = new BehaviorSubject(2);
587+
const { fixture, nativeElement } = withObservableDictionarySetup({
588+
o1$,
589+
o2$,
590+
});
591+
592+
fixture.detectChanges();
593+
expect(nativeElement.textContent).toBe('1-2');
594+
595+
o1$.next(10);
596+
fixture.detectChanges();
597+
expect(nativeElement.textContent).toBe('10-2');
598+
599+
o2$.next(20);
600+
fixture.detectChanges();
601+
expect(nativeElement.textContent).toBe('10-20');
602+
603+
o1$.next(100);
604+
o2$.next(200);
605+
fixture.detectChanges();
606+
expect(nativeElement.textContent).toBe('100-200');
607+
});
608+
});
515609
});

modules/component/spec/push/push.pipe.spec.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
TestBed,
77
waitForAsync,
88
} from '@angular/core/testing';
9-
import { BehaviorSubject, EMPTY, NEVER, of, throwError } from 'rxjs';
9+
import { BehaviorSubject, delay, EMPTY, NEVER, of, throwError } from 'rxjs';
1010
import { PushPipe } from '../../src/push/push.pipe';
1111
import { MockChangeDetectorRef, MockErrorHandler } from '../fixtures/fixtures';
1212
import { stripSpaces, wrapWithSpace } from '../helpers';
@@ -66,11 +66,21 @@ describe('PushPipe', () => {
6666
expect(pushPipe.transform(true)).toBe(true);
6767
});
6868

69-
it('should return initially passed object', () => {
69+
it('should return initially passed empty object', () => {
70+
const obj = {};
71+
expect(pushPipe.transform(obj)).toBe(obj);
72+
});
73+
74+
it('should return initially passed object with non-observable values', () => {
7075
const obj = { ngrx: 'component' };
7176
expect(pushPipe.transform(obj)).toBe(obj);
7277
});
7378

79+
it('should return initially passed object with at least one non-observable value', () => {
80+
const obj = { ngrx: 'component', obs$: of(10) };
81+
expect(pushPipe.transform(obj)).toBe(obj);
82+
});
83+
7484
it('should return initially passed array', () => {
7585
const arr = [1, 2, 3];
7686
expect(pushPipe.transform(arr)).toBe(arr);
@@ -115,6 +125,19 @@ describe('PushPipe', () => {
115125
});
116126
});
117127

128+
it('should return undefined when any observable from dictionary emits first value asynchronously', () => {
129+
const result = pushPipe.transform({
130+
o1$: of(1).pipe(delay(1)),
131+
o2$: of(2),
132+
});
133+
expect(result).toBe(undefined);
134+
});
135+
136+
it('should return emitted values from observables that are passed as dictionary and emit first values synchronously', () => {
137+
const result = pushPipe.transform({ o1: of(10), o2: of(20) });
138+
expect(result).toEqual({ o1: 10, o2: 20 });
139+
});
140+
118141
it('should return undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => {
119142
expect(pushPipe.transform(of(42))).toBe(42);
120143
expect(pushPipe.transform(NEVER)).toBe(undefined);
@@ -192,14 +215,28 @@ describe('PushPipe', () => {
192215
expect(componentNativeElement.textContent).toBe(wrapWithSpace('true'));
193216
});
194217

195-
it('should render initially passed object', () => {
218+
it('should render initially passed empty object', () => {
219+
pushPipeTestComponent.value$ = {};
220+
fixturePushPipeTestComponent.detectChanges();
221+
expect(stripSpaces(componentNativeElement.textContent)).toBe('{}');
222+
});
223+
224+
it('should render initially passed object with non-observable values', () => {
196225
pushPipeTestComponent.value$ = { ngrx: 'component' };
197226
fixturePushPipeTestComponent.detectChanges();
198227
expect(stripSpaces(componentNativeElement.textContent)).toBe(
199228
'{"ngrx":"component"}'
200229
);
201230
});
202231

232+
it('should render initially passed object with at least one non-observable value', () => {
233+
pushPipeTestComponent.value$ = { ngrx: 'component', o: of('ngrx') };
234+
fixturePushPipeTestComponent.detectChanges();
235+
expect(stripSpaces(componentNativeElement.textContent)).toBe(
236+
'{"ngrx":"component","o":{}}'
237+
);
238+
});
239+
203240
it('should render initially passed array', () => {
204241
pushPipeTestComponent.value$ = [1, 2, 3];
205242
fixturePushPipeTestComponent.detectChanges();
@@ -265,6 +302,39 @@ describe('PushPipe', () => {
265302
expect(componentNativeElement.textContent).toBe(wrapWithSpace('42'));
266303
}));
267304

305+
it('should render undefined initially when any observable from dictionary emits first value asynchronously', () => {
306+
pushPipeTestComponent.value$ = {
307+
o1: of(100),
308+
o2: of(200).pipe(delay(1)),
309+
};
310+
fixturePushPipeTestComponent.detectChanges();
311+
expect(componentNativeElement.textContent).toBe(
312+
wrapWithSpace('undefined')
313+
);
314+
});
315+
316+
it('should render emitted values from observables that are passed as dictionary', () => {
317+
const o1 = new BehaviorSubject('ng');
318+
const o2 = new BehaviorSubject('rx');
319+
pushPipeTestComponent.value$ = { o1, o2 };
320+
fixturePushPipeTestComponent.detectChanges();
321+
expect(stripSpaces(componentNativeElement.textContent)).toBe(
322+
'{"o1":"ng","o2":"rx"}'
323+
);
324+
325+
o1.next('ngrx');
326+
fixturePushPipeTestComponent.detectChanges();
327+
expect(stripSpaces(componentNativeElement.textContent)).toBe(
328+
'{"o1":"ngrx","o2":"rx"}'
329+
);
330+
331+
o2.next('component');
332+
fixturePushPipeTestComponent.detectChanges();
333+
expect(stripSpaces(componentNativeElement.textContent)).toBe(
334+
'{"o1":"ngrx","o2":"component"}'
335+
);
336+
});
337+
268338
it('should render undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => {
269339
pushPipeTestComponent.value$ = of(42);
270340
fixturePushPipeTestComponent.detectChanges();
@@ -310,7 +380,7 @@ describe('PushPipe', () => {
310380
);
311381
});
312382

313-
it('should return non-observable value when it was passed after another non-observable', () => {
383+
it('should render non-observable value when it was passed after another non-observable', () => {
314384
pushPipeTestComponent.value$ = 10;
315385
fixturePushPipeTestComponent.detectChanges();
316386
expect(componentNativeElement.textContent).toBe(wrapWithSpace('10'));

modules/component/spec/types/let.directive.types.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ describe('LetDirective', () => {
1919
expectPotentialObservable('number').toBeInferredAs('number');
2020
expectPotentialObservable('null').toBeInferredAs('null');
2121
expectPotentialObservable('string[]').toBeInferredAs('string[]');
22+
expectPotentialObservable('{}').toBeInferredAs('{}');
2223
expectPotentialObservable('{ ngrx: boolean; }').toBeInferredAs(
2324
'{ ngrx: boolean; }'
2425
);
26+
expectPotentialObservable(
27+
'User',
28+
'interface User { name: string; }'
29+
).toBeInferredAs('User');
2530
});
2631

2732
it('should infer the value when potential observable is a union of non-observables', () => {
@@ -77,4 +82,69 @@ describe('LetDirective', () => {
7782
'Observable<number> | Promise<{ ngrx: string; }> | boolean | null'
7883
).toBeInferredAs('number | boolean | { ngrx: string; } | null');
7984
});
85+
86+
it('should infer the value when potential observable is an observable dictionary', () => {
87+
expectPotentialObservable(
88+
'{ o1: Observable<number>; o2: Observable<{ ngrx: string }> }'
89+
).toBeInferredAs('{ o1: number; o2: { ngrx: string; }; }');
90+
});
91+
92+
it('should infer the value when potential observable is an observable dictionary typed as interface', () => {
93+
expectPotentialObservable(
94+
'Dictionary',
95+
'interface Dictionary { x: Observable<string>; y: Observable<boolean | undefined> }'
96+
).toBeInferredAs('{ x: string; y: boolean | undefined; }');
97+
});
98+
99+
it('should infer the value as static when potential observable is a dictionary with at least one non-observable property', () => {
100+
expectPotentialObservable(
101+
'{ o: Observable<bigint>; n: number }'
102+
).toBeInferredAs('{ o: Observable<bigint>; n: number; }');
103+
expectPotentialObservable(
104+
'Dictionary',
105+
'interface Dictionary { o: Observable<number>; p: Promise<string> }'
106+
).toBeInferredAs('Dictionary');
107+
});
108+
109+
it('should infer the value as static when potential observable is an observable dictionary with optional properties', () => {
110+
expectPotentialObservable(
111+
'{ o1: Observable<boolean>; o2?: Observable<string> }'
112+
).toBeInferredAs(
113+
'{ o1: Observable<boolean>; o2?: Observable<string> | undefined; }'
114+
);
115+
expectPotentialObservable(
116+
'Dictionary',
117+
'interface Dictionary { o1: Observable<number>; o2?: Observable<bigint> }'
118+
).toBeInferredAs('Dictionary');
119+
});
120+
121+
it('should infer the value when potential observable is a union of observable dictionary and non-observable', () => {
122+
expectPotentialObservable(
123+
'{ o: Observable<string> } | { ngrx: string }'
124+
).toBeInferredAs('{ ngrx: string; } | { o: string; }');
125+
expectPotentialObservable(
126+
'Dictionary | number',
127+
'interface Dictionary { o: Observable<number> }'
128+
).toBeInferredAs('number | { o: number; }');
129+
});
130+
131+
it('should infer the value when potential observable is a union of observable dictionary and promise', () => {
132+
expectPotentialObservable(
133+
'{ o: Observable<symbol> } | Promise<{ ngrx: string }>'
134+
).toBeInferredAs('{ ngrx: string; } | { o: symbol; }');
135+
expectPotentialObservable(
136+
'Dictionary | Promise<number>',
137+
'interface Dictionary { o: Observable<number> }'
138+
).toBeInferredAs('number | { o: number; }');
139+
});
140+
141+
it('should infer the value when potential observable is a union of observable dictionary and observable', () => {
142+
expectPotentialObservable(
143+
'{ o: Observable<number> } | Observable<{ ngrx: string }>'
144+
).toBeInferredAs('{ ngrx: string; } | { o: number; }');
145+
expectPotentialObservable(
146+
'Dictionary | Observable<boolean>',
147+
'interface Dictionary { o: Observable<boolean> }'
148+
).toBeInferredAs('boolean | { o: boolean; }');
149+
});
80150
});

0 commit comments

Comments
 (0)