capejs/capejs

View on GitHub
lib/cape/router.js

Summary

Maintainability
C
7 hrs
Test Coverage
'use strict'

let Inflector = require('inflected')
let Cape = require('./utilities')

// Cape.Router
//
// public properties:
//   routes: array of hashes that contains routing information.
//   params: the parameters that are extracted from URL hash fragment.
//   query: the parameters that are extracted from the query part of URL hash fragment.
//   vars: an object which users can store arbitrary data to.
//   flash: an object which users can store arbitrary data to, but is erased after each
//          navigation.
//   namespace: the namespace part of URL hash fragment.
//   resource: the resource part of URL hash fragment.
//   action: the action name of current route.
//   container: the name of container of component.
//   component: the name of component.
// private properties:
//   _: the object that holds internal methods and properties of this class.
class Router {
  constructor(rootContainer) {
    this._ = new _Internal(this)
    this.rootContainer = rootContainer || window
    this.routes = []
    this.params = {}
    this.query = {}
    this.vars = {}
    this.flash = {}
    this.namespace = null
    this.resource = null
    this.action = null
    this.container = null
    this.component = null
  }

  draw(callback) {
    if (typeof callback !== 'function')
      throw new Error("The last argument must be a function.")
    if (callback.length === 0)
      throw new Error("Callback requires an argument.")

    let mapper = new global.Cape.RoutingMapper(this)
    callback(mapper)
  }

  mount(elementId) {
    this._.targetElementId = elementId
  }

  start() {
    if (window.addEventListener)
      window.addEventListener('hashchange', this._.eventListener, false)
    else if (window.attachEvent)
      window.attachEvent('onhashchange', this._.eventListener)

    this._.currentHash = window.location.href.split('#')[1] || ''
    this.navigate(this._.currentHash)
  }

  stop() {
    if (window.removeEventListener)
      window.removeEventListener('hashchange', this._.eventListener, false)
    else if (window.detachEvent)
      window.detachEvent('onhashchange', this._.eventListener)
  }

  routeFor(hash) {
    let i, len, route

    for (i = 0, len = this.routes.length; i < len; i++) {
      route = this.routes[i]
      if (hash.match(route.regexp)) return route
    }
    throw new Error("No route match. [" + hash + "]")
  }

  navigateTo(hash, params, options) {
    if (params !== undefined) {
      hash = this._.constructHash(params, hash)
    }

    this._.currentHash = hash
    this._.setHash(hash)

    options = options || {}
    this.flash.notice = options.notice
    this.flash.alert = options.alert

    if (this._.beforeNavigationCallbacks.length) {
      let promises = []
      let promise = new Promise((resolve, reject) => resolve(hash))
      promises.push(promise)

      let i, len
      for (i = 0, len = this._.beforeNavigationCallbacks.length; i < len; i++) {
        promise = promise.then(this._.beforeNavigationCallbacks[i])
        promises.push(promise)
      }
      Promise.all(promises).then((results) => {
        this._.mountComponent(results.pop())
      }, this._.errorHandler)
    }
    else {
      this._.mountComponent(hash)
    }
  }

  // Deprecated. Use navigateTo() instead.
  navigate(hash, options) {
    this.navigateTo(hash, {}, options)
  }

  redirectTo(hash, params, options) {
    // For backward compatibility, if the second argument has a key 'notice'
    // or 'alert' and the third argument is undefined, the second argument
    // should be treated as options.
    if (typeof params === 'object' && options === undefined) {
      if (params.hasOwnProperty('notice') || params.hasOwnProperty('alert')) {
        options = params
        params = undefined
      }
    }

    if (params !== undefined) {
      hash = this._.constructHash(params, hash)
    }

    this._.currentHash = hash
    this._.setHash(hash)

    options = options || {}
    this.flash.notice = options.notice
    this.flash.alert = options.alert
    this._.mountComponent(hash)
  }

