Skip to content

Commit 76622b0

Browse files
committed
Normalize state shape, add reselect and withLoading component
1 parent 80627f8 commit 76622b0

15 files changed

+162
-93
lines changed

package-lock.json

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"react-router-dom": "^5.1.2",
1515
"react-scripts": "3.2.0",
1616
"redux": "^4.0.4",
17-
"redux-thunk": "^2.3.0"
17+
"redux-thunk": "^2.3.0",
18+
"reselect": "^4.0.0"
1819
},
1920
"scripts": {
2021
"start": "react-scripts start",

src/App.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
Switch,
55
Route,
66
} from "react-router-dom";
7-
import ArticleList from './components/ArticleList';
7+
import ArticleListPage from './components/ArticleListPage';
88
import ArticleDetails from './components/ArticleDetails';
99
import Navbar from './components/Navbar';
1010
import AddArticlePage from './components/AddArticlePage';
@@ -16,7 +16,7 @@ function App() {
1616
<Router>
1717
<Navbar />
1818
<Switch>
19-
<Route exact path="/" component={ArticleList} />
19+
<Route exact path="/" component={ArticleListPage} />
2020
<Route path="/article/new" component={AddArticlePage} />
2121
<Route path="/article/:id" component={ArticleDetails} />
2222
</Switch>

src/actions/articles.js

+23-21
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
11
import * as types from '../types/articles';
22
import axios from '../utils/axios';
33

4-
export const fetchArticles = () => {
5-
return (dispatch) => {
6-
dispatch({type: types.GET_ARTICLES});
7-
axios.get('/articles')
8-
.then(response => {
9-
dispatch({ type: types.GET_ARTICLES_SUCCESS, payload: response.data });
10-
})
11-
.catch(error => {
12-
dispatch({ type: types.GET_ARTICLES_ERROR, payload: error });
13-
});
4+
export const fetchArticles = () => async dispatch => {
5+
try {
6+
dispatch(getArticles());
7+
const response = await axios.get('/articles');
8+
dispatch(getArticlesSuccess(response.data));
9+
} catch (error) {
10+
dispatch(getArticlesError(error));
1411
}
1512
}
1613

17-
export const addArticle = (payload, history) => {
18-
return (dispatch) => {
19-
dispatch({type: types.ADD_ARTICLE});
20-
axios.post('/articles', payload)
21-
.then(response => {
22-
dispatch({ type: types.ADD_ARTICLE_SUCCESS, payload: response.data });
23-
history.push(`/article/${response.data.id}`);
24-
})
25-
.catch(error => {
26-
dispatch({ type: types.ADD_ARTICLE_ERROR, payload: error });
27-
});
14+
export const createArticle = (payload, history) => async dispatch => {
15+
try {
16+
dispatch(addArticle());
17+
const response = await axios.post('/articles', payload);
18+
dispatch(addArticleSuccess(response.data));
19+
history.push(`/article/${response.data.id}`);
20+
} catch (error) {
21+
dispatch(addArticleError(error));
2822
}
2923
}
24+
25+
export const getArticles = () => ({ type: types.GET_ARTICLES });
26+
export const getArticlesSuccess = payload => ({ type: types.GET_ARTICLES_SUCCESS, payload });
27+
export const getArticlesError = payload => ({ type: types.GET_ARTICLES_ERROR, payload });
28+
29+
export const addArticle = () => ({ type: types.ADD_ARTICLE });
30+
export const addArticleSuccess = payload => ({ type: types.ADD_ARTICLE_SUCCESS, payload });
31+
export const addArticleError = payload => ({ type: types.ADD_ARTICLE_ERROR, payload });

src/actions/articles.test.js

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import configureMockStore from 'redux-mock-store';
22
import thunk from 'redux-thunk';
33
import mockAxios from 'axios';
4-
import * as types from '../types/articles';
54
import * as actions from './articles';
65

76
const middlewares = [thunk]
@@ -33,6 +32,8 @@ const addArticleData = {
3332
text: 'New article content'
3433
};
3534

35+
const setupHistory = () => ({ push: jest.fn()});
36+
3637
describe('articles actions', () => {
3738
beforeEach(() => {
3839
store.clearActions();
@@ -42,8 +43,8 @@ describe('articles actions', () => {
4243
mockAxios.get.mockImplementationOnce(() => Promise.resolve({ data: fetchArticlesData }));
4344

4445
const expectedActions = [
45-
{ type: types.GET_ARTICLES },
46-
{ type: types.GET_ARTICLES_SUCCESS, payload: fetchArticlesData }
46+
actions.getArticles(),
47+
actions.getArticlesSuccess(fetchArticlesData)
4748
];
4849

4950
await store.dispatch(actions.fetchArticles())
@@ -56,11 +57,11 @@ describe('articles actions', () => {
5657
mockAxios.post.mockImplementationOnce(() => Promise.resolve({ data: addArticleData }));
5758

5859
const expectedActions = [
59-
{ type: types.ADD_ARTICLE },
60-
{ type: types.ADD_ARTICLE_SUCCESS, payload: addArticleData }
60+
actions.addArticle(),
61+
actions.addArticleSuccess(addArticleData)
6162
];
6263

63-
await store.dispatch(actions.addArticle())
64+
await store.dispatch(actions.createArticle(addArticleData, setupHistory()))
6465

6566
expect(store.getActions()).toEqual(expectedActions);
6667
expect(mockAxios.post).toHaveBeenCalledTimes(1);

src/components/ArticleDetails.jsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import React from "react";
22
import { connect } from 'react-redux';
3-
import { selectArticleById } from '../selectors/articles';
3+
import { getArticleById } from '../selectors/articles';
44

55
export const ArticleDetails = ({ article: { title, text, date } }) => (
66
<article className="Article">
77
<header className="Article__title">{title}</header>
88
<section className="Article__content">{text}</section>
99
<footer className="Article__footer">Created: {date}</footer>
1010
</article>
11-
);
11+
)
1212

1313
const mapStateToProps = (state, props) => ({
14-
article: selectArticleById(state, props.match.params.id),
15-
});
14+
article: getArticleById(state, props.match.params.id)
15+
})
1616

1717
export default connect(mapStateToProps)(ArticleDetails);

src/components/ArticleForm.jsx

+11-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React, { Component, Fragment } from "react";
2+
import { compose } from 'redux';
23
import { connect } from "react-redux";
34
import { Formik, Field, Form } from "formik";
45
import { format } from 'date-fns';
56
import { withRouter } from 'react-router-dom';
67
import TextField from "./utils/TextField";
78
import SnackbarContext from "./Snackbar/SnackbarContext";
8-
import { addArticle } from "../actions/articles";
9+
import { createArticle } from "../actions/articles";
910

1011
const initialValues = {
1112
title: "",
@@ -30,16 +31,17 @@ export class ArticleForm extends Component {
3031
date: format(new Date(), 'MMMM d, yyyy H:mm:ss'),
3132
id: Date.now()
3233
};
33-
this.props.addArticle(data);
34-
this.props.history.push(`/article/${data.id}`);
34+
values.date = format(new Date(), 'MMMM d, yyyy H:mm:ss');
35+
values.id = Date.now();
36+
this.props.createArticle(data, this.props.history);
3537
this.context.showSnackbar('The article was published successfully', 'success');
3638
resetForm();
3739
}
3840

3941
render() {
4042
return (
4143
<Fragment>
42-
<h1 className="Article-form__title">{this.props.title}</h1>
44+
<h1 className="Article-form__title">{this.props.title || ''}</h1>
4345
<Formik
4446
initialValues={this.props.article || initialValues}
4547
onSubmit={this.submit}
@@ -78,10 +80,10 @@ export class ArticleForm extends Component {
7880
}
7981

8082
const mapDispatchToProps = {
81-
addArticle
83+
createArticle
8284
};
8385

84-
export default connect(
85-
null,
86-
mapDispatchToProps
87-
)(withRouter(ArticleForm));
86+
export default compose(
87+
connect(null, mapDispatchToProps),
88+
withRouter,
89+
)(ArticleForm);

src/components/ArticleList.jsx

+7-23
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,21 @@
11
import React, {Component} from 'react';
2-
import { connect } from 'react-redux';
3-
import { getArticles, isLoading } from '../selectors/articles';
4-
import { fetchArticles } from '../actions/articles';
52
import ArticleListItem from './ArticleListItem';
6-
import Spinner from './utils/Spinner';
3+
import withLoading from './utils/withLoading';
74

85
export class ArticleList extends Component {
9-
componentDidMount() {
10-
this.props.fetchArticles();
11-
}
12-
13-
listArticles = () =>
14-
this.props.articles.map(article => {
6+
listArticles = () => (
7+
this.props.articles.map((article) => {
158
return <ArticleListItem key={article.id} article={article} />
16-
});
9+
})
10+
)
1711

1812
render() {
19-
if (this.props.isLoading) return <Spinner />
20-
return (
13+
return (
2114
<ul className="Article-list">
2215
{this.listArticles()}
2316
</ul>
2417
)
2518
}
2619
}
2720

28-
const mapStateToProps = (state) => ({
29-
articles: getArticles(state),
30-
isLoading: isLoading(state),
31-
});
32-
33-
const mapDispatchToProps = {
34-
fetchArticles
35-
};
36-
37-
export default connect(mapStateToProps, mapDispatchToProps)(ArticleList);
21+
export default withLoading(ArticleList);

src/components/ArticleListPage.jsx

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React, { Component } from 'react';
2+
import { connect } from 'react-redux';
3+
import { isLoading, getArticlesSelector } from '../selectors/articles';
4+
import { fetchArticles } from '../actions/articles';
5+
import { ArticleList } from './ArticleList';
6+
7+
class ArticleListPage extends Component {
8+
componentDidMount() {
9+
this.props.fetchArticles();
10+
}
11+
12+
render() {
13+
const { articles, isLoading } = this.props;
14+
return (
15+
<ArticleList articles={articles} isLoading={isLoading} />
16+
)
17+
}
18+
}
19+
20+
const mapStateToProps = (state) => ({
21+
articles: getArticlesSelector(state),
22+
isLoading: isLoading(state),
23+
});
24+
25+
const mapDispatchToProps = {
26+
fetchArticles
27+
};
28+
29+
export default connect(mapStateToProps, mapDispatchToProps)(ArticleListPage);

src/components/Snackbar/SnackbarProvider.jsx

+8-17
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,6 @@ import SnackbarContext from './SnackbarContext';
33
import Snackbar from './Snackbar';
44

55
export class SnackbarProvider extends Component {
6-
constructor(props) {
7-
super(props);
8-
this.state = {
9-
isOpen: false,
10-
message: '',
11-
status: 'success',
12-
};
13-
}
14-
156
showSnackbar = (message, status) => {
167
this.setState({
178
message,
@@ -21,18 +12,18 @@ export class SnackbarProvider extends Component {
2112
setTimeout(() => this.setState({ isOpen: false}), 3000);
2213
};
2314

15+
state = {
16+
isOpen: false,
17+
message: '',
18+
status: 'success',
19+
showSnackbar: this.showSnackbar,
20+
};
21+
2422
render() {
2523
const { children } = this.props;
2624

2725
return (
28-
<SnackbarContext.Provider
29-
value={{
30-
showSnackbar: this.showSnackbar,
31-
isOpen: this.state.isOpen,
32-
message: this.state.message,
33-
status: this.state.status,
34-
}}
35-
>
26+
<SnackbarContext.Provider value={this.state}>
3627
<Snackbar />
3728
{children}
3829
</SnackbarContext.Provider>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react';
2+
import { mount } from 'enzyme';
3+
import SnackbarProvider from './SnackbarProvider';
4+
5+
jest.useFakeTimers();
6+
7+
describe('SnackbarProvider', () => {
8+
it('should display snackbar when showSnackbar() is called', () => {
9+
const component = mount(<SnackbarProvider />);
10+
component.state().showSnackbar('test', 'success');
11+
12+
expect(component.state('message')).toEqual('test');
13+
expect(component.state('status')).toEqual('success');
14+
expect(component.state('isOpen')).toEqual(true);
15+
expect(component.find('Snackbar')).toExist();
16+
17+
jest.runAllTimers();
18+
19+
expect(component.state('isOpen')).toEqual(false);
20+
})
21+
})

src/components/utils/withLoading.jsx

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React from 'react';
2+
import Spinner from './Spinner';
3+
4+
const withLoading = WrappedComponent => {
5+
return ({ isLoading, ...props }) => {
6+
if (isLoading === true) return <Spinner />;
7+
return <WrappedComponent {...props} />
8+
}
9+
}
10+
11+
export default withLoading;

src/reducers/articles.js

+16-4
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import * as types from '../types/articles';
22

3-
const initialState = { data: [], loading: false, error: null };
3+
const initialState = { byId: {}, allIds: [], loading: false, error: null };
44

5-
export default function articles(state = initialState, action) {
5+
const normalizeData = data => data.reduce((previous, current) => {
6+
const result = addNormalizedData(previous, current);
7+
return {...result, loading: false, error: null};
8+
}, initialState);
9+
10+
const addNormalizedData = (result, data) => ({
11+
...result,
12+
byId: {...result.byId, [data.id]: data},
13+
allIds: [...result.allIds, data.id]
14+
})
15+
16+
export default function getArticles(state = initialState, action) {
617
switch (action.type) {
718
case types.GET_ARTICLES:
819
case types.ADD_ARTICLE:
920
return {...state, loading: true};
1021
case types.GET_ARTICLES_SUCCESS:
11-
return {...state, loading: false, data: action.payload};
22+
return normalizeData(action.payload);
1223
case types.ADD_ARTICLE_SUCCESS:
13-
return {...state, loading: false, data: [...state.data, action.payload]};
24+
const data = addNormalizedData(state, action.payload);
25+
return {...data, loading: false, error: null};
1426
case types.GET_ARTICLES_ERROR:
1527
case types.ADD_ARTICLE_ERROR:
1628
return {...state, loading: false, error: action.payload};

0 commit comments

Comments
 (0)