Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(component-store): adjust updater to accept partials #2765

Merged
merged 1 commit into from
Oct 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -77,10 +77,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 @@ -116,9 +127,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