Source

client/ui/data/kanban.js

/** 
 * 
 * The **Kanban** derives from [DataComponent](kiss.ui.DataComponent.html).
 * 
 * @param {object} config
 * @param {Collection} config.collection - The data source collection
 * @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.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 kanban? (default = true)
 * @param {boolean} [config.canCreateRecord] - Can we create new records from the kanban?
 * @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-kanban class="a-kanban">
 *      <div class="kanban-toolbar">
 *          <!-- Kanban toolbar items -->
 *      </div>
 *      <div class="kanban-header-container">
 *          <div class="kanban-header">
 *              <!-- Header columns -->
 *          </div>
 *      </div>
 *      <div class="kanban-body-container">
 *          <div class="kanban-body">
 *              <!-- Body columns -->
 *          </div>
 *      </div>
 * </a-kanban>
 * ```
 */
kiss.ui.Kanban = class Kanban extends kiss.ui.DataComponent {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the 3 following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myKanban = document.createElement("a-kanban").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myKanban = createKanban({
     *   id: "my-kanban",
     *   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-kanban").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"),
     *   }
     * })
     * 
     * myKanban.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a Kanban 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.showLayoutButton = (config.showLayoutButton !== false)
        this.canSearch = (config.canSearch !== false)
        this.canSort = (config.canSort !== false)
        this.canFilter = (config.canFilter !== false)
        this.canGroup = (config.canGroup !== false)
        this.canSelectFields = (config.canSelectFields !== false)
        this.actions = config.actions || []
        this.buttons = config.buttons || []
        this.color = config.color || "#00aaee"
        this.defaultColumnWidth = 280

        // Build kanban skeletton markup
        let id = this.id
        this.innerHTML = /*html*/
            `<div class="kanban">
                <div id="kanban-toolbar:${id}" class="kanban-toolbar">
                    <div id="create:${id}"></div>
                    <div id="actions:${id}"></div>
                    <div id="select:${id}"></div>
                    <div id="sort:${id}"></div>
                    <div id="filter:${id}"></div>
                    <div id="group:${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="kanban-header-container">
                    <div id="kanban-header:${id}" class="kanban-header"></div>
                </div>

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

        // Set kanban components
        this.kanban = this.querySelector(".kanban")
        this.kanbanToolbar = this.querySelector(".kanban-toolbar")
        this.kanbanHeaderContainer = this.querySelector(".kanban-header-container")
        this.kanbanHeader = this.querySelector(".kanban-header")
        this.kanbanBodyContainer = this.querySelector(".kanban-body-container")
        this.kanbanBody = this.querySelector(".kanban-body")

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

        return this
    }

    /**
     * 
     * KANBAN METHODS
     * 
     */

    /**
     * Load data into the kanban.
     * 
     * 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 - Kanban ${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()

            // Render the kanban toolbar
            this._renderToolbar()

        } catch (err) {
            log(err)
            log(`kiss.ui - Kanban ${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()
    }

    /**
     * Move a card to a new column.
     * This is equivalent to changing the value of a field.
     * 
     * @param {string} recordId 
     * @param {string} fieldId 
     * @param {string} value
     */
    moveCardToColumn(recordId, fieldId, value) {
        const record = this.collection.getRecord(recordId)
        const currentValue = record[fieldId]
        const color = this._getCategoryColor(fieldId, value)

        let message
        if (value !== undefined && value !== "") {
            message = txtTitleCase("#move card") + ` <span class="fas fa-circle kanban-column-header-icon" style="color: ${color}"></span><b>${value}</b> ?`
        } else {
            message = txtTitleCase("#move card") + ` <span class="fas fa-circle kanban-column-header-icon" style="color: #cccccc"></span><b>${txtTitleCase("#no category")}</b> ?`
        }
        createDialog({
            title: currentValue + " → " + value,
            icon: "fas fa-clipboard-check",
            type: "dialog",
            message,
            action: async () => {
                const loadingId = kiss.loadingSpinner.show()
                await this.collection.updateOne(recordId, {
                    [fieldId]: value
                })
                kiss.loadingSpinner.hide(loadingId)
                await this.reload()

                createNotification(txtTitleCase("#card moved") + " " + value)
                // const card = this.querySelector(`.kanban-record[recordid="${recordId}"]`)
                // setTimeout(() => this.jumpToCard(card), 1000)
            }
        })
    }

    /**
     * WORK IN PROGRESS - FOCUSING ON THE DRAGGED CARD
     */
    jumpToCard(card) {
        kiss.global.kanbanScrollStop = true
        const container = card.closest(".kanban-column-container")
        const cardPosition = card.getBoundingClientRect()
        const containerPosition = container.getBoundingClientRect()
        const relativeTop = cardPosition.top - containerPosition.top + container.scrollTop
        container.scrollTop = relativeTop - 20
    }

    scrollToCard(card) {
        kiss.global.kanbanScrollStop = true
        let attempts = 0

        function scroll() {
            card.scrollIntoView({
                block: "end",
                inline: "end",
                // behavior: "auto"
            });

            setTimeout(() => {
                const cardPosition = card.getBoundingClientRect()
                const isCardInView = (
                    cardPosition.top >= 0 &&
                    cardPosition.bottom <= (window.innerHeight || document.documentElement.clientHeight)
                )

                if (!isCardInView && attempts < 5) {
                    attempts++
                    scroll()
                } else kiss.global.kanbanScrollStop = false
            }, 300)
        }
        scroll()
    }

    /**
     * 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()
        }
    }

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

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

    /**
     * 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() {
        let filterButton = $("filter:" + this.id)
        const box = filterButton.getBoundingClientRect()
        super.showFilterWindow(null, null, this.color)
    }

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

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

        // Save new row height locally
        const localStorageId = "config-kanban-" + 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("--kanban-column-width", this.columnWidth + "px")

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

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

    /**
     * Initialize kanban 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 kanban 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
            document.documentElement.style.setProperty("--kanban-column-width", this.columnWidth + "px")
        } else {
            this.columnWidth = this.columnWidth || config.columnWidth || this._getColumnsWidthFromLocalStorage()
            document.documentElement.style.setProperty("--kanban-column-width", this.columnWidth + "px")
        }
    }

    /**
     * Initialize all kanban events
     * 
     * @private
     * @ignore
     * @eturns this
     */
    _initEvents() {

        // Clicked somewhere in the kanban
        this.onclick = async (event) => {
            const clickedElement = event.target
            const card = clickedElement.closest(".kanban-record")
            const cardButton = clickedElement.closest(".kanban-record-button")

            // Clicked on a card button to switch card to another column
            if (cardButton) {
                event.stop()

                const recordId = card.getAttribute("recordid")
                const column = card.closest(".kanban-column-container")
                const columnValue = column.getAttribute("value")
                const fieldId = column.getAttribute("fieldid")
                const field = this.model.getField(fieldId)
                if (!field) return

                return createMenu({
                    title: txtTitleCase("#move card"),
                    icon: "fas fa-exchange-alt",
                    items: field.options
                        .filter(option => option.value != columnValue)
                        .map(option => {
                            return {
                                text: option.value,
                                icon: "fas fa-circle",
                                iconColor: option.color,
                                action: () => this.moveCardToColumn(recordId, fieldId, option.value)
                            }
                        })
                }).render()
            }

            // Open a record
            if (card) {
                const recordId = card.getAttribute("recordid")
                const record = await this.collection.getRecord(recordId)
                await this.selectRecord(record)
            }
        }

        // Clicked on a column resizer
        this.kanbanHeader.onmousedown = (event) => {
            const clickedElement = event.target
            if (clickedElement.classList.contains("kanban-column-header-resizer")) {
                this._columnsResizeWithDragAndDrop(event, clickedElement)
            }
        }

        return this
    }

    /**
     * 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([
            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 kanban.
     * 
     * @private
     * @ignore
     * @param {string} recordId 
     */
    _updateRecord(recordId) {
        const record = this.collection.getRecord(recordId)
        const recordNode = document.querySelector(`.kanban-record[recordid="${recordId}"]`)

        if (recordNode) {
            const replacementNode = document.createElement("div")
            const recordIndex = recordNode.getAttribute("row")
            replacementNode.setAttribute("row", recordIndex)
            replacementNode.classList.add("kanban-record")
            replacementNode.innerHTML = this._renderRecordAsCard(record, recordIndex)
            recordNode.parentNode.replaceChild(replacementNode, recordNode)
            replacementNode.setAttribute("recordid", recordId)
        }
    }

    /**
     * Initialize the Cards drag and drop
     * 
     * @private
     * @ignore
     */
    _enableDragAndDrop() {
        // Autoscroll management when dragging a card close to the edge of the screen
        let autoScrollInterval
        const kanbanContainer = document.querySelector('.kanban-body-container')
        const scrollSpeed = 20
        const threshold = 100

        function autoScroll(mouseX) {
            clearInterval(autoScrollInterval)
            autoScrollInterval = setInterval(() => {
                if (mouseX < kanbanContainer.getBoundingClientRect().left + threshold) {
                    requestAnimationFrame(() => kanbanContainer.scrollLeft = kanbanContainer.scrollLeft - scrollSpeed)
                } else if (mouseX > kiss.screen.current.width - threshold) {
                    requestAnimationFrame(() => kanbanContainer.scrollLeft = kanbanContainer.scrollLeft + scrollSpeed)
                }
            }, 10)
        }

        const stopAutoScroll = () => clearInterval(autoScrollInterval)

        // Drag and drop helpers
        const getColumns = () => document.querySelectorAll(".kanban-column-container")
        const resetColumns = () => getColumns().forEach(column => column.classList.remove("kanban-column-highlight"))
        const resetColumn = (column) => column.classList.remove("kanban-column-highlight")
        const highlightColumn = (column) => column.classList.add("kanban-column-highlight")

        // Drag and drop events
        const dndEvents = {
            ondragstart: (event) => {
                const kanbanColumn = event.target.closest(".kanban-column-container")
                kiss.context.draggedValue = kanbanColumn.getAttribute("value")
                kiss.context.draggedRecordId = event.target.getAttribute("recordid")
            },

            ondragover: (event) => {
                event.preventDefault()
                resetColumns()
                const kanbanColumn = event.target.closest(".kanban-column-container")
                if (!kanbanColumn) return
                const value = kanbanColumn.getAttribute("value")
                if (value == kiss.context.draggedValue) return
                highlightColumn(kanbanColumn)
                autoScroll(event.clientX);
            },

            ondrop: (event) => {
                event.preventDefault()
                resetColumns()
                const kanbanColumn = event.target.closest(".kanban-column-container")
                if (!kanbanColumn) return

                const columnValue = kanbanColumn.getAttribute("value")
                if (columnValue == kiss.context.draggedValue) return

                const recordId = kiss.context.draggedRecordId
                const fieldId = kanbanColumn.getAttribute("fieldid")
                const value = kanbanColumn.getAttribute("value")
                this.moveCardToColumn(recordId, fieldId, value)
                stopAutoScroll()
            },

            ondragleave: (event) => {
                const kanbanColumn = event.target.closest(".kanban-column-container")
                if (!kanbanColumn) return
                resetColumn(event.target)
            }
        }

        document.querySelectorAll('.kanban-column-container').forEach(kanbanColumn => {
            Object.assign(kanbanColumn, dndEvents)
        })

        document.querySelectorAll('.kanban-record').forEach(kanbanCard => {
            kanbanCard.draggable = true
            kanbanCard.ondragstart = dndEvents.ondragstart
            kanbanCard.ondragend = stopAutoScroll
        })
    }

    /**
     * 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.kanban.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.kanban.style.height = newHeight
    }

    /**
     * Get the columns width config stored locally
     * 
     * @private
     * @ignore
     */
    _getColumnsWidthFromLocalStorage() {
        const localStorageId = "config-kanban-" + 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) {
        // Generates the groups, then get the grouped records
        await this.collection.groupBy(groupFields)
        this._render()

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

    /**
     * 
     * RENDERING THE KANBAN
     * 
     */

    /**
     * Render the kanban
     * 
     * @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._renderKanbanBody()
        this._observeCards()
        this._enableDragAndDrop()

        return this
    }

    /**
     * Observe the cards to render them only when they are visible
     * 
     * @private
     * @ignore
     */
    _observeCards() {
        const kanbanColumnContainers = this.querySelectorAll('.kanban-column-container')
        kanbanColumnContainers.forEach(container => {
            container.onscroll = () => {
                if (kiss.global.kanbanScrollStop) return

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

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

        this._renderDetailsOfVisibleCards()
    }

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

        requestAnimationFrame(() => {
            document.querySelectorAll(".kanban-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)
                    const cardContent = _this._renderRecordAsCard(record, rowIndex)
                    const cardElement = _this.querySelector('.kanban-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 kanban body
     * 
     * Tech note: we don't use string litterals to build the HTML because it's slower than native String concatenation
     * 
     * @private
     * @ignore
     */
    _renderKanbanBody() {
        let cardIndex = 0
        let kanbanHeader = ""
        let kanban = ""

        if (this.collection.group.length === 0) {
            // No group: can't render a Kanban view
            kanban = `<div class="kanban-help">${txtTitleCase("#kanban help")}</div>`

            this.kanbanHeaderContainer.style.display = "none"
            this.kanbanBodyContainer.classList.remove("kanban-body-container-empty")

        } else {

            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) {
                    cardIndex = 0
                    if (lastCellType == "record") { // Close the last column container
                        kanban += "</div>"
                    }

                    lastCellType = "group"

                    // Group header
                    kanbanHeader += this._renderKanbanHeader(record, rowIndex)

                    // Group container
                    kanban += this._renderKanbanColumnContainer(record, rowIndex)

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

                    // Sub-category
                    kanban += "<div class=\"kanban-column-category\">" + record.$groupId + " - " + record.$name + "</div>"

                } else {
                    cardIndex++
                    lastCellType = "record"

                    // Regular row
                    kanban += this._renderKanbanCardContainer(record, cardIndex)
                }

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

            // Show / hide "empty" icon and header
            if (this.collection.records.length == "0") {
                this.kanbanBodyContainer.classList.add("kanban-body-container-empty")
                this.kanbanHeaderContainer.style.display = "none"
            } else {
                this.kanbanBodyContainer.classList.remove("kanban-body-container-empty")
                this.kanbanHeaderContainer.style.display = "flex"
            }
        }

        this.kanbanHeader.innerHTML = kanbanHeader
        this.kanbanBody.innerHTML = kanban
    }

    /**
     * Render the content of a Kanban header
     * 
     * @private
     * @ignore
     * @param {object} record 
     * @returns {string} Html source for the column header
     */
    _renderKanbanHeader(record, rowIndex) {
        const color = this._getCategoryColor(this.collection.group[0], record.$name)

        let row = "<div row=\"" + rowIndex + "\" class=\"kanban-column-header\">"
        if (record.$name !== undefined && record.$name !== "") {
            row += `<span class="fas fa-circle kanban-column-header-icon" style="color: ${color}"></span>`
            row += `<div>${record.$name} (${record.$size})</div>`
        } else {
            row += `<span class="fas fa-circle kanban-column-header-icon" style="color: #cccccc"></span>`
            row += `<div>${txtTitleCase("#no category")} (${record.$size})</div>`
        }
        row += "</div>"
        return row
    }

    /**
     * Get the color of a category, if any
     * 
     * @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 Kanban column
     * 
     * @param {object} record 
     * @param {number} rowIndex 
     * @returns {string} Html source for Kanban column container
     */
    _renderKanbanColumnContainer(record, rowIndex) {
        const groupFieldId = this.group[0]
        const value = record.$name
        return "<div row=\"" + rowIndex + "\" fieldid=\"" + groupFieldId + "\" value=\"" + value + "\" class=\"kanban-column-container\">"
    }

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

    /**
     * Render a single record as a Card for 1 week view
     * 
     * @private
     * @ignore
     * @param {object} record
     * @returns {string} Html for a single record
     */
    _renderRecordAsCard(record, index) {
        let recordHtml = "<span class=\"kanban-record-index\">" + index + "</span>"
        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) return

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

        if (kiss.screen.isMobile) {
            recordHtml += /*html*/ `
                <div class="a-button kanban-record-button">
                    <span class="kanban-record-button-icon fas fa-exchange-alt"></span>
                    ${txtTitleCase("#move card")}
                </div>
            `.removeExtraSpaces()
        }

        return recordHtml
    }


    /**
     * Render a single value inside a card
     * 
     * @private
     * @ignore
     * @param {object} field - Field to render
     * @param {*} value - Field value
     * @param {object} value - The record, useful for custom renderers
     * @returns {string} Html for the value
     */
    _renderSingleValue(field, value, record) {

        // Convert summary & lookup fields to mimic the type of their source field
        let type = field.type
        if (type == "lookup") {
            type = field.lookup.type
        } else if (type == "summary") {
            if (field.summary.type == "directory" && field.summary.operation == "LIST_NAMES") type = "directory"
        }

        if (field.valueRenderer) return field.valueRenderer(value, record)

        switch (type) {
            case "textarea":
            case "aiTextarea":
                return this._rendererForTextarea(field, value)
            case "date":
                return new Date(value).toLocaleDateString()
            case "directory":
                return this._rendererForDirectoryFields(value)
            case "select":
                return this._rendererForSelectFields(field, value)
            case "checkbox":
                return this._rendererForCheckboxFields(field, value)
            case "rating":
                return this._rendererForRatingFields(field, value)
            case "attachment":
            case "aiImage":
                return this._rendererForAttachmentFields(field, value)
            case "password":
                return "***"
            default:
                return value
        }
    }

    /**
     * Renderer for "Textarea" fields.
     * Mainly keeps the line breaks.
     * 
     * @private
     * @ignore
     * @returns {string} Html for the value
     */
    _rendererForTextarea(field, value) {
        return value.replaceAll("\n", "<br>")
    }

    /**
     * Renderer for "Checkbox" fields
     * 
     * @private
     * @ignore
     * @returns {string} Html for the value
     */
    _rendererForCheckboxFields(field, value) {
        const shape = field.shape || "square"
        const iconColorOn = field.iconColorOn || "#000000"

        try {
            if (field.type == "lookup") {
                const linkId = field.lookup.linkId
                const linkField = this.model.getField(linkId)
                const foreignModelId = linkField.link.modelId
                const foreignModel = kiss.app.models[foreignModelId]
                const sourceField = foreignModel.getField(field.lookup.fieldId)
                shape = sourceField.shape
                iconColorOn = sourceField.iconColorOn
            }
        } catch (err) {
            log("kiss.ui - Kanban - Couldn't generate renderer for checkboxes", 4)
            return value
        }

        const iconClasses = kiss.ui.Checkbox.prototype.getIconClasses()
        const defaultIconOn = iconClasses[shape]["on"]
        const defaultIconOff = iconClasses[shape]["off"]
        return `<span ${(value === true) ? `style="color: ${iconColorOn}"` : ""} class=\"${(value === true) ? defaultIconOn + " kanban-type-checkbox-checked" : defaultIconOff + " kanban-type-checkbox-unchecked"}\"></span>`
    }

    /**
     * Renderer for "Rating" fields
     * 
     * @private
     * @ignore
     * @returns {string} Html for the value
     */
    _rendererForRatingFields(field, value) {
        const iconColorOn = field.iconColorOn || "#ffd139"
        const iconColorOff = field.iconColorOff || "#dddddd"
        const shape = field.shape || "star"
        const iconClasses = kiss.ui.Rating.prototype.getIconClasses()
        const icon = iconClasses[shape]
        const max = field.max || 5

        let html = ""
        for (let i = 0; i < max; i++) {
            const color = (i < value) ? iconColorOn : iconColorOff
            html += /*html*/ `<span class="rating ${icon}" style="color: ${color}" index=${i}></span>`
        }
        return html
    }

    /**
     * Renderer for "Select" fields
     * 
     * @private
     * @ignore
     * @returns {string} Html for the value
     */
    _rendererForSelectFields(field, values) {
        const options = (typeof field.options == "function") ? field.options() : field.options

        // If no options, returns default layout
        if (!options) {
            return [].concat(values).map(value => {
                if (!value) return ""
                return `<span class="field-select-value">${value}</span>`
            }).join("")
        }

        // If options, returns values with the right option colors
        return [].concat(values).map(value => {
            if (!value) return ""

            let option = options.find(option => option.value === value)

            if (!option) option = {
                value
            }

            if (!option.value || option.value == " ") return ""

            return `<span class="field-select-value" ${(option.color) ? `style="color: #ffffff; background-color: ${option.color}"` : ""}>${option.value}</span>`
        }).join("")
    }

    /**
     * Render for "Directory" fields
     * 
     * @private
     * @ignore
     * @returns {string} Html for the value
     */
    _rendererForDirectoryFields(values) {
        let listOfNames = "-"
        if (values) {
            listOfNames = [].concat(values).map(value => {
                if (!value) return ""

                let name
                switch (value) {
                    case "*":
                        name = kiss.directory.roles.everyone.label
                        break
                    case "$authenticated":
                        name = kiss.directory.roles.authenticated.label
                        break
                    case "$creator":
                        name = kiss.directory.roles.creator.label
                        break
                    case "$nobody":
                        name = kiss.directory.roles.nobody.label
                        break
                    default:
                        name = kiss.directory.getEntryName(value)
                }

                return (name) ? name : ""
            }).join(", ")
        }

        return listOfNames
    }

    /**
     * Renderer for "Attachment" fields
     * 
     * @private
     * @ignore
     * @returns {string} Html for the value
     */
    _rendererForAttachmentFields(field, value) {
        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, "s")
            const fileExtension = file.path.split(".").pop().toLowerCase()

            if (["jpg", "jpeg", "png", "gif", "webp"].indexOf(fileExtension) != -1) {
                // Image
                preview = `<img id="${file.id}" class="kanban-type-attachment-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} kanban-type-attachment-icon"></span>`
            }

            return /*html*/ `<span id="${file.id}" class="kanban-type-attachment">${preview}</span>`
        }).join("")
        return attachmentItems
    }    

    /**
     * 
     * Render the toolbar
     * 
     * The toolbar includes multiple components:
     * - button to create a new record
     * - button to select columns
     * - button to sort
     * - button to filter
     * - field to group
     * 
     * @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: "kanban-create-record",
            target: "create:" + this.id,
            text: this.config.createRecordText || this.model.name.toTitleCase(),
            icon: "fas fa-plus",
            iconColor: this.color,
            borderWidth: "3px",
            borderRadius: "32px",
            maxWidth: (kiss.screen.isMobile && kiss.screen.isVertical()) ? 160 : 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: 32,
            action: () => this._buildActionMenu()
        }).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: 32,
            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: 32,
            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: 32,
            action: () => this.showFilterWindow()
        }).render()

        // Layout button
        createButton({
            hidden: !this.showLayoutButton,
            target: "layout:" + this.id,
            tip: {
                text: txtTitleCase("layout"),
                minWidth: 100
            },
            icon: "fas fa-ellipsis-v",
            iconColor: this.color,
            width: 32,
            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: 200,
            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()

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

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

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

    /**
     * 
     * COLUMNS MANAGEMENT
     * 
     */

    /**
     * Save the width of a column in the localStorage
     * 
     * @private
     * @ignore
     * @param {string} columnId - Id of the column to resize
     * @param {number} newWidth - New column width
     */
    _columnsSetWidth(columnId, newWidth) {
        let localStorageId

        // Other columns: get the column config and update it
        let columnIndex = this.columns.findIndex(column => column.id == columnId)
        if (newWidth <= 10) newWidth = 10
        this.columns[columnIndex].width = newWidth

        // Save new column size locally
        localStorageId = "config-kanban-" + this.id + "-columns"
        localStorage.setItem(localStorageId, JSON.stringify(this.columns))
    }

    /**
     * Drag and drop a column
     * 
     * @private
     * @ignore
     * @param {string} phase - dragstart | dragover | dragleave | drop
     * @param {object} event - The drag Event: dragStart | dragOver | dragLeave | drop
     * @param {object} element - The DOM element which is dragged
     */
    _columnsMoveWithdragAndDrop(phase, event, element) {
        let target = event.target
        let targetCenterX = null
        let colIndex = target.closest("div").getAttribute("col")
        let columnCells = Array.from(this.querySelectorAll("div[col='" + colIndex + "']"))

        switch (phase) {
            case "dragstart":
                // Store the column to be moved
                this.sourceColumnId = target.id.split("title-")[1]
                break

            case "dragover":
                // Adjust target column style to show where to drop the column
                targetCenterX = target.offsetLeft + target.clientWidth / 2

                if (event.x < targetCenterX) {
                    columnCells.forEach(cell => {
                        cell.classList.remove("kanban-column-dragover-right")
                        cell.classList.add("kanban-column-dragover-left")
                    })
                } else {
                    columnCells.forEach(cell => {
                        cell.classList.remove("kanban-column-dragover-left")
                        cell.classList.add("kanban-column-dragover-right")
                    })
                }
                event.preventDefault()
                return false

            case "dragleave":
                // Restore style of header and column
                columnCells.forEach(cell => {
                    cell.classList.remove("kanban-column-dragover-left")
                    cell.classList.remove("kanban-column-dragover-right")
                })
                break

            case "drop":
                event.stopPropagation()

                // Restore style of header and column
                columnCells.forEach(cell => {
                    cell.classList.remove("kanban-column-dragover-left")
                    cell.classList.remove("kanban-column-dragover-right")
                })

                // Perform the drop action
                targetCenterX = target.offsetLeft + target.clientWidth / 2
                let position = (event.x < targetCenterX) ? "before" : "after"
                this._columnsMove(this.sourceColumnId, target.id.split("title-")[1], position)
                break
        }
    }

    /**
     * 
     * OTHER MISC METHODS
     * 
     */

    /**
     * Render the menu to change kanban 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: "2px",
                    text: txtTitleCase("compact"),
                    action: () => {
                        this.columnWidth = 250
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to NORMAL
                {
                    icon: "fas fa-circle",
                    iconSize: "6px",
                    text: txtTitleCase("normal"),
                    action: () => {
                        this.columnWidth = this.defaultColumnWidth
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to MEDIUM
                {
                    icon: "fas fa-circle",
                    iconSize: "10px",
                    text: txtTitleCase("medium"),
                    action: () => {
                        this.columnWidth = 350
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to TALL
                {
                    icon: "fas fa-circle",
                    iconSize: "14px",
                    text: txtTitleCase("tall"),
                    action: () => {
                        this.columnWidth = 400
                        this.setColumnWidth(this.columnWidth)
                    }
                },
                // Change row height to VERY TALL
                {
                    icon: "fas fa-circle",
                    iconSize: "18px",
                    text: txtTitleCase("very tall"),
                    action: () => {
                        this.columnWidth = 450
                        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-kanban", kiss.ui.Kanban)

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

;