From 52c47e3da6d07aeff7968c1a22c1c998633583f9 Mon Sep 17 00:00:00 2001 From: Alex Okrushko Date: Sat, 31 Oct 2020 14:46:03 -0400 Subject: [PATCH] fix(component-store): adjust updater to accept partials (#2765) Closes #2754 --- .../spec/types/component-store.types.spec.ts | 208 +++++++++++++++--- .../component-store/src/component-store.ts | 23 +- 2 files changed, 190 insertions(+), 41 deletions(-) diff --git a/modules/component-store/spec/types/component-store.types.spec.ts b/modules/component-store/spec/types/component-store.types.spec.ts index 77178bc509..fe69ea303a 100644 --- a/modules/component-store/spec/types/component-store.types.spec.ts +++ b/modules/component-store/spec/types/component-store.types.spec.ts @@ -9,6 +9,10 @@ describe('ComponentStore types', () => { import { of, EMPTY, Observable } from 'rxjs'; import { concatMap } from 'rxjs/operators'; + interface Obj { + prop: string; + } + const number$: Observable = of(5); const string$: Observable = of('string'); @@ -20,76 +24,88 @@ describe('ComponentStore types', () => { describe('infers Subscription', () => { it('when argument type is specified and a variable with corresponding type is passed', () => { - expectSnippet( - `const eff = componentStore.effect((e: Observable) => number$)('string');` - ).toInfer('eff', 'Subscription'); + const effectTest = `const sub = componentStore.effect((e: Observable) => number$)('string');`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); }); it( 'when argument type is specified, returns EMPTY and ' + 'a variable with corresponding type is passed', () => { - expectSnippet( - `const eff = componentStore.effect((e: Observable) => EMPTY)('string');` - ).toInfer('eff', 'Subscription'); + const effectTest = `const sub = componentStore.effect((e: Observable) => EMPTY)('string');`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); } ); it('when argument type is specified and an Observable with corresponding type is passed', () => { - expectSnippet( - `const eff = componentStore.effect((e: Observable) => EMPTY)(string$);` - ).toInfer('eff', 'Subscription'); + const effectTest = `const sub = componentStore.effect((e: Observable) => EMPTY)(string$);`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); }); it('when argument type is specified as Observable and any type is passed', () => { - expectSnippet( - `const eff = componentStore.effect((e: Observable) => EMPTY)(5);` - ).toInfer('eff', 'Subscription'); + const effectTest = `const sub = componentStore.effect((e: Observable) => EMPTY)(5);`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); }); it('when generic type is specified and a variable with corresponding type is passed', () => { - expectSnippet( - `const eff = componentStore.effect((e) => number$)('string');` - ).toInfer('eff', 'Subscription'); + const effectTest = `const sub = componentStore.effect((e) => number$)('string');`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); }); it('when generic type is specified as unknown and a variable with any type is passed', () => { - expectSnippet( - `const eff = componentStore.effect((e) => number$)('string');` - ).toInfer('eff', 'Subscription'); + const effectTest = `const sub = componentStore.effect((e) => number$)('string');`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); }); it('when generic type is specified as unknown and origin can still be piped', () => { - expectSnippet( - `const eff = componentStore.effect((e) => e.pipe(concatMap(() => of())))('string');` - ).toInfer('eff', 'Subscription'); + const effectTest = `const sub = componentStore.effect((e) => e.pipe(concatMap(() => of())))('string');`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); }); it('when generic type is specified as unknown and origin can still be piped', () => { expectSnippet( - `const eff = componentStore.effect((e) => e.pipe(concatMap(() => of())))('string');` - ).toInfer('eff', 'Subscription'); + `const sub = componentStore.effect((e) => e.pipe(concatMap(() => of())))('string');` + ).toInfer('sub', 'Subscription'); + }); + + it('when argument type is an interface and a variable with corresponding type is passed', () => { + const effectTest = `const sub = componentStore.effect((e: Observable) => number$)({prop: 'string'});`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is a partial interface and a variable with corresponding type is passed', () => { + const effectTest = `const sub = componentStore.effect((e: Observable>) => number$)({prop: 'string'});`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('sub', 'Subscription'); }); }); describe('infers void', () => { it('when argument type is specified as Observable and nothing is passed', () => { - expectSnippet( - `const eff = componentStore.effect((e: Observable) => string$)();` - ).toInfer('eff', 'void'); + const effectTest = `const v = componentStore.effect((e: Observable) => string$)();`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('v', 'void'); }); it('when type is not specified and origin can still be piped', () => { - expectSnippet( - // treated as Observable 👇 - `const eff = componentStore.effect((e) => e.pipe(concatMap(() => of())))();` - ).toInfer('eff', 'void'); + // treated as Observable 👇 + const effectTest = `const v = componentStore.effect((e) => e.pipe(concatMap(() => of())))();`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('v', 'void'); }); it('when generic type is specified as void and origin can still be piped', () => { - expectSnippet( - `const eff = componentStore.effect((e) => e.pipe(concatMap(() => number$)))();` - ).toInfer('eff', 'void'); + const effectTest = `const v = componentStore.effect((e) => e.pipe(concatMap(() => number$)))();`; + expectSnippet(effectTest).toSucceed(); + expectSnippet(effectTest).toInfer('v', 'void'); }); }); @@ -144,7 +160,7 @@ describe('ComponentStore types', () => { it('when type is not specified and anything is passed', () => { expectSnippet( - `const eff = componentStore.effect((e) => EMPTY)('string');` + `const sub = componentStore.effect((e) => EMPTY)('string');` ).toFail(/Expected 0 arguments, but got 1/); }); @@ -155,4 +171,128 @@ describe('ComponentStore types', () => { }); }); }); + + describe('updater', () => { + const expectSnippet = expecter( + (code) => ` + import { ComponentStore } from '@ngrx/component-store'; + import { of, EMPTY, Observable } from 'rxjs'; + import { concatMap } from 'rxjs/operators'; + + export enum LoadingState { + INIT = 'INIT', + LOADING = 'LOADING', + LOADED = 'LOADED', + ERROR = 'ERROR', + } + + interface Obj { + prop: string; + } + + const number$: Observable = of(5); + const string$: Observable = of('string'); + + const componentStore = new ComponentStore({ prop: 'init', prop2: 'yeah!'}); + ${code} + `, + compilerOptions() + ); + + describe('infers Subscription', () => { + it('when argument type is specified and a variable with corresponding type is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: string) => ({...state}))('string');`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is specified and an Observable with corresponding type is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: string) => ({...state}))(string$);`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is an interface and a variable with corresponding type is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: Obj) => ({...state}))({prop: 'obj'});`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is an partial interface and a variable with corresponding type is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: Partial) => ({...state}))({prop: 'obj'});`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is an enum and a variable with corresponding type is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: LoadingState) => ({...state}))(LoadingState.LOADED);`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is a union and a variable with corresponding type is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: string|number) => ({...state}))(5);`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is an intersection and a variable with corresponding type is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: {p: string} & {p2: number}) => ({...state}))({p: 's', p2: 3});`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when argument type is unknown and any variable is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v: unknown) => ({...state}))({anything: 'works'});`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when generic type is specified and any variable is passed', () => { + const updaterTest = `const sub = componentStore.updater((state, v) => ({...state}))('works');`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('sub', 'Subscription'); + }); + + it('when type is not specified and nothing is passed', () => { + const updaterTest = `const v = componentStore.updater((state) => ({...state}))();`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('v', 'void'); + }); + + it('when type void is specified and nothing is passed', () => { + const updaterTest = `const v = componentStore.updater((state) => ({...state}))();`; + expectSnippet(updaterTest).toSucceed(); + expectSnippet(updaterTest).toInfer('v', 'void'); + }); + }); + + describe('catches improper usage', () => { + it('when type is specified and argument is not passed', () => { + expectSnippet( + `const sub = componentStore.updater((state, v: string) => ({...state}))();` + ).toFail(/Expected 1 arguments, but got 0/); + }); + + it('when argument type is unknown and nothing is passed', () => { + expectSnippet( + `const sub = componentStore.updater((state, v: unknown) => ({...state}))();` + ).toFail(/Expected 1 arguments, but got 0/); + }); + + it('when no argument is expected but one is passed', () => { + expectSnippet( + `const sub = componentStore.updater((state) => ({...state}))('string');` + ).toFail(/Expected 0 arguments, but got 1/); + }); + + it('when type is specified and Observable argument of incorrect type is passed', () => { + expectSnippet( + `const sub = componentStore.updater((state, v: string) => ({...state}))(number$);` + ).toFail( + /Argument of type 'Observable' is not assignable to parameter of type 'string \| Observable'/ + ); + }); + }); + }); }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index d311059681..9ebad31579 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -77,10 +77,21 @@ export class ComponentStore implements OnDestroy { * second argument to `updaterFn`. Every time this function is called * subscribers will be notified of the state change. */ - updater( - updaterFn: (state: T, value: V) => T - ): unknown extends V ? () => void : (t: V | Observable) => Subscription { - return ((observableOrValue?: V | Observable): Subscription => { + updater< + // Allow to force-provide the type + ProvidedType = void, + // This type is derived from the `value` property, defaulting to void if it's missing + OriginType = ProvidedType, + // The Value type is assigned from the Origin + ValueType = OriginType, + // Return either an empty callback or a function requiring specific types as inputs + ReturnType = OriginType extends void + ? () => void + : (observableOrValue: ValueType | Observable) => Subscription + >(updaterFn: (state: T, value: OriginType) => T): ReturnType { + return ((( + observableOrValue?: OriginType | Observable + ): Subscription => { let initializationError: Error | undefined; // We can receive either the value or an observable. In case it's a // simple value, we'll wrap it with `of` operator to turn it into @@ -116,9 +127,7 @@ export class ComponentStore implements OnDestroy { throw /** @type {!Error} */ (initializationError); } return subscription; - }) as unknown extends V - ? () => void - : (t: V | Observable) => Subscription; + }) as unknown) as ReturnType; } /**