Skip to content

Commit

Permalink
feat(component): handle observable dictionaries (#3602)
Browse files Browse the repository at this point in the history
Closes #3545
  • Loading branch information
markostanimirovic committed Oct 24, 2022
1 parent ea13560 commit 42efccb
Show file tree
Hide file tree
Showing 14 changed files with 504 additions and 71 deletions.
28 changes: 27 additions & 1 deletion modules/component/spec/core/potential-observable.spec.ts
Expand Up @@ -32,7 +32,17 @@ describe('fromPotentialObservable', () => {

testNonObservableInput(true, 'boolean');

testNonObservableInput({ ngrx: 'component' }, 'object');
testNonObservableInput({}, 'empty object');

testNonObservableInput(
{ ngrx: 'component' },
'object with non-observable values'
);

testNonObservableInput(
{ x: of(1), y: 2 },
'object with at least one non-observable value'
);

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

Expand All @@ -52,4 +62,20 @@ describe('fromPotentialObservable', () => {
const obs2$ = fromPotentialObservable(obs1$);
expect(obs1$).toBe(obs2$);
});

it('should combine observables and distinct same values from observable dictionary', () => {
const { testScheduler } = setup();

testScheduler.run(({ cold, expectObservable }) => {
const o1$ = cold('o--p-q', { o: 1, p: 2, q: 2 });
const o2$ = cold('-xy-z-', { x: 3, y: 3, z: 4 });

const result$ = fromPotentialObservable({ o1: o1$, o2: o2$ });
expectObservable(result$).toBe('-k-lm-', {
k: { o1: 1, o2: 3 },
l: { o1: 2, o2: 3 },
m: { o1: 2, o2: 4 },
});
});
});
});
2 changes: 1 addition & 1 deletion modules/component/spec/core/render-event/manager.spec.ts
Expand Up @@ -19,7 +19,7 @@ describe('createRenderEventManager', () => {
const nextHandler = jest.fn();
const errorHandler = jest.fn();
const completeHandler = jest.fn();
const renderEventManager = createRenderEventManager<T>({
const renderEventManager = createRenderEventManager<Observable<T>>({
suspense: suspenseHandler,
next: nextHandler,
error: errorHandler,
Expand Down
96 changes: 95 additions & 1 deletion modules/component/spec/let/let.directive.spec.ts
Expand Up @@ -20,6 +20,7 @@ import {
EMPTY,
interval,
NEVER,
Observable,
of,
switchMap,
take,
Expand Down Expand Up @@ -274,14 +275,28 @@ describe('LetDirective', () => {
expect(componentNativeElement.textContent).toBe('true');
});

it('should render initially passed object', () => {
it('should render initially passed empty object', () => {
letDirectiveTestComponent.value$ = {};
fixtureLetDirectiveTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe('{}');
});

it('should render initially passed object with non-observable values', () => {
letDirectiveTestComponent.value$ = { ngrx: 'component' };
fixtureLetDirectiveTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe(
'{"ngrx":"component"}'
);
});

it('should render initially passed object with at least one non-observable value', () => {
letDirectiveTestComponent.value$ = { ngrx: 'component', o$: of(1) };
fixtureLetDirectiveTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe(
'{"ngrx":"component","o$":{}}'
);
});

it('should render initially passed array', () => {
letDirectiveTestComponent.value$ = [1, 2, 3];
fixtureLetDirectiveTestComponent.detectChanges();
Expand Down Expand Up @@ -512,4 +527,83 @@ describe('LetDirective', () => {
expect(componentNativeElement.textContent).toBe('1');
}));
});

describe('with observable dictionary', () => {
function withObservableDictionarySetup<
O1 extends Observable<unknown>,
O2 extends Observable<unknown>
>(config: { o1$: O1; o2$: O2 }) {
@Component({
template: `
<ng-container *ngrxLet="{ o1: o1$, o2: o2$ } as vm">{{
vm.o1 + '-' + vm.o2
}}</ng-container>
`,
})
class LetDirectiveTestComponent {
o1$ = config.o1$;
o2$ = config.o2$;
}

TestBed.configureTestingModule({
declarations: [LetDirectiveTestComponent, LetDirective],
providers: [
{ provide: ChangeDetectorRef, useClass: MockChangeDetectorRef },
{ provide: ErrorHandler, useClass: MockErrorHandler },
TemplateRef,
ViewContainerRef,
],
});

const fixture = TestBed.createComponent(LetDirectiveTestComponent);

return {
fixture,
nativeElement: fixture.nativeElement,
};
}

it('should not create embedded view until all observables from dictionary emit first value', fakeAsync(() => {
const { fixture, nativeElement } = withObservableDictionarySetup({
o1$: of(1).pipe(delay(10)),
o2$: of(2).pipe(delay(20)),
});

fixture.detectChanges();
expect(nativeElement.textContent).toBe('');

tick(10);
fixture.detectChanges();
expect(nativeElement.textContent).toBe('');

tick(20);
fixture.detectChanges();
expect(nativeElement.textContent).toBe('1-2');
}));

it('should update embedded view when any observable from dictionary emits value', () => {
const o1$ = new BehaviorSubject(1);
const o2$ = new BehaviorSubject(2);
const { fixture, nativeElement } = withObservableDictionarySetup({
o1$,
o2$,
});

fixture.detectChanges();
expect(nativeElement.textContent).toBe('1-2');

o1$.next(10);
fixture.detectChanges();
expect(nativeElement.textContent).toBe('10-2');

o2$.next(20);
fixture.detectChanges();
expect(nativeElement.textContent).toBe('10-20');

o1$.next(100);
o2$.next(200);
fixture.detectChanges();
expect(nativeElement.textContent).toBe('100-200');
});
});
});
78 changes: 74 additions & 4 deletions modules/component/spec/push/push.pipe.spec.ts
Expand Up @@ -6,7 +6,7 @@ import {
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { BehaviorSubject, EMPTY, NEVER, of, throwError } from 'rxjs';
import { BehaviorSubject, delay, EMPTY, NEVER, of, throwError } from 'rxjs';
import { PushPipe } from '../../src/push/push.pipe';
import { MockChangeDetectorRef, MockErrorHandler } from '../fixtures/fixtures';
import { stripSpaces, wrapWithSpace } from '../helpers';
Expand Down Expand Up @@ -66,11 +66,21 @@ describe('PushPipe', () => {
expect(pushPipe.transform(true)).toBe(true);
});

it('should return initially passed object', () => {
it('should return initially passed empty object', () => {
const obj = {};
expect(pushPipe.transform(obj)).toBe(obj);
});

it('should return initially passed object with non-observable values', () => {
const obj = { ngrx: 'component' };
expect(pushPipe.transform(obj)).toBe(obj);
});

it('should return initially passed object with at least one non-observable value', () => {
const obj = { ngrx: 'component', obs$: of(10) };
expect(pushPipe.transform(obj)).toBe(obj);
});

it('should return initially passed array', () => {
const arr = [1, 2, 3];
expect(pushPipe.transform(arr)).toBe(arr);
Expand Down Expand Up @@ -115,6 +125,19 @@ describe('PushPipe', () => {
});
});

it('should return undefined when any observable from dictionary emits first value asynchronously', () => {
const result = pushPipe.transform({
o1$: of(1).pipe(delay(1)),
o2$: of(2),
});
expect(result).toBe(undefined);
});

it('should return emitted values from observables that are passed as dictionary and emit first values synchronously', () => {
const result = pushPipe.transform({ o1: of(10), o2: of(20) });
expect(result).toEqual({ o1: 10, o2: 20 });
});

it('should return undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => {
expect(pushPipe.transform(of(42))).toBe(42);
expect(pushPipe.transform(NEVER)).toBe(undefined);
Expand Down Expand Up @@ -192,14 +215,28 @@ describe('PushPipe', () => {
expect(componentNativeElement.textContent).toBe(wrapWithSpace('true'));
});

it('should render initially passed object', () => {
it('should render initially passed empty object', () => {
pushPipeTestComponent.value$ = {};
fixturePushPipeTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe('{}');
});

it('should render initially passed object with non-observable values', () => {
pushPipeTestComponent.value$ = { ngrx: 'component' };
fixturePushPipeTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe(
'{"ngrx":"component"}'
);
});

it('should render initially passed object with at least one non-observable value', () => {
pushPipeTestComponent.value$ = { ngrx: 'component', o: of('ngrx') };
fixturePushPipeTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe(
'{"ngrx":"component","o":{}}'
);
});

it('should render initially passed array', () => {
pushPipeTestComponent.value$ = [1, 2, 3];
fixturePushPipeTestComponent.detectChanges();
Expand Down Expand Up @@ -265,6 +302,39 @@ describe('PushPipe', () => {
expect(componentNativeElement.textContent).toBe(wrapWithSpace('42'));
}));

it('should render undefined initially when any observable from dictionary emits first value asynchronously', () => {
pushPipeTestComponent.value$ = {
o1: of(100),
o2: of(200).pipe(delay(1)),
};
fixturePushPipeTestComponent.detectChanges();
expect(componentNativeElement.textContent).toBe(
wrapWithSpace('undefined')
);
});

it('should render emitted values from observables that are passed as dictionary', () => {
const o1 = new BehaviorSubject('ng');
const o2 = new BehaviorSubject('rx');
pushPipeTestComponent.value$ = { o1, o2 };
fixturePushPipeTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe(
'{"o1":"ng","o2":"rx"}'
);

o1.next('ngrx');
fixturePushPipeTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe(
'{"o1":"ngrx","o2":"rx"}'
);

o2.next('component');
fixturePushPipeTestComponent.detectChanges();
expect(stripSpaces(componentNativeElement.textContent)).toBe(
'{"o1":"ngrx","o2":"component"}'
);
});

it('should render undefined as value when a new observable NEVER was passed (as no value ever was emitted from new observable)', () => {
pushPipeTestComponent.value$ = of(42);
fixturePushPipeTestComponent.detectChanges();
Expand Down Expand Up @@ -310,7 +380,7 @@ describe('PushPipe', () => {
);
});

it('should return non-observable value when it was passed after another non-observable', () => {
it('should render non-observable value when it was passed after another non-observable', () => {
pushPipeTestComponent.value$ = 10;
fixturePushPipeTestComponent.detectChanges();
expect(componentNativeElement.textContent).toBe(wrapWithSpace('10'));
Expand Down
70 changes: 70 additions & 0 deletions modules/component/spec/types/let.directive.types.spec.ts
Expand Up @@ -19,9 +19,14 @@ describe('LetDirective', () => {
expectPotentialObservable('number').toBeInferredAs('number');
expectPotentialObservable('null').toBeInferredAs('null');
expectPotentialObservable('string[]').toBeInferredAs('string[]');
expectPotentialObservable('{}').toBeInferredAs('{}');
expectPotentialObservable('{ ngrx: boolean; }').toBeInferredAs(
'{ ngrx: boolean; }'
);
expectPotentialObservable(
'User',
'interface User { name: string; }'
).toBeInferredAs('User');
});

it('should infer the value when potential observable is a union of non-observables', () => {
Expand Down Expand Up @@ -77,4 +82,69 @@ describe('LetDirective', () => {
'Observable<number> | Promise<{ ngrx: string; }> | boolean | null'
).toBeInferredAs('number | boolean | { ngrx: string; } | null');
});

it('should infer the value when potential observable is an observable dictionary', () => {
expectPotentialObservable(
'{ o1: Observable<number>; o2: Observable<{ ngrx: string }> }'
).toBeInferredAs('{ o1: number; o2: { ngrx: string; }; }');
});

it('should infer the value when potential observable is an observable dictionary typed as interface', () => {
expectPotentialObservable(
'Dictionary',
'interface Dictionary { x: Observable<string>; y: Observable<boolean | undefined> }'
).toBeInferredAs('{ x: string; y: boolean | undefined; }');
});

it('should infer the value as static when potential observable is a dictionary with at least one non-observable property', () => {
expectPotentialObservable(
'{ o: Observable<bigint>; n: number }'
).toBeInferredAs('{ o: Observable<bigint>; n: number; }');
expectPotentialObservable(
'Dictionary',
'interface Dictionary { o: Observable<number>; p: Promise<string> }'
).toBeInferredAs('Dictionary');
});

it('should infer the value as static when potential observable is an observable dictionary with optional properties', () => {
expectPotentialObservable(
'{ o1: Observable<boolean>; o2?: Observable<string> }'
).toBeInferredAs(
'{ o1: Observable<boolean>; o2?: Observable<string> | undefined; }'
);
expectPotentialObservable(
'Dictionary',
'interface Dictionary { o1: Observable<number>; o2?: Observable<bigint> }'
).toBeInferredAs('Dictionary');
});

it('should infer the value when potential observable is a union of observable dictionary and non-observable', () => {
expectPotentialObservable(
'{ o: Observable<string> } | { ngrx: string }'
).toBeInferredAs('{ ngrx: string; } | { o: string; }');
expectPotentialObservable(
'Dictionary | number',
'interface Dictionary { o: Observable<number> }'
).toBeInferredAs('number | { o: number; }');
});

it('should infer the value when potential observable is a union of observable dictionary and promise', () => {
expectPotentialObservable(
'{ o: Observable<symbol> } | Promise<{ ngrx: string }>'
).toBeInferredAs('{ ngrx: string; } | { o: symbol; }');
expectPotentialObservable(
'Dictionary | Promise<number>',
'interface Dictionary { o: Observable<number> }'
).toBeInferredAs('number | { o: number; }');
});

it('should infer the value when potential observable is a union of observable dictionary and observable', () => {
expectPotentialObservable(
'{ o: Observable<number> } | Observable<{ ngrx: string }>'
).toBeInferredAs('{ ngrx: string; } | { o: number; }');
expectPotentialObservable(
'Dictionary | Observable<boolean>',
'interface Dictionary { o: Observable<boolean> }'
).toBeInferredAs('boolean | { o: boolean; }');
});
});

0 comments on commit 42efccb

Please sign in to comment.