1 | var StateState = require('./lib/state-state')
|
2 | var StateComparison = require('./lib/state-comparison')
|
3 | var CurrentState = require('./lib/current-state')
|
4 | var stateChangeLogic = require('./lib/state-change-logic')
|
5 | var parse = require('./lib/state-string-parser')
|
6 | var StateTransitionManager = require('./lib/state-transition-manager')
|
7 | var defaultRouterOptions = require('./default-router-options.js')
|
8 |
|
9 | var series = require('./lib/promise-map-series')
|
10 | var denodeify = require('then-denodeify')
|
11 |
|
12 | var EventEmitter = require('events').EventEmitter
|
13 | var extend = require('xtend')
|
14 | var newHashBrownRouter = require('hash-brown-router')
|
15 | var combine = require('combine-arrays')
|
16 | var buildPath = require('page-path-builder')
|
17 |
|
18 | require('native-promise-only/npo')
|
19 |
|
20 | var expectedPropertiesOfAddState = ['name', 'route', 'defaultChild', 'data', 'template', 'resolve', 'activate', 'querystringParameters', 'defaultQuerystringParameters', 'defaultParameters']
|
21 |
|
22 | module.exports = function StateProvider(makeRenderer, rootElement, stateRouterOptions) {
|
23 | var prototypalStateHolder = StateState()
|
24 | var lastCompletelyLoadedState = CurrentState()
|
25 | var lastStateStartedActivating = CurrentState()
|
26 | var stateProviderEmitter = new EventEmitter()
|
27 | StateTransitionManager(stateProviderEmitter)
|
28 | stateRouterOptions = extend({
|
29 | throwOnError: true,
|
30 | pathPrefix: '#'
|
31 | }, stateRouterOptions)
|
32 |
|
33 | if (!stateRouterOptions.router) {
|
34 | stateRouterOptions.router = newHashBrownRouter(defaultRouterOptions)
|
35 | }
|
36 |
|
37 | stateRouterOptions.router.setDefault(function(route, parameters) {
|
38 | stateProviderEmitter.emit('routeNotFound', route, parameters)
|
39 | })
|
40 |
|
41 | var destroyDom = null
|
42 | var getDomChild = null
|
43 | var renderDom = null
|
44 | var resetDom = null
|
45 |
|
46 | var activeDomApis = {}
|
47 | var activeStateResolveContent = {}
|
48 | var activeEmitters = {}
|
49 |
|
50 | function handleError(event, err) {
|
51 | process.nextTick(function() {
|
52 | stateProviderEmitter.emit(event, err)
|
53 | console.error(event + ' - ' + err.message)
|
54 | if (stateRouterOptions.throwOnError) {
|
55 | throw err
|
56 | }
|
57 | })
|
58 | }
|
59 |
|
60 | function destroyStateName(stateName) {
|
61 | var state = prototypalStateHolder.get(stateName)
|
62 | stateProviderEmitter.emit('beforeDestroyState', {
|
63 | state: state,
|
64 | domApi: activeDomApis[stateName]
|
65 | })
|
66 |
|
67 | activeEmitters[stateName].emit('destroy')
|
68 | activeEmitters[stateName].removeAllListeners()
|
69 | delete activeEmitters[stateName]
|
70 | delete activeStateResolveContent[stateName]
|
71 |
|
72 | return destroyDom(activeDomApis[stateName]).then(function() {
|
73 | delete activeDomApis[stateName]
|
74 | stateProviderEmitter.emit('afterDestroyState', {
|
75 | state: state
|
76 | })
|
77 | })
|
78 | }
|
79 |
|
80 | function resetStateName(parameters, stateName) {
|
81 | var domApi = activeDomApis[stateName]
|
82 | var content = getContentObject(activeStateResolveContent, stateName)
|
83 | var state = prototypalStateHolder.get(stateName)
|
84 |
|
85 | stateProviderEmitter.emit('beforeResetState', {
|
86 | domApi: domApi,
|
87 | content: content,
|
88 | state: state,
|
89 | parameters: parameters
|
90 | })
|
91 |
|
92 | activeEmitters[stateName].emit('destroy')
|
93 | delete activeEmitters[stateName]
|
94 |
|
95 | return resetDom({
|
96 | domApi: domApi,
|
97 | content: content,
|
98 | template: state.template,
|
99 | parameters: parameters
|
100 | }).then(function(newDomApi) {
|
101 | if (newDomApi) {
|
102 | activeDomApis[stateName] = newDomApi
|
103 | }
|
104 |
|
105 | stateProviderEmitter.emit('afterResetState', {
|
106 | domApi: activeDomApis[stateName],
|
107 | content: content,
|
108 | state: state,
|
109 | parameters: parameters
|
110 | })
|
111 | })
|
112 | }
|
113 |
|
114 | function getChildElementForStateName(stateName) {
|
115 | return new Promise(function(resolve) {
|
116 | var parent = prototypalStateHolder.getParent(stateName)
|
117 | if (parent) {
|
118 | var parentDomApi = activeDomApis[parent.name]
|
119 | resolve(getDomChild(parentDomApi))
|
120 | } else {
|
121 | resolve(rootElement)
|
122 | }
|
123 | })
|
124 | }
|
125 |
|
126 | function renderStateName(parameters, stateName) {
|
127 | return getChildElementForStateName(stateName).then(function(childElement) {
|
128 | var state = prototypalStateHolder.get(stateName)
|
129 | var content = getContentObject(activeStateResolveContent, stateName)
|
130 |
|
131 | stateProviderEmitter.emit('beforeCreateState', {
|
132 | state: state,
|
133 | content: content,
|
134 | parameters: parameters
|
135 | })
|
136 |
|
137 | return renderDom({
|
138 | element: childElement,
|
139 | template: state.template,
|
140 | content: content,
|
141 | parameters: parameters
|
142 | }).then(function(domApi) {
|
143 | activeDomApis[stateName] = domApi
|
144 | stateProviderEmitter.emit('afterCreateState', {
|
145 | state: state,
|
146 | domApi: domApi,
|
147 | content: content,
|
148 | parameters: parameters
|
149 | })
|
150 | return domApi
|
151 | })
|
152 | })
|
153 | }
|
154 |
|
155 | function renderAll(stateNames, parameters) {
|
156 | return series(stateNames, renderStateName.bind(null, parameters))
|
157 | }
|
158 |
|
159 | function onRouteChange(state, parameters) {
|
160 | try {
|
161 | var finalDestinationStateName = prototypalStateHolder.applyDefaultChildStates(state.name)
|
162 |
|
163 | if (finalDestinationStateName === state.name) {
|
164 | emitEventAndAttemptStateChange(finalDestinationStateName, parameters)
|
165 | } else {
|
166 |
|
167 |
|
168 | var theRouteWeNeedToEndUpAt = makePath(finalDestinationStateName, parameters)
|
169 | var currentRoute = stateRouterOptions.router.location.get()
|
170 |
|
171 | if (theRouteWeNeedToEndUpAt === currentRoute) {
|
172 |
|
173 | emitEventAndAttemptStateChange(finalDestinationStateName, parameters)
|
174 | } else {
|
175 |
|
176 | stateProviderEmitter.go(finalDestinationStateName, parameters, { replace: true })
|
177 | }
|
178 | }
|
179 | } catch (err) {
|
180 | handleError('stateError', err)
|
181 | }
|
182 | }
|
183 |
|
184 | function addState(state) {
|
185 | if (typeof state === 'undefined') {
|
186 | throw new Error('Expected \'state\' to be passed in.')
|
187 | } else if (typeof state.name === 'undefined') {
|
188 | throw new Error('Expected the \'name\' option to be passed in.')
|
189 | } else if (typeof state.template === 'undefined') {
|
190 | throw new Error('Expected the \'template\' option to be passed in.')
|
191 | }
|
192 | Object.keys(state).filter(function(key) {
|
193 | return expectedPropertiesOfAddState.indexOf(key) === -1
|
194 | }).forEach(function(key) {
|
195 | console.warn('Unexpected property passed to addState:', key)
|
196 | })
|
197 |
|
198 | prototypalStateHolder.add(state.name, state)
|
199 |
|
200 | var route = prototypalStateHolder.buildFullStateRoute(state.name)
|
201 |
|
202 | stateRouterOptions.router.add(route, onRouteChange.bind(null, state))
|
203 | }
|
204 |
|
205 | function getStatesToResolve(stateChanges) {
|
206 | return stateChanges.change.concat(stateChanges.create).map(prototypalStateHolder.get)
|
207 | }
|
208 |
|
209 | function emitEventAndAttemptStateChange(newStateName, parameters) {
|
210 | stateProviderEmitter.emit('stateChangeAttempt', function stateGo(transition) {
|
211 | attemptStateChange(newStateName, parameters, transition)
|
212 | })
|
213 | }
|
214 |
|
215 | function attemptStateChange(newStateName, parameters, transition) {
|
216 | function ifNotCancelled(fn) {
|
217 | return function() {
|
218 | if (transition.cancelled) {
|
219 | var err = new Error('The transition to ' + newStateName + 'was cancelled')
|
220 | err.wasCancelledBySomeoneElse = true
|
221 | throw err
|
222 | } else {
|
223 | return fn.apply(null, arguments)
|
224 | }
|
225 | }
|
226 | }
|
227 |
|
228 | return promiseMe(prototypalStateHolder.guaranteeAllStatesExist, newStateName)
|
229 | .then(function applyDefaultParameters() {
|
230 | var state = prototypalStateHolder.get(newStateName)
|
231 | var defaultParams = state.defaultParameters || state.defaultQuerystringParameters || {}
|
232 | var needToApplyDefaults = Object.keys(defaultParams).some(function missingParameterValue(param) {
|
233 | return typeof parameters[param] === 'undefined'
|
234 | })
|
235 |
|
236 | if (needToApplyDefaults) {
|
237 | throw redirector(newStateName, extend(defaultParams, parameters))
|
238 | }
|
239 | return state
|
240 | }).then(ifNotCancelled(function(state) {
|
241 | stateProviderEmitter.emit('stateChangeStart', state, parameters)
|
242 | lastStateStartedActivating.set(state.name, parameters)
|
243 | })).then(function getStateChanges() {
|
244 | var stateComparisonResults = StateComparison(prototypalStateHolder)(lastCompletelyLoadedState.get().name, lastCompletelyLoadedState.get().parameters, newStateName, parameters)
|
245 | return stateChangeLogic(stateComparisonResults)
|
246 | }).then(ifNotCancelled(function resolveDestroyAndActivateStates(stateChanges) {
|
247 | return resolveStates(getStatesToResolve(stateChanges), extend(parameters)).catch(function onResolveError(e) {
|
248 | e.stateChangeError = true
|
249 | throw e
|
250 | }).then(ifNotCancelled(function destroyAndActivate(stateResolveResultsObject) {
|
251 | transition.cancellable = false
|
252 |
|
253 | function activateAll() {
|
254 | var statesToActivate = stateChanges.change.concat(stateChanges.create)
|
255 |
|
256 | return activateStates(statesToActivate)
|
257 | }
|
258 |
|
259 | activeStateResolveContent = extend(activeStateResolveContent, stateResolveResultsObject)
|
260 |
|
261 | return series(reverse(stateChanges.destroy), destroyStateName).then(function() {
|
262 | return series(reverse(stateChanges.change), resetStateName.bind(null, extend(parameters)))
|
263 | }).then(function() {
|
264 | return renderAll(stateChanges.create, extend(parameters)).then(activateAll)
|
265 | })
|
266 | }))
|
267 |
|
268 | function activateStates(stateNames) {
|
269 | return stateNames.map(prototypalStateHolder.get).forEach(function(state) {
|
270 | var emitter = new EventEmitter()
|
271 | var context = Object.create(emitter)
|
272 | context.domApi = activeDomApis[state.name]
|
273 | context.data = state.data
|
274 | context.parameters = parameters
|
275 | context.content = getContentObject(activeStateResolveContent, state.name)
|
276 | activeEmitters[state.name] = emitter
|
277 |
|
278 | try {
|
279 | state.activate && state.activate(context)
|
280 | } catch (e) {
|
281 | process.nextTick(function() {
|
282 | throw e
|
283 | })
|
284 | }
|
285 | })
|
286 | }
|
287 | })).then(function stateChangeComplete() {
|
288 | lastCompletelyLoadedState.set(newStateName, parameters)
|
289 | try {
|
290 | stateProviderEmitter.emit('stateChangeEnd', prototypalStateHolder.get(newStateName), parameters)
|
291 | } catch (e) {
|
292 | handleError('stateError', e)
|
293 | }
|
294 | }).catch(ifNotCancelled(function handleStateChangeError(err) {
|
295 | if (err && err.redirectTo) {
|
296 | stateProviderEmitter.emit('stateChangeCancelled', err)
|
297 | return stateProviderEmitter.go(err.redirectTo.name, err.redirectTo.params, { replace: true })
|
298 | } else if (err) {
|
299 | handleError('stateChangeError', err)
|
300 | }
|
301 | })).catch(function handleCancellation(err) {
|
302 | if (err && err.wasCancelledBySomeoneElse) {
|
303 |
|
304 | } else {
|
305 | throw new Error("This probably shouldn't happen, maybe file an issue or something " + err)
|
306 | }
|
307 | })
|
308 | }
|
309 |
|
310 | function makePath(stateName, parameters, options) {
|
311 | function getGuaranteedPreviousState() {
|
312 | if (!lastStateStartedActivating.get().name) {
|
313 | throw new Error('makePath required a previous state to exist, and none was found')
|
314 | }
|
315 | return lastStateStartedActivating.get()
|
316 | }
|
317 | if (options && options.inherit) {
|
318 | parameters = extend(getGuaranteedPreviousState().parameters, parameters)
|
319 | }
|
320 |
|
321 | var destinationStateName = stateName === null ? getGuaranteedPreviousState().name : stateName
|
322 |
|
323 | var destinationState = prototypalStateHolder.get(destinationStateName) || {}
|
324 | var defaultParams = destinationState.defaultParameters || destinationState.defaultQuerystringParameters
|
325 |
|
326 | parameters = extend(defaultParams, parameters)
|
327 |
|
328 | prototypalStateHolder.guaranteeAllStatesExist(destinationStateName)
|
329 | var route = prototypalStateHolder.buildFullStateRoute(destinationStateName)
|
330 | return buildPath(route, parameters || {})
|
331 | }
|
332 |
|
333 | var defaultOptions = {
|
334 | replace: false
|
335 | }
|
336 |
|
337 | stateProviderEmitter.addState = addState
|
338 | stateProviderEmitter.go = function go(newStateName, parameters, options) {
|
339 | options = extend(defaultOptions, options)
|
340 | var goFunction = options.replace ? stateRouterOptions.router.replace : stateRouterOptions.router.go
|
341 |
|
342 | return promiseMe(makePath, newStateName, parameters, options).then(goFunction, handleError.bind(null, 'stateChangeError'))
|
343 | }
|
344 | stateProviderEmitter.evaluateCurrentRoute = function evaluateCurrentRoute(defaultState, defaultParams) {
|
345 | return promiseMe(makePath, defaultState, defaultParams).then(function(defaultPath) {
|
346 | stateRouterOptions.router.evaluateCurrent(defaultPath)
|
347 | }).catch(function(err) {
|
348 | handleError('stateError', err)
|
349 | })
|
350 | }
|
351 | stateProviderEmitter.makePath = function makePathAndPrependHash(stateName, parameters, options) {
|
352 | return stateRouterOptions.pathPrefix + makePath(stateName, parameters, options)
|
353 | }
|
354 | stateProviderEmitter.stateIsActive = function stateIsActive(stateName, opts) {
|
355 | var currentState = lastCompletelyLoadedState.get()
|
356 | return (currentState.name === stateName || currentState.name.indexOf(stateName + '.') === 0) && (typeof opts === 'undefined' || Object.keys(opts).every(function matches(key) {
|
357 | return opts[key] === currentState.parameters[key]
|
358 | }))
|
359 | }
|
360 |
|
361 | var renderer = makeRenderer(stateProviderEmitter)
|
362 |
|
363 | destroyDom = denodeify(renderer.destroy)
|
364 | getDomChild = denodeify(renderer.getChildElement)
|
365 | renderDom = denodeify(renderer.render)
|
366 | resetDom = denodeify(renderer.reset)
|
367 |
|
368 | return stateProviderEmitter
|
369 | }
|
370 |
|
371 | function getContentObject(stateResolveResultsObject, stateName) {
|
372 | var allPossibleResolvedStateNames = parse(stateName)
|
373 |
|
374 | return allPossibleResolvedStateNames.filter(function(stateName) {
|
375 | return stateResolveResultsObject[stateName]
|
376 | }).reduce(function(obj, stateName) {
|
377 | return extend(obj, stateResolveResultsObject[stateName])
|
378 | }, {})
|
379 | }
|
380 |
|
381 | function redirector(newStateName, parameters) {
|
382 | return {
|
383 | redirectTo: {
|
384 | name: newStateName,
|
385 | params: parameters
|
386 | }
|
387 | }
|
388 | }
|
389 |
|
390 |
|
391 | function resolveStates(states, parameters) {
|
392 | var statesWithResolveFunctions = states.filter(isFunction('resolve'))
|
393 | var stateNamesWithResolveFunctions = statesWithResolveFunctions.map(property('name'))
|
394 | var resolves = Promise.all(statesWithResolveFunctions.map(function(state) {
|
395 | return new Promise(function (resolve, reject) {
|
396 | function resolveCb(err, content) {
|
397 | err ? reject(err) : resolve(content)
|
398 | }
|
399 |
|
400 | resolveCb.redirect = function redirect(newStateName, parameters) {
|
401 | reject(redirector(newStateName, parameters))
|
402 | }
|
403 |
|
404 | var res = state.resolve(state.data, parameters, resolveCb)
|
405 | if (res && (typeof res === 'object' || typeof res === 'function') && typeof res.then === 'function') {
|
406 | resolve(res)
|
407 | }
|
408 | })
|
409 | }))
|
410 |
|
411 | return resolves.then(function(resolveResults) {
|
412 | return combine({
|
413 | stateName: stateNamesWithResolveFunctions,
|
414 | resolveResult: resolveResults
|
415 | }).reduce(function(obj, result) {
|
416 | obj[result.stateName] = result.resolveResult
|
417 | return obj
|
418 | }, {})
|
419 | })
|
420 | }
|
421 |
|
422 | function property(name) {
|
423 | return function(obj) {
|
424 | return obj[name]
|
425 | }
|
426 | }
|
427 |
|
428 | function reverse(ary) {
|
429 | return ary.slice().reverse()
|
430 | }
|
431 |
|
432 | function isFunction(property) {
|
433 | return function(obj) {
|
434 | return typeof obj[property] === 'function'
|
435 | }
|
436 | }
|
437 |
|
438 | function promiseMe() {
|
439 | var fn = Array.prototype.shift.apply(arguments)
|
440 | var args = arguments
|
441 | return new Promise(function(resolve) {
|
442 | resolve(fn.apply(null, args))
|
443 | })
|
444 | }
|