├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── bower.json ├── devServer.js ├── index.html ├── package.json ├── src ├── js │ ├── components │ │ ├── Picker.js │ │ └── Posts.js │ ├── containers │ │ ├── App.js │ │ ├── DevTools.js │ │ ├── Root.dev.js │ │ ├── Root.js │ │ └── Root.prod.js │ ├── index.js │ └── store │ │ ├── actionTransformer.js │ │ ├── configureStore.dev.js │ │ ├── configureStore.js │ │ └── configureStore.prod.js └── purs │ ├── Actions.purs │ └── Reducers.purs ├── webpack.config.dev.js └── webpack.config.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"], 3 | "env": { 4 | "development": { 5 | "presets": ["react-hmre"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "standard-react"], 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "quotes": [2, "double"], 6 | "jsx-quotes": [2, "prefer-double"], 7 | "strict": [2, "never"], 8 | "babel/generator-star-spacing": 1, 9 | "babel/new-cap": 1, 10 | "babel/object-shorthand": 1, 11 | "babel/arrow-parens": 0, 12 | "babel/no-await-in-loop": 1, 13 | "react/react-in-jsx-scope": 2 14 | }, 15 | "plugins": [ 16 | "babel" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | dist 5 | 6 | /bower_components/ 7 | /node_modules/ 8 | /.pulp-cache/ 9 | /output/ 10 | /.psci* 11 | 12 | .tern-port 13 | /flycheck_output/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux PureScript Example 2 | 3 | ![screenshot](https://cloud.githubusercontent.com/assets/111265/14035960/09bcc846-f23d-11e5-8488-ebe573ba0790.png) 4 | 5 | ## About 6 | This project is a work-in-progress example/boilerplate demonstrating my attempt 7 | to write Redux reducers and actions in PureScript. 8 | 9 | ### Motivation 10 | 11 | Browser apps are getting ever so complicated as browsers gain new capabilities 12 | and move from simple document viewers to full-fledged app platforms. We need 13 | better tools as plain JS is just not enough to deal with this complexity. 14 | 15 | Writing your business logic and parts of your app interacting with the outside 16 | world (e.g. AJAX requests) in languages like Haskell or PureScript makes a huge 17 | difference. They help you manage side effects, avoid callback hell, create 18 | testable code, and use libs like QuickCheck. **This is my attempt to find a 19 | sweet spot which can reduce the surface area for bugs to linger while still 20 | benefiting from the great tooling React and Redux ecosystem offers.** 21 | 22 | ### Important bits 23 | 24 | * You can dispatch actions written in PureScript. These are defined using 25 | `Redux.Action.action` and `Redux.Action.asyncAction` utility functions found 26 | in 27 | [`purescript-redux-utils`](https://github.com/osener/purescript-redux-utils/tree/master/docs/Redux). 28 | See 29 | [`purs/Actions.purs`](https://github.com/osener/redux-purescript-example/blob/master/purs/Actions.purs) 30 | for some examples. 31 | * You can write your reducers in PureScript. Use `Redux.Reducer.reducer` utility 32 | function to define them (also from `purescript-redux-utils`). See 33 | [`purs/Reducers.purs`](https://github.com/osener/redux-purescript-example/blob/master/purs/Reducers.purs) 34 | for some examples. 35 | * Components are created using React and JSX. 36 | * Hot reloading and Redux devtools! 37 | 38 | ### Installation 39 | 40 | ```bash 41 | git clone https://github.com/osener/redux-purescript-example.git 42 | cd redux-purescript-example 43 | bower install 44 | npm install 45 | npm start 46 | open http://localhost:3000 47 | ``` 48 | 49 | ### TODOs 50 | 51 | * Try to reintroduce some of the type safety sacrificed for ease of use from JS 52 | * Investigate whether Immutable.js would be useful for this 53 | * Investigate using TypeScript or Flow for JS bits (maybe generate type definitions?) 54 | * Improve the example code by adding useful stuff like routing, CSS, etc. 55 | * Implement a more real-world(ish) app (TodoMVC?) 56 | 57 | ## Thanks 58 | 59 | * [@gaearon](https://github.com/gaearon) for Redux and a bunch of other tools 60 | that improve the state of front-end development. This project is based on 61 | [react-transform-boilerplate](https://github.com/gaearon/react-transform-boilerplate), 62 | and all of the caveats mentioned in its README apply to this project as well. 63 | 64 | ## License 65 | 66 | CC0 (public domain) 67 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-purescript-example", 3 | "version": "1.0.0", 4 | "moduleType": [ 5 | "node" 6 | ], 7 | "ignore": [ 8 | "**/.*", 9 | "node_modules", 10 | "bower_components", 11 | "output" 12 | ], 13 | "dependencies": { 14 | "purescript-console": "^0.1.0", 15 | "purescript-maybe": "~0.3.5", 16 | "purescript-aff": "^0.16.1", 17 | "purescript-affjax": "^0.13.0", 18 | "purescript-datetime": "^0.9.1", 19 | "purescript-redux-utils": "^0.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /devServer.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var express = require('express'); 3 | var webpack = require('webpack'); 4 | var config = require('./webpack.config.dev'); 5 | 6 | var app = express(); 7 | var compiler = webpack(config); 8 | 9 | app.use(require('webpack-dev-middleware')(compiler, { 10 | noInfo: true, 11 | publicPath: config.output.publicPath, 12 | watchOptions: { 13 | poll: false, 14 | }, 15 | stats: { 16 | hash: false, 17 | timings: false, 18 | version: false, 19 | assets: false, 20 | errors: true, 21 | colors: false, 22 | chunks: false, 23 | children: false, 24 | cached: false, 25 | modules: false, 26 | chunkModules: false, 27 | }, 28 | })); 29 | 30 | app.use(require('webpack-hot-middleware')(compiler)); 31 | 32 | app.use('/public', express.static('public')); 33 | 34 | app.get('*', function(req, res) { 35 | res.sendFile(path.join(__dirname, 'index.html')); 36 | }); 37 | 38 | app.listen(3000, function(err) { 39 | if (err) { 40 | console.log(err); 41 | return; 42 | } 43 | 44 | console.log('Listening at http://localhost:3000'); 45 | }); 46 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redux PureScript Example 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-purescript-example", 3 | "version": "1.0.0", 4 | "description": "Webpack boilerplate/example demonstrating Redux+PureScript integration with hot reloading.", 5 | "scripts": { 6 | "clean": "rimraf dist", 7 | "build:webpack": "cross-env NODE_ENV=production webpack --config webpack.config.prod.js", 8 | "build": "npm run clean && npm run build:webpack", 9 | "start": "node devServer.js", 10 | "lint": "eslint src" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/osener/redux-purescript-example.git" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "reactjs", 19 | "boilerplate", 20 | "hot", 21 | "reload", 22 | "hmr", 23 | "live", 24 | "edit", 25 | "webpack", 26 | "babel", 27 | "react-transform", 28 | "purescript", 29 | "redux" 30 | ], 31 | "author": "Ozan Sener (https://github.com/osener)", 32 | "license": "CC0-1.0", 33 | "private": true, 34 | "bugs": { 35 | "url": "https://github.com/osener/redux-purescript-example/issues" 36 | }, 37 | "homepage": "https://github.com/osener/redux-purescript-example", 38 | "devDependencies": { 39 | "@osener/redux-devtools-log-monitor": "^1.0.5", 40 | "babel-core": "^6.7.2", 41 | "babel-eslint": "^6.0.2", 42 | "babel-loader": "^6.2.4", 43 | "babel-preset-es2015": "^6.6.0", 44 | "babel-preset-react": "^6.5.0", 45 | "babel-preset-react-hmre": "^1.1.1", 46 | "babel-preset-stage-0": "^6.3.13", 47 | "cross-env": "^1.0.7", 48 | "eslint": "^2.4.0", 49 | "eslint-config-standard": "^5.1.0", 50 | "eslint-config-standard-jsx": "^1.1.1", 51 | "eslint-config-standard-react": "^2.3.0", 52 | "eslint-plugin-babel": "^3.1.0", 53 | "eslint-plugin-promise": "^1.1.0", 54 | "eslint-plugin-react": "^5.0.1", 55 | "eslint-plugin-standard": "^1.3.2", 56 | "estraverse-fb": "^1.3.1", 57 | "eventsource-polyfill": "^0.9.6", 58 | "express": "^4.13.3", 59 | "flow-bin": "^0.23.0", 60 | "purescript-webpack-plugin": "^0.3.0", 61 | "purs-loader": "^0.6.0", 62 | "redux-devtools": "^3.0.1", 63 | "redux-devtools-dock-monitor": "^1.0.1", 64 | "redux-logger": "^2.3.2", 65 | "rimraf": "^2.4.3", 66 | "webpack": "^1.12.11", 67 | "webpack-dev-middleware": "^1.4.0", 68 | "webpack-hot-middleware": "^2.9.1" 69 | }, 70 | "dependencies": { 71 | "react": "^15.0.1", 72 | "react-dom": "^15.0.1", 73 | "react-redux": "^4.0.6", 74 | "redux": "^3.3.1", 75 | "redux-thunk": "^2.0.1" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/js/components/Picker.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from "react" 2 | 3 | export default class Picker extends Component { 4 | render() { 5 | const { value, onChange, options } = this.props 6 | 7 | return ( 8 | 9 |

{value}

10 | 18 |
19 | ) 20 | } 21 | } 22 | 23 | Picker.propTypes = { 24 | options: PropTypes.arrayOf( 25 | PropTypes.string.isRequired 26 | ).isRequired, 27 | value: PropTypes.string.isRequired, 28 | onChange: PropTypes.func.isRequired 29 | } 30 | -------------------------------------------------------------------------------- /src/js/components/Posts.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from "react" 2 | 3 | export default class Posts extends Component { 4 | render() { 5 | return ( 6 | 11 | ) 12 | } 13 | } 14 | 15 | Posts.propTypes = { 16 | posts: PropTypes.array.isRequired 17 | } 18 | -------------------------------------------------------------------------------- /src/js/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from "react" 2 | import { connect } from "react-redux" 3 | import { selectSubreddit, fetchPostsIfNeeded, invalidateSubreddit } from "Actions" 4 | import Picker from "../components/Picker" 5 | import Posts from "../components/Posts" 6 | 7 | class App extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.handleChange = this.handleChange.bind(this) 11 | this.handleRefreshClick = this.handleRefreshClick.bind(this) 12 | } 13 | 14 | componentDidMount() { 15 | const { dispatch, selectedSubreddit } = this.props 16 | dispatch(fetchPostsIfNeeded(selectedSubreddit)) 17 | } 18 | 19 | componentWillReceiveProps(nextProps) { 20 | if (nextProps.selectedSubreddit !== this.props.selectedSubreddit) { 21 | const { dispatch, selectedSubreddit } = nextProps 22 | dispatch(fetchPostsIfNeeded(selectedSubreddit)) 23 | } 24 | } 25 | 26 | handleChange(nextSubreddit) { 27 | this.props.dispatch(selectSubreddit(nextSubreddit)) 28 | } 29 | 30 | handleRefreshClick(e) { 31 | e.preventDefault() 32 | 33 | const { dispatch, selectedSubreddit } = this.props 34 | dispatch(invalidateSubreddit(selectedSubreddit)) 35 | dispatch(fetchPostsIfNeeded(selectedSubreddit)) 36 | } 37 | 38 | render() { 39 | const { selectedSubreddit, posts, isFetching, lastUpdated } = this.props 40 | return ( 41 |
42 | 45 |

46 | {lastUpdated > 0 && 47 | 48 | Last updated at {new Date(lastUpdated).toLocaleTimeString()}. 49 | {" "} 50 | 51 | } 52 | {!isFetching && 53 | 55 | Refresh 56 | 57 | } 58 |

59 | {isFetching && posts.length === 0 && 60 |

Loading...

61 | } 62 | {!isFetching && posts.length === 0 && 63 |

Empty.

64 | } 65 | {posts.length > 0 && 66 |
67 | 68 |
69 | } 70 |
71 | ) 72 | } 73 | } 74 | 75 | App.propTypes = { 76 | selectedSubreddit: PropTypes.string.isRequired, 77 | posts: PropTypes.array.isRequired, 78 | isFetching: PropTypes.bool.isRequired, 79 | lastUpdated: PropTypes.number, 80 | dispatch: PropTypes.func.isRequired 81 | } 82 | 83 | function mapStateToProps(state) { 84 | const { selectedSubreddit, postsBySubreddit } = state 85 | const { 86 | isFetching, 87 | lastUpdated, 88 | items: posts 89 | } = postsBySubreddit[selectedSubreddit] || { 90 | isFetching: true, 91 | items: [] 92 | } 93 | 94 | return { 95 | selectedSubreddit, 96 | posts, 97 | isFetching, 98 | lastUpdated 99 | } 100 | } 101 | 102 | export default connect(mapStateToProps)(App) 103 | -------------------------------------------------------------------------------- /src/js/containers/DevTools.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | // Exported from redux-devtools 4 | import { createDevTools } from "redux-devtools" 5 | 6 | // Monitors are separate packages, and you can make a custom one 7 | import LogMonitor from "@osener/redux-devtools-log-monitor" 8 | import DockMonitor from "redux-devtools-dock-monitor" 9 | import actionTransformer from "../store/actionTransformer" 10 | 11 | // createDevTools takes a monitor and produces a DevTools component 12 | const DevTools = createDevTools( 13 | // Monitors are individually adjustable with props. 14 | // Consult their repositories to learn about those props. 15 | // Here, we put LogMonitor inside a DockMonitor. 16 | 17 | 18 | 19 | ) 20 | 21 | export default DevTools 22 | -------------------------------------------------------------------------------- /src/js/containers/Root.dev.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Provider } from "react-redux"; 3 | import App from "./App"; 4 | import DevTools from "./DevTools"; 5 | 6 | export default class Root extends Component { 7 | render() { 8 | const { store } = this.props; 9 | return ( 10 | 11 |
12 | 13 | 14 |
15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/js/containers/Root.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === "production") { 2 | module.exports = require("./Root.prod"); 3 | } else { 4 | module.exports = require("./Root.dev"); 5 | } 6 | -------------------------------------------------------------------------------- /src/js/containers/Root.prod.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Provider } from "react-redux"; 3 | import App from "./App"; 4 | 5 | export default class Root extends Component { 6 | render() { 7 | const { store } = this.props; 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { render } from "react-dom" 3 | import Root from "./containers/Root" 4 | import configureStore from "./store/configureStore" 5 | 6 | let store = configureStore() 7 | 8 | let rootElement = document.getElementById("root") 9 | 10 | render(, rootElement) 11 | -------------------------------------------------------------------------------- /src/js/store/actionTransformer.js: -------------------------------------------------------------------------------- 1 | export default function actionTransformer (action) { 2 | var type = action.type 3 | var values = {} 4 | 5 | if (typeof action.type === "object") { 6 | type = action.type.constructor.name 7 | 8 | for (var prop in action.type) { 9 | if (/^value\d+$/.test(prop)) { 10 | let value = action.type[prop] 11 | values[prop] = value 12 | type += " " 13 | type += typeof value === "object" ? "..." : JSON.stringify(value) 14 | } 15 | } 16 | } 17 | 18 | return { ...values, ...action, type } 19 | } 20 | -------------------------------------------------------------------------------- /src/js/store/configureStore.dev.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux" 2 | import thunkMiddleware from "redux-thunk" 3 | import createLogger from "redux-logger" 4 | import DevTools from "../containers/DevTools" 5 | import actionTransformer from "../store/actionTransformer" 6 | 7 | import { rootReducer } from "Reducers" 8 | 9 | const loggerMiddleware = createLogger({ 10 | level: "info", 11 | collapsed: true, 12 | actionTransformer 13 | }) 14 | 15 | const finalCreateStore = compose( 16 | applyMiddleware(thunkMiddleware, loggerMiddleware), 17 | DevTools.instrument() 18 | )(createStore) 19 | 20 | export default function configureStore (initialState) { 21 | const store = finalCreateStore(rootReducer, initialState) 22 | // Hot reload reducers (requires Webpack or Browserify HMR to be enabled) 23 | if (module.hot) { 24 | module.hot.accept("Reducers", () => 25 | store.replaceReducer(require("Reducers").default)) 26 | } 27 | 28 | return store 29 | } 30 | -------------------------------------------------------------------------------- /src/js/store/configureStore.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === "production") { 2 | module.exports = require("./configureStore.prod") 3 | } else { 4 | module.exports = require("./configureStore.dev") 5 | } 6 | -------------------------------------------------------------------------------- /src/js/store/configureStore.prod.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux" 2 | import thunkMiddleware from "redux-thunk" 3 | 4 | import { rootReducer } from "Reducers" 5 | 6 | const finalCreateStore = compose( 7 | applyMiddleware(thunkMiddleware), 8 | )(createStore) 9 | 10 | export default function configureStore (initialState) { 11 | return finalCreateStore(rootReducer, initialState) 12 | }; 13 | -------------------------------------------------------------------------------- /src/purs/Actions.purs: -------------------------------------------------------------------------------- 1 | module Actions 2 | ( RedditAction(..), Subreddit, Post 3 | , selectSubreddit 4 | , invalidateSubreddit 5 | , fetchPostsIfNeeded 6 | ) where 7 | 8 | import Prelude 9 | 10 | import Control.Bind ((>=>)) 11 | import Control.Monad.Reader.Trans (lift) 12 | import Control.Monad.Eff.Class (liftEff) 13 | import Control.Monad (when) 14 | 15 | import Data.Either (Either(..)) 16 | import Data.Maybe (Maybe(..)) 17 | import Data.Foreign (Foreign, unsafeFromForeign) 18 | import Data.Foreign.Class (class IsForeign, readJSON, readProp) 19 | import Data.Foreign.Index (prop) 20 | import Data.Date (nowEpochMilliseconds) 21 | import Data.Time (Milliseconds) 22 | import Data.StrMap as StrMap 23 | 24 | import Network.HTTP.Affjax (get) 25 | 26 | import Redux.Action (action, asyncAction, dispatch, getState) 27 | 28 | newtype Post = Post Foreign 29 | instance postIsForeign :: IsForeign Post where 30 | read value = Post <$> readProp "data" value 31 | 32 | newtype PostList = PostList (Array Post) 33 | instance responseIsForeign :: IsForeign PostList where 34 | read value = PostList <$> (value # (prop "data" >=> readProp "children")) 35 | 36 | type Subreddit = String 37 | 38 | data RedditAction 39 | = SelectSubreddit Subreddit 40 | | InvalidateSubreddit Subreddit 41 | | RequestPosts Subreddit 42 | | ReceivePosts Subreddit 43 | (Array Post) 44 | Milliseconds 45 | 46 | selectSubreddit = action <<< SelectSubreddit 47 | 48 | invalidateSubreddit = action <<< InvalidateSubreddit 49 | 50 | fetchPosts subreddit = 51 | do result <- get ("https://www.reddit.com/r/" ++ subreddit ++ ".json") 52 | now <- liftEff nowEpochMilliseconds 53 | return $ 54 | case readJSON result.response of 55 | Right (PostList posts) -> Just (ReceivePosts subreddit posts now) 56 | _ -> Nothing 57 | 58 | shouldFetchPosts state subreddit = 59 | case StrMap.lookup subreddit state.postsBySubreddit of 60 | Nothing -> true 61 | Just {isFetching: true} -> false 62 | Just posts -> posts.didInvalidate 63 | 64 | fetchPostsIfNeeded subreddit = 65 | asyncAction $ 66 | do state <- unsafeFromForeign <$> getState 67 | when (shouldFetchPosts state subreddit) $ 68 | do dispatch $ action (RequestPosts subreddit) 69 | result <- lift $ fetchPosts subreddit 70 | case result of 71 | Just a -> dispatch (action a) 72 | Nothing -> pure unit 73 | -------------------------------------------------------------------------------- /src/purs/Reducers.purs: -------------------------------------------------------------------------------- 1 | module Reducers (rootReducer) where 2 | 3 | import Prelude 4 | 5 | import Data.Maybe (fromMaybe) 6 | import Data.StrMap as StrMap 7 | import Data.Time (Milliseconds(Milliseconds)) 8 | 9 | import Redux.Reducer (reducer, combineReducers) 10 | 11 | import Actions 12 | 13 | selectedSubreddit = reducer r "reactjs" 14 | where r (SelectSubreddit sub) state = sub 15 | r _ state = state 16 | 17 | posts (InvalidateSubreddit _) state = 18 | state {didInvalidate = true} 19 | posts (RequestPosts _) state = 20 | state {isFetching = true 21 | ,didInvalidate = false} 22 | posts (ReceivePosts _ xs receivedAt) state = 23 | state {isFetching = false 24 | ,didInvalidate = false 25 | ,items = xs 26 | ,lastUpdated = receivedAt} 27 | 28 | postsBySubreddit = reducer r StrMap.empty 29 | where r a@(InvalidateSubreddit sub) state = updatePosts sub a state 30 | r a@(ReceivePosts sub _ _) state = updatePosts sub a state 31 | r a@(RequestPosts sub) state = updatePosts sub a state 32 | 33 | updatePosts sub action state = StrMap.insert sub (posts action xs) state 34 | where xs = fromMaybe defaultPosts (StrMap.lookup sub state) 35 | defaultPosts = 36 | {isFetching: false 37 | ,didInvalidate: false 38 | ,items: [] 39 | ,lastUpdated: Milliseconds 0.0} 40 | 41 | rootReducer = 42 | combineReducers {postsBySubreddit 43 | ,selectedSubreddit} 44 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | var path = require("path") 2 | var webpack = require("webpack") 3 | 4 | var PurescriptWebpackPlugin = require("purescript-webpack-plugin") 5 | 6 | module.exports = { 7 | // or devtool: "eval" to debug issues with compiled output: 8 | devtool: "cheap-module-eval-source-map", 9 | entry: [ 10 | // necessary for hot reloading with IE: 11 | "eventsource-polyfill", 12 | // listen to code updates emitted by hot middleware: 13 | "webpack-hot-middleware/client", 14 | // your code: 15 | "./src/js/index" 16 | ], 17 | output: { 18 | path: path.join(__dirname, "dist"), 19 | filename: "bundle.js", 20 | publicPath: "/dist/" 21 | }, 22 | plugins: [ 23 | { 24 | apply: function (compiler) { 25 | compiler.plugin("should-emit", function(compilation) { 26 | if (compilation.errors.length > 1) 27 | compilation.errors = compilation.errors.filter(function (error) { 28 | var message = error.message || error 29 | return !~message.indexOf('PureScript compilation has failed.'); 30 | }); 31 | }); 32 | } 33 | }, 34 | new PurescriptWebpackPlugin({ 35 | src: ["bower_components/purescript-*/src/**/*.purs", "src/purs/**/*.purs"], 36 | ffi: ["bower_components/purescript-*/src/**/*.js", "src/purs/**/*.js"], 37 | bundle: false, 38 | psc: "psa", 39 | pscArgs: { 40 | sourceMaps: true 41 | } 42 | }), 43 | new webpack.HotModuleReplacementPlugin(), 44 | new webpack.NoErrorsPlugin(), 45 | ], 46 | resolve: { 47 | alias: { 48 | "redux-devtools-log-monitor": "@osener/redux-devtools-log-monitor" 49 | }, 50 | extensions: ["", ".js", ".purs"], 51 | modulesDirectories: ["node_modules", "bower_components", "src/purs"] 52 | }, 53 | module: { 54 | loaders: [{ 55 | test: /\.js$/, 56 | loaders: ["babel"], 57 | include: path.join(__dirname, "src", "js") 58 | }, { 59 | test: /\.purs$/, 60 | loaders: ["purs-loader"] 61 | }] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var path = require("path") 2 | var webpack = require("webpack") 3 | 4 | var PurescriptWebpackPlugin = require("purescript-webpack-plugin") 5 | 6 | module.exports = { 7 | devtool: "source-map", 8 | entry: [ 9 | "./src/js/index" 10 | ], 11 | output: { 12 | path: path.join(__dirname, "dist"), 13 | filename: "bundle.js", 14 | publicPath: "/static/" 15 | }, 16 | plugins: [ 17 | new PurescriptWebpackPlugin({ 18 | src: ["bower_components/purescript-*/src/**/*.purs", "src/purs/**/*.purs"], 19 | ffi: ["bower_components/purescript-*/src/**/*.js", "src/purs/**/*.js"] 20 | }), 21 | new webpack.optimize.OccurrenceOrderPlugin(), 22 | new webpack.DefinePlugin({ 23 | "process.env": { 24 | "NODE_ENV": JSON.stringify("production") 25 | } 26 | }), 27 | new webpack.optimize.UglifyJsPlugin({ 28 | compressor: { 29 | warnings: false 30 | } 31 | }) 32 | ], 33 | resolve: { 34 | extensions: ["", ".js", ".purs"], 35 | modulesDirectories: ["node_modules", "bower_components", "src/purs"] 36 | }, 37 | module: { 38 | loaders: [{ 39 | test: /\.js$/, 40 | loaders: ["babel"], 41 | include: path.join(__dirname, "src", "js") 42 | }, { 43 | test: /\.purs$/, 44 | loaders: ["purs-loader"] 45 | }] 46 | } 47 | } 48 | --------------------------------------------------------------------------------