UNPKG

abstract-state-router

Version:

The basics of a client-side state router ala the AngularJS ui-router, but without any DOM interactions

429 lines (366 loc) 13.9 kB
var StateState = require('./lib/state-state') var StateComparison = require('./lib/state-comparison') var CurrentState = require('./lib/current-state') var stateChangeLogic = require('./lib/state-change-logic') var parse = require('./lib/state-string-parser') var StateTransitionManager = require('./lib/state-transition-manager') var defaultRouterOptions = require('./default-router-options.json') var series = require('./lib/promise-map-series') var denodeify = require('then-denodeify') var EventEmitter = require('events').EventEmitter var extend = require('xtend') var newHashBrownRouter = require('hash-brown-router') var combine = require('combine-arrays') var buildPath = require('page-path-builder') require('native-promise-only/npo') var expectedPropertiesOfAddState = ['name', 'route', 'defaultChild', 'data', 'template', 'resolve', 'activate', 'querystringParameters', 'defaultQuerystringParameters'] module.exports = function StateProvider(makeRenderer, rootElement, stateRouterOptions) { var prototypalStateHolder = StateState() var current = CurrentState() var stateProviderEmitter = new EventEmitter() StateTransitionManager(stateProviderEmitter) stateRouterOptions = extend({ throwOnError: true, pathPrefix: '#' }, stateRouterOptions) if (!stateRouterOptions.router) { stateRouterOptions.router = newHashBrownRouter(defaultRouterOptions) } stateRouterOptions.router.setDefault(function(route, parameters) { stateProviderEmitter.emit('routeNotFound', route, parameters) }) current.set('', {}) var destroyDom = null var getDomChild = null var renderDom = null var resetDom = null var activeDomApis = {} var activeStateResolveContent = {} var activeEmitters = {} function handleError(event, err) { process.nextTick(function() { stateProviderEmitter.emit(event, err) console.error(event + ' - ' + err.message) if (stateRouterOptions.throwOnError) { throw err } }) } function destroyStateName(stateName) { var state = prototypalStateHolder.get(stateName) stateProviderEmitter.emit('beforeDestroyState', { state: state, domApi: activeDomApis[stateName] }) activeEmitters[stateName].emit('destroy') activeEmitters[stateName].removeAllListeners() delete activeEmitters[stateName] delete activeStateResolveContent[stateName] return destroyDom(activeDomApis[stateName]).then(function() { delete activeDomApis[stateName] stateProviderEmitter.emit('afterDestroyState', { state: state }) }) } function resetStateName(parameters, stateName) { var domApi = activeDomApis[stateName] var content = getContentObject(activeStateResolveContent, stateName) var state = prototypalStateHolder.get(stateName) stateProviderEmitter.emit('beforeResetState', { domApi: domApi, content: content, state: state, parameters: parameters }) activeEmitters[stateName].emit('destroy') delete activeEmitters[stateName] return resetDom({ domApi: domApi, content: content, template: state.template, parameters: parameters }).then(function() { stateProviderEmitter.emit('afterResetState', { domApi: domApi, content: content, state: state, parameters: parameters }) }) } function getChildElementForStateName(stateName) { return new Promise(function(resolve) { var parent = prototypalStateHolder.getParent(stateName) if (parent) { var parentDomApi = activeDomApis[parent.name] resolve(getDomChild(parentDomApi)) } else { resolve(rootElement) } }) } function renderStateName(parameters, stateName) { return getChildElementForStateName(stateName).then(function(childElement) { var state = prototypalStateHolder.get(stateName) var content = getContentObject(activeStateResolveContent, stateName) stateProviderEmitter.emit('beforeCreateState', { state: state, content: content, parameters: parameters }) return renderDom({ element: childElement, template: state.template, content: content, parameters: parameters }).then(function(domApi) { activeDomApis[stateName] = domApi stateProviderEmitter.emit('afterCreateState', { state: state, domApi: domApi, content: content, parameters: parameters }) return domApi }) }) } function renderAll(stateNames, parameters) { return series(stateNames, renderStateName.bind(null, parameters)) } function onRouteChange(state, parameters) { try { var finalDestinationStateName = prototypalStateHolder.applyDefaultChildStates(state.name) if (finalDestinationStateName === state.name) { emitEventAndAttemptStateChange(finalDestinationStateName, parameters) } else { // There are default child states that need to be applied var theRouteWeNeedToEndUpAt = makePath(finalDestinationStateName, parameters) var currentRoute = stateRouterOptions.router.location.get() if (theRouteWeNeedToEndUpAt === currentRoute) { // the child state has the same route as the current one, just start navigating there emitEventAndAttemptStateChange(finalDestinationStateName, parameters) } else { // change the url to match the full default child state route stateProviderEmitter.go(finalDestinationStateName, parameters, { replace: true }) } } } catch (err) { handleError('stateError', err) } } function addState(state) { if (typeof state === 'undefined') { throw new Error('Expected \'state\' to be passed in.') } else if (typeof state.name === 'undefined') { throw new Error('Expected the \'name\' option to be passed in.') } else if (typeof state.template === 'undefined') { throw new Error('Expected the \'template\' option to be passed in.') } Object.keys(state).filter(function(key) { return expectedPropertiesOfAddState.indexOf(key) === -1 }).forEach(function(key) { console.warn('Unexpected property passed to addState:', key) }) prototypalStateHolder.add(state.name, state) var route = prototypalStateHolder.buildFullStateRoute(state.name) stateRouterOptions.router.add(route, onRouteChange.bind(null, state)) } function getStatesToResolve(stateChanges) { return stateChanges.change.concat(stateChanges.create).map(prototypalStateHolder.get) } function emitEventAndAttemptStateChange(newStateName, parameters) { stateProviderEmitter.emit('stateChangeAttempt', function stateGo(transition) { attemptStateChange(newStateName, parameters, transition) }) } function attemptStateChange(newStateName, parameters, transition) { function ifNotCancelled(fn) { return function() { if (transition.cancelled) { var err = new Error('The transition to ' + newStateName + 'was cancelled') err.wasCancelledBySomeoneElse = true throw err } else { return fn.apply(null, arguments) } } } return promiseMe(prototypalStateHolder.guaranteeAllStatesExist, newStateName) .then(function applyDefaultParameters() { var state = prototypalStateHolder.get(newStateName) var defaultParams = state.defaultQuerystringParameters || {} var needToApplyDefaults = Object.keys(defaultParams).some(function missingParameterValue(param) { return !parameters[param] }) if (needToApplyDefaults) { throw redirector(newStateName, extend(defaultParams, parameters)) } return state }).then(ifNotCancelled(function(state) { stateProviderEmitter.emit('stateChangeStart', state, parameters) })).then(function getStateChanges() { var stateComparisonResults = StateComparison(prototypalStateHolder)(current.get().name, current.get().parameters, newStateName, parameters) return stateChangeLogic(stateComparisonResults) // { destroy, change, create } }).then(ifNotCancelled(function resolveDestroyAndActivateStates(stateChanges) { return resolveStates(getStatesToResolve(stateChanges), extend(parameters)).catch(function onResolveError(e) { e.stateChangeError = true throw e }).then(ifNotCancelled(function destroyAndActivate(stateResolveResultsObject) { transition.cancellable = false function activateAll() { var statesToActivate = stateChanges.change.concat(stateChanges.create) return activateStates(statesToActivate) } activeStateResolveContent = extend(activeStateResolveContent, stateResolveResultsObject) return series(reverse(stateChanges.destroy), destroyStateName).then(function() { return series(reverse(stateChanges.change), resetStateName.bind(null, extend(parameters))) }).then(function() { return renderAll(stateChanges.create, extend(parameters)).then(activateAll) }) })) function activateStates(stateNames) { return stateNames.map(prototypalStateHolder.get).forEach(function(state) { var emitter = new EventEmitter() var context = Object.create(emitter) context.domApi = activeDomApis[state.name] context.data = state.data context.parameters = parameters context.content = getContentObject(activeStateResolveContent, state.name) activeEmitters[state.name] = emitter try { state.activate && state.activate(context) } catch (e) { process.nextTick(function() { throw e }) } }) } })).then(function stateChangeComplete() { current.set(newStateName, parameters) try { stateProviderEmitter.emit('stateChangeEnd', prototypalStateHolder.get(newStateName), parameters) } catch (e) { handleError('stateError', e) } }).catch(ifNotCancelled(function handleStateChangeError(err) { if (err && err.redirectTo) { stateProviderEmitter.emit('stateChangeCancelled', err) return stateProviderEmitter.go(err.redirectTo.name, err.redirectTo.params, { replace: true }) } else if (err) { handleError('stateChangeError', err) } })).catch(function handleCancellation(err) { if (err && err.wasCancelledBySomeoneElse) { // we don't care, the state transition manager has already emitted the stateChangeCancelled for us } else { throw new Error("This probably shouldn't happen, maybe file an issue or something " + err) } }) } function makePath(stateName, parameters, options) { if (options && options.inherit) { parameters = extend(current.get().parameters, parameters) } prototypalStateHolder.guaranteeAllStatesExist(stateName) var route = prototypalStateHolder.buildFullStateRoute(stateName) return buildPath(route, parameters || {}) } var defaultOptions = { replace: false } stateProviderEmitter.addState = addState stateProviderEmitter.go = function go(newStateName, parameters, options) { options = extend(defaultOptions, options) var goFunction = options.replace ? stateRouterOptions.router.replace : stateRouterOptions.router.go return promiseMe(makePath, newStateName, parameters, options).then(goFunction, handleError.bind(null, 'stateChangeError')) } stateProviderEmitter.evaluateCurrentRoute = function evaluateCurrentRoute(defaultState, defaultParams) { return promiseMe(makePath, defaultState, defaultParams).then(function(defaultPath) { stateRouterOptions.router.evaluateCurrent(defaultPath) }).catch(function(err) { handleError('stateError', err) }) } stateProviderEmitter.makePath = function makePathAndPrependHash(stateName, parameters, options) { return stateRouterOptions.pathPrefix + makePath(stateName, parameters, options) } stateProviderEmitter.stateIsActive = function stateIsActive(stateName, opts) { var currentState = current.get() return currentState.name.indexOf(stateName) === 0 && (typeof opts === 'undefined' || Object.keys(opts).every(function matches(key) { return opts[key] === currentState.parameters[key] })) } var renderer = makeRenderer(stateProviderEmitter) destroyDom = denodeify(renderer.destroy) getDomChild = denodeify(renderer.getChildElement) renderDom = denodeify(renderer.render) resetDom = denodeify(renderer.reset) return stateProviderEmitter } function getContentObject(stateResolveResultsObject, stateName) { var allPossibleResolvedStateNames = parse(stateName) return allPossibleResolvedStateNames.filter(function(stateName) { return stateResolveResultsObject[stateName] }).reduce(function(obj, stateName) { return extend(obj, stateResolveResultsObject[stateName]) }, {}) } function redirector(newStateName, parameters) { return { redirectTo: { name: newStateName, params: parameters } } } // { [stateName]: resolveResult } function resolveStates(states, parameters) { var statesWithResolveFunctions = states.filter(isFunction('resolve')) var stateNamesWithResolveFunctions = statesWithResolveFunctions.map(property('name')) var resolves = Promise.all(statesWithResolveFunctions.map(function(state) { return new Promise(function (resolve, reject) { function resolveCb(err, content) { err ? reject(err) : resolve(content) } resolveCb.redirect = function redirect(newStateName, parameters) { reject(redirector(newStateName, parameters)) } var res = state.resolve(state.data, parameters, resolveCb) if (res && (typeof res === 'object' || typeof res === 'function') && typeof res.then === 'function') { resolve(res) } }) })) return resolves.then(function(resolveResults) { return combine({ stateName: stateNamesWithResolveFunctions, resolveResult: resolveResults }).reduce(function(obj, result) { obj[result.stateName] = result.resolveResult return obj }, {}) }) } function property(name) { return function(obj) { return obj[name] } } function reverse(ary) { return ary.slice().reverse() } function isFunction(property) { return function(obj) { return typeof obj[property] === 'function' } } function promiseMe() { var fn = Array.prototype.shift.apply(arguments) var args = arguments return new Promise(function(resolve) { resolve(fn.apply(null, args)) }) }