  show(klass, params) {
    this.query = {}
    if (params !== undefined) {
      for (let prop in params) {
        if ({}.hasOwnProperty.call(params, prop)) {
          this.query[prop] = params[prop]
        }
      }
    }

    let component = new klass()
    component.mount(this._.targetElementId)
    this._.mountedComponentClass = klass
    this._.mountedComponent = component
  }

  attach(listener) {
    if (listener === undefined) throw new Error("Missing listener.")
    if (typeof listener.refresh !== 'function')
      throw new Error('The listener must have the "refresh" function.')

    for (let i = 0, len = this._.notificationListeners.length; i < len; i++) {
      if (this._.notificationListeners[i] === listener) return
    }
    this._.notificationListeners.push(listener)
  }

  detach(listener) {
    for (let i = 0, len = this._.notificationListeners.length; i < len; i++) {
      if (this._.notificationListeners[i] === listener) {
        this._.notificationListeners.splice(i, 1)
        break
      }
    }
  }

  beforeNavigation(callback) {
    this._.beforeNavigationCallbacks.push(callback)
  }

  errorHandler(callback) {
    this._.errorHandler = callback
  }

  notify() {
    for (let i = this._.notificationListeners.length; i--;) {
      this._.notificationListeners[i].refresh()
    }
  }
}

// Internal class of Cape.Router
class _Internal {
  constructor(main) {
    this.main = main
    this.eventListener = () => {
      let hash = window.location.href.split('#')[1] || ''
      if (hash !== this.currentHash) this.main.navigate(hash)
    }
    this.beforeNavigationCallbacks = []
    this.notificationListeners = []
    this.currentHash = null
    this.mountedComponent = null
    this.targetElementId = null
  }

  mountComponent(hash) {
    if (typeof hash !== 'string')
      throw new Error("The first argument must be a string.")

    let route = this.main.routeFor(hash)
    this.main.namespace = route.namespace
    this.main.resource = route.resource
    this.main.action = route.action
    this.main.container = route.container
    this.main.component = route.component
    this.setParams(route)
    this.setQuery(route)
    let componentClass = this.getComponentClassFor(route)

    if (componentClass === this.mountedComponentClass) {
      this.main.notify()
    }
    else {
      if (this.mountedComponent) this.mountedComponent.unmount()
      this.main.notify()
      let component = new componentClass
      component.mount(this.targetElementId)
      this.mountedComponentClass = componentClass
      this.mountedComponent = component
    }

    this.main.flash = {}
  }

  constructHash(params, hash) {
    let pairs = []
    for (let prop in params) {
      if ({}.hasOwnProperty.call(params, prop)) {
        pairs.push(prop + '=' + params[prop])
      }
    }
    if (pairs.length > 0)
      return hash + '?' + pairs.join('&')
    else
      return hash
  }

  setHash(hash) {
    window.location.hash = hash
  }

  setParams(route) {
    let md = this.currentHash.match(route.regexp)
    this.main.params = {}
    route.keys.forEach((key, i) => {
      this.main.params[key] = md[i + 1]
    })
  }

  setQuery(route) {
    this.main.query = {}
    let queryString = this.currentHash.split('?')[1]
    if (queryString === undefined) return
    let pairs = queryString.split('&')
    pairs.forEach(pair => {
      let parts = pair.split('=')
      this.main.query[parts[0]] = parts[1] || ''
    })
  }

  getComponentClassFor(route) {
    let fragments = []
    if (route.container) {
      route.container.split('.').forEach(part => {
        fragments.push(Inflector.camelize(part))
      })
    }

    let obj = this.main.rootContainer
    for (let i = 0; obj && i < fragments.length; i++) {
      if (obj[fragments[i]]) obj = obj[fragments[i]]
      else obj = null
    }

    let componentName = Inflector.camelize(route.component)
    if (obj && obj[componentName]) return obj[componentName]

    throw new Error(
      "Component class "
        + fragments.concat([componentName]).join('.')
        + " is not defined."
    )
  }
}

module.exports = Router