Source: navigation.js

/**
 * @namespace qui.navigation
 */

import $      from '$qui/lib/jquery.module.js'
import Logger from '$qui/lib/logger.module.js'

import {gettext}           from '$qui/base/i18n.js'
import ConditionVariable   from '$qui/base/condition-variable.js'
import Config              from '$qui/config.js'
import * as Toast          from '$qui/messages/toast.js'
import PageMixin           from '$qui/pages/page.js'
import {getCurrentContext} from '$qui/pages/pages.js'
import * as Sections       from '$qui/sections/sections.js'
import * as ObjectUtils    from '$qui/utils/object.js'
import * as StringUtils    from '$qui/utils/string.js'
import URL                 from '$qui/utils/url.js'
import * as Window         from '$qui/window.js'


/**
 * @alias qui.navigation.BACK_MODE_HISTORY
 */
export const BACK_MODE_HISTORY = 'history'

/**
 * @alias qui.navigation.BACK_MODE_CLOSE
 */
export const BACK_MODE_CLOSE = 'close'

const logger = Logger.get('qui.navigation')

let basePath = ''
let initialURLPath = null
let initialURLQuery = null
let currentURLPath = null
let currentURLQuery = null
let backMode = BACK_MODE_CLOSE
let historyIndex = 0


/**
 * A condition that is fulfilled as soon as the initial navigation is completed.
 * @alias qui.navigation.whenInitialNavigationReady
 * @type {qui.base.ConditionVariable}
 */
export let whenInitialNavigationReady = new ConditionVariable()

/**
 * An error indicating that navigation could not be done beyond a certain path.
 * @alias qui.navigation.PageNotFoundError
 * @extends Error
 */
export class PageNotFoundError extends Error {

    /**
     * @constructs
     * @param {String[]} path the full path that could not be navigated
     * @param {String} pathId the path id to which the navigation could not be done
     * @param {qui.sections.Section} section the section in which the navigation error occurred
     * @param {qui.navigation.PageMixin} page the page where the navigation stopped
     */
    constructor(path, pathId, section, page) {
        super(gettext(`Page not found: /${path.join('/')}`))

        this.path = path
        this.pathId = pathId
        this.section = section
        this.page = page
    }

}

/**
 * An error indicating that navigation could not be done due to a page load error.
 * @alias qui.navigation.PageLoadError
 * @extends Error
 */
export class PageLoadError extends Error {

    /**
     * @constructs
     * @param {String[]} path the full path that could not be navigated
     * @param {String} pathId the path id to which the navigation could not be done
     * @param {qui.sections.Section} section the section in which the navigation error occurred
     * @param {qui.navigation.PageMixin} page the page where the navigation stopped
     * @param {Error} error the error that occurred
     */
    constructor(path, pathId, section, page, error) {
        let msg
        if (error) {
            msg = StringUtils.formatPercent(
                gettext('Page could not be loaded: %(error)s'),
                {error: error.message}
            )
        }
        else {
            msg = gettext('Page could not be loaded')
        }
        super(msg)

        this.path = path
        this.pathId = pathId
        this.section = section
        this.page = page
        this.error = error
    }

}

/**
 * An error indicating that navigation could not be done due to a section load error.
 * @alias qui.navigation.SectionLoadError
 * @extends Error
 */
export class SectionLoadError extends Error {

    /**
     * @constructs
     * @param {String[]} path the full path that could not be navigated
     * @param {String} pathId the path id to which the navigation could not be done
     * @param {qui.sections.Section} section the section in which the navigation error occurred
     * @param {Error} error the error that occurred
     */
    constructor(path, pathId, section, error) {
        let msg
        if (error) {
            msg = StringUtils.formatPercent(
                gettext('Page could not be loaded: %(error)s'),
                {error: error.message}
            )
        }
        else {
            msg = gettext('Page could not be loaded')
        }
        super(msg)

        this.path = path
        this.pathId = pathId
        this.section = section
        this.error = error
    }

}


function updateCurrentURL() {
    let details
    if (Config.navigationUsesFragment) {
        details = URL.parse(window.location.hash.substring(1))
    }
    else {
        details = URL.parse(window.location.href)
    }

    currentURLPath = details.path.substring(basePath.length)
    currentURLQuery = details.queryStr
}


/**
 * Set the function of the back button:
 *  * {@link qui.navigation.BACK_MODE_CLOSE} makes back button close the current page (default on small screens)
 *  * {@link qui.navigation.BACK_MODE_HISTORY} makes back button go back through history (default on large screens)
 * @alias qui.navigation.setBackMode
 * @param {String} mode
 */
