Source

client/core/modules/router.js

/**
 * 
 * ## A simple client router
 * 
 * It also works with local files paths (file:///)
 * 
 * - Initialize the router at application startup using: router.init(setup)
 * - Trigger a new route using: kiss.router.navigateTo(newRoute)
 * - The router also observes url hash changes and automatically triggers new routes accordingly
 * 
 * When initializing the router, you can optionally define what to do:
 * - before a routing event occurs, using "beforeRouting" callback
 * - after the routing event occurred, using "afterRouting" callback
 * 
 * ```
 * // 1) Init your app router:
 * kiss.router.init({
 *  beforeRouting: async () => { await doStuff() },
 *  afterRouting: async () => { await doOtherStuff() }
 * })
 * 
 * // 2) Use it to change your application route:
 * const newApplicationRoute = {ui: "homepage", applicationId: "123", viewId: "456"}
 * kiss.router.navigateTo(newApplicationRoute)
 * 
 * // 3) Get the current application route by reading the url hash:
 * const currentApplicationRoute = kiss.router.getRoute()
 * ```
 * 
 * @namespace
 * 
 */
kiss.router = {
    /**
     * Default list of public routes which doesn't require authentication.
     * 
     * Add custom public routes using addPublicRoutes([...]) method.
     * 
     * By default, the following routes are public:
     * - authentication-login
     * - authentication-register
     * - authentication-reset-password
     * - authentication-error
     */
    publicRoutes: [
        "authentication-login",
        "authentication-register",
        "authentication-reset-password",
        "authentication-error"
    ],

    /**
     * Init the router
     * 
     * It will observe any url hash change, which will:
     * - perform a custom action before triggering the new route
     * - perform a custom action after the routing
     * 
     * @param {object} setup - The router setup, containing the 2 methods:
     * @param {string[]} [setup.publicRoutes] - Define public routes (skip login)
     * @param {function} [setup.beforeRouting] - Method to execute before routing
     * @param {function} [setup.afterRouting] - Method to execute after routing
     */
    init(setup = {}) {
        // Setup the router
        if (setup.beforeRouting) Object.assign(kiss.router.hooks, {
            beforeRouting: setup.beforeRouting
        })
        
        if (setup.afterRouting) Object.assign(kiss.router.hooks, {
            afterRouting: setup.afterRouting
        })

        if (setup.publicRoutes) kiss.router.publicRoutes = setup.publicRoutes

        // Observe hash changes
        window.onhashchange = async function () {

            // Update the application context
            const newRoute = kiss.router.getRoute()
            kiss.context.update(newRoute)

            // Do something after the hash has changed
            if (kiss.router.hooks.afterRouting) await kiss.router.hooks.afterRouting()
        }
    },

    /**
     * Set the public routes
     *  
     * @param {string[]} publicRoutes
     */
    setPublicRoutes(publicRoutes) {
        kiss.router.publicRoutes = publicRoutes
    },

    /**
     * Add some public routes
     *  
     * @param {string[]} publicRoutes
     */
    addPublicRoutes(publicRoutes) {
        kiss.router.publicRoutes = kiss.router.publicRoutes.concat(publicRoutes)
    },    

    /**
     * Check if the current route (given by the ui parameter) is public
     * 
     * @returns {boolean}
     */
    isPublicRoute() {
        const currentRoute = kiss.router.getRoute().ui
        if (!currentRoute) return false
        return kiss.router.publicRoutes.includes(currentRoute)
    },

    /**
     * Navigate to a new hash
     * It indirectly triggers the new route by dispatching the window's *hashchange* event.
     * 
     * @param {object|string} newRoute
     * @param {boolean} [reset] - Set to true to reset the previous route before routing to a new one
     * 
     * @example
     * // Using an object
     * const newRoute = {ui: "homepage", applicationId: "123", viewId: "456"}
     * kiss.router.navigateTo(newRoute)
     * 
     * // Using a string
     * kiss.router.navigateTo("home-start") // Is equivalent to: kiss.router.navigateTo({ui: "home-start"})
     */
    async navigateTo(newRoute, reset) {
        if (typeof newRoute === "string") newRoute = {
            ui: newRoute
        }
        kiss.router.updateUrlHash(newRoute, reset)

        // Perform action before routing
        // The routing can be interrupted if the method beforeRouting returns false
        // For example: checking the session...
        if (kiss.router.hooks.beforeRouting) {
            const doRoute = await kiss.router.hooks.beforeRouting(newRoute)
            if (!doRoute) return
        }

        // Propagate the hash change
        window.dispatchEvent(new HashChangeEvent("hashchange"))
    },

    /**
     * Get the current application route from the url hash.
     * 
     * For example:
     * - if current url is: http://.../...#ui=homepage&applicationId=123&viewId=456
     * - the output is: {ui: "homepage", applicationId: "123", viewId: "456"}
     * 
     * @returns {object}
     */
    getRoute() {
        return kiss.router._toRoute(window.location.hash.slice(1))
    },

    /**
     * Update URL hash according to new route params.
     * 
     * @param {object} newRoute 
     * @param {boolean} [reset] - True to reset the current hash
     * 
     * @example
     * kiss.router.updateUrlHash({chapter: 10, section: 2}, true)
     */
    updateUrlHash(newRoute, reset) {
        const currentRoute = kiss.router.getRoute()
        const toRoute = (reset) ? newRoute : Object.assign(currentRoute, newRoute)
        const newHash = "#" + kiss.router._toHash(toRoute)
        window.history.pushState(toRoute, toRoute.ui, newHash)
    },

    /**
     * Convert a url hash into an application route object.
     * 
     * For example:
     * input: http://.../...#ui=homepage&applicationId=123&viewId=456
     * output: {ui: "homepage", applicationId: "123", viewId: "456"}
     * 
     * @private
     * @ignore
     * @param {string} hash
     * 
     * @returns {object} Object containing the application route
     */
    _toRoute(hash) {
        const route = {}
        hash.split("&").forEach(param => {
            const paramName = param.split("=")[0]
            if (paramName) route[paramName] = param.split("=")[1]
        })
        return route
    },

    /**
     * Convert an application route into an url hash
     * 
     * @private
     * @ignore
     * @param {object} newRoute - The application route
     * @returns {string} An url hash
     * 
     * @example
     * kiss.router._toHash({ui: "homepage", applicationId: "123", viewId: "456"})
     * // URL hash will be: ui=homepage&applicationId=123&viewId=456
     */
    _toHash(newRoute) {
        const hash = []
        Object.keys(newRoute).forEach(key => (newRoute[key]) ? hash.push(key + "=" + newRoute[key]) : "")
        return hash.join("&")
    },

    /**
     * Hooks to modify the router behavior before and after routing
     */
    hooks: {
        /**
         * Default action performed *before* routing.
         * By default, it checks if the application required elements are properly loaded.
         * 
         * @ignore
         * @param {object} newRoute - Intended application route
         * @returns {promise} Resolve to false if the routing must be interrupted for any reason
         */
        beforeRouting: async (newRoute) => {
            // Always authorize public routes / views, if any
            if (kiss.router.publicRoutes && newRoute.ui) {
                if (kiss.router.publicRoutes.indexOf(newRoute.ui) != -1) return true
            }

            // Block routing if the app is not properly loaded
            if (kiss.app.load && !kiss.app.isLoaded) {
                const isLoaded = await kiss.app.load()
                if (!isLoaded) return false
            }

            return true
        },

        /**
         * Default action performed *after* routing.
         * By default, it checks the new application route and displays a new view according to the *ui* parameter.
         * It can display multiple views simultaneously, using multiple parameters starting with "ui".
         * 
         * @ignore
         */
        afterRouting: async () => {
            const newRoute = kiss.router.getRoute()

            // Display a new main view if there is a *ui* parameter
            // (the main view is exclusive to other views in the same container)
            if (newRoute.ui) await kiss.views.show(newRoute.ui, null, true)

            // Display other views using all parameters starting with "ui" (ui1, ui2, uiMap, uiAccount, etc...)
            // This allows, for example, to open secondary windows / popup / information messages...
            for (let route of Object.keys(newRoute)) {
                if (route.startsWith("ui") && route != "ui") await kiss.views.show(newRoute[route])
            }

            // Publish the new route
            kiss.pubsub.publish("EVT_ROUTE_UPDATED", newRoute)
        }
    }    
}

;