Source

client/ui/data/gallery.js

/** 
 * 
 * The **Gallery** derives from [DataComponent](kiss.ui.DataComponent.html).
 * 
 * It's a [simple gallery](https://kissjs.net/#ui=start&section=gallery) with the following features:
 * - choosing the fields to display in the cards
 * - multi-fields sorting
 * - complex filtering with combination of AND/OR filters
 * - mutli-level grouping
 * - virtual scrolling which also works with grouped data
 * - possibility to choose the featured image to display in the card
 * 
 * @param {object} config
 * @param {Collection} config.collection - The data source collection
 * @param {string} [config.imageFieldId] - The field to use as the image in the gallery. If not set, the first attachment field will be used.
 * @param {object} [config.record] - Record to persist the view configuration into the db
 * @param {object[]} [config.columns] - Where each column is: {title: "abc", type: "text|number|integer|float|date|button", id: "fieldId", button: {config}, renderer: function() {}}
 * @param {string} [config.color] - Hexa color code. Ex: #00aaee
 * @param {boolean} [config.showToolbar] - false to hide the toolbar (default = true)
 * @param {boolean} [config.showActions] - false to hide the custom actions menu (default = true)
 * @param {boolean} [config.showLayoutButton] - false to hide the button to adjust the layout (default = true)
 * @param {boolean} [config.canSearch] - false to hide the search button (default = true)
 * @param {boolean} [config.canSelect] - false to hide the selection checkboxes (default = true)
 * @param {boolean} [config.canSort] - false to hide the sort button (default = true)
 * @param {boolean} [config.canFilter] - false to hide the filter button (default = true)
 * @param {boolean} [config.canGroup] - false to hide the group button (default = true)
 * @param {boolean} [config.canSelectFields] - Can we select the fields (= columns) to display in the gallery? (default = true)
 * @param {boolean} [config.canCreateRecord] - Can we create new records from the gallery?
 * @param {boolean} [config.createRecordText] - Optional text to insert in the button to create a new record, instead of the default model's name
 * @param {object[]} [config.actions] - Array of menu actions, where each menu entry is: {text: "abc", icon: "fas fa-check", action: function() {}}
 * @param {number|string} [config.width]
 * @param {number|string} [config.height]
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-gallery class="a-gallery">
 *      <div class="gallery-toolbar">
 *          <!-- Gallery toolbar items -->
 *      </div>
 *      <div class="gallery-body-container">
 *          <div class="gallery-body">
 *              <!-- Body columns -->
 *          </div>
 *      </div>
 * </a-gallery>
 * ```
 */
