UNPKG

14.8 kBJavaScriptView Raw
1var StateState = require('./lib/state-state')
2var StateComparison = require('./lib/state-comparison')
3var CurrentState = require('./lib/current-state')
4var stateChangeLogic = require('./lib/state-change-logic')
5var parse = require('./lib/state-string-parser')
6var StateTransitionManager = require('./lib/state-transition-manager')
7var defaultRouterOptions = require('./default-router-options.js')
8
9var series = require('./lib/promise-map-series')
10var denodeify = require('then-denodeify')
11
12var EventEmitter = require('events').EventEmitter
13var extend = require('xtend')
14var newHashBrownRouter = require('hash-brown-router')
15var combine = require('combine-arrays')
16var buildPath = require('page-path-builder')
17
18require('native-promise-only/npo')
19
20var expectedPropertiesOfAddState = ['name', 'route', 'defaultChild', 'data', 'template', 'resolve', 'activate', 'querystringParameters', 'defaultQuerystringParameters', 'defaultParameters']
21
22module.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 // There are default child states that need to be applied
167
168 var theRouteWeNeedToEndUpAt = makePath(finalDestinationStateName, parameters)
169 var currentRoute = stateRouterOptions.router.location.get()
170
171 if (theRouteWeNeedToEndUpAt === currentRoute) {
172 // the child state has the same route as the current one, just start navigating there
173 emitEventAndAttemptStateChange(finalDestinationStateName, parameters)
174 } else {
175 // change the url to match the full default child state route
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) // { destroy, change, create }
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 // we don't care, the state transition manager has already emitted the stateChangeCancelled for us
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
371function 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
381function redirector(newStateName, parameters) {
382 return {
383 redirectTo: {
384 name: newStateName,
385 params: parameters
386 }
387 }
388}
389
390// { [stateName]: resolveResult }
391function 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
422function property(name) {
423 return function(obj) {
424 return obj[name]
425 }
426}
427
428function reverse(ary) {
429 return ary.slice().reverse()
430}
431
432function isFunction(property) {
433 return function(obj) {
434 return typeof obj[property] === 'function'
435 }
436}
437
438function 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}