Source: pages/page.js


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

import {AssertionError} from '$qui/base/errors.js'
import {Mixin}          from '$qui/base/mixwith.js'
import * as GlobalGlass from '$qui/global-glass.js'
import * as OptionsBar  from '$qui/main-ui/options-bar.js'
import * as Navigation  from '$qui/navigation.js'
import * as Theme       from '$qui/theme.js'
import {asap}           from '$qui/utils/misc.js'
import ViewMixin        from '$qui/views/view.js'
import * as Sections    from '$qui/sections/sections.js'
import * as Window      from '$qui/window.js'

import {getPagesContainer} from './pages.js'
import {updateUI}          from './pages.js'


const viewMixinPrototype = ViewMixin().prototype


/** @lends qui.pages.PageMixin */
const PageMixin = Mixin((superclass = Object, rootclass) => {

    let rootPrototype = rootclass.prototype


    /**
     * A mixin that offers page behavior.
     * @alias qui.pages.PageMixin
     * @mixin
     * @extends qui.views.ViewMixin
     */
    class PageMixin extends ViewMixin(superclass) {

        /**
         * @constructs
         * @param {?String} [title] page title
         * @param {String} [pathId] identifies the page in the URL; leave this unset if the page should not be part
         * of the URL or is the main page of a section
         * @param {Boolean} [columnLayout] indicates that the page layout is a column and does not expand horizontally
         * (defaults to `false`)
         * @param {Boolean} [keepPrevVisible] indicates that the previous page should be kept visible while this page
         * is the current one, to the extent possible (defaults to `false`)
         * @param {Boolean} [popup] indicates that the page should be shown as a popup container, on top of the existing
         * content (defaults to `false`)
         * @param {Boolean} [modal] indicates that the page should be modal, not allowing any external interaction
         * (defaults to `false`, implies `popup` as `true`)
         * @param {Boolean} [transparent] indicates that the page should be transparent (defaults to `true`)
         * @param {...*} args parent class parameters
         */
        constructor({
            title = null,
            pathId = null,
            columnLayout = false,
            keepPrevVisible = false,
            popup = false,
            modal = false,
            transparent = true,
            ...args
        } = {}) {
            super(args)

            this._title = title
            this._pathId = pathId
            this._columnLayout = columnLayout
            this._keepPrevVisible = keepPrevVisible
            this._transparent = transparent
            this._popup = popup || modal
            this._modal = modal

            this._pageHTML = null
            this._closingPromise = null
            this._closed = false
            this._attached = false
            this._context = null
            this._optionsBarContent = null
            this._optionsBarOpen = false /* Flag indicating the last known options bar status for this page */
            this._whenLoaded = null
        }

        /**
         * Override this method to customize navigation beyond this page. By default, it returns `null`, preventing
         * further navigation.
         *
         * It is safe to assume that this page is visible and loaded when this method is called.
         *
         * @param {String} pathId the next path id
         * @returns {?qui.pages.PageMixin|Promise<qui.pages.PageMixin>} the next page or `null` if navigation to given
         * path id is not possible; a promise that resolves to a page can also be returned
         */
        navigate(pathId) {
            return null
        }

        /**
         * Return the page title.
         * @returns {?String}
         */
        getTitle() {
            if (rootPrototype.getTitle) {
                return rootPrototype.getTitle.call(this)
            }

            return this._title
        }

        /**
         * Set the page title.
         * @param {?String} title the new title
         */
        setTitle(title) {
            if (rootPrototype.getTitle) {
                rootPrototype.setTitle.call(this, title)
            }

            this._title = title
            if (this.getContext() && this.getContext().isCurrent()) {
                updateUI()
            }
        }

        /**
         * Return the path id of the page.
         * @returns {?String}
         */
        getPathId() {
            return this._pathId
        }

        /**
         * Update the path id of the page.
         * @param {?String} pathId
         */
        setPathId(pathId) {
            this._pathId = pathId
            if (this.getContext() && this.getContext().isCurrent()) {
                Navigation.updateHistoryEntry()
            }
        }

        /**
         * Override this method to specify page state to be saved when page is saved into browser history.
         *
         * This state will later be restored by calling {@link qui.pages.PageMixin#restoreHistoryState}.
         *
         * @returns {*} the state
         */
        getHistoryState() {
            return {}
        }

        /**
         * Override this method to implement restoring page state from history.
         *
         * This method will be given as argument the state that has been previously created by
         * {@link qui.pages.PageMixin#getHistoryState}.
         *
         * This method must be prepared to receive a `null` history state.
         *
         * @param {*} state
         */
        restoreHistoryState(state) {
        }

        /**
         * Call this whenever the content of the history state changes.
         */
        updateHistoryState() {
            if (this.getContext() && this.getContext().isCurrent()) {
                Navigation.updateHistoryEntry()
            }
        }

        /**
         * Ad a new entry to the browser history. This is just a convenience wrapper around
         * {@link qui.navigation.addHistoryEntry}.
         */
        addHistoryEntry() {
            Navigation.addHistoryEntry()
        }

        /**
         * Override this to implement how the page is loaded.
         *
         * Does nothing by default, returning a resolved promise.
         *
         * @returns {Promise}
         */
        load() {
            return Promise.resolve()
        }

        /**
         * Return a promise that settles as soon as the page is loaded.
         *
         * This method calls {@link qui.pages.PageMixin#load} once per page instance.
         *
         * @returns {Promise}
         */
        whenLoaded() {
            if (!this._whenLoaded) {
                this.setProgress()
                this._whenLoaded = this.load()
                this._whenLoaded.then(function () {
                    this.clearProgress()
                }.bind(this)).catch(function (error) {
                    this.setError(error)
                }.bind(this))
            }

            return this._whenLoaded
        }

        /**
         * Create the page HTML wrapper. This method is called only once per page instance.
         * @returns {jQuery}
         */
        makePageHTML() {
            let html = $('<div></div>', {class: 'qui-page'})

            if (this._columnLayout) {
                html.addClass('column-layout')
            }

            if (this._transparent) {
                html.addClass('transparent')
            }

            if (this._popup) {
                html.addClass('popup')
            }

            if (this._modal) {
                html.addClass('modal')
            }

            /* Add a reference from HTML element to the page object */
            html.data('page', this)

            html.append(this.getHTML())

            return html
        }

        /**
         * Override this to further initialize the Page HTML wrapper.
         * @param {jQuery} html the HTML wrapper to be initialized
         */
        initPageHTML(html) {
        }

        /**
         * Return the page HTML wrapper. Calls {@link qui.pages.PageMixin#makePageHTML} at first invocation.
         * @returns {jQuery}
         */
        getPageHTML() {
            if (this._pageHTML == null) {
                this._pageHTML = this.makePageHTML()
                this.initPageHTML(this._pageHTML)
            }

            return this._pageHTML
        }

        /**
         * Attach the page to the page container.
         */
        attach() {
            if (this._attached) {
                throw new AssertionError('Attempt to attach an already attached page')
            }
            // TODO following condition breaks sticky modal pages
            // if (!this._context || !this._context.isCurrent()) {
            //     throw new AssertionError('Attempt to attach a page belonging to a non-current context')
            // }

            let html = this.getPageHTML()
            if (this.isPopup()) {
                GlobalGlass.addContent(html)
                GlobalGlass.setModal(this.isModal())
            }
            else {
                getPagesContainer().append(html)
            }

            asap(function () {
                this.getPageHTML().addClass('attached')
            }.bind(this))

            this._attached = true
        }

        /**
         * Detach the page from the page container.
         */
        detach() {
            if (!this._attached) {
                throw new AssertionError('Attempt to detach an already detached page')
            }

            this._attached = false

            this.getPageHTML().removeClass('attached')
            if (this.isPopup()) {
                GlobalGlass.setModal(true) /* default */
            }

            Theme.afterTransition(function () {

                if (this._attached) {
                    /* Detaching cancelled by subsequent attach */
                    return
                }

                this.getPageHTML().detach()

            }.bind(this), this.getPageHTML())
        }

        /**
         * Tell if the page layout is a column and does not expand horizontally.
         * @returns {Boolean}
         */
        isColumnLayout() {
            return this._columnLayout
        }

        /**
         * Set the page column layout.
         * @param {Boolean} columnLayout
         */
        setColumnLayout(columnLayout) {
            if (columnLayout !== this._columnLayout) {
                this._columnLayout = columnLayout
                this.getPageHTML().toggleClass('column-layout', columnLayout)

                updateUI()
            }
        }

        /**
         * Return the current vertical scroll parameters.
         * @returns {{offset: Number, maxOffset: Number}} `offset` represents the current scroll offset and `maxOffset`
         * is the maximum scroll offset (`0` if no scrolling is possible)
         */
        getVertScrollParams() {
            let pageHTML = this.getPageHTML()

            return {
                offset: pageHTML[0].scrollTop,
                maxOffset: pageHTML[0].scrollHeight - pageHTML[0].clientHeight
            }
        }

        /**
         * Called when the page is scrolled vertically.
         * @param {Number} offset the vertical scroll offset
         * @param {Number} maxOffset the maximum vertical scroll offset
         */
        onVertScroll(offset, maxOffset) {
        }

        /**
         * Handle vertical scroll events. Internally calls {@link qui.pages.PageMixin#onVertScroll}.
         */
        handleVertScroll() {
            let params = this.getVertScrollParams()

            this.onVertScroll(params.offset, params.maxOffset)
        }

        /**
         * Called when the page is resized.
         */
        onResize() {
        }

        /**
         * Handle the resize events. Internally calls {@link qui.pages.PageMixin#onResize}.
         */
        handleResize() {
            this.onResize()
        }

        /**
         * Return the index of this page in its context. If page has no context, `-1` is returned.
         * @returns {Number}
         */
        getContextIndex() {
            if (!this._context) {
                return -1
            }

            return this._context.getPages().indexOf(this)
        }

        /**
         * Return the associated pages context.
         * @returns {?qui.pages.PagesContext}
         */
        getContext() {
            return this._context
        }

        /**
         * Tell if this page is the current page within its context.
         * @returns {Boolean}
         */
        isCurrent() {
            if (!this._context) {
                return false
            }

            return this._context.getCurrentPage() === this
        }

        /**
         * Tell if the page is visible.
         * @returns {Boolean}
         */
        isVisible() {
            if (!this._context) {
                return false
            }

            if (!this._context.isCurrent()) {
                return false
            }

            if (!this._context.getPages().includes(this)) {
                return false /* Not part of current context */
            }

            if (Window.isSmallScreen() && this.getContextIndex() < this._context.getSize() - 1) {
                return false /* On small screens, only the last page is actually visible */
            }

            return this._context.getVisiblePages().includes(this)
        }

        /**
         * Tells if the page has a context, effectively indicating whether the page is currently added to a context, or
         * not.
         * @returns {Boolean}
         */
        hasContext() {
            return !!this._context
        }

        /**
         * Tell if the page is kept visible while the next page is current.
         * @returns {Boolean}
         */
        isPrevKeptVisible() {
            return this._keepPrevVisible
        }

        /**
         * Set the *keep-prev-visible* flag, controlling if the page is kept visible while the next page is current.
         * @param {Boolean} keepPrevVisible
         */
        setKeepPrevVisible(keepPrevVisible) {
            this._keepPrevVisible = keepPrevVisible

            if (this._context && this._context.isCurrent()) {
                updateUI()
            }
        }

        /**
         * Tell if the page is popup.
         * @returns {Boolean}
         */
        isPopup() {
            return this._popup
        }

        /**
         * Set the popup flag.
         * @param {Boolean} popup
         */
        setPopup(popup) {
            if (this._modal) {
                popup = true /* Modals are always popups */
            }

            let needsReattach = false
            if (this._popup !== popup && this._attached) {
                needsReattach = true
                this.detach()
            }

            this._popup = popup

            if (needsReattach) {
                this.attach()
            }

            this.getPageHTML().toggleClass('popup', popup)

            if (this.getContext() && this.getContext().isCurrent()) {
                updateUI()
            }
        }

        /**
         * Tell if the page is modal.
         * @returns {Boolean}
         */
        isModal() {
            return this._modal
        }

        /**
         * Set the modal flag.
         * @param {Boolean} modal
         */
        setModal(modal) {
            let needsReattach = false
            if (this._modal !== modal && this._attached) {
                needsReattach = true
                this.detach()
            }

            this._modal = modal
            if (modal) {
                this._popup = true /* Modals are always popups */
            }

            if (needsReattach) {
                this.attach()
            }

            this.getPageHTML().toggleClass('modal', this._modal)
            this.getPageHTML().toggleClass('popup', this._popup) /* Popup might have also changed */

            if (this.getContext() && this.getContext().isCurrent()) {
                updateUI()
            }
        }

        /**
         * Called when the page is closed.
         */
        onClose() {
        }

        /**
         * Called when the next page is closed.
         * @param {qui.pages.PageMixin} next the next page that has just been closed
         */
        onCloseNext(next) {
        }

        /**
         * Close the page. Calls {@link qui.pages.PageMixin#canClose} to determine if the page can be closed.
         * @param {Boolean} [force] set to `true` to force page close without calling
         * {@link qui.pages.PageMixin#canClose}
         * @returns {Promise} a promise that is resolved as soon as the page is closed and is rejected if the page close
         * was rejected.
         */
        close(force = false) {
            if (this._closed) {
                throw new AssertionError('Attempt to close an already closed page')
            }

            /* If page is already in the process of being closed, return the closing promise */
            if (this._closingPromise) {
                return this._closingPromise
            }

            /* Close all following pages */
            let promise = Promise.resolve()
            let index = this.getContextIndex()
            let context = this.getContext()
            if (index >= 0 && context && context.getSize() > index + 1) {
                promise = context.getPageAt(index + 1).close(force)
            }

            this._closingPromise = promise.then(() => force || this.canClose()).then(function () {
                if (rootPrototype.close) {
                    rootPrototype.close.call(this)
                }

                /* Mark as closed */
                this._closed = true
                this._closingPromise = null

                /* Pop this page from context */
                if (context) {
                    let currentPage = context.getCurrentPage()
                    if (currentPage !== this) {
                        throw new AssertionError('New page added to context while current page closing')
                    }

                    if (context.isCurrent()) {
                        this.handleLeaveCurrent()
                    }

                    context.pop()
                }

                this.onClose()
                this._context = null

                /* Detach from DOM */
                if (this._attached) {
                    this.detach()
                }

                updateUI()

                if (context) {
                    let currentPage = context.getCurrentPage()
                    if (currentPage) {
                        currentPage.onCloseNext(this)

                        if (context.isCurrent()) {
                            currentPage.handleBecomeCurrent()
                            Navigation.updateHistoryEntry()
                        }
                    }
                    else {
                        if (context.isCurrent()) {
                            OptionsBar.setContent(null)
                        }
                    }
                }
            }.bind(this)).catch(function (e) {

                /* Clear closing promise if close cancelled */
                this._closingPromise = null
                throw e

            }.bind(this))

            return this._closingPromise

        }

        /**
         * Tell if the page has been closed.
         * @returns {Boolean}
         */
        isClosed() {
            /* Prefer root isClosed unless it's the one inherited from ViewMixin */
            if (rootPrototype.isClosed &&
                (rootPrototype.isClosed !== viewMixinPrototype.isClosed) &&
                (rootPrototype.isClosed.toString() !== viewMixinPrototype.isClosed.toString())) {

                return rootPrototype.isClosed.call(this)
            }

            return this._closed
        }

        /**
         * Override this method to prevent accidental closing of the page, to the possible extent. Pages can be closed
         * by default.
         * @returns {Promise} a promise that, if rejected, will prevent the page close
         */
        canClose() {
            return Promise.resolve()
        }

        /**
         * Called when the page becomes the current page on the current context.
         */
        onBecomeCurrent() {
        }

        /**
         * Handle the event of becoming the current page of the current context.
         */
        handleBecomeCurrent() {
            this.onBecomeCurrent()
            let context = this.getContext()
            if (!context || !context.isCurrent()) {
                throw new AssertionError('Attempt to call handleBecomeCurrent() on non-current context')
            }

            OptionsBar.setContent(this._prepareOptionsBarContent())
            if (this._optionsBarOpen) {
                OptionsBar.open()
            }
            else {
                OptionsBar.close()
            }
        }

        /**
         * Called when the page is no longer the current page on the current context.
         */
        onLeaveCurrent() {
        }

        /**
         * Handle the event of no longer being the current page of the current context.
         */
        handleLeaveCurrent() {
            this.onLeaveCurrent()
        }

        /**
         * Called when the section to which page belongs is shown.
         */
        onSectionShow() {
        }

        /**
         * Handle the event of owning section becoming visible.
         */
        handleSectionShow() {
            this.onSectionShow()
        }

        /**
         * Called when the section to which page belongs is hidden.
         */
        onSectionHide() {
        }

        /**
         * Handle the event of owning section becoming hidden.
         */
        handleSectionHide() {
            this.onSectionHide()
        }

        /**
         * Returns the section to which the page currently belongs (may be `null`).
         * @returns {?qui.sections.Section}
         */
        getSection() {
            return Sections.all().find(s => s.getPagesContext() === this.getContext()) || null
        }

        /**
         * Override this method to enable the options bar for this page.
         * @returns {?jQuery|qui.views.ViewMixin}
         */
        makeOptionsBarContent() {
            return null
        }

        /**
         * Return the options bar content of this page.
         * @returns {?jQuery|qui.views.ViewMixin}
         */
        getOptionsBarContent() {
            if (!this._optionsBarContent) {
                this._optionsBarContent = this.makeOptionsBarContent()
            }

            return this._optionsBarContent
        }

        /**
         * Called when the page options change; the page options are defined by the options bar content.
         *
         * This currently works only when using an {@link qui.forms.OptionsForm} for the options bar content.
         *
         * @param {Object} options
         */
        onOptionsChange(options) {
        }

        _prepareOptionsBarContent() {
            let content = this.getOptionsBarContent()
            if (content) {
                if (content instanceof ViewMixin) {
                    content = content.getHTML()
                }
            }

            return content
        }

        /**
         * If this is the current page, open the options bar right away. Otherwise, the options bar will be
         * automatically opened as soon as this page becomes current.
         */
        openOptionsBar() {
            if (this.isCurrent()) {
                OptionsBar.open()
            }
            else {
                this._optionsBarOpen = true
            }
        }

        /**
         * If this is the current page, close the options bar right away. Otherwise, the options bar will remain closed
         * as soon as this page becomes current.
         */
        closeOptionsBar() {
            if (this.isCurrent()) {
                OptionsBar.close()
            }
            else {
                this._optionsBarOpen = false
            }
        }

        /**
         * Called when the page is pushed to a context.
         */
        onPush() {
        }

        /**
         * Push a new page after this one. Any following pages will be closed. The new page is not guaranteed to be
         * pushed by the time the function exists.
         * @param {qui.pages.PageMixin} page the page to be pushed
         * @param {Boolean} [historyEntry] whether to create a new history entry for current page before adding the new
         * page, or not (determined automatically by default, using new page's `pathId`)
         * @returns {Promise} a promise that resolves as soon as the page is pushed, or rejected if the page cannot be
         * pushed
         */
        pushPage(page, historyEntry = null) {
            let index = this.getContextIndex()
            if (index < 0) {
                throw new AssertionError('Attempt to push from a contextless page')
            }

            /* By default, pages with pathId will add a new history entry */
            if (historyEntry == null) {
                historyEntry = !!page.getPathId()
            }

            let context = this.getContext()
            if (!context.isCurrent()) {
                historyEntry = false
            }

            /* Preserve current state for after potential next page close */
            let state = null
            if (historyEntry) {
                state = Navigation.getCurrentHistoryEntryState()
            }

            /* Close any following page */
            let promise
            let nextPage = context.getPageAt(index + 1)
            if (nextPage) {
                promise = nextPage.close()
            }
            else {
                promise = Promise.resolve()
            }

            return promise.then(function () {
                if (historyEntry) {
                    Navigation.updateHistoryEntry(state)
                }

                if (context.isCurrent()) {
                    this.handleLeaveCurrent()
                }

                page.pushSelf(context)
                page.whenLoaded() /* Start loading the page automatically when pushed */

                if (historyEntry) {
                    Navigation.addHistoryEntry()
                }
                else if (context.isCurrent()) {
                    Navigation.updateHistoryEntry()
                }
            }.bind(this))
        }

        /**
         * Push this page to a context.
         * @param {qui.pages.PagesContext} context
         */
        pushSelf(context) {
            if (this._context) {
                throw new AssertionError('Attempt to push page with context')
            }

            this._closed = false
            this._closingPromise = null

            /* Attach the page to context */
            context.push(this)
            this._context = context

            if (context.isCurrent()) {
                this.attach()
                updateUI()
            }

            this.onPush()

            if (context.isCurrent()) {
                this.handleBecomeCurrent()
            }
        }

        /**
         * Return the previous page in context.
         * @returns {?qui.pages.PageMixin}
         */
        getPrev() {
            let index = this.getContextIndex()
            if (index < 0) {
                return null
            }

            return this._context.getPageAt(index - 1)
        }

        /**
         * Return the next page in context.
         * @returns {?qui.pages.PageMixin}
         */
        getNext() {
            let index = this.getContextIndex()
            if (index < 0) {
                return null
            }

            return this._context.getPageAt(index + 1)
        }


        /* Following methods are overridden so that versions from the rootclass are also taken into consideration */

        makeHTML() {
            if (rootPrototype.makeHTML) {
                return rootPrototype.makeHTML.call(this)
            }

            return super.makeHTML()
        }

        initHTML(html) {
            if (rootPrototype.initHTML) {
                rootPrototype.initHTML.call(this, html)
            }
            else {
                super.initHTML(html)
            }
        }

        init() {
            if (rootPrototype.init) {
                rootPrototype.init.call(this)
            }
            else {
                super.init()
            }
        }

        showProgress(percent) {
            if (rootPrototype.showProgress) {
                rootPrototype.showProgress.call(this, percent)
            }
            else {
                super.showProgress(percent)
            }
        }

        hideProgress() {
            if (rootPrototype.hideProgress) {
                rootPrototype.hideProgress.call(this)
            }
            else {
                super.hideProgress()
            }
        }

        showWarning(message) {
            if (rootPrototype.showWarning) {
                rootPrototype.showWarning.call(this, message)
            }
            else {
                super.showWarning(message)
            }
        }

        hideWarning() {
            if (rootPrototype.hideWarning) {
                rootPrototype.hideWarning.call(this)
            }
            else {
                super.hideWarning()
            }
        }

        showError(message) {
            if (rootPrototype.showError) {
                rootPrototype.showError.call(this, message)
            }
            else {
                super.showError(message)
            }
        }

        hideError() {
            if (rootPrototype.hideError) {
                rootPrototype.hideError.call(this)
            }
            else {
                super.hideError()
            }
        }

    }

    return PageMixin

})


export default PageMixin