├── .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 | 126 | 127 |
128 |
129 | 130 |
131 |
132 | 140 |
141 | 142 |
143 | 153 | 154 |

How to build webapps that scale

155 |

This is the description for the post.

156 | Read more... 157 |
158 |
159 | 160 |
161 | 171 | 172 |

The song you won't ever stop singing. No matter how hard you try.

173 |

This is the description for the post.

174 | Read more... 175 |
176 |
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 |
221 |
222 | 223 |
224 |
225 | 226 |
227 |
228 | 229 |
230 | 233 |
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 |
272 | 280 |
281 | 282 |
283 | 293 | 294 |

How to build webapps that scale

295 |

This is the description for the post.

296 | Read more... 297 |
298 |
299 | 300 |
301 | 311 | 312 |

The song you won't ever stop singing. No matter how hard you try.

313 |

This is the description for the post.

314 | Read more... 315 |
    316 |
  • Music
  • 317 |
  • Song
  • 318 |
319 |
320 |
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 |
342 |
343 |
344 | 345 |
346 |
347 | 348 |
349 |
350 | 351 |
352 |
353 | 354 |
355 |
356 | 357 |
358 | 361 |
362 |
363 |
364 | 365 |
366 |
367 |
368 | ``` 369 | 370 | ### Create/Edit Article 371 | 372 | ```html 373 |
374 |
375 |
376 | 377 |
378 |
379 |
380 |
381 | 382 |
383 |
384 | 385 |
386 |
387 | 388 |
389 |
390 |
391 |
392 | 395 |
396 |
397 |
398 | 399 |
400 |
401 |
402 | 403 | 404 | ``` 405 | 406 | ### Article 407 | 408 | ```html 409 |
410 | 411 | 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 | 472 |
473 | 474 |
475 | 476 |
477 | 478 |
479 |
480 | 481 |
482 | 488 |
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 | ![Build Status](https://travis-ci.org/j-hannes/realworld-react-redux-modular.svg?branch=master) 2 | 3 | # ![RealWorld Example App](logo.png) 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 |
16 |
17 |
18 | 19 |
20 |
21 |
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 |
8 |
9 |
10 | 11 | avatar 12 | 13 |
14 | {article.author.username} 15 | {formatDate(article.createdAt)} 16 |
17 | 20 |
21 | 22 |

{article.title}

23 |

{article.description}

24 | Read more... 25 |
26 |
27 |
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 |
12 |
13 |
14 | 15 |
16 |

17 | {props.login ? 'Log in' : 'Sign up'} 18 |

19 |

20 | 21 | {props.login ? 'Need' : 'Have'} an account? 22 | 23 |

24 | 25 |
    26 | {props.formErrors.map(({ field, error }) => ( 27 |
  • {field} {error}
  • 28 | ))} 29 |
30 | 31 |
32 | {!props.login && 33 |
34 | 42 |
} 43 |
44 | 52 |
53 |
54 | 62 |
63 | 66 |
67 |
68 | 69 |
70 |
71 |
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 | --------------------------------------------------------------------------------