diff --git a/examples/http/client.js b/examples/http/client.js index 31370a2..c1473a5 100644 --- a/examples/http/client.js +++ b/examples/http/client.js @@ -5,7 +5,7 @@ const mainView = require('./views/main') const app = choo() app.model(require('./models/error')) -app.model('api', require('./models/api')) +app.model(require('./models/api')) app.router((route) => [ route('/', mainView) diff --git a/examples/http/models/api.js b/examples/http/models/api.js index 37907e3..73daf8a 100644 --- a/examples/http/models/api.js +++ b/examples/http/models/api.js @@ -1,45 +1,32 @@ const http = require('../../../http') module.exports = { + namespace: 'api', state: { title: 'Button pushing machine 3000' }, reducers: { - set: function (action, state) { - return { 'api:title': action.payload } - } + set: (action, state) => ({ 'title': action.payload }) }, effects: { - good: performGoodRequest, - bad: performBadRequest + good: (action, state, send) => request('/good', send), + bad: (action, state, send) => request('/bad', send) } } -function performGoodRequest (action, state, send) { - http('/good', { json: true }, function (err, res, body) { - if (err) return send('error', { payload: 'HTTP error' }) - if (res.statusCode !== 200) { - return send('error', { payload: body.payload }) - } - if (!body) { - return send('error', { payload: 'fatal: no body received' }) - } - send('api:set', { payload: body.message }) - }) -} - -function performBadRequest (action, state, send) { - http('/bad', { json: true }, function (err, res, body) { - if (err) return send('error', { payload: 'HTTP error' }) +function request (uri, send) { + http(uri, { json: true }, function (err, res, body) { + if (err) return send('app:error', { payload: 'HTTP error' }) if (res.statusCode !== 200) { const message = (body && body.message) - ? body.message - : 'unknown server error' - return send('error', { payload: message }) + ? body.message + : 'unknown server error' + return send('app:error', { payload: message }) } if (!body) { - return send('error', { payload: 'fatal: no body received' }) + console.log('req made!') + return send('app:error', { payload: 'fatal: no body received' }) } - send('api:set', { payload: body.title }) + send('api:set', { payload: body.message || body.title }) }) } diff --git a/examples/http/models/error.js b/examples/http/models/error.js index 4bc3694..7d05ce5 100644 --- a/examples/http/models/error.js +++ b/examples/http/models/error.js @@ -8,6 +8,7 @@ const ERROR_TIMEOUT = 1000 module.exports = { + namespace: 'app', state: { error: [], errorTimeDone: null, @@ -33,9 +34,9 @@ module.exports = { }, effects: { error: function (action, state, send) { - const timeout = state.errorTimeDone - Date.now() + const timeout = state.app.errorTimeDone - Date.now() setTimeout(function () { - send('error:delete') + send('app:error:delete') }, timeout) } } diff --git a/examples/http/views/main.js b/examples/http/views/main.js index 22c5414..4d0d435 100644 --- a/examples/http/views/main.js +++ b/examples/http/views/main.js @@ -1,10 +1,12 @@ const choo = require('../../../') module.exports = function (params, state, send) { + const error = state.app.error[0] + const title = state.api.title return choo.view`
-

${state['api:title']}

-

Latest error: ${state.error[0]}

+

${title}

+

Latest error: ${error}

diff --git a/index.js b/index.js index aad8e4e..3e012dd 100644 --- a/index.js +++ b/index.js @@ -3,13 +3,14 @@ const sheetRouter = require('sheet-router') const document = require('global/document') const href = require('sheet-router/href') const sendAction = require('send-action') +const assert = require('assert') const xtend = require('xtend') const yo = require('yo-yo') choo.view = yo module.exports = choo -// A framework for creating sturdy web applications +// framework for creating sturdy web applications // null -> fn function choo () { const _models = [] @@ -23,6 +24,7 @@ function choo () { return start // render the application to a string + // (str, obj) -> str function toString (route, state) { const initialState = {} @@ -53,61 +55,79 @@ function choo () { if (model.effects) apply(model.namespace, model.effects, effects) }) + // send() is used to trigger actions inside + // views, effects and subscriptions const send = sendAction({ onaction: handleAction, onchange: onchange, state: initialState }) + // subscriptions are loaded after sendAction() is called + // because they both need access to send() and can't + // react to actions (read-only) _models.forEach(function (model) { if (model.subscriptions) { + assert.ok(Array.isArray(model.subscriptions, 'subs must be an array')) model.subscriptions.forEach(function (sub) { sub(send) }) } }) + // the rootId is determined to find the application root + // on update. Since the DOM nodes change between updates, + // we must call document.querySelector() to find the root. + // Use different names when loading multiple choo applications + // on the same page const rootId = name + '-root' const tree = _router(send.state().app.location, send.state(), send) tree.setAttribute('id', rootId) return tree + // handle an action by either reducers, effects + // or both - return the new state when done + // (obj, obj, fn) -> obj function handleAction (action, state, send) { - var _reducers = false - var _effects = false - var newState = null + var reducersCalled = false + var effectsCalled = false + // validate if a namespace exists. Namespaces + // are delimited by the first ':'. Perhaps + // we'll allow recursive namespaces in the + // future - who knows if (/:/.test(action.type)) { const arr = action.type.split(':') - var ns = arr[0] - action.type = arr[1] + var ns = arr.shift() + action.type = arr.join(':') } - const nsReducers = ns ? reducers[ns] : reducers - if (nsReducers && nsReducers[action.type]) { + const _reducers = ns ? reducers[ns] : reducers + if (_reducers && _reducers[action.type]) { if (ns) { - state[ns] = reducers[ns][action.type](action, state[ns]) - newState = state + const newState = reducers[ns][action.type](action, state[ns]) + state[ns] = xtend(state[ns], newState) } else { - newState = xtend(state, reducers[action.type](action, state)) + state = xtend(state, reducers[action.type](action, state)) } - _reducers = true + reducersCalled = true } - const nsEffects = ns ? effects[ns] : effects - if (nsEffects && nsEffects[action.type]) { - nsEffects[action.type](action, newState || state) - _effects = true + const _effects = ns ? effects[ns] : effects + if (_effects && _effects[action.type]) { + _effects[action.type](action, state, send) + effectsCalled = true } - if (!_reducers && !_effects) { + if (!reducersCalled && !effectsCalled) { throw new Error('Could not find action ' + action.type) } - return newState || state + return state } - // update on every change + // update the DOM after every state mutation + // (obj, obj) -> null function onchange (action, state) { const oldTree = document.querySelector('#' + rootId) const newTree = _router(state.app.location, state, send) @@ -116,7 +136,7 @@ function choo () { } } - // register all routes + // register all routes on the router // [obj|fn] -> null function router (cb) { _router = sheetRouter(cb) @@ -136,42 +156,45 @@ function appInit (opts) { const model = { namespace: 'app', state: { location: document.location.href }, - reducers: { location: setLocation }, - subscriptions: [] + subscriptions: [], + reducers: { + // handle href links + location: function setLocation (action, state) { + return { + location: action.location.replace(/#.*/, '') + } + } + } } - if (opts.href !== false) { - model.subscriptions.push(function (send) { - href(function (href) { - send('app:location', { location: href }) - }) - }) - } - - if (opts.history !== false) { - model.subscriptions.push(function (send) { - history(function (href) { - send('app:location', { location: href }) - }) - }) - } + // enable catching links + // enable HTML5 history API + if (opts.history !== false) pushLocationSub(href) + if (opts.history !== false) pushLocationSub(history) return model - // handle href links - function setLocation (action, state) { - return { location: action.location.replace(/#.*/, '') } + // create a new subscription that modifies + // 'app:location' and push it to the subs + // fn -> null + function pushLocationSub (cb) { + model.subscriptions.push(function (send) { + cb(function (href) { + send('app:location', { location: href }) + }) + }) } } // compose an object conditionally +// optionally contains a namespace +// which is used to nest properties. // (str, obj, obj) -> null -function apply (name, source, target) { +function apply (ns, source, target) { Object.keys(source).forEach(function (key) { - if (name) { - if (!target[name]) target[name] = {} - target[name][key] = source[key] - target[name][key].namespace = name + if (ns) { + if (!target[ns]) target[ns] = {} + target[ns][key] = source[key] } else target[key] = source[key] }) }