├── docs └── api │ ├── data │ ├── model.md │ └── collection.md │ └── renderables │ ├── modelView.md │ ├── collectionView.md │ ├── view.md │ └── component.md ├── .travis.yml ├── .editorconfig ├── .npmignore ├── .gitignore ├── lib ├── renderables │ ├── mixins │ │ ├── Style.js │ │ ├── Sugar.js │ │ └── Pure.js │ ├── view.js │ ├── component.js │ ├── modelView.js │ └── collectionView.js ├── util │ ├── combineQS.js │ ├── alias.js │ ├── RAFBatching.js │ └── ensureInstance.js └── data │ ├── model.js │ └── collection.js ├── .jshintrc ├── test ├── util │ ├── combineQS.js │ ├── alias.js │ └── ensureInstance.js ├── index.js └── renderables │ ├── modelView.js │ ├── view.js │ └── component.js ├── LICENSE ├── index.js ├── package.json └── README.md /docs/api/data/model.md: -------------------------------------------------------------------------------- 1 | # Model Documentation 2 | 3 | TODO -------------------------------------------------------------------------------- /docs/api/data/collection.md: -------------------------------------------------------------------------------- 1 | # Collection Documentation 2 | 3 | TODO -------------------------------------------------------------------------------- /docs/api/renderables/modelView.md: -------------------------------------------------------------------------------- 1 | # Model View Documentation 2 | 3 | TODO -------------------------------------------------------------------------------- /docs/api/renderables/collectionView.md: -------------------------------------------------------------------------------- 1 | # Collection View Documentation 2 | 3 | TODO -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "0.10" 5 | after_script: 6 | - npm run coveralls 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build 3 | lib-cov 4 | *.seed 5 | *.log 6 | *.csv 7 | *.dat 8 | *.out 9 | *.pid 10 | *.gz 11 | 12 | pids 13 | logs 14 | results 15 | 16 | npm-debug.log 17 | node_modules 18 | *.sublime* 19 | 20 | test/browser/main.js 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build 3 | lib-cov 4 | *.seed 5 | *.log 6 | *.csv 7 | *.dat 8 | *.out 9 | *.pid 10 | *.gz 11 | 12 | pids 13 | logs 14 | results 15 | 16 | npm-debug.log 17 | node_modules 18 | *.sublime* 19 | *.node 20 | coverage 21 | *.orig 22 | .idea 23 | sandbox 24 | -------------------------------------------------------------------------------- /lib/renderables/mixins/Style.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var insertCSS = require('insert-css'); 4 | var inserted = {}; 5 | 6 | module.exports = { 7 | componentWillMount: function() { 8 | if (!this.css || inserted[this.css]) { 9 | return; 10 | } 11 | insertCSS(this.css); 12 | inserted[this.css] = true; 13 | } 14 | }; -------------------------------------------------------------------------------- /lib/renderables/mixins/Sugar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | tryUpdate: function(cb){ 5 | // prevent useless binds on fns with args (like EE) 6 | if (typeof cb !== 'function') { 7 | cb = undefined; 8 | } 9 | 10 | if (this.isMounted()) { 11 | this.forceUpdate(cb); 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "camelcase": true, 3 | "curly": true, 4 | "eqeqeq": true, 5 | "freeze": true, 6 | "indent": 2, 7 | "newcap": false, 8 | "quotmark": "single", 9 | "maxdepth": 3, 10 | "maxstatements": 50, 11 | "maxlen": 80, 12 | "eqnull": true, 13 | "funcscope": true, 14 | "strict": true, 15 | "undef": true, 16 | "unused": true, 17 | "browser": true, 18 | "node": true, 19 | "mocha": true 20 | } 21 | -------------------------------------------------------------------------------- /lib/util/combineQS.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var merge = require('lodash.merge'); 4 | var url = require('url'); 5 | 6 | // take a url and merge a query object into it 7 | module.exports = function(modelUrl, query){ 8 | // shortcut 9 | if (query == null) { 10 | return modelUrl; 11 | } 12 | var parsed = url.parse(modelUrl, true); 13 | delete parsed.search; 14 | parsed.query = merge(parsed.query, query); 15 | return url.format(parsed); 16 | }; -------------------------------------------------------------------------------- /lib/data/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Model = require('ampersand-model'); 4 | var clone = require('lodash.clone'); 5 | var sync = require('ampersand-sync'); 6 | 7 | module.exports = function(config) { 8 | var model = clone(config); 9 | 10 | if (model.sync == null) { 11 | model.sync = sync; 12 | } 13 | if (model.spec == null) { 14 | model.spec = config; 15 | } 16 | var ctor = Model.extend(model); 17 | ctor.spec = model; 18 | return ctor; 19 | }; 20 | 21 | module.exports.extend = function() { 22 | return module.exports(Model.extend.apply(null, arguments)); 23 | }; 24 | -------------------------------------------------------------------------------- /lib/renderables/view.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Router = require('fission-router'); 4 | var isArray = require('is-array'); 5 | var component = require('./component'); 6 | var SugarMixin = require('./mixins/Sugar'); 7 | 8 | var fissionMixins = [ 9 | SugarMixin, 10 | Router.mixins.State, 11 | Router.mixins.Navigation 12 | ]; 13 | 14 | // a view is just a component but with a few more mixins added 15 | module.exports = function(config, mixins) { 16 | if (mixins && !isArray(mixins)) { 17 | throw new Error('mixins must be an array'); 18 | } 19 | return component(config, (mixins || []).concat(fissionMixins)); 20 | }; -------------------------------------------------------------------------------- /docs/api/renderables/view.md: -------------------------------------------------------------------------------- 1 | # View Documentation 2 | 3 | Creates a React component. `fission.view` is a layer on top of `fission.component` that adds a few helpers. This documentation will only go over the differences between `fission.view` and `fission.component`, for any other behavior you should consult the [fission.component](./component.md) documentation. 4 | 5 | If you want to make a standalone reusable component, use `fission.component`. If you want to make a higher level page that can be routed to, use `fission.view`. If you don't need info from the router, don't use `fission.view`. 6 | 7 | ## fission.view(config) 8 | 9 | ```js 10 | TODO 11 | ``` -------------------------------------------------------------------------------- /lib/renderables/component.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var isArray = require('is-array'); 5 | var StyleMixin = require('./mixins/Style'); 6 | var PureMixin = require('./mixins/Pure'); 7 | var alias = require('../util/alias'); 8 | 9 | var fissionMixins = [ 10 | StyleMixin, 11 | PureMixin 12 | ]; 13 | 14 | // a view is a react component + some aliases 15 | // TODO: what happens when mixins have mixins? 16 | // do they get aliased? 17 | module.exports = function(config, mixins) { 18 | if (mixins && !isArray(mixins)) { 19 | throw new Error('mixins must be an array'); 20 | } 21 | var vu = alias(config); 22 | var allMixins = (mixins || []).concat(fissionMixins).map(alias); 23 | vu.mixins = allMixins.concat(vu.mixins); 24 | 25 | return React.createFactory(React.createClass(vu)); 26 | }; -------------------------------------------------------------------------------- /lib/renderables/mixins/Pure.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var update = require('react/lib/update'); 4 | var shallowEqual = require('react/lib/shallowEqual'); 5 | 6 | function equal(a, b) { 7 | if (a == null && b == null) { 8 | return true; 9 | } 10 | return shallowEqual(a, b); 11 | } 12 | 13 | module.exports = { 14 | shouldComponentUpdate: function(nextProps, nextState, nextContext) { 15 | var path = !this._lastPath || this._lastPath !== this.getPath(); 16 | var ctx = !equal(this.context, nextContext); 17 | var state = !equal(this.state, nextState); 18 | var props = !equal(this.props, nextProps); 19 | 20 | if (this.getPath) { 21 | this._lastPath = this.getPath(); 22 | } 23 | 24 | return this.impure || path || props || state || ctx; 25 | }, 26 | 27 | updateState: function(t, cb) { 28 | return this.replaceState(update(this.state, t), cb); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /test/util/combineQS.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var combineQS = require('../../lib/util/combineQS'); 4 | 5 | describe('util/combineQS()', function(){ 6 | it('should work with an empty object', function(){ 7 | combineQS('http://example.com/yo', {}) 8 | .should.equal('http://example.com/yo'); 9 | }); 10 | it('should work with a null query', function(){ 11 | combineQS('http://example.com/yo', null) 12 | .should.equal('http://example.com/yo'); 13 | }); 14 | it('should work with a query object', function(){ 15 | combineQS('http://example.com/yo', { 16 | q: 123, 17 | d: 456 18 | }).should.equal('http://example.com/yo?q=123&d=456'); 19 | }); 20 | it('should work with a url that already has params', function(){ 21 | combineQS('http://example.com/yo?z=789', { 22 | q: 123, 23 | d: 456 24 | }).should.equal('http://example.com/yo?z=789&q=123&d=456'); 25 | }); 26 | }); -------------------------------------------------------------------------------- /lib/data/collection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var clone = require('lodash.clone'); 4 | var Collection = require('ampersand-collection'); 5 | var lodashMixin = require('ampersand-collection-lodash-mixin'); 6 | var restMixin = require('ampersand-collection-rest-mixin'); 7 | var sync = require('ampersand-sync'); 8 | 9 | module.exports = function(config) { 10 | var col = clone(config); 11 | 12 | if (col.model == null) { 13 | throw new Error('Missing model'); 14 | } 15 | if (col.model.spec == null) { 16 | throw new Error('Model is missing a spec'); 17 | } 18 | if (col.sync == null) { 19 | col.sync = col.model.spec.sync || sync; 20 | } 21 | if (col.url == null) { 22 | col.url = col.model.spec.urlRoot; 23 | } 24 | if (col.spec == null) { 25 | col.spec = config; 26 | } 27 | var ctor = Collection.extend( 28 | lodashMixin, restMixin, col 29 | ); 30 | return ctor; 31 | }; 32 | 33 | module.exports.extend = function() { 34 | return module.exports(Collection.extend.apply(null, arguments)); 35 | }; 36 | -------------------------------------------------------------------------------- /lib/util/alias.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var clone = require('lodash.clone'); 4 | 5 | var aliases = { 6 | init: 'getInitialState', 7 | defaultProps: 'getDefaultProps', 8 | mounting: 'componentWillMount', 9 | mounted: 'componentDidMount', 10 | unmounting: 'componentWillUnmount', 11 | rendering: 'componentWillUpdate', 12 | rendered: 'componentDidUpdate', 13 | props: 'propTypes', 14 | name: 'displayName' 15 | }; 16 | var aliasKeys = Object.keys(aliases); 17 | 18 | // TODO: one iteration on the input object to clone 19 | // and mutate is probably faster than one on alias keys and one on input 20 | function alias(config) { 21 | var out = clone(config); 22 | 23 | aliasKeys.forEach(function(k){ 24 | var v = aliases[k]; 25 | // if config has the alias and no normal react name 26 | if (out[k] != null && out[v] == null) { 27 | out[v] = out[k]; 28 | delete out[k]; 29 | } 30 | }); 31 | 32 | // always add a mixins array and alias them 33 | out.mixins = (out.mixins || []).map(alias); 34 | return out; 35 | } 36 | 37 | module.exports = alias; 38 | -------------------------------------------------------------------------------- /lib/util/RAFBatching.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/petehunt/react-raf-batching 2 | // but also triggers `tick` regularly if tab is inactive. 3 | 4 | 'use strict'; 5 | 6 | var ReactUpdates = require('react/lib/ReactUpdates'); 7 | var now = require('react/lib/performanceNow'); 8 | var raf = require('raf'); 9 | 10 | var FORCE_TICK_INTERVAL = 1000; 11 | var FORCE_TICK_THRESHOLD = 100; 12 | var lastTick; 13 | 14 | function tick() { 15 | ReactUpdates.flushBatchedUpdates(); 16 | raf(tick); 17 | lastTick = now(); 18 | } 19 | 20 | function forceTick() { 21 | if (now() - lastTick > FORCE_TICK_THRESHOLD) { 22 | tick(); 23 | } 24 | } 25 | 26 | var ReactRAFBatchingStrategy = { 27 | isBatchingUpdates: true, 28 | 29 | /** 30 | * Call the provided function in a context within which calls to `setState` 31 | * and friends are batched such that components aren't updated unnecessarily. 32 | */ 33 | batchedUpdates: function(callback, a, b) { 34 | callback(a, b); 35 | } 36 | }; 37 | 38 | tick(); 39 | setInterval(forceTick, FORCE_TICK_INTERVAL); 40 | 41 | module.exports = ReactRAFBatchingStrategy; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Fractal 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fission = require('../'); 4 | var should = require('should'); 5 | 6 | describe('fission', function(){ 7 | it('should expose the fission features', function(){ 8 | should.exist(fission); 9 | should.exist(fission.router); 10 | should.exist(fission.component); 11 | should.exist(fission.view); 12 | should.exist(fission.modelView); 13 | should.exist(fission.collectionView); 14 | should.exist(fission.model); 15 | should.exist(fission.collection); 16 | should.exist(fission.classes); 17 | should.exist(fission.update); 18 | }); 19 | 20 | it('should expose the fission router features', function(){ 21 | should.exist(fission.ChildView); 22 | should.exist(fission.Link); 23 | }); 24 | 25 | it('should expose underlying React features', function(){ 26 | should.exist(fission.React); 27 | should.exist(fission.DOM); 28 | should.exist(fission.render); 29 | should.exist(fission.renderToString); 30 | should.exist(fission.createElement); 31 | should.exist(fission.createFactory); 32 | should.exist(fission.PropTypes); 33 | should.exist(fission.render); 34 | }); 35 | }); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var Router = require('fission-router'); 5 | var update = require('react/lib/update'); 6 | var component = require('./lib/renderables/component'); 7 | var view = require('./lib/renderables/view'); 8 | var modelView = require('./lib/renderables/modelView'); 9 | var collectionView = require('./lib/renderables/collectionView'); 10 | var model = require('./lib/data/model'); 11 | var collection = require('./lib/data/collection'); 12 | var classNames = require('classnames'); 13 | 14 | var ReactUpdates = require('react/lib/ReactUpdates'); 15 | var RAFBatch = require('./lib/util/RAFBatching'); 16 | ReactUpdates.injection.injectBatchingStrategy(RAFBatch); 17 | 18 | module.exports = { 19 | router: Router, 20 | 21 | // renderables 22 | component: component, 23 | view: view, 24 | modelView: modelView, 25 | collectionView: collectionView, 26 | DOM: React.DOM, 27 | 28 | // data stuff 29 | model: model, 30 | collection: collection, 31 | 32 | // move some react-router stuff up 33 | ChildView: Router.ChildView, 34 | Link: Router.Link, 35 | 36 | // some utils 37 | classes: classNames, 38 | update: update, 39 | 40 | // expose underlying react stuff 41 | React: React, 42 | createElement: React.createElement, 43 | createFactory: React.createFactory, 44 | PropTypes: React.PropTypes, 45 | render: React.render, 46 | renderToString: React.renderToStaticMarkup, 47 | withContext: React.withContext 48 | }; -------------------------------------------------------------------------------- /test/util/alias.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var alias = require('../../lib/util/alias'); 4 | var noop = function(){}; 5 | 6 | describe('util/alias()', function(){ 7 | it('should add a mixin array', function(){ 8 | alias({}).should.eql({mixins: []}); 9 | }); 10 | it('should keep existing mixin array', function(){ 11 | alias({mixins:[123]}).should.eql({mixins:[123]}); 12 | }); 13 | it('should alias mixins', function(){ 14 | alias({ 15 | mixins:[ 16 | { 17 | init: function(){} 18 | } 19 | ] 20 | }).should.eql({ 21 | mixins:[ 22 | { 23 | mixins: [], 24 | getInitialState: function(){} 25 | } 26 | ] 27 | }); 28 | }); 29 | it('should recursively alias mixins', function(){ 30 | alias({ 31 | mixins:[ 32 | { 33 | mixins: [ 34 | { 35 | init: function(){} 36 | } 37 | ], 38 | init: function(){} 39 | } 40 | ] 41 | }).should.eql({ 42 | mixins:[ 43 | { 44 | mixins: [ 45 | { 46 | mixins: [], 47 | getInitialState: function(){} 48 | } 49 | ], 50 | getInitialState: function(){} 51 | } 52 | ] 53 | }); 54 | }); 55 | it('should correctly alias init', function(){ 56 | alias({ 57 | init: noop 58 | }).should.eql({ 59 | getInitialState: noop, 60 | mixins: [] 61 | }); 62 | }); 63 | it('should correctly alias mounting', function(){ 64 | alias({ 65 | mounting: noop 66 | }).should.eql({ 67 | componentWillMount: noop, 68 | mixins: [] 69 | }); 70 | }); 71 | it('should correctly alias mounted', function(){ 72 | alias({ 73 | mounted: noop 74 | }).should.eql({ 75 | componentDidMount: noop, 76 | mixins: [] 77 | }); 78 | }); 79 | it('should correctly alias unmounting', function(){ 80 | alias({ 81 | unmounting: noop 82 | }).should.eql({ 83 | componentWillUnmount: noop, 84 | mixins: [] 85 | }); 86 | }); 87 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fission", 3 | "description": "The React Toolkit", 4 | "version": "0.1.0", 5 | "homepage": "http://github.com/fissionjs/fission", 6 | "repository": "git://github.com/fissionjs/fission.git", 7 | "author": "Fractal (http://wearefractal.com/)", 8 | "dependencies": { 9 | "ampersand-collection": "^1.3.13", 10 | "ampersand-collection-lodash-mixin": "^2.0.1", 11 | "ampersand-collection-rest-mixin": "^4.0.0", 12 | "ampersand-model": "contra/ampersand-model", 13 | "ampersand-subcollection": "^2.0.0", 14 | "ampersand-sync": "^3.0.3", 15 | "classnames": "^2.1.1", 16 | "fission-router": "https://github.com/fissionjs/fission-router/archive/master.tar.gz", 17 | "insert-css": "^0.2.0", 18 | "is-array": "^1.0.1", 19 | "lodash.clone": "^3.0.0", 20 | "lodash.merge": "^3.0.1", 21 | "raf": "^3.0.0", 22 | "url": "^0.10.2" 23 | }, 24 | "peerDependencies": { 25 | "react": "^0.12.0" 26 | }, 27 | "devDependencies": { 28 | "istanbul-coveralls": "^1.0.1", 29 | "jshint": "^2.5.11", 30 | "jshint-stylish": "^2.0.0", 31 | "mochify": "dylanfm/mochify.js", 32 | "mochify-istanbul": "^2.1.1", 33 | "should": "^7.0.0" 34 | }, 35 | "main": "./index.js", 36 | "scripts": { 37 | "dev": "mochify --recursive --reporter spec --watch", 38 | "dev-coverage": "mochify --recursive --plugin [ mochify-istanbul --exclude '**/+(test|node_modules)/**/*' --dir ./coverage ]", 39 | "lint": "jshint index.js lib --exclude node_modules --config .jshintrc --reporter node_modules/jshint-stylish/index.js", 40 | "test": "npm run-script lint && mochify --recursive --reporter spec", 41 | "coveralls": "mochify --recursive --plugin [ mochify-istanbul --exclude '**/+(test|node_modules)/**/*' --report lcov --dir ./coverage ] && istanbul-coveralls" 42 | }, 43 | "engines": { 44 | "node": ">= 0.10" 45 | }, 46 | "licenses": [ 47 | { 48 | "type": "MIT", 49 | "url": "http://github.com/fissionjs/fission-router/raw/master/LICENSE" 50 | } 51 | ], 52 | "keywords": [ 53 | "fission", 54 | "reactjs" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /lib/util/ensureInstance.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var clone = require('lodash.clone'); 4 | var AmpersandCollection = require('ampersand-collection'); 5 | var AmpersandModel = require('ampersand-model'); 6 | var combineQS = require('../util/combineQS'); 7 | var model = require('../data/model'); 8 | var collection = require('../data/collection'); 9 | 10 | function construct(opt) { 11 | opt = opt || {}; 12 | // already an instance, just return 13 | if (opt.input instanceof opt.expected) { 14 | opt.input._needsInitialFetch = false; 15 | return opt.input; 16 | } 17 | 18 | opt.options = clone(opt.options) || {}; 19 | var providedData = !!opt.options.data; 20 | opt.options.data = opt.options.data || {}; 21 | var ctor; 22 | 23 | if (typeof opt.input === 'object') { 24 | ctor = opt.createConstructor(opt.input); 25 | } else { 26 | ctor = opt.input; 27 | } 28 | 29 | if (typeof ctor !== 'function') { 30 | throw new Error('Field must be an object, instance, or constructor'); 31 | } 32 | 33 | if (opt.options.id && 34 | !opt.options.data[ctor.prototype.idAttribute]) { 35 | opt.options.data[ctor.prototype.idAttribute] = opt.options.id; 36 | } 37 | var inst = new ctor(opt.options.data, opt.options); 38 | inst._needsInitialFetch = !providedData; 39 | 40 | // set id attrib to id - needed for fetching 41 | // only set if not already done in data option 42 | if (!inst[inst.idAttribute] && opt.options.id) { 43 | inst[inst.idAttribute] = opt.options.id; 44 | } 45 | 46 | // TODO: move to a model/collection mixin 47 | if (opt.options.query) { 48 | var origFn, origURL; 49 | if (typeof inst.url === 'function') { 50 | origFn = inst.url.bind(inst); 51 | } else { 52 | origURL = inst.url; 53 | origFn = function() { 54 | return origURL; 55 | }; 56 | } 57 | 58 | inst.url = function() { 59 | return combineQS(origFn(), opt.options.query); 60 | }; 61 | } 62 | return inst; 63 | } 64 | 65 | // Model = either a Model constructor, 66 | // a Model instance, or an object to create a Model constructor 67 | // options = construction options 68 | function constructModel(Model, options) { 69 | return construct({ 70 | input: Model, 71 | expected: AmpersandModel, 72 | createConstructor: model, 73 | options: options 74 | }); 75 | } 76 | 77 | // Collection = either a Collection constructor, 78 | // a Collection instance, or an object to create a Collection constructor 79 | // options = construction options 80 | function constructCollection(Collection, options) { 81 | return construct({ 82 | input: Collection, 83 | expected: AmpersandCollection, 84 | createConstructor: collection, 85 | options: options 86 | }); 87 | } 88 | 89 | module.exports = { 90 | model: constructModel, 91 | collection: constructCollection 92 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | # fission [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Support us][gittip-image]][gittip-url] [![Build Status][travis-image]][travis-url] [![Coveralls Status][coveralls-image]][coveralls-url] 6 | 7 | 8 | ## Information 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
Packagefission
DescriptionThe React Toolkit
Node Version>= 0.10
24 | 25 | This is undergoing constant rewrites and is still a work in progress. Documentation will be done when the API stabilizes. For ideas and planning see [future-fission](https://github.com/contra/future-fission) 26 | 27 | ## Usage 28 | 29 | ## Install 30 | 31 | ``` 32 | npm install fission --save 33 | ``` 34 | 35 | ## Testing 36 | 37 | ``` 38 | npm test 39 | ``` 40 | 41 | ## LICENSE 42 | 43 | (MIT License) 44 | 45 | Copyright (c) 2015 Fractal 46 | 47 | Permission is hereby granted, free of charge, to any person obtaining 48 | a copy of this software and associated documentation files (the 49 | "Software"), to deal in the Software without restriction, including 50 | without limitation the rights to use, copy, modify, merge, publish, 51 | distribute, sublicense, and/or sell copies of the Software, and to 52 | permit persons to whom the Software is furnished to do so, subject to 53 | the following conditions: 54 | 55 | The above copyright notice and this permission notice shall be 56 | included in all copies or substantial portions of the Software. 57 | 58 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 59 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 60 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 61 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 62 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 63 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 64 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 65 | 66 | [gittip-url]: https://www.gittip.com/wearefractal/ 67 | [gittip-image]: http://img.shields.io/gittip/wearefractal.svg 68 | 69 | [downloads-image]: http://img.shields.io/npm/dm/fission.svg 70 | [npm-url]: https://npmjs.org/package/fission 71 | [npm-image]: http://img.shields.io/npm/v/fission.svg 72 | 73 | [travis-url]: https://travis-ci.org/fissionjs/fission 74 | [travis-image]: https://travis-ci.org/fissionjs/fission.png?branch=master 75 | 76 | [coveralls-url]: https://coveralls.io/r/fissionjs/fission 77 | [coveralls-image]: https://coveralls.io/repos/fissionjs/fission/badge.png 78 | 79 | [depstat-url]: https://david-dm.org/fissionjs/fission 80 | [depstat-image]: https://david-dm.org/fissionjs/fission.png 81 | 82 | [david-url]: https://david-dm.org/fissionjs/fission 83 | [david-image]: https://david-dm.org/fissionjs/fission.png?theme=shields.io 84 | -------------------------------------------------------------------------------- /test/util/ensureInstance.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var should = require('should'); 4 | var ensureInstance = require('../../lib/util/ensureInstance'); 5 | var model = require('../../lib/data/model'); 6 | var collection = require('../../lib/data/model'); 7 | 8 | var userConfig = { 9 | urlRoot: '/api/users', 10 | props: { 11 | name: 'string' 12 | }, 13 | test: function() { 14 | return 123; 15 | } 16 | }; 17 | var User = model(userConfig); 18 | var UserWithCustomId = model({ 19 | idAttribute: '_id', 20 | urlRoot: '/api/users', 21 | props: { 22 | _id: 'number', 23 | name: 'string' 24 | }, 25 | test: function() { 26 | return 123; 27 | } 28 | }); 29 | 30 | describe('util/ensureInstance.model()', function(){ 31 | it('should work with instance', function(){ 32 | var inst = ensureInstance.model(new UserWithCustomId({ 33 | _id: 123, 34 | name: 'Todd' 35 | })); 36 | should.exist(inst); 37 | inst._id.should.equal(123); 38 | inst.test().should.equal(123); 39 | inst.name.should.equal('Todd'); 40 | inst._needsInitialFetch.should.equal(false); 41 | }); 42 | it('should work with constructor config', function(){ 43 | var inst = ensureInstance.model(userConfig); 44 | should.exist(inst); 45 | inst.test().should.equal(123); 46 | inst._needsInitialFetch.should.equal(true); 47 | }); 48 | it('should work with a constructor', function(){ 49 | var inst = ensureInstance.model(User); 50 | should.exist(inst); 51 | inst.test().should.equal(123); 52 | inst._needsInitialFetch.should.equal(true); 53 | }); 54 | it('should explode on unknown fn', function(){ 55 | try { 56 | ensureInstance.model(function(){}); 57 | } catch (err) { 58 | should.exist(err); 59 | } 60 | }); 61 | it('should work with a constructor and id', function(){ 62 | var inst = ensureInstance.model(UserWithCustomId, {id: 123}); 63 | should.exist(inst); 64 | inst._id.should.equal(123); 65 | inst.test().should.equal(123); 66 | inst._needsInitialFetch.should.equal(true); 67 | }); 68 | it('should work with a constructor and idAttribute', function(){ 69 | var inst = ensureInstance.model(User, {id: 123}); 70 | should.exist(inst); 71 | inst.id.should.equal(123); 72 | inst.test().should.equal(123); 73 | inst._needsInitialFetch.should.equal(true); 74 | }); 75 | it('should work with a constructor and data', function(){ 76 | var inst = ensureInstance.model(User, { 77 | data: { 78 | name: 'Todd' 79 | } 80 | }); 81 | should.exist(inst); 82 | inst.name.should.equal('Todd'); 83 | inst.test().should.equal(123); 84 | inst._needsInitialFetch.should.equal(false); 85 | }); 86 | it('should not override data id with option id', function(){ 87 | var inst = ensureInstance.model(UserWithCustomId, { 88 | id: 456, 89 | data: { 90 | _id: 123, 91 | name: 'Todd' 92 | } 93 | }); 94 | should.exist(inst); 95 | inst._id.should.equal(123); 96 | inst.name.should.equal('Todd'); 97 | inst.test().should.equal(123); 98 | inst._needsInitialFetch.should.equal(false); 99 | }); 100 | it('should work with a constructor and query', function(){ 101 | var inst = ensureInstance.model(User, { 102 | id: 123, 103 | query: { 104 | q: 456 105 | } 106 | }); 107 | should.exist(inst); 108 | inst.url().should.equal('/api/users/123?q=456'); 109 | }); 110 | }); 111 | 112 | describe('util/ensureInstance.collection()', function(){ 113 | }); -------------------------------------------------------------------------------- /test/renderables/modelView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var should = require('should'); 4 | var fission = require('../../'); 5 | var modelView = fission.modelView; 6 | var model = fission.model; 7 | var render = fission.render; 8 | var router = fission.router; 9 | var view = fission.view; 10 | 11 | var UserSchema = { 12 | urlRoot: '/v1/users', 13 | props: { 14 | firstName: 'string', 15 | lastName: 'string' 16 | }, 17 | derived: { 18 | fullName: { 19 | deps: ['firstName', 'lastName'], 20 | fn: function () { 21 | return this.firstName + ' ' + this.lastName; 22 | } 23 | } 24 | } 25 | }; 26 | 27 | var User = model(UserSchema); 28 | 29 | describe('renderables/modelView()', function(){ 30 | beforeEach(createRouterContext); 31 | beforeEach(function(){ 32 | this.container = document.createElement('div'); 33 | this.model = new User({ 34 | firstName: 'Donny', 35 | lastName: 'Seinfeld' 36 | }); 37 | }); 38 | 39 | it('should throw an error on missing model', function(){ 40 | var View = modelView({ 41 | render: function(){ 42 | return this.model.firstName; 43 | } 44 | }); 45 | try { 46 | View(); 47 | } catch (err) { 48 | should.exist(err); 49 | err.message.should.equal('modelView never got a model'); 50 | } 51 | }); 52 | 53 | it('should return a renderable function', function(){ 54 | var View = modelView({ 55 | render: function(){} 56 | }); 57 | var virtualNode = View({model: this.model}); 58 | should.exist(virtualNode); 59 | should.exist(virtualNode.type); 60 | virtualNode.type.should.equal(View.type); 61 | }); 62 | 63 | it('should return a component function when props', function(done){ 64 | var modelInst = this.model; 65 | 66 | var View = modelView({ 67 | render: function(){ 68 | should.exist(this.model); 69 | should.exist(this.model.firstName); 70 | this.model.firstName.should.equal(modelInst.firstName); 71 | done(); 72 | return null; 73 | } 74 | }); 75 | render(View({model: this.model}), this.container); 76 | }); 77 | 78 | it('should return a component function when config', function(done){ 79 | var modelInst = this.model; 80 | 81 | var View = modelView({ 82 | model: this.model, 83 | render: function(){ 84 | should.exist(this.model); 85 | should.exist(this.model.firstName); 86 | this.model.firstName.should.equal(modelInst.firstName); 87 | done(); 88 | return null; 89 | } 90 | }); 91 | render(View(), this.container); 92 | }); 93 | 94 | it('should return a component function when config schema', function(done){ 95 | var modelInst = this.model; 96 | var View = modelView({ 97 | data: { 98 | firstName: 'Donny', 99 | lastName: 'Seinfeld' 100 | }, 101 | model: UserSchema, 102 | render: function(){ 103 | should.exist(this.model); 104 | should.exist(this.model.firstName); 105 | this.model.firstName.should.equal(modelInst.firstName); 106 | done(); 107 | return null; 108 | } 109 | }); 110 | render(View(), this.container); 111 | }); 112 | }); 113 | 114 | function createRouterContext(cb) { 115 | var Dummy = view({ 116 | mounted: function(){ 117 | fakeRouter.stop(); 118 | fission.withContext(this.context, cb); 119 | }, 120 | render: function(){ 121 | return null; 122 | } 123 | }); 124 | var fakeRouter = router({ 125 | app: { 126 | view: Dummy, 127 | path: '/' 128 | } 129 | }); 130 | fakeRouter.start(document.createElement('div'), {location: '/'}); 131 | } -------------------------------------------------------------------------------- /lib/renderables/modelView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Model = require('ampersand-model'); 4 | var React = require('react'); 5 | var merge = require('lodash.merge'); 6 | var view = require('./view'); 7 | var constructModel = require('../util/ensureInstance').model; 8 | 9 | module.exports = function(config) { 10 | if (config == null) { 11 | throw new Error('config parameter is required'); 12 | } 13 | var configModel = config.model; 14 | delete config.model; 15 | 16 | var ModelViewMixin = { 17 | displayName: 'modelView', 18 | propTypes: { 19 | index: React.PropTypes.number, 20 | model: React.PropTypes.oneOfType([ 21 | React.PropTypes.func, 22 | React.PropTypes.object, 23 | React.PropTypes.instanceOf(Model) 24 | ]), 25 | data: React.PropTypes.object, 26 | routeIdAttribute: React.PropTypes.string, 27 | query: React.PropTypes.object 28 | }, 29 | 30 | // most options can be passed via config or props 31 | // props takes precedence 32 | getDefaultProps: function(){ 33 | return { 34 | model: configModel, 35 | data: config.data, 36 | routeIdAttribute: (config.routeIdAttribute || 'id') 37 | }; 38 | }, 39 | 40 | componentWillMount: function() { 41 | this._initialize(this.props); 42 | }, 43 | 44 | componentWillReceiveProps: function(nextProps) { 45 | if (!this.model) { 46 | return; 47 | } 48 | // TODO: check if routeIdAttribute changed 49 | // if the next route id changed, redo the model 50 | // sometimes views get recycled 51 | var nextRouteIdAttribute = this.getParams()[nextProps.routeIdAttribute]; 52 | if (nextRouteIdAttribute && nextRouteIdAttribute !== this.model.getId()) { 53 | this._initialize(nextProps); 54 | } 55 | }, 56 | 57 | _modelFetchFailed: function(m, res) { 58 | var notFound = res.status === 404 || res.status === 204; 59 | var handled404 = notFound && this.modelNotFound; 60 | if (this.modelFetchFailed) { 61 | this.modelFetchFailed.apply(this, arguments); 62 | } 63 | if (handled404) { 64 | this.modelNotFound.apply(this, arguments); 65 | } 66 | 67 | this.model = null; 68 | if (!this.modelFetchFailed && !handled404) { 69 | throw new Error('Model fetch failed with a ' + res.status); 70 | } 71 | }, 72 | 73 | _initialize: function(props) { 74 | var routeIdAttribute = this.getParams()[props.routeIdAttribute]; 75 | var options = props; 76 | if (routeIdAttribute) { 77 | options = merge({ 78 | id: routeIdAttribute 79 | }, options); 80 | } 81 | 82 | var model = constructModel(props.model, options); 83 | 84 | // no model specified in props or config 85 | if (!model) { 86 | throw new Error('modelView never got a model'); 87 | } 88 | 89 | var setModel = (function(m, res){ 90 | if (res && res.status !== 200) { 91 | return this._modelFetchFailed(model, res); 92 | } 93 | this.model = model; 94 | this.model.on('change', function(){ 95 | if (this.model.hasChanged()) { 96 | this.tryUpdate(); 97 | } 98 | }.bind(this)); 99 | if (this.modelDidFetch) { 100 | this.modelDidFetch(); 101 | } 102 | if (model._needsInitialFetch) { 103 | this.tryUpdate(); 104 | } 105 | }).bind(this); 106 | 107 | if (model._needsInitialFetch) { 108 | if (this.modelWillFetch) { 109 | this.modelWillFetch(model); 110 | } 111 | model.fetch({ 112 | success: setModel, 113 | error: this._modelFetchFailed 114 | }); 115 | } else { 116 | setModel(); 117 | } 118 | } 119 | }; 120 | 121 | return view(config, [ModelViewMixin]); 122 | }; 123 | -------------------------------------------------------------------------------- /test/renderables/view.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var should = require('should'); 4 | var fission = require('../../'); 5 | var router = fission.router; 6 | var view = fission.view; 7 | var ChildView = fission.ChildView; 8 | 9 | describe('renderables/view()', function(){ 10 | beforeEach(function(){ 11 | this.container = document.createElement('div'); 12 | }); 13 | 14 | it('should return a renderable component function', function(){ 15 | // this test makes sure we dont support jsx 16 | var View = view({ 17 | render: function(){ 18 | return null; 19 | } 20 | }); 21 | var virtualNode = View(); 22 | should.exist(virtualNode); 23 | should.exist(virtualNode.type); 24 | virtualNode.type.should.equal(View.type); 25 | }); 26 | 27 | it('should be renderable by a router', function(done){ 28 | var routerInst; 29 | var View = view({ 30 | mounted: function(){ 31 | routerInst.stop(); 32 | done(); 33 | }, 34 | render: function(){ 35 | return null; 36 | } 37 | }); 38 | 39 | routerInst = router({ 40 | app: { 41 | path: '/test', 42 | view: View 43 | } 44 | }); 45 | routerInst.replaceWith('/test'); 46 | routerInst.start(this.container); 47 | }); 48 | 49 | it('should be renderable by a router as a child view', function(done){ 50 | var routerInst; 51 | var View2 = view({ 52 | mounted: function(){ 53 | this.getParams().testId.should.equal('123'); 54 | routerInst.stop(); 55 | done(); 56 | }, 57 | render: function(){ 58 | return null; 59 | } 60 | }); 61 | var View = view({ 62 | render: function(){ 63 | return ChildView(); 64 | } 65 | }); 66 | 67 | routerInst = router({ 68 | app: { 69 | path: '/test', 70 | view: View, 71 | children: { 72 | childTest: { 73 | path: ':testId', 74 | view: View2 75 | } 76 | } 77 | } 78 | }); 79 | routerInst.replaceWith('/test/123'); 80 | routerInst.start(this.container, {location: '/'}); 81 | }); 82 | 83 | it('should have router state mixin sugar', function(done){ 84 | var routerInst; 85 | var View = view({ 86 | mounted: function(){ 87 | this.getPath().should.equal('/test/456?p=123'); 88 | this.getPathname().should.equal('/test/456'); 89 | this.getParams().should.eql({ 90 | testId: '456' 91 | }); 92 | this.getQuery().should.eql({ 93 | p: '123' 94 | }); 95 | this.isActive('app').should.equal(true); 96 | this.isActive('app', { 97 | testId: '456' 98 | }).should.equal(true); 99 | this.isActive('app', { 100 | testId: '456' 101 | }, { 102 | p: '123' 103 | }).should.equal(true); 104 | routerInst.stop(); 105 | done(); 106 | }, 107 | render: function(){ 108 | return null; 109 | } 110 | }); 111 | 112 | routerInst = router({ 113 | app: { 114 | path: '/test/:testId', 115 | view: View 116 | } 117 | }); 118 | routerInst.replaceWith('/test/456?p=123'); 119 | routerInst.start(this.container, {location: '/'}); 120 | }); 121 | 122 | it('should have navigation state mixin sugar', function(done){ 123 | var routerInst; 124 | var View = view({ 125 | mounted: function(){ 126 | should.exist(this.goBack); 127 | should.exist(this.transitionTo); 128 | should.exist(this.replaceWith); 129 | should.exist(this.makePath); 130 | should.exist(this.makeHref); 131 | routerInst.stop(); 132 | done(); 133 | }, 134 | render: function(){ 135 | return null; 136 | } 137 | }); 138 | 139 | routerInst = router({ 140 | app: { 141 | path: '/test/:testId', 142 | view: View 143 | } 144 | }); 145 | routerInst.replaceWith('/test/456?p=123'); 146 | routerInst.start(this.container, {location: '/'}); 147 | }); 148 | }); -------------------------------------------------------------------------------- /test/renderables/component.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var should = require('should'); 4 | var fission = require('../../'); 5 | var DOM = fission.DOM; 6 | var component = fission.component; 7 | 8 | describe('renderables/component()', function(){ 9 | beforeEach(function(){ 10 | this.container = document.createElement('div'); 11 | }); 12 | 13 | it('should return a renderable component function', function(){ 14 | // this test makes sure we dont support jsx 15 | var Component = component({ 16 | render: function(){ 17 | return null; 18 | } 19 | }); 20 | var virtualNode = Component(); 21 | should.exist(virtualNode); 22 | should.exist(virtualNode.type); 23 | virtualNode.type.should.equal(Component.type); 24 | }); 25 | 26 | it('should return a pure component', function(){ 27 | var called = 0; 28 | var Component = component({ 29 | init: function() { 30 | return { 31 | a: 123 32 | }; 33 | }, 34 | trigger: function() { 35 | this.setState({a: 123}); 36 | }, 37 | render: function(){ 38 | ++called; 39 | return DOM.div(null, this.state.a); 40 | } 41 | }); 42 | var inst = fission.render(Component(), this.container); 43 | inst.trigger(); 44 | setTimeout(function(){ 45 | called.should.equal(1); 46 | }, 100); 47 | }); 48 | 49 | it('should return a impure component when asked', function(){ 50 | var called = 0; 51 | var Component = component({ 52 | impure: true, 53 | init: function() { 54 | return { 55 | a: 123 56 | }; 57 | }, 58 | trigger: function() { 59 | this.setState({a: 123}); 60 | }, 61 | render: function(){ 62 | ++called; 63 | return DOM.div(null, this.state.a); 64 | } 65 | }); 66 | var inst = fission.render(Component(), this.container); 67 | inst.trigger(); 68 | setTimeout(function(){ 69 | called.should.equal(2); 70 | }, 100); 71 | }); 72 | 73 | it('should return a component with immutability helpers', function(){ 74 | var called = 0; 75 | var Component = component({ 76 | init: function() { 77 | return { 78 | a: { 79 | b: 123 80 | } 81 | }; 82 | }, 83 | trigger: function() { 84 | this.updateState({ 85 | a: { 86 | b: { 87 | $set: 123 88 | } 89 | } 90 | }); 91 | }, 92 | render: function(){ 93 | ++called; 94 | return DOM.div(null, this.state.a.b); 95 | } 96 | }); 97 | var inst = fission.render(Component(), this.container); 98 | inst.trigger(); 99 | setTimeout(function(){ 100 | called.should.equal(2); 101 | }, 100); 102 | }); 103 | 104 | it('should return a pure component with immutability helpers', function(){ 105 | var called = 0; 106 | var Component = component({ 107 | init: function() { 108 | return { 109 | a: 123 110 | }; 111 | }, 112 | trigger: function() { 113 | this.updateState({ 114 | a: { 115 | $set: 123 116 | } 117 | }); 118 | }, 119 | render: function(){ 120 | ++called; 121 | return DOM.div(null, this.state.a.b); 122 | } 123 | }); 124 | var inst = fission.render(Component(), this.container); 125 | inst.trigger(); 126 | setTimeout(function(){ 127 | called.should.equal(1); 128 | }, 100); 129 | }); 130 | 131 | it('should alias input config', function(done){ 132 | var Component = component({ 133 | init: function(){ 134 | done(); 135 | return {}; 136 | }, 137 | render: function(){ 138 | return null; 139 | } 140 | }); 141 | fission.render(Component(), this.container); 142 | }); 143 | 144 | it('should alias mixins provided in config', function(done){ 145 | var initMixin = { 146 | init: function(){ 147 | done(); 148 | return {}; 149 | } 150 | }; 151 | var Component = component({ 152 | mixins: [initMixin], 153 | render: function(){ 154 | return null; 155 | } 156 | }); 157 | fission.render(Component(), this.container); 158 | }); 159 | 160 | it('should alias mixins provided in arguments', function(done){ 161 | var initMixin = { 162 | init: function(){ 163 | done(); 164 | return {}; 165 | } 166 | }; 167 | var Component = component({ 168 | render: function(){ 169 | return null; 170 | } 171 | }, [initMixin]); 172 | fission.render(Component(), this.container); 173 | }); 174 | 175 | it('should alias mixins provided in both', function(done){ 176 | // argument mixins should take precedence 177 | // so the arg mixin init should get called first 178 | var firstCalled = false; 179 | var initMixin = { 180 | init: function(){ 181 | firstCalled.should.equal(true); 182 | done(); 183 | return {}; 184 | } 185 | }; 186 | var initMixin2 = { 187 | init: function(){ 188 | firstCalled.should.equal(false); 189 | firstCalled = true; 190 | return {}; 191 | } 192 | }; 193 | var Component = component({ 194 | mixins: [initMixin], 195 | render: function(){ 196 | return null; 197 | } 198 | }, [initMixin2]); 199 | fission.render(Component(), this.container); 200 | }); 201 | }); -------------------------------------------------------------------------------- /lib/renderables/collectionView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Collection = require('ampersand-collection'); 4 | var SubCollection = require('ampersand-subcollection'); 5 | var React = require('react'); 6 | var merge = require('lodash.merge'); 7 | var view = require('./view'); 8 | var constructCollection = require('../util/ensureInstance').collection; 9 | 10 | function bindIfFn(fn, ctx) { 11 | if (typeof fn === 'function') { 12 | return fn.bind(ctx); 13 | } 14 | return fn; 15 | } 16 | 17 | module.exports = function(config) { 18 | if (config == null) { 19 | throw new Error('config parameter is required'); 20 | } 21 | if (config.itemView == null) { 22 | throw new Error('itemView attribute is required'); 23 | } 24 | var configCollection = config.collection; 25 | delete config.collection; 26 | 27 | // items = array of item views 28 | // collection = ampersand collection 29 | var CollectionViewMixin = { 30 | collectionName: 'modelView', 31 | propTypes: { 32 | collection: React.PropTypes.oneOfType([ 33 | React.PropTypes.func, 34 | React.PropTypes.object, 35 | React.PropTypes.instanceOf(Collection) 36 | ]), 37 | data: React.PropTypes.arrayOf(React.PropTypes.object), 38 | query: React.PropTypes.object, 39 | offset: React.PropTypes.number, 40 | limit: React.PropTypes.number, 41 | where: React.PropTypes.object, 42 | filter: React.PropTypes.func, 43 | filters: React.PropTypes.arrayOf(React.PropTypes.func), 44 | watch: React.PropTypes.arrayOf(React.PropTypes.string), 45 | sort: React.PropTypes.oneOfType([ 46 | React.PropTypes.string, 47 | React.PropTypes.func 48 | ]) 49 | }, 50 | 51 | // most options can be passed via config or props 52 | // props takes precedence 53 | getDefaultProps: function(){ 54 | return { 55 | collection: configCollection, 56 | data: config.data, 57 | query: config.query, 58 | offset: config.offset, 59 | limit: config.limit, 60 | where: config.where, 61 | filter: config.filter, 62 | filters: config.filters, 63 | watch: config.watch, 64 | sort: config.sort 65 | }; 66 | }, 67 | 68 | // on component mount we initialize everything 69 | componentWillMount: function() { 70 | this._initialize(this.props); 71 | this._computeItemsWithContext(); 72 | }, 73 | 74 | componentWillReceiveProps: function(nextProps) { 75 | this._configureItems(nextProps); 76 | }, 77 | 78 | componentWillUpdate: function(){ 79 | this._computeItemsWithContext(true); 80 | }, 81 | 82 | _collectionFetchFailed: function(m, res) { 83 | var notFound = res.status === 404 || res.status === 204; 84 | var handled404 = notFound && this.collectionNotFound; 85 | if (this.collectionFetchFailed) { 86 | this.collectionFetchFailed.apply(this, arguments); 87 | } 88 | if (handled404) { 89 | this.collectionNotFound.apply(this, arguments); 90 | } 91 | 92 | this.collection = null; 93 | if (!this.collectionFetchFailed && !handled404) { 94 | if (res.status === 0) { 95 | throw new Error('Collection fetch failed, no response'); 96 | } else { 97 | throw new Error('Collection fetch failed with a ' + res.status); 98 | } 99 | } 100 | }, 101 | 102 | _initialize: function(props) { 103 | // get sync and create collection based on model 104 | // model passed in via props or config 105 | var collection = constructCollection(props.collection, props); 106 | 107 | var setCollection = (function() { 108 | this.collection = collection; 109 | // create items sub-collection 110 | this._items = new SubCollection(this.collection); 111 | this._configureItems(props); 112 | // TODO: remove events on prop change switch up or destroy 113 | this._items.on('add change remove sort reset', this.tryUpdate); 114 | if (this.collectionDidFetch) { 115 | this.collectionDidFetch(); 116 | } 117 | this.tryUpdate(); 118 | }).bind(this); 119 | 120 | // TODO: override all sync behavior to run errors and success 121 | // through our hooks 122 | if (collection._needsInitialFetch) { 123 | if (this.collectionWillFetch) { 124 | this.collectionWillFetch(collection); 125 | } 126 | collection.fetch({ 127 | success: setCollection, 128 | error: this._collectionFetchFailed 129 | }); 130 | } else { 131 | setCollection(); 132 | } 133 | }, 134 | 135 | // internal fn to sync props to shadow collection 136 | _configureItems: function(props) { 137 | if (typeof props !== 'object') { 138 | throw new Error('_configureItems called with invalid props'); 139 | } 140 | // not initialized yet 141 | if (!this._items) { 142 | return; 143 | } 144 | this._items.configure({ 145 | where: bindIfFn(props.where, this), 146 | filter: bindIfFn(props.filter, this), 147 | limit: bindIfFn(props.limit, this), 148 | offset: bindIfFn(props.offset, this), 149 | comparator: bindIfFn(props.sort, this) 150 | }, true); 151 | }, 152 | 153 | // internal fn to sync this.items with collection 154 | _computeItemsWithContext: function(runFilters) { 155 | var runTheTrap = this._computeItems.bind(this, runFilters); 156 | React.withContext(this.context, runTheTrap); 157 | }, 158 | _computeItems: function(runFilters) { 159 | // nothing to compute, data hasnt been constructed yet 160 | if (!this._items) { 161 | return; 162 | } 163 | 164 | // TODO: switch to transform when available 165 | // https://github.com/AmpersandJS/ampersand-subcollection/issues/31 166 | if (runFilters) { 167 | this._items.off('add change remove sort', this.tryUpdate); 168 | this._items._runFilters(); 169 | this._items.on('add change remove sort', this.tryUpdate); 170 | } 171 | this.items = this._items.map(function(m, idx) { 172 | var fissionProps = { 173 | model: m, 174 | index: idx, 175 | key: m.id || m.cid 176 | }; 177 | 178 | // if itemview props transform, use that merged w/ defaults 179 | // otherwise just defaults 180 | var itemProps = this.getItemViewProps ? 181 | merge(this.getItemViewProps(m, idx), fissionProps) : 182 | fissionProps; 183 | 184 | return this.itemView(itemProps); 185 | }, this); 186 | } 187 | }; 188 | 189 | return view(config, [CollectionViewMixin]); 190 | }; 191 | -------------------------------------------------------------------------------- /docs/api/renderables/component.md: -------------------------------------------------------------------------------- 1 | # Component Documentation 2 | 3 | Creates a React component. `fission.component` is different from `React.createClass` in a few ways - this will document the differences, for any other behavior you should consult the [React documentation](https://facebook.github.io/react/docs/component-specs.html). 4 | 5 | ## fission.component(config) 6 | 7 | ```js 8 | var fission = require('fission'); 9 | var DOM = fission.DOM; 10 | 11 | var Timer = fission.component({ 12 | init: function(){ 13 | return { 14 | elapsed: 0 15 | }; 16 | }, 17 | mounted: function(){ 18 | this.interval = setInterval(this.tick, 1000); 19 | }, 20 | unmounting: function(){ 21 | clearInterval(this.interval); 22 | }, 23 | 24 | tick: function(){ 25 | this.updateState({ 26 | elapsed: function(curr) { 27 | return ++curr; 28 | } 29 | }); 30 | }, 31 | render: function(){ 32 | var elapsedTxt = 'Seconds Elapsed: ' + this.state.elapsed; 33 | return DOM.div({ 34 | className: 'timer-component' 35 | }, elapsedTxt); 36 | } 37 | }); 38 | ``` 39 | 40 | ### Configuration 41 | 42 | The configuration object you pass to fission.component is the [same object you would pass to React.createClass](https://facebook.github.io/react/docs/component-specs.html), but with a few added aliases and nice things. 43 | 44 | #### props 45 | 46 | Type: `object` 47 | 48 | This object is used to validate properties passed to the component. Alias for [propTypes](https://facebook.github.io/react/docs/component-specs.html#proptypes). Used in conjuction with `fission.PropTypes` which is an alias for [React.PropTypes](https://facebook.github.io/react/docs/reusable-components.html) 49 | 50 | ```js 51 | var fission = require('fission'); 52 | var types = fission.PropTypes; 53 | 54 | var DummyUser = fission.component({ 55 | props: { 56 | name: types.string.isRequired, 57 | age: types.number, 58 | profile: types.shape({ 59 | location: types.string, 60 | bio: types.string 61 | }) 62 | }, 63 | // ... 64 | }); 65 | ``` 66 | 67 | #### init 68 | 69 | Type: `function` 70 | 71 | This function is called to get the default state before attempting to mount. Alias for [getInitialState](https://facebook.github.io/react/docs/component-specs.html#getinitialstate). 72 | 73 | ```js 74 | var fission = require('fission'); 75 | 76 | var DummyComponent = fission.component({ 77 | init: function(){ 78 | return { 79 | elapsed: 123 80 | }; 81 | }, 82 | mounting: function(){ 83 | console.log(this.state.elapsed); // 123 84 | } 85 | // ... 86 | }); 87 | ``` 88 | 89 | #### mounting 90 | 91 | Type: `function` 92 | 93 | This function is called when your component is preparing to mount. Alias for [componentWillMount](https://facebook.github.io/react/docs/component-specs.html#componentwillmount). 94 | 95 | ```js 96 | var fission = require('fission'); 97 | 98 | var DummyComponent = fission.component({ 99 | mounting: function(){ 100 | console.log('Preparing to go to the DOM'); 101 | // Do some initialization 102 | }, 103 | // ... 104 | }); 105 | ``` 106 | 107 | #### mounted 108 | 109 | Type: `function` 110 | 111 | This function is called when your component has been mounted. Alias for [componentDidMount](https://facebook.github.io/react/docs/component-specs.html#componentdidmount). 112 | 113 | ```js 114 | var fission = require('fission'); 115 | 116 | var DummyComponent = fission.component({ 117 | mounted: function(){ 118 | console.log('We are on the DOM'); 119 | console.log('Our node =', this.getDOMNode()); 120 | }, 121 | // ... 122 | }); 123 | ``` 124 | 125 | #### unmounting 126 | 127 | Type: `function` 128 | 129 | This function is called when your component is preparing to unmount (be destroyed). Alias for [componentWillUnmount](https://facebook.github.io/react/docs/component-specs.html#componentwillunmount). 130 | 131 | ```js 132 | var fission = require('fission'); 133 | 134 | var DummyComponent = fission.component({ 135 | unmounting: function(){ 136 | console.log('Preparing to be destroyed'); 137 | // Do some cleanup... 138 | }, 139 | // ... 140 | }); 141 | ``` 142 | 143 | #### css 144 | 145 | Type: `string` 146 | 147 | On first mount of your component, this CSS for it will be added to the page. This makes it easy to modularize your stylesheets per-component and also ensures that style conflicts don't happen between components. 148 | 149 | ##### Using a string 150 | 151 | ```js 152 | var fission = require('fission'); 153 | var DOM = fission.DOM; 154 | 155 | var styles = '.dummy-component { font-size: 20px; }'; 156 | 157 | var DummyComponent = fission.component({ 158 | css: styles, 159 | render: function(){ 160 | return DOM.div({ 161 | className: 'dummy-component' 162 | }, 'Hello World!'); 163 | } 164 | }); 165 | ``` 166 | 167 | ##### Using a pre-processor 168 | 169 | You don't want to put a bunch of CSS in your JS file, so you will probably use some browserify middleware. This example uses stylify which compiles the stylus into CSS and exports it as a string. 170 | 171 | ```js 172 | var fission = require('fission'); 173 | var styles = require('./DummyComponent.styl'); 174 | var DOM = fission.DOM; 175 | 176 | var DummyComponent = fission.component({ 177 | css: styles, 178 | render: function(){ 179 | return DOM.div({ 180 | className: 'dummy-component' 181 | }, 'Hello World!'); 182 | } 183 | }); 184 | ``` 185 | 186 | ### Pure Rendering 187 | 188 | By default, React will re-render on any state or prop change even if the value is the same. 189 | 190 | ```js 191 | // Re-renders 3 times 192 | this.setState({a: 123}); 193 | this.setState({a: 123}); 194 | this.setState({a: 123}); 195 | ``` 196 | 197 | fission employs a new `shouldComponentUpdate` function that ensures your component won't re-render unless there has actually been a state or prop change - even on nested objects and arrays (or arrays of objects with objects of arrays of objects, you get the point). 198 | 199 | ```js 200 | var fission = require('fission'); 201 | 202 | var Counter = fission.component({ 203 | init: function(){ 204 | return { 205 | count: 0 206 | }; 207 | }, 208 | mounted: function(){ 209 | // Calls render 1 time 210 | this.setState({count: 123}); 211 | this.setState({count: 123}); 212 | this.setState({count: 123}); 213 | }, 214 | // ... 215 | }); 216 | ``` 217 | 218 | Make sure your render function only gets its data from sanctioned source: (`this.props`, `this.state`, `this.items`, `this.model`, etc.). 219 | 220 | If for some reason you absolutely need to disable this behavior (you have global state), you can mark your component as impure. 221 | 222 | ```js 223 | var fission = require('fission'); 224 | 225 | var Counter = fission.component({ 226 | impure: true, 227 | init: function(){ 228 | return { 229 | count: 0 230 | }; 231 | }, 232 | mounted: function(){ 233 | // Calls render 3 times 234 | this.setState({count: 123}); 235 | this.setState({count: 123}); 236 | this.setState({count: 123}); 237 | }, 238 | // ... 239 | }); 240 | ``` 241 | 242 | ### Immutability Helpers 243 | 244 | fission incorporates the React immutability helpers into the core of it's component system. 245 | 246 | Components have access to an `updateState` function that lets you perform high-performance immutable operations on component state. This makes it extremely easy to update nested state which helps the Pure rendering mechanism determine if a render is needed. 247 | 248 | ```js 249 | var fission = require('fission'); 250 | var DOM = fission.DOM; 251 | 252 | var Counter = fission.component({ 253 | init: function(){ 254 | return { 255 | count: 0 256 | }; 257 | }, 258 | increment: function(){ 259 | this.updateState({ 260 | count: function(curr) { 261 | return ++curr; 262 | } 263 | }); 264 | }, 265 | render: function(){ 266 | return DOM.div({ 267 | onClick: this.increment 268 | }, this.state.count); 269 | } 270 | }); 271 | ``` --------------------------------------------------------------------------------