kiss.ui.Gallery = class Gallery extends kiss.ui.DataComponent {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myGallery = document.createElement("a-gallery").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myGallery = createGallery({
     *   id: "my-gallery",
     *   color: "#00aaee",
     *   collection: kiss.app.collections["contact"],
     * 
     *   // We can define a menu with custom actions
     *   actions: [
     *       {
     *           text: "Group by status",
     *           icon: "fas fa-sort",
     *           action: () => $("my-gallery").groupBy(["Status"])
     *       }
     *   ],
     *   
     *   // We can add custom methods, and also override default ones
     *   methods: {
     * 
     *      // Override the createRecord method
     *      createRecord(model) {
     *          // Create a record from this model
     *          console.log(model)
     *      },
     * 
     *      // Override the selectRecord method
     *      selectRecord(record) {
     *          // Show the clicked record
     *          console.log(record)
     *      },
     * 
     *      sayHello: () => console.log("Hello"),
     *   }
     * })
     * 
     * myGallery.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a Gallery from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config) {
        // This component must be resized with its parent container
        config.autoSize = true

        // Init the parent DataComponent
        super.init(config)

        // Options
        this.showToolbar = (config.showToolbar !== false)
        this.showActions = (config.showActions !== false)
        this.showSetup = (config.showSetup !== false)
        this.showLayoutButton = (config.showLayoutButton !== false)
        this.showGroupButtons = (config.showGroupButtons !== false)
        this.canSearch = (config.canSearch !== false)
        this.canSort = (config.canSort !== false)
        this.canFilter = (config.canFilter !== false)
        this.canGroup = (config.canGroup !== false)
        this.canSelect = (config.canSelect !== false)
        this.canSelectFields = (config.canSelectFields !== false)
        this.actions = config.actions || []
        this.buttons = config.buttons || []
        this.color = config.color || "#00aaee"
        this.defaultColumnWidth = 20 // in rem

        // Manage groups state
        this.collapsedGroups = new Set()

        // Build gallery skeletton markup
        let id = this.id
        this.innerHTML = /*html*/
            `<div class="gallery">
                <div id="gallery-toolbar:${id}" class="gallery-toolbar">
                    <div id="create:${id}"></div>
                    <div id="actions:${id}"></div>
                    <div id="setup:${id}"></div>
                    <div id="select:${id}"></div>
                    <div id="sort:${id}"></div>
                    <div id="filter:${id}"></div>
                    <div id="group:${id}"></div>
                    <div id="collapse:${id}"></div>
                    <div id="expand:${id}"></div>
                    <div id="refresh:${id}"></div>
                    <div id="search-field:${id}"></div>
                    <div id="search:${id}"></div>
                    <div class="spacer"></div>
                    <div id="layout:${id}"></div>
                </div>

                <div class="gallery-body-container">
                    <div id="gallery-body:${id}" class="gallery-body"></div>
                </div>
            </div>`.removeExtraSpaces()

        // Set gallery components
        this.gallery = this.querySelector(".gallery")
        this.galleryToolbar = this.querySelector(".gallery-toolbar")
        this.galleryBodyContainer = this.querySelector(".gallery-body-container")
        this.galleryBody = this.querySelector(".gallery-body")

        this._initColumns(config.columns)
            ._initGalleryParams(config)
            ._initSize(config)
            ._initElementsVisibility()
            ._initEvents()
            ._initSubscriptions()

        return this
    }

    /**
     * 
     * GALLERY METHODS
     * 
     */

    /**
     * Load data into the gallery.
     * 
     * Remark:
     * - rendering time is proportional to the number of cards and visible fields (cards x fields)
     * - rendering takes an average of 0.03 millisecond per card on an Intel i7-4790K
     * 
     * @ignore
     */
    async load() {
        try {
            log(`kiss.ui - Gallery ${this.id} - Loading collection <${this.collection.id} (changed: ${this.collection.hasChanged})>`)

            // Apply filter, sort, group, projection
            // Priority is given to local config, then to the passed collection, then to default
            this.collection.filter = this.filter
            this.collection.filterSyntax = this.filterSyntax
            this.collection.sort = this.sort
            this.collection.sortSyntax = this.sortSyntax
            this.collection.group = this.group
            this.collection.projection = this.projection
            this.collection.groupUnwind = this.groupUnwind

            // Load records
            await this.collection.find()

            // Get the selected records
            this.getSelection()

            // Render the gallery toolbar
            this._renderToolbar()

        } catch (err) {
            log(err)
            log(`kiss.ui - Gallery ${this.id} - Couldn't load data properly`)
        }
    }

    /**
     * Generic method to refresh / re-render the view
     * 
     * Note: used in dataComponent (parent class) showSearchBar method.
     * This method is invoked to refresh the view after a full-text search has been performed
     */
    refresh() {
        this._render()
    }

    /**
     * Switch to search mode
     * 
     * Show/hide only the necessary buttons in this mode.
     */
    switchToSearchMode() {
        if (kiss.screen.isMobile) {
            $("create:" + this.id).hide()
            $("search:" + this.id).hide()
            $("expand:" + this.id).hide()
            $("collapse:" + this.id).hide()
        }
    }

    /**
     * Reset search mode
     */
    resetSearchMode() {
        if (kiss.screen.isMobile) {
            $("create:" + this.id).show()
            $("search:" + this.id).show()
            $("expand:" + this.id).show()
            $("collapse:" + this.id).show()
        }
    }

    /**
     * Update the gallery color (toolbar buttons + modal windows)
     * 
     * @param {string} newColor
     */
    async setColor(newColor) {
        this.color = newColor
        Array.from(this.galleryToolbar.children).forEach(item => {
            if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
        })
    }

    /**
     * Show the window to setup the gallery:
     * - field used to display the image
     */
    showSetupWindow() {
        let attachmentFields = this.model.getFieldsByType(["attachment", "aiImage"])
            .filter(field => !field.deleted)
            .map(field => {
                return {
                    value: field.id,
                    label: field.label.toTitleCase()
                }
            })

        createPanel({
            icon: "fas fa-image",
            title: txtTitleCase("setup the gallery"),
            headerBackgroundColor: this.color,
            modal: true,
            backdropFilter: true,
            draggable: true,
            closable: true,
            align: "center",
            verticalAlign: "center",
            width: "40rem",

            defaultConfig: {
                labelPosition: "top",
                optionsColor: this.color
            },

            items: [
                // Show images ?
                {
                    type: "checkbox",
                    id: "gallery-showimage:" + this.id,
                    label: txtTitleCase("#gallery show image"),
                    labelPosition: "right",
                    shape: "switch",
                    iconColorOn: this.color,
                    value: this.showImage,
                    events: {
                        change: async function () {
                            let showImage = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                showImage
                            })

                            if (showImage == true) {
                                $("gallery-imagefield:" + viewId).show()
                            } else {
                                $("gallery-imagefield:" + viewId).hide()
                            }
                        }
                    }
                },
                // Source image field
                {
                    hidden: !this.showImage,
                    type: "select",
                    id: "gallery-imagefield:" + this.id,
                    label: txtTitleCase("#gallery image field"),
                    options: attachmentFields,
                    maxHeight: () => kiss.screen.current.height - 200,
                    value: this.imageFieldId,
                    events: {
                        change: async function () {
                            let imageFieldId = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                imageFieldId
                            })
                        }
                    }
                }
            ]
        }).render()
    }

    /**
     * Show the window just under the sorting button
     */
    showSortWindow() {
        let sortButton = $("sort:" + this.id)
        const box = sortButton.getBoundingClientRect()
        super.showSortWindow(box.left, box.top + 40, this.color)
    }

    /**
     * Show the window just under the fields selector button
     */
    showFieldsWindow() {
        let selectionButton = $("select:" + this.id)
        const box = selectionButton.getBoundingClientRect()
        super.showFieldsWindow(box.left, box.top + 40, this.color)
    }

    /**
     * Show the window just under the filter button
     */
    showFilterWindow() {
        super.showFilterWindow(null, null, this.color)
    }

    /**
     * Update the gallery size (recomputes its width and height functions)
     */
    updateLayout() {
        if (this.isConnected) {
            this._setWidth()
            this._setHeight()
            this._render()
        }
    }

    /**
     * Set the gallery column width
     * 
     * @param {number} width - The column width in pixels
     */
    setColumnWidth(width) {
        this.columnWidth = width
        document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")

        // Save new row height locally
        const localStorageId = "config-view-gallery-" + this.id + "-column-width"
        localStorage.setItem(localStorageId, this.columnWidth)
        this.reload()
    }

    /**
     * Reset all the columns to their default width
     */
    async resetColumnsWidth() {
        this.columnWidth = this.defaultColumnWidth
        document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")

        const localStorageId = "config-view-gallery-" + this.id + "-column-width"
        localStorage.removeItem(localStorageId)
    }

    /**
     * Collapse a group
     * 
     * @param {string} groupId
     * returns this
     */
    collapseGroup(groupId, group) {
        group.classList.add("gallery-group-collapsed")
        group.classList.remove("gallery-group-expanded")

        this.collapsedGroups.add(groupId)

        this.galleryBody.querySelectorAll("[groupid]").forEach(el => {
            const gid = el.getAttribute("groupid")

            // Hide records of the group and all subgroups
            if (gid !== groupId && gid.startsWith(groupId + ".")) {
                el.style.display = "none"
            } else if (gid === groupId && !el.classList.contains("gallery-group")) {
                el.style.display = "none"
            }
        })

        this._renderDetailsOfVisibleCards()
        return this
    }

    /**
     * Expand a group
     * 
     * @param {string} groupId
     * @returns this
     */
    expandGroup(groupId, group) {
        group.classList.add("gallery-group-expanded")
        group.classList.remove("gallery-group-collapsed")

        this.collapsedGroups.delete(groupId)

        this.galleryBody.querySelectorAll("[groupid]").forEach(el => {
            const gid = el.getAttribute("groupid")

            if (!(gid === groupId || gid.startsWith(groupId + "."))) return

            const isGroup = el.classList.contains("gallery-group")
            const isRecord = el.classList.contains("gallery-record")

            // Skip if hidden by an ancestor that is still collapsed (excluding self)
            if (this._isHiddenByCollapsedParent(gid, true)) return

            // If it"s a subgroup still collapsed: show the group header but hide its records
            if (this.collapsedGroups.has(gid)) {
                if (isGroup) {
                    el.style.display = "" // show the group header
                } else if (isRecord) {
                    el.style.display = "none" // keep record hidden
                }
                return
            }

            // Otherwise show it
            el.style.display = ""
        })

        this._renderDetailsOfVisibleCards()
        return this
    }

    /**
     * Collapse all groups
     * 
     * @returns this
     */
    collapseAll() {
        this.galleryBody.querySelectorAll(".gallery-group").forEach(el => {
            let groupId = el.getAttribute("groupid")
            this.collapseGroup(groupId, el)
        })
        return this
    }

    /**
     * Expand all groups
     * 
     * @returns this
     */
    expandAll() {
        this.collapsedGroups.clear()

        this.galleryBody.querySelectorAll("[groupid]").forEach(el => {
            el.classList.add("gallery-group-expanded")
            el.classList.remove("gallery-group-collapsed")
            el.style.display = ""
        })

        this._renderDetailsOfVisibleCards()
        return this
    }    

    /**
     * Define the specific gallery params
     * 
     * @private
     * @ignore
     * @param {object} config
     * @param {string} config.imageFieldId - The field to use as the image in the gallery. If not set, the first attachment field will be used.
     * @returns this
     */
    _initGalleryParams(config) {
        if (this.record) {
            this.imageFieldId = config.imageFieldId || this.record.config.imageFieldId
            this.showImage = (config.hasOwnProperty("showImage")) ? !!config.showImage : (this.record.config.showImage !== false)

        } else {
            this.imageFieldId = config.imageFieldId || this.config.imageFieldId
            this.showImage = (config.hasOwnProperty("showImage")) ? !!config.showImage : (this.config.showImage !== false)
        }

        // Defaults to the first attachment field
        if (!this.imageFieldId) {
            let modelAttachmentFields = this.model.getFieldsByType(["attachment"])
            if (modelAttachmentFields.length != 0) {
                this.imageFieldId = modelAttachmentFields[0].id
            }
        }

        return this
    }

    /**
     * Set toolbar visibility
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initElementsVisibility() {
        if (this.showToolbar === false) this.galleryToolbar.style.display = "none"
        return this
    }

    /**
     * Initialize gallery sizes
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initSize(config) {
        if (config.width) {
            this._setWidth()
        } else {
            this.style.width = this.config.width = "100%"
        }

        if (config.height) {
            this._setHeight()
        } else {
            this.style.height = this.config.height = "100%"
        }
        return this
    }

    /**
     * Init the columns width according to local settings and/or config.
     * If the gallery is displayed on a mobile device, the column width is set to the screen width.
     * 
     * @private
     * @ignore
     */
    _initColumnWidth(config = {}) {
        const isMobile = kiss.screen.isMobile
        const isPortrait = kiss.screen.isVertical()

        if (isMobile && isPortrait) {
            this.columnWidth = kiss.screen.current.width - 20
            this.columnWidth = kiss.tools.pxToRem(this.columnWidth)
            document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")
        } else {
            this.columnWidth = this.columnWidth || config.columnWidth || this._getColumnsWidthFromLocalStorage()
            document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")
        }
    }

    /**
     * Initialize all gallery events
     * 
     * @private
     * @ignore
     * @eturns this
     */
    _initEvents() {
        this.onclick = async (event) => {
            const clickedElement = event.target
            const card = clickedElement.closest(".gallery-record")
            const group = clickedElement.closest(".gallery-group")
            const checkbox = clickedElement.closest(".gallery-checkbox")

            // Open a record
            if (checkbox) {
                const recordId = card.getAttribute("recordid")
                this._toggleSelect(recordId)
            } else if (card) {
                const recordId = card.getAttribute("recordid")
                const record = await this.collection.getRecord(recordId)
                await this.selectRecord(record)
            } else if (group) {
                const groupId = group.getAttribute("groupId")
                const groupState = this._getGroupState(groupId)
                if (groupState == "expanded") {
                    this.collapseGroup(groupId, group)
                } else {
                    this.expandGroup(groupId, group)
                }
            }
        }

        return this
    }

    /**
     * Highlight the records that are selected in the rendered page
     * 
     * @private
     * @ignore
     */
    _renderSelection() {
        if (!this.selectedRecords) return

        this.selectedRecords.forEach(recordId => {
            this._select(recordId)
        })
    }

    /**
     * Restore the selection of the rendered page.
     * First clean the existing selection that might be obsolete,
     * then add the active selection.
     * 
     * @private
     * @ignore
     */
    _renderSelectionRestore() {
        this.getSelection()
        this._renderGalleryBody()
        this._renderDetailsOfVisibleCards()
    }

    /**
     * Check / Uncheck a card with the card checkbox.
     * 
     * @private
     * @ignore
     * @param {string} recordId - The record id to select / deselect
     */
    _toggleSelect(recordId) {
        let isSelected = (this.selectedRecords.indexOf(recordId) != -1)

        if (isSelected) {
            this._deselect(recordId)
            kiss.selection.delete(this.id, recordId)
        } else {
            this._select(recordId)
            kiss.selection.insertOne(this.id, recordId)
        }

        // Update the datatable
        this.selectedRecords = kiss.selection.get(this.id)
        return recordId
    }

    /**
     * Select a card, and add it to the collection selection.
     * 
     * @private
     * @ignore
     * @param {string} recordId - The record id to select
     */
    _select(recordId) {
        const card = this.querySelector('.gallery-record[recordid="' + recordId + '"]')
        const checkbox = card.querySelector(".gallery-checkbox")
        if (checkbox) {
            checkbox.classList.remove("fa-square")
            checkbox.classList.add("fa-check-square")
            checkbox.setAttribute("selected", "true")
            card.classList.add("gallery-record-selected")
        }
    }

    /**
     * Deselect a card, and remove it from the collection selection.
     * 
     * @private
     * @ignore
     * @param {string} recordId - The record id to deselect
     */    
    _deselect(recordId) {
        const card = this.querySelector('.gallery-record[recordid="' + recordId + '"]')
        const checkbox = card.querySelector(".gallery-checkbox")
        if (checkbox) {
            checkbox.classList.remove("fa-check-square")
            checkbox.classList.add("fa-square")
            checkbox.setAttribute("selected", "false")
            card.classList.remove("gallery-record-selected")
        }
    }

    /**
     * Get the state of a group
     * 
     * @private
     * @ignore
     * @param {string} groupId 
     * @returns {string} "collapsed" or "expanded"
     */
    _getGroupState(groupId) {
        return this.collapsedGroups.has(groupId) ? "collapsed" : "expanded"
    }

    /**
     * Check if a group is hidden by a collapsed parent
     * 
     * @private
     * @ignore
     * @param {string} groupId 
     * @param {boolean} excludeSelf
     * @returns {boolean} true if the group is hidden by a collapsed parent
     */
    _isHiddenByCollapsedParent(groupId, excludeSelf = false) {
        const parts = groupId.split(".")
        while (parts.length > 0) {
            const parentId = parts.join(".")
            if (this.collapsedGroups.has(parentId)) {
                if (excludeSelf && parentId === groupId) {
                    // Ignore self if requested
                } else {
                    return true
                }
            }
            parts.pop()
        }
        return false
    }

    /**
     * Initialize subscriptions to PubSub
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initSubscriptions() {
        super._initSubscriptions()

        const viewModelId = this.modelId.toUpperCase()

        // React to database mutations
        this.subscriptions = this.subscriptions.concat([
            // Local events (not coming from websocket)
            subscribe("EVT_VIEW_SETUP:" + this.id, (msgData) => this._updateConfig(msgData)),

            // React to database mutations
            subscribe("EVT_DB_INSERT:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
            subscribe("EVT_DB_UPDATE:" + viewModelId, (msgData) => this._updateOneAndReload(msgData)),
            subscribe("EVT_DB_DELETE:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
            subscribe("EVT_DB_INSERT_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
            subscribe("EVT_DB_UPDATE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
            subscribe("EVT_DB_DELETE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
            subscribe("EVT_DB_UPDATE_BULK", (msgData) => this._reloadWhenNeeded(msgData, 2000)),
        ])

        return this
    }

    /**
     * Update a single record then reload the view if required
     * 
     * @private
     * @ignore
     * @param {object} msgData - The original pubsub message
     */
    async _updateOneAndReload(msgData) {
        const sortFields = this.sort.map(sort => Object.keys(sort)[0])
        const filterFields = kiss.db.mongo.getFilterFields(this.filter)

        let groupHasChanged = false
        let sortHasChanged = false
        let filterHasChanged = false

        let updates = msgData.data
        for (let fieldId of Object.keys(updates)) {
            if (this.group.indexOf(fieldId) != -1) groupHasChanged = true
            if (sortFields.indexOf(fieldId) != -1) sortHasChanged = true
            if (filterFields.indexOf(fieldId) != -1) filterHasChanged = true
        }

        this._updateRecord(msgData.id)

        if (sortHasChanged || filterHasChanged || groupHasChanged) {
            this.reload()
        }
    }

    /**
     * Update a single record of the gallery.
     * 
     * @private
     * @ignore
     * @param {string} recordId 
     */
    _updateRecord(recordId) {
        const record = this.collection.getRecord(recordId)
        const recordNode = document.querySelector(`.gallery-record[recordid="${recordId}"]`)

        if (recordNode) {
            const replacementNode = document.createElement("div")
            const recordIndex = recordNode.getAttribute("row")
            replacementNode.setAttribute("row", recordIndex)
            replacementNode.classList.add("gallery-record")

            let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
            if (isSelected) replacementNode.classList.add("gallery-record-selected")
            
            replacementNode.innerHTML = this._renderRecordAsCard(record, recordIndex, isSelected)
            recordNode.parentNode.replaceChild(replacementNode, recordNode)
            replacementNode.setAttribute("recordid", recordId)
        }
    }

    /**
     * Update the gallery configuration
     * 
     * @private
     * @ignore
     * @param {object} newConfig 
     */
    async _updateConfig(newConfig) {
        if (newConfig.hasOwnProperty("showImage")) this.showImage = newConfig.showImage
        if (newConfig.hasOwnProperty("imageFieldId")) this.imageFieldId = newConfig.imageFieldId

        this._render()

        let currentConfig
        if (this.record) {
            currentConfig = this.record.config
        } else {
            currentConfig = {
                showImage: this.showImage,
                imageFieldId: this.imageFieldId,
                columns: this.columns
            }
        }

        let config = Object.assign(currentConfig, newConfig)
        await this.updateConfig({
            config
        })
    }

    /**
     * Adjust the component width
     * 
     * @ignore
     * @param {(number|string|function)} [width] - The width to set
     */
    _setWidth() {
        let newWidth = this._computeSize("width")

        setTimeout(() => {
            this.style.width = newWidth
            this.gallery.style.width = this.clientWidth.toString() + "px"
        }, 50)
    }

    /**
     * Adjust the components height
     * 
     * @private
     * @ignore
     * @param {(number|string|function)} [height] - The height to set
     */
    _setHeight() {
        let newHeight = this._computeSize("height")
        this.style.height = this.gallery.style.height = newHeight
    }

    /**
     * Get the columns width config stored locally
     * 
     * @private
     * @ignore
     */
    _getColumnsWidthFromLocalStorage() {
        const localStorageId = "config-view-gallery-" + this.id + "-column-width"
        const columnWidth = localStorage.getItem(localStorageId)
        if (!columnWidth) return this.defaultColumnWidth
        return Number(columnWidth)
    }

    /**
     * 
     * DATA GROUPING MANAGEMENT
     * 
     */

    /**
     * Group data by a list of fields
     * 
     * @private
     * @ignore
     * @param {string[]} groupFields - Array of fields to group by.
     */
    async _dataGroupBy(groupFields) {
        this.group = groupFields

        // Generates the groups, then get the grouped records
        await this.collection.groupBy(groupFields)
        this._render()

        // Save the new group config
        await this.updateConfig({
            group: this.group
        })
    }

    /**
     * 
     * RENDERING THE GALLERY
     * 
     */

    /**
     * Render the gallery
     * 
     * @private
     * @ignore
     * @returns this
     */
    _render() {
        // Adjust size
        this._initColumnWidth()

        // Filters out hidden and deleted columns
        this.visibleColumns = this.columns.filter(column => column.hidden != true && column.deleted != true)

        // Render body
        this._renderGalleryBody()
        this._observeCards()

        return this
    }

    /**
     * Observe the cards to render them only when they are visible
     * 
     * @private
     * @ignore
     */
    _observeCards() {
        const galleryColumnContainers = this.querySelectorAll(".gallery-group-container")
        galleryColumnContainers.forEach(container => {
            container.onscroll = () => {
                if (kiss.global.galleryScrollStop) return

                clearTimeout(this.scrollTimeout)
                this.scrollTimeout = setTimeout(() => this._renderDetailsOfVisibleCards(), 10)
                // requestAnimationFrame(() => this._renderDetailsOfVisibleCards())
            }
        })

        this.galleryBodyContainer.onscroll = () => {
            clearTimeout(this.scrollTimeout)
            this.scrollTimeout = setTimeout(() => this._renderDetailsOfVisibleCards(), 10)
            // requestAnimationFrame(() => this._renderDetailsOfVisibleCards())
        }

        this._renderDetailsOfVisibleCards()
    }

    /**
     * Render the details of the visible cards
     * 
     * @private
     * @ignore
     */
    _renderDetailsOfVisibleCards() {
        const _this = this
        const collection = this.collection

        requestAnimationFrame(() => {
            document.querySelectorAll(".gallery-record").forEach(card => {
                if (_this._isElementVisible(card)) {
                    const isRendered = card.getAttribute("rendered")
                    if (isRendered == "true") return

                    const recordId = card.getAttribute("recordid")
                    const rowIndex = card.getAttribute("row")
                    const record = collection.getRecord(recordId)

                    let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
                    if (isSelected) card.classList.add("gallery-record-selected")

                    const cardContent = _this._renderRecordAsCard(record, rowIndex, isSelected)
                    const cardElement = _this.querySelector('.gallery-record[recordid="' + recordId + '"]')

                    cardElement.innerHTML = cardContent
                    cardElement.setAttribute("rendered", "true")
                }
            })
        })
    }

    /**
     * Check if an element is partly visible in the viewport
     * 
     * @private
     * @ignore
     */
    _isElementVisible(el) {
        const rect = el.getBoundingClientRect()
        const windowHeight = (window.innerHeight || document.documentElement.clientHeight)
        const windowWidth = (window.innerWidth || document.documentElement.clientWidth)
        const vertInView = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0)
        const horInView = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0)
        return (vertInView && horInView)
    }

    /**
     * Render the gallery body
     * 
     * Tech note: we don't use string litterals to build the HTML because it's slower than native String concatenation
     * 
     * @private
     * @ignore
     */
    _renderGalleryBody() {
        let cardIndex = 0
        let gallery = ""

        if (this.collection.group.length === 0) {
            // RENDER WITH NO GROUPING
            this.galleryBody.classList.add("gallery-body-no-group")

            for (let rowIndex = 0; rowIndex < this.collection.records.length; rowIndex++) {
                let record = this.collection.records[rowIndex]
                cardIndex++
                gallery += this._renderGalleryCardContainer(record, cardIndex)
                if (rowIndex == this.collection.records.length - 1) gallery += "</div>"
            }

            if (this.collection.records.length == "0") {
                this.galleryBodyContainer.classList.add("gallery-body-container-empty")
            } else {
                this.galleryBodyContainer.classList.remove("gallery-body-container-empty")
            }

        } else {
            // RENDER WITH GROUPING
            this.galleryBody.classList.remove("gallery-body-no-group")
            let groupId = ""
            let lastCellType = "group"

            for (let rowIndex = 0; rowIndex < this.collection.records.length; rowIndex++) {
                let record = this.collection.records[rowIndex]

                if (record.$type == "group" && record.$groupLevel == 0) {
                    groupId = record.$groupId
                    cardIndex = 0

                    if (lastCellType == "record") { // Close the last column container
                        gallery += "</div>"
                    }

                    lastCellType = "group"

                    // Group container
                    gallery += this._renderGalleryGroupContainer(record, rowIndex)

                } else if (record.$type == "group") {

                    // Sub-category
                    groupId = record.$groupId
                    const marginSize = groupId.length + "rem"
                    const marginStyle = `style="margin-left: ${marginSize};"`

                    const value = this._renderGroupValue(record)
                    gallery += "<div class=\"gallery-group gallery-group-expanded\" groupId=\"" + groupId + "\" " + marginStyle + ">" + groupId + " - &nbsp;" + value + "</div>"

                } else {
                    cardIndex++
                    lastCellType = "record"

                    // Regular row
                    gallery += this._renderGalleryCardContainer(record, cardIndex, groupId)
                }

                // Close the last group container
                if (rowIndex == this.collection.records.length - 1) {
                    gallery += "</div>"
                }
            }

            if (this.collection.records.length == "0") {
                this.galleryBodyContainer.classList.add("gallery-body-container-empty")
            } else {
                this.galleryBodyContainer.classList.remove("gallery-body-container-empty")
            }
        }

        this.galleryBody.innerHTML = gallery
    }

    /**
     * Get the color of a category, if any
     * 
     * @private
     * @ignore
     * @param {string} groupFieldId 
     * @param {*} columnValue 
     * @returns {string} The color of the category
     */
    _getCategoryColor(groupFieldId, columnValue) {
        const field = this.model.getField(groupFieldId)
        const options = field.options || []
        const option = options.find(option => option.value == columnValue)
        return (option) ? option.color : "#cccccc"
    }

    /**
     * Render a Gallery group
     * 
     * @private
     * @ignore
     * @param {object} record 
     * @param {number} rowIndex 
     * @returns {string} Html source for Gallery column container
     */
    _renderGalleryGroupContainer(record, rowIndex) {
        const value = this._renderGroupValue(record)

        return "<div row=\"" + rowIndex + "\" class=\"gallery-group-container\">" +
               "<div class=\"gallery-group gallery-group-expanded\" groupId=\"" + record.$groupId + "\">" + record.$groupId + " - &nbsp;" + value + "</div>"
    }

    /**
     * Render the HTML value of a group
     * 
     * @private
     * @ignore
     * @param {object} record 
     * @returns {*} The HTML value of the group
     */
    _renderGroupValue(record) {
        const groupFieldId = this.group[record.$groupLevel]
        const field = this.model.getField(groupFieldId)

        if (field) {
            return this._renderSingleValue(field, record.$name, record)
        }
        else {
            return record.$name
        }        
    }

    /**
     * Render a single row of the gallery
     * 
     * @private
     * @ignore
     * @param {number} rowIndex
     * @returns {HTMLDivElement} The div containing the row
     */
    _renderGalleryCardContainer(record, cardIndex, groupId) {
        return "<div row=\"" + cardIndex + "\" class=\"gallery-record\" groupId=\"" + (groupId || "") + "\" recordid=\"" + record.id + "\"></div>"
    }

    /**
     * Render a single record as a Card
     * 
     * @private
     * @ignore
     * @param {object} record
     * @param {number} index - The index of the record in the gallery
     * @returns {string} Html for a single record
     */
    _renderRecordAsCard(record, index, isSelected = false) {
        let recordHtml =
            ((this.canSelect) ? "<span class=\"gallery-checkbox " + ((isSelected) ? "far fa-check-square" : "far fa-square") + "\"></span>" : "") + // Selection checkbox
            "<span class=\"gallery-record-index\">" + index + "</span>"

        if (this.showImage) {
            const imageFieldId = this.imageFieldId
            let images = record[imageFieldId] || [record]
            
            if (images && Array.isArray(images) && images.length != 0) {
                const thumbSize = (this.columnWidth > 20) ? "l" : "m"
                let imageHtml = this._renderCardImage({
                    value: [images[0]],
                    config: {
                        thumbSize
                    }
                })
                recordHtml += "<div class=\"gallery-record-image-container\">" + imageHtml + "</div>"
            } else {
                recordHtml += "<div class=\"gallery-record-image-container\"><span class=\"fas fa-archive gallery-record-empty\"></span></div>"
            }
        }

        this.columns
            .filter(column => column.hidden !== true)
            .forEach(column => {
                let field = this.model.getField(column.id)
                if (!field) return

                if (["password", "link"].includes(field.type)) return

                let value = record[column.id]
                if (!value && value !== false && value !== 0) return

                let valueHtml = this._renderSingleValue(field, value, record)
                recordHtml += /*html*/ `
                    <div class="gallery-record-field">
                        <div class="gallery-record-label">${field.label} ${(field.unit) ? `(${field.unit})` : ""}</div>
                        <div class="gallery-record-value">${valueHtml}</div>
                    </div>
                `.removeExtraSpaces()
            })

        return recordHtml
    }

    /**
     * Render the image of a card
     * 
     * @private
     * @ignore
     * @param {object} params
     * @param {Array} params.value - The value of the field, should be an array of files
     * @param {object} [params.config] - Additional configuration
     * @returns {string} Html for the card image
     */
    _renderCardImage({
        value,
        config = {}
    }) {
        if ((!value) || (value == " ") || !Array.isArray(value)) return ""

        let attachmentItems = value.map((file, i) => {
            if (!file.path) return ""

            let preview
            let filePath = kiss.tools.createFileURL(file, config.thumbSize || "s")
            const fileExtension = file.path.split(".").pop().toLowerCase()

            if (["jpg", "jpeg", "png", "gif", "webp"].indexOf(fileExtension) != -1) {
                // Image
                preview = `<img id="${file.id}" class="gallery-record-image" src="${filePath}" loading="lazy"></img>`
            } else {
                // Other
                const {
                    icon,
                    color
                } = kiss.tools.fileToIcon(fileExtension)
                preview = `<span id="${file.id}" style="color: ${color}" class="fas ${icon} gallery-record-icon"></span>`
            }

            return preview
        }).join("")

        return attachmentItems
    }

    /**
     * Render a single value inside a card
     * 
     * @private
     * @ignore
     * @param {object} field - Field to render
     * @param {*} value - Field value
     * @param {object} record - The record, useful for custom renderers
     * @returns {string} Html for the value
     */
    _renderSingleValue(field, value, record, config) {
        const renderer = kiss.fields.renderers[this.model.id][field.id]
        const type = kiss.fields.getFieldType(field)

        switch (type) {
            case "date":
            case "textarea":
            case "aiTextarea":
            case "select":
            case "directory":
            case "checkbox":
            case "rating":
            case "color":
            case "icon":
            case "attachment":
            case "aiImage":
            case "selectViewColumn":
                return renderer({
                    value,
                    record,
                    config
                })

            case "number":
            case "slider":
                return renderer({
                    value,
                    record,
                    config: {
                        unit: false
                    }
                })
            default:
                return value
        }
    }

    /**
     * Render the toolbar
     * 
     * @private
     * @ignore
     */
    _renderToolbar() {
        // If the toolbar is already rendered, we just update it
        if (this.isToolbarRendered) {
            this._groupUpdateGroupingFields()
            return
        }

        // New record creation button
        createButton({
            hidden: !this.canCreateRecord,
            class: "gallery-create-record",
            target: "create:" + this.id,
            text: this.config.createRecordText || this.model.name.toTitleCase(),
            icon: "fas fa-plus",
            iconColor: this.color,
            borderWidth: 3,
            borderRadius: "3.2rem",
            maxWidth: (kiss.screen.isMobile && kiss.screen.isVertical()) ? "16rem" : null,
            action: async () => this.createRecord(this.model)
        }).render()

        // Actions button
        createButton({
            hidden: this.showActions === false,
            target: "actions:" + this.id,
            tip: txtTitleCase("actions"),
            icon: "fas fa-bolt",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this._buildActionMenu()
        }).render()

        // Setup the gallery
        createButton({
            hidden: !this.showSetup,
            target: "setup:" + this.id,
            tip: txtTitleCase("setup the gallery"),
            icon: "fas fa-cog",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.showSetupWindow()
        }).render()

        // Column selection button
        createButton({
            hidden: !this.canSelectFields,
            target: "select:" + this.id,
            tip: txtTitleCase("#display fields"),
            icon: "fas fa-bars fa-rotate-90",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.showFieldsWindow()
        }).render()

        // Sorting button
        createButton({
            hidden: !this.canSort,
            target: "sort:" + this.id,
            tip: txtTitleCase("to sort"),
            icon: "fas fa-sort",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.showSortWindow()
        }).render()

        // Filtering button
        createButton({
            hidden: !this.canFilter,
            target: "filter:" + this.id,
            tip: txtTitleCase("to filter"),
            icon: "fas fa-filter",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.showFilterWindow()
        }).render()

        // Layout button
        createButton({
            hidden: !this.showLayoutButton,
            target: "layout:" + this.id,
            tip: {
                text: txtTitleCase("layout"),
                minWidth: "10rem"
            },
            icon: "fas fa-ellipsis-v",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this._buildLayoutMenu()
        }).render()

        // Grouping
        let groupingFields = this._groupGetModelFields({
            excludeSystemFields: true,
            excludePluginFields: true
        })
        let groupingFieldValues = []

        this.collection.group.forEach(fieldId => {
            let groupingField = groupingFields.find(field => field.value == fieldId)
            if (groupingField) groupingFieldValues.push(groupingField.value)
        })

        createSelect({
            hidden: !this.canGroup,
            target: "group:" + this.id,
            id: "grouping-field:" + this.id,
            label: txtTitleCase("group by"),
            multiple: true,
            allowClickToDelete: true,
            options: groupingFields,
            minWidth: "20rem",
            maxHeight: () => kiss.screen.current.height - 200,
            optionsColor: this.color,
            value: groupingFieldValues,
            styles: {
                "this": "align-items: center;",
                "field-label": "white-space: nowrap;",
                "field-select": "white-space: nowrap;",
            },
            events: {
                change: async function (event) {
                    let groupFields = this.getValue()

                    // Restrict to 6 grouping fields
                    if (groupFields.length > 6) {
                        let fieldGroupSelect = $(this.id)
                        fieldGroupSelect.value = fieldGroupSelect.getValue().slice(0, 6)
                        fieldGroupSelect._renderValues()

                        createDialog({
                            type: "message",
                            title: txtTitleCase("seriously"),
                            icon: "fas fa-exclamation-triangle",
                            message: txtTitleCase("#too many groups"),
                            buttonOKText: txtTitleCase("#understood")
                        })
                        return
                    }

                    // Publish the "grouping" event
                    let viewId = this.id.split(":")[1]
                    publish("EVT_VIEW_GROUPING:" + viewId, groupFields)
                }
            }
        }).render()

        // Expand button
        this.buttonExpand = createButton({
            hidden: (!this.showGroupButtons || this.collection.group.length === 0),

            target: "expand:" + this.id,
            tip: txtTitleCase("expand all"),
            icon: "far fa-plus-square",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.expandAll()
        }).render()

        // Collapse button
        this.buttonCollapse = createButton({
            hidden: (!this.showGroupButtons || this.collection.group.length === 0),

            target: "collapse:" + this.id,
            tip: txtTitleCase("collapse all"),
            icon: "far fa-minus-square",
            iconColor: this.color,
            width: "3.2rem",
            action: () => this.collapseAll()
        }).render()

        // View refresh button
        if (!kiss.screen.isMobile) {
            createButton({
                target: "refresh:" + this.id,
                tip: txtTitleCase("refresh"),
                icon: "fas fa-undo-alt",
                iconColor: this.color,
                width: "3.2rem",
                events: {
                    click: () => this.reload()
                }
            }).render()
        }

        // Search button
        createButton({
            hidden: !this.canSearch,
            target: "search:" + this.id,
            icon: "fas fa-search",
            iconColor: this.color,
            width: "3.2rem",
            events: {
                click: () => this.showSearchBar()
            }
        }).render()

        // Flag the toolbar as "rendered", so that the method _renderToolbar() is idempotent
        this.isToolbarRendered = true
    }

    /**
     * 
     * OTHER MISC METHODS
     * 
     */

    /**
     * Render the menu to change gallery layout
     * 
     * @private
     * @ignore
     */
    async _buildLayoutMenu() {
        let buttonLeftPosition = $("layout:" + this.id).offsetLeft
        let buttonTopPosition = $("layout:" + this.id).offsetTop

        createMenu({
            top: buttonTopPosition,
            left: buttonLeftPosition,
            items: [
                // Title
                txtTitleCase("cell size"),
                "-",
                // Change row height to  COMPACT
                {
                    icon: "fas fa-circle",
                    iconSize: "0.2rem",
                    text: txtTitleCase("compact"),
                    action: () => {
                        this.columnWidth = 15
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to NORMAL
                {
                    icon: "fas fa-circle",
                    iconSize: "0.6rem",
                    text: txtTitleCase("normal"),
                    action: () => {
                        this.columnWidth = this.defaultColumnWidth
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to MEDIUM
                {
                    icon: "fas fa-circle",
                    iconSize: "1rem",
                    text: txtTitleCase("medium"),
                    action: () => {
                        this.columnWidth = 25
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to TALL
                {
                    icon: "fas fa-circle",
                    iconSize: "1.4rem",
                    text: txtTitleCase("tall"),
                    action: () => {
                        this.columnWidth = 30
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to VERY TALL
                {
                    icon: "fas fa-circle",
                    iconSize: "1.8rem",
                    text: txtTitleCase("very tall"),
                    action: () => {
                        this.columnWidth = 35
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                "-",
                // Reset columns width
                {
                    icon: "fas fa-undo-alt",
                    text: txtTitleCase("#reset view params"),
                    action: () => this.resetLocalViewParameters()
                }
            ]
        }).render()
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-gallery", kiss.ui.Gallery)

/**
 * Shorthand to create a new Gallery. See [kiss.ui.Gallery](kiss.ui.Gallery.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createGallery = (config) => document.createElement("a-gallery").init(config)

;