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

Use redux-thunk for side effects #176

Merged
merged 13 commits into from
Feb 20, 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"redux": "4.0.1",
"redux-devtools-extension": "2.13.8",
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0",
"ts-node": "8.0.2",
"typesafe-actions": "3.1.0",
"typescript": "3.3.3",
Expand Down
50 changes: 16 additions & 34 deletions src/components/Navbar/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,24 @@ import { Store } from 'redux';
import configureStore from '../../configureStore';
import LoginButton from '../LoginButton';
import styles from './styles.module.scss';
import { fakeUser } from '../../test-helpers';
import { logOutFromServer } from '../../api';
import { actions as userActions } from '../../reducers/users';
import { createFakeThunk, fakeUser } from '../../test-helpers';
import { actions as userActions, requestLogOut } from '../../reducers/users';

import Navbar, { NavbarBase } from '.';
import Navbar from '.';

describe(__filename, () => {
type RenderParams = {
_logOutFromServer?: typeof logOutFromServer;
_requestLogOut?: typeof requestLogOut;
store?: Store;
};

const render = ({
_logOutFromServer = jest.fn(),
_requestLogOut = jest.fn(),
store = configureStore(),
}: RenderParams = {}) => {
// TODO: Use shallowUntilTarget()
// https://github.com/mozilla/addons-code-manager/issues/15
const root = shallow(<Navbar _logOutFromServer={_logOutFromServer} />, {
const root = shallow(<Navbar _requestLogOut={_requestLogOut} />, {
context: { store },
}).shallow();

Expand Down Expand Up @@ -80,37 +79,20 @@ describe(__filename, () => {
});

describe('Log out button', () => {
it('configures the click handler', () => {
const root = render({ store: storeWithUser() });
const instance = root.instance() as NavbarBase;

expect(root.find(`.${styles.logOut}`)).toHaveProp(
'onClick',
instance.logOut,
);
});
it('dispatches requestLogOut when clicked', () => {
const store = storeWithUser();
const dispatch = jest
.spyOn(store, 'dispatch')
.mockImplementation(jest.fn());

it('calls logOutFromServer when clicked', async () => {
const logOutFromServerMock = jest.fn();
const fakeThunk = createFakeThunk();
const root = render({
_logOutFromServer: logOutFromServerMock,
store: storeWithUser(),
store,
_requestLogOut: fakeThunk.createThunk,
});

const instance = root.instance() as NavbarBase;
await instance.logOut();
expect(logOutFromServerMock).toHaveBeenCalled();
});

it('dispatches userActions.logOut when clicked', async () => {
const store = storeWithUser();
const dispatch = jest.spyOn(store, 'dispatch');

const root = render({ store });

const instance = root.instance() as NavbarBase;
await instance.logOut();
expect(dispatch).toHaveBeenCalledWith(userActions.logOut());
root.find(`.${styles.logOut}`).simulate('click');
expect(dispatch).toHaveBeenCalledWith(fakeThunk.thunk);
});
});
});
22 changes: 6 additions & 16 deletions src/components/Navbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,28 @@ import { connect } from 'react-redux';

import { gettext } from '../../utils';
import LoginButton from '../LoginButton';
import { logOutFromServer } from '../../api';
import { ApplicationState, ConnectedReduxProps } from '../../configureStore';
import { ApiState } from '../../reducers/api';
import {
User,
actions as userActions,
getCurrentUser,
} from '../../reducers/users';
import { User, getCurrentUser, requestLogOut } from '../../reducers/users';
import styles from './styles.module.scss';

type PublicProps = {
_logOutFromServer: typeof logOutFromServer;
_requestLogOut: typeof requestLogOut;
};

type PropsFromState = {
apiState: ApiState;
profile: User | null;
};

type Props = PublicProps & PropsFromState & ConnectedReduxProps;

export class NavbarBase extends React.Component<Props> {
static defaultProps = {
_logOutFromServer: logOutFromServer,
_requestLogOut: requestLogOut,
};

logOut = async () => {
const { _logOutFromServer, apiState, dispatch } = this.props;

await _logOutFromServer(apiState);
dispatch(userActions.logOut());
logOut = () => {
const { _requestLogOut, dispatch } = this.props;
dispatch(_requestLogOut());
};

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

const mapStateToProps = (state: ApplicationState): PropsFromState => {
return {
apiState: state.api,
profile: getCurrentUser(state.users),
};
};
Expand Down
40 changes: 33 additions & 7 deletions src/configureStore.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,66 @@
import {
Action,
AnyAction,
Dispatch,
Middleware,
Store,
applyMiddleware,
combineReducers,
createStore,
} from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { createLogger } from 'redux-logger';
import thunk, {
ThunkAction,
ThunkDispatch as ReduxThunkDispatch,
ThunkMiddleware,
} from 'redux-thunk';

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

export type ConnectedReduxProps<A extends Action = AnyAction> = {
dispatch: Dispatch<A>;
};

export type ApplicationState = {
api: ApiState;
users: UsersState;
versions: VersionsState;
};

export type ThunkActionCreator<PromiseResult = void> = ThunkAction<
Promise<PromiseResult>,
ApplicationState,
undefined,
AnyAction
>;

export type ThunkDispatch<A extends Action = AnyAction> = ReduxThunkDispatch<
ApplicationState,
undefined,
A
>;

export type ConnectedReduxProps<A extends Action = AnyAction> = {
dispatch: ThunkDispatch<A>;
};

const createRootReducer = () => {
return combineReducers<ApplicationState>({ api, users, versions });
};

const configureStore = (
preloadedState?: ApplicationState,
): Store<ApplicationState> => {
let middleware;
const allMiddleware: Middleware[] = [
thunk as ThunkMiddleware<ApplicationState, AnyAction>,
];
let addDevTools = false;

if (process.env.NODE_ENV === 'development') {
middleware = applyMiddleware(createLogger());
allMiddleware.push(createLogger());
addDevTools = true;
}

let middleware = applyMiddleware(...allMiddleware);
if (addDevTools) {
const composeEnhancers = composeWithDevTools({});
middleware = composeEnhancers(middleware);
}
Expand Down
26 changes: 25 additions & 1 deletion src/reducers/users.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import reducer, {
createInternalUser,
getCurrentUser,
initialState,
requestLogOut,
} from './users';
import { fakeUser } from '../test-helpers';
import { fakeUser, thunkTester } from '../test-helpers';

describe(__filename, () => {
describe('reducer', () => {
Expand Down Expand Up @@ -53,4 +54,27 @@ describe(__filename, () => {
expect(getCurrentUser(state)).toEqual(null);
});
});

describe('requestLogOut', () => {
it('calls logOutFromServer', async () => {
const _logOutFromServer = jest.fn();
const { store, thunk } = thunkTester({
createThunk: () => requestLogOut({ _logOutFromServer }),
});

await thunk();

expect(_logOutFromServer).toHaveBeenCalledWith(store.getState().api);
});

it('dispatches logOut', async () => {
const { dispatch, thunk } = thunkTester({
createThunk: () => requestLogOut({ _logOutFromServer: jest.fn() }),
});

await thunk();

expect(dispatch).toHaveBeenCalledWith(actions.logOut());
});
});
});
12 changes: 12 additions & 0 deletions src/reducers/users.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Reducer } from 'redux';
import { ActionType, createAction, getType } from 'typesafe-actions';

import { ThunkActionCreator } from '../configureStore';
import { logOutFromServer } from '../api';

type UserId = number;

export type ExternalUser = {
Expand Down Expand Up @@ -47,6 +50,15 @@ export const actions = {
logOut: createAction('LOG_OUT'),
};

export const requestLogOut = ({
_logOutFromServer = logOutFromServer,
} = {}): ThunkActionCreator => {
return async (dispatch, getState) => {
await _logOutFromServer(getState().api);
dispatch(actions.logOut());
};
};

export type UsersState = {
currentUser: User | null;
};
Expand Down
1 change: 1 addition & 0 deletions src/test-helpers.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe(__filename, () => {
};

const wrapper = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this resolves #174 :D

return (WrappedComponent: any) => {
return (props: object) => {
return <WrappedComponent {...props} />;
Expand Down
83 changes: 83 additions & 0 deletions src/test-helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import PropTypes from 'prop-types';
import { History, Location } from 'history';
import { shallow } from 'enzyme';
import { Store } from 'redux';

import configureStore, {
ApplicationState,
ThunkActionCreator,
} from './configureStore';
import { ExternalUser } from './reducers/users';
import {
ExternalVersion,
Expand Down Expand Up @@ -167,7 +172,9 @@ type ShallowUntilTargetOptions = {
};

export const shallowUntilTarget = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
componentInstance: React.ReactElement<any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
targetComponent: React.JSXElementConstructor<any>,
{
maxTries = 10,
Expand Down Expand Up @@ -197,3 +204,79 @@ export const shallowUntilTarget = (
gave up after ${maxTries} tries`,
);
};

/*
* Creates a fake thunk for testing.
*
* Let's say you had a real thunk like this:
*
* const doLogout = () => {
* return (dispatch, getState) => {
* // Make a request to the API...
* dispatch({ type: 'LOG_OUT' });
* };
* };
*
* You can replace this thunk for testing as:
*
* const fakeThunk = createFakeThunk();
* render({ _doLogout: fakeThunk.createThunk });
*
* You can make an assertion that it was called like:
*
* expect(dispatch).toHaveBeenCalledWith(fakeThunk.thunk);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

*/
export const createFakeThunk = () => {
// This is a placeholder for the dispatch callback function,
// the thunk itself.
// In reality it would look like (dispatch, getState) => {}
// but here it gets set to a string for easy test assertions.
const dispatchCallback = '__thunkDispatchCallback__';

return {
// This is a function that creates the dispatch callback.
createThunk: jest.fn().mockReturnValue(dispatchCallback),
thunk: dispatchCallback,
};
};

/*
* Sets up a thunk for testing.
*
* Let's say you had a real thunk like this:
*
* const doLogout = () => {
* return (dispatch, getState) => {
* // Make a request to the API...
* dispatch({ type: 'LOG_OUT' });
* };
* };
*
* You can set it up for testing like this:
*
* const { dispatch, thunk, store } = thunkTester({
* createThunk: () => doLogout(),
* });
*
* await thunk();
*
* expect(dispatch).toHaveBeenCalledWith({ type: 'LOG_OUT' });
*
*/
export const thunkTester = ({
store = configureStore(),
createThunk,
}: {
store?: Store<ApplicationState>;
createThunk: () => ThunkActionCreator;
}) => {
const thunk = createThunk();
const dispatch = jest.fn();

return {
dispatch,
// This simulates how the middleware will run the thunk.
thunk: () => thunk(dispatch, () => store.getState(), undefined),
store,
};
};
Loading