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