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 = {
	/**
	 * Routing mode.
	 * - "hash" (default): route is read/written from URL hash
	 * - "pathname": route is read/written from URL pathname using mapping functions
	 */
	routerMode: "hash",

	/**
	 * Optional mapper used in pathname mode to convert a pathname to a route object.
	 * Signature: pathnameToRoute(pathname) => routeObject
	 */
	pathnameToRoute: null,

	/**
	 * Optional mapper used in pathname mode to convert a route object to a pathname.
	 * Signature: routeToPathname(routeObject) => pathname
	 */
	routeToPathname: null,

	/**
	 * 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
	 * 
	 * Routing modes:
	 * - "hash" (default): route is stored in URL hash (example: /index.html#ui=homepage&applicationId=123)
	 * - "pathname": route is stored in URL pathname using 2 optional mapping functions
	 *   (example: /fr/landing mapped to {ui: "start", content: "landing", language: "fr"})
	 * 
	 * In pathname mode, you can inject:
	 * - pathnameToRoute(pathname): converts current URL pathname to a route object
	 * - routeToPathname(route): converts a route object to a pathname for navigation
	 * 
	 * Example:
	 * ```js
	 * kiss.router.init({
	 *   routerMode: "pathname",
	 *   pathnameToRoute(pathname) {
	 *     if (pathname === "/fr/landing") return {ui: "start", content: "landing", language: "fr"}
	 *     if (pathname === "/en/landing") return {ui: "start", content: "landing", language: "en"}
	 *     return {}
	 *   },
	 *   routeToPathname(route) {
	 *     if (route.ui === "start" && route.content === "landing") {
	 *       return `/${route.language || "en"}/landing`
	 *     }
	 *     return "/"
	 *   }
	 * })
	 * ```
	 * 
	 * @param {object} config - The router config, containing the 2 methods:
	 * @param {string[]} [config.publicRoutes] - Define public routes (skip login)
	 * @param {string} [config.routerMode] - "hash" (default) or "pathname"
	 * @param {boolean} [config.usePathRouting] - Shortcut to set routerMode to "pathname"
	 * @param {function} [config.pathnameToRoute] - Mapper: pathname => route object
	 * @param {function} [config.routeToPathname] - Mapper: route object => pathname
	 */
	init(config = {}) {
		// Set public routes
		if (config.publicRoutes && Array.isArray(config.publicRoutes)) {
			kiss.router.publicRoutes = config.publicRoutes
		}

		// Configure routing mode
		kiss.router.routerMode = (
			config.routerMode == "pathname" ||
			config.usePathRouting === true
		) ? "pathname" : "hash"
		kiss.router.pathnameToRoute = (typeof config.pathnameToRoute == "function") ? config.pathnameToRoute : null
		kiss.router.routeToPathname = (typeof config.routeToPathname == "function") ? config.routeToPathname : null

		// Observe hash changes
		window.onhashchange = async function () {
			if (kiss.router.routerMode == "pathname") {
				const hashRoute = kiss.router._toRoute(window.location.hash.slice(1))
				if (!kiss.router._isRouteExploitable(hashRoute)) return
			}
			await kiss.router._route()
		}

		// Observe browser history navigation for pathname routing
		window.onpopstate = async function () {
			if (kiss.router.routerMode != "pathname") return
			await kiss.router._route()
		}
	},

	/**
	 * 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 route.
	 * 
	 * URL update strategy depends on router mode:
	 * - hash mode: write to URL hash
	 * - pathname mode: write to URL pathname if routeToPathname is provided
	 * 
	 * @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)
		await kiss.router._route()
	},

	/**
	 * Get the current application route from the current URL.
	 * 
	 * Route priority:
	 * - if URL hash is exploitable, it's always used
	 * - otherwise, if pathname mode is enabled, pathnameToRoute(pathname) is used
	 * 
	 * A route is considered exploitable when it contains a non-empty "ui" string.
	 * 
	 * 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() {
		const hashRoute = kiss.router._toRoute(window.location.hash.slice(1))

		// Priority: if hash is present and exploitable, always use it
		if (kiss.router._isRouteExploitable(hashRoute)) return hashRoute

		// Otherwise, if pathname routing is enabled, parse pathname with the injected mapper
		if (kiss.router.routerMode == "pathname" && typeof kiss.router.pathnameToRoute == "function") {
			try {
				const pathnameRoute = kiss.router.pathnameToRoute(window.location.pathname)
				if (kiss.router._isRouteExploitable(pathnameRoute)) return pathnameRoute
			} catch (err) {
				log.err("kiss.router - pathnameToRoute error", err)
			}
		}

		return hashRoute
	},

	/**
	 * Update URL according to new route params.
	 * 
	 * Kept as updateUrlHash for backward compatibility.
	 * In pathname mode, if reset is true, it uses history.replaceState to avoid
	 * creating a new browser history entry.
	 * 
	 * @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) ? Object.assign({}, newRoute) : Object.assign({}, currentRoute, newRoute)

		// Pathname mode with injected mapper
		if (kiss.router.routerMode == "pathname" && typeof kiss.router.routeToPathname == "function") {
			try {
				let pathname = kiss.router.routeToPathname(toRoute)
				if (typeof pathname == "string" && pathname) {
					if (!pathname.startsWith("/")) pathname = "/" + pathname
					const newUrl = pathname + window.location.search
					const historyMethod = (reset === true) ? "replaceState" : "pushState"
					window.history[historyMethod](toRoute, toRoute.ui, newUrl)
					return
				}
			} catch (err) {
				log.err("kiss.router - routeToPathname error", err)
			}
		}

		const newHash = "#" + kiss.router._toHash(toRoute)
		window.history.pushState(toRoute, toRoute.ui, newHash)
	},

	/**
	 * Check whether a parsed route can be used for routing.
	 * 
	 * Strict mode:
	 * - route must be an object
	 * - route.ui must be a non-empty string
	 * 
	 * @private
	 * @ignore
	 * @param {object} route
	 * @returns {boolean}
	 */
	_isRouteExploitable(route) {
		if (!route || typeof route != "object") return false
		return (typeof route.ui == "string" && route.ui.trim() != "")
	},

	/**
	 * Execute a full routing cycle from current URL.
	 * 
	 * @private
	 * @ignore
	 */
	async _route() {
		const newRoute = kiss.router.getRoute()

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

		// Update the application context
		kiss.context.update(newRoute)

		// Execute router actions after routing
		await kiss.router._afterRouting()
	},

	/**
	 * 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)
		}
	]
}