Merge pull request #8 from yoshuawuyts/model-update
model: add namespaces
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
## `2.0.0`
|
||||
### breaking changes
|
||||
- namespaces are now enforced more strictly
|
||||
- models now only accept a single argument
|
||||
- the `namespace` key was introduced inside of models (was prior the leading
|
||||
string in models)
|
||||
- namespaced models can now only operate within themselves
|
||||
|
||||
## `1.0.0`
|
||||
- first version of choo
|
||||
@@ -13,6 +13,7 @@ productive package.
|
||||
- [Demos](#demos)
|
||||
- [Usage](#usage)
|
||||
- [Concepts](#concepts)
|
||||
- [Models](#models)
|
||||
- [Effects](#effects)
|
||||
- [HTTP](#http)
|
||||
- [Subscriptions](#subscriptions)
|
||||
@@ -23,6 +24,7 @@ productive package.
|
||||
- [API](#api)
|
||||
- [FAQ](#faq)
|
||||
- [Installation](#installation)
|
||||
- [See Also](#see-also)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
@@ -51,28 +53,31 @@ productive package.
|
||||
const choo = require('choo')
|
||||
|
||||
const app = choo()
|
||||
app.model('title', {
|
||||
app.model({
|
||||
namespace: 'input',
|
||||
state: {
|
||||
title: 'my-demo-app'
|
||||
title: 'my demo app'
|
||||
},
|
||||
reducers: {
|
||||
'update': (action, state) => ({ title: action.payload })
|
||||
update: (action, state) => ({ title: action.payload })
|
||||
},
|
||||
effects: {
|
||||
'update': (action, state, send) => (document.title = action.payload)
|
||||
update: (action, state, send) => (document.title = action.payload)
|
||||
}
|
||||
})
|
||||
|
||||
const mainView = (params, state, send) => choo.view`
|
||||
const mainView = (params, state, send) => {
|
||||
return choo.view`
|
||||
<main class="app">
|
||||
<h1>${state.title}</h1>
|
||||
<h1>${state.input.title}</h1>
|
||||
<label>Set the title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${state.title}
|
||||
oninput=${(e) => send('title:update', { payload: e.target.value })}>
|
||||
placeholder=${state.input.title}
|
||||
oninput=${(e) => send('input:update', { payload: e.target.value })}>
|
||||
</main>
|
||||
`
|
||||
}
|
||||
|
||||
app.router((route) => [
|
||||
route('/', mainView)
|
||||
@@ -88,8 +93,8 @@ document.body.appendChild(tree)
|
||||
your browser
|
||||
- __actions:__ a named event with optional properties attached. Used to call
|
||||
`effects` and `reducers` that have been registered in `models`
|
||||
- __model:__ optionally namespaced object containing `subscriptions`, `effects`
|
||||
and `reducers`
|
||||
- __model:__ optionally namespaced object containing `subscriptions`,
|
||||
`effects`, `reducers` and initial `state`
|
||||
- __subscriptions:__ read-only data sources that emit `actions`
|
||||
- __effects:__ asynchronous functions that emit an `action` when done
|
||||
- __reducers:__ synchronous functions that modify `state`
|
||||
@@ -116,6 +121,41 @@ document.body.appendChild(tree)
|
||||
└────────┘ └────────┘
|
||||
```
|
||||
|
||||
## Models
|
||||
`models` are objects that contain initial `state`, `subscriptions`, `effects`
|
||||
and `reducers`. They're generally grouped around a theme (or domain, if you
|
||||
like). To provide some sturdiness to your `models`, they can either be
|
||||
namespaced or not. Namespacing means that only state within the model can be
|
||||
accessed. Models can still trigger actions on other models, though it's
|
||||
recommeded to keep that to a minimum.
|
||||
|
||||
So say we have a `todos` namespace, an `add` reducer and a `todos` model.
|
||||
Outside the model they're called by `send('todos:add')` and
|
||||
`state.todos.todos`. Inside the namespaced model they're called by
|
||||
`send('todos:add')` and `state.todos`. An example namespaced model:
|
||||
```js
|
||||
const app = choo()
|
||||
app.model({
|
||||
namespace: 'todos',
|
||||
model: { todos: [] },
|
||||
reducers: {
|
||||
add: (state, action) => ({ todos: state.todos.concat(action.payload) })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
In most cases using namespaces is beneficial, as having clear boundries makes
|
||||
it easier to follow logic. But sometimes you need to call `actions` that
|
||||
operate over multiple domains (such as a "logout" `action`), or have a
|
||||
`subscription` that might trigger multiple `reducers` (such as a `websocket`
|
||||
that calls a different `action` based on the incoming data).
|
||||
|
||||
In these cases you probably want to have a `model` that doesn't use namespaces,
|
||||
and has access to the full application state. Try and keep the logic in these
|
||||
`models` to a minimum, and declare as few `reducers` as possible. That way the
|
||||
bulk of your logic will safely shielded, with only a few points touching every
|
||||
part of your application.
|
||||
|
||||
## Effects
|
||||
Side effects are done through `effects` declared in `app.model()`. Unlike
|
||||
`reducers` they cannot modify the state by returning objects, but get a
|
||||
@@ -136,25 +176,49 @@ A typical `effect` flow looks like:
|
||||
that weighs only `2.4kb`:
|
||||
```js
|
||||
const http = require('choo/http')
|
||||
const choo = require('choo')
|
||||
const app = choo()
|
||||
|
||||
// GET JSON
|
||||
http.get('/my-endpoint', { json: true }, function (err, res, body) {
|
||||
if (err) throw err
|
||||
if (res.statusCode !== 200 || !body) throw new Error('something went wrong')
|
||||
app.model({
|
||||
effects: {
|
||||
'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
|
||||
'app:print': (state, event) => console.log(`http: ${event.payload}`),
|
||||
'http:get_json': getJson,
|
||||
'http:post_json': postJson,
|
||||
'http:delete': httpDelete
|
||||
}
|
||||
})
|
||||
|
||||
// POST JSON
|
||||
function getJson (state, action, send) {
|
||||
http.get('/my-endpoint', { json: true }, function (err, res, body) {
|
||||
if (err) return send('app:error', { payload: err.message })
|
||||
if (res.statusCode !== 200 || !body) {
|
||||
return send('app:error', { payload:'something went wrong' })
|
||||
}
|
||||
send('app:print', { payload: body })
|
||||
})
|
||||
}
|
||||
|
||||
function postJson (state, action, send) {
|
||||
const body = { foo: 'bar' }
|
||||
http.post('/my-endpoint', { json: body }, function (err, res, body) {
|
||||
if (err) throw err
|
||||
if (res.statusCode !== 200 || !body) throw new Error('something went wrong')
|
||||
if (err) return send('app:error', { payload: err.message })
|
||||
if (res.statusCode !== 200 || !body) {
|
||||
return send('app:error', { payload:'something went wrong' })
|
||||
}
|
||||
send('app:print', { payload: body })
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE
|
||||
http.del('/my-endpoint', function (err, res) {
|
||||
if (err) throw err
|
||||
if (res.statusCode !== 200) throw new Error('something went wrong')
|
||||
function httpDelete (state, action, send) {
|
||||
const body = { foo: 'bar' }
|
||||
http.post('/my-endpoint', { json: body }, function (err, res, body) {
|
||||
if (err) return send('app:error', { payload: err.message })
|
||||
if (res.statusCode !== 200) {
|
||||
return send('app:error', { payload:'something went wrong' })
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
Note that `http` only runs in the browser to prevent accidental requests when
|
||||
rendering in Node. For more details view the [`raynos/xhr`
|
||||
@@ -165,6 +229,19 @@ Subscriptions are a way of receiving data from a source. For example when
|
||||
listening for events from a server using `SSE` or `Websockets` for a
|
||||
chat app, or when catching keyboard input for a videogame.
|
||||
|
||||
An example subscription that logs `"dog?"` every second:
|
||||
```js
|
||||
const app = choo()
|
||||
choo.model({
|
||||
subscriptions: [
|
||||
(send) => setTimeout(() => send('app:print', { payload: 'dog?' }), 1000)
|
||||
],
|
||||
effects: {
|
||||
'app:print': (state, action) => console.log(action.payload)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Server Sent Events (SSE)
|
||||
[Server Sent Events (SSE)][sse] allow servers to push data to the browser.
|
||||
They're the unidirectional cousin of `websockets` and compliment `HTTP`
|
||||
@@ -176,17 +253,20 @@ const stream = new document.EventSource('/sse')
|
||||
app.model({
|
||||
subscriptions: [
|
||||
function (send) {
|
||||
stream.onerror = (e) => send('error', { payload: JSON.stringify(e) })
|
||||
stream.onmessage = (e) => send('print', { payload: e.data })
|
||||
stream.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
|
||||
stream.onmessage = (e) => send('app:print', { payload: e.data })
|
||||
}
|
||||
],
|
||||
effects: {
|
||||
'sse:close': () => stream.close()
|
||||
error: (state, event_ => console.error(`error: ${event.payload}`)),
|
||||
print: (state, event) => console.log(`pressed key num: ${event.payload}`)
|
||||
'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
|
||||
'app:print': (state, event) => console.log(`sse: ${event.payload}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
This code does not handle reconnects, server timeouts, exponential backoff and
|
||||
queueing data. You might want to use a package from `npm` or [write your
|
||||
own][sse-reconnect] if you're building something for production.
|
||||
|
||||
### Keyboard
|
||||
Most browsers have [basic support for keyboard events][keyboard-support]. To
|
||||
@@ -195,11 +275,11 @@ capture keyboard events, setup a `subscription`:
|
||||
app.model({
|
||||
subscriptions: [
|
||||
function (send) {
|
||||
keyboard.onkeypress = (e) => send('print', { payload: e.keyCode })
|
||||
keyboard.onkeypress = (e) => send('app:print', { payload: e.keyCode })
|
||||
}
|
||||
],
|
||||
effects: {
|
||||
print: (state, event) => console.log(`pressed key num: ${event.payload}`)
|
||||
'app:print': (state, event) => console.log(`pressed key: ${event.payload}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -213,18 +293,21 @@ const socket = new document.WebSocket('ws://localhost:8081')
|
||||
app.model({
|
||||
subscriptions: [
|
||||
function (send) {
|
||||
socket.onerror = (e) => send('error', { payload: JSON.stringify(e) })
|
||||
socket.onmessage = (e) => send('print', { payload: e.data })
|
||||
socket.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
|
||||
socket.onmessage = (e) => send('app:print', { payload: e.data })
|
||||
}
|
||||
],
|
||||
effects: {
|
||||
'ws:close': () => socket.close(),
|
||||
'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)),
|
||||
error: (state, event_ => console.error(`error: ${event.payload}`)),
|
||||
print: (state, event) => console.log(`pressed key num: ${event.payload}`)
|
||||
'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
|
||||
'app:print': (state, event) => console.log(`ws: ${event.payload}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
This code does not handle reconnects, server timeouts, exponential backoff and
|
||||
queueing data. You might want to use a package from `npm` or [write your
|
||||
own][ws-reconnect] if you're building something for production.
|
||||
|
||||
## Rendering in Node
|
||||
Sometimes it's necessary to render code inside of Node; for serving first
|
||||
@@ -273,9 +356,12 @@ else document.body.appendChild(app.start())
|
||||
### app = choo()
|
||||
Create a new `choo` app
|
||||
|
||||
### app.model(name?, obj)
|
||||
Create a new model. Models modify data and perform IO. Obj takes the following
|
||||
### app.model(obj)
|
||||
Create a new model. Models modify data and perform IO. Takes the following
|
||||
arguments:
|
||||
- __namespace:__ optional namespace that prefixes the keys in `state`,
|
||||
`reducers` and `effects`. Also limits `actions` called by `send()` to
|
||||
in-namespace only.
|
||||
- __state:__ object. Key value store of initial values
|
||||
- __reducers:__ object. Syncronous functions that modify state. Each function
|
||||
has a signature of `(action, state)`
|
||||
@@ -283,10 +369,6 @@ arguments:
|
||||
a signature of `(action, state, send)` where `send` is a reference to
|
||||
`app.send()`
|
||||
|
||||
If a `name` string is passed as a first argument, `reducers` and `signatures`
|
||||
will be prefixed by the name. So if name is "user" and a reducer called
|
||||
"update" is registered, it would be accessed as `'user:update'` in `send()`.
|
||||
|
||||
### choo.view\`html\`
|
||||
Tagged template string HTML builder. See
|
||||
[`yo-yo`](https://github.com/maxogden/yo-yo) for full documentation. Views
|
||||
@@ -316,6 +398,32 @@ Start the application. Returns a tree of DOM nodes that can be mounted using
|
||||
accordingly.
|
||||
|
||||
## FAQ
|
||||
### Why did you build this?
|
||||
`choo` is nothing but a formalization of how I've been building my applications
|
||||
for the past year. I originally used `virtual-dom` with `virtual-app` and
|
||||
`wayfarer` where now it's `yo-yo` with `send-action` and `sheet-router`. The
|
||||
main benefit of using `choo` over these technologies separately is that it
|
||||
becomes easier for teams to pick up and gather around. The code base for `choo`
|
||||
itself is super petite (`~150` LOC) and mostly acts to enforce structure around
|
||||
some excellent npm packages. This is my take on modular frameworks; I hope
|
||||
you'll find it pleasant.
|
||||
|
||||
### Why is it called choo?
|
||||
Because I thought it sounded cute. All these programs talk about being
|
||||
"performant", "rigid", "robust" - I like programming to be light, fun and
|
||||
non-scary. `choo` embraces that.
|
||||
|
||||
Also imagine telling some business people you chose to rewrite something
|
||||
critical to the company using the `choo` framework.
|
||||
:steam_locomotive::train::train::train:
|
||||
|
||||
### Why is it a framework, and not a library?
|
||||
I love small libraries that do one thing well, but when working in a team,
|
||||
having an undocumented combination of packages often isn't great. `choo()` is a
|
||||
small set of packages that work well together, wrapped in an an architectural
|
||||
pattern. This means you get all the benefits of small packages, but get to be
|
||||
productive right from the start.
|
||||
|
||||
### How does choo compare to X?
|
||||
Ah, so this is where I get to rant. `choo` (_chugga-chugga-chugga-choo-choo!_)
|
||||
was built because other options didn't quite cut it for me, so instead of
|
||||
@@ -382,6 +490,10 @@ transforms:
|
||||
- [uglifyify](https://github.com/hughsk/uglifyify) - minify your code using
|
||||
UglifyJS2. Use as a `--global` transform
|
||||
|
||||
## Hey, doesn't this look a lot like Elm?
|
||||
Yup, it's greatly inspired by the `elm` architecture. But contrary to `elm`,
|
||||
`choo` doesn't introduce a completely new language to build web applications.
|
||||
|
||||
### Is it production ready?
|
||||
Sure.
|
||||
|
||||
@@ -390,6 +502,11 @@ Sure.
|
||||
$ npm install choo
|
||||
```
|
||||
|
||||
## See Also
|
||||
- [budo](https://github.com/mattdesl/budo) - quick prototyping tool for
|
||||
`browserify`
|
||||
- [stack.gl](http://stack.gl/) - open software ecosystem for WebGL
|
||||
|
||||
## License
|
||||
[MIT](https://tldrlegal.com/license/mit-license)
|
||||
|
||||
@@ -416,3 +533,5 @@ $ npm install choo
|
||||
[morphdom]: https://github.com/patrick-steele-idem/morphdom
|
||||
[morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks
|
||||
[module-parent]: https://nodejs.org/dist/latest-v6.x/docs/api/modules.html#modules_module_parent
|
||||
[sse-reconnect]: http://stackoverflow.com/questions/24564030/is-an-eventsource-sse-supposed-to-try-to-reconnect-indefinitely
|
||||
[ws-reconnect]: http://stackoverflow.com/questions/13797262/how-to-reconnect-to-websocket-after-close-connection
|
||||
|
||||
@@ -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)
|
||||
|
||||
+10
-24
@@ -1,45 +1,31 @@
|
||||
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, state) {
|
||||
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 })
|
||||
return send('app:error', { payload: message })
|
||||
}
|
||||
if (!body) {
|
||||
return send('error', { payload: 'fatal: no body received' })
|
||||
return send('app:error', { payload: 'fatal: no body received' })
|
||||
}
|
||||
send('api:set', { payload: body.title })
|
||||
send('api:set', { payload: body.message || body.title })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
const ERROR_TIMEOUT = 1000
|
||||
|
||||
module.exports = {
|
||||
namespace: 'app',
|
||||
state: {
|
||||
error: [],
|
||||
errorTimeDone: null,
|
||||
@@ -35,7 +36,7 @@ module.exports = {
|
||||
error: function (action, state, send) {
|
||||
const timeout = state.errorTimeDone - Date.now()
|
||||
setTimeout(function () {
|
||||
send('error:delete')
|
||||
send('app:error:delete')
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const http = require('http')
|
||||
const PORT = 8080
|
||||
|
||||
const server = http.createServer(createRouter())
|
||||
server.listen(PORT, () => console.log(`listening on port ${PORT}`))
|
||||
server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`))
|
||||
|
||||
var index = 0
|
||||
const errors = [
|
||||
|
||||
@@ -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`
|
||||
<section>
|
||||
<h1>${state['api:title']}</h1>
|
||||
<h2>Latest error: ${state.error[0]}</h2>
|
||||
<h1>${title}</h1>
|
||||
<h2>Latest error: ${error}</h2>
|
||||
<button onclick=${(e) => send('api:good')}>OK!</button>
|
||||
<button onclick=${(e) => send('api:bad')}>Naughty</button>
|
||||
</section>
|
||||
|
||||
@@ -6,9 +6,9 @@ sf('tachyons')
|
||||
|
||||
const app = choo()
|
||||
|
||||
app.model('inbox', require('./models/inbox'))
|
||||
app.model('spam', require('./models/spam'))
|
||||
app.model('sent', require('./models/sent'))
|
||||
app.model(require('./models/inbox'))
|
||||
app.model(require('./models/spam'))
|
||||
app.model(require('./models/sent'))
|
||||
|
||||
app.router((route) => [
|
||||
route('/', require('./views/empty')),
|
||||
|
||||
@@ -3,7 +3,7 @@ const choo = require('../../../')
|
||||
|
||||
module.exports = function (params, state, send) {
|
||||
const mailbox = params.mailbox
|
||||
const messages = state[mailbox + ':messages']
|
||||
const messages = state[mailbox].messages
|
||||
return choo.view`
|
||||
<div>
|
||||
<div class="db cf w-100">
|
||||
|
||||
@@ -4,7 +4,7 @@ module.exports = function (params, state, send) {
|
||||
const mailbox = params.mailbox
|
||||
const message = params.message
|
||||
|
||||
const email = state[mailbox + ':messages'].filter(function (msg) {
|
||||
const email = state[mailbox].messages.filter(function (msg) {
|
||||
return String(msg.id) === message
|
||||
})[0]
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ module.exports = function () {
|
||||
return function (params, state, send) {
|
||||
const mailbox = params.mailbox
|
||||
const message = params.message
|
||||
const messages = state[mailbox].messages
|
||||
|
||||
if (message) {
|
||||
const email = state[mailbox + ':messages'].filter(function (msg) {
|
||||
const email = state[mailbox].messages.filter(function (msg) {
|
||||
return String(msg.id) === message
|
||||
})[0]
|
||||
|
||||
@@ -15,7 +16,7 @@ module.exports = function () {
|
||||
<section class="fl mt4 w-80 db">
|
||||
<div>
|
||||
${createHeader()}
|
||||
${state[mailbox + ':messages'].map(function (msg) {
|
||||
${messages.map(function (msg) {
|
||||
return createMessage(msg, mailbox)
|
||||
})}
|
||||
</div>
|
||||
@@ -28,7 +29,7 @@ module.exports = function () {
|
||||
return choo.view`
|
||||
<section class="fl mt4 w-80 db">
|
||||
${createHeader()}
|
||||
${state[mailbox + ':messages'].map(function (msg) {
|
||||
${messages.map(function (msg) {
|
||||
return createMessage(msg, mailbox)
|
||||
})}
|
||||
</section>
|
||||
|
||||
@@ -3,16 +3,15 @@ const choo = require('../../../')
|
||||
const mailboxes = [ 'inbox', 'spam', 'sent' ]
|
||||
|
||||
module.exports = function (params, state, send) {
|
||||
const mailbox = params.mailbox
|
||||
|
||||
return choo.view`
|
||||
<aside class="fl mt4 w-20 db">
|
||||
<ul>
|
||||
<li>
|
||||
<h2 class="f4 b lh0">Mailbox</h2>
|
||||
</li>
|
||||
${mailboxes.map(function (name) {
|
||||
return createLi(name, state[name + ':messages'], mailbox)
|
||||
${mailboxes.map(function (mailbox) {
|
||||
const messages = mailbox.messages
|
||||
return createLi(mailbox, messages, mailbox)
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
@@ -2,9 +2,10 @@ const pathname = require('pathname-match')
|
||||
const choo = require('../../../')
|
||||
|
||||
module.exports = function (params, state, send) {
|
||||
const location = state.app.location
|
||||
return choo.view`
|
||||
<span class="fl mt4 w-100 f4 b">
|
||||
URL: ${pathname(state.location) || '/'}
|
||||
URL: ${pathname(location) || '/'}
|
||||
</span>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = {
|
||||
namespace: 'inbox',
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = {
|
||||
namespace: 'sent',
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = {
|
||||
namespace: 'spam',
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ const http = require('http')
|
||||
const PORT = 8080
|
||||
|
||||
const server = http.createServer(createRouter())
|
||||
server.listen(PORT, () => console.log(`listening on port ${PORT}`))
|
||||
server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`))
|
||||
|
||||
function createRouter () {
|
||||
const router = serverRouter('/404')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const serverRouter = require('server-router')
|
||||
const http = require('http')
|
||||
|
||||
const PORT = 8080
|
||||
const client = require('./client')
|
||||
|
||||
// If an incoming request accepts "text/html", render the
|
||||
// appropriate HTML. Else use the API server
|
||||
const apiRouter = createRouter()
|
||||
const server = http.createServer(function (req, res) {
|
||||
if (/text\/html/.test(req.headers.accept)) handleHtml(req, res)
|
||||
else apiRouter(req, res)
|
||||
})
|
||||
server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`))
|
||||
|
||||
// create a new router
|
||||
// null -> fn
|
||||
function createRouter () {
|
||||
const apiRouter = serverRouter('/404')
|
||||
apiRouter.on('/404', (req, res) => res.end('not found'))
|
||||
apiRouter.on('/', (req, res) => {
|
||||
res.end(JSON.stringify({ routes: [ '/', '/404' ] }))
|
||||
})
|
||||
return apiRouter
|
||||
}
|
||||
|
||||
// render the client to string
|
||||
// based on the requested url
|
||||
// (obj, obj) -> null
|
||||
function handleHtml (req, res) {
|
||||
const html = client.toString(req.url, { message: 'hello server!' })
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(html)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
const serverRouter = require('server-router')
|
||||
const http = require('http')
|
||||
|
||||
const PORT = 8080
|
||||
|
||||
const client = require('./client')
|
||||
|
||||
const server = http.createServer(createRouter())
|
||||
server.listen(PORT, () => console.log(`listening on port ${PORT}`))
|
||||
|
||||
function createRouter () {
|
||||
const router = serverRouter('/404')
|
||||
|
||||
router.on('/404', (req, res) => res.end('not found'))
|
||||
router.on('/', function (req, res, params) {
|
||||
const html = client.toString('/', { message: 'hello server!' })
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(html)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
const choo = require('../')
|
||||
const choo = require('../../')
|
||||
|
||||
const app = choo()
|
||||
app.model('title', {
|
||||
state: { title: 'my-demo-app' },
|
||||
app.model({
|
||||
namespace: 'input',
|
||||
state: {
|
||||
title: 'my demo app'
|
||||
},
|
||||
reducers: {
|
||||
'update': (action, state) => ({ title: action.payload })
|
||||
update: (action, state) => ({ title: action.payload })
|
||||
},
|
||||
effects: {
|
||||
'update': (action, state, send) => (document.title = action.payload)
|
||||
update: (action, state, send) => (document.title = action.payload)
|
||||
}
|
||||
})
|
||||
|
||||
const mainView = (params, state, send) => choo.view`
|
||||
const mainView = (params, state, send) => {
|
||||
return choo.view`
|
||||
<main class="app">
|
||||
<h1>${state.title}</h1>
|
||||
<h1>${state.input.title}</h1>
|
||||
<label>Set the title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${state.title}
|
||||
oninput=${(e) => send('title:update', { payload: e.target.value })}>
|
||||
placeholder=${state.input.title}
|
||||
oninput=${(e) => send('input:update', { payload: e.target.value })}>
|
||||
</main>
|
||||
`
|
||||
}
|
||||
|
||||
app.router((route) => [
|
||||
route('/', mainView)
|
||||
|
||||
@@ -3,17 +3,16 @@ 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 (opts) {
|
||||
opts = opts || {}
|
||||
const name = opts.name || 'choo'
|
||||
function choo () {
|
||||
const _models = []
|
||||
var _router = null
|
||||
|
||||
@@ -25,6 +24,7 @@ function choo (opts) {
|
||||
return start
|
||||
|
||||
// render the application to a string
|
||||
// (str, obj) -> str
|
||||
function toString (route, state) {
|
||||
const initialState = {}
|
||||
|
||||
@@ -40,71 +40,102 @@ function choo (opts) {
|
||||
}
|
||||
|
||||
// start the application
|
||||
// null -> DOMNode
|
||||
function start () {
|
||||
// obj -> DOMNode
|
||||
function start (opts) {
|
||||
opts = opts || {}
|
||||
const name = opts.name || 'choo'
|
||||
const initialState = {}
|
||||
const reducers = {}
|
||||
const effects = {}
|
||||
|
||||
_models.push(appInit(opts))
|
||||
_models.forEach(function (model) {
|
||||
if (model.state) apply(model.name, model.state, initialState)
|
||||
if (model.reducers) apply(model.name, model.reducers, reducers)
|
||||
if (model.effects) apply(model.name, model.effects, effects)
|
||||
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)
|
||||
})
|
||||
|
||||
// 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().location, send.state(), send)
|
||||
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
|
||||
|
||||
if (reducers[action.type]) {
|
||||
newState = xtend(state, reducers[action.type](action, state))
|
||||
_reducers = true
|
||||
// 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(':')
|
||||
}
|
||||
|
||||
if (effects[action.type]) {
|
||||
effects[action.type](action, newState || state, send)
|
||||
newState = newState || state
|
||||
_effects = true
|
||||
const _reducers = ns ? reducers[ns] : reducers
|
||||
if (_reducers && _reducers[action.type]) {
|
||||
if (ns) {
|
||||
const newState = _reducers[action.type](action, state[ns])
|
||||
state[ns] = xtend(state[ns], newState)
|
||||
} else state = xtend(state, reducers[action.type](action, state))
|
||||
reducersCalled = true
|
||||
}
|
||||
|
||||
if (!_reducers && !_effects) {
|
||||
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)
|
||||
}
|
||||
|
||||
return newState
|
||||
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.location, state, send)
|
||||
const newTree = _router(state.app.location, state, send)
|
||||
newTree.setAttribute('id', rootId)
|
||||
yo.update(oldTree, newTree)
|
||||
}
|
||||
}
|
||||
|
||||
// register all routes
|
||||
// register all routes on the router
|
||||
// [obj|fn] -> null
|
||||
function router (cb) {
|
||||
_router = sheetRouter(cb)
|
||||
@@ -113,9 +144,7 @@ function choo (opts) {
|
||||
|
||||
// create a new model
|
||||
// (str?, obj) -> null
|
||||
function model (name, model) {
|
||||
if (!model) model = name
|
||||
if (typeof name === 'string') model.name = name
|
||||
function model (model) {
|
||||
_models.push(model)
|
||||
}
|
||||
}
|
||||
@@ -124,45 +153,47 @@ function choo (opts) {
|
||||
// obj -> obj
|
||||
function appInit (opts) {
|
||||
const model = {
|
||||
state: {
|
||||
location: document.location.href
|
||||
},
|
||||
namespace: 'app',
|
||||
state: { location: document.location.href },
|
||||
subscriptions: [],
|
||||
reducers: {
|
||||
location: setLocation
|
||||
},
|
||||
subscriptions: []
|
||||
// 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('location', { location: href })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.history !== false) {
|
||||
model.subscriptions.push(function (send) {
|
||||
history(function (href) {
|
||||
send('location', { location: href })
|
||||
})
|
||||
})
|
||||
}
|
||||
// enable catching <href a=""></href> 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) {
|
||||
const location = action.location.replace(/#.*/, '')
|
||||
return xtend(state, { location: location })
|
||||
// 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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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) target[name + ':' + key] = source[key]
|
||||
else target[key] = source[key]
|
||||
if (ns) {
|
||||
if (!target[ns]) target[ns] = {}
|
||||
target[ns][key] = source[key]
|
||||
} else target[key] = source[key]
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ const http = require('http')
|
||||
const PORT = 8080
|
||||
|
||||
const server = http.createServer(createRouter())
|
||||
server.listen(PORT, () => console.log(`listening on port ${PORT}`))
|
||||
server.listen(PORT, () => process.stdout.write(`listening on port ${PORT}\n`))
|
||||
|
||||
function createRouter () {
|
||||
const router = serverRouter('/404')
|
||||
|
||||
Reference in New Issue
Block a user