Source

client/core/modules/router.js

/**
 * 
 * ## A simple client router
 * 
 * The router allows to navigate between different views in a single-page application.
 * It also works with local files paths (file:///)
 * 
 * The router is based on the url hash, for example:
 * ```
 * /index.html#ui=homepage
 * ```
 * 
 * The "ui" parameter is mandatory and represents the main view to display.
 * Other parameters can be added to the url hash to manage deeper navigation:
 * ```
 * /index.html#ui=homepage&applicationId=123&viewId=456
 * ```
 * 
 * If you need to display multiple views simultaneously, you can use multiple parameters starting with "ui":
 * ```
 * /index.html#ui=homepage&ui1=map&ui2=account
 * ```
 * 
 * To use the router:
 * ```
 * kiss.router.navigateTo(newRoute)
 * ```
 * 
 * You can pass a single string if you just want to change the main view:
 * ```
 * kiss.router.navigateTo("homepage")
 * ```
 * 
 * This is equivalent to:
 * ```
 * kiss.router.navigateTo({ui: "homepage"})
 * ```
 * 
 * If you need deeper navigation, you can pass an object:
 * ```
 * kiss.router.navigateTo({ui: "homepage", applicationId: "123", viewId: "456"})
 * ```
 * 
 * The router observes url hash changes and automatically triggers new routes accordingly.
 * 
 * When initializing the router, you can optionally define public routes:
 * 
 * ```
 * // Init your app router:
 * kiss.router.init({
 *  publicRoutes: ["login", "register"]
 * })
 * 
 * // Setting public routes after initialization:
 * kiss.router.setPublicRoutes(["login", "register"])
 * 
 * // Adding routing guards, to check if a route is valid before routing:
 * kiss.router.addRoutingGuards([
 *  async function(newRoute) {
 *      return await app.api.checkViewAuthorization(newRoute)
 *  }
 * ])
 * 
 * // Adding routing actions, to perform some actions after routing:
 * kiss.router.addRoutingActions([
 *  async function() {
 *      // Do something after routing
 *  }
 * ])
 * 
 * // Navigating to a new route:
 * kiss.router.navigateTo({ui: "homepage", applicationId: "123", viewId: "456"})
 * 
 * // Get the current application route by reading the url hash:
 * const currentApplicationRoute = kiss.router.getRoute() // {ui: "homepage", applicationId: "123", viewId: "456"}
 * ```
 * 
 * @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} config - The router config, containing the 2 methods:
     * @param {string[]} [config.publicRoutes] - Define public routes (skip login)
     */
    init(config = {}) {
        // Set public routes
        if (config.publicRoutes && Array.isArray(config.publicRoutes)) {
            kiss.router.publicRoutes = config.publicRoutes
        }

        // Observe hash changes
        window.onhashchange = async function () {
            // Update the application context
            const newRoute = kiss.router.getRoute()

            // Perform verifications before routing
            const doRoute = await kiss.router._beforeRouting(newRoute)
            if (!doRoute) return
            
            kiss.context.update(newRoute)

            // Execute router actions after routing
            await kiss.router._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).unique()
    },

    /**
     * Add routing guards
     * 
     * Guards are used to check if a route is valid **before** routing.
     * Must be an array of async functions where each function must return a boolean.
     * The route is accepted if all guards return true.
     * 
     * @param {function[]} guards
     */
    addRoutingGuards(guards) {
        if (guards && Array.isArray(guards)) {
            kiss.router.beforeRoutingGuards = kiss.router.beforeRoutingGuards.concat(guards)
        }
    },

    /**
     * Add routing actions
     * 
     * Actions are used to perform some actions **after** routing.
     * Must be an array of async functions to execute sequentially.
     * 
     * @param {function[]} actions
     */
    addRoutingActions(actions) {
        if (actions && Array.isArray(actions)) {
            kiss.router.afterRoutingActions = kiss.router.afterRoutingActions.concat(actions)
        }
    },

    /**
     * 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 verifications before routing
        // The routing can be interrupted if the method beforeRouting returns false


        // const doRoute = await kiss.router._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("&")
    },

    /**
     * Perform some validations before routing.
     * 
     * Validations are defined in kiss.router.validators and can be customized at initialization.
     * Standard validation process is:
     * - check if the new route is the login page (always accepted)
     * - check if the new route is a public route (accepted if true)
     * - check if the application is properly loaded (routing accepted if true)
     * 
     * @private
     * @ignore
     * @param {object} newRoute - Intended application route
     * @returns {promise} Resolve to false if the routing must be interrupted for any reason
     */
    async _beforeRouting(newRoute) {
        // Always accept the login page
        if (kiss.router._isLoginPage(newRoute)) return true

        // Accept public routes
        if (kiss.router._isPublicRoute(newRoute)) return true

        // Check if the application is properly loaded
        const appLoaded = await kiss.router._isAppLoaded()
        if (!appLoaded) return false

        // Check if the route is authorized
        for (let guardFunction of kiss.router.beforeRoutingGuards) {
            const guardResult = await guardFunction(newRoute)
            if (!guardResult) return false
        }

        return true
    },

    /**
     * Perform some actions *after* routing.
     * 
     * Actions are defined in kiss.router.actions and can be customized at initialization.
     * 
     * @private
     * @ignore
     */
    async _afterRouting() {
        for (let actionFunction of kiss.router.afterRoutingActions) {
            await actionFunction()
        }
    },

    /**
     * Check if the new route is the login page
     * 
     * @private
     * @ignore
     * @param {object} newRoute 
     * @returns {boolean} True if the new route is the login page
     * 
     * @example
     * kiss.router.validators.isLoginPage({ui: "homepage", applicationId: "123", viewId: "456"}) // false
     * kiss.router.validators.isLoginPage({ui: "authentication-login"}) // true
     */
    _isLoginPage(newRoute) {
        if (newRoute.ui == kiss.session.defaultViews.login) return true
        return false
    },

    /**
     * Check if the new route is a public route
     * 
     * @private
     * @ignore
     * @param {object} newRoute
     * @returns {boolean} True if the new route is a public route
     * 
     * @example
     * kiss.router.validators.isPublicRoute({ui: "homepage", applicationId: "123", viewId: "456"}) // false
     * kiss.router.validators.isPublicRoute({ui: "authentication-login"}) // true
     */
    _isPublicRoute(newRoute) {
        if (kiss.router.publicRoutes.indexOf(newRoute.ui) != -1) return true
        return false
    },

    /**
     * Check if the application is properly loaded.
     * 
     * "Properly" means:
     * - required core data is loaded
     * - custom application data is loaded (depends on the use case)
     * 
     * Custom data is loaded using a "loader" function defined at initialization with kiss.app.init({...}).
     * The loader:
     * - is a function that must be fully completed before the routing can occur
     * - will be waited for completion if it's async
     * - must return a boolean to indicate if the required elements are properly loaded
     * 
     * Once loaded, kiss.app.isLoaded is flagged and the routing can occur.
     * 
     * @private
     * @ignore
     * @returns {boolean} True if the application is properly loaded
     * 
     * @example
     * kiss.router.validators.isAppLoaded() // true
     */
    async _isAppLoaded() {
        if (!kiss.app.isLoaded) {
            // Load core data
            let success = await kiss.app.load()
            if (!success) return false

            // Optionally Load custom data defined when initializing the app, using kiss.app.init({...})
            if (kiss.app.loader && typeof kiss.app.loader === "function") {
                success = await kiss.app.loader()
                if (!success) return false
            }

            kiss.app.isLoaded = true
        }

        return true
    },

    /**
     * Optional validations to perform before routing:
     * - It's an array of async functions where each function must return a boolean.
     * - Must be set up at initialization.
     * - The route is accepted if all validators return true.
     * - Can be extended at runtime using kiss.router.addRoutingGuards([...])
     * 
     * @private
     * @ignore
     */
    beforeRoutingGuards: [],

    /**
     * Default actions to perform after routing.
     * 
     * Can be extended with new actions, using kiss.router.addActions([...])
     * 
     * @private
     * @ignore
     */
    afterRoutingActions: [
        /**
         * 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".
         * 
         * @private
         * @ignore
         */
        async function () {
            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)
        }
    ]
}

;