├── .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 |
20 |
21 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
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 |
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 |
--------------------------------------------------------------------------------