diff --git a/examples/http/client.js b/examples/http/client.js index c1473a5..44974b3 100644 --- a/examples/http/client.js +++ b/examples/http/client.js @@ -2,7 +2,26 @@ const choo = require('../../') const mainView = require('./views/main') -const app = choo() +const app = choo({ + onError: function (err, state, createSend) { + console.groupCollapsed(`Error: ${err.message}`) + console.error(err) + console.groupEnd() + const send = createSend('onError: ') + send('app:error', err) + }, + onAction: function (action, state, name, caller, createSend) { + console.groupCollapsed(`Action: ${caller} -> ${name}`) + console.log(action) + console.groupEnd() + }, + onState: function (action, state, prev, createSend) { + console.groupCollapsed('State') + console.log(prev) + console.log(state) + console.groupEnd() + } +}) app.model(require('./models/error')) app.model(require('./models/api')) diff --git a/examples/http/models/api.js b/examples/http/models/api.js index 65be0db..e7bd2c0 100644 --- a/examples/http/models/api.js +++ b/examples/http/models/api.js @@ -6,26 +6,26 @@ module.exports = { title: 'Button pushing machine 3000' }, reducers: { - set: (action, state) => ({ 'title': action.payload }) + set: (action, state) => ({ 'title': action.data }) }, effects: { - good: (action, state, send) => request('/good', send), - bad: (action, state, send) => request('/bad', send) + good: function (action, state, send, done) { + request('/good', send, done) + }, + bad: (action, state, send, done) => request('/bad', send, done) } } -function request (uri, send, state) { +function request (uri, send, done) { http(uri, { json: true }, function (err, res, body) { - if (err) return send('app:error', { payload: 'HTTP error' }) + if (err) return done(new Error('HTTP error')) if (res.statusCode !== 200) { const message = (body && body.message) ? body.message : 'unknown server error' - return send('app:error', { payload: message }) + return done(new Error(message)) } - if (!body) { - return send('app:error', { payload: 'fatal: no body received' }) - } - send('api:set', { payload: body.message || body.title }) + if (!body) return done(new Error('fatal: no body received')) + send('api:set', { data: body.message || body.title }, done) }) } diff --git a/examples/http/models/error.js b/examples/http/models/error.js index 632d0e5..b3e0cea 100644 --- a/examples/http/models/error.js +++ b/examples/http/models/error.js @@ -10,34 +10,43 @@ const ERROR_TIMEOUT = 1000 module.exports = { namespace: 'app', state: { - error: [], - errorTimeDone: null, + errors: [], + errorTimeDone: 0, triggerTime: null }, reducers: { - error: function (action, state) { - const now = Date.now() - const timeDone = state.errorTimeDone - const newTimestamp = (timeDone && timeDone >= now) - ? timeDone + ERROR_TIMEOUT - : now + ERROR_TIMEOUT - + setError: function (action, state) { return { - error: state.error.concat(action.payload), - errorTimeDone: newTimestamp + errors: state.errors.concat(action.message), + errorTimeDone: action.errorTimeDone } }, - 'error:delete': function (action, state) { - state.error.shift() - return { error: state.error } + 'delError': function (action, state) { + state.errors.shift() + return { errors: state.errors } } }, effects: { - error: function (action, state, send) { - const timeout = state.errorTimeDone - Date.now() - setTimeout(function () { - send('app:error:delete') - }, timeout) + error: function (err, state, send, done) { + const timeDone = state.errorTimeDone + const now = Date.now() + + const timeStamp = (timeDone && timeDone >= now) + ? timeDone + ERROR_TIMEOUT + : now + ERROR_TIMEOUT + + const timeout = timeStamp - now + + const errAction = { + message: err.message, + errorTimeDone: timeStamp + } + send('app:setError', errAction, function (err) { + if (err) return done(err) + setTimeout(function () { + send('app:delError', done) + }, timeout) + }) } } } diff --git a/examples/http/views/main.js b/examples/http/views/main.js index 683d3aa..0811c69 100644 --- a/examples/http/views/main.js +++ b/examples/http/views/main.js @@ -1,7 +1,7 @@ const html = require('../../../html') module.exports = function (params, state, send) { - const error = state.app.error[0] + const error = state.app.errors[0] const title = state.api.title return html`
diff --git a/examples/mailbox/client.js b/examples/mailbox/client.js index e71b15c..a5f118f 100644 --- a/examples/mailbox/client.js +++ b/examples/mailbox/client.js @@ -2,7 +2,6 @@ const choo = require('../../') const sf = require('sheetify') sf('css-wipe/dest/bundle') -sf('tachyons') const app = choo() diff --git a/examples/mailbox/package.json b/examples/mailbox/package.json index f29ef4d..9a1e58b 100644 --- a/examples/mailbox/package.json +++ b/examples/mailbox/package.json @@ -6,16 +6,18 @@ "scripts": { "start": "NODE_ENV=development node server.js" }, - "browserify": { - "transform": [ - "sheetify/transform" - ] - }, "author": "Yoshua Wuyts ", "license": "ISC", "dependencies": { "css-wipe": "^4.2.1", "dateformat": "^1.0.12", "tachyons": "^4.0.0-beta.33" + }, + "devDependencies": { + "bankai": "^2.0.5", + "browserify": "^13.0.1", + "insert-css": "^0.2.0", + "server-router": "^2.1.0", + "sheetify": "^5.0.3" } } diff --git a/examples/title/package.json b/examples/title/package.json new file mode 100644 index 0000000..731e7c1 --- /dev/null +++ b/examples/title/package.json @@ -0,0 +1,15 @@ +{ + "name": "title", + "version": "1.0.0", + "description": "", + "main": "client.js", + "scripts": { + "start": "budo client.js -p 8080" + }, + "keywords": [], + "author": "Yoshua Wuyts ", + "license": "ISC", + "dependencies": { + "budo": "^8.3.0" + } +} diff --git a/index.js b/index.js index 92c4cd3..1e3848f 100644 --- a/index.js +++ b/index.js @@ -4,8 +4,7 @@ const document = require('global/document') const href = require('sheet-router/href') const hash = require('sheet-router/hash') const hashMatch = require('hash-match') -const sendAction = require('send-action') -const mutate = require('xtend/mutable') +const barracks = require('barracks') const assert = require('assert') const xtend = require('xtend') const yo = require('yo-yo') @@ -14,8 +13,11 @@ module.exports = choo // framework for creating sturdy web applications // null -> fn -function choo () { - const _models = [] +function choo (opts) { + opts = opts || {} + + const _store = barracks(xtend(opts, { onState: render })) + var _rootNode = null var _router = null start.toString = toString @@ -28,153 +30,55 @@ function choo () { // render the application to a string // (str, obj) -> str function toString (route, serverState) { - const initialState = {} - const nsState = {} - - _models.forEach(function (model) { - const ns = model.namespace - if (ns) { - if (!nsState[ns]) nsState[ns] = {} - apply(ns, model.state, nsState) - nsState[ns] = xtend(nsState[ns], serverState[ns]) - } else { - apply(model.namespace, model.state, initialState) - } - }) - - const state = xtend(initialState, xtend(serverState, nsState)) + serverState = serverState || {} + assert.equal(typeof route, 'string', 'choo.app.toString: route must be a string') + assert.equal(typeof serverState, 'object', 'choo.app.toString: serverState must be an object') + _store.start({ noSubscriptions: true, noReducers: true, noEffects: true }) + const state = _store.state({ state: serverState }) const tree = _router(route, state, function () { - throw new Error('send() cannot be called on the server') + assert.fail('choo: send() cannot be called from Node') }) - return tree.toString() } // start the application // (str?, obj?) -> DOMNode - function start (rootId, opts) { - if (!opts && typeof rootId !== 'string') { - opts = rootId - rootId = null + function start (selector, startOpts) { + if (!startOpts && typeof selector !== 'string') { + startOpts = selector + selector = null } - opts = opts || {} - const name = opts.name || 'choo' - const initialState = {} - const reducers = {} - const effects = {} + startOpts = startOpts || {} - _models.push(appInit(opts)) - _models.forEach(function (model) { - if (model.state) apply(model.namespace, model.state, initialState) - if (model.reducers) apply(model.namespace, model.reducers, reducers) - if (model.effects) apply(model.namespace, model.effects, effects) - }) + _store.model(appInit(startOpts)) + const createSend = _store.start(startOpts) + const send = createSend('view', true) + const state = _store.state() - // 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) - also wait on DOM to - // be loaded - document.addEventListener('DOMContentLoaded', function () { - _models.forEach(function (model) { - if (model.subscriptions) { - assert.ok(Array.isArray(model.subscriptions), 'subs must be an arr') - model.subscriptions.forEach(function (sub) { - sub(send) - }) - } - }) - }) - - // If an id is provided, the application will rehydrate - // on the node. If no id is provided it will return - // a tree that's ready to be appended to the DOM. - // - // 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 - if (rootId) { - document.addEventListener('DOMContentLoaded', function (event) { - rootId = rootId.replace(/^#/, '') - - const oldTree = document.querySelector('#' + rootId) - assert.ok(oldTree, 'could not find node #' + rootId) - - const newTree = _router(send.state().app.location, send.state(), send) - - yo.update(oldTree, newTree) - }) - } else { - rootId = name + '-root' - const tree = _router(send.state().app.location, send.state(), send) - tree.setAttribute('id', rootId) + if (!selector) { + const tree = _router(state.app.location, state, send) + _rootNode = tree return tree + } else { + document.addEventListener('DOMContentLoaded', function (event) { + const oldTree = document.querySelector(selector) + assert.ok(oldTree, 'could not query selector: ' + selector) + const newTree = _router(state.app.location, state, send) + _rootNode = yo.update(oldTree, newTree) + }) } + } - // 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 reducersCalled = false - var effectsCalled = false - const newState = xtend(state) + // update the DOM after every state mutation + // (obj, obj, obj, str, fn) -> null + function render (action, state, prev, name, createSend) { + if (opts.onState) opts.onState(action, state, prev, name, createSend) + if (state === prev) return - // 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.shift() - action.type = arr.join(':') - } - - const _reducers = ns ? reducers[ns] : reducers - if (_reducers && _reducers[action.type]) { - if (ns) { - const reducedState = _reducers[action.type](action, state[ns]) - if (!newState[ns]) newState[ns] = {} - mutate(newState[ns], xtend(state[ns], reducedState)) - } else { - mutate(newState, reducers[action.type](action, state)) - } - reducersCalled = true - } - - const _effects = ns ? effects[ns] : effects - if (_effects && _effects[action.type]) { - if (ns) _effects[action.type](action, state[ns], send) - else _effects[action.type](action, state, send) - effectsCalled = true - } - - if (!reducersCalled && !effectsCalled) { - throw new Error('Could not find action ' + action.type) - } - - // allows (newState === oldState) checks - return (reducersCalled) ? newState : state - } - - // update the DOM after every state mutation - // (obj, obj) -> null - function onchange (action, newState, oldState) { - if (newState === oldState) return - const oldTree = document.querySelector('#' + rootId) - assert.ok(oldTree, "Could not find DOM node '#" + rootId + "' to update") - const newTree = _router(newState.app.location, newState, send, oldState) - newTree.setAttribute('id', rootId) - yo.update(oldTree, newTree) - } + // note(yw): only here till sheet-router supports custom constructors + const send = createSend('view', true) + const newTree = _router(state.app.location, state, send, prev) + _rootNode = yo.update(_rootNode, newTree) } // register all routes on the router @@ -187,68 +91,48 @@ function choo () { // create a new model // (str?, obj) -> null function model (model) { - _models.push(model) + _store.model(model) } } // initial application state model // obj -> obj function appInit (opts) { - const initialLocation = (opts.hash === true) - ? hashMatch(document.location.hash) - : document.location.href - - const model = { - namespace: 'app', - state: { location: initialLocation }, - subscriptions: [], - reducers: { - // handle href links - location: function setLocation (action, state) { - return { - location: action.location.replace(/#.*/, '') - } - } + const loc = document.location + const state = { location: (opts.hash) ? hashMatch(loc.hash) : loc.href } + const reducers = { + location: function setLocation (action, state) { + return { location: action.location.replace(/#.*/, '') } } } - // if hash routing explicitly enabled, subscribe to it + const subs = {} if (opts.hash === true) { pushLocationSub(function (navigate) { hash(function (fragment) { navigate(hashMatch(fragment)) }) - }) - // otherwise, subscribe to HTML5 history API + }, 'handleHash', subs) } else { - if (opts.history !== false) pushLocationSub(history) - // enable catching links - if (opts.href !== false) pushLocationSub(href) + if (opts.history !== false) pushLocationSub(history, 'setLocation', subs) + if (opts.href !== false) pushLocationSub(href, 'handleHref', subs) } - return model + return { + namespace: 'app', + subscriptions: subs, + reducers: reducers, + state: state + } // create a new subscription that modifies // 'app:location' and push it to be loaded - // fn -> null - function pushLocationSub (cb) { - model.subscriptions.push(function (send) { - cb(function (href) { - send('app:location', { location: href }) + // (fn, obj) -> null + function pushLocationSub (cb, key, model) { + model[key] = function (send, done) { + cb(function navigate (href) { + send('app:location', { location: href }, done) }) - }) + } } } - -// compose an object conditionally -// optionally contains a namespace -// which is used to nest properties. -// (str, obj, obj) -> null -function apply (ns, source, target) { - Object.keys(source).forEach(function (key) { - if (ns) { - if (!target[ns]) target[ns] = {} - target[ns][key] = source[key] - } else target[key] = source[key] - }) -} diff --git a/package.json b/package.json index 98b920b..dbea8fa 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ ], "license": "MIT", "dependencies": { + "barracks": "^7.0.3", "global": "^4.3.0", "hash-match": "^1.0.2", - "send-action": "^2.0.2", "sheet-router": "^3.1.0", "xhr": "^2.2.0", "xtend": "^4.0.1", diff --git a/tests/server/index.js b/tests/server/index.js index 29d8f7f..ac5417b 100644 --- a/tests/server/index.js +++ b/tests/server/index.js @@ -98,7 +98,7 @@ tape('should render on the server', function (t) { }) ]) - const msg = /send\(\) cannot be called on the server/ + const msg = /send\(\) cannot be called/ t.throws(app.toString.bind(null, '/', { message: 'nyan!' }), msg) }) })