export function setBackMode(mode) {
    backMode = mode
    logger.debug(`back mode set to "${backMode}"`)
}

/**
 * Return the function of the back button.
 * @alias qui.navigation.getBackMode
 * @returns {String} one of:
 *  * {@link qui.navigation.BACK_MODE_CLOSE}
 *  * {@link qui.navigation.BACK_MODE_HISTORY}
 */
export function getBackMode() {
    return backMode
}

/**
 * Navigate to initial browser path. Call this function after all sections have been registered.
 * @returns {Promise} a promise that settles as soon as the navigation ends, being rejected in case of any error
 */
export function navigateInitial() {
    let promise

    if (initialURLPath) {
        logger.debug(`initial navigation to ${initialURLPath}`)
        promise = navigate({path: initialURLPath, historyEntry: false})
    }
    else {
        logger.debug('initial navigation to home')
        promise = Sections.showHome()
    }

    return promise.then(function () {
        whenInitialNavigationReady.fulfill()
    })
}

/**
 * Return the current path as an array of path ids.
 * @alias qui.navigation.getCurrentPath
 * @returns {String[]}
 */
export function getCurrentPath() {
    let path = []
    let context = getCurrentContext()

    if (!context) {
        return []
    }

    context.getPages().forEach(function (page) {
        if (!page.getPathId()) {
            return
        }


        path = path.concat(page.getPathId().split('/').filter(p => Boolean(p)))
    })

    return path
}

/**
 * Return the current URL query as a dictionary.
 * @alias qui.navigation.getCurrentQuery
 * @returns {Object<String,String>}
 */
export function getCurrentQuery() {
    if (!currentURLQuery) {
        return {}
    }

    /* Transform query string into key-value pairs */
    return URL.parse(`?${currentURLQuery}`).query
}

/**
 * Transform a QUI path into a fully qualified URL.
 * @alias qui.navigation.pathToURL
 * @param {String|String[]} path
 * @returns {String}
 */
export function pathToURL(path) {
    if (Array.isArray(path)) {
        path = `/${path.join('/')}`
    }

    return Config.navigationBasePrefix + path
}

/**
 * Create an anchor element that represents a link to an internal path.
 * @alias qui.navigation.makeInternalAnchor
 * @param {String|String[]} path
 * @param {String|jQuery} content
 * @returns {jQuery}
 */
export function makeInternalAnchor(path, content) {
    let url = pathToURL(path)

    let anchor = $('<a></a>', {href: url})
    anchor.html(content)

    anchor.on('click', function (e) {
        /* Prevent browser navigation, handle navigation internally */
        e.preventDefault()
        navigate({path})
    })

    return anchor
}

/**
 * Navigate the given path.
 * @alias qui.navigation.navigate
 * @param {String|String[]} path the path to navigate
 * @param {Boolean} [handleErrors] set to `false` to pass errors to the caller instead of handling them internally
 * (defaults to `true`)
 * @param {*} [pageState] optional history state to pass to {@link qui.navigation.PageMixin#restoreHistoryState}
 * @param {Boolean} [historyEntry] whether to create a new history entry for current page before navigating (defaults
 * to `true`)
 * @returns {Promise} a promise that settles as soon as the navigation ends, being rejected in case of any error
 */
