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:
+20
-1
@@ -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
@@ -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 })
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
setTimeout(function () {
|
const now = Date.now()
|
||||||
send('app:error:delete')
|
|
||||||
}, timeout)
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
} 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
|
// update the DOM after every state mutation
|
||||||
// or both - return the new state when done
|
// (obj, obj, obj, str, fn) -> null
|
||||||
// (obj, obj, fn) -> obj
|
function render (action, state, prev, name, createSend) {
|
||||||
function handleAction (action, state, send) {
|
if (opts.onState) opts.onState(action, state, prev, name, createSend)
|
||||||
var reducersCalled = false
|
if (state === prev) return
|
||||||
var effectsCalled = false
|
|
||||||
const newState = xtend(state)
|
|
||||||
|
|
||||||
// validate if a namespace exists. Namespaces
|
// note(yw): only here till sheet-router supports custom constructors
|
||||||
// are delimited by the first ':'. Perhaps
|
const send = createSend('view', true)
|
||||||
// we'll allow recursive namespaces in the
|
const newTree = _router(state.app.location, state, send, prev)
|
||||||
// future - who knows
|
_rootNode = yo.update(_rootNode, newTree)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 = {
|
||||||
|
location: function setLocation (action, state) {
|
||||||
const model = {
|
return { location: action.location.replace(/#.*/, '') }
|
||||||
namespace: 'app',
|
|
||||||
state: { location: initialLocation },
|
|
||||||
subscriptions: [],
|
|
||||||
reducers: {
|
|
||||||
// handle href links
|
|
||||||
location: function setLocation (action, state) {
|
|
||||||
return {
|
|
||||||
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
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user