architecture: use barracks

Changes
=======
- add hook handlers
- add effect composition
- add noFreeze option

Commits
=======
- fixup! fix toString()
- fixup! use assert instead of throw
- fixup! fix reducers
- fixup! work on done callbacks
- fixup! more callback work
- fixup! woooh fixed effects errs
- fixup! wrap up stuff
This commit is contained in:
Yoshua Wuyts
2016-07-02 16:00:26 +02:00
parent 8f8df602c5
commit 9e039ed561
10 changed files with 146 additions and 218 deletions
+20 -1
View File
@@ -2,7 +2,26 @@ const choo = require('../../')
const mainView = require('./views/main') 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/error'))
app.model(require('./models/api')) app.model(require('./models/api'))
+10 -10
View File
@@ -6,26 +6,26 @@ module.exports = {
title: 'Button pushing machine 3000' title: 'Button pushing machine 3000'
}, },
reducers: { reducers: {
set: (action, state) => ({ 'title': action.payload }) set: (action, state) => ({ 'title': action.data })
}, },
effects: { effects: {
good: (action, state, send) => request('/good', send), good: function (action, state, send, done) {
bad: (action, state, send) => request('/bad', send) 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) { 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) { if (res.statusCode !== 200) {
const message = (body && body.message) const message = (body && body.message)
? body.message ? body.message
: 'unknown server error' : 'unknown server error'
return send('app:error', { payload: message }) return done(new Error(message))
} }
if (!body) { if (!body) return done(new Error('fatal: no body received'))
return send('app:error', { payload: 'fatal: no body received' }) send('api:set', { data: body.message || body.title }, done)
}
send('api:set', { payload: body.message || body.title })
}) })
} }
+26 -17
View File
@@ -10,34 +10,43 @@ const ERROR_TIMEOUT = 1000
module.exports = { module.exports = {
namespace: 'app', namespace: 'app',
state: { state: {
error: [], errors: [],
errorTimeDone: null, errorTimeDone: 0,
triggerTime: null triggerTime: null
}, },
reducers: { reducers: {
error: function (action, state) { setError: function (action, state) {
const now = Date.now()
const timeDone = state.errorTimeDone
const newTimestamp = (timeDone && timeDone >= now)
? timeDone + ERROR_TIMEOUT
: now + ERROR_TIMEOUT
return { return {
error: state.error.concat(action.payload), errors: state.errors.concat(action.message),
errorTimeDone: newTimestamp errorTimeDone: action.errorTimeDone
} }
}, },
'error:delete': function (action, state) { 'delError': function (action, state) {
state.error.shift() state.errors.shift()
return { error: state.error } return { errors: state.errors }
} }
}, },
effects: { effects: {
error: function (action, state, send) { error: function (err, state, send, done) {
const timeout = state.errorTimeDone - Date.now() 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 () { setTimeout(function () {
send('app:error:delete') send('app:delError', done)
}, timeout) }, timeout)
})
} }
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
const html = require('../../../html') const html = require('../../../html')
module.exports = function (params, state, send) { module.exports = function (params, state, send) {
const error = state.app.error[0] const error = state.app.errors[0]
const title = state.api.title const title = state.api.title
return html` return html`
<section> <section>
-1
View File
@@ -2,7 +2,6 @@ const choo = require('../../')
const sf = require('sheetify') const sf = require('sheetify')
sf('css-wipe/dest/bundle') sf('css-wipe/dest/bundle')
sf('tachyons')
const app = choo() const app = choo()
+7 -5
View File
@@ -6,16 +6,18 @@
"scripts": { "scripts": {
"start": "NODE_ENV=development node server.js" "start": "NODE_ENV=development node server.js"
}, },
"browserify": {
"transform": [
"sheetify/transform"
]
},
"author": "Yoshua Wuyts <i@yoshuawuyts.com>", "author": "Yoshua Wuyts <i@yoshuawuyts.com>",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"css-wipe": "^4.2.1", "css-wipe": "^4.2.1",
"dateformat": "^1.0.12", "dateformat": "^1.0.12",
"tachyons": "^4.0.0-beta.33" "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"
} }
} }
+15
View File
@@ -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 <i@yoshuawuyts.com>",
"license": "ISC",
"dependencies": {
"budo": "^8.3.0"
}
}
+59 -175
View File
@@ -4,8 +4,7 @@ const document = require('global/document')
const href = require('sheet-router/href') const href = require('sheet-router/href')
const hash = require('sheet-router/hash') const hash = require('sheet-router/hash')
const hashMatch = require('hash-match') const hashMatch = require('hash-match')
const sendAction = require('send-action') const barracks = require('barracks')
const mutate = require('xtend/mutable')
const assert = require('assert') const assert = require('assert')
const xtend = require('xtend') const xtend = require('xtend')
const yo = require('yo-yo') const yo = require('yo-yo')
@@ -14,8 +13,11 @@ module.exports = choo
// framework for creating sturdy web applications // framework for creating sturdy web applications
// null -> fn // null -> fn
function choo () { function choo (opts) {
const _models = [] opts = opts || {}
const _store = barracks(xtend(opts, { onState: render }))
var _rootNode = null
var _router = null var _router = null
start.toString = toString start.toString = toString
@@ -28,153 +30,55 @@ function choo () {
// render the application to a string // render the application to a string
// (str, obj) -> str // (str, obj) -> str
function toString (route, serverState) { function toString (route, serverState) {
const initialState = {} serverState = serverState || {}
const nsState = {} 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')
_models.forEach(function (model) { _store.start({ noSubscriptions: true, noReducers: true, noEffects: true })
const ns = model.namespace const state = _store.state({ state: serverState })
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))
const tree = _router(route, state, function () { 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() return tree.toString()
} }
// start the application // start the application
// (str?, obj?) -> DOMNode // (str?, obj?) -> DOMNode
function start (rootId, opts) { function start (selector, startOpts) {
if (!opts && typeof rootId !== 'string') { if (!startOpts && typeof selector !== 'string') {
opts = rootId startOpts = selector
rootId = null selector = null
} }
opts = opts || {} startOpts = startOpts || {}
const name = opts.name || 'choo'
const initialState = {}
const reducers = {}
const effects = {}
_models.push(appInit(opts)) _store.model(appInit(startOpts))
_models.forEach(function (model) { const createSend = _store.start(startOpts)
if (model.state) apply(model.namespace, model.state, initialState) const send = createSend('view', true)
if (model.reducers) apply(model.namespace, model.reducers, reducers) const state = _store.state()
if (model.effects) apply(model.namespace, model.effects, effects)
})
// send() is used to trigger actions inside if (!selector) {
// views, effects and subscriptions const tree = _router(state.app.location, state, send)
const send = sendAction({ _rootNode = tree
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)
return tree 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 reducersCalled = false
var effectsCalled = false
const newState = xtend(state)
// 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 { } else {
mutate(newState, reducers[action.type](action, state)) 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)
})
} }
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 // update the DOM after every state mutation
// (obj, obj) -> null // (obj, obj, obj, str, fn) -> null
function onchange (action, newState, oldState) { function render (action, state, prev, name, createSend) {
if (newState === oldState) return if (opts.onState) opts.onState(action, state, prev, name, createSend)
const oldTree = document.querySelector('#' + rootId) if (state === prev) return
assert.ok(oldTree, "Could not find DOM node '#" + rootId + "' to update")
const newTree = _router(newState.app.location, newState, send, oldState) // note(yw): only here till sheet-router supports custom constructors
newTree.setAttribute('id', rootId) const send = createSend('view', true)
yo.update(oldTree, newTree) const newTree = _router(state.app.location, state, send, prev)
} _rootNode = yo.update(_rootNode, newTree)
} }
// register all routes on the router // register all routes on the router
@@ -187,68 +91,48 @@ function choo () {
// create a new model // create a new model
// (str?, obj) -> null // (str?, obj) -> null
function model (model) { function model (model) {
_models.push(model) _store.model(model)
} }
} }
// initial application state model // initial application state model
// obj -> obj // obj -> obj
function appInit (opts) { function appInit (opts) {
const initialLocation = (opts.hash === true) const loc = document.location
? hashMatch(document.location.hash) const state = { location: (opts.hash) ? hashMatch(loc.hash) : loc.href }
: document.location.href const reducers = {
const model = {
namespace: 'app',
state: { location: initialLocation },
subscriptions: [],
reducers: {
// handle href links
location: function setLocation (action, state) { location: function setLocation (action, state) {
return { return { location: action.location.replace(/#.*/, '') }
location: action.location.replace(/#.*/, '')
} }
} }
}
}
// if hash routing explicitly enabled, subscribe to it // if hash routing explicitly enabled, subscribe to it
const subs = {}
if (opts.hash === true) { if (opts.hash === true) {
pushLocationSub(function (navigate) { pushLocationSub(function (navigate) {
hash(function (fragment) { hash(function (fragment) {
navigate(hashMatch(fragment)) navigate(hashMatch(fragment))
}) })
}) }, 'handleHash', subs)
// otherwise, subscribe to HTML5 history API
} else { } else {
if (opts.history !== false) pushLocationSub(history) if (opts.history !== false) pushLocationSub(history, 'setLocation', subs)
// enable catching <a href=""></a> links if (opts.href !== false) pushLocationSub(href, 'handleHref', subs)
if (opts.href !== false) pushLocationSub(href)
} }
return model return {
namespace: 'app',
subscriptions: subs,
reducers: reducers,
state: state
}
// create a new subscription that modifies // create a new subscription that modifies
// 'app:location' and push it to be loaded // 'app:location' and push it to be loaded
// fn -> null // (fn, obj) -> null
function pushLocationSub (cb) { function pushLocationSub (cb, key, model) {
model.subscriptions.push(function (send) { model[key] = function (send, done) {
cb(function (href) { cb(function navigate (href) {
send('app:location', { location: 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]
})
} }
+1 -1
View File
@@ -24,9 +24,9 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"barracks": "^7.0.3",
"global": "^4.3.0", "global": "^4.3.0",
"hash-match": "^1.0.2", "hash-match": "^1.0.2",
"send-action": "^2.0.2",
"sheet-router": "^3.1.0", "sheet-router": "^3.1.0",
"xhr": "^2.2.0", "xhr": "^2.2.0",
"xtend": "^4.0.1", "xtend": "^4.0.1",
+1 -1
View File
@@ -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) t.throws(app.toString.bind(null, '/', { message: 'nyan!' }), msg)
}) })
}) })