export function navigate({path, handleErrors = true, pageState = null, historyEntry = true}) {
    /* Normalize path */
    if (typeof path === 'string') {
        path = path.split('/')
    }

    path = path.filter(id => Boolean(id))

    let origPath = path.slice()
    let pathStr = `/${path.join('/')}`
    let oldPath = getCurrentPath()
    let currIndex = 1 /* Starts from 1, skipping the section id */
    let section
    let sectionRedirected = false

    function handleError(error) {
        if (handleErrors) {
            Toast.error(error.message)
            logger.errorStack('navigation error', error)

            // TODO this is a good place to add custom navigation error handling function
            Sections.showHome(/* reset = */ true)

            return Promise.resolve()
        }
        else {
            throw error
        }
    }

    /* Don't do anything if requested path is actually current path */
    if (ObjectUtils.deepEquals(path, oldPath) && path.length > 0) {
        return Promise.resolve(getCurrentContext().getCurrentPage())
    }

    if (historyEntry) {
        addHistoryEntry()
    }

    logger.debug(`navigating to "${pathStr}", pageState = "${JSON.stringify(pageState)}"`)

    if (!path.length) { /* Empty path means home */
        section = Sections.getHome()
        if (!section) {
            logger.warn('no home section')
            return Promise.reject(new Error('No home section'))
        }
    }
    else {
        let sectionId = path.shift() /* path[0] is always the section id */
        section = Sections.get(sectionId)
        if (!section) {
            logger.error(`cannot find section with id "${sectionId}"`)
            return handleError(new PageNotFoundError(origPath, sectionId, /* section = */ null, /* page = */ null))
        }
    }

    /* One step navigation function */
    function navigateNext(currentPage) {
        if (!path.length) { /* Navigation done */
            /* Pop everything beyond given path */
            let promise
            let page = getCurrentContext().getPageAt(currIndex)
            if (page) {
                promise = page.close()
            }
            else {
                promise = Promise.resolve()
            }

            return promise.then(function () {
                updateHistoryEntry()
            })
        }

        let pathId = path.shift()

        logger.debug(`navigating from "${currentPage.getPathId()}" to "${pathId}"`)

        let promiseOrPage = currentPage.navigate(pathId)
        let promise = promiseOrPage
        if (promiseOrPage == null || (promiseOrPage instanceof PageMixin) /* Page passed directly */) {
            promise = Promise.resolve(promiseOrPage)
        }

        return promise.then(function (nextPage) {

            if (nextPage == null) {
                logger.error(`could not navigate from "${currentPage.getPathId()}" to "${pathId}"`)
                throw new PageNotFoundError(origPath, pathId, section, currentPage)
            }

            let index = nextPage.getContextIndex()
            if (index >= 0) {
                logger.debug('page with context, detected navigation flow stop')
                return nextPage.whenLoaded().then(function () {
                    return nextPage
                })
            }

            return currentPage.pushPage(nextPage, /* historyEntry = */ false).then(function () {
                currIndex++

                return nextPage.whenLoaded().then(function () {
                    return navigateNext(nextPage)
                })
            })
        })
    }

    /* Call section.navigate() to determine possible redirects */
    if (Sections.getCurrent() !== section) {
        let s = section.navigate(origPath)
        if (s !== section) {
            logger.debug(`section "${section.getId()}" redirected to section "${s.getId()}"`)
            sectionRedirected = true
            section = s
        }
    }

    return Sections.switchTo(section, /* source = */ 'navigate').catch(function (error) {

        logger.errorStack(`could not navigate to section "${section.getId()}"`, error)

        throw new SectionLoadError(origPath, section.getId(), section, error)

    }).then(function () {

        /* Section redirection can also occur during Sections.switchTo() */
        if (Sections.getCurrent() !== section) {
            sectionRedirected = true
        }

        /* If we've been redirected by Section.navigate() to another section, discard the rest of the path, since it
         * doesn't make sense anymore. */
        if (sectionRedirected) {
            return
        }

        /* Count the number of path elements that are common between old and new paths.
         * commonPathLen doesn't take into account section id.
         * We also have to make sure we're on the same section. */
        let commonPathLen = 0
        if (oldPath[0] === section.getId()) {
            while ((oldPath.length > commonPathLen + 1) &&
                   (path.length > commonPathLen) &&
                   (oldPath[commonPathLen + 1] === path[commonPathLen])) {

                commonPathLen++
            }
        }

        let currentContext = getCurrentContext()
        let promise
        if (commonPathLen) { /* We have a common path part */
            path = path.slice(commonPathLen)
            currIndex += commonPathLen

            /* Context size also contains the section id, while commonPathLen does not */
            if (currentContext.getSize() > commonPathLen + 1) {
                let page = currentContext.getPageAt(commonPathLen + 1)
                promise = page.close().then(() => currentContext.getCurrentPage())
                promise.catch(function () {
                    logger.debug('page close rejected')
                })
            }
            else { /* Common path, but no page to close */
                promise = Promise.resolve(currentContext.getCurrentPage())
            }
        }
        else { /* No common path */
            promise = Promise.resolve(section.getMainPage())
        }

        return promise.then(function (currentPage) {

            return currentPage.whenLoaded().then(function () {
                return currentPage
            })

        })

    }).then(function (currentPage) {

        if (sectionRedirected) {
            return
        }

        return navigateNext(currentPage)

    }).then(function () {

        if (sectionRedirected) {
            return
        }

        if (pageState) {
            getCurrentContext().getPages().forEach(function (page, i) {
                page.restoreHistoryState(pageState[i])
            })
        }

    }).catch(handleError)
}


function setHistoryEntry(addUpdate, state) {
    let pathStr = state.pathStr

    let msg = `${addUpdate === 'add' ? 'adding' : 'updating'} history entry`
    msg = `${msg}: path = "${pathStr}", pageState = "${JSON.stringify(state.pageState)}"`

    logger.debug(msg)

    if (addUpdate === 'add') {
        currentURLQuery = null
        state.historyIndex = ++historyIndex
        window.history.pushState(state, '', basePath + pathStr)
    }
    else {
        currentURLPath = pathStr
        state.historyIndex = historyIndex
        window.history.replaceState(state, '', basePath + pathStr)
    }
}

/**
 * Capture a snapshot of the state of the current history entry.
 * @alias qui.navigation.getCurrentHistoryEntryState
 * @returns {Object}
 */
export function getCurrentHistoryEntryState() {
    let path = getCurrentPath()
    let pathStr = `/${path.join('/')}`
    let pageState = []

    try {
        pageState = getCurrentContext().getPages().map(page => page.getHistoryState())
    }
    catch (e) {
        logger.errorStack('failed to gather history state', e)
    }

    /* Preserve query, if present */
    if (currentURLQuery) {
        pathStr += `?${currentURLQuery}`
    }

    if (Config.navigationUsesFragment) {
        pathStr = `#${pathStr}`
    }

    return {
        pageState: pageState,
        pathStr: pathStr
    }
}

/**
 * Add a history entry corresponding to the current path, or optionally to a given state.
 * @alias qui.navigation.addHistoryEntry
 * @see qui.navigation.getCurrentPath
 * @param {Object} [state] the history entry state to add (will use {@link qui.navigation.getCurrentHistoryEntryState}
 * by default)
 */
export function addHistoryEntry(state = null) {
    state = state || getCurrentHistoryEntryState()
    setHistoryEntry(/* addUpdate = */ 'add', state)
}

/**
 * Update the current history entry from the current path, or optionally from a given state.
 * @alias qui.navigation.updateHistoryEntry
 * @see qui.navigation.getCurrentPath
 * @param {Object} [state] the history entry state to add (will use {@link qui.navigation.getCurrentHistoryEntryState}
 * by default)
 */
export function updateHistoryEntry(state = null) {
    state = state || getCurrentHistoryEntryState()
    setHistoryEntry(/* addUpdate = */ 'update', state)
}

function initHistory() {
    Window.$window.on('hashchange', function () {
        if (!Config.navigationUsesFragment) {
            return
        }

        updateCurrentURL()
        logger.debug(`hash-change: navigating to "${currentURLPath}"`)
        navigate({path: currentURLPath, historyEntry: false})
    })

    Window.$window.on('popstate', function (e) {
        let oe = e.originalEvent
        if (oe.state == null || oe.state.pageState == null || oe.state.historyIndex == null) { /* Not ours */
            return
        }

        let forward = oe.state.historyIndex > historyIndex
        if (forward) {
            logger.debug('pop-state: forward history move detected')
        }
        else {
            logger.debug('pop-state: back history move detected')
        }

        historyIndex = oe.state.historyIndex

        if (backMode === BACK_MODE_CLOSE) {
            let context = getCurrentContext()
            let currentPage = context.getCurrentPage()

            if (forward) {
                logger.debug('pop-state: ignoring forward history move')
                return
            }

            if (context.getSize() === 1) {
                /* A context size of 1 indicates that only current section's main page is present; instead of closing
                 * the main page, we close the app, by going back through history for as long as we have control */
                logger.debug('pop-state: closing app')

                let goBack = function () {
                    window.history.back()
                    setTimeout(goBack, 100)
                }

                goBack()
            }
            else {
                logger.debug('pop-state: closing current page')
                currentPage.close()
            }
        }
        else {
            updateCurrentURL()
            logger.debug(`pop-state: going through history to "${currentURLPath}"`)

            navigate({path: currentURLPath, pageState: oe.state.pageState, historyEntry: false}).catch(function () {
                addHistoryEntry(oe.state)
            })
        }
    })
}


export function init() {
    /* Deduce base path from base URL */
    if (Config.navigationBasePrefix) {
        basePath = URL.parse(Config.navigationBasePrefix).path
    }

    /* On desktop, use the browser history when going back, by default */
    if (!Window.isSmallScreen()) {
        backMode = BACK_MODE_HISTORY
    }

    updateCurrentURL()
    initialURLPath = currentURLPath
    initialURLQuery = currentURLQuery

    initHistory()
}