Skip to content

Commit

Permalink
feat(store): add action creator functions (#1654)
Browse files Browse the repository at this point in the history
Closes #1480, #1634
  • Loading branch information
alex-okrushko authored and brandonroberts committed Mar 29, 2019
1 parent d559998 commit e7fe28b
Show file tree
Hide file tree
Showing 48 changed files with 536 additions and 364 deletions.
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 }));
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

0 comments on commit e7fe28b

Please sign in to comment.