Skip to content

Commit 8b824e3

Browse files
committed
Normalize state shape, add reselect and withLoading component
1 parent 16618a8 commit 8b824e3

15 files changed

+158
-81
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
@@ -15,7 +15,8 @@
1515
"react-router-dom": "^5.1.2",
1616
"react-scripts": "3.2.0",
1717
"redux": "^4.0.4",
18-
"redux-thunk": "^2.3.0"
18+
"redux-thunk": "^2.3.0",
19+
"reselect": "^4.0.0"
1920
},
2021
"scripts": {
2122
"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+
const getArticles = () => ({ type: types.GET_ARTICLES });
26+
const getArticlesSuccess = payload => ({ type: types.GET_ARTICLES_SUCCESS, payload });
27+
const getArticlesError = payload => ({ type: types.GET_ARTICLES_ERROR, payload });
28+
29+
const addArticle = () => ({ type: types.ADD_ARTICLE });
30+
const addArticleSuccess = payload => ({ type: types.ADD_ARTICLE_SUCCESS, payload });
31+
const addArticleError = payload => ({ type: types.ADD_ARTICLE_ERROR, payload });

src/actions/articles.test.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const addArticleData = {
3333
text: 'New article content'
3434
};
3535

36+
const setupHistory = () => ({ push: jest.fn()});
37+
3638
describe('articles actions', () => {
3739
beforeEach(() => {
3840
store.clearActions();
@@ -60,7 +62,7 @@ describe('articles actions', () => {
6062
{ type: types.ADD_ARTICLE_SUCCESS, payload: addArticleData }
6163
];
6264

63-
await store.dispatch(actions.addArticle())
65+
await store.dispatch(actions.createArticle(addArticleData, setupHistory()))
6466

6567
expect(store.getActions()).toEqual(expectedActions);
6668
expect(mockAxios.post).toHaveBeenCalledTimes(1);

src/components/ArticleDetails.jsx

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import React from "react";
22
import { connect } from 'react-redux';
3-
import { selectArticleById } from '../selectors/articles';
3+
import { getArticleByIdSelector } 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

13-
const mapStateToProps = (state, props) => ({
14-
article: selectArticleById(state, props.match.params.id),
15-
});
13+
const mapStateToProps = () => {
14+
const getArticleById = getArticleByIdSelector()
15+
return (state, props) => ({
16+
article: getArticleById(state, props.match.params.id)
17+
})
18+
}
1619

1720
export default connect(mapStateToProps)(ArticleDetails);

src/components/ArticleForm.jsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { format } from 'date-fns';
55
import { withRouter } from 'react-router-dom';
66
import TextField from "./utils/TextField";
77
import SnackbarContext from "./Snackbar/SnackbarContext";
8-
import { addArticle } from "../actions/articles";
8+
import { createArticle } from "../actions/articles";
99

1010
const initialValues = {
1111
title: "",
@@ -28,15 +28,15 @@ export class ArticleForm extends Component {
2828
submit = (values, { resetForm }) => {
2929
values.date = format(new Date(), 'MMMM d, yyyy H:mm:ss');
3030
values.id = Date.now();
31-
this.props.addArticle(values, this.props.history);
31+
this.props.createArticle(values, this.props.history);
3232
this.context.showSnackbar('The article was published successfully', 'success');
3333
resetForm();
3434
}
3535

3636
render() {
3737
return (
3838
<Fragment>
39-
<h1 className="Article-form__title">{this.props.title}</h1>
39+
<h1 className="Article-form__title">{this.props.title || ''}</h1>
4040
<Formik
4141
initialValues={this.props.article || initialValues}
4242
onSubmit={this.submit}
@@ -75,7 +75,7 @@ export class ArticleForm extends Component {
7575
}
7676

7777
const mapDispatchToProps = {
78-
addArticle
78+
createArticle
7979
};
8080

8181
export default connect(

src/components/ArticleList.jsx

+8-22
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
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 => {
15-
return <ArticleListItem key={article.id} article={article} />
16-
});
6+
listArticles = () => (
7+
Object.entries(this.props.articles).map(([key, val]) => {
8+
return <ArticleListItem key={key} article={val} />
9+
})
10+
)
1711

1812
render() {
19-
if (this.props.isLoading) return <Spinner />
2013
return (
2114
<ul className="Article-list">
2215
{this.listArticles()}
@@ -25,13 +18,6 @@ export class ArticleList extends Component {
2518
}
2619
}
2720

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

37-
export default connect(mapStateToProps, mapDispatchToProps)(ArticleList);
23+
export default 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-15
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,17 +12,19 @@ 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 (
2826
<SnackbarContext.Provider
29-
value={{
30-
showSnackbar: this.showSnackbar,
31-
isOpen: this.state.isOpen,
32-
message: this.state.message,
33-
status: this.state.status,
34-
}}
27+
value={this.state}
3528
>
3629
<Snackbar />
3730
{children}
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};

src/selectors/articles.js

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
export const selectArticleById = (state, id) => {
2-
return state.articles.data.find(article => article.id === Number(id));
3-
}
1+
import { createSelector } from 'reselect'
42

5-
export const getArticles = state => state.articles.data;
3+
export const getArticleById = (state, id) => state.articles.byId[id];
4+
5+
const getArticles = state => state.articles.byId;
66

77
export const isLoading = state => state.articles.loading;
8+
9+
export const getArticlesSelector = createSelector(
10+
getArticles,
11+
(articles) => articles
12+
)
13+
14+
export const getArticleByIdSelector = () => createSelector(
15+
getArticleById,
16+
(article) => article
17+
)

src/store.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { createStore, applyMiddleware } from 'redux';
1+
import { createStore, applyMiddleware, compose } from 'redux';
22
import thunk from 'redux-thunk';
33
import rootReducer from './reducers';
44

5+
const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
6+
57
export default function configureStore() {
68
return createStore(
79
rootReducer,
8-
applyMiddleware(thunk)
10+
composeEnhancer(applyMiddleware(thunk))
911
);
1012
}

0 commit comments

Comments
 (0)