diff --git a/README.md b/README.md index 168e577..e264e02 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,120 @@ # meteor-react-redux-example This is a sample TODO app written in Meteor, React, and Redux. I start from tutorial app from Meteor and continue -the integration from that, so most of the UI is from [here](https://www.meteor.com/tutorials/react/creating-an-app). I use this as a sandbox to explore the possibility to -integrate these frameworks together. +the integration from that, so most of the UI is from [here](https://www.meteor.com/tutorials/react/creating-an-app). +I use this as a sandbox to explore the possibility to integrate these frameworks together. + +Please note that this is still a **WIP**. # Goal The goal of this exploration is to propose an integration approach that: * Follow strictly [three principles](http://redux.js.org/docs/introduction/ThreePrinciples.html) from Redux. - * Provide a clear guideline how to use Meteor API like Collection, Subscription, Method, etc within Redux. + * Provide a clear guideline how to use Meteor Collection, Subscription and Method. * Embrace best practices from all frameworks. +In general, we will place all side-effects call to redux actions using **react-thunk**. We try to avoid using unnecessary +middleware or other libraries, unless it is really necessary. + +# Meteor Method + +Meteor Method can be considered as normal server-side call, and therefore is side-effect. We can wrap it inside an +action and optionally dispatch call result. + +``` javascript +export const removeAllTasks = () => (dispatch) => { + dispatch({ type: 'REMOVE_ALL_TASK_REQUEST' }); + Meteor.call('removeAllTasks', (error, result) => { + if (err) dispatch({ type: 'REMOVE_ALL_TASK_ERROR', payload: error }); + else dispatch({ type: 'REMOVE_ALL_TASK_SUCCESS', payload: result }); + }); +}; +``` + +## Collections + +### Subscription + +We can directly subscribe / unsubscribe to a subscription in an action. + +``` javascript +export const someAction = (id) => (dispatch) => { + // ... + Meteor.subscribe('publicationName', { id }); + // ... +}; +``` + +If we can determine if the lifecycle of a subscription is controlled by a component, we can enhance it with **meteorSubscribe** +high-order component. + +``` javascript +import meteorSubscribe from 'path/to/imports/lib/subscribe'; + +export const enhancer = compose( + meteorSubscribe(props => ({ name: 'publicationName', options: { id: props.id } })), + otherEnhancer +); + +export default enhancer(YourComponent); +``` + +When YourComponent is mounted, it will automatically subscribe to the publication and unsubscribe when the component is removed. + +### Fetching Data + +One of the coolest feature of Meteor is Reactive Collection. But since we don't use Blade we will have to manage the +update by ourselves. The approach is similar to subscription, we use a Container component to manage the +lifecycle of the cursor. + +``` javascript +import cursorListener from 'path/to/imports/lib/cursorListener'; + +class YourComponent extends React.Component { + // ... +} + +export const enhancer = compose( + cursorListener(props => YourCollection.find({ someCondition: props.someValue })) +); + +export default enhancer(YourComponent); +``` + +When YourComponent is mounted, it will listen to the cursor and start dispatching actions when there are changes. Then +in your reducers, you can capture the change into store as below: + +``` +import { makeCursorReducer } from '../lib/cursorReducer'; +import Tasks from 'path/to/imports/api/yourCollection'; +import { combineReducers } from 'redux ; + +const tasks = makeCursorReducer(tasks); + +export default combineReducers({ + // .... + tasks +}); +``` + +### Create/Update/Delete + +All CUD activities to Meteor collection are considered as side effect, because it changes the underlying storage. It is +best to wrap all manipulation calls in actions, the change from the collection will propagate through cursor listener. +There should be no Collection manipulation in reducers. + + +``` javascript +export const addTask = (text) => () => { + Tasks.insert({ text }); +}; + +export const toggleTask = (id, checked) => () => { + Tasks.update({ _id: id }, { $set: { checked } }); +}; +``` + # Data flow diagram ![Data flow diagram](./dataflow.png) diff --git a/client/main.css b/client/main.css index 3593c36..a4cdfa6 100644 --- a/client/main.css +++ b/client/main.css @@ -108,6 +108,12 @@ header .hide-completed { .toggle-private { margin-left: 5px; } + +.remove-all-tasks { + text-align: right; + padding: 0.5em 15px; + cursor: pointer; +} @media (max-width: 600px) { li { diff --git a/imports/actionCreators.js b/imports/actionCreators.js index 9f118c4..a02be79 100644 --- a/imports/actionCreators.js +++ b/imports/actionCreators.js @@ -2,30 +2,15 @@ import { Meteor } from 'meteor/meteor'; import { createAction } from 'redux-actions'; import Tasks from './api/tasks/collection'; -const createMeteorAction = (type, payloadTransfomer, meteorMeta) => (...args) => dispatch => { - dispatch({ - type, - payload: payloadTransfomer ? payloadTransfomer(...args) : args[0], - meta: { - meteor: meteorMeta - } - }); +export const addTask = (text) => () => { + Tasks.insert({ text }); }; -const createCollectionAction = (collection, type, payloadTransfomer) => - createMeteorAction(type, payloadTransfomer, { - collection: typeof collection === 'object' ? collection._name : collection - }); - -export const ADD_TODO = 'ADD_TODO'; -export const addTodo = createCollectionAction(Tasks, ADD_TODO, (text) => ({ text })); - -export const REMOVE_TODO = 'REMOVE_TODO'; -export const removeTodo = createCollectionAction(Tasks, REMOVE_TODO); +export const removeTodo = (id) => () => { + Tasks.remove({ _id: id }); +}; -export const TOGGLE_TODO = 'TOGGLE_TODO'; -export const toggleTodo = id => dispatch => { - dispatch({ type: TOGGLE_TODO, payload: { id } }); +export const toggleTodo = id => () => { const task = Tasks.findOne(id); if (task) { @@ -33,5 +18,16 @@ export const toggleTodo = id => dispatch => { } }; +// view state actions export const TOGGLE_VISIBILITY_FILTER = 'TOGGLE_VISIBILITY_FILTER'; export const toggleVisibilityFilter = createAction(TOGGLE_VISIBILITY_FILTER); + +export const REMOVE_ALL_TASK_SUCCESS = 'REMOVE_ALL_TASK_SUCCESS'; +export const REMOVE_ALL_TASK_ERROR = 'REMOVE_ALL_TASK_ERROR'; +export const removeAllTasks = () => (dispatch) => { + dispatch({ type: 'REMOVE_ALL_TASK_REQUEST' }); + Meteor.call('removeAllTasks', (error, result) => { + if (error) dispatch({ type: REMOVE_ALL_TASK_ERROR, payload: error }); + else dispatch({ type: REMOVE_ALL_TASK_SUCCESS, payload: result }); + }); +}; diff --git a/imports/api/tasks/server/index.js b/imports/api/tasks/server/index.js index 43c7cb9..a641f47 100644 --- a/imports/api/tasks/server/index.js +++ b/imports/api/tasks/server/index.js @@ -1,3 +1,4 @@ +import { Meteor } from 'meteor/meteor'; import Tasks from '../collection'; import './publications'; @@ -7,3 +8,9 @@ Tasks.allow({ update: () => true, remove: () => true }); + +Meteor.methods({ + 'removeAllTasks'() { + return Tasks.remove({}); + } +}); \ No newline at end of file diff --git a/imports/components/addtodo.js b/imports/components/addtodo.js index 0567e2c..8bbc36b 100644 --- a/imports/components/addtodo.js +++ b/imports/components/addtodo.js @@ -1,12 +1,11 @@ import React from 'react'; import { compose } from 'recompose'; import { connect } from 'react-redux' -import { addTodo } from '../actionCreators'; - -class AddTodoForm extends React.Component { +import { addTask } from '../actionCreators'; +class AddTaskForm extends React.Component { handleSubmit(e) { - this.props.addTodo(this.refs.textInput.value); + this.props.addTask(this.refs.textInput.value); e.stopPropagation(); e.preventDefault(); @@ -27,8 +26,8 @@ class AddTodoForm extends React.Component { const enhancer = compose( connect(null, dispatch=> ({ - addTodo: (text) => dispatch(addTodo(text)), + addTask: (text) => dispatch(addTask(text)), })) ); -export default enhancer(AddTodoForm); +export default enhancer(AddTaskForm); diff --git a/imports/components/app.jsx b/imports/components/app.jsx index d8520d9..5d5cb9f 100644 --- a/imports/components/app.jsx +++ b/imports/components/app.jsx @@ -1,13 +1,13 @@ import React, { Component } from 'react'; -import { compose } from 'recompose';; +import { compose } from 'recompose'; import { connect } from 'react-redux' -import { toggleVisibilityFilter } from '../actionCreators'; +import { toggleVisibilityFilter, removeAllTasks } from '../actionCreators'; import Tasks from '../api/tasks/collection'; import meteorSubscribe from '../lib/subscribe' import cursorListener from '../lib/cursorListener'; import TaskList from './list'; -import AddTodoForm from './addtodo'; +import AddTaskForm from './addtodo'; class App extends Component { renderTasks() { @@ -30,10 +30,8 @@ class App extends Component { /> Hide Completed Tasks - - + - ); diff --git a/imports/components/list.js b/imports/components/list.js index 9f2e77d..2732fbe 100644 --- a/imports/components/list.js +++ b/imports/components/list.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import { connect } from 'react-redux' -import { toggleTodo, removeTodo } from '../actionCreators'; +import { toggleTodo, removeTodo, removeAllTasks } from '../actionCreators'; import { getVisibleTodos } from '../store/selectors'; import Task from './task.jsx'; @@ -14,10 +14,17 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => ({ toggleTodo: (id) => dispatch(toggleTodo(id)), removeTodo: (id) => dispatch(removeTodo(id)), + removeAllTasks: () => dispatch(removeAllTasks()), }); const EmptyTaskPlaceHolder = () => (
  • There is no task yet.
); +const RemoveAllTasks = (props) => ( +
+ Remove all tasks +
+); + class TaskList extends Component { renderTasks() { return this.props.todos.map((task, i) => ( @@ -28,17 +35,24 @@ class TaskList extends Component { /> )); } - + render() { return ( -
    - {this.props.todos.length ? - this.renderTasks() : - - } -
+
+
    + {this.props.todos.length ? + this.renderTasks() : + + } +
+ {this.props.todos.length > 0 && } +
); } } -export default connect(mapStateToProps, mapDispatchToProps)(TaskList); +const enhancer = ( + connect(mapStateToProps, mapDispatchToProps) +); + +export default enhancer(TaskList); diff --git a/imports/lib/cursorListener.js b/imports/lib/cursorListener.js index 4772cdb..bb209d2 100644 --- a/imports/lib/cursorListener.js +++ b/imports/lib/cursorListener.js @@ -3,7 +3,7 @@ import { lifecycle } from 'recompose'; import createHelper from 'recompose/createHelper'; import { METEOR_ITEM_ADDED, METEOR_ITEM_CHANGED, METEOR_ITEM_REMOVED} from './constants'; -const subscribeToCursor = (cursor, dispatch) => { +export const subscribeToCursor = (cursor, dispatch) => { const meta = { collection: cursor.collection.name }; @@ -35,13 +35,12 @@ const subscribeToCursor = (cursor, dispatch) => { }); }; -// TODO: we can pass extra metadata so reducer can work with multi-cursor +// TODO: we can pass extra metadata so reducer can work with multiple cursors of the same collection const cursorListener = fn => BaseComponent => { let handler; const component = class extends BaseComponent { componentDidMount() { if (super.componentDidMount) super.componentDidMount(); - const cursor = fn(this.props); handler = subscribeToCursor(cursor, this.context.store.dispatch); diff --git a/imports/store/index.js b/imports/store/index.js index 929ed04..54c2b3e 100644 --- a/imports/store/index.js +++ b/imports/store/index.js @@ -3,13 +3,12 @@ import thunk from 'redux-thunk'; import promise from 'redux-promise'; import createLogger from 'redux-logger'; import todoApp from './reducers'; -import meteorMiddleware from '../lib/meteorMiddleware'; const logger = createLogger(); const store = createStore( todoApp, - applyMiddleware(thunk, promise, logger, meteorMiddleware) + applyMiddleware(thunk, promise, logger) ); export default store;