backbone.prism
v1.3.2
Published
Flux-like architecture for Backbone.js
Downloads
6
Readme
Backbone.Prism
Flux architecture for Backbone.js
Backbone.Prism features a Flux based architecture combining Backbone.js and React.
bower install backbone.prism --save
npm install backbone.prism --save
// Prism.Store is a 'viewable' Backbone.Collection let store = new Prism.Store([ { name: 'Eiffel Tower', location: 'France' }, { name: 'Taj Mahal', location: 'India' }, { name: 'Louvre Museum', location: 'France' }, { name: 'Machu Picchu', location 'Peru' } ]);
// Create a view only holding a particular set of data let view = store.createView({ name: 'france', filter: model => { return model.get('location') === 'France'; } });
// Make models available in all views store.publish();
console.log(view.length); // prints '2'
<br>
When a `Store` instance calls the `publish` method, all `store views` will start listening for changes. Any element added/removed/modified on the store will trigger a sync routine.
<br>
```javascript
// Adding an element to a store will trigger an event
store.add({
name: 'Arc de Triomphe',
location: 'France'
});
// Views will listen for these types of event and sync their data again
console.log(view.length); // prints '3'
class MyComponent extends React.Component { // ... }
// Builds a wrapping component listening to the 'view' prop export default Prism.compose(MyComponent, ['view']);
<br>
This simplifies the process of binding a component to a view. In order to use this component we need to provide a valid `model view` as the `view` prop.
<br>
```javascript
// file: MainComponent.jsx
import React from 'react';
import store from './store';
import MyComponent from './MyComponent.jsx';
class MainComponent extends React.Component {
componentWillMount() {
this.defaultView = store.getDefaultView();
}
componentDidMount() {
store.publish();
}
componentWillUnmount() {
this.defaultView.destroy();
}
render() {
return (<div>
<MyComponent view={this.defaultView} />
</div>);
}
}
export default MainComponent;
let store = new Prism.Store([ { name: 'Eiffel Tower', location: 'France' }, { name: 'Taj Mahal', location: 'India' }, { name: 'Machu Picchu', location 'Peru' }, { name: 'Statue of Liberty', location: 'USA' }, { name: 'The Great Wall', location: 'China' }, { name: 'Brandenburg Gate', location: 'Germany' } ]);
export default store;
<br>
The first component will represent the app itself. It will be responsible of generating a default view for the list component.
<br>
```javascript
// file: DemoApp.jsx
import React from 'react';
import store from './demostore';
import LandmarkList from './LandmarkList.jsx';
class DemoApp extends React.Component {
componentWillMount() {
this.defaultView = store.getDefaultView();
}
componentDidMount() {
store.publish();
}
componentWillUnmount() {
this.defaultView.destroy();
}
render() {
return (<div>
<h3>Landmarks of the World</h3>
<LandmarkList view={this.defaultView} />
</div>);
}
}
export default DemoApp;
class LandmarkList extends React.Component { render() { let list = this.props.view; let render = model => { return ({model.get('name')} ~ {model.get('location')}); };
return (<ul>{list.map(render)}</ul>);
} }
export default Prism.compose(LandmarkList, ['view']);
<br>
Finally, we render our app using `react-dom`.
<br>
```javascript
import React from 'react';
import ReactDOM from 'react-dom';
import DemoApp from './DemoApp.jsx';
ReactDOM.render(<DemoApp />, document.getElementById('app'));
class LandmarkList extends React.Component { render() { let list = this.props.view; let render = model => { return ({model.get('name')} ~ {model.get('location')}); };
// Check if data is available
if (!list.isInitialized()) {
return (<div>Fetching data...</div>);
}
return (<ul>{list.map(render)}</ul>);
} }
export default Prism.compose(LandmarkList, ['view']);
<br>
We can simulate this process by delaying the call to `publish` in the main component.
<br>
```javascript
componentDidMount() {
setTimeout(() => store.publish(), 3000);
}
let view = store.getDefaultView(); view.name === 'default'; // true
<br>
Both this method and `createView` accept an object containing a set of options. This object can contain the following properties:
<br>
* name: A name that identifies this view. You can obtain a view by name through the `getView` method.
* comparator: A function or property name used to sort the collection.
* filter: A function used for filtering models in a collection.
* size: The amount of elements to hold.
* offset: The amount of elements to omit from the beginning.
<br>
View configuration
====
<br>
Views provide an easy mechanism for changing configuration options through `configs`. A `ViewConfig` object sets a particular list of options in a view and then notifies the view through an event (the `set` event). The next example implements a component that defines the amount of elements to show on a list.
<br>
```javascript
import React from 'react';
class ListSizeSelector extends React.Component {
constructor(props) {
super(props);
// Initialize component state
this.state = {
size: 5
};
}
}
export default ListSizeSelector;
componentWillUnmount() { this.config.destroy(); }
<br>
The `createConfig` method expects a context object, generally the component itself, and a configuration callback. This callback gets invoked after calling the `apply` method and uses the context provided during the initialization. The configuration object returned by this callback is then merged against the view configuration. We need to make sure we destroy the configuration object once the component is unmounted.
<br>
```javascript
render() {
let options = [3, 5, 10];
let render = value => {
return (<option key={value} value={value}>{value}</option>);
};
return (<select value={this.state.size} onChange={this.onOptionChange.bind(this)}>{options.map(render)}</select>);
}
class ListOrderSelector extends React.Component { constructor(props) { super(props);
// Initialize component state
this.state = {
field: 'name',
ascending: true
};
}
componentWillMount() { // Setup comparator this.comparator = this.props.view.createComparator(this, () => { let field = this.state.field; let ascending = this.state.ascending;
return (model1, model2) => {
if (model1.get(field) < model2.get(field)) {
return ascending ? -1 : 1;
} else if (model1.get(field) > model2.get(field)) {
return ascending ? 1 : -1;
}
return 0;
};
});
}
componentWillUnmount() { this.comparator.destroy(); }
handleFieldChange(e) { // Update state and apply comparator let value = e.target.value; this.setState({field: value}, this.comparator.eval()); }
handleOrderChange(e) { let value = e.target.value == 'Ascending'; this.setState({ascending: value}, this.comparator.eval()); }
render() { let fields = ['name', 'location']; let options = ['Ascending', 'Descending'];
return (<div>
<p>
<em>Order by:</em>
<select value={this.field} onChange={this.handleFieldChange.bind(this)}>
{fields.map(field => {
return (<option key={field} value={field}>{field.substring(0,1).toUpperCase() + field.substring(1)}</option>);
})}
</select>
</p>
<p>
<em>Sorting order:</em>
<select value={this.state.ascending ? 'Ascending' : 'Descending'} onChange={this.handleOrderChange.bind(this)}>
{options.map(order => {
return (<option key={order} value={order}>{order}</option>);
})}
</select>
</p>
</div>);
} }
export default ListOrderSelector;
<br>
Paginators
====
<br>
Paginators offers a simple way of separating a big list of elements into smaller sets. We begin by calling the `createPaginator` method passing the component instance, the page size and the initial page. Once done, we simply update the page number through `setPage` and apply the new configuration. Keep in mind that pagination components still need to listen for changes in the view that contains the elements we want to paginate. These kind of components are an example of components that listen to a view but apply modifications to another.
<br>
```javascript
// file: ListPaginator.jsx
import React from 'react';
import Prism from 'backbone.prism';
import _ from 'underscore';
class ListPaginationBar extends React.Component {
constructor(props) {
super(props);
// Initialize component state
this.state = {
page: 1
};
}
componentWillMount() {
// Setup pagination
this.paginator = this.props.paginateOn.createPaginator(this, this.props.pageSize, this.state.page);
}
componentWillUnmount() {
this.paginator.destroy()
}
handlePageClick(e) {
e.preventDefault();
// Update component state and apply pagination
let page = +e.target.innerHTML;
this.paginator.setPage(page);
this.setState({page}, this.paginator.eval());
}
render() {
// Get amount of pages available
let totalPages = this.paginator.getTotalPages(this.props.view.length);
let render = counter => {
return (<a href="#" key={counter} onClick={this.handlePageClick.bind(this)}>{counter + 1}</a>)
};
return (<div>
{_(totalPages).times(render)}
<small>Showing page {this.state.page} of {totalPages}</small>
</div>);
}
}
export default Prism.compose(ListPaginationBar, ['view']);
class DemoApp extends React.Component { componentWillMount() { this.defaultView = store.createDefaultView();
// Create paginated subview
this.paginatedView = this.defaultView.createView({
name: 'paginated',
listenTo: 'sync'
});
}
componentDidMount() { store.publish(); }
componentWillUnmount() { this.defaultView.destroy(); }
render() { return ( Landmarks of the World ); } }
export default DemoApp;
<br>
Filters
====
<br>
Filters are pretty straightforward. This time we invoke the `createFilter` method passing a context object and a callback. Callbacks can return either a filter function or an object setting a specific criteria. This example sets a filter combining regex matching and the [debounce](http://underscorejs.org/#debounce) function utility.
<br>
```javascript
// file: ListFilter.jsx
import React from 'react';
import Prism from 'backbone.prism';
import _ from 'underscore';
class ListFilter extends React.Component {
constructor(props) {
super(props);
// Initialize filter state
this.state = {
filter: ''
};
}
componentWillMount() {
// Initialize filter
this.filter = this.props.filterOn.createFilter(this, () => {
let value = this.state.filter;
let regex = new RegExp(value.replace(/([.*+?^${}()|\[\]\/\\])/g, "\\$1"), 'i');
return model => value === '' ? true : model.get('name').match(regex);
});
// Build a debounced callback to avoid any blocking behavior
this.filterCallback = _.debounce(this.filter.eval(), 250);
}
componentWillUnmount() {
this.filter.destroy();
}
handleInputChange(e) {
let value = e.target.value;
this.setState({filter: value}, this.filterCallback);
}
render() {
return (<div>
<input onChange={this.handleInputChange.bind(this)} value={this.state.filter} />
</div>);
}
}
export default ListFilter;
class ChannelComponent extends React.Component { componentWillMount() { this.channel = new Prism.Channel(); this.channel.reply('initialize', { clicked: 0 }); }
componentWillUnmount() {
this.channel.destroy();
}
render() {
return (
<div>
<EmitterComponent channel={this.channel} />
<ListenerComponent channel={this.channel} />
</div>
);
}
}
export default MainComponent;
<br>
Whenever a new state is applied, we communicate it to the listener component. In this case we use the `trigger` method to send the amount of clicks registered.
<br>
```javascript
// file: EmitterComponent.jsx
import React from 'react';
class EmitterComponent extends React.Component {
constructor(props) {
super(props);
this.state = this.props.channel.request('initialize');
}
handleClick(e) {
e.preventDefault();
let channel = this.props.channel;
let clicked = this.state.clicked + 1;
this.setState({clicked}, () => {
channel.trigger('update:clicked', clicked);
});
}
render() {
return (
<button onClick={this.handleClick.bind(this)}>Click me</button>
);
}
}
export default EmitterComponent;
class ListenerComponent extends React.Component { constructor(props) { super(props); this.state = this.props.channel.request('initialize'); }
componentDidMount() {
var self = this;
this.props.channel.on('update:clicked', clicked => {
self.setState({clicked});
});
}
render() {
return (
<span>Clicks: {this.state.clicked}</span>
);
}
}
export default ListenerComponent;
<br>
Communicating between components
====
<br>
Let's go back to our demo app. We're goig to add a channel to the main component so both the pagination component and the filter can communicate efficiently.
<br>
```javascript
// file: DemoApp.jsx
import React from 'react';
import store from './demostore';
import LandmarkList from './LandmarkList.jsx';
import ListOrderSelector from './ListOrderSelector.jsx';
import ListPaginationBar from './ListPaginationBar.jsx';
import ListFilter from './ListFilter.jsx';
class DemoApp extends React.Component {
componentWillMount() {
this.defaultView = store.createDefaultView();
// Create paginated subview
this.paginatedView = this.defaultView.createView({
name: 'paginated',
listenTo: 'sync'
});
// Create channel instance
this.channel = new Prism.Channel();
}
componentDidMount() {
store.publish();
}
componentWillUnmount() {
this.defaultView.destroy();
}
render() {
return (<div>
<h3>Landmarks of the World</h3>
<ListOrderSelector view={this.defaultView} />
<ListFilter filterOn={this.defaultView} channel={this.channel} />
<LandmarkList view={this.paginatedView} />
<ListPaginationBar view={this.defaultView} paginateOn={this.paginatedView} channel={this.channel}/>
</div>);
}
}
export default DemoApp;
let value = this.state.filter;
let regex = new RegExp(value.replace(/([.*+?^${}()|\[\]\/\\])/g, "\\$1"), 'i');
return model => value === '' ? true : model.get('name').match(regex);
});
// Build a debounced callback to avoid any blocking behavior
this.filterCallback = _.debounce(this.filter.eval(), 250);
}
<br>
The `ListPaginationBar` component will listen to this event and update accordingly.
<br>
```javascript
componentWillMount() {
// Setup pagination
this.paginator = this.props.paginateOn.createPaginator(this, this.props.pageSize, this.state.page);
// Listen `page:reset` event
this.props.channel.on('page:reset', () => {
this.paginator.setPage(1);
this.setState({page: 1}, this.paginator.eval());
}, this);
}
// Set parent state vars to force re-render
this.setState({lastUpdate: (new Date()).getTime()});
}
<br>
In order to obtain a parent state var we'll use the `$value` method available in the props object.
<br>
```javascript
render() {
let lastUpdate = this.props.$value('lastUpdate');
return (<small>Last update: {lastUpdate}</small>);
}
render() { return (Showing {this.props.$value('total')} records); }
<br>
Flux by example
===
<br>
Stores
=====
<br>
According to the designers of *Flux*, a store *"contains the application state and logic"*. This same approach is implemented through the `Prism.Store` class, which extends `Backbone.Collection`.
<br>
```javascript
import {Model} from 'backbone';
import {Store} from 'backbone.prism';
let Task = Model.extend({
urlRoot: '/tasks'
});
let TaskStore = Store.extend({
model: Task,
url: '/tasks'
});
let Profile = State.extend({ url: '/profile' });
<br>
Dispatcher
=====
<br>
The `Prism.Dispatcher` class doesn't add much to the original *Flux* dispatcher except for a few methods like `handleViewAction` and `handleServerAction`.
<br>
```javascript
// file: dispatcher.js
import {Dispatcher} from 'backbone.prism';
export default new Dispatcher();
Stores need to register their list of actions through the dispatcher. This example shows a simple approach for registering actions for a task store.
let Task = Model.extend({ urlRoot: '/tasks' });
let TaskStore = Store.extend({ model: Task, url: '/tasks' });
let store = new TaskStore([ new Task({ title: 'Do some coding', priority: 3 }), new Task({ title: '(Actually) make some tests', priority: 2 }), new Task({ title: 'Check out that cool new framework', priority: 1 }), new Task({ title: 'Make some documentation', priority: 1 }), new Task({ title: 'Call Saoul', priority: 3 }) ]);
store.dispatchToken = dispatcher.register(payload => { let action = payload.action;
switch (action.type) {
case 'add-task':
store.add(new Task(action.data));
break;
default:
}
});
export default store;
<br>
Finally, we define a simple interface for these actions.
<br>
```javascript
// File: actions.js
import dispatcher from './dispatcher';
let TaskActions = {
addTask(task) {
dispatcher.handleViewAction({
type: 'add-task',
data: task
});
}
};
export default TaskActions;