├── .env
├── .eslintrc.js
├── .gitignore
├── .travis.yml
├── FRONTEND_INSTRUCTIONS.md
├── logo.png
├── package-lock.json
├── package.json
├── public
└── index.html
├── readme.md
└── src
├── api.js
├── index.js
├── modules
├── app
│ ├── components
│ │ ├── app.js
│ │ ├── footer.js
│ │ ├── home.js
│ │ └── navbar.js
│ ├── containers
│ │ └── root.js
│ ├── index.js
│ ├── reducer.js
│ ├── sagas.js
│ └── store.js
├── articles
│ ├── components
│ │ └── article-preview.js
│ ├── containers
│ │ └── articles.js
│ ├── index.js
│ ├── reducer.js
│ ├── sagas.js
│ ├── selectors.js
│ ├── types.js
│ └── utils
│ │ └── datetime.js
└── auth
│ ├── components
│ └── auth-form.js
│ ├── index.js
│ ├── proxy.js
│ ├── reducer.js
│ ├── sagas.js
│ ├── selectors.js
│ └── types.js
└── utils
└── logging.js
/.env:
--------------------------------------------------------------------------------
1 | NODE_PATH=src/
2 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 |
3 | module.exports = {
4 | extends: 'airbnb',
5 | env: {
6 | browser: true,
7 | },
8 | settings: {
9 | 'import/resolver': {
10 | node: {
11 | paths: [path.resolve(__dirname, './src')],
12 | },
13 | },
14 | },
15 | rules: {
16 | 'arrow-parens': 'off',
17 | 'generator-star-spacing': 'off',
18 | 'import/prefer-default-export': 'off',
19 | 'max-len': ['warn', 80],
20 | 'no-constant-condition': ['error', { checkLoops: false }],
21 | 'no-underscore-dangle': 'off',
22 | 'no-use-before-define': 'off',
23 | 'react/jsx-filename-extension': 'off',
24 | semi: ['warn', 'never'],
25 | },
26 | }
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 |
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 7
4 |
5 | script:
6 | - npm run lint
7 | - npm run build
8 |
9 | # cache:
10 | # yarn: true
11 | # directories:
12 | # - node_modules
13 |
--------------------------------------------------------------------------------
/FRONTEND_INSTRUCTIONS.md:
--------------------------------------------------------------------------------
1 | > *Note: Delete this file before publishing your app!*
2 |
3 | ### Using the hosted API
4 |
5 | Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go!
6 |
7 | ### Routing Guidelines
8 |
9 | - Home page (URL: /#/ )
10 | - List of tags
11 | - List of articles pulled from either Feed, Global, or by Tag
12 | - Pagination for list of articles
13 | - Sign in/Sign up pages (URL: /#/login, /#/register )
14 | - Uses JWT (store the token in localStorage)
15 | - Authentication can be easily switched to session/cookie based
16 | - Settings page (URL: /#/settings )
17 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here )
18 | - Article page (URL: /#/article/article-slug-here )
19 | - Delete article button (only shown to article's author)
20 | - Render markdown from server client side
21 | - Comments section at bottom of page
22 | - Delete comment button (only shown to comment's author)
23 | - Profile page (URL: /#/profile/:username, /#/profile/:username/favorites )
24 | - Show basic user info
25 | - List of articles populated from author's created articles or author's favorited articles
26 |
27 | # Styles
28 |
29 | Instead of having the Bootstrap theme included locally, we recommend loading the precompiled theme from our CDN (our [header template](#header) does this by default):
30 |
31 | ```html
32 |
33 | ```
34 |
35 | Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template).
36 |
37 |
38 | # Templates
39 |
40 | - [Layout](#layout)
41 | - [Header](#header)
42 | - [Footer](#footer)
43 | - [Pages](#pages)
44 | - [Home](#home)
45 | - [Login/Register](#loginregister)
46 | - [Profile](#profile)
47 | - [Settings](#settings)
48 | - [Create/Edit Article](#createedit-article)
49 | - [Article](#article)
50 |
51 |
52 | ## Layout
53 |
54 |
55 | ### Header
56 |
57 | ```html
58 |
59 |
60 |
61 |
62 | Conduit
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
95 |
96 |
97 | ```
98 |
99 | ### Footer
100 | ```html
101 |
109 |
110 |
111 |
112 | ```
113 |
114 | ## Pages
115 |
116 | ### Home
117 | ```html
118 |
119 |
120 |
121 |
122 |
conduit
123 |
A place to share your knowledge.
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
141 |
142 |
159 |
160 |
177 |
178 |
179 |
180 |
181 |
195 |
196 |
197 |
198 |
199 |
200 |
201 | ```
202 |
203 | ### Login/Register
204 |
205 | ```html
206 |
207 |
208 |
209 |
210 |
211 |
Sign up
212 |
213 | Have an account?
214 |
215 |
216 |
217 | - That email is already taken
218 |
219 |
220 |
234 |
235 |
236 |
237 |
238 |
239 | ```
240 |
241 | ### Profile
242 |
243 | ```html
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |

252 |
Eric Simons
253 |
254 | Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from the Hunger Games
255 |
256 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
281 |
282 |
299 |
300 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 | ```
330 |
331 | ### Settings
332 |
333 | ```html
334 |
335 |
336 |
337 |
338 |
339 |
Your Settings
340 |
341 |
363 |
364 |
365 |
366 |
367 |
368 | ```
369 |
370 | ### Create/Edit Article
371 |
372 | ```html
373 |
402 |
403 |
404 | ```
405 |
406 | ### Article
407 |
408 | ```html
409 |
410 |
411 |
412 |
413 |
414 |
How to build webapps that scale
415 |
416 |
417 |

418 |
422 |
427 |
428 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 | Web development technologies have evolved at an incredible clip over the past few years.
444 |
445 |
Introducing RealWorld.
446 |
It's a great solution for learning how other frameworks work.
447 |
448 |
449 |
450 |
451 |
452 |
453 |
454 |

455 |
459 |
460 |
465 |
466 |
471 |
472 |
473 |
474 |
475 |
476 |
477 |
478 |
489 |
490 |
491 |
492 |
With supporting text below as a natural lead-in to additional content.
493 |
494 |
502 |
503 |
504 |
505 |
506 |
With supporting text below as a natural lead-in to additional content.
507 |
508 |
520 |
521 |
522 |
523 |
524 |
525 |
526 |
527 |
528 |
529 | ```
530 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/j-hannes/realworld-react-redux-modular/6b05e9fc999bff4f36b359b1b2dc0cb445e0a1a8/logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "realworld-react-redux-modular",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "axios": "~0.16.1",
7 | "immutable": "^3.8.1",
8 | "prop-types": "^15.5.8",
9 | "react": "^15.5.4",
10 | "react-dom": "^15.5.4",
11 | "react-immutable-proptypes": "^2.1.0",
12 | "react-redux": "^5.0.4",
13 | "react-router-dom": "^4.1.1",
14 | "redux": "^3.6.0",
15 | "redux-immutable": "^4.0.0",
16 | "redux-saga": "^0.15.3",
17 | "reselect": "^3.0.0"
18 | },
19 | "devDependencies": {
20 | "eslint": "^3.19.0",
21 | "eslint-config-airbnb": "^14.1.0",
22 | "eslint-plugin-import": "^2.2.0",
23 | "eslint-plugin-react": "^7.0.0",
24 | "react-scripts": "0.9.5"
25 | },
26 | "scripts": {
27 | "start": "react-scripts start",
28 | "build": "react-scripts build",
29 | "lint": "eslint src",
30 | "test": "react-scripts test --env=jsdom",
31 | "eject": "react-scripts eject"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Conduit
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # 
4 |
5 | > ### React-redux codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API.
6 |
7 | I am aware of the existing [React + Redux Real World Example App](https://github.com/gothinkster/react-redux-realworld-example-app) but for educational purposes, to use a handsome composition of libraries but most importantly to follow a modular architecture with encapsulated modules as described in [Jack Hsu's excellent blog post](https://jaysoo.ca/2016/02/28/organizing-redux-application) I still want to create this application here myself.
8 |
9 | ---
10 |
11 | > ### Specs: [FRONTEND_INSTRUCTIONS](FRONTEND_INSTRUCTIONS.md)
12 |
13 | Based on [create-react-app](https://github.com/facebookincubator/create-react-app) (no-escape yet).
14 |
15 | It uses the following library stack:
16 | * [x] [React](https://facebook.github.io/react)
17 | * [x] [Redux](http://redux.js.org)
18 | * [x] [Immutable](https://facebook.github.io/immutable-js)
19 | * [x] [Reselect](https://github.com/reactjs/reselect)
20 | * [x] [Redux-Saga](https://github.com/redux-saga/redux-saga)
21 | * [ ] ~~[Styled components](https://styled-components.com)~~ (not for now, migration too complex)
22 | * [x] [Prettier](https://github.com/prettier/prettier) (uses as editor plugin)
23 | * [x] [Eslint](http://eslint.org)
24 |
25 | The first iteration will be test-free. :smile:
26 |
27 | In a second iteration I will add:
28 | * [ ] [Jest](https://facebook.github.io/jest) unit (and integration?) testing
29 | * [ ] [Cypress](https://www.cypress.io) end-to-end testing
30 |
31 | In a third iteration I might add
32 | * [ ] [Flow](https://flow.org)
33 |
34 | I might also experiment in using [redux-observable](https://redux-observable.js.org) instead of redux-saga.
35 |
36 | Later I might also port this application to [Elm](http://elm-lang.org) and/or play with a Haskell backend.
37 |
38 |
39 | ### [RealWorld](https://github.com/gothinkster/realworld)
40 |
41 | This codebase was created to demonstrate a fully fledged fullstack application built with **React-redux** including CRUD operations, authentication, routing, pagination, and more.
42 |
43 | We've gone to great lengths to adhere to the **React-redux** community styleguides & best practices.
44 |
45 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo.
46 |
47 |
48 | # How it works
49 |
50 | This implementation uses a modular segmentation of application logic, so each
51 | feature is added as module, containing components, actions, reducers, sagas,
52 | selectors etc that concerns around that feature. Each module defines explicitly
53 | what is exported, which hides implementation details and gives exposure to what
54 | will affect things outside the module on change.
55 |
56 | # Getting started
57 |
58 | ```
59 | ❯ npm install
60 | ❯ npm start
61 | ```
62 |
--------------------------------------------------------------------------------
/src/api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | const localBackend = true
4 |
5 | axios.defaults.baseURL = localBackend
6 | ? 'http://localhost:3001'
7 | : 'https://conduit.productionready.io'
8 |
9 | export default {
10 | fetchArticles: () =>
11 | axios
12 | .get('/api/articles')
13 | .then(response => response.data)
14 | .catch(error => error),
15 |
16 | signup: formData =>
17 | axios
18 | .post('/api/users', { user: formData })
19 | .then(response => response.data)
20 | .catch(error => error.response.data),
21 |
22 | login: (email, password) =>
23 | axios
24 | .post('/api/users/login', { user: { email, password } })
25 | .then(response => response.data)
26 | .catch(error => error.response.data),
27 | }
28 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import * as app from 'modules/app'
4 | import { log } from 'utils/logging'
5 |
6 | const { Root } = app.containers
7 |
8 | window.log = log
9 |
10 | ReactDOM.render(, document.getElementById('root'))
11 |
12 |
--------------------------------------------------------------------------------
/src/modules/app/components/app.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BrowserRouter as Router, Route } from 'react-router-dom'
3 |
4 | import * as auth from 'modules/auth'
5 |
6 | import Navbar from './navbar'
7 | import Footer from './footer'
8 | import Home from './home'
9 |
10 | const { SignupForm, LoginForm } = auth.components
11 |
12 | const App = () => (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 |
24 | export default App
25 |
--------------------------------------------------------------------------------
/src/modules/app/components/footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Footer = () => (
4 |
15 | )
16 |
17 | export default Footer
18 |
--------------------------------------------------------------------------------
/src/modules/app/components/home.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import * as articles from 'modules/articles'
4 |
5 | const { Articles } = articles.containers
6 |
7 | const Home = () => (
8 |
9 |
10 |
11 |
conduit
12 |
A place to share your knowledge.
13 |
14 |
15 |
22 |
23 | )
24 |
25 | export default Home
26 |
--------------------------------------------------------------------------------
/src/modules/app/components/navbar.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router-dom'
3 |
4 | const Navbar = () => (
5 |
21 | )
22 |
23 | export default Navbar
24 |
--------------------------------------------------------------------------------
/src/modules/app/containers/root.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Provider } from 'react-redux'
3 |
4 | import { configureStore } from '../store'
5 | import App from '../components/app'
6 |
7 | const Root = () => (
8 |
9 |
10 |
11 | )
12 |
13 | export default Root
14 |
--------------------------------------------------------------------------------
/src/modules/app/index.js:
--------------------------------------------------------------------------------
1 | import Root from './containers/root'
2 |
3 | export const containers = {
4 | Root,
5 | }
6 |
--------------------------------------------------------------------------------
/src/modules/app/reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux-immutable'
2 |
3 | import * as articles from 'modules/articles'
4 | import * as auth from 'modules/auth'
5 |
6 | export default combineReducers({
7 | [articles.moduleName]: articles.reducer,
8 | [auth.moduleName]: auth.reducer,
9 | })
10 |
--------------------------------------------------------------------------------
/src/modules/app/sagas.js:
--------------------------------------------------------------------------------
1 | import { fork } from 'redux-saga/effects'
2 |
3 | import * as articles from 'modules/articles'
4 | import * as auth from 'modules/auth'
5 |
6 | export default function* mainSaga() {
7 | yield fork(articles.sagas.mainSaga)
8 | yield fork(auth.sagas.mainSaga)
9 | }
10 |
--------------------------------------------------------------------------------
/src/modules/app/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, compose, applyMiddleware } from 'redux'
2 | import createSagaMiddleware from 'redux-saga'
3 |
4 | import reducer from './reducer'
5 | import mainSaga from './sagas'
6 |
7 | export const configureStore = initialState => {
8 | const composeEnhancers =
9 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
10 |
11 | const sagaMiddleware = createSagaMiddleware()
12 |
13 | const store = createStore(
14 | reducer,
15 | initialState,
16 | composeEnhancers(applyMiddleware(sagaMiddleware)),
17 | )
18 |
19 | sagaMiddleware.run(mainSaga)
20 |
21 | return store
22 | }
23 |
--------------------------------------------------------------------------------
/src/modules/articles/components/article-preview.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import * as types from '../types'
4 | import { formatDate } from '../utils/datetime'
5 |
6 | const ArticlePreview = ({ article }) => (
7 |
28 | )
29 |
30 | ArticlePreview.propTypes = {
31 | article: types.article.isRequired,
32 | }
33 |
34 | export default ArticlePreview
35 |
--------------------------------------------------------------------------------
/src/modules/articles/containers/articles.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { connect } from 'react-redux'
3 | import { createStructuredSelector } from 'reselect'
4 | import * as types from '../types'
5 |
6 | import { articles } from '../selectors'
7 | import ArticlePreview from '../components/article-preview'
8 |
9 | const Articles = props => (
10 |
11 | {props.articles.map(article => (
12 |
13 | ))}
14 |
15 | )
16 |
17 | Articles.propTypes = {
18 | articles: types.articles.isRequired,
19 | }
20 |
21 | const mapStateToProps = createStructuredSelector({
22 | articles,
23 | })
24 |
25 | export default connect(mapStateToProps)(Articles)
26 |
--------------------------------------------------------------------------------
/src/modules/articles/index.js:
--------------------------------------------------------------------------------
1 | import Articles from './containers/articles'
2 |
3 | import mainSaga from './sagas'
4 |
5 | export const moduleName = 'articles'
6 |
7 | export { default as reducer } from './reducer'
8 |
9 | export const sagas = {
10 | mainSaga,
11 | }
12 |
13 | export const containers = {
14 | Articles,
15 | }
16 |
--------------------------------------------------------------------------------
/src/modules/articles/reducer.js:
--------------------------------------------------------------------------------
1 | import { List, Record } from 'immutable'
2 |
3 | const Author = Record(
4 | {
5 | image: '',
6 | username: '',
7 | },
8 | 'Author',
9 | )
10 |
11 | const Article = Record(
12 | {
13 | title: '',
14 | slug: '',
15 | description: '',
16 | author: Author(),
17 | createdAt: '',
18 | favoritesCount: 0,
19 | },
20 | 'Article',
21 | )
22 |
23 | export const generateArticle = jsonData =>
24 | Article({
25 | ...jsonData,
26 | author: Author(jsonData.author),
27 | })
28 |
29 | const initialState = new List()
30 |
31 | export default (state = initialState, action) => {
32 | switch (action.type) {
33 | case 'FETCH_ARTICLES_SUCCESS':
34 | return new List(action.payload.articles.map(generateArticle))
35 | default:
36 | return state
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/modules/articles/sagas.js:
--------------------------------------------------------------------------------
1 | import { call, fork, put, take } from 'redux-saga/effects'
2 | import api from 'api'
3 |
4 | export default function*() {
5 | yield fork(loadArticles)
6 | yield put({ type: 'FETCH_ARTICLES' })
7 | }
8 |
9 | export function* loadArticles() {
10 | while (true) {
11 | yield take('FETCH_ARTICLES')
12 | const { articles, message } = yield call(api.fetchArticles)
13 | if (articles) {
14 | yield put({ type: 'FETCH_ARTICLES_SUCCESS', payload: { articles } })
15 | } else if (message) {
16 | yield put({ type: 'FETCH_ARTICLES_ERROR', payload: { message } })
17 | } else {
18 | throw Error('API must return articles or mesage')
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/modules/articles/selectors.js:
--------------------------------------------------------------------------------
1 | import { moduleName } from './index'
2 |
3 | export const articles = state => state.get(moduleName)
4 |
--------------------------------------------------------------------------------
/src/modules/articles/types.js:
--------------------------------------------------------------------------------
1 | import { listOf as list, recordOf as record } from 'react-immutable-proptypes'
2 | import { string, number } from 'prop-types'
3 |
4 | export const author = record({
5 | image: string.isRequired,
6 | username: string.isRequired,
7 | })
8 |
9 | export const article = record({
10 | title: string.isRequired,
11 | description: string,
12 | author: author.isRequired,
13 | createdAt: string.isRequired,
14 | favoritesCount: number.isRequired,
15 | })
16 |
17 | export const articles = list(article)
18 |
--------------------------------------------------------------------------------
/src/modules/articles/utils/datetime.js:
--------------------------------------------------------------------------------
1 | export const formatDate = dateValue => new Date(dateValue).toDateString()
2 |
--------------------------------------------------------------------------------
/src/modules/auth/components/auth-form.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { connect } from 'react-redux'
3 | import { Link } from 'react-router-dom'
4 | import { createStructuredSelector } from 'reselect'
5 | import { bool, func } from 'prop-types'
6 |
7 | import * as types from '../types'
8 | import { getFormData, getFormErrors } from '../selectors'
9 |
10 | const AuthForm = props => (
11 |
72 | )
73 |
74 | AuthForm.propTypes = {
75 | formData: types.formData.isRequired,
76 | formErrors: types.formErrors.isRequired,
77 | changeFormField: func.isRequired,
78 | authenticateUser: func.isRequired,
79 | login: bool,
80 | }
81 |
82 | AuthForm.defaultProps = {
83 | login: false,
84 | }
85 |
86 | export default connect(
87 | createStructuredSelector({
88 | formData: getFormData,
89 | formErrors: getFormErrors,
90 | }),
91 | (dispatch, props) => ({
92 | changeFormField(e) {
93 | dispatch({
94 | type: 'UPDATE_FORM_FIELD',
95 | field: e.target.name,
96 | value: e.target.value,
97 | })
98 | },
99 | authenticateUser(e) {
100 | e.preventDefault()
101 | if (props.login) {
102 | dispatch({ type: 'LOGIN_REQUEST' })
103 | } else {
104 | dispatch({ type: 'SIGNUP_REQUEST' })
105 | }
106 | },
107 | }),
108 | )(AuthForm)
109 |
--------------------------------------------------------------------------------
/src/modules/auth/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import AuthForm from './components/auth-form'
3 |
4 | import mainSaga from './sagas'
5 |
6 | export const moduleName = 'signup'
7 |
8 | export { default as reducer } from './reducer'
9 |
10 | export const sagas = {
11 | mainSaga,
12 | }
13 |
14 | export const components = {
15 | SignupForm: AuthForm,
16 | LoginForm: () => ,
17 | }
18 |
--------------------------------------------------------------------------------
/src/modules/auth/proxy.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | export function storeToken(token) {
4 | try {
5 | localStorage.setItem('authToken', JSON.stringify(token))
6 | } catch (e) {
7 | console.warn('could not store token')
8 | console.warn(e)
9 | }
10 | }
11 |
12 | export function deleteToken() {
13 | localStorage.removeItem('authToken')
14 | }
15 |
16 | export function getStoredToken() {
17 | try {
18 | return JSON.parse(localStorage.getItem('authToken'))
19 | } catch (e) {
20 | console.warn('could not parse token')
21 | console.warn(e)
22 | return ''
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/modules/auth/reducer.js:
--------------------------------------------------------------------------------
1 | import { Record } from 'immutable'
2 |
3 | const SignupForm = Record({
4 | username: '',
5 | email: '',
6 | password: '',
7 | })
8 |
9 | const SignupErrors = Record({
10 | username: [],
11 | email: [],
12 | password: [],
13 | })
14 |
15 | const SignupState = Record({
16 | form: SignupForm(),
17 | errors: SignupErrors(),
18 | })
19 |
20 | const initialState = SignupState()
21 |
22 | export default (state = initialState, action) => {
23 | switch (action.type) {
24 | case 'UPDATE_FORM_FIELD':
25 | return state.setIn(['form', action.field], action.value)
26 | case 'SIGNUP_ERROR':
27 | return state.set('errors', SignupErrors(action.payload.errors))
28 | case 'SIGNUP_SUCCESS':
29 | return state.set('errors', SignupErrors())
30 | default:
31 | return state
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/modules/auth/sagas.js:
--------------------------------------------------------------------------------
1 | import { call, fork, put, select, take } from 'redux-saga/effects'
2 |
3 | import api from 'api'
4 |
5 | import { getFormData } from './selectors'
6 | import * as proxy from './proxy'
7 |
8 | export default function*() {
9 | yield fork(signup)
10 | yield fork(authentication)
11 | }
12 |
13 | function* signup() {
14 | while (true) {
15 | yield take('SIGNUP_REQUEST')
16 | const formData = yield select(getFormData)
17 | const { errors, user } = yield call(api.signup, formData.toJS())
18 | if (user) {
19 | yield call(proxy.storeToken, user.token)
20 | yield put({ type: 'SIGNUP_SUCCESS', payload: { user } })
21 | } else if (errors) {
22 | yield put({ type: 'SIGNUP_ERROR', payload: { errors } })
23 | } else {
24 | throw Error('API must return user or errors')
25 | }
26 | }
27 | }
28 |
29 | function* authentication() {
30 | while (true) {
31 | let authorized = false
32 | while (!authorized) {
33 | yield take('LOGIN_REQUEST')
34 | const formData = yield select(getFormData)
35 | const { email, password } = formData.toJS()
36 | const { user, errors } = yield call(api.login, email, password)
37 | if (user) {
38 | yield call(proxy.storeToken, user.token)
39 | yield put({ type: 'LOGIN_SUCCESS', payload: { user } })
40 | authorized = true
41 | } else if (errors) {
42 | yield put({ type: 'LOGIN_ERROR', payload: { errors } })
43 | } else {
44 | throw Error('API must return user or errors')
45 | }
46 | }
47 | take('LOGOUT')
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/modules/auth/selectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect'
2 | import { moduleName } from './index'
3 |
4 | const signup = state => state.get(moduleName)
5 |
6 | export const getFormData = createSelector(signup, state => state.form)
7 |
8 | export const getFormErrors = createSelector(signup, state =>
9 | state.errors
10 | .entrySeq()
11 | .toArray()
12 | .filter(x => x[1].length)
13 | .map(([field, error]) => ({ field, error })),
14 | )
15 |
--------------------------------------------------------------------------------
/src/modules/auth/types.js:
--------------------------------------------------------------------------------
1 | import { recordOf as record } from 'react-immutable-proptypes'
2 | import { string, array, arrayOf, shape } from 'prop-types'
3 |
4 | export const formData = record({
5 | username: string.isRequired,
6 | email: string.isRequired,
7 | password: string.isRequired,
8 | })
9 |
10 | export const formErrors = arrayOf(shape({
11 | field: string.isRequired,
12 | error: array.isRequired,
13 | }))
14 |
--------------------------------------------------------------------------------
/src/utils/logging.js:
--------------------------------------------------------------------------------
1 | export const log = value => {
2 | if (process.env.NODE_ENV === 'development') {
3 | // eslint-disable-next-line no-console
4 | console.log(value)
5 | }
6 | return value
7 | }
8 |
--------------------------------------------------------------------------------