Source

client/core/modules/views.js

/**
 * 
 * ## A simple view manager
 * 
 * @namespace
 * 
 */
kiss.views = {

    /**
     * Contains all the views that are already built
     */
    views: {},

    /**
     * Contains view renderers functions
     */
    viewRenderers: {},

    /**
     * Contains view controllers functions
     */
    viewControllers: {},

    /**
     * Contains view meta informations for SEO
     */
    viewMetas: {},

    /**
     * Contains nodes temporarily detached from the DOM
     */
    cachedNodes: {},

    /**
     * Add a view by storing its renderer function in the list of view renderers.
     * 
     * It does NOT store a view, but instead stores a view 'renderer' function that will generate the view later, and only when needed.
     * The renderer function receives 2 parameters:
     * - the view id
     * - the view target: insertion point in the DOM to insert the view
     * 
     * Note: using this method is equivalent to using kiss.app.defineView({
     *  id,
     *  renderer
     * })
     * 
     * When using KissJS for building a SEO-friendly website, you can use meta configuration to help search engine understand your content.
     * Meta information currently supported is:
     * - url
     * - locale
     * - site_name
     * - type: website | article | ...
     * - title
     * - description
     * - author
     * - image
     * - audio
     * - video
     * - other tags will generate a basic meta, like: <meta name="..." content="...">
     * 
     * KissJS is dispatching information properly so you don't have to repeat yourself.
     * For example, if you enter a title, this will generate:
     * - a <title> tag
     * - an opengraph title
     * - a twitter title
     * 
     * If meta 'url' property has multiple languages set up, the first is canonical and other are alternate:
     * 
     * ```
     * meta: {
     *  url: {
     *      en: "https://pickaform.fr/en",
     *      fr: "https://pickaform.fr/fr"
     *  }
     * }
     * 
     * // Will generate this in the header:
     * <link rel="canonical" href="https://pickaform.fr/en">
     * <link rel="alternate" hreflang="fr" href="https://pickaform.fr/fr">
     * ```
     *
     * @param {object} config
     * @param {string} config.id - The id of the view to add
     * @param {function} config.renderer - The function that will build the view when needed
     * @param {object} config.meta - Meta informations injected in the HTML header. Can be localized or not. See example.
     * 
     * @example
     * kiss.views.addView({
     *  id: "myView",
     *  renderer: function (id, target) {
     *      let myView = ... // Must be an Html element or a KissJS component
     *      myView.id = id // The view must have the passed id
     *      myView.target = target // The view can optionaly have a target to be inserted at a specific point in the DOM
     *      return myView
     *  }
     * })
     * 
     * // Example of a view using meta informations
     * kiss.app.defineView({
     *  id: "myProductPage",
     *  meta: {
     *      title: "CRM", // Meta without localization
     *      // Meta with localization
     *      description: {
     *          en: "A useful CRM",
     *          fr: "Un CRM utile"
     *      }
     *  },
     *  renderer: function(id, target) {
     *      // ...
     *  }
     * })
     * 
     * // Example returning a KissJS panel
     * kiss.app.defineView({
     *  id: "myPanel",
     *  renderer: function (id, target) {
     *      return createPanel({
     *          id,
     *          target,
     *       
     *          title: "My panel",
     *          icon: "fas fa-check",
     *
     *          width: 300,
     *          height: 200,
     *          align: "center",
     *          verticalAlign: "center",
     *
     *          draggable: true,
     *          modal: true,
     *          closable: true,
     *          expandable: true,
     *
     *          layout: "vertical", // => The content will be "display: flex" and "flex-flow: column"
     *          items: [{
     *                  type: "html",
     *                  flex: 1, // Fill maximum space
     *                  html: `<h3>Title</h3>
     *                       <p>Hello world</p>
     *                       <br>`
     *              },
     *              {
     *                  type: "button",
     *                  text: "Say hello",
     *                  icon: "far fa-comment",
     *                  action: () => createNotification("Hello!")
     *              }
     *          ]
     *      })
     *  }
     * })
     * 
     * // Display the view
     * kiss.views.show("myPanel")
     * 
     * // Display the view using the router
     * kiss.router.navigateTo("myPanel")
     * 
     * // ...or
     * kiss.router.navigateTo({
     *  ui: "myPanel"
     * })
     */
    addView({
        id,
        renderer,
        meta
    }) {
        if (!id || !renderer) {
            log(`kiss.views - You're trying to define the view <${id}>, but it's not properly setup: a view needs an id and a renderer.`, 3)
            return
        }

        this.viewRenderers[id] = renderer
        if (meta) this.viewMetas[id] = meta
    },

    /**
     * Adds a view controller
     * 
     * @param {string} id - The view id which will receive the controller
     * @param {function} controller - The view controller
     */
    addViewController(id, controller) {
        this.viewControllers[id] = controller
    },

    /**
     * Build a view from its renderer function
     * 
     * @ignore
     * @param {string} id - The id of the view to build
     * @param {string} [target] - The DOM node where the view should be inserted
     * @returns {HTMLElement} The DOM node that represents the view
     */
    buildView(id, target) {
        // If the view is generated from a mobile and if a mobile version does exist => build the mobile version
        if (kiss.screen.isMobile && this.viewRenderers["mobile-" + id]) {
            id = "mobile-" + id
        }

        if (!this.views[id]) {
            try {
                log(`kiss.views - buildView - Building view ${id}`)

                const viewRenderer = this.viewRenderers[id]

                // Abort if the view id wasn't found
                if (!viewRenderer) {
                    log(`kiss.views - You're trying to build the view <${id}>, but it hasn't been defined.`, 3)
                    return null
                }

                const view = this.views[id] = viewRenderer(id, target)
                view.isView = true

                // Bind external controllers for views that are *not* KissJS components, but standard HTML Elements
                if (!view.isComponent) {
                    const viewControllers = kiss.views.viewControllers[id]

                    if (viewControllers)
                        for (let method in viewControllers)
                            view[method] = viewControllers[method]
                }

                // Inject meta tags if needed for SEO
                if (this.viewMetas[id]) this._insertMeta(id)

            } catch (err) {
                log(`kiss.views - buildView - The view ${id} couldn't be built`, 4, err)
            }
        }

        // Update view target in case it moved to another DOM insertion point
        if (target) this.views[id].target = target

        return this.views[id]
    },

    /**
     * Get a view from its id
     * 
     * @param {string} id - The id of the view
     * @returns {HTMLElement} The DOM node that represents the view
     */
    get(id) {
        return this.views[id]
    },

    /**
     * Remove a view *OR* any node from its id
     * - removes the view if it exists
     * - delete the node and its children
     * - unsubscribe the node and its children from PubSub to avoid memory leaks
     * 
     * @param {string} id - The id of the view
     */
    remove(id) {
        // Delete the view reference from the manager
        if (this.views[id]) delete this.views[id]

        // Delete the view node (and all its children)
        let node = $(id)
        if (node) node.deepDelete()
    },

    /**
     * Show a view at a specific point of the DOM
     * 
     * If no target has been specified, the view is inserted into the document body.
     * 
     * When calling this method, the view is either built, or retrieved from the cache if it already exists.
     * 
     * If a view is displayed inside a non-empty container, there are 2 scenarii:
     * - by default, the view is appended to the container's children
     * - if the view is "exclusive", it will replace all other children of the container (that will be pushed into the cache for future use)
     * 
     * @param {string} id - The id of the view
     * @param {string} [target] - The DOM node where the view should be inserted
     * @param {boolean} [exclusive] - Indicates if the view must be shown exclusively to other sibling views within its parent container
     * @returns {HTMLElement} The DOM node that represents the view
     * 
     * @example
     * // Example 1:
     * kiss.views.show("view 1", "containerId") // Displays view 1
     * kiss.views.show("view 2", "containerId") // Append view 2 to the container
     * 
     * // Example 2:
     * kiss.views.show("view 1", "containerId") // Displays view 1
     * kiss.views.show("view 2", "containerId", true) // Replace view 2 inside the container. View 2 is pushed into kiss.views.cachedNodes
     * 
     * // Example 3:
     * kiss.views.show("view 1", "containerId") // Displays view 1
     * kiss.views.replaceBy("view 2", "containerId") // Equivalent to previous example
     */
    show(id, target, exclusive) {
        const view = this.buildView(id)

        if (view && (!view.isConnected || view.hidden)) {
            // if (this.getCachedNode(id) != null) {
            //     log("kiss.views - The view **was** in cache: " + id)
            // } else {
            //     log("kiss.views - The view **was not** in cache: " + id)
            // }

            view.render(target)

            // If a view is exclusive within a container, we hide (and cache) all other views within that container.
            if (exclusive) {

                // Remove and cache views which are at the same level
                Object.keys(this.views).forEach(function (viewId) {
                    if (viewId == view.id) return

                    const otherView = kiss.views.views[viewId]
                    if (otherView.parentNode == view.parentNode && viewId != "topbar") kiss.views.removeAndCacheNode(viewId)
                })
            }

            // Cache view params for being able to rebuild it with the same params
            if (target != undefined) view.target = target
            view.exclusive = !!exclusive
        }

        if (view && view.hidden) view.show()

        // Inject meta tags if needed for SEO
        if (this.viewMetas[id]) this._insertMeta(id)

        return view
    },

    /**
     * If the view has some meta informations, it's injected / updated in the header.
     * 
     * @private
     * @ignore
     * @param {string} id
     */
    _insertMeta(id) {
        const meta = this.viewMetas[id]

        Object.keys(meta).forEach(name => {
            switch (name) {
                case "title":
                    document.title = this._getMetaData(meta, "title")
                    this._injectMetaTag("name", "twitter:title", meta, name)
                    this._injectMetaTag("property", "og:title", meta, name)
                    break

                case "description":
                    this._injectMetaTag("name", "description", meta, name)
                    this._injectMetaTag("name", "twitter:description", meta, name)
                    this._injectMetaTag("property", "og:description", meta, name)
                    break

                case "author":
                    this._injectMetaTag("name", "author", meta, name)
                    this._injectMetaTag("name", "twitter:creator", meta, name)
                    this._injectMetaTag("property", "og:article:author", meta, name)
                    break

                case "type":
                    this._injectMetaTag("property", "og:type", meta, name)
                    break

                case "site_name":
                    this._injectMetaTag("property", "og:site_name", meta, name)
                    break

                case "image":
                    this._injectMetaTag("name", "twitter:image", meta, name)
                    this._injectMetaTag("property", "og:image", meta, name)
                    break

                case "audio":
                    this._injectMetaTag("property", "og:audio", meta, name)
                    break

                case "video":
                    this._injectMetaTag("property", "og:video", meta, name)
                    break

                case "url":
                    this._injectMetaTag("name", "twitter:url", meta, name)
                    this._injectMetaTag("property", "og:url", meta, name)
                    this._injectLanguageLinks(meta)
                    break

                case "locale":
                    this._injectMetaTag("property", "og:locale", meta, name)
                    break

                default:
                    this._injectMetaTag("name", name, meta, name)
            }
        })
    },


    /**
     * Update language meta tags for SEO.
     * 
     * @private
     * @ignore
     * @param {object} meta 
     */
    _injectLanguageLinks(meta) {
        const url = meta.url

        if (typeof url == "string") {
            // Single language
            let language = kiss.language.current || "en"
            this._injectLinkRel("canonical", language, url)
        } else {
            // Multiple languages
            Object.keys(url).forEach((language, index) => {
                if (index == 0) {
                    this._injectLinkRel("canonical", language, url[language])
                } else {
                    this._injectLinkRel("alternate", language, url[language])
                }
            })
        }
    },

    /**
     * Insert or update a <link rel="canonical"> or <link rel="alternate" hreflang="..."> tag.
     * 
     * @private
     * @ignore
     * @param {string} type 
     * @param {string} language 
     * @param {string} url 
     */
    _injectLinkRel(type, language, url) {
        let linkTag

        if (type == "canonical") {
            linkTag = document.querySelector('link[rel="canonical"]')
        } else if (type == "alternate") {
            linkTag = document.querySelector('link[rel="alternate"][hreflang="' + language + '"')
        }

        if (linkTag) {
            linkTag.setAttribute("href", url)
        } else {
            linkTag = document.createElement("link")
            linkTag.setAttribute("rel", type)
            if (type == "alternate") linkTag.setAttribute("hreflang", language)
            linkTag.setAttribute("href", url)
            document.head.appendChild(linkTag)
        }
    },

    /**
     * Insert or replace a meta tag in the document header
     * 
     * @private
     * @ignore
     * @param {string} propertyType
     * @param {string} propertyName 
     * @param {object} meta
     * @param {string} name
     */
    _injectMetaTag(propertyType, propertyName, meta, name) {
        let metaTag = document.querySelector('meta[' + propertyType + '="' + propertyName + '"]')
        let value = this._getMetaData(meta, name)

        if (metaTag) {
            metaTag.setAttribute("content", value)
        } else {
            metaTag = document.createElement("meta")
            metaTag.setAttribute(propertyType, propertyName)
            metaTag.setAttribute("content", value)
            document.head.appendChild(metaTag)
        }
    },

    /**
     * Get a meta data which can be:
     * - a string, if there is no translation
     * - an object, if it's localized
     * 
     * @private
     * @ignore
     * @param {object} meta 
     * @param {string} name 
     * @returns {string|object} The meta value
     */
    _getMetaData(meta, name) {
        if (typeof meta[name] == "string") {
            return meta[name]
        } else {
            return meta[name][kiss.language.current]
        }
    },

    /**
     * Destroys a view and rebuild it
     * 
     * @param {string} viewId 
     */
    reset(viewId, target, exclusive) {
        const view = kiss.views.get(viewId)
        kiss.views.remove(viewId)
        kiss.views.show(viewId, view.target, view.exclusive)
    },

    /**
     * Show a view and replace other views in the same container.
     * 
     * All the replaced views are pushed into the cache (kiss.views.cachedNodes) for future use.
     * 
     * @param {string} id - The id of the view
     * @param {string} [target] - The DOM node where the view should be replaced
     * @returns {HTMLElement} The DOM node that represents the view
     * 
     * @example
     * kiss.views.show("view 1", "containerId") // Displays view 1
     * kiss.views.replaceBy("view 2", "containerId") // Replace view 2 inside the container. View 2 is pushed into kiss.views.cachedNodes
     */
    replaceBy(id, target) {
        return this.show(id, target, true)
    },

    /**
     * Remove a node from the DOM and keep it temporarily in cache for future use.
     * It stores an object that keeps track of the parent and the index within its sibling nodes, like this:
     * ```
     * {parentId: "abc", index: 4, node: theCachedNode}
     * ```
     * 
     * Tech note:
     * - Removed elements are kept in a cache to not be garbage collected, so they can be used later.
     * - Compared to display:none, removed elements are **not** triggering their events anymore.
     * - When you have a huge number of hidden elements, preventing them from participating in the events and pubsub mechanism gives a massive performance boost.
     * 
     * @param {string} id - The id of the node Element 
     * @returns {HTMLElement} The DOM node Element that was removed and cached
     */
    removeAndCacheNode(id) {
        log("kiss.views - Pushed node into cache: " + id)

        let node = $(id)

        this.cachedNodes[id] = {
            parentId: node.parentNode.id,
            index: Array.from(node.parentNode.children).indexOf(node),
            node: node.parentElement.removeChild(node)
        }
        return node
    },

    /**
     * Restore a node and inserts it at its previous position within its parent node
     * 
     * @param {string} id - The id of the node Element to restore
     * @returns {HTMLElement} The DOM node Element that was restore into the DOM
     */
    restoreCachedNode(id) {
        log("kiss.views - Restored node from cache: " + id)

        let cachedNode = this.cachedNodes[id]
        let parentNode = (cachedNode.parentId) ? $(cachedNode.parentId) : document.body
        parentNode.insertBefore(cachedNode.node, parentNode.children[cachedNode.index])
        return cachedNode.node
    },

    /**
     * Get a cached node
     * 
     * @returns {HTMLElement} The DOM node actually cached, or null if not found
     */
    getCachedNode(id) {
        let cachedNode = this.cachedNodes[id]
        if (cachedNode) {
            return cachedNode.node
        } else {
            return null
        }
    },

    /**
     * Deletes a node which is in cache to free memory
     * 
     * @param {string} id 
     */
    deleteCachedNode(id) {
        log("kiss.views - Deleted node from cache: " + id)

        delete this.cachedNodes[id]
    }
}

;