Skip to content
This repository was archived by the owner on Nov 18, 2024. It is now read-only.

Commit 1c9bd54

Browse files
authored
Use redux-thunk for side effects (#176)
1 parent 5c1ce6d commit 1c9bd54

File tree

9 files changed

+182
-58
lines changed

9 files changed

+182
-58
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"redux": "4.0.1",
4141
"redux-devtools-extension": "2.13.8",
4242
"redux-logger": "3.0.6",
43+
"redux-thunk": "2.3.0",
4344
"ts-node": "8.0.2",
4445
"typesafe-actions": "3.1.0",
4546
"typescript": "3.3.3",

src/components/Navbar/index.spec.tsx

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,24 @@ import { Store } from 'redux';
55
import configureStore from '../../configureStore';
66
import LoginButton from '../LoginButton';
77
import styles from './styles.module.scss';
8-
import { fakeUser } from '../../test-helpers';
9-
import { logOutFromServer } from '../../api';
10-
import { actions as userActions } from '../../reducers/users';
8+
import { createFakeThunk, fakeUser } from '../../test-helpers';
9+
import { actions as userActions, requestLogOut } from '../../reducers/users';
1110

12-
import Navbar, { NavbarBase } from '.';
11+
import Navbar from '.';
1312

1413
describe(__filename, () => {
1514
type RenderParams = {
16-
_logOutFromServer?: typeof logOutFromServer;
15+
_requestLogOut?: typeof requestLogOut;
1716
store?: Store;
1817
};
1918

2019
const render = ({
21-
_logOutFromServer = jest.fn(),
20+
_requestLogOut = jest.fn(),
2221
store = configureStore(),
2322
}: RenderParams = {}) => {
2423
// TODO: Use shallowUntilTarget()
2524
// https://github.com/mozilla/addons-code-manager/issues/15
26-
const root = shallow(<Navbar _logOutFromServer={_logOutFromServer} />, {
25+
const root = shallow(<Navbar _requestLogOut={_requestLogOut} />, {
2726
context: { store },
2827
}).shallow();
2928

@@ -80,37 +79,20 @@ describe(__filename, () => {
8079
});
8180

8281
describe('Log out button', () => {
83-
it('configures the click handler', () => {
84-
const root = render({ store: storeWithUser() });
85-
const instance = root.instance() as NavbarBase;
86-
87-
expect(root.find(`.${styles.logOut}`)).toHaveProp(
88-
'onClick',
89-
instance.logOut,
90-
);
91-
});
82+
it('dispatches requestLogOut when clicked', () => {
83+
const store = storeWithUser();
84+
const dispatch = jest
85+
.spyOn(store, 'dispatch')
86+
.mockImplementation(jest.fn());
9287

93-
it('calls logOutFromServer when clicked', async () => {
94-
const logOutFromServerMock = jest.fn();
88+
const fakeThunk = createFakeThunk();
9589
const root = render({
96-
_logOutFromServer: logOutFromServerMock,
97-
store: storeWithUser(),
90+
store,
91+
_requestLogOut: fakeThunk.createThunk,
9892
});
9993

100-
const instance = root.instance() as NavbarBase;
101-
await instance.logOut();
102-
expect(logOutFromServerMock).toHaveBeenCalled();
103-
});
104-
105-
it('dispatches userActions.logOut when clicked', async () => {
106-
const store = storeWithUser();
107-
const dispatch = jest.spyOn(store, 'dispatch');
108-
109-
const root = render({ store });
110-
111-
const instance = root.instance() as NavbarBase;
112-
await instance.logOut();
113-
expect(dispatch).toHaveBeenCalledWith(userActions.logOut());
94+
root.find(`.${styles.logOut}`).simulate('click');
95+
expect(dispatch).toHaveBeenCalledWith(fakeThunk.thunk);
11496
});
11597
});
11698
});

src/components/Navbar/index.tsx

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,28 @@ import { connect } from 'react-redux';
44

55
import { gettext } from '../../utils';
66
import LoginButton from '../LoginButton';
7-
import { logOutFromServer } from '../../api';
87
import { ApplicationState, ConnectedReduxProps } from '../../configureStore';
9-
import { ApiState } from '../../reducers/api';
10-
import {
11-
User,
12-
actions as userActions,
13-
getCurrentUser,
14-
} from '../../reducers/users';
8+
import { User, getCurrentUser, requestLogOut } from '../../reducers/users';
159
import styles from './styles.module.scss';
1610

1711
type PublicProps = {
18-
_logOutFromServer: typeof logOutFromServer;
12+
_requestLogOut: typeof requestLogOut;
1913
};
2014

2115
type PropsFromState = {
22-
apiState: ApiState;
2316
profile: User | null;
2417
};
2518

2619
type Props = PublicProps & PropsFromState & ConnectedReduxProps;
2720

2821
export class NavbarBase extends React.Component<Props> {
2922
static defaultProps = {
30-
_logOutFromServer: logOutFromServer,
23+
_requestLogOut: requestLogOut,
3124
};
3225

33-
logOut = async () => {
34-
const { _logOutFromServer, apiState, dispatch } = this.props;
35-
36-
await _logOutFromServer(apiState);
37-
dispatch(userActions.logOut());
26+
logOut = () => {
27+
const { _requestLogOut, dispatch } = this.props;
28+
dispatch(_requestLogOut());
3829
};
3930

4031
render() {
@@ -62,7 +53,6 @@ export class NavbarBase extends React.Component<Props> {
6253

6354
const mapStateToProps = (state: ApplicationState): PropsFromState => {
6455
return {
65-
apiState: state.api,
6656
profile: getCurrentUser(state.users),
6757
};
6858
};

src/configureStore.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,66 @@
11
import {
22
Action,
33
AnyAction,
4-
Dispatch,
4+
Middleware,
55
Store,
66
applyMiddleware,
77
combineReducers,
88
createStore,
99
} from 'redux';
1010
import { composeWithDevTools } from 'redux-devtools-extension';
1111
import { createLogger } from 'redux-logger';
12+
import thunk, {
13+
ThunkAction,
14+
ThunkDispatch as ReduxThunkDispatch,
15+
ThunkMiddleware,
16+
} from 'redux-thunk';
1217

1318
import api, { ApiState } from './reducers/api';
1419
import users, { UsersState } from './reducers/users';
1520
import versions, { VersionsState } from './reducers/versions';
1621

17-
export type ConnectedReduxProps<A extends Action = AnyAction> = {
18-
dispatch: Dispatch<A>;
19-
};
20-
2122
export type ApplicationState = {
2223
api: ApiState;
2324
users: UsersState;
2425
versions: VersionsState;
2526
};
2627

28+
export type ThunkActionCreator<PromiseResult = void> = ThunkAction<
29+
Promise<PromiseResult>,
30+
ApplicationState,
31+
undefined,
32+
AnyAction
33+
>;
34+
35+
export type ThunkDispatch<A extends Action = AnyAction> = ReduxThunkDispatch<
36+
ApplicationState,
37+
undefined,
38+
A
39+
>;
40+
41+
export type ConnectedReduxProps<A extends Action = AnyAction> = {
42+
dispatch: ThunkDispatch<A>;
43+
};
44+
2745
const createRootReducer = () => {
2846
return combineReducers<ApplicationState>({ api, users, versions });
2947
};
3048

3149
const configureStore = (
3250
preloadedState?: ApplicationState,
3351
): Store<ApplicationState> => {
34-
let middleware;
52+
const allMiddleware: Middleware[] = [
53+
thunk as ThunkMiddleware<ApplicationState, AnyAction>,
54+
];
55+
let addDevTools = false;
56+
3557
if (process.env.NODE_ENV === 'development') {
36-
middleware = applyMiddleware(createLogger());
58+
allMiddleware.push(createLogger());
59+
addDevTools = true;
60+
}
3761

62+
let middleware = applyMiddleware(...allMiddleware);
63+
if (addDevTools) {
3864
const composeEnhancers = composeWithDevTools({});
3965
middleware = composeEnhancers(middleware);
4066
}

src/reducers/users.spec.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import reducer, {
33
createInternalUser,
44
getCurrentUser,
55
initialState,
6+
requestLogOut,
67
} from './users';
7-
import { fakeUser } from '../test-helpers';
8+
import { fakeUser, thunkTester } from '../test-helpers';
89

910
describe(__filename, () => {
1011
describe('reducer', () => {
@@ -53,4 +54,27 @@ describe(__filename, () => {
5354
expect(getCurrentUser(state)).toEqual(null);
5455
});
5556
});
57+
58+
describe('requestLogOut', () => {
59+
it('calls logOutFromServer', async () => {
60+
const _logOutFromServer = jest.fn();
61+
const { store, thunk } = thunkTester({
62+
createThunk: () => requestLogOut({ _logOutFromServer }),
63+
});
64+
65+
await thunk();
66+
67+
expect(_logOutFromServer).toHaveBeenCalledWith(store.getState().api);
68+
});
69+
70+
it('dispatches logOut', async () => {
71+
const { dispatch, thunk } = thunkTester({
72+
createThunk: () => requestLogOut({ _logOutFromServer: jest.fn() }),
73+
});
74+
75+
await thunk();
76+
77+
expect(dispatch).toHaveBeenCalledWith(actions.logOut());
78+
});
79+
});
5680
});

src/reducers/users.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Reducer } from 'redux';
22
import { ActionType, createAction, getType } from 'typesafe-actions';
33

4+
import { ThunkActionCreator } from '../configureStore';
5+
import { logOutFromServer } from '../api';
6+
47
type UserId = number;
58

69
export type ExternalUser = {
@@ -47,6 +50,15 @@ export const actions = {
4750
logOut: createAction('LOG_OUT'),
4851
};
4952

53+
export const requestLogOut = ({
54+
_logOutFromServer = logOutFromServer,
55+
} = {}): ThunkActionCreator => {
56+
return async (dispatch, getState) => {
57+
await _logOutFromServer(getState().api);
58+
dispatch(actions.logOut());
59+
};
60+
};
61+
5062
export type UsersState = {
5163
currentUser: User | null;
5264
};

src/test-helpers.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe(__filename, () => {
1111
};
1212

1313
const wrapper = () => {
14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1415
return (WrappedComponent: any) => {
1516
return (props: object) => {
1617
return <WrappedComponent {...props} />;

src/test-helpers.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import PropTypes from 'prop-types';
22
import { History, Location } from 'history';
33
import { shallow } from 'enzyme';
4+
import { Store } from 'redux';
45

6+
import configureStore, {
7+
ApplicationState,
8+
ThunkActionCreator,
9+
} from './configureStore';
510
import { ExternalUser } from './reducers/users';
611
import {
712
ExternalVersion,
@@ -167,7 +172,9 @@ type ShallowUntilTargetOptions = {
167172
};
168173

169174
export const shallowUntilTarget = (
175+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
170176
componentInstance: React.ReactElement<any>,
177+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
171178
targetComponent: React.JSXElementConstructor<any>,
172179
{
173180
maxTries = 10,
@@ -197,3 +204,79 @@ export const shallowUntilTarget = (
197204
gave up after ${maxTries} tries`,
198205
);
199206
};
207+
208+
/*
209+
* Creates a fake thunk for testing.
210+
*
211+
* Let's say you had a real thunk like this:
212+
*
213+
* const doLogout = () => {
214+
* return (dispatch, getState) => {
215+
* // Make a request to the API...
216+
* dispatch({ type: 'LOG_OUT' });
217+
* };
218+
* };
219+
*
220+
* You can replace this thunk for testing as:
221+
*
222+
* const fakeThunk = createFakeThunk();
223+
* render({ _doLogout: fakeThunk.createThunk });
224+
*
225+
* You can make an assertion that it was called like:
226+
*
227+
* expect(dispatch).toHaveBeenCalledWith(fakeThunk.thunk);
228+
*/
229+
export const createFakeThunk = () => {
230+
// This is a placeholder for the dispatch callback function,
231+
// the thunk itself.
232+
// In reality it would look like (dispatch, getState) => {}
233+
// but here it gets set to a string for easy test assertions.
234+
const dispatchCallback = '__thunkDispatchCallback__';
235+
236+
return {
237+
// This is a function that creates the dispatch callback.
238+
createThunk: jest.fn().mockReturnValue(dispatchCallback),
239+
thunk: dispatchCallback,
240+
};
241+
};
242+
243+
/*
244+
* Sets up a thunk for testing.
245+
*
246+
* Let's say you had a real thunk like this:
247+
*
248+
* const doLogout = () => {
249+
* return (dispatch, getState) => {
250+
* // Make a request to the API...
251+
* dispatch({ type: 'LOG_OUT' });
252+
* };
253+
* };
254+
*
255+
* You can set it up for testing like this:
256+
*
257+
* const { dispatch, thunk, store } = thunkTester({
258+
* createThunk: () => doLogout(),
259+
* });
260+
*
261+
* await thunk();
262+
*
263+
* expect(dispatch).toHaveBeenCalledWith({ type: 'LOG_OUT' });
264+
*
265+
*/
266+
export const thunkTester = ({
267+
store = configureStore(),
268+
createThunk,
269+
}: {
270+
store?: Store<ApplicationState>;
271+
createThunk: () => ThunkActionCreator;
272+
}) => {
273+
const thunk = createThunk();
274+
const dispatch = jest.fn();
275+
276+
return {
277+
dispatch,
278+
// This simulates how the middleware will run the thunk.
279+
thunk: () => thunk(dispatch, () => store.getState(), undefined),
280+
store,
281+
};
282+
};

0 commit comments

Comments
 (0)