Skip to content

Commit

Permalink
fix(component-store): adjust updater to accept partials (#2765)
Browse files Browse the repository at this point in the history
Closes #2754
  • Loading branch information
alex-okrushko authored Oct 31, 2020
1 parent 207da44 commit b54b9b6
Show file tree
Hide file tree
Showing 2 changed files with 190 additions and 41 deletions.
208 changes: 174 additions & 34 deletions modules/component-store/spec/types/component-store.types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = of(5);
const string$: Observable<string> = of('string');
Expand All @@ -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<string>) => number$)('string');`
).toInfer('eff', 'Subscription');
const effectTest = `const sub = componentStore.effect((e: Observable<string>) => 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<string>) => EMPTY)('string');`
).toInfer('eff', 'Subscription');
const effectTest = `const sub = componentStore.effect((e: Observable<string>) => 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<string>) => EMPTY)(string$);`
).toInfer('eff', 'Subscription');
const effectTest = `const sub = componentStore.effect((e: Observable<string>) => EMPTY)(string$);`;
expectSnippet(effectTest).toSucceed();
expectSnippet(effectTest).toInfer('sub', 'Subscription');
});

it('when argument type is specified as Observable<unknown> and any type is passed', () => {
expectSnippet(
`const eff = componentStore.effect((e: Observable<unknown>) => EMPTY)(5);`
).toInfer('eff', 'Subscription');
const effectTest = `const sub = componentStore.effect((e: Observable<unknown>) => 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<string>((e) => number$)('string');`
).toInfer('eff', 'Subscription');
const effectTest = `const sub = componentStore.effect<string>((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<unknown>((e) => number$)('string');`
).toInfer('eff', 'Subscription');
const effectTest = `const sub = componentStore.effect<unknown>((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<unknown>((e) => e.pipe(concatMap(() => of())))('string');`
).toInfer('eff', 'Subscription');
const effectTest = `const sub = componentStore.effect<unknown>((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<unknown>((e) => e.pipe(concatMap(() => of())))('string');`
).toInfer('eff', 'Subscription');
`const sub = componentStore.effect<unknown>((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<Obj>) => 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<Partial<Obj>>) => number$)({prop: 'string'});`;
expectSnippet(effectTest).toSucceed();
expectSnippet(effectTest).toInfer('sub', 'Subscription');
});
});

describe('infers void', () => {
it('when argument type is specified as Observable<void> and nothing is passed', () => {
expectSnippet(
`const eff = componentStore.effect((e: Observable<void>) => string$)();`
).toInfer('eff', 'void');
const effectTest = `const v = componentStore.effect((e: Observable<void>) => 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<void> 👇
`const eff = componentStore.effect((e) => e.pipe(concatMap(() => of())))();`
).toInfer('eff', 'void');
// treated as Observable<void> 👇
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<void>((e) => e.pipe(concatMap(() => number$)))();`
).toInfer('eff', 'void');
const effectTest = `const v = componentStore.effect<void>((e) => e.pipe(concatMap(() => number$)))();`;
expectSnippet(effectTest).toSucceed();
expectSnippet(effectTest).toInfer('v', 'void');
});
});

Expand Down Expand Up @@ -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/);
});

Expand All @@ -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<number> = of(5);
const string$: Observable<string> = 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<Obj>) => ({...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<string>((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<void>((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<number>' is not assignable to parameter of type 'string \| Observable<string>'/
);
});
});
});
});
23 changes: 16 additions & 7 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,21 @@ export class ComponentStore<T extends object> implements OnDestroy {
* second argument to `updaterFn`. Every time this function is called
* subscribers will be notified of the state change.
*/
updater<V>(
updaterFn: (state: T, value: V) => T
): unknown extends V ? () => void : (t: V | Observable<V>) => Subscription {
return ((observableOrValue?: V | Observable<V>): 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<ValueType>) => Subscription
>(updaterFn: (state: T, value: OriginType) => T): ReturnType {
return (((
observableOrValue?: OriginType | Observable<OriginType>
): 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
Expand Down Expand Up @@ -118,9 +129,7 @@ export class ComponentStore<T extends object> implements OnDestroy {
throw /** @type {!Error} */ (initializationError);
}
return subscription;
}) as unknown extends V
? () => void
: (t: V | Observable<V>) => Subscription;
}) as unknown) as ReturnType;
}

/**
Expand Down

0 comments on commit b54b9b6

Please sign in to comment.