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

feat(store, example): add action creator #1654

Merged
merged 3 commits into from
Mar 29, 2019
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
51 changes: 28 additions & 23 deletions modules/store-devtools/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import * as Actions from './actions';
import { STORE_DEVTOOLS_CONFIG, StoreDevtoolsConfig } from './config';
import { DevtoolsExtension } from './extension';
import { LiftedState, liftInitialState, liftReducerWith } from './reducer';
import { liftAction, unliftState, shouldFilterActions, filterLiftedState } from './utils';
import {
liftAction,
unliftState,
shouldFilterActions,
filterLiftedState,
} from './utils';
import { DevtoolsDispatcher } from './devtools-dispatcher';
import { PERFORM_ACTION } from './actions';

Expand Down Expand Up @@ -73,25 +78,25 @@ export class StoreDevtools implements Observer<any> {
state: LiftedState;
action: any;
}
>(
({ state: liftedState }, [action, reducer]) => {
let reducedLiftedState = reducer(liftedState, action);
// On full state update
// If we have actions filters, we must filter completly our lifted state to be sync with the extension
if (action.type !== PERFORM_ACTION && shouldFilterActions(config)) {
reducedLiftedState = filterLiftedState(
reducedLiftedState,
config.predicate,
config.actionsWhitelist,
config.actionsBlacklist
);
}
// Extension should be sent the sanitized lifted state
extension.notify(action, reducedLiftedState);
return { state: reducedLiftedState, action };
},
{ state: liftedInitialState, action: null as any }
)
>(
({ state: liftedState }, [action, reducer]) => {
let reducedLiftedState = reducer(liftedState, action);
// On full state update
// If we have actions filters, we must filter completly our lifted state to be sync with the extension
if (action.type !== PERFORM_ACTION && shouldFilterActions(config)) {
reducedLiftedState = filterLiftedState(
reducedLiftedState,
config.predicate,
config.actionsWhitelist,
config.actionsBlacklist
);
}
// Extension should be sent the sanitized lifted state
extension.notify(action, reducedLiftedState);
return { state: reducedLiftedState, action };
},
{ state: liftedInitialState, action: null as any }
)
)
.subscribe(({ state, action }) => {
liftedStateSubject.next(state);
Expand All @@ -109,7 +114,7 @@ export class StoreDevtools implements Observer<any> {

const liftedState$ = liftedStateSubject.asObservable() as Observable<
LiftedState
>;
>;
const state$ = liftedState$.pipe(map(unliftState));

this.extensionStartSubscription = extensionStartSubscription;
Expand All @@ -127,9 +132,9 @@ export class StoreDevtools implements Observer<any> {
this.dispatcher.next(action);
}

error(error: any) { }
error(error: any) {}

complete() { }
complete() {}

performAction(action: any) {
this.dispatch(new Actions.PerformAction(action, +Date.now()));
Expand Down
1 change: 1 addition & 0 deletions modules/store/spec/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ ts_test_library(
"//modules/store",
"//modules/store/testing",
"@npm//rxjs",
"@npm//ts-snippet",
],
)

Expand Down
145 changes: 145 additions & 0 deletions modules/store/spec/action_creator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { createAction, props, union } from '@ngrx/store';
import { expecter } from 'ts-snippet';

describe('Action Creators', () => {
let originalTimeout: number;

beforeEach(() => {
originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000;
});

afterEach(() => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
});

const expectSnippet = expecter(
code => `
// path goes from root
import {createAction, props, union} from './modules/store/src/action_creator';
${code}`,
{
moduleResolution: 'node',
target: 'es2015',
}
);

describe('createAction', () => {
it('should create an action', () => {
const foo = createAction('FOO', (foo: number) => ({ foo }));
alex-okrushko marked this conversation as resolved.
Show resolved Hide resolved
const fooAction = foo(42);

expect(fooAction).toEqual({ type: 'FOO', foo: 42 });
});

it('should narrow the action', () => {
const foo = createAction('FOO', (foo: number) => ({ foo }));
const bar = createAction('BAR', (bar: number) => ({ bar }));
const both = union({ foo, bar });
const narrow = (action: typeof both) => {
if (action.type === foo.type) {
expect(action.foo).toEqual(42);
} else {
throw new Error('Should not get here.');
}
};

narrow(foo(42));
});

it('should be serializable', () => {
const foo = createAction('FOO', (foo: number) => ({ foo }));
const fooAction = foo(42);
const text = JSON.stringify(fooAction);

expect(JSON.parse(text)).toEqual({ type: 'FOO', foo: 42 });
});

it('should enforce ctor parameters', () => {
expectSnippet(`
const foo = createAction('FOO', (foo: number) => ({ foo }));
const fooAction = foo('42');
`).toFail(/not assignable to parameter of type 'number'/);
});

it('should enforce action property types', () => {
expectSnippet(`
const foo = createAction('FOO', (foo: number) => ({ foo }));
const fooAction = foo(42);
const value: string = fooAction.foo;
`).toFail(/'number' is not assignable to type 'string'/);
});

it('should enforce action property names', () => {
expectSnippet(`
const foo = createAction('FOO', (foo: number) => ({ foo }));
const fooAction = foo(42);
const value = fooAction.bar;
`).toFail(/'bar' does not exist on type/);
});
});

describe('empty', () => {
it('should allow empty action', () => {
const foo = createAction('FOO');
const fooAction = foo();

expect(fooAction).toEqual({ type: 'FOO' });
});
});

describe('props', () => {
it('should create an action', () => {
const foo = createAction('FOO', props<{ foo: number }>());
const fooAction = foo({ foo: 42 });

expect(fooAction).toEqual({ type: 'FOO', foo: 42 });
});

it('should narrow the action', () => {
const foo = createAction('FOO', props<{ foo: number }>());
const bar = createAction('BAR', props<{ bar: number }>());
const both = union({ foo, bar });
const narrow = (action: typeof both) => {
if (action.type === foo.type) {
expect(action.foo).toEqual(42);
} else {
throw new Error('Should not get here.');
}
};

narrow(foo({ foo: 42 }));
});

it('should be serializable', () => {
const foo = createAction('FOO', props<{ foo: number }>());
const fooAction = foo({ foo: 42 });
const text = JSON.stringify(fooAction);

expect(JSON.parse(text)).toEqual({ foo: 42, type: 'FOO' });
});

it('should enforce ctor parameters', () => {
expectSnippet(`
const foo = createAction('FOO', props<{ foo: number }>());
const fooAction = foo({ foo: '42' });
`).toFail(/'string' is not assignable to type 'number'/);
});

it('should enforce action property types', () => {
expectSnippet(`
const foo = createAction('FOO', props<{ foo: number }>());
const fooAction = foo({ foo: 42 });
const value: string = fooAction.foo;
`).toFail(/'number' is not assignable to type 'string'/);
});

it('should enforce action property names', () => {
expectSnippet(`
const foo = createAction('FOO', props<{ foo: number }>());
const fooAction = foo({ foo: 42 });
const value = fooAction.bar;
`).toFail(/'bar' does not exist on type/);
});
});
});
67 changes: 67 additions & 0 deletions modules/store/src/action_creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
Creator,
ActionCreator,
TypedAction,
FunctionWithParametersType,
ParametersType,
} from './models';

/**
* Action creators taken from ts-action library and modified a bit to better
* fit current NgRx usage. Thank you Nicholas Jamieson (@cartant).
*/
export function createAction<T extends string>(
type: T
): ActionCreator<T, () => TypedAction<T>>;
export function createAction<T extends string, P extends object>(
type: T,
config: { _as: 'props'; _p: P }
): ActionCreator<T, (props: P) => P & TypedAction<T>>;
export function createAction<T extends string, C extends Creator>(
type: T,
creator: C
): FunctionWithParametersType<
ParametersType<C>,
ReturnType<C> & TypedAction<T>
> &
TypedAction<T>;
export function createAction<T extends string>(
type: T,
config?: { _as: 'props' } | Creator
): Creator {
if (typeof config === 'function') {
return defineType(type, (...args: unknown[]) => ({
...config(...args),
type,
}));
}
const as = config ? config._as : 'empty';
switch (as) {
case 'empty':
return defineType(type, () => ({ type }));
case 'props':
return defineType(type, (props: unknown) => ({
...(props as object),
type,
}));
default:
throw new Error('Unexpected config.');
}
}

export function props<P>(): { _as: 'props'; _p: P } {
return { _as: 'props', _p: undefined! };
}

export function union<
C extends { [key: string]: ActionCreator<string, Creator> }
>(creators: C): ReturnType<C[keyof C]> {
return undefined!;
}

function defineType(type: string, creator: Creator): Creator {
return Object.defineProperty(creator, 'type', {
value: type,
writable: false,
});
}
3 changes: 3 additions & 0 deletions modules/store/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
export {
Action,
ActionCreator,
ActionReducer,
ActionReducerMap,
ActionReducerFactory,
Creator,
MetaReducer,
Selector,
SelectorWithProps,
} from './models';
export { createAction, props, union } from './action_creator';
export { Store, select } from './store';
export { combineReducers, compose, createReducerFactory } from './utils';
export { ActionsSubject, INIT } from './actions_subject';
Expand Down
18 changes: 18 additions & 0 deletions modules/store/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ export interface Action {
type: string;
}

// declare to make it property-renaming safe
export declare interface TypedAction<T extends string> extends Action {
readonly type: T;
}

export type TypeId<T> = () => T;

export type InitialState<T> = Partial<T> | TypeId<Partial<T>> | void;
Expand Down Expand Up @@ -39,3 +44,16 @@ export type SelectorWithProps<State, Props, Result> = (
state: State,
props: Props
) => Result;

export type Creator = (...args: any[]) => object;

export type ActionCreator<T extends string, C extends Creator> = C &
TypedAction<T>;

export type FunctionWithParametersType<P extends unknown[], R = void> = (
...args: P
) => R;

export type ParametersType<T> = T extends (...args: infer U) => unknown
? U
: never;
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"build:stackblitz": "ts-node ./build/stackblitz.ts && git add ./stackblitz.html"
},
"engines": {
"node": ">=10.9.0 <11.2.0",
"node": ">=10.9.0 <=11.12.0",
"npm": ">=5.3.0",
"yarn": ">=1.9.2 <2.0.0"
},
Expand Down Expand Up @@ -159,6 +159,7 @@
"sorcery": "^0.10.0",
"ts-loader": "^5.3.3",
"ts-node": "^5.0.1",
"ts-snippet": "^4.1.0",
"tsconfig-paths": "^3.1.3",
"tsickle": "^0.34.3",
"tslib": "^1.7.1",
Expand Down
Loading