Skip to content

Commit 93d295e

Browse files
committed
feat(store, example): add action creator
1 parent d559998 commit 93d295e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+530
-363
lines changed

modules/store-devtools/src/devtools.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ import * as Actions from './actions';
2121
import { STORE_DEVTOOLS_CONFIG, StoreDevtoolsConfig } from './config';
2222
import { DevtoolsExtension } from './extension';
2323
import { LiftedState, liftInitialState, liftReducerWith } from './reducer';
24-
import { liftAction, unliftState, shouldFilterActions, filterLiftedState } from './utils';
24+
import {
25+
liftAction,
26+
unliftState,
27+
shouldFilterActions,
28+
filterLiftedState,
29+
} from './utils';
2530
import { DevtoolsDispatcher } from './devtools-dispatcher';
2631
import { PERFORM_ACTION } from './actions';
2732

@@ -73,25 +78,25 @@ export class StoreDevtools implements Observer<any> {
7378
state: LiftedState;
7479
action: any;
7580
}
76-
>(
77-
({ state: liftedState }, [action, reducer]) => {
78-
let reducedLiftedState = reducer(liftedState, action);
79-
// On full state update
80-
// If we have actions filters, we must filter completly our lifted state to be sync with the extension
81-
if (action.type !== PERFORM_ACTION && shouldFilterActions(config)) {
82-
reducedLiftedState = filterLiftedState(
83-
reducedLiftedState,
84-
config.predicate,
85-
config.actionsWhitelist,
86-
config.actionsBlacklist
87-
);
88-
}
89-
// Extension should be sent the sanitized lifted state
90-
extension.notify(action, reducedLiftedState);
91-
return { state: reducedLiftedState, action };
92-
},
93-
{ state: liftedInitialState, action: null as any }
94-
)
81+
>(
82+
({ state: liftedState }, [action, reducer]) => {
83+
let reducedLiftedState = reducer(liftedState, action);
84+
// On full state update
85+
// If we have actions filters, we must filter completly our lifted state to be sync with the extension
86+
if (action.type !== PERFORM_ACTION && shouldFilterActions(config)) {
87+
reducedLiftedState = filterLiftedState(
88+
reducedLiftedState,
89+
config.predicate,
90+
config.actionsWhitelist,
91+
config.actionsBlacklist
92+
);
93+
}
94+
// Extension should be sent the sanitized lifted state
95+
extension.notify(action, reducedLiftedState);
96+
return { state: reducedLiftedState, action };
97+
},
98+
{ state: liftedInitialState, action: null as any }
99+
)
95100
)
96101
.subscribe(({ state, action }) => {
97102
liftedStateSubject.next(state);
@@ -109,7 +114,7 @@ export class StoreDevtools implements Observer<any> {
109114

110115
const liftedState$ = liftedStateSubject.asObservable() as Observable<
111116
LiftedState
112-
>;
117+
>;
113118
const state$ = liftedState$.pipe(map(unliftState));
114119

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

130-
error(error: any) { }
135+
error(error: any) {}
131136

132-
complete() { }
137+
complete() {}
133138

134139
performAction(action: any) {
135140
this.dispatch(new Actions.PerformAction(action, +Date.now()));

modules/store/spec/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ts_test_library(
1212
"//modules/store",
1313
"//modules/store/testing",
1414
"@npm//rxjs",
15+
"@npm//ts-snippet",
1516
],
1617
)
1718

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { createAction, props, union } from '@ngrx/store';
2+
import { expecter } from 'ts-snippet';
3+
4+
describe('Action Creators', () => {
5+
let originalTimeout: number;
6+
beforeEach(() => {
7+
originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
8+
jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000;
9+
});
10+
11+
afterEach(() => {
12+
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
13+
});
14+
15+
const expectSnippet = expecter(
16+
code => `
17+
// path goes from root
18+
import {createAction, props, union} from './modules/store/src/action_creator';
19+
${code}`,
20+
{
21+
moduleResolution: 'node',
22+
target: 'es2015',
23+
}
24+
);
25+
26+
describe('createAction', () => {
27+
it('should create an action', () => {
28+
const foo = createAction('FOO', (foo: number) => ({ foo }));
29+
const fooAction = foo(42);
30+
expect(fooAction).toEqual({ type: 'FOO', foo: 42 });
31+
});
32+
33+
it('should narrow the action', () => {
34+
const foo = createAction('FOO', (foo: number) => ({ foo }));
35+
const bar = createAction('BAR', (bar: number) => ({ bar }));
36+
const both = union({ foo, bar });
37+
const narrow = (action: typeof both) => {
38+
if (action.type === foo.type) {
39+
expect(action.foo).toEqual(42);
40+
} else {
41+
throw new Error('Should not get here.');
42+
}
43+
};
44+
narrow(foo(42));
45+
});
46+
47+
it('should be serializable', () => {
48+
const foo = createAction('FOO', (foo: number) => ({ foo }));
49+
const fooAction = foo(42);
50+
const text = JSON.stringify(fooAction);
51+
expect(JSON.parse(text)).toEqual({ type: 'FOO', foo: 42 });
52+
});
53+
54+
it('should enforce ctor parameters', () => {
55+
expectSnippet(`
56+
const foo = createAction('FOO', (foo: number) => ({ foo }));
57+
const fooAction = foo('42');
58+
`).toFail(/not assignable to parameter of type 'number'/);
59+
});
60+
61+
it('should enforce action property types', () => {
62+
expectSnippet(`
63+
const foo = createAction('FOO', (foo: number) => ({ foo }));
64+
const fooAction = foo(42);
65+
const value: string = fooAction.foo;
66+
`).toFail(/'number' is not assignable to type 'string'/);
67+
});
68+
69+
it('should enforce action property names', () => {
70+
expectSnippet(`
71+
const foo = createAction('FOO', (foo: number) => ({ foo }));
72+
const fooAction = foo(42);
73+
const value = fooAction.bar;
74+
`).toFail(/'bar' does not exist on type/);
75+
});
76+
});
77+
describe('empty', () => {
78+
it('should allow empty action', () => {
79+
const foo = createAction('FOO');
80+
const fooAction = foo();
81+
expect(fooAction).toEqual({ type: 'FOO' });
82+
});
83+
});
84+
85+
describe('props', () => {
86+
it('should create an action', () => {
87+
const foo = createAction('FOO', props<{ foo: number }>());
88+
const fooAction = foo({ foo: 42 });
89+
expect(fooAction).toEqual({ type: 'FOO', foo: 42 });
90+
});
91+
92+
it('should narrow the action', () => {
93+
const foo = createAction('FOO', props<{ foo: number }>());
94+
const bar = createAction('BAR', props<{ bar: number }>());
95+
const both = union({ foo, bar });
96+
const narrow = (action: typeof both) => {
97+
if (action.type === foo.type) {
98+
expect(action.foo).toEqual(42);
99+
} else {
100+
throw new Error('Should not get here.');
101+
}
102+
};
103+
narrow(foo({ foo: 42 }));
104+
});
105+
106+
it('should be serializable', () => {
107+
const foo = createAction('FOO', props<{ foo: number }>());
108+
const fooAction = foo({ foo: 42 });
109+
const text = JSON.stringify(fooAction);
110+
expect(JSON.parse(text)).toEqual({ foo: 42, type: 'FOO' });
111+
});
112+
113+
it('should enforce ctor parameters', () => {
114+
expectSnippet(`
115+
const foo = createAction('FOO', props<{ foo: number }>());
116+
const fooAction = foo({ foo: '42' });
117+
`).toFail(/'string' is not assignable to type 'number'/);
118+
});
119+
120+
it('should enforce action property types', () => {
121+
expectSnippet(`
122+
const foo = createAction('FOO', props<{ foo: number }>());
123+
const fooAction = foo({ foo: 42 });
124+
const value: string = fooAction.foo;
125+
`).toFail(/'number' is not assignable to type 'string'/);
126+
});
127+
128+
it('should enforce action property names', () => {
129+
expectSnippet(`
130+
const foo = createAction('FOO', props<{ foo: number }>());
131+
const fooAction = foo({ foo: 42 });
132+
const value = fooAction.bar;
133+
`).toFail(/'bar' does not exist on type/);
134+
});
135+
});
136+
});

modules/store/src/action_creator.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {
2+
Creator,
3+
ActionCreator,
4+
TypedAction,
5+
FunctionWithParametersType,
6+
ParametersType,
7+
} from './models';
8+
9+
/**
10+
* Action creators taken from ts-action library and modified a bit to better
11+
* fit current NgRx usage. Thank you Nicholas Jamieson (@cartant).
12+
*/
13+
export function createAction<T extends string>(
14+
type: T
15+
): ActionCreator<T, () => TypedAction<T>>;
16+
export function createAction<T extends string, P extends object>(
17+
type: T,
18+
config: { _as: 'props'; _p: P }
19+
): ActionCreator<T, (props: P) => P & TypedAction<T>>;
20+
export function createAction<T extends string, C extends Creator>(
21+
type: T,
22+
creator: C
23+
): FunctionWithParametersType<
24+
ParametersType<C>,
25+
ReturnType<C> & TypedAction<T>
26+
> &
27+
TypedAction<T>;
28+
export function createAction<T extends string>(
29+
type: T,
30+
config?: { _as: 'props' } | Creator
31+
): Creator {
32+
if (typeof config === 'function') {
33+
return defineType(type, (...args: unknown[]) => ({
34+
...config(...args),
35+
type,
36+
}));
37+
}
38+
const as = config ? config._as : 'empty';
39+
switch (as) {
40+
case 'empty':
41+
return defineType(type, () => ({ type }));
42+
case 'props':
43+
return defineType(type, (props: unknown) => ({
44+
...(props as object),
45+
type,
46+
}));
47+
default:
48+
throw new Error('Unexpected config.');
49+
}
50+
}
51+
52+
export function props<P>(): { _as: 'props'; _p: P } {
53+
return { _as: 'props', _p: undefined! };
54+
}
55+
56+
export function union<
57+
C extends { [key: string]: ActionCreator<string, Creator> }
58+
>(creators: C): ReturnType<C[keyof C]> {
59+
return undefined!;
60+
}
61+
62+
function defineType(type: string, creator: Creator): Creator {
63+
return Object.defineProperty(creator, 'type', {
64+
value: type,
65+
writable: false,
66+
});
67+
}

modules/store/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
export {
22
Action,
3+
ActionCreator,
34
ActionReducer,
45
ActionReducerMap,
56
ActionReducerFactory,
7+
Creator,
68
MetaReducer,
79
Selector,
810
SelectorWithProps,
911
} from './models';
12+
export { createAction, props, union } from './action_creator';
1013
export { Store, select } from './store';
1114
export { combineReducers, compose, createReducerFactory } from './utils';
1215
export { ActionsSubject, INIT } from './actions_subject';

modules/store/src/models.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ export interface Action {
22
type: string;
33
}
44

5+
// declare to make it property-renaming safe
6+
export declare interface TypedAction<T extends string> extends Action {
7+
readonly type: T;
8+
}
9+
510
export type TypeId<T> = () => T;
611

712
export type InitialState<T> = Partial<T> | TypeId<Partial<T>> | void;
@@ -39,3 +44,16 @@ export type SelectorWithProps<State, Props, Result> = (
3944
state: State,
4045
props: Props
4146
) => Result;
47+
48+
export type Creator = (...args: any[]) => object;
49+
50+
export type ActionCreator<T extends string, C extends Creator> = C &
51+
TypedAction<T>;
52+
53+
export type FunctionWithParametersType<P extends unknown[], R = void> = (
54+
...args: P
55+
) => R;
56+
57+
export type ParametersType<T> = T extends (...args: infer U) => unknown
58+
? U
59+
: never;

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"build:stackblitz": "ts-node ./build/stackblitz.ts && git add ./stackblitz.html"
3838
},
3939
"engines": {
40-
"node": ">=10.9.0 <11.2.0",
40+
"node": ">=10.9.0 <=11.12.0",
4141
"npm": ">=5.3.0",
4242
"yarn": ">=1.9.2 <2.0.0"
4343
},
@@ -159,6 +159,7 @@
159159
"sorcery": "^0.10.0",
160160
"ts-loader": "^5.3.3",
161161
"ts-node": "^5.0.1",
162+
"ts-snippet": "^4.1.0",
162163
"tsconfig-paths": "^3.1.3",
163164
"tsickle": "^0.34.3",
164165
"tslib": "^1.7.1",
Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
1-
import { Action } from '@ngrx/store';
1+
import { union, props, createAction } from '@ngrx/store';
22
import { User } from '@example-app/auth/models/user';
33

4-
export enum AuthApiActionTypes {
5-
LoginSuccess = '[Auth/API] Login Success',
6-
LoginFailure = '[Auth/API] Login Failure',
7-
LoginRedirect = '[Auth/API] Login Redirect',
8-
}
4+
export const loginSuccess = createAction(
5+
'[Auth/API] Login Success',
6+
props<{ user: User }>()
7+
);
98

10-
export class LoginSuccess implements Action {
11-
readonly type = AuthApiActionTypes.LoginSuccess;
9+
export const loginFailure = createAction(
10+
'[Auth/API] Login Failure',
11+
props<{ error: any }>()
12+
);
1213

13-
constructor(public payload: { user: User }) {}
14-
}
14+
export const loginRedirect = createAction('[Auth/API] Login Redirect');
1515

16-
export class LoginFailure implements Action {
17-
readonly type = AuthApiActionTypes.LoginFailure;
18-
19-
constructor(public payload: { error: any }) {}
20-
}
21-
22-
export class LoginRedirect implements Action {
23-
readonly type = AuthApiActionTypes.LoginRedirect;
24-
}
25-
26-
export type AuthApiActionsUnion = LoginSuccess | LoginFailure | LoginRedirect;
16+
const all = union({ loginSuccess, loginFailure, loginRedirect });
17+
export type AuthApiActionsUnion = typeof all;

0 commit comments

Comments
 (0)