├── .babelrc ├── .gitignore ├── .idea ├── .name ├── encodings.xml ├── jsLibraryMappings.xml ├── meteor-redux.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── packages ├── platforms ├── release └── versions ├── README.md ├── client ├── main.css ├── main.html └── main.js ├── dataflow.png ├── imports ├── actionCreators.js ├── actionTypes.js ├── api │ └── tasks │ │ ├── collection.js │ │ ├── constants.js │ │ └── server │ │ ├── index.js │ │ ├── methods.js │ │ └── publications.js ├── components │ ├── accounts-ui-wrapper.js │ ├── addtodo.js │ ├── list.js │ └── task.js ├── containers │ ├── app.js │ └── list.js ├── lib │ ├── constants.js │ ├── cursorListener.js │ ├── cursorReducer.js │ ├── meteorActions.js │ ├── meteorMiddleware.js │ ├── reactiveProps.js │ └── subscribe.js └── store │ ├── index.js │ ├── reducers.js │ └── selectors.js ├── package.json └── server └── main.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-class-properties" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | meteor-redux -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/meteor-redux.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | 21 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 13b1yi71jis8gxb3ceye 8 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base # Packages every Meteor app needs to have 8 | mobile-experience # Packages for a great mobile UX 9 | mongo # The database Meteor supports right now 10 | blaze-html-templates # Compile .html files into Meteor Blaze views 11 | reactive-var # Reactive variable for tracker 12 | jquery # Helpful client-side library 13 | tracker # Meteor's client-side reactive programming library 14 | 15 | standard-minifier-css # CSS minifier run for production mode 16 | standard-minifier-js # JS minifier run for production mode 17 | es5-shim # ECMAScript 5 compatibility for older browsers. 18 | ecmascript # Enable ECMAScript2015+ syntax in app code 19 | 20 | session 21 | accounts-ui 22 | accounts-password 23 | promise 24 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.3.3.1 2 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.8 2 | accounts-password@1.1.11 3 | accounts-ui@1.1.9 4 | accounts-ui-unstyled@1.1.12 5 | allow-deny@1.0.5 6 | autoupdate@1.2.10 7 | babel-compiler@6.8.2 8 | babel-runtime@0.1.9 9 | base64@1.0.9 10 | binary-heap@1.0.9 11 | blaze@2.1.8 12 | blaze-html-templates@1.0.4 13 | blaze-tools@1.0.9 14 | boilerplate-generator@1.0.9 15 | caching-compiler@1.0.5 16 | caching-html-compiler@1.0.6 17 | callback-hook@1.0.9 18 | check@1.2.3 19 | ddp@1.2.5 20 | ddp-client@1.2.8 21 | ddp-common@1.2.6 22 | ddp-rate-limiter@1.0.5 23 | ddp-server@1.2.8 24 | deps@1.0.12 25 | diff-sequence@1.0.6 26 | ecmascript@0.4.5 27 | ecmascript-runtime@0.2.11 28 | ejson@1.0.12 29 | email@1.0.14 30 | es5-shim@4.5.12 31 | fastclick@1.0.12 32 | geojson-utils@1.0.9 33 | hot-code-push@1.0.4 34 | html-tools@1.0.10 35 | htmljs@1.0.10 36 | http@1.1.6 37 | id-map@1.0.8 38 | jquery@1.11.9 39 | launch-screen@1.0.12 40 | less@2.6.2 41 | livedata@1.0.18 42 | localstorage@1.0.11 43 | logging@1.0.13 44 | meteor@1.1.15 45 | meteor-base@1.0.4 46 | minifier-css@1.1.12 47 | minifier-js@1.1.12 48 | minimongo@1.0.17 49 | mobile-experience@1.0.4 50 | mobile-status-bar@1.0.12 51 | modules@0.6.3 52 | modules-runtime@0.6.4 53 | mongo@1.1.9 54 | mongo-id@1.0.5 55 | npm-bcrypt@0.8.6_1 56 | npm-mongo@1.4.44 57 | observe-sequence@1.0.12 58 | ordered-dict@1.0.8 59 | promise@0.7.2 60 | random@1.0.10 61 | rate-limit@1.0.5 62 | reactive-dict@1.1.8 63 | reactive-var@1.0.10 64 | reload@1.1.9 65 | retry@1.0.8 66 | routepolicy@1.0.11 67 | service-configuration@1.0.10 68 | session@1.1.6 69 | sha@1.0.8 70 | spacebars@1.0.12 71 | spacebars-compiler@1.0.12 72 | srp@1.0.9 73 | standard-minifier-css@1.0.7 74 | standard-minifier-js@1.0.7 75 | templating@1.1.11 76 | templating-tools@1.0.4 77 | tracker@1.0.14 78 | ui@1.0.11 79 | underscore@1.0.9 80 | url@1.0.10 81 | webapp@1.2.9 82 | webapp-hashing@1.0.9 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # meteor-react-redux-example 2 | 3 | This is a sample TODO app written in Meteor, React, and Redux. I start from tutorial app from Meteor and continue 4 | the integration from that, so most of the UI is from [here](https://www.meteor.com/tutorials/react/creating-an-app). 5 | I use this as a sandbox to explore the possibility to integrate these frameworks together. 6 | 7 | If you have any question or feedback, feel free to submit a PR or open an issue. 8 | 9 | # Goal 10 | 11 | The goal of this exploration is to propose an integration approach that: 12 | 13 | * Follow strictly [three principles](http://redux.js.org/docs/introduction/ThreePrinciples.html) from Redux. 14 | * Provide a clear guideline how to use Meteor Collection, Subscription and Method. 15 | * Embrace best practices from all frameworks. 16 | 17 | In general, we will place all side-effects call to redux actions using **react-thunk**. We try to avoid using unnecessary 18 | middleware or other libraries, unless it is really necessary. 19 | 20 | # TODOs 21 | 22 | * Accounts 23 | * SimpleSchema 24 | * Router (React-Router or Iron-Router) 25 | 26 | # Meteor 27 | 28 | ## Methods 29 | 30 | Meteor Method can be considered as normal server-side call, and therefore is side-effect. We can wrap it inside an 31 | action and optionally dispatch call result. 32 | 33 | ``` javascript 34 | export const removeAllTasks = () => (dispatch) => { 35 | dispatch({ type: 'REMOVE_ALL_TASK_REQUEST' }); 36 | Meteor.call('removeAllTasks', (error, result) => { 37 | if (err) dispatch({ type: 'REMOVE_ALL_TASK_ERROR', payload: error }); 38 | else dispatch({ type: 'REMOVE_ALL_TASK_SUCCESS', payload: result }); 39 | }); 40 | }; 41 | ``` 42 | 43 | There is also a helper function to quickly create a meteor action call: 44 | 45 | ``` javascript 46 | import { createMeteorCallAction } from './libs/meteorActions'; 47 | 48 | export removeAllTasks = createMeteorCallAction('removeAllTasks'); 49 | ``` 50 | 51 | ## Collections 52 | 53 | ### Subscription 54 | 55 | We can directly subscribe / unsubscribe to a subscription in an action. 56 | 57 | ``` javascript 58 | export const someAction = (id) => (dispatch) => { 59 | // ... 60 | Meteor.subscribe('publicationName', { id }); 61 | // ... 62 | }; 63 | ``` 64 | 65 | If we can determine if the lifecycle of a subscription is controlled by a component, we can enhance it with **meteorSubscribe** 66 | high-order component. 67 | 68 | ``` javascript 69 | import meteorSubscribe from 'path/to/imports/lib/subscribe'; 70 | 71 | export const enhancer = compose( 72 | meteorSubscribe(props => ({ name: 'publicationName', options: { id: props.id } })), 73 | otherEnhancer 74 | ); 75 | 76 | export default enhancer(YourComponent); 77 | ``` 78 | 79 | When YourComponent is mounted, it will automatically subscribe to the publication and unsubscribe when the component is removed. 80 | 81 | ### Fetching Data 82 | 83 | One of the coolest feature of Meteor is Reactive Collection. But since we don't use Blade we will have to manage the 84 | update by ourselves. The approach is similar to subscription, we use a Container component to manage the 85 | lifecycle of the cursor. 86 | 87 | ``` javascript 88 | import cursorListener from 'path/to/imports/lib/cursorListener'; 89 | 90 | class YourComponent extends React.Component { 91 | // ... 92 | } 93 | 94 | export const enhancer = compose( 95 | cursorListener(props => YourCollection.find({ someCondition: props.someValue })) 96 | ); 97 | 98 | export default enhancer(YourComponent); 99 | ``` 100 | 101 | When YourComponent is mounted, it will listen to the cursor and start dispatching actions when there are changes. Then 102 | in your reducers, you can capture the change into store as below: 103 | 104 | ``` javascript 105 | import { makeCursorReducer } from '../lib/cursorReducer'; 106 | import Tasks from 'path/to/imports/api/yourCollection'; 107 | import { combineReducers } from 'redux' ; 108 | 109 | const tasks = makeCursorReducer(tasks); 110 | 111 | export default combineReducers({ 112 | // .... 113 | tasks 114 | }); 115 | ``` 116 | 117 | ### Create / Update / Delete 118 | 119 | Collection mutators are implemented as methods. When we call any of these functions on client side, we're actually 120 | invoking a method. For simplicity, we can wrap all collection mutations into methods. It is also better for security reason. 121 | There should be no direct manipulation at client side at all. 122 | 123 | On server side: 124 | 125 | ``` javascript 126 | Meteor.methods({ 127 | 'addTask'({ text }) { 128 | return Tasks.insert({ text }); 129 | }, 130 | 131 | 'toggleTask'({ id }) { 132 | const task = Tasks.findOne(id); 133 | 134 | if (task) { 135 | return Tasks.update({ _id: id }, { $set: { checked: !task.checked } }) 136 | } 137 | } 138 | }); 139 | ``` 140 | 141 | On client side: 142 | 143 | ``` javascript 144 | export const addTask = (text) => () => { 145 | Meteor.call('addTask', { text }); 146 | }; 147 | 148 | export const toggleTask = (id) => () => { 149 | Meteor.call('toggleTask', { id }); 150 | }; 151 | ``` 152 | 153 | # React 154 | 155 | We should separate our components into [Container and Presentational Components](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.ry4qjvhng). 156 | Only Container Components know about Meteor integration. We should also favor functional component when possible. 157 | 158 | # FAQ 159 | 160 | - Why don't we use createContainer from [meteor-react-data](http://guide.meteor.com/react.html#data) to fill data from Collection into Component? 161 | Because we want to enforce uni-directional dataflow, and Component will receive data only from Redux store. 162 | 163 | - When should we use Component state and when to use redux store? 164 | In general, we should use redux store as much as we can. But sometime it may be too much to store everything inside the redux store. 165 | If the data is only used within the Component then we can use it as component state. But if the data will be used by another component we should 166 | store the data in redux store instead. 167 | 168 | # References 169 | 170 | * http://guide.meteor.com/react.html 171 | * https://www.meteor.com/tutorials/react/creating-an-app 172 | * https://medium.com/modern-user-interfaces/how-we-redux-part-1-introduction-18a24c3b7efe 173 | * https://subvisual.co/blog/posts/79-a-bridge-between-redux-and-meteor 174 | * https://atmospherejs.com/meteor/react-meteor-data 175 | * https://github.com/acdlite/flux-standard-action 176 | * https://github.com/DaxChen/meteor-react-redux-starter-kit 177 | 178 | -------------------------------------------------------------------------------- /client/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | background-color: #315481; 4 | background-image: linear-gradient(to bottom, #315481, #918e82 100%); 5 | background-attachment: fixed; 6 | 7 | position: absolute; 8 | top: 0; 9 | bottom: 0; 10 | left: 0; 11 | right: 0; 12 | 13 | padding: 0; 14 | margin: 0; 15 | 16 | font-size: 14px; 17 | } 18 | 19 | .container { 20 | max-width: 600px; 21 | margin: 0 auto; 22 | min-height: 100%; 23 | background: white; 24 | } 25 | 26 | header { 27 | background: #d2edf4; 28 | background-image: linear-gradient(to bottom, #d0edf5, #e1e5f0 100%); 29 | padding: 20px 15px 15px 15px; 30 | position: relative; 31 | } 32 | 33 | #login-buttons { 34 | display: block; 35 | } 36 | 37 | h1 { 38 | font-size: 1.5em; 39 | margin: 0; 40 | margin-bottom: 10px; 41 | display: inline-block; 42 | margin-right: 1em; 43 | } 44 | 45 | form { 46 | margin-top: 10px; 47 | margin-bottom: -10px; 48 | position: relative; 49 | } 50 | 51 | .new-task input { 52 | box-sizing: border-box; 53 | padding: 10px 0; 54 | background: transparent; 55 | border: none; 56 | width: 100%; 57 | padding-right: 80px; 58 | font-size: 1em; 59 | } 60 | 61 | .new-task input:focus{ 62 | outline: 0; 63 | } 64 | 65 | ul { 66 | margin: 0; 67 | padding: 0; 68 | background: white; 69 | } 70 | 71 | .delete { 72 | float: right; 73 | font-weight: bold; 74 | background: none; 75 | font-size: 1em; 76 | border: none; 77 | position: relative; 78 | } 79 | 80 | li { 81 | position: relative; 82 | list-style: none; 83 | padding: 15px; 84 | border-bottom: #eee solid 1px; 85 | } 86 | 87 | li .text { 88 | margin-left: 10px; 89 | } 90 | 91 | li.checked { 92 | color: #888; 93 | } 94 | 95 | li.checked .text { 96 | text-decoration: line-through; 97 | } 98 | 99 | li.private { 100 | background: #eee; 101 | border-color: #ddd; 102 | } 103 | 104 | header .hide-completed { 105 | float: right; 106 | } 107 | 108 | .toggle-private { 109 | margin-left: 5px; 110 | } 111 | 112 | .remove-all-tasks { 113 | text-align: right; 114 | padding: 0.5em 15px; 115 | cursor: pointer; 116 | } 117 | 118 | @media (max-width: 600px) { 119 | li { 120 | padding: 12px 15px; 121 | } 122 | 123 | .search { 124 | width: 150px; 125 | clear: both; 126 | } 127 | 128 | .new-task input { 129 | padding-bottom: 5px; 130 | } 131 | } -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | Todo List 3 | 4 | 5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import App from '../imports/containers/app.js'; 5 | import { Provider } from 'react-redux' 6 | import {Router, Route, IndexRoute, browserHistory} from 'react-router'; 7 | import store from '../imports/store'; 8 | 9 | Accounts.ui.config({ 10 | passwordSignupFields: 'USERNAME_ONLY', 11 | }); 12 | 13 | Meteor.startup(() => { 14 | ReactDOM.render( 15 | 16 | 17 | 18 | 19 | 20 | , 21 | document.getElementById('app')); 22 | }); 23 | -------------------------------------------------------------------------------- /dataflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nqbao/meteor-react-redux-example/508e2e800b74390a111a9c5bdd7ea8212bf9d09e/dataflow.png -------------------------------------------------------------------------------- /imports/actionCreators.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { createAction } from 'redux-actions'; 3 | import { createMeteorCallAction } from './lib/meteorActions'; 4 | import { TOGGLE_VISIBILITY_FILTER } from './actionTypes'; 5 | import { ADD_TASK, REMOVE_TASK, TOGGLE_TASK, REMOVE_ALL_TASKS } from './api/tasks/constants'; 6 | 7 | export const addTask = createMeteorCallAction(ADD_TASK, (text) => ({ text })); 8 | export const removeTask = createMeteorCallAction(REMOVE_TASK, (id) => ({ id })); 9 | export const toggleTask = createMeteorCallAction(TOGGLE_TASK, (id) => ({ id })); 10 | export const removeAllTasks = createMeteorCallAction(REMOVE_ALL_TASKS); 11 | 12 | // view state actions 13 | export const toggleVisibilityFilter = createAction(TOGGLE_VISIBILITY_FILTER); 14 | -------------------------------------------------------------------------------- /imports/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const TOGGLE_VISIBILITY_FILTER = 'TOGGLE_VISIBILITY_FILTER'; 2 | -------------------------------------------------------------------------------- /imports/api/tasks/collection.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo'; 2 | 3 | const Tasks = new Mongo.Collection('tasks'); 4 | 5 | export default Tasks; 6 | -------------------------------------------------------------------------------- /imports/api/tasks/constants.js: -------------------------------------------------------------------------------- 1 | export const ADD_TASK = 'ADD_TASK'; 2 | export const REMOVE_TASK = 'REMOVE_TASK'; 3 | export const TOGGLE_TASK = 'TOGGLE_TASK'; 4 | export const REMOVE_ALL_TASKS = 'REMOVE_ALL_TASKS'; 5 | -------------------------------------------------------------------------------- /imports/api/tasks/server/index.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import Tasks from '../collection'; 3 | import './publications'; 4 | import './methods'; 5 | 6 | // Temporary enables all permissions 7 | Tasks.allow({ 8 | insert: () => true, 9 | update: () => true, 10 | remove: () => true 11 | }); 12 | -------------------------------------------------------------------------------- /imports/api/tasks/server/methods.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import Tasks from '../collection'; 3 | import { ADD_TASK, REMOVE_TASK, TOGGLE_TASK, REMOVE_ALL_TASKS } from '../constants'; 4 | 5 | Meteor.methods({ 6 | [ADD_TASK]({ text }) { 7 | return Tasks.insert({ 8 | text, 9 | owner: Meteor.userId(), 10 | username: Meteor.user().username, 11 | createdAt: new Date() 12 | }); 13 | }, 14 | 15 | [REMOVE_TASK]({ id }) { 16 | return Tasks.remove({ _id: id }); 17 | }, 18 | 19 | [TOGGLE_TASK]({id}) { 20 | const task = Tasks.findOne(id); 21 | 22 | if (task) { 23 | return Tasks.update({ _id: id }, { $set: { checked: !task.checked } }) 24 | } 25 | }, 26 | 27 | [REMOVE_ALL_TASKS]() { 28 | return Tasks.remove({}); 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /imports/api/tasks/server/publications.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import Tasks from '../collection'; 3 | 4 | Meteor.publish('todos', () => Tasks.find({})); 5 | -------------------------------------------------------------------------------- /imports/components/accounts-ui-wrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Template } from 'meteor/templating'; 4 | import { Blaze } from 'meteor/blaze'; 5 | 6 | export default class AccountsUIWrapper extends Component { 7 | componentDidMount() { 8 | // Use Meteor Blaze to render login buttons 9 | this.view = Blaze.render(Template.loginButtons, 10 | ReactDOM.findDOMNode(this.refs.container)); 11 | } 12 | componentWillUnmount() { 13 | // Clean up Blaze view 14 | Blaze.remove(this.view); 15 | } 16 | render() { 17 | // Just render a placeholder container that will be filled in 18 | return ; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /imports/components/addtodo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose } from 'recompose'; 3 | import { connect } from 'react-redux' 4 | import { addTask } from '../actionCreators'; 5 | 6 | class AddTaskForm extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.state = { text: '' }; 11 | } 12 | 13 | handleTextChange(e) { 14 | this.setState({ text: e.target.value }); 15 | } 16 | 17 | handleSubmit(e) { 18 | e.preventDefault(); 19 | 20 | const { addTask } = this.props; 21 | 22 | addTask(this.state.text); 23 | this.setState({ text: '' }); 24 | } 25 | 26 | render() { 27 | return ( 28 |
29 | 35 |
36 | ); 37 | } 38 | } 39 | 40 | const enhancer = compose( 41 | connect(null, dispatch=> ({ 42 | addTask: (text) => dispatch(addTask(text)), 43 | })) 44 | ); 45 | 46 | export default enhancer(AddTaskForm); 47 | -------------------------------------------------------------------------------- /imports/components/list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getVisibleTodos } from '../store/selectors'; 3 | import Task from './task.js'; 4 | 5 | const EmptyTaskPlaceHolder = () => (); 6 | 7 | const RemoveAllTasks = (props) => ( 8 |
9 | Remove all tasks 10 |
11 | ); 12 | 13 | class TaskList extends React.Component { 14 | renderTasks() { 15 | const { todos, toggleTask, removeTask } = this.props; 16 | 17 | return todos.map((task, i) => ( 18 | toggleTask(task._id)} 21 | onDeleted={() => removeTask(task._id)} 22 | /> 23 | )); 24 | } 25 | 26 | render() { 27 | return ( 28 |
29 |
    30 | {this.props.todos.length ? 31 | this.renderTasks() : 32 | 33 | } 34 |
35 | {this.props.todos.length > 0 && } 36 |
37 | ); 38 | } 39 | } 40 | 41 | export default TaskList; 42 | -------------------------------------------------------------------------------- /imports/components/task.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | // Task component - represents a single item 4 | export const Task = (props) => { 5 | const taskClassName = props.task.checked ? 'checked' : ''; 6 | 7 | return ( 8 |
  • 9 | 12 | 13 | 18 | 19 | 20 | {props.task.username}: {props.task.text} 21 | 22 |
  • 23 | ); 24 | }; 25 | 26 | Task.propTypes = { 27 | // This component gets the task to display through a React prop. 28 | // We can use propTypes to indicate it is required 29 | task: PropTypes.object.isRequired, 30 | onToggled: PropTypes.func, 31 | onDeleted: PropTypes.func 32 | }; 33 | 34 | export default Task; 35 | -------------------------------------------------------------------------------- /imports/containers/app.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { compose } from 'recompose'; 3 | import { connect } from 'react-redux' 4 | import { toggleVisibilityFilter, removeAllTasks } from '../actionCreators'; 5 | import Tasks from '../api/tasks/collection'; 6 | import meteorSubscribe from '../lib/subscribe' 7 | import cursorListener from '../lib/cursorListener'; 8 | import reactiveProps from '../lib/reactiveProps'; 9 | 10 | import TaskListContainer from './list'; 11 | 12 | import AccountsUIWrapper from '../components/accounts-ui-wrapper'; 13 | import TaskList from '../components/list'; 14 | import AddTaskForm from '../components/addtodo'; 15 | 16 | class App extends Component { 17 | render() { 18 | return ( 19 |
    20 |
    21 |

    Todo List

    22 | 31 | 32 | {this.props.user && } 33 |
    34 | {this.props.user && } 35 |
    36 | ); 37 | } 38 | } 39 | 40 | const enhancer = compose( 41 | connect( 42 | state => ({ 43 | visibilityFilter: state.visibilityFilter === 'NONE' 44 | }), 45 | dispatch => ({ 46 | toggleVisibilityFilter: () => dispatch(toggleVisibilityFilter()) 47 | }) 48 | ), 49 | reactiveProps(() => ({ 50 | user: Meteor.user() 51 | })), 52 | meteorSubscribe('todos'), // XXX: this has some problem when placing reactiveProps after subscribe 53 | cursorListener(() => Tasks.find()) 54 | ); 55 | 56 | export default enhancer(App); 57 | -------------------------------------------------------------------------------- /imports/containers/list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux' 3 | import { toggleTask, removeTask, removeAllTasks } from '../actionCreators'; 4 | import { getVisibleTodos } from '../store/selectors'; 5 | import TaskList from '../components/list'; 6 | 7 | const mapStateToProps = (state) => { 8 | return { 9 | todos: getVisibleTodos(state) 10 | } 11 | }; 12 | 13 | const mapDispatchToProps = (dispatch) => ({ 14 | toggleTask: (id) => dispatch(toggleTask(id)), 15 | removeTask: (id) => dispatch(removeTask(id)), 16 | removeAllTasks: () => dispatch(removeAllTasks()), 17 | }); 18 | 19 | const enhancer = ( 20 | connect(mapStateToProps, mapDispatchToProps) 21 | ); 22 | 23 | export default enhancer(TaskList); 24 | -------------------------------------------------------------------------------- /imports/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const METEOR_ITEM_ADDED = 'meteor/ITEM_ADDED'; 2 | export const METEOR_ITEM_REMOVED = 'meteor/ITEM_REMOVED'; 3 | export const METEOR_ITEM_CHANGED = 'meteor/ITEM_CHANGED'; 4 | -------------------------------------------------------------------------------- /imports/lib/cursorListener.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | import { lifecycle } from 'recompose'; 3 | import createHelper from 'recompose/createHelper'; 4 | import { METEOR_ITEM_ADDED, METEOR_ITEM_CHANGED, METEOR_ITEM_REMOVED} from './constants'; 5 | 6 | export const subscribeToCursor = (cursor, dispatch) => { 7 | const meta = { 8 | collection: cursor.collection.name 9 | }; 10 | 11 | return cursor.observe({ 12 | added(item) { 13 | dispatch({ 14 | type: METEOR_ITEM_ADDED, 15 | payload: item, 16 | meta 17 | }); 18 | }, 19 | 20 | changed(item) { 21 | dispatch({ 22 | type: METEOR_ITEM_CHANGED, 23 | payload: item, 24 | meta 25 | }); 26 | }, 27 | 28 | removed(item) { 29 | dispatch({ 30 | type: METEOR_ITEM_REMOVED, 31 | payload: item, 32 | meta 33 | }); 34 | } 35 | }); 36 | }; 37 | 38 | // TODO: we can pass extra metadata so reducer can work with multiple cursors of the same collection 39 | const cursorListener = fn => BaseComponent => { 40 | let handler; 41 | const component = class extends BaseComponent { 42 | componentDidMount() { 43 | if (super.componentDidMount) super.componentDidMount(); 44 | const cursor = fn(this.props); 45 | 46 | handler = subscribeToCursor(cursor, this.context.store.dispatch); 47 | } 48 | 49 | componentWillUnmount() { 50 | if (super.componentDidMount) super.componentDidMount(); 51 | 52 | handler.stop(); 53 | } 54 | }; 55 | 56 | component.contextTypes = { 57 | store: PropTypes.object 58 | }; 59 | 60 | return component; 61 | }; 62 | 63 | export default createHelper(cursorListener, 'cursorListener'); 64 | -------------------------------------------------------------------------------- /imports/lib/cursorReducer.js: -------------------------------------------------------------------------------- 1 | import update from 'immutability-helper'; 2 | import { METEOR_ITEM_ADDED, METEOR_ITEM_CHANGED, METEOR_ITEM_REMOVED} from './constants'; 3 | 4 | export const makeCursorReducer = (collection) => (state = [], action) => { 5 | const payload = action.payload; 6 | let index; 7 | 8 | // ignore unknown action 9 | if (!action.meta || action.meta.collection !== collection._name) { 10 | return state; 11 | } 12 | 13 | switch (action.type) { 14 | case METEOR_ITEM_ADDED: 15 | return state.concat([payload]); 16 | 17 | case METEOR_ITEM_CHANGED: 18 | index = state.findIndex(item => item._id === payload._id); 19 | 20 | return update(state, { 21 | [index]: { 22 | $set: payload 23 | } 24 | }); 25 | 26 | case METEOR_ITEM_REMOVED: 27 | index = state.findIndex(item => item._id === payload._id); 28 | return update(state, { 29 | $splice: [[index, 1]] 30 | }); 31 | 32 | default: 33 | return state 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /imports/lib/meteorActions.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { identity } from 'lodash'; 3 | 4 | export const createMeteorCallAction = (method, transform=identity) => (...args) => (dispatch) => { 5 | const actionName = `meteor/${method}`; 6 | const meta = { meteor: true, method }; 7 | 8 | Meteor.call(method, transform(...args), (error, payload) => { 9 | if (error) { 10 | dispatch({ type: actionName, error: true, payload: error, meta }); 11 | } else { 12 | dispatch({ type: actionName, payload, meta }); 13 | } 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /imports/lib/meteorMiddleware.js: -------------------------------------------------------------------------------- 1 | function getCollectionByName(name) { 2 | return global.Meteor.connection._mongo_livedata_collections[name]; 3 | } 4 | 5 | const meteorMiddleware = store => next => action => { 6 | const meta = action.meta; 7 | const payload = action.payload; 8 | 9 | if (meta && meta.meteor) { 10 | if (meta.meteor.collection) { 11 | const collection = getCollectionByName(meta.meteor.collection); 12 | 13 | // check if this is a meteor collection action 14 | if (/REMOVE_/.test(action.type)) { 15 | collection.remove(payload.id); 16 | } else if (/ADD_/.test(action.type)) { 17 | collection.insert(payload); 18 | } 19 | } 20 | } 21 | 22 | return next(action); 23 | }; 24 | 25 | export default meteorMiddleware; 26 | -------------------------------------------------------------------------------- /imports/lib/reactiveProps.js: -------------------------------------------------------------------------------- 1 | import { Tracker } from 'meteor/tracker'; 2 | import createElement from 'recompose/createElement' 3 | import createHelper from 'recompose/createHelper'; 4 | 5 | const reactiveProps = (fn) => BaseComponent => 6 | class extends BaseComponent { 7 | componentDidMount() { 8 | if (super.componentDidMount) super.componentDidMount(); 9 | 10 | this._tracker = Tracker.autorun(() => { 11 | this.computedProps = fn(this.props); 12 | this.forceUpdate(); 13 | }); 14 | } 15 | 16 | componentWillUnmount() { 17 | if (super.componentWillUnmount) super.componentWillUnmount(); 18 | 19 | this._tracker.stop(); 20 | } 21 | 22 | render() { 23 | return createElement(BaseComponent, { 24 | ...this.props, 25 | ...this.computedProps 26 | }); 27 | } 28 | }; 29 | 30 | export default createHelper(reactiveProps, 'reactiveProps'); 31 | -------------------------------------------------------------------------------- /imports/lib/subscribe.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import createHelper from 'recompose/createHelper'; 3 | import { lifecycle } from 'recompose'; 4 | 5 | const meteorSubscribe = (factory, onReady) => { 6 | let subscription; 7 | 8 | return lifecycle({ 9 | componentDidMount() { 10 | let name; 11 | let options; 12 | let returnOptions = factory; 13 | 14 | if (typeof returnOptions === 'function') { 15 | returnOptions = returnOptions(this.props); 16 | } 17 | 18 | if (typeof returnOptions === 'string') { 19 | name = returnOptions; 20 | } else if (typeof returnOptions === 'object') { 21 | name = returnOptions.name; 22 | options = returnOptions.options; 23 | } 24 | 25 | subscription = Meteor.subscribe(name, options, onReady); 26 | }, 27 | 28 | componentWillUnmount() { 29 | subscription.stop(); 30 | } 31 | }); 32 | }; 33 | 34 | export default createHelper(meteorSubscribe, 'meteorSubscribe'); 35 | -------------------------------------------------------------------------------- /imports/store/index.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import promise from 'redux-promise'; 4 | import createLogger from 'redux-logger'; 5 | import todoApp from './reducers'; 6 | 7 | const logger = createLogger(); 8 | 9 | const store = createStore( 10 | todoApp, 11 | applyMiddleware(thunk, promise, logger) 12 | ); 13 | 14 | export default store; 15 | -------------------------------------------------------------------------------- /imports/store/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { makeCursorReducer } from '../lib/cursorReducer'; 3 | import Tasks from '../api/tasks/collection'; 4 | import { TOGGLE_VISIBILITY_FILTER } from '../actionCreators'; 5 | 6 | const todos = makeCursorReducer(Tasks); 7 | 8 | function visibilityFilter(state = 'ALL', action) { 9 | switch (action.type) { 10 | case TOGGLE_VISIBILITY_FILTER: 11 | return state === 'NONE' ? 'ALL' : 'NONE'; 12 | default: 13 | return state; 14 | } 15 | } 16 | 17 | const todoApps = combineReducers({ 18 | visibilityFilter, 19 | todos 20 | }); 21 | 22 | export default todoApps; 23 | -------------------------------------------------------------------------------- /imports/store/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | const getVisibleFilter = state => state.visibilityFilter; 4 | const getTodos = state => state.todos; 5 | 6 | export const getVisibleTodos = createSelector( 7 | getTodos, 8 | getVisibleFilter, 9 | (todos, visibility) => todos.filter(todo => visibility === 'ALL' || !todo.checked) 10 | ); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meteor-redux", 3 | "private": true, 4 | "scripts": { 5 | "start": "meteor run" 6 | }, 7 | "dependencies": { 8 | "immutability-helper": "^2.0.0", 9 | "lodash": "^4.13.1", 10 | "meteor-node-stubs": "~0.2.0", 11 | "react": "^15.1.0", 12 | "react-dom": "^15.1.0", 13 | "react-redux": "^4.4.5", 14 | "react-router": "^2.4.1", 15 | "recompose": "^0.19.0", 16 | "redux": "^3.5.2", 17 | "redux-actions": "^0.9.1", 18 | "redux-logger": "^2.6.1", 19 | "redux-promise": "^0.5.3", 20 | "redux-thunk": "^2.1.0", 21 | "reselect": "^2.5.1" 22 | }, 23 | "devDependencies": { 24 | "babel-cli": "^6.10.1", 25 | "babel-plugin-transform-class-properties": "^6.9.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/main.js: -------------------------------------------------------------------------------- 1 | import '../imports/api/tasks/server'; 2 | 3 | --------------------------------------------------------------------------------