Skip to content

Commit

Permalink
Write up all the docs
Browse files Browse the repository at this point in the history
  • Loading branch information
nqbao committed Jun 7, 2016
1 parent a6d180e commit 9e39479
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 50 deletions.
109 changes: 106 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
6 changes: 6 additions & 0 deletions client/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 17 additions & 21 deletions imports/actionCreators.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,32 @@ 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) {
Tasks.update({ _id: id }, { $set: { checked: !task.checked } })
}
};

// 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 });
});
};
7 changes: 7 additions & 0 deletions imports/api/tasks/server/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Meteor } from 'meteor/meteor';
import Tasks from '../collection';
import './publications';

Expand All @@ -7,3 +8,9 @@ Tasks.allow({
update: () => true,
remove: () => true
});

Meteor.methods({
'removeAllTasks'() {
return Tasks.remove({});
}
});
11 changes: 5 additions & 6 deletions imports/components/addtodo.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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);
10 changes: 4 additions & 6 deletions imports/components/app.jsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -30,10 +30,8 @@ class App extends Component {
/>
Hide Completed Tasks
</label>

<AddTodoForm />
<AddTaskForm />
</header>

<TaskList />
</div>
);
Expand Down
32 changes: 23 additions & 9 deletions imports/components/list.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = () => (<ul><li><em>There is no task yet.</em></li></ul>);

const RemoveAllTasks = (props) => (
<div {...props} className='remove-all-tasks'>
Remove all tasks
</div>
);

class TaskList extends Component {
renderTasks() {
return this.props.todos.map((task, i) => (
Expand All @@ -28,17 +35,24 @@ class TaskList extends Component {
/>
));
}

render() {
return (
<ul>
{this.props.todos.length ?
this.renderTasks() :
<EmptyTaskPlaceHolder />
}
</ul>
<div>
<ul>
{this.props.todos.length ?
this.renderTasks() :
<EmptyTaskPlaceHolder />
}
</ul>
{this.props.todos.length > 0 && <RemoveAllTasks onClick={this.props.removeAllTasks} />}
</div>
);
}
}

export default connect(mapStateToProps, mapDispatchToProps)(TaskList);
const enhancer = (
connect(mapStateToProps, mapDispatchToProps)
);

export default enhancer(TaskList);
5 changes: 2 additions & 3 deletions imports/lib/cursorListener.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions imports/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

0 comments on commit 9e39479

Please sign in to comment.