Source

client/kissjs.ux.js

/**
 * 
 * The Rich Text Field derives from [Component](kiss.ui.Component.html).
 * It's a simple componant to edit rich text content:
 * - headers (h1, h2, h3)
 * - bold, italic, underline
 * - color
 * - lists (ordered, bullet, check)
 * - blockquote
 * - code block
 * - clear formatting
 * 
 * Encapsulates original Quill inside a KissJS UI component:
 * https://quilljs.com
 * 
 * - Check official documentation to customize the toolbar.
 * - Current version of local Quill: 2.0.2
 * 
 * @param {string} [config.value] - Default value
 * @param {string} [config.label]
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled]
 * @param {boolean} [config.required]
 * @param {string|number} [config.labelWidth]
 * @param {string|number} [config.fieldWidth]
 * @param {string} [config.fieldPadding]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {string} [config.labelFontSize] - Any valid CSS value
 * @param {string} [config.labelFontWeight] - Any valid CSS value
 * @param {string} [config.labelColor] - Any valid CSS value
 * @param {number} [config.boxShadow]
 * @param {integer} [config.width] - Width in pixels
 * @param {integer} [config.height] - Height in pixels
 * @param {boolean} [config.useCDN] - Set to true to use the CDN version of Quill. Default is true.
 * @param {string} [config.theme] - Use "snow" for a docked toolbar, and "bubble" for a floating toolbar. Default is "bubble".
 * @param {object[]} [config.toolbar1] - Toolbar 1. Default is ["clean", { "header": 1 }, { "header": 2 }, { "header": 3 }, { "header": 4 }]
 * @param {object[]} [config.toolbar2] - Toolbar 2. Default is ["bold", "italic", "underline", {color: []}]
 * @param {object[]} [config.toolbar3] - Toolbar 3. Default is [{ "list": "ordered"}, { "list": "bullet" }, { "list": "check" }]
 * @param {object[]} [config.toolbar4] - Toolbar 4. Default is ["blockquote", "code-block"]
 * @param {boolean} [config.imageWithCaption] - If true, the editor will allow to insert images with a caption.
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-richtextfield class="a-richtextfield">
 *  <label class="field-label"></label>
 *  <div class="field-richtext">
 *      <!-- Quill editor is here !-->
 *  </div>
 * </a-richtextfield>
 * ```
 */
kiss.ux.RichTextField = class RichTextField extends kiss.ui.Component {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the 2 following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myRichTextField = document.createElement("a-richtextfield").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myRichTextField = createRichTextField({
     *  label: "My rich text field",
     *  width: 600,
     *  labelPosition: "top"
     * })
     * 
     * myRichTextField.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *          type: "richTextField",
     *          label: "My rich text field",
     *          width: 600,
     *          labelPosition: "top"
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a label and a rich text editor inside a div container
     * 
     * @ignore
     * @returns {HTMLElement}
     */
    init(config = {}) {
        super.init(config)

        this.isQuillInitialized = false
        this.useCDN = (config.useCDN === false) ? false : true
        this.readOnly = !!config.readOnly
        this.disabled = !!config.disabled
        this.required = !!config.required

        this.innerHTML = `
            ${ (config.label) ? `<label id="field-label-${this.id}" for="${this.id}" class="field-label">
                ${ (this.isLocked()) ? this.locker : "" }
                ${ config.label || "" }
                ${ (this.isRequired()) ? this.asterisk : "" }
            </label>` : "" }
            <div id="container-${this.id}" class="field-richtext"></div>
        `
        // Set properties and styles
        this.label = this.querySelector(".field-label")
        this.field = this.querySelector(".field-richtext")

        this._setProperties(config, [
            [
                ["draggable"],
                [this]
            ],
            [
                ["flex", "flexFlow", "width", "minWidth", "height", "minHeight", "flex", "display", "margin", "padding"],
                [this.style]
            ],
            [
                ["fieldWidth=width", "fieldHeight=height", "maxHeight", "fieldPadding=padding", "boxShadow"],
                [this.field.style]
            ],
            [
                ["fontSize", "labelAlign=textAlign", "labelFlex=flex", "labelFontSize=fontSize", "labelFontWeight=fontWeight", "labelColor=color"],
                [this.label?.style]
            ]
        ])

        // Set the default display mode that will be restored by the show() method
        this.displayMode = "flex"

        // Manage label and field layout according to label position
        this.style.flexFlow = "row"

        if (config.label) {
            // Label width
            if (config.labelWidth) this.setLabelWidth(config.labelWidth)

            // Label position
            this.config.labelPosition = config.labelPosition || "left"
            this.setLabelPosition(config.labelPosition)
        }

        // Init Quill toolbars
        this.theme = config.theme || "bubble"
        this.toolbar1 = config.toolbar1 || ["clean", {
            "header": 1
        }, {
            "header": 2
        }, {
            "header": 3
        }, {
            "header": 4
        }]
        this.toolbar2 = config.toolbar2 || ["bold", "italic", "underline", {
            color: []
        }]
        this.toolbar3 = config.toolbar3 || [{
            "list": "ordered"
        }, {
            "list": "bullet"
        }, {
            "list": "check"
        }]
        this.toolbar4 = config.toolbar4 || ["blockquote", "code-block", "link"]

        return this
    }

    /**
     * After render, initialize the "Quill" rich text editor
     * 
     * Note: the focus and blur management is a bit tricky because the Quill editor doesn't not manage it internally.
     * For example, the blur event is triggered when the editor is left, but also when the user clicks on the editor toolbar, which is not the expected behavior.
     * To fix this, we have to check if the last "blur" event was inside the editor or the toolbar, an cancel the blur event if it was the toolbar.
     * On top of this, the "change" event is triggered on every key press, which is not the standard way for a field.
     * We circumvent this by triggering the change event only when the editor is left, and by comparing the previous value with the new one.
     * 
     * @ignore
     */
    async _afterRender() {
        if (window.Quill) {
            this._initRichTextField()
        } else {
            await this._initRichTextEditor()
            this._initRichTextField()
        }

        // Set initial value + eventually bind record
        if (this.config.record) {
            this._bindRecord(this.config.record)
        } else if (this.config.value) {
            this.richTextField.clipboard.dangerouslyPasteHTML(this.config.value)
        }

        // READONLY
        if (this.readOnly || this.disabled) {
            this.richTextContainer.classList.add("field-richtext-read-only")
            this.richTextField.disable()
            return
        }

        // FOCUS
        this.isFirstFocus = true

        this.richTextField.root.onfocus = () => {
            this.focused = true

            if (!this.isFirstFocus) return

            this.isFirstFocus = false
            this.previousValue = this.getValue()
            this.dispatchEvent(new Event("focus"))
        }

        // BLUR + GLOBAL CHANGE
        this.richTextField.root.onblur = () => {
            if (!this.focused) return
            this.focused = false

            if (!this._isInsideEditor()) {
                this.isFirstFocus = true
                this.dispatchEvent(new Event("blur"))

                const newValue = this.getValue()
                if (this.previousValue == newValue) return

                if (this.validate()) {
                    this.setValue(newValue, true)
                }
            }
        }

        // CHANGE
        this.richTextField.on("text-change", () => {
            this.validate()
        })

        // EDITOR CHANGE
        this.richTextField.on("editor-change", () => {
            this._adjustToolbarPosition.call(this)
        })
    }

    /**
     * Load the editor library
     * 
     * @private
     * @ignore
     */
    async _initRichTextEditor() {
        if (this.useCDN === false) {
            // Local (version 2.0.2)
            await kiss.loader.loadScript("../../kissjs/client/ux/richTextField/richTextField_quill")
            await kiss.loader.loadStyle("../../kissjs/client/ux/richTextField/richTextField_quill." + this.theme)
        } else {
            // CDN
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill")
            await kiss.loader.loadStyle("https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill." + this.theme)
        }
    }

    /**
     * Initialize the editor
     * 
     * @private
     * @ignore
     */
    _initRichTextField() {
        if (this.richTextField) return

        this.richTextField = new Quill("#container-" + this.id, {
            theme: this.theme,
            modules: {
                toolbar: [
                    this.toolbar1,
                    this.toolbar2,
                    this.toolbar3,
                    this.toolbar4
                ]
            }
        })

        this.richTextToolbar = this.querySelector(".ql-toolbar")
        this.richTextContainer = this.querySelector(".ql-container")
        this.isQuillInitialized = true

        this._initSelectionObserver()

        if (this.config.imageWithCaption) {
            this._initImageWithCaption()
            this._initImageWithCaptionClick()
            this._addCreationButton()
        }
    }

    /**
     * Keep track of the current selection index
     */
    _initSelectionObserver() {
        this.richTextField.root.addEventListener("click", () => {
            const currentSelection = this.richTextField.getSelection()
            this.currentSelectionIndex = (currentSelection || {}).index || 0
        })        
    }

    /**
     * Bind the field to a record
     * (this subscribes the field to react to database changes)
     * 
     * @private
     * @ignore
     * @param {object} record
     * @returns this
     */
    _bindRecord(record) {
        this.record = record
        this.modelId = record.model.id
        this.recordId = record.id

        // Set initial value
        if (record[this.id]) {
            this.initialValue = record[this.id]
            this.richTextField.clipboard.dangerouslyPasteHTML(this.initialValue)
        }

        // React to changes on a single record of the binded model
        this.subscriptions.push(
            subscribe("EVT_DB_UPDATE:" + this.modelId.toUpperCase(), (msgData) => {
                if ((msgData.modelId == this.modelId) && (msgData.id == this.recordId)) {
                    const updates = msgData.data
                    this._updateField(updates)
                }
            })
        )

        // React to changes on multiple records of the binded Model
        this.subscriptions.push(
            subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
                const operations = msgData.data
                operations.forEach(operation => {
                    if ((operation.modelId == this.modelId) && (operation.recordId == this.recordId)) {
                        const updates = operation.updates
                        this._updateField(updates)
                    }
                })
            })
        )

        return this
    }

    /**
     * Update the code editor value internally
     * 
     * @private
     * @ignore
     * @param {*} updates
     */
    _updateField(updates) {
        if (this.id in updates) {
            const newValue = updates[this.id]
            if (newValue || (newValue === 0) || (newValue === "")) {
                this.richTextField.clipboard.dangerouslyPasteHTML(newValue)
            }
        }
    }

    /**
     * Set the code
     * 
     * @param {string} newValue
     * @param {boolean} [fromBlurEvent] - If true, the update is only performed on binded record, not locally
     * @returns this
     */
    async setValue(newValue, fromBlurEvent) {

        // Ensure the editor is available before setting the value
        await kiss.tools.waitUntil(() => this.isQuillInitialized, 50, 5000)

        if (this.record) {
            // If the field is connected to a record, we update the database
            this.record.updateFieldDeep(this.id, newValue).then(success => {

                // Rollback the initial value if the update failed (ACL)
                if (!success) {
                    this.richTextField.clipboard.dangerouslyPasteHTML(this.initialValue || "")
                }
            })
        } else {
            // Otherwise, we just change the field value
            if (!fromBlurEvent) {
                this.richTextField.clipboard.dangerouslyPasteHTML(newValue)
            }
        }

        return this
    }

    /**
     * Get the field value, which is the HTML content
     * 
     * @returns {string} - The field value
     */
    getValue() {
        return this.richTextField.getSemanticHTML()
    }

    /**
     * Clear the field value
     * 
     * @returns this
     */
    clearValue() {
        this.setValue("")
        return this
    }

    /**
     * Validate the field value and apply UI style accordingly
     * 
     * @returns {boolean} true is the field is valid, false otherwise
     */
    validate() {
        this.setValid()

        // Exit if field is readOnly
        if (this.config.readOnly) return true

        // Required
        if (this.required && this.isEmpty()) this.setInvalid()
        return this.isValid
    }

    /**
     * Give focus to the input field
     * 
     * @returns this
     */
    focus() {
        this.richTextField.focus()
        return this
    }

    /**
     * Unset the focus of the input field
     * 
     * @returns this
     */
    blur() {
        this.richTextField.blur()
        return this
    }

    /**
     * Reset the focus
     */
    resetFocus() {
        this.blur()
        setTimeout(() => this.focus(), 100)
    }

    /**
     * Remove the invalid style
     * 
     * @returns this
     */
    setValid() {
        this.isValid = true
        this.richTextContainer.classList.remove("field-richtext-invalid")
        return this
    }

    /**
     * Change the style when the field is invalid
     * 
     * @returns this
     */
    setInvalid() {
        log("kiss.ui - field.setInvalid - Invalid value for the field: " + this.config.label, 4)

        this.isValid = false
        this.richTextContainer.classList.add("field-richtext-invalid")
        return this
    }

    /**
     * Check if the field is empty
     * 
     * @returns {boolean}
     */
    isEmpty() {
        const value = this.getValue()
        const regex = /^(\s*<p>\s*<\/p>\s*)+$/;
        return regex.test(value)
    }

    /**
     * Set the field label
     * 
     * @param {string} newLabel
     * @returns this
     */
    setLabel(newLabel) {
        if (!this.label) return

        this.config.label = newLabel
        this.label.innerText = newLabel
        return this
    }

    /**
     * Get the field label
     * 
     * @returns {string}
     */
    getLabel() {
        return this?.label?.innerText || ""
    }

    /**
     * Set the field width
     * 
     * @param {*} width
     * @returns this
     */
    setWidth(width) {
        this.config.width = width
        this.style.width = this._computeSize("width", width)
        return this
    }

    /**
     * Set the color selector field width
     * 
     * @param {*} width
     * @returns this
     */
    setFieldWidth(width) {
        this.config.fieldWidth = width
        this.field.style.width = this._computeSize("fieldWidth", width)
        return this
    }

    /**
     * Set the label width
     * 
     * @param {*} width
     * @returns this
     */
    setLabelWidth(width) {
        this.config.labelWidth = width
        this.label.style.width = this.label.style.maxWidth = this._computeSize("labelWidth", width)
        return this
    }

    /**
     * Get the label position
     * 
     * @returns {string} "left" | "right" | "top"
     */
    getLabelPosition() {
        return this.config.labelPosition
    }

    /**
     * Set label position
     * 
     * @param {string} position - "left" (default) | "right" | "top" | "bottom"
     * @returns this
     */
    setLabelPosition(position) {
        this.config.labelPosition = position

        switch (position) {
            case "top":
                this.style.flexFlow = "column"
                this.field.style.order = 1
                break
            case "bottom":
                this.style.flexFlow = "column"
                this.field.style.order = -1
                break
            case "right":
                this.style.flexFlow = "row"
                this.field.style.order = -1
                break
            default:
                this.style.flexFlow = "row"
                this.field.style.order = 1
        }
        return this
    }

    /**
     * Check if the last blur event was inside the editor
     * 
     * @private
     * @ignore
     * @returns {boolean}
     */
    _isInsideEditor() {
        const {
            x,
            y
        } = kiss.screen.mousePosition

        // Check if it was inside the editor
        const editorRect = this.richTextField.root.getBoundingClientRect()
        const isInsideEditor = (
            x >= editorRect.left &&
            x <= editorRect.right &&
            y >= editorRect.top &&
            y <= editorRect.bottom
        )
        if (isInsideEditor) return true

        // Check if it was inside the toolbar
        const toolbarRect = this.richTextToolbar.getBoundingClientRect()
        const isInsideToolbar = (
            x >= toolbarRect.left &&
            x <= toolbarRect.right &&
            y >= toolbarRect.top &&
            y <= toolbarRect.bottom
        )
        if (isInsideToolbar) return true

        return false
    }

    /**
     * Adjust the toolbar position to fix default Quill behavior.
     * Center it horizontally inside the editor instead of cropping it when it reaches the window border.
     * 
     * @private
     * @ignore
     */
    _adjustToolbarPosition() {
        setTimeout(() => {
            const tooltip = document.querySelector(".ql-tooltip")
            if (!tooltip) return
            const componentBounds = this.getBoundingClientRect()
            const tooltipWidth = tooltip.offsetWidth
            let left = (componentBounds.width / 2) - (tooltipWidth / 2)
            if (left < 0) left = 10
            if (left + tooltipWidth > window.innerWidth) left = window.innerWidth - tooltipWidth - 10
            tooltip.style.left = left + "px"
        }, 5)
    }

    //
    //
    // CUSTOM BLOT FOR IMAGE WITH CAPTION
    //
    //

    /**
     * Initialize the custom blot for image with caption
     * 
     * @private
     * @ignore
     */
    _initImageWithCaption() {
        const BlockEmbed = window.Quill.import("blots/block/embed")

        class ImageFigureBlot extends BlockEmbed {
            static create(value) {
                const node = super.create()
                const figure = document.createElement("figure")
                const img = document.createElement("img")
                img.setAttribute("src", value.src)

                if (value.alt) img.setAttribute("alt", value.alt)

                const caption = document.createElement("figcaption")
                caption.innerText = value.caption || ""
                caption.classList.add("ql-caption")

                figure.appendChild(img)
                figure.appendChild(caption)
                node.appendChild(figure)
                return node
            }

            static value(node) {
                const img = node.querySelector("img")
                if (!img) return null

                const figcaption = node.querySelector("figcaption")

                return {
                    src: img.getAttribute("src"),
                    alt: img.getAttribute("alt"),
                    caption: figcaption ? figcaption.innerText : ""
                }
            }
        }

        ImageFigureBlot.blotName = "imagefigure"
        ImageFigureBlot.tagName = "div"
        window.Quill.register(ImageFigureBlot)
    }

    /**
     * Initialize the click event on the image with caption
     * 
     * @private
     * @ignore
     */
    _initImageWithCaptionClick() {
        this.richTextField.root.addEventListener("click", (event) => {
            const figure = event.target.closest("figure")
            if (!figure) return

            const img = figure.querySelector("img")
            const currentImg = img.getAttribute("src") || ""
            const currentAlt = img.getAttribute("alt") || ""
            const figcaption = figure.querySelector("figcaption")
            const currentCaption = figcaption?.innerText || ""

            this._insertImageFromURL({
                mode: "update",
                figure,
                img,
                src: currentImg,
                alt: currentAlt,
                figcaption,
                caption: currentCaption
            })
        })
    }

    /**
     * Add a custom button to the toolbar to create a menu for image management
     * 
     * @private
     * @ignore
     */
    _addCreationButton() {
        const toolbar = this.richTextField.getModule("toolbar")
        const toolbarContainer = toolbar.container
        const customButton = document.createElement("div")
        customButton.innerHTML = "<span class='fas fa-plus ql-toolbar-extension'></span>"

        const group = document.createElement("span")
        group.classList.add("ql-formats")
        group.appendChild(customButton)
        toolbarContainer.appendChild(group)

        customButton.addEventListener("click", (event) => {
            createMenu({
                items: [
                txtTitleCase("images"),
                "-",
                {
                    icon: "fas fa-globe",
                    text: txtTitleCase("#image from url"),
                    action: () => this._insertImageFromURL({})
                },
                {
                    icon: "fas fa-download",
                    text: txtTitleCase("#image from download"),
                    action: () => this._insertImageFromDownload()
                },
                {
                    icon: "fas fa-th",
                    text: txtTitleCase("#image from library"),
                    action: () => this._insertImageFromLibrary()
                },
                {
                    hidden: true, // TODO: Implement Unsplash integration
                    icon: "fas fa-search",
                    text: txtTitleCase("#image from unsplash"),
                    action: () => this._insertImageFromUnsplash()
                },
                "-",
                txtTitleCase("other"),
                "-",
                {
                    icon: "fas fa-paperclip",
                    text: txtTitleCase("#file attachment"),
                    action: () => this._insertAttachment()
                },
                {
                    icon: "fas fa-square",
                    text: txtTitleCase("#integrate button"),
                    action: () => this._insertButton()
                }
            ]}).render().showAt(event.clientX - 10, event.clientY - 10)
        })        
    }

    /**
     * Insert an image from a download
     * 
     * @private
     * @ignore
     * @param {object} config
     */    
    _insertImageFromDownload() {
        const _this = this
        createFileUploadWindow({
            modelId: _this.modelId,
            multiple: false,
            maxSize: 5 * 1024 * 1024, // 5 MB
            ACL: "public",
            callback: (data) => {
                _this._insertImageFromURL({
                    mode: "create",
                    src: "/" + data[0].path.replaceAll("\\", "/"),
                    alt: data[0].filename,
                    caption: data[0].filename
                })
            }
        })        
    }

    /**
     * Insert an image from the library
     * 
     * @private
     * @ignore
     * @param {object} config
     */    
    _insertImageFromLibrary() {
        createFileLibraryWindow({
            callback: (file) => {
                if (!file || !file.filename) return

                // If the file is an image, insert it
                if (file.mimeType.startsWith("image/")) {
                    this._insertImageFromURL({
                        mode: "create",
                        src: kiss.tools.createFileURL(file),
                        alt: file.filename,
                        caption: file.filename
                    })
                    
                    $("file-library-window").close()
                }
            }
        })
    }

    /**
     * Insert an image from Unsplash
     * 
     * @private
     * @ignore
     * @param {object} config
     */    
    _insertImageFromUnsplash() {
    }

    /**
     * Insert an attachment (file)
     * 
     * @private
     * @ignore
     * @param {object} config
     */    
    _insertAttachment() {
    }

    /**
     * Insert a button (link)
     * 
     * @private
     * @ignore
     * @param {object} config
     */    
    _insertButton() {
    }

    /**
     * Show the dialog to insert an image and a caption from a URL
     * 
     * @param {object} [config] - Configuration object
     * @param {string} [config.mode] - "create" to insert a new image, "update" to update an existing image
     * @param {HTMLElement} [config.figure] - The figure element to update (if mode is "update")
     * @param {HTMLElement} [config.img] - The img element to update (if mode is "update")
     * @param {string} [config.src] - The image URL (if mode is "create" or "update")
     * @param {string} [config.alt] - The alternative text for the image (if mode is "create" or "update")
     * @param {string} [config.figcaption] - The figcaption element to update (if mode is "update")
     * @param {string} [config.caption] - The caption text for the image (if mode is "create" or "update")
     * 
     * @example
     * // Insert a new image with caption
     * myRichTextField._insertImageFromURL({
     *  mode: "create",
     *  src: "https://example.com/image.jpg",
     *  alt: "Example Image",
     *  caption: "This is an example image"
     * })
     */
    _insertImageFromURL({
        mode,
        figure = "",
        img = "",
        src = "",
        alt = "",
        figcaption = "",
        caption = "",
    }) {
        const _this = this

        createPanel({
            id: "image-caption-panel",
            title: txtTitleCase("image properties"),
            icon: "fas fa-image",
            modal: true,
            draggable: true,
            closable: true,
            align: "center",
            verticalAlign: "center",
            width: "40rem",
            defaultConfig: {
                labelPosition: "top",
                width: "100%",
                fieldWidth: "100%",
                margin: "1rem 0rem",
                events: {
                    keydown: (event) => {
                        if (event.key === "Enter") {
                            event.preventDefault()
                            $("image-caption-panel").ok()
                        }
                    }
                }
            },
            items: [{
                    id: "image-src-input",
                    type: "text",
                    label: txtTitleCase("image url"),
                    value: src,
                    validationType: "url",
                    required: true,
                    readOnly: (mode === "create")
                },
                {
                    id: "image-alt-input",
                    type: "text",
                    label: txtTitleCase("alternative text"),
                    value: alt,
                    required: true
                },
                {
                    id: "image-caption-input",
                    type: "text",
                    label: txtTitleCase("caption"),
                    value: caption,
                    required: true
                },
                {
                    layout: "horizontal",
                    items: [
                        {
                            hidden: (mode !== "update"),
                            type: "button",
                            text: txtTitleCase("delete"),
                            icon: "fas fa-trash",
                            iconColor: "var(--red)",
                            margin: "0 0.5rem 0 0",
                            flex: 1,
                            action: () => $("image-caption-panel").delete()
                        },
                        {
                            type: "button",
                            text: txtTitleCase("validate"),
                            icon: "fas fa-check",
                            iconColor: "var(--green)",
                            flex: 1,
                            action: () => $("image-caption-panel").ok()
                        }
                    ]
                }
            ],
            methods: {
                delete() {
                    const wrapper = figure.closest("div")
                    if (wrapper && wrapper.parentNode) {
                        wrapper.parentNode.removeChild(wrapper)
                    }
                    $("image-caption-panel").close()              
                },
                ok() {
                    const panel = $("image-caption-panel")
                    if (!panel.validate()) return

                    const newSrc = $("image-src-input").getValue()
                    const newAlt = $("image-alt-input").getValue()
                    const newCaption = $("image-caption-input").getValue()

                    if (mode === "update") {
                        if (!newSrc) return
                        img.setAttribute("src", newSrc)
                        img.setAttribute("alt", newAlt)
                        figcaption.innerText = newCaption
                    }
                    else {
                        _this.addImageWithCaption({
                            src: newSrc,
                            alt: newAlt,
                            caption: newCaption
                        })
                    }
                    panel.close()
                }
            }
        }).render()
    }

    /**
     * Insert an image with a caption into the editor
     * 
     * @param {object} config
     * @param {string} config.src - URL of the image
     * @param {string} config.alt 
     * @param {string} config.caption
     */
    addImageWithCaption({
        src,
        alt = "",
        caption = ""
    }) {
        if (!src) return

        const index = this.currentSelectionIndex || 0
        this.richTextField.insertEmbed(index, "imagefigure", {
            src,
            alt,
            caption
        })
        this.richTextField.setSelection(index + 1)
    } 
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-richtextfield", kiss.ux.RichTextField)

/**
 * Shorthand to create a new Rich text field. See [kiss.ux.RichTextField](kiss.ux.RichTextField.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createRichTextField = (config) => document.createElement("a-richtextfield").init(config)

;/**
 * 
 * The Code Editor component derives from [Component](kiss.ui.Component.html).
 * 
 * It allows to write code, embedding the famous Ace Editor.
 * 
 * @param {object} config
 * @param {*} [config.value] - Default value
 * @param {string} [config.label]
 * @param {string} [config.labelWidth]
 * @param {string} [config.fieldWidth]
 * @param {string} [config.fieldHeight]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {string} [config.labelFontSize] - Any valid CSS value
 * @param {string} [config.labelFontWeight] - Any valid CSS value
 * @param {string} [config.labelColor] - Any valid CSS value
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled]
 * @param {boolean} [config.required]
 * @param {boolean} [config.draggable]
 * @param {string} [config.margin]
 * @param {string} [config.display] - flex | inline flex
 * @param {string|number} [config.width]
 * @param {string|number} [config.height]
 * @param {string|number} [config.border]
 * @param {string|number} [config.borderStyle]
 * @param {string|number} [config.borderWidth]
 * @param {string|number} [config.borderColor]
 * @param {string|number} [config.borderRadius]
 * @param {string|number} [config.boxShadow]
 * @param {boolean} [config.showMargin]
 * @param {boolean} [config.hideHorizontalScrollbar] - Hide the horizontal editor scrollbar if set to true. Default is false.
 * @param {boolean} [config.hideVerticalScrollbar] - Hide the vertical editor scrollbar if set to true. Default is false.
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-codeeditor class="a-codeeditor">
 *  <label class="field-label"></label>
 *  <div class="code-editor"></div>
 * </a-codeeditor>
 * ```
 */
kiss.ux.CodeEditor = class CodeEditor extends kiss.ui.Component {
    /**
     * 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 myCodeEditor = document.createElement("a-codeeditor").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myCodeEditor = createCodeEditor({
     *   label: "Enter your code",
     *   height: 300
     * })
     * 
     * myCodeEditor.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *           type: "codeEditor",
     *           label: "Enter your code",
     *           height: 300
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a Code Editor from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config) {
        super.init(config)

        // Template
        this.innerHTML = /*html*/ `
            ${ (config.label) ? `<label id="field-label-${this.id}" for="${this.id}" class="field-label">
                ${ (this.isLocked()) ? this.locker : "" }
                ${ config.label || "" }
                ${ (this.isRequired()) ? this.asterisk : "" }
            </label>` : "" }

            <div id="editor-for:${this.id}" class="code-editor"></div>
            `.removeExtraSpaces()

        // Set properties
        this.label = this.querySelector(".field-label")
        this.field = this.querySelector(".code-editor")

        this._setProperties(config, [
            [
                ["draggable"],
                [this]
            ],
            [
                ["width", "minWidth", "height", "flex", "display", "margin"],
                [this.style]
            ],
            [
                ["fieldWidth=width", "fieldHeight=height", "maxHeight", "fieldFlex=flex", "boxShadow", "border", "borderStyle", "borderWidth", "borderColor", "borderRadius"],
                [this.field.style]
            ],
            [
                ["labelAlign=textAlign", "labelFlex=flex", "labelFontSize=fontSize", "labelFontWeight=fontWeight", "labelColor=color"],
                [this.label?.style]
            ]
        ])

        // Set the default display mode that will be restored by the show() method
        this.displayMode = "flex"

        // Manage label and field layout according to label position
        this.style.flexFlow = "row"

        // The field will be display after ACE component is fully loaded
        this.field.style.display = "none"

        if (config.label) {
            // Label width
            if (config.labelWidth) this.setLabelWidth(config.labelWidth)

            // Label position
            this.config.labelPosition = config.labelPosition || "left"
            this.setLabelPosition(config.labelPosition)
        }

        return this
    }

    /**
     * Bind the field to a record
     * (this subscribes the field to react to database changes)
     * 
     * @private
     * @ignore
     * @param {object} record
     * @returns this
     */
    _bindRecord(record) {
        this.record = record
        this.modelId = record.model.id
        this.recordId = record.id

        // Set initial value
        if (record[this.id]) {
            this.initialValue = record[this.id]
            this.editor.setValue(this.initialValue)
        }

        // React to changes on a single record of the binded model
        this.subscriptions.push(
            subscribe("EVT_DB_UPDATE:" + this.modelId.toUpperCase(), (msgData) => {
                if ((msgData.modelId == this.modelId) && (msgData.id == this.recordId)) {
                    const updates = msgData.data
                    this._updateField(updates)
                }
            })
        )

        // React to changes on multiple records of the binded Model
        this.subscriptions.push(
            subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
                const operations = msgData.data
                operations.forEach(operation => {
                    if ((operation.modelId == this.modelId) && (operation.recordId == this.recordId)) {
                        const updates = operation.updates
                        this._updateField(updates)
                    }
                })
            })
        )

        return this
    }

    /**
     * Update the code editor value internally
     * 
     * @private
     * @ignore
     * @param {*} updates
     */
    _updateField(updates) {
        if (this.id in updates) {
            const newValue = updates[this.id]
            if (newValue || (newValue === 0) || (newValue === "")) {
                this.editor.setValue(newValue)
            }
        }
    }    

    /**
     * Insert Ace editor into the Web Component
     * 
     * @private
     * @render
     */
    async _afterRender() {
        if (!window.ace) {
            await kiss.loader.loadScript("../../kissjs/client/ux/codeEditor/ace")
        }

        this.editor = ace.edit("editor-for:" + this.id, {
            selectionStyle: "text"
        })

        this.editor.setOptions({
            autoScrollEditorIntoView: true,
            copyWithEmptySelection: false,
            showPrintMargin: false,
            fontSize: "var(--field-font-size)",
            showFoldWidgets: false
        })

        // Show hide line number
        this.editor.renderer.setShowGutter((this.config.showMargin == false) ? false : true)

        // Set Ace to Javascript / Monokai
        this.editor.session.setMode("ace/mode/javascript")
        this.editor.setTheme("ace/theme/monokai")
        this.editor.session.setUseWorker(false)

        //
        // Override common events: focus, blur, change
        //
        
        // FOCUS
        this.editor.on("focus", () => {
            this.previousValue = this.editor.getValue()
            this.dispatchEvent(new Event("focus"))
        })

        // BLUR
        this.editor.on("blur", () => {
            const newValue = this.editor.getValue()
            if (newValue != this.previousValue) this.hasChanged = true
            else this.hasChanged = false
            this.dispatchEvent(new Event("blur"))
        })

        // CHANGE
        this.editor.session.on("change", () => {
            this.dispatchEvent(new Event("change"))
        })

        // Set initial value + eventually bind record
        if (this.config.record) {
            this._bindRecord(this.config.record)
        }
        else if (this.config.value) {
            this.editor.setValue(this.config.value)
        }

        // Hide scrollbars if needed
        if (this.config.hideHorizontalScrollbar == true) {
            this.classList.add("no-scrollbar-h")
        }

        if (this.config.hideVerticalScrollbar == true) {
            this.classList.add("no-scrollbar-v")
        }

        this.field.style.display = "block"
        setTimeout(() => {
            this.editor.resize()
        }, 50)
    }

    /**
     * Set the code
     * 
     * @param {string} newValue
     * @param {boolean} [fromBlurEvent] - If true, the update is only performed on binded record, not locally
     * @returns this
     */
    async setValue(newValue, fromBlurEvent) {

        // Ensure the editor is available before setting the value
        await kiss.tools.waitUntil(() => this.editor, 50, 5000)

        if (this.record) {
            // If the field is connected to a record, we update the database
            this.record.updateFieldDeep(this.id, newValue).then(success => {

                // Rollback the initial value if the update failed (ACL)
                if (!success) this.editor.setValue(this.initialValue || "")
            })
        } else {
            // Otherwise, we just change the field value
            if (!fromBlurEvent) {
                this.editor.setValue(newValue)
            }
        }

        return this
    }

    /**
     * Get the code
     * 
     * @returns {string} The image src
     */
    getValue() {
        if (!this.editor) return ""
        return this.editor.getValue()
    }

    validate() {
        return true
    }

    /**
     * Insert a text at the current cursor position
     * 
     * @param {string} text
     * @returns this
     */
    insert(text) {
        const cursorPosition = this.editor.getCursorPosition()
        this.editor.session.insert(cursorPosition, text)
        this.editor.focus()
        return this
    }

    /**
     * Give focus to the input field
     * 
     * @returns this
     */
    focus() {
        this.editor.focus()
        return this
    }

    /**
     * Unset the focus of the input field
     * 
     * @returns this
     */
    blur() {
        this.editor.blur()
        return this
    }
    
    /**
     * Clear the current selection
     * 
     * @returns this
     */
    clearSelection() {
        this.editor.clearSelection()
        return this
    }    

    /**
     * Set the field label
     * 
     * @param {string} newLabel
     * @returns this
     */
    setLabel(newLabel) {
        if (!this.label) return

        this.config.label = newLabel
        this.label.innerText = newLabel
        return this
    }

    /**
     * Get the field label
     * 
     * @returns {string}
     */
    getLabel() {
        return this?.label?.innerText || ""
    }

    /**
     * Set the field width
     * 
     * @param {*} width
     * @returns this
     */
    setWidth(width) {
        this.config.width = width
        this.style.width = this._computeSize("width", width)
        return this
    }

    /**
     * Set the color selector field width
     * 
     * @param {*} width
     * @returns this
     */
    setFieldWidth(width) {
        this.config.fieldWidth = width
        this.field.style.width = this._computeSize("fieldWidth", width)
        return this
    }

    /**
     * Set the label width
     * 
     * @param {*} width
     * @returns this
     */
    setLabelWidth(width) {
        this.config.labelWidth = width
        this.label.style.width = this.label.style.maxWidth = this._computeSize("labelWidth", width)
        return this
    }

    /**
     * Get the label position
     * 
     * @returns {string} "left" | "right" | "top"
     */
    getLabelPosition() {
        return this.config.labelPosition
    }

    /**
     * Set label position
     * 
     * @param {string} position - "left" (default) | "right" | "top" | "bottom"
     * @returns this
     */
    setLabelPosition(position) {
        this.config.labelPosition = position

        switch (position) {
            case "top":
                this.style.flexFlow = "column"
                this.field.style.order = 1
                break
            case "bottom":
                this.style.flexFlow = "column"
                this.field.style.order = -1
                break
            case "right":
                this.style.flexFlow = "row"
                this.field.style.order = -1
                break
            default:
                this.style.flexFlow = "row"
                this.field.style.order = 1
        }
        return this
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-codeeditor", kiss.ux.CodeEditor)

/**
 * Shorthand to create a new Code Editor. See [kiss.ux.CodeEditor](kiss.ux.CodeEditor.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createCodeEditor = (config) => document.createElement("a-codeeditor").init(config)

;/**
 * 
 * The aiTextarea derives from [Field](kiss.ui.Field.html).
 * 
 * **AI** field allows to generate content automatically
 * 
 * It's basically a textarea field with an extra button to open the AI parameters and prompt
 * 
 * @param {object} config
 * @param {string} [config.iconColorOn] - Icon color
 * @param {object} [config.ai] - Optional AI default configuration
 * @param {string} [config.ai.who] - Default persona: "-" | "sales manager" | "hr manager" | "marketing manager" | "product manager"
 * @param {string} [config.ai.what] - Default task: "-" | "draft a blog post" | "summup a text" | "convert to tweet" | "write an email" | "create user persona" | "create job description"
 * @param {string} [config.ai.tone] - Default tone: "casual" | "formal" | "humour" | "ironic"
 * @param {string} [config.ai.goal] - Default goal: "-" | "inform" | "persuade" | "inspire"
 * @param {number} [config.ai.temperature] - OpenAI creativity, from 0 to 1
 * @param {number} [config.ai.max_tokens] - Max number of tokens for OpenAI answer
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-aitextarea class="a-aitextarea">
 *  <span class="field-label"></span>
 *  <textarea class="field-input"></textarea>
 * </a-aitextarea>
 * ```
 */
kiss.ux.AiTextarea = class AiTextarea extends kiss.ui.Field {
    /**
     * 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 myAiTextareaField = document.createElement("a-aitextarea").init(config)
     * ```
     * 
     * Or use a shorthand to create one the various field types:
     * ```
     * const myAiTextArea = createAiTextareaField({
     *   label: "I'm a long text field",
     *   cols: 100,
     *   rows: 10
     * })
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *           type: "aitextarea",
     *           label: "I'm an AI textarea"
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */    
    constructor() {
        super()
    }

    /**
     * @ignore
     */
    init(config = {}) {
        config.type = "aiTextarea"

        // Generates the field
        super.init(config)

        // Append a button right after the label
        this.label.appendChild(this._createAIButton())

        return this
    }

    /**
     * Add a button to open an AI assistant
     * 
     * @private
     * @ignore
     */
    _createAIButton() {
        const color = this.config.iconColorOn || "#00aaee"
        let lastParams = localStorage.getItem("config-ai-paragraph") || "{}"
        lastParams = JSON.parse(lastParams)

        return createButton({
            icon: "far fa-lightbulb",
            iconSize: "1.6rem",
            iconColor: color,
            height: "1.7rem",
            margin: "0 0 0 0.5rem",
            padding: "0.2rem 0",
            borderWidth: 0,
            boxShadow: "none",
            iconColorHover: "#ffffff",
            backgroundColorHover: color,

            action: (event) => {
                event.stop()

                createPanel({
                    id: "AI-panel",
                    title: txtTitleCase("your AI assistant"),
                    icon: "far fa-lightbulb",
                    headerBackgroundColor: color,
                    modal: true,
                    closable: true,
                    draggable: true,
                    width: "50rem",
                    align: "center",
                    verticalAlign: "center",

                    // Prevent from closing if the user started to work with a prompt
                    events: {
                        close: (forceClose) => {
                            if (forceClose) return true

                            if ($("prompt").getValue() != "") {
                                createDialog({
                                    type: "danger",
                                    message: txtTitleCase("are you sure you want to cancel your input?"),
                                    buttonOKPosition: "left",
                                    action: () => $("AI-panel").close("remove", true)
                                })
                                return false
                            }
                        }
                    },

                    defaultConfig: {
                        labelPosition: "top",
                        width: "100%"
                    },

                    items: [{
                            layout: "horizontal",
                            defaultConfig: {
                                flex: 1,
                                labelPosition: "top"
                            },
                            items: [
                                // AI PROFILE
                                {
                                    id: "who",
                                    type: "select",
                                    label: txtTitleCase("AI profile"),
                                    value: this.config?.ai?.who || lastParams.who || "-",
                                    allowValuesNotInList: true,
                                    options: [{
                                            label: txtTitleCase("no profile"),
                                            value: "-",
                                            color: "var(--green)"
                                        }, {
                                            label: txtTitleCase("sales rep"),
                                            value: "sales manager",
                                            color: "var(--red)"
                                        },
                                        {
                                            label: txtTitleCase("HR manager"),
                                            value: "hr manager",
                                            color: "var(--purple)"
                                        },
                                        {
                                            label: txtTitleCase("marketing manager"),
                                            value: "marketing manager",
                                            color: "var(--blue)"
                                        },
                                        {
                                            label: txtTitleCase("product manager"),
                                            value: "product manager",
                                            color: "var(--orange)"
                                        }
                                    ]
                                },
                                // TASK TO PERFORM
                                {
                                    id: "what",
                                    type: "select",
                                    label: txtTitleCase("task"),
                                    value: this.config?.ai?.what || lastParams.what || "-",
                                    allowValuesNotInList: true,
                                    options: [{
                                            label: txtTitleCase("free"),
                                            value: "-",
                                            color: "var(--green)"
                                        }, {
                                            label: txtTitleCase("draft a blog post"),
                                            value: "draft a blog post"
                                        },
                                        {
                                            label: txtTitleCase("summup a text"),
                                            value: "summup a text"
                                        },
                                        {
                                            label: txtTitleCase("convert to Tweet"),
                                            value: "convert to Tweet"
                                        },
                                        {
                                            label: txtTitleCase("write an email"),
                                            value: "write an email"
                                        },
                                        {
                                            label: txtTitleCase("create user persona"),
                                            value: "create user persona"
                                        },
                                        {
                                            label: txtTitleCase("create job description"),
                                            value: "create job description"
                                        }
                                    ]
                                }
                            ]
                        },
                        {
                            layout: "horizontal",
                            defaultConfig: {
                                flex: 1,
                                labelPosition: "top"
                            },
                            items: [
                                // AI TONE
                                {
                                    id: "tone",
                                    type: "select",
                                    label: txtTitleCase("tone to use"),
                                    value: this.config?.ai?.tone || lastParams.tone || "casual",
                                    allowValuesNotInList: true,
                                    options: [{
                                            label: txtTitleCase("casual"),
                                            value: "casual",
                                            color: "var(--green)"
                                        },
                                        {
                                            label: txtTitleCase("formal"),
                                            value: "formal",
                                            color: "var(--orange)"
                                        },
                                        {
                                            label: txtTitleCase("humour"),
                                            value: "humour",
                                            color: "var(--red)"
                                        },
                                        {
                                            label: txtTitleCase("ironic"),
                                            value: "ironic",
                                            color: "var(--purple)"
                                        }
                                    ]
                                },
                                // TASK GOAL
                                {
                                    id: "goal",
                                    type: "select",
                                    label: txtTitleCase("goal"),
                                    value: this.config?.ai?.goal || lastParams.goal || "-",
                                    allowValuesNotInList: true,
                                    options: [{
                                            label: txtTitleCase("none"),
                                            value: "-",
                                            color: "var(--green)"
                                        }, {
                                            label: txtTitleCase("inform"),
                                            value: "inform",
                                            color: "var(--blue)"
                                        },
                                        {
                                            label: txtTitleCase("persuade"),
                                            value: "persuade",
                                            color: "var(--purple)"
                                        },
                                        {
                                            label: txtTitleCase("inspire"),
                                            value: "inspire",
                                            color: "var(--red)"
                                        }
                                    ]
                                }
                            ]
                        },
                        {
                            layout: "horizontal",
                            defaultConfig: {
                                flex: 1,
                                labelPosition: "top"
                            },
                            items: [
                                // MAX RESULT LENGTH
                                {
                                    id: "max_tokens",
                                    label: txtTitleCase("response max length"),
                                    type: "number",
                                    value: Math.min(this.config?.ai?.max_tokens || lastParams.max_tokens || 1000, 2000) || 1000,
                                    max: 2000
                                },
                                // TEMPERATURE
                                {
                                    id: "temperature",
                                    label: txtTitleCase("creativity"),
                                    type: "slider",
                                    min: 0,
                                    max: 100,
                                    value: this.config?.ai?.temperature || lastParams.temperature || 50
                                }
                            ]
                        },
                        // AI PROMPT
                        {
                            id: "prompt",
                            type: "textarea",
                            label: txtTitleCase("#AI prompt instructions"),
                            required: true,
                            value: lastParams.prompt || "",
                            rows: 10
                        },
                        // BUTTON TO SEND THE PROMPT
                        {
                            type: "button",
                            text: txtTitleCase("generate content..."),
                            icon: "fas fa-bolt",
                            iconColor: "var(--orange)",
                            margin: "2rem 0 0 0",
                            height: "4rem",
                            action: async () => {
                                if (!$("AI-panel").validate()) {
                                    return
                                }

                                localStorage.setItem("config-ai-paragraph", JSON.stringify($("AI-panel").getData()))

                                const data = $("AI-panel").getData()
                                const prompt = this._preparePrompt(data)
                                const temperature = Number((data.temperature / 100).toFixed(2))
                                const result = await this._executePrompt(prompt, temperature, data.max_tokens)

                                if (!result.success) {
                                    createDialog({
                                        type: "danger",
                                        message: txtTitleCase("#openAI error"),
                                        noCancel: true
                                    })
                                    return
                                }

                                await this.setValue(result.data)
                                $("AI-panel").close("remove", true)
                            }
                        }
                    ]
                }).setAnimation({
                    name: "jackInTheBox",
                    speed: "fast"
                }).render()
            }
        })
    }

    /**
     * Prepare the prompt with extra parameters.
     * 
     * @private
     * @ignore
     * @param {object} config
     * @param {string} config.who - AI agent personality
     * @param {string} config.what - Task to perform
     * @param {string} config.tone - Tone to use when answering
     * @param {string} config.goal - Content goal
     * @param {string} config.prompt - Free prompt to detail the task
     * 
     * @returns {string} Prompt with options
     */
    _preparePrompt({
        who,
        what,
        tone,
        goal,
        prompt
    }) {
        const language = (kiss.language.current == "fr") ? "french" : "english"

        let instructions = ""
        if (who != "-") instructions += `You are a ${who}. `
        if (goal != "-") instructions += `The goal is to ${goal} the reader. `
        if (tone != "-") instructions += `The tone must be ${tone}. `
        if (what != "-") instructions += `You have to ${what}. `
        instructions += `Your answer must be in ${language}. `
        instructions += `Data to process using previous requirements: ${prompt}`

        return instructions
    }

    /**
     * Execute the prompt calling OpenAI service
     * 
     * @private
     * @ignore
     * @param {string} prompt 
     * @param {number} temperature - OpenAI temperature (default 0.5)
     * @param {number} max_tokens - Max number of tokens for OpenAI answer (default 2000)
     * @returns {object} The OpenAI service response, or an error
     */
    async _executePrompt(prompt, temperature = 0.5, max_tokens = 2000) {
        return await kiss.ajax.request({
            url: "/command/openai/createCompletion",
            method: "post",
            showLoading: true,
            timeout: 3 * 60 * 1000, // Give OpenAI 3mn to answer
            body: JSON.stringify({
                prompt,
                temperature,
                max_tokens
            })
        })
    }
}

// Create a Custom Element
customElements.define("a-aitextarea", kiss.ux.AiTextarea)

/**
 * Shorthand to create a new AI textarea. See [kiss.ux.AiTextarea](kiss.ux.AiTextarea.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createAiTextareaField = (config) => document.createElement("a-aitextarea").init(config)

;/**
 * 
 * The aiImage derives from [Field](kiss.ui.Attachment.html).
 * 
 * **AI** image field allows to generate an image with AI.
 * 
 * It's basically an attachment dedicated to store images generated by an AI.
 * 
 * @param {object} config
 * @returns this
 * 
 */
kiss.ux.AiImage = class AiImage extends kiss.ui.Attachment {
    constructor() {
        super()
    }

    /**
     * @ignore
     */
    init(config = {}) {
        config.type = "aiImage"
        config.buttonText = txtTitleCase("generate an image")
        super.init(config)
        return this
    }

    /**
     * Handle click event
     * 
     * @private
     * @ignore
     */
    _initClickEvent() {
        this.onclick = function (event) {
            if (event.target.classList.contains("field-upload-button")) {
                this.showPromptWindow()
            } else if (event.target.classList.contains("display-as-list")) {
                this.renderAs("list")
            } else if (event.target.classList.contains("display-as-thumbnails")) {
                this.renderAs("thumbnails")
            } else if (event.target.classList.contains("display-as-thumbnails-large")) {
                this.renderAs("thumbnails-large")
            }
        }
    }

    /**
     * Add a button to open an AI assistant
     * 
     * @private
     * @ignore
     */
    showPromptWindow() {
        const localStorageId = "config-ai-image-prompt-" + this.id

        createPanel({
            id: "AI-panel",
            title: txtTitleCase("#image generator"),
            icon: "fas fa-images",
            modal: true,
            closable: true,
            draggable: true,
            width: "50rem",
            align: "center",
            verticalAlign: "center",

            // Prevent from closing if the user started to work with a prompt
            events: {
                close: (forceClose) => {
                    if (forceClose) return true

                    if ($("prompt").getValue() != "") {
                        createDialog({
                            type: "danger",
                            message: txtTitleCase("are you sure you want to cancel your input?"),
                            buttonOKPosition: "left",
                            action: () => $("AI-panel").close("remove", true)
                        })
                        return false
                    }
                }
            },

            defaultConfig: {
                labelPosition: "top",
                width: "100%"
            },

            items: [
                // IMAGE SIZE
                {
                    id: "size",
                    type: "select",
                    label: txtTitleCase("image format"),
                    value: "1792x1024",
                    allowValuesNotInList: true,
                    options: [{
                        value: "1024x1024",
                        label: txtTitleCase("square")
                    }, {
                        value: "1792x1024",
                        label: txtTitleCase("landscape")
                    }, {
                        value: "1024x1792",
                        label: txtTitleCase("portrait")
                    }]
                },
                // AI PROMPT
                {
                    id: "prompt",
                    type: "textarea",
                    label: txtTitleCase("#AI image instructions"),
                    required: true,
                    rows: 10,
                    value: localStorage.getItem(localStorageId)
                },
                // BUTTON TO SEND THE PROMPT
                {
                    type: "button",
                    text: txtTitleCase("generate image..."),
                    icon: "fas fa-bolt",
                    iconColor: "var(--orange)",
                    margin: "2rem 0 0 0",
                    height: "4rem",
                    action: async () => {
                        if (!$("AI-panel").validate()) {
                            return
                        }

                        // Call the OpenAI service
                        const data = $("AI-panel").getData()
                        const result = await this._executePrompt({
                            prompt: data.prompt,
                            size: data.size
                        })

                        // Save the prompt for the next time
                        localStorage.setItem(localStorageId, data.prompt)

                        if (!result.success) {
                            createDialog({
                                type: "danger",
                                message: txtTitleCase("#openAI error"),
                                noCancel: true
                            })
                            return
                        }

                        $("AI-panel").close("remove", true)
                    }
                }
            ]
        }).setAnimation({
            name: "jackInTheBox",
            speed: "fast"
        }).render()
    }

    /**
     * Execute the prompt calling OpenAI service
     * 
     * @private
     * @ignore
     * @param {string} prompt 
     * @param {string} size - A size supported by Dall-E (1024x1024, 1792x1024, 1024x1792)
     * @returns {object} The OpenAI service response, or an error
     */
    async _executePrompt({prompt, size}) {
        return await kiss.ajax.request({
            url: "/command/openai/createImageToField",
            method: "post",
            showLoading: true,
            timeout: 3 * 60 * 1000, // Give OpenAI 3mn to answer
            body: JSON.stringify({
                modelId: this.record.model.id,
                recordId: this.record.id,
                fieldId: this.id,
                prompt,
                size
            })
        })
    }
}

// Create a Custom Element
customElements.define("a-aiimage", kiss.ux.AiImage)

/**
 * Shorthand to create a new AI image. See [kiss.ux.AiImage](kiss.ux.AiImage.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createAiImageField = (config) => document.createElement("a-aiimage").init(config)

;/**
 * 
 * The Map derives from [Component](kiss.ui.Component.html).
 * 
 * Encapsulates original OpenLayers inside a KissJS UI component:
 * https://openlayers.org/
 * 
 * @param {object} config
 * @param {float} [config.longitude] - Longitude
 * @param {float} [config.latitude] - Latitude
 * @param {string} [config.address] - Address
 * @param {integer} [config.zoom] - Zoom level (default 10)
 * @param {integer} [config.width] - Width in pixels
 * @param {integer} [config.height] - Height in pixels
 * @param {boolean} [config.showMarker] - Set false to hide the marker. Default is true.
 * @param {boolean} [config.useCDN] - Set to false to use the local version of OpenLayers. Default is true.
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-map class="a-map">
 *  <div class="ol-viewport"></div>
 * </a-map>
 * ```
 */
kiss.ux.Map = class Map extends kiss.ui.Component {
    /**
     * 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 myMap = document.createElement("a-map").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myMap = createMap({
     *  width: 300,
     *  height: 200,
     *  longitude: 2.3483915,
     *  latitude: 48.8534951,
     *  zoom: 15
     * })
     * 
     * myMap.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *          type: "map",
     *          width: 300,
     *          height: 200,
     *          longitude: 2.3483915,
     *          latitude: 48.8534951,
     *          zoom: 15
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     * 
     * You can define a map from a geolocation or an address:
     * ```
     * const myMapFromGeoloc = createMap({
     *  longitude: 2.3483915,
     *  latitude: 48.8534951,
     * })
     * 
     * const myMapFromAddress = createMap({
     *  address: "10 Downing Street, London",
     * })
     * ```
     * 
     * For now, the geoencoding is done with Nominatim, which is a free service but has limitations when it comes to the accuracy of the address street number.
     */
    constructor() {
        super()
    }

    /**
     * Generates a map from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config = {}) {

        // Set default values
        config.width = config.width || 300
        config.height = config.height || 225
        this.zoom = config.zoom || 10
        this.longitude = config.longitude
        this.latitude = config.latitude
        this.address = config.address
        this.showMarker = (config.showMarker === false) ? false : true
        this.useCDN = (config.useCDN === false) ? false : true

        super.init(config)

        this._setProperties(config, [
            [
                ["display", "flex", "position", "top", "left", "width", "height", "margin", "padding", "background", "backgroundColor", "borderColor", "borderRadius", "borderStyle", "borderWidth", "boxShadow"],
                [this.style]
            ]
        ])

        return this
    }

    /**
     * Check if the OpenLayers (ol) library is loaded, and initialize the map
     * 
     * @ignore
     */
    async _afterRender() {
        if (window.ol) {
            this.initMap()
        } else {
            await this.initOpenLayers()
            this.initMap()
        }
    }

    /**
     * Load the OpenLayers library
     * 
     * @ignore
     */
    async initOpenLayers() {
        if (this.useCDN === false) {
            // Local
            await kiss.loader.loadScript("../../kissjs/client/ux/map/map_ol")
            await kiss.loader.loadStyle("../../kissjs/client/ux/map/map_ol")
        } else {
            // CDN
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/ol@v10.0.0/dist/ol")
            await kiss.loader.loadStyle("https://cdn.jsdelivr.net/npm/ol@v10.0.0/ol")
        }
    }

    /**
     * Initialize the OpenLayers map
     * - Create the map
     * - Set the target
     * - Add a click event to store the click coordinates in the "clicked" property
     * 
     * @ignore
     */
    initMap() {
        // Create the map
        this.map = new ol.Map({
            layers: [
                new ol.layer.Tile({
                    source: new ol.source.OSM(),
                })
            ],

            view: new ol.View({
                zoom: this.zoom
            })
        })

        // Insert the map inside the KissJS component
        this.map.setTarget(this.id)

        if (this.longitude && this.latitude) {
            this.setGeolocation({
                longitude: this.longitude,
                latitude: this.latitude
            })
        } else if (this.address) {
            this.setAddress(this.address)
        }

        // Store the clicked coordinates
        // const _this = this
        // this.map.on("click", function (evt) {
        //     const coordinate = evt.coordinate
        //     const lonLat = ol.proj.toLonLat(coordinate)
        //     _this.clicked = {
        //         longitude: lonLat[0],
        //         latitude: lonLat[1]
        //     }
        // })
    }

    /**
     * Set a new address on the map
     * 
     * IMPORTANT: this methods uses Nominatim for geocoding, which is a free service but has limitations when it comes to the accuracy address street number.
     * 
     * @async
     * @param {string} address 
     * @returns {object} The geolocation object: {longitude, latitude}
     * 
     * @example
     * myMap.setAddress("10 Downing Street, London")
     */
    async setAddress(address) {
        const geoloc = await kiss.tools.getGeolocationFromAddress(address)
        if (!geoloc) return

        this.longitude = geoloc.longitude
        this.latitude = geoloc.latitude

        this.setGeolocation({
            longitude: this.longitude,
            latitude: this.latitude
        })

        return {
            longitude: this.longitude,
            latitude: this.latitude
        }
    }

    /**
     * Set a new geolocation on the map
     * 
     * @param {object} geoloc
     * @param {number} geoloc.longitude
     * @param {number} geoloc.latitude
     * @returns {object} The geolocation object
     * 
     * @example
     * myMap.setGeolocation({
     *  longitude: 2.3483915,
     *  latitude: 48.8534951
     * })
     */
    setGeolocation(geoloc) {
        try {
            this.longitude = geoloc.longitude
            this.latitude = geoloc.latitude

            const newLonLat = [this.longitude, this.latitude]
            const newCenter = ol.proj.fromLonLat(newLonLat)
            this.map.getView().setCenter(newCenter)

            if (this.showMarker) this.addGeoMarker()
            return this
        }
        catch(err) {
            // Map is not loaded yet
            return this
        }
    }

    /**
     * Add a marker on the map at the current geolocation
     * 
     * @returns this
     */
    addGeoMarker() {
        const position = ol.proj.fromLonLat([this.longitude, this.latitude])

        const iconStyle = new ol.style.Style({
            text: new ol.style.Text({
                font: '900 24px "Font Awesome 5 Free"',
                text: "\uf3c5",
                fill: new ol.style.Fill({
                    color: "#ff0000"
                }),
                offsetY: -12
            })
        })

        const iconFeature = new ol.Feature({
            geometry: new ol.geom.Point(position)
        })
        iconFeature.setStyle(iconStyle)

        const vectorLayer = new ol.layer.Vector({
            source: new ol.source.Vector({
                features: [iconFeature]
            })
        })

        this.map.addLayer(vectorLayer)
        return this
    }

    /**
     * Set a new zoom level on the map
     * 
     * @param {number} zoom
     * @returns this
     * 
     * @example
     * myMap.setZoom(15)
     */
    setZoom(zoom) {
        this.zoom = zoom
        this.map.getView().setZoom(zoom)
        return this
    }

    /**
     * Set the width of the map
     * 
     * @param {number} width 
     * @returns this
     */
    setWidth(width) {
        this.style.width = width
        return this
    }

    /**
     * Set the height of the map
     * 
     * @param {number} height 
     * @returns this
     */
    setHeight(height) {
        this.style.height = height
        return this
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-map", kiss.ux.Map)

/**
 * Shorthand to create a new Map component. See [kiss.ux.Map](kiss.ux.Map.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createMap = (config) => document.createElement("a-map").init(config)

;/**
 * 
 * The Map field derives from [Field](kiss.ui.Field.html).
 * 
 * **Map** field displays a map with a text field to enter an address or geo coordinates.
 * 
 * @param {object} config
 * @param {string} [config.value] - Default address or geo coordinates like: latitude,longitude
 * @param {number} [config.zoom] - Zoom level (default 10, max 19)
 * @param {number} [config.mapHeight] - Height (the map width is defined by the field's width)
 * @param {number|string} [config.mapRatio] - Ratio between the field width and the map height (default 4/3). Can be a number or a string to evaluate, like: "4/3", "16/9", 1.77, 1.33, 2, etc. Use this property only if the height is not defined.
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-mapfield class="a-mapfield">
 *  <label class="field-label"></label>
 *  <input type="text" class="field-input"></input>
 *  <a-map class="a-map">
 *      <div class="ol-viewport"></div>
 *  </a-map>
 * </a-mapfield>
 * ```
 */
kiss.ux.MapField = class MapField extends kiss.ui.Field {
    /**
     * 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 myMapField = document.createElement("a-mapfield").init(config)
     * ```
     * 
     * Or use a shorthand to create one the various field types:
     * ```
     * const myMapField = createMapField({
     *  value: "-21,55",
     *  zoom: 15,
     *  width: 600,
     *  mapHeight: 400
     * })
     * 
     * myMapField.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *           type: "mapfield",
     *           value: "-21,55",
     *           zoom: 15,
     *           width: 600,
     *           mapHeight: 400
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * @ignore
     */
    init(config = {}) {
        config.type = "mapField"
        config.autoSize = true

        // Generates the text field to enter the address or geo coordinates
        super.init(config)

        // Ensure the map will be displayed below the field
        this.style.flexFlow = "row wrap"

        this._observeKeys()
        return this
    }

    /**
     * @ignore
     */
    async _afterRender() {
        // Insert a map right after the field
        this._createMap()

        // Wait for the OpenLayers library to be loaded
        await this._waitForOpenLayers()

        // Adjust the map height based on the field width, if no height is defined
        if (this.config.mapRatio && !this.config.mapHeight) {
            this._adjustMapRatio()
        }

        // Set the map's default position
        if (this.config.value) {
            this._setMapValue(this.config.value)
        }

        // Add a button to expand the map fullscreen
        this._addExpandButton()
    }

    /**
     * Add a map to the field
     * 
     * @private
     * @ignore
     */
    async _createMap() {
        let zoom = this.config.zoom || 10
        if (zoom > 19) zoom = 19
        if (zoom < 1) zoom = 1

        this.map = createMap({
            zoom: this.config.zoom,
            width: this.config.width,
            height: this.config.mapHeight
        })

        this.map.style.order = 2
        this.map.style.flex = "1 1 100%"

        this.appendChild(this.map)
        this.map.render()
    }

    /**
     * Wait for the OpenLayers library to be loaded
     * 
     * @private
     * @ignore
     * @param {number} [maxAttempts=50] - Maximum number of attempts
     */
    _waitForOpenLayers(maxAttempts = 50) {
        let attempts = 0
        return new Promise((resolve, reject) => {
            function checkOpenLayers() {
                if (typeof ol !== "undefined") {
                    resolve();
                } else if (attempts < maxAttempts) {
                    attempts++
                    setTimeout(checkOpenLayers, 100)
                } else {
                    reject(new Error("Could not load openLayers library"))
                }
            }
            checkOpenLayers()
        })
    }

    /**
     * Adjusts the map height based on the field width
     * 
     * @private
     * @ignore
     */
    _adjustMapRatio() {
        this.mapRatio = this.config.mapRatio
        if (typeof this.mapRatio == "string") {
            const mapRatio = eval(this.mapRatio)
            this.mapRatio = (isNaN(mapRatio)) ? (4 / 3) : mapRatio
        }

        setTimeout(() => {
            const width = this.getBoundingClientRect().width
            this.map.setHeight(width / this.mapRatio + "px")
        }, 50)
    }

    /**
     * Updates the field value internally
     * 
     * @private
     * @ignore
     * @param {*} updates 
     */
    _updateField(updates) {
        if (this.id in updates) {
            const newValue = updates[this.id]
            if (newValue || (newValue === 0) || (newValue === "")) {
                this.field.value = newValue
                this._setMapValue(newValue)
            }
        }
    }

    /**
     * Add a button to expand the map fullscreen
     * 
     * @private
     * @ignore
     */
    _addExpandButton() {
        setTimeout(() => {
            const fieldMap = this.map
            const mapExpandButton = document.createElement("button")
            mapExpandButton.innerHTML = "â›¶"
            mapExpandButton.classList.add("a-mapfield-button")
            fieldMap.map.getViewport().appendChild(mapExpandButton)
            mapExpandButton.onclick = () => this.expandMap()
        }, 500)
    }

    /**
     * @ignore
     */
    _observeKeys() {
        const _this = this
        this.field.onkeydown = function (e) {
            if (e.key === "Enter") {
                _this._setMapValue(_this.field.value)
            }
        }
    }

    /**
     * @ignore
     */
    _setMapValue(input) {
        const geoloc = kiss.tools.isGeolocation(input)
        if (geoloc) {
            this.map.setGeolocation(geoloc)
        } else {
            this.map.setAddress(input)
        }
    }

    /**
     * Expand the map fullscreen
     * 
     * @returns this
     */
    expandMap() {
        let map = createMap({
            width: "100%",
            height: "100%",
            longitude: this.map.longitude,
            latitude: this.map.latitude,
            zoom: this.map.zoom
        })

        createPanel({
            title: this.config.label,
            closable: true,
            position: "absolute",
            top: 0,
            left: 0,
            padding: 0,
            width: "100%",
            height: "100%",
            items: [
                map
            ]
        }).render()

        return this
    }

    /**
     * Set a new address on the map
     * 
     * IMPORTANT: this methods uses Nominatim for geocoding, which is a free service but has limitations when it comes to the accuracy address street number.
     * 
     * @param {string} address 
     * @returns this
     * 
     * @example
     * myMapField.setAddress("10 Downing Street, London")
     */
    setAddress(address) {
        this.map.setAddress(address)
    }

    /**
     * Set a new geolocation on the map
     * 
     * @param {object} geoloc
     * @param {number} geoloc.longitude
     * @param {number} geoloc.latitude
     * @returns this
     * 
     * @example
     * myMapField.setGeolocation({
     *  longitude: 2.3483915,
     *  latitude: 48.8534951
     * })
     */
    setGeolocation(geoloc) {
        this.map.setGeolocation(geoloc)
    }
}

// Create a Custom Element
customElements.define("a-mapfield", kiss.ux.MapField)

/**
 * Shorthand to create a new Map field. See [kiss.ux.MapField](kiss.ux.MapField.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createMapField = (config) => document.createElement("a-mapfield").init(config)

;/**
 * 
 * The QrCode derives from [Component](kiss.ui.Component.html).
 * 
 * Encapsulates original QRCode.js inside a KissJS UI component:
 * https://github.com/davidshimjs/qrcodejs
 * 
 * The generator includes correct levels:
 * 
 * Level L
 * This is the lowest level of error correction rate that a QR code can have. QR code software uses this level if the user intends to generate a less dense QR code image.
 * Level L has the highest error correction rate of approximately seven percent (7%).
 * 
 * Level M
 * Level M is the middle tier of the error correction level that QR code experts recommend for marketing use. Because of this, marketers can correct their QR codes at a medium level. Level M has the highest error correction rate of approximately fifteen percent (15%).
 * 
 * Level Q
 * This level is the second to the highest error correction level. This error correction level has the highest error correction rate of approximately twenty-five percent (25%).
 * 
 * Level H
 * Level H is the highest error correction level that can withstand an extreme level of damage in their QR code. The level Q and H error correction levels are most recommended for industrial and manufacturing companies.
 * 
 * @param {object} config
 * @param {string} config.text - The text to encode as a QRCode
 * @param {integer} [config.width] - Width in pixels
 * @param {integer} [config.height] - Height in pixels
 * @param {string} [config.colorDark] - Hexa color code. Default #000000
 * @param {string} [config.colorLight] - Hexa color code. Default #ffffff
 * @param {string} [config.correctLevel] - "L", "M", "Q", or "H". Default "M"
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-qrcode class="a-qrcode">
 *  <div class="qrcode-image"></div>
 * </a-qrcode>
 * ```
 */
kiss.ux.QrCode = class QrCode extends kiss.ui.Component {
    /**
     * 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 myQrCode = document.createElement("a-qrcode").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myQrCode = createQrCode({
     *  text: "I'm a QRCode"
     * })
     * 
     * myQrCode.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *          type: "qrcode",
     *          text: "I'm a QRCode",
     *          colorDark: "#00aaee",
     *          correctionLevel: "H"
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a QRCode from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config = {}) {
        super.init(config)

        this.innerHTML = `<div class="qrcode-image"></div>`
        this.QRCodeImage = this.querySelector(".qrcode-image")
        this.style.display = "inline-block"

        this._setProperties(config, [
            [
                ["width", "height"],
                [this.style]
            ]
        ])

        return this
    }

    /**
     * Check if the QRCode library is loaded, and initialize the QRCode
     * 
     * @private
     * @ignore
     */    
    async _afterRender() {
        if (!window.QRCode) {
            await kiss.loader.loadScript("../../kissjs/client/ux/qrcode/qrcode.lib")
        }

        // Insert QRCode inside the KissJS component
        const correctLevels = {L: 1, M: 0, Q: 3, H: 2}
        new QRCode(this.QRCodeImage, {
            text: this.config.text,
            width: this.config.width || "100",
            height: this.config.height || "100",
            correctLevel: correctLevels[this.config.correctLevel] || 0
        })        
    }
}

customElements.define("a-qrcode", kiss.ux.QrCode)

/**
 * Shorthand to create a new QrCode. See [kiss.ux.QrCode](kiss.ux.QrCode.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createQRCode = (config) => document.createElement("a-qrcode").init(config)

;/**
 * 
 * The chart derives from [Component](kiss.ui.Component.html).
 * 
 * Encapsulates original Chart.js charts inside a KissJS UI component:
 * https://www.chartjs.org/
 * 
 * @param {object} config
 * @param {string} config.chartType - Chart type (bar, line, pie, ... check Chart.js documentation)
 * @param {object} config.data - Chart data (https://www.chartjs.org/docs/latest/general/data-structures.html)
 * @param {object} config.options - Chart options (https://www.chartjs.org/docs/latest/general/options.html)
 * @param {object} [config.plugins] - Chart plugins (https://www.chartjs.org/docs/latest/developers/plugins.html)
 * @param {integer} [config.width] - Width in pixels
 * @param {integer} [config.height] - Height in pixels
 * @param {boolean} [config.useCDN] - Set to false to use the local version of ChartJS. Default is true.
 * @param {boolean} [config.useDataLabels] - Set to true to use the plugin for data labels. Default is false. See https://chartjs-plugin-datalabels.netlify.app/
 * @param {boolean} [config.useMoment] - Set to true to use the plugin for moment.js. Default is false. See https://github.com/chartjs/chartjs-adapter-moment
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-chart class="a-chart">
 *  <canvas id="chart-id">
 *      <!-- Chart.js canvas -->
 *  </canvas>
 * </a-chart>
 * ```
 */
kiss.ux.Chart = class UxChart extends kiss.ui.Component {
    /**
     * 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 myChart = document.createElement("a-chart").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myChart = createChart({
     *  chartType: "bar",
     *  data: {...},
     *  options: {...},
     *  width: 300,
     *  height: 200
     * })
     * 
     * myChart.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *          type: "chart",
     *          chartType: "bar",
     *          data: {...},
     *          options: {...},
     *          width: 300,
     *          height: 200
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a chart from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config = {}) {
        config.type = "chart"

        // Set default values
        config.width = config.width || "30rem"
        config.height = config.height || "22.5rem"
        this.chartType = config.chartType
        this.data = config.data
        this.options = config.options
        this.plugins = config.plugins || []
        this.useCDN = (config.useCDN === false) ? false : true
        this.useDataLabels = config.useDataLabels || false
        this.useMoment = config.useMoment || false

        super.init(config)

        this.innerHTML = `<canvas id="chart-${this.id}"></canvas>`
        this.chartContainer = this.querySelector("canvas")

        // Set the style
        this.style.display = "flex"
        this.style.alignItems = "center"
        this.style.justifyContent = "center"
        this.style.overflow = "hidden"
        this.chartContainer.style.flex = 1

        this._setProperties(config, [
            [
                ["flex", "position", "top", "left", "width", "height", "margin", "padding", "background", "backgroundColor", "borderColor", "borderRadius", "borderStyle", "borderWidth", "boxShadow"],
                [this.style]
            ]
        ])

        return this
    }

    /**
     * Check if the Chart.js library is loaded, and initialize the chart
     * 
     * @private
     * @ignore
     */
    async _afterRender() {
        await this._initChartJS({
            useDataLabels: this.useDataLabels,
            useMoment: this.useMoment
        })

        this._initChart()
    }

    /**
     * Load the OpenLayers library
     * 
     * @private
     * @ignore
     */
    async _initChartJS({useDataLabels, useMoment} = {}) {
        await this._initChartJSCore()

        // Data labels plugin
        if (useDataLabels) await this._initDataLabels()

        // Moment.js adapter
        if (useMoment) await this._initMoment()
    }

    async _initChartJSCore() {
        if (window.Chart) return

        if (this.useCDN === false) {
            await kiss.loader.loadScript("../../kissjs/client/ux/chart/chartjs")
        }
        else {
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/chart")
        }
    }

    /**
     * Load the Chart.js plugin for data labels
     * 
     * @private
     * @ignore
     */
    async _initDataLabels() {
        if (typeof ChartDataLabels !== "undefined") return

        if (this.useCDN === false) {
            await kiss.loader.loadScript("../../kissjs/client/ux/chart/chartjs-plugin-datalabels")
            
        }
        else {
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels", {
                autoAddExtension: false
            })
        }

        Chart.register(ChartDataLabels)
    }

    /**
     * Load the Chart.js adapter for moment.js
     * 
     * @private
     * @ignore
     */
    async _initMoment() {
        if (window.moment) return

        if (this.useCDN === false) {
            await kiss.loader.loadScript("../../kissjs/client/ux/chart/chartjs-moment")
            await kiss.loader.loadScript("../../kissjs/client/ux/chart/chartjs-moment-adapter")
        }
        else {
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/moment/min/moment-with-locales.min")
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/chartjs-adapter-moment", {
                autoAddExtension: false
            })
        }

        // Set the locale to be able to translate the dates in time series
        window.moment.locale(kiss.language.current || "en")
    }

    /**
     * Initialize the chart
     * 
     * @private
     * @ignore
     */
    _initChart() {
        this.chart = new Chart(this.chartContainer, {
            type: this.chartType,
            data: this.data,
            options: this.options,
            plugins: this.plugins
        })
    }

    /**
     * Refresh the chart with new data and/or options
     * 
     * @param {object} config
     * @param {object} [config.chartType] - New chart type
     * @param {object} [config.data] - New chart data
     * @param {object} [config.options] - New chart options
     * @param {boolean} [config.useDataLabels] - Set to true to use the plugin for data labels
     * @param {boolean} [config.useMoment] - Set to true to use the adapter for moment.js
     */
    async refresh({chartType, data, options, useDataLabels, useMoment, width, height}) {
        if (!this.chart) return
        
        this.setWidth(width)
        this.setHeight(height)

        // The chart should be regenerated if the chart type or the useDataLabels property has changed
        const shouldRegenerate = (chartType != this.chartType) || (useDataLabels != this.useDataLabels)
        
        if (shouldRegenerate) {
            this.chart.destroy()
            this.chartType = chartType

            await this._initChartJS({
                useDataLabels,
                useMoment
            })
            
            this.chart = new Chart(this.chartContainer, {
                type: this.chartType,
                data,
                options,
                plugins: this.plugins
            })
        }
        else {
            Object.assign(this.chart.data, data)
            Object.assign(this.chart.options, options)
            this.chart.update()
        }
    }

    /**
     * Destroy the chart
     * 
     * https://www.chartjs.org/docs/latest/developers/api.html
     */
    destroy() {
        this.chart.destroy()
    }

    /**
     * Update the chart
     * 
     * https://www.chartjs.org/docs/latest/developers/api.html
     */
    update() {
        this.chart.update()
    }

    /**
     * Reset the chart
     * 
     * https://www.chartjs.org/docs/latest/developers/api.html
     */
    reset() {
        this.chart.reset()
    }

    /**
     * Resize the chart
     * 
     * @param {number} width - Width in pixels
     * @param {number} height - Height in pixels
     */
    resize(width, height) {
        this.style.width = width + "px"
        this.style.height = height + "px"
    }

    /**
     * Export the chart to an image
     * 
     * https://www.chartjs.org/docs/latest/developers/api.html
     * 
     * @param {string} type - image type (image/png, image/jpeg, image/webp, ...)
     * @param {number} quality - 0 to 1
     * @returns {string} Base64 image
     * 
     * @example
     * ```
     * // Returns a png data url of the image on the canvas
     * const imageAsPng = myChart.toBase64Image()
     * 
     * // Returns a jpeg data url in the highest quality of the canvas
     * const imageAsJpg = myChart.toBase64Image("image/jpg", 1)
     * ```
     */
    toBase64Image(type, quality) {
        return this.chart.toBase64Image(type, quality)
    }

    /**
     * Download the chart as an image
     * 
     * @param {object} config
     * @param {string} [config.filename] - image filename. Default is "chart.jpg"
     * @param {string} [config.type] - image type (image/png, image/jpeg, image/webp, ...). Default is "image/jpg"
     * @param {number} [config.quality] - 0 to 1. Default is 1
     */
    downloadBase64Image({type, quality, filename} = {}) {
        const link = document.createElement("a")
        link.href = this.toBase64Image(type || "image/jpg", quality || 1)
        link.download = filename || "chart.jpg"
        document.body.appendChild(link)
        link.click()
        document.body.removeChild(link)
    }    

    /**
     * Set the width of the map
     * 
     * @param {number} width 
     * @returns this
     */
    setWidth(width) {
        this.config.width = width
        this.style.width = this._computeSize("width")
        return this
    }

    /**
     * Set the height of the map
     * 
     * @param {number} height 
     * @returns this
     */
    setHeight(height) {
        this.config.height = height
        this.style.height = this._computeSize("height")
        return this
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-chart", kiss.ux.Chart)

/**
 * Shorthand to create a new Chart component. See [kiss.ux.Chart](kiss.ux.Chart.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createChart = (config) => document.createElement("a-chart").init(config)

;/**
 * 
 * A *Directory* field allows to select users, groups, roles.
 * It also handles API clients, which can be considered as users with specific rights inside an application.
 * 
 * It has some special options compared to the standard <Select> field:
 * - users: use false to hide users
 * - groups: use false to hide groups
 * - roles: add custom roles in the list (like "everyone", "nobody, "creator"...)
 * - apiClients: use true to show them
 * - sortBy: to sort by first name or last name
 * - nameOrder: to display the first name or the last name first
 * - sortOrder: use "asc" or "desc"
 * - displayAsCards: to display the selected users as nice colored card
 * 
 * @param {object} config
 * @param {boolean} [config.multiple] - True to enable multi-select - Default to true
 * @param {boolean} [config.users] - true to list the users - Default to true
 * @param {boolean} [config.groups] - true to list the groups - Default to true
 * @param {object[]} [config.roles] - list of custom roles like: ["everyone", "authenticated", "creator", "userId", "nobody"]
 * @param {boolean} [config.apiClients] - true to list the API clients - Default to false
 * @param {string} [config.sortBy] - Use "firstName" or "lastName", to sort users according to their first name or last name
 * @param {string} [config.nameOrder] - Use "firstName" or "lastName", to show users like "Smith John" or "John Smith"
 * @param {string} [config.sortOrder] - Use "asc" (default) or "desc", to change the sort order for users and groups
 * @param {boolean} [config.displayAsCards] - true to display values as cards
 * @param {string|string[]} [config.value] - Default value
 * @param {string} [config.optionsColor] - Default color for all options
 * @param {string} [config.valueSeparator] - Character used to display multiple values
 * @param {string} [config.inputSeparator] - Character used to input multiple values
 * @param {boolean} [config.stackValues] - True to render the values one on another
 * @param {boolean} [config.hideInput] - true (default) to automatically hide the input field after a completed search
 * @param {boolean} [config.allowValuesNotInList] - Allow to input a value which is not in the list of options
 * @param {boolean} [config.allowDuplicates] - Allow to input duplicate values. Default to false.
 * @param {boolean} [config.allowClickToDelete] - Add a "cross" icon over the values to delete them. Default to false.
 * @param {boolean} [config.allowSwitchOnOff] - Allow to click on a value to switch it on/off
 * @param {function} [config.optionRenderer] - Custom function to render each option in the list of options
 * @param {function} [config.valueRenderer] - Custom function to render the actual field values
 * @param {string} [config.label]
 * @param {string} [config.labelWidth]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {boolean} [config.autocomplete] - Set "off" to disable
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled] - TODO
 * @param {boolean} [config.required] - TODO
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {string} [config.display] - flex | inline flex
 * @param {string|number} [config.width]
 * @param {string|number} [config.minWidth]
 * @param {string|number} [config.height]
 * @returns this
 * 
 */
kiss.ux.Directory = class Directory extends kiss.ui.Select {
    constructor() {
        super()
    }

    /**
     * @ignore
     */
    init(config = {}) {
        // Defaults
        config.multiple = !!config.multiple
        config.optionRenderer = this.optionRenderer
        config.allowDuplicates = false
        config.allowClickToDelete = true
        config.maxHeight = (kiss.screen.isMobile) ? "calc(100% - 3.2rem)" : "42rem"

        // Load options for users and/or groups and/or roles
        this.showUsers = (config.users !== false)
        this.showGroups = (config.groups !== false)
        this.showRoles = (Array.isArray(config.roles) && config.roles.length > 0)
        this.showApiClients = (config.apiClients === true)
        this.roles = config.roles || []

        // Define icons for each entry type
        this.types = {
            user: "fas fa-user directory-user-icon",
            group: "fas fa-user-friends directory-group-icon",
            role: "fas fa-key directory-role-icon",
            api: "fas fa-plug directory-role-icon"
        }

        // If true, display values as cards
        this.displayAsCards = config.displayAsCards

        // Ordering
        this.nameOrder = config.nameOrder || "lastName"
        this.sortBy = config.sortBy || "lastName"
        this.sortOrder = config.sortOrder || "asc"

        // Readonly
        this.readOnly = !!config.readOnly || !!config.computed

        // Generates the <Select> field
        super.init(config)
        
        if (!this.readOnly) {
            // Override click event
            this.onclick = function (event) {
                event.stop()
                const classes = event.target.classList
                if (classes.contains("field-select-value-delete")) return this._deleteValueByClick(event)
                else if (classes.contains("field-select-value")) return this._showOptions()
                else if (classes.contains("field-select-values")) return this._showOptions()
                else if (classes.contains("field-select")) return this._showOptions()
                else if (classes.contains("field-select-input")) return this._showOptions()
                else if (classes.contains("directory-item-initials")) return this._showOptions()
                else if (classes.contains("directory-item-title")) return this._showOptions()
                else if (classes.contains("directory-item-subtitle")) return this._showOptions()
                else if (classes.contains("field-option")) return this._selectOption(event)
            }
        }

        return this
    }

    /**
     * Defines how values are displayed
     * 
     * @private
     * @ignore
     */
    _renderValues() {
        this._loadOptions()

        // Check if the field is empty
        let isEmpty = false

        if (this.multiple) {
            if (this.value && Array.isArray(this.value) && this.value.length == 0) isEmpty = true
        } else {
            if (this.value === undefined || this.value === "") isEmpty = true
        }

        if (isEmpty) {
            this.fieldValues.innerHTML = ""
            this._adjustSizeAndPosition()
            return
        }

        // Set the value renderer
        let renderer = (this.displayAsCards) ? (this._renderValueAsCard).bind(this) : (this._renderValue).bind(this)

        // Separate values by <br> if the option "stackValues" is true
        let htmlSeparator = (this.stackValues) ? "<br>" : ""

        this.fieldValues.innerHTML = []
            .concat(this.value)
            .filter(value => value != "" && value != undefined && value != null)
            .map(value => {
                let option = this.options.find(option => option.value == value)

                if (option) return renderer(option)

                if (this.allowValuesNotInList) return renderer({
                    label: value,
                    value
                })
            })
            .join(htmlSeparator)

        // Adjust the size of the options wrapper depending on the field content
        this._adjustSizeAndPosition()
    }

    /**
     * Default renderer to render a single value
     * 
     * @private
     * @ignore
     * @param {object} option 
     */
    _renderValue(option) {
        return /*html*/ `
            <div class="field-select-value" value="${option.value}" ${(option.color || this.optionsColor) ? `style="background: ${option.color || this.optionsColor}"` : ""}>
                ${option.label || option.value}
                ${(this.allowClickToDelete == true) ? `<span class="field-select-value-delete fas fa-times"></span>` : ""}
            </div>
        `.removeExtraSpaces()
    }

    /**
     * Extended renderer to render a single value
     * 
     * @private
     * @ignore
     * @param {object} option 
     */
    _renderValueAsCard(option) {
        let initials = kiss.directory.getUserInitials(option)
        let userColor = kiss.directory.getEntryColor(option.value)

        return /*html*/ `
            <div class="field-select-value directory-item" value="${option.value}">
                <span class="directory-item-initials" style="background: ${userColor}">${initials}</span>
                <div class="directory-item-infos">
                    <span class="directory-item-title">${option.label}</span>
                    <span class="directory-item-subtitle">${option.value}</span>
                </div>
                ${(this.allowClickToDelete == true) ? `<span class="field-select-value-delete fas fa-times"></span>` : ""}
            </div>
        `.removeExtraSpaces()
    }

    /**
     * Create the list of options
     */
    async _createOptions() {
        await this._loadOptions()
        super._createOptions()
    }

    /**
     * Get the list of possible values from the directory
     * 
     * @private
     * @ignore
     */
    _loadOptions() {
        if (this.isLoaded) return

        this.options = []

        if (this.showRoles) {
            kiss.directory._initRoles()
            this.options = this.options.concat(this.roles.map(roleId => kiss.directory.roles[roleId]))
        }
        if (this.showUsers != false) this.options = this.options.concat(this.getUsers())
        if (this.showGroups == true) this.options = this.options.concat(this.getGroups())
        if (this.showApiClients == true) this.options = this.options.concat(this.getApiClients())

        this.isLoaded = true
    }

    /**
     * Get users
     * 
     * @ignore
     * @returns {object[]} Array of users
     */
    getUsers() {
        return kiss.directory
            .getUsers({
                sortBy: this.sortBy,
                sortOrder: this.sortOrder,
                nameOrder: this.nameOrder,
                onlyActiveUsers: true
            })
            .map(user => {
                return {
                    type: "user",
                    label: user.name,
                    firstName: user.firstName,
                    lastName: user.lastName,
                    value: user.email
                }
            })
    }

    /**
     * Get groups
     * 
     * @ignore
     * @returns {object[]} Array of groups
     */
    getGroups() {
        return kiss.directory
            .getGroups(this.sortOrder)
            .map(group => {
                return {
                    type: "group",
                    label: group.name,
                    value: group.id
                }
            })
    }

    /**
     * Get API clients
     * 
     * @ignore
     * @returns {object[]} Array of API clients
     */
    getApiClients() {
        return kiss.directory
            .getApiClients()
            .map(client => {
                return {
                    type: "api",
                    label: client.name,
                    value: client.id
                }
            })
    }    

    /**
     * Defines how options are displayed
     * 
     * @ignore
     */
    optionRenderer(option) {
        return `<span class="${this.types[option.type]} field-option-icon" style="color: #00aaee"></span>${option.label}`
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-directory", kiss.ux.Directory)

/**
 * Shorthand to create a new Directory field. See [kiss.ux.Directory](kiss.ux.Directory.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createDirectory = (config) => document.createElement("a-directory").init(config)

;/**
 * 
 * A *Link* field allows to link records together by picking a foreign record from a list.
 * 
 * @param {object} config
 * @param {object} config.link - Configuration of the link:
 * @param {string} config.link.modelId - Id of the foreign model
 * @param {string} config.link.fieldId - Id of the field in the foreign model that will be linked
 * @param {boolean} [config.canCreateRecord] - Set to false to prevent from creating a new foreign record directly from the Link field. Default = true
 * @param {boolean} [config.canLinkRecord] - Set to false to prevent from linking to a new foreign record directly from the Link field. Default = true
 * @param {boolean} [config.canDeleteLinks] - Set to false to prevent from deleting links directly from the Link field. Default = true
 * @param {boolean} [config.multiple] - True to enable multi-select
 * @param {boolean} [config.linkStyle] - "default" or "compact"
 * @param {string} [config.label]
 * @param {string} [config.labelWidth]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {boolean} [config.readOnly]
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {string|number} [config.width]
 * @param {string|number} [config.minWidth]
 * @param {string|number} [config.height]
 * @returns this
 * 
 * @example
 * {
 *   type: "link",
 *   id: "customers",
 *   label: "Customers",
 *   link: {
 *      modelId: "customer",
 *      fieldId: "name"
 *   },
 *   multiple: true,
 *   canCreateRecord: true,
 *   canLinkRecord: true
 * }
 * 
 */
kiss.ux.Link = class Link extends kiss.ui.Select {
    constructor() {
        super()
    }

    init(config = {}) {
        this.readOnly = !!config.readOnly
        this.canCreateRecord = config.canCreateRecord
        this.canLinkRecord = config.canLinkRecord
        this.canDeleteLinks = config.canDeleteLinks

        // Init the foreign table
        this.foreignModel = kiss.app.models[config.link.modelId]
        this.foreignCollection = this.foreignModel?.collection || {}
        this.sort = []

        // Init the global table that contains relationships
        this.linkModel = kiss.app.models.link
        this.linkCollection = this.linkModel.collection

        // Implement the default <Select> field
        super.init(config)

        // Overrides default click event
        this.onclick = this._handleClick
        
        // Disable the dropdown list that shows options
        this._showOptions = () => {}

        return this
    }

    /**
     * Handle the click event
     * 
     * @private
     * @ignore
     * @param {object} event 
     */
    _handleClick(event) {
        const classes = event.target.classList

        // Clicked on the unlink button
        if (classes.contains("field-link-value-delete")) {
            if (!this.readOnly) {
                const fieldValueElement = event.target.closest("div")
                const linkId = fieldValueElement.getAttribute("linkId")
                return this._deleteLink(linkId)
            }
        }

        // Clicked on a foreign record item
        const item = event.target.closest(".field-link-value")
        if (item) {
            const clickedItem = event.target.closest(".field-link-value")
            const recordId = clickedItem.getAttribute("recordId")
            return this._openRecord(recordId)
        }

        // Clicked on a button
        const button = event.target.closest(".a-button")
        if (button) {
            if (button.classList.contains("field-link-button-link")) return this._linkForeignRecords()
            if (button.classList.contains("field-link-button-add")) return this._createAndLink()
            if (button.classList.contains("field-link-button-expand")) return this._showForeignRecords()
        }

        // Clicked in the buttons area
        if (event.target.closest(".field-link-buttons") && this.canLinkRecord && !this.readOnly) {
            this._linkForeignRecords()
        }
    }

    /**
     * Bind the field to a record
     * (this subscribes the field to react to database changes)
     * 
     * @private
     * @ignore
     * @param {object} record
     * @returns this
     */
    _bindRecord(record) {
        this.record = record
        this.modelId = record.model.id
        this.recordId = record.id

        // React to changes on a single record update of the binded foreign model
        const foreignModelId = this.foreignModel.id

        this.subscriptions.push(
            subscribe("EVT_DB_UPDATE:" + foreignModelId.toUpperCase(), (msgData) => {
                if (msgData.modelId == foreignModelId) {
                    const recordIds = this.links.map(link => link.recordId)
                    if (recordIds.includes(msgData.id)) {
                        this._renderValues()
                    }
                }
            })
        )

        // React to changes on foreign records deletions
        this.subscriptions.push(
            subscribe("EVT_DB_DELETE:" + foreignModelId.toUpperCase(), (msgData) => {
                if (msgData.modelId == foreignModelId) {
                    const recordIds = this.links.map(link => link.recordId)
                    if (recordIds.includes(msgData.id)) {
                        this._renderValues()
                    }
                }
            })
        )

        // React to changes on multiple records changes of the binded foreign model
        this.subscriptions.push(
            subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
                let shouldUpdate = false
                const recordIds = this.links.map(link => link.recordId)
                const operations = msgData.data

                operations.forEach(operation => {
                    if ((operation.modelId == foreignModelId) && recordIds.includes(operation.recordId)) shouldUpdate = true
                })

                if (shouldUpdate) {
                    this._renderValues()
                }
            })
        )

        // React to changes on link creations
        this.subscriptions.push(
            subscribe("EVT_DB_INSERT:LINK", (msgData) => {
                if ((msgData.data.rX == this.record.id) || (msgData.data.rY == this.record.id)) {
                    this._renderValues()
                }
            })
        )

        // React to changes on link deletions
        this.subscriptions.push(
            subscribe("EVT_DB_DELETE:LINK", (msgData) => {
                const recordIds = this.links.map(link => link.linkId)
                if (recordIds.includes(msgData.id)) {
                    this._renderValues()
                }
            })
        )

        return this
    }

    /**
     * Get the field value
     * 
     * @returns {object[]} - The field value, which is an array of foreign records
     */
    getValue() {
        return this.links || []
    }

    /**
     * Create a new foreign record then link it directly with the active record
     * 
     * @private
     * @ignore
     */
    async _createAndLink() {
        // Prevent from linking multiple records if the field is not flagged "multiple"
        if (!this.multiple && this.links.length > 0) {
            return createNotification(txtTitleCase("#only one link"))
        }

        // Creates the new foreign record
        let newForeignRecordData = {}
        let newForeignRecord

        if (!this.config.inherit) {
            newForeignRecord = this.foreignModel.create()
        }
        else {
            // If the inheritance option is enabled,
            // each new document created will be pre-filled with the values of the fields of the same name
            const model = kiss.app.models[this.modelId]
            const sharedFields = model.fields.filter(fX => this.foreignModel.fields.find(
                fY => fX.label == fY.label &&
                !fX.deleted &&
                !fY.deleted &&
                !fX.isSystem &&
                !fX.isFromPlugin
            ))
            
            sharedFields.forEach(field => {
                const foreignField = this.foreignModel.getFieldByLabel(field.label)
                newForeignRecordData[foreignField.id] = this.record[field.id]
            })

            newForeignRecord = this.foreignModel.create(newForeignRecordData, true)
        }
        
        await newForeignRecord.save()

        // Display the new record in a form
        createForm(newForeignRecord)

        // Link the 2 records together
        await this.record.linkTo(newForeignRecord, this.id, this.config.link.fieldId)

        // Update the list of links
        this._renderValues()
    }

    /**
     * Delete a link to a foreign record
     * 
     * @private
     * @ignore
     * @param {string} linkId - id of the record that holds the link
     */
    async _deleteLink(linkId) {
        createDialog({
            title: txtTitleCase("delete a link"),
            type: "dialog",
            message: txtTitleCase("#delete link"),
            colorOK: "var(--red)",
            colorCancel: "var(--green)",
            action: async () => {
                const success = await this.record.deleteLink(linkId)
                if (!success) return

                this._renderValues()
                this.dispatchEvent(new Event("change"))
            }
        })
    }

    /**
     * Open a foreign record
     * 
     * @private
     * @ignore
     * @param {string} recordId - id of the record to open
     */
    async _openRecord(recordId) {
        const link = this.links.find(linkInfo => linkInfo.record.id == recordId)
        const record = this.foreignModel.create(link.record)
        createForm(record)
    }

    /**
     * Show linked foreign records
     * 
     * @private
     * @ignore
     */
    async _showForeignRecords() {
        const foreignRecords = this.links.map(link => link.record)
        createRecordSelectionWindow(this.foreignModel, this.id, foreignRecords, null, {
            canSelect: false
        })
    }

    /**
     * Link a record from the datatable
     * 
     * @private
     * @ignore
     * @param {object} record
     */
    async _linkRecord(record) {
        createDialog({
            title: txtTitleCase("#connect records"),
            message: txtTitleCase("#connect confirmation"),
            icon: "fas fa-link",
            action: async () => {
                // Note: in this context, "this" is the datatable view associated with the field
                const linkField = $(this.config.fieldId)

                const success = await linkField._addLink(record)
                if (!success) return createNotification(txtTitleCase("#record already linked"))
                
                linkField.setValid()
                this.closest("a-panel").close()
            }
        })
    }

    /**
     * Show all the foreign records that can be selected
     * 
     * @private
     * @ignore
     */
    async _linkForeignRecords() {
        // Prevent from linking multiple records if the field is not flagged "multiple"
        if (!this.multiple && this.links.length > 0) {
            return createNotification(txtTitleCase("#only one link"))
        }

        createRecordSelectionWindow(this.foreignModel, this.id, null, this._linkRecord, {
            iconAction: "fas fa-link",
            canSelect: false
        })
    }

    /**
     * Add a link with an existing foreign record
     * 
     * @private
     * @ignore
     * @param {object} foreignRecord
     * @eturns {boolean} false if the operation failed
     */
    async _addLink(foreignRecord) {
        // Prevent from selecting a record which is already linked
        if (this.links.map(link => link.recordId).includes(foreignRecord.id)) return false

        await this.record.linkTo(foreignRecord, this.id, this.config.link.fieldId)
        this._renderValues()
        this.dispatchEvent(new Event("change"))
        return true
    }

    /**
     * Get the view configuration
     * 
     * @private
     * @ignore
     * @returns {object[]}
     */
    _getViewConfig() {
        const viewRecord = kiss.app.collections.view.records.find(view => view.modelId == this.foreignModel.id && view.fieldId == this.id)

        // Register the field to listen to view changes
        if (viewRecord && !this.viewId) {
            this.viewId = viewRecord.id
            this.subscriptions.push(
                kiss.pubsub.subscribe("EVT_DB_UPDATE:VIEW", msgData => {
                    if (msgData.id != this.viewId) return
                    if (msgData.data.sort) this._renderValues()
                    if (msgData.data.config && msgData.data.config.columns) this._renderValues()
                })
            )
        }

        // Assign sort infos
        this.sort = (viewRecord) ? viewRecord.sort : this.sort
        
        return (viewRecord) ? viewRecord.config.columns : []
    }

    /**
     * Load the linked records
     * 
     * @private
     * @ignore
     */
    async _loadLinks() {
        if (!this.record) {
            this.links = []
            return
        }
        this.links = await kiss.data.relations.getLinksAndRecords(this.record.model.id, this.record.id, this.id, this.sort)
    }

    /**
     * Render the current value(s) of the widget
     * 
     * @private
     * @ignore
     * @async
     */
    async _renderValues() {
        const viewConfig = this._getViewConfig()

        await this._loadLinks()

        const linkButtonId = kiss.tools.shortUid()
        const hasLinks = (this.links.length != 0)
        const canLinkOtherRecords = (hasLinks && this.multiple != true) ? false : true

        const showAddButton = this.record && !this.readOnly && this.canCreateRecord !== false && canLinkOtherRecords
        const showLinkButton = !this.readOnly && this.canLinkRecord !== false && canLinkOtherRecords
        const showExpandButton = this.multiple && hasLinks
        const showButtons = showAddButton || showLinkButton || showExpandButton

        const linkButtons = (!showButtons) ? "" : `
            <div class="field-link-buttons">
                ${(showAddButton) ? `<div id="${linkButtonId}" class="a-button field-link-button field-link-button-add"><span class="button-icon fas fa-plus"></span><span class="button-text">${txtTitleCase("new")}</span></div>` : ""}
                ${(showLinkButton) ? `<div class="a-button field-link-button field-link-button-link"><span class="button-icon fas fa-link"></span><span class="button-text">${txtTitleCase("#select link")}</span></div>` : ""}
                ${(showExpandButton) ? `<div class="a-button field-link-button field-link-button-expand"><span class="button-icon fas fa-table"></span><span class="button-text">${txtTitleCase("display as table")}</span></div>` : ""}
            </div>`.removeExtraSpaces()

        // No record attached, or no links => just display buttons
        if (!this.record || !hasLinks) {
            this.fieldValues.innerHTML = linkButtons
            return
        }

        // Separate values with <br> if the option "stackValues" is true
        let htmlSeparator = (this.stackValues) ? "<br>" : ""

        // Get the fields to display in the cards, depending on the config
        const isCompact = (this.config.linkStyle == "compact")
        const displayLabels = (!["compact", "no labels"].includes(this.config.linkStyle))

        let fields = this.foreignModel.getActiveFields()
        let fieldsToDisplay = fields

        if (isCompact) {
            const primaryKeyField = this.foreignModel.getPrimaryKeyField()
            fieldsToDisplay = [primaryKeyField || fields[0]]
        } else {
            if (viewConfig.length > 0) {
                fieldsToDisplay = viewConfig
                    .filter(column => column.hidden != true)
                    .map(column => fieldsToDisplay.find(field => field.id == column.id))
                    .filter(field => field)
            }
        }

        // Render!
        const badge = (isCompact) ? "" : `<div class="field-link-item-badge" style="background: ${this.foreignModel.color}">
                            <span class="${this.foreignModel.icon}"></span>
                        </div>`

        this.fieldValues.innerHTML =
            linkButtons +
            this.links.map(recordInfo => {
                return `<div class="field-link-value ${(isCompact) ? "field-link-value-compact" : ""}" recordId="${recordInfo.recordId}" linkId="${recordInfo.linkId}" style="border-color: ${this.foreignModel.color}">
                            ${badge}
                            <div class="field-link-record" id="field-link-record:${recordInfo.recordId}">
                                ${this._renderSingleValue(recordInfo.record, fieldsToDisplay, displayLabels)}
                            </div>
                            ${(this.readOnly || !this.canDeleteLinks) ? "" : `<span class="field-link-value-delete fas fa-times"></span>`}
                        </div>`.removeExtraSpaces()
            }).join(htmlSeparator)
    }

    /**
     * Render a single value
     * 
     * @private
     * @ignore
     * @param {object} record - Record to render
     * @returns {string} Html for the value
     */
    _renderSingleValue(record, fieldsToDisplay, displayLabels) {
        return fieldsToDisplay.map(field => {

            // Skip system fields
            if (field.isSystem) return ""
            
            // Skip link fields
            if (field.type == "link") return ""

            let value = record[field.id]
            const htmlLabel = (displayLabels) ? `<div class="field-link-item-label">${field.label}</div>` : ""
            const htmlValue = kiss.fields.renderers[this.foreignModel.id][field.id]({field, value, record})

            return `<div class="field-link-item">
                ${htmlLabel}
                <div class="field-link-item-value">${htmlValue}</div>
            </div>`
        }).join("")
    }

    /**
     * Get the list of possible values from the linked collection
     * 
     * @private
     * @ignore
     */
    async _loadOptions() {
        if ((!this.foreignCollection) || (!this.config.link.modelId)) {
            this.options = []
            return
        }

        const options = await this.foreignCollection.find()
        this.options = options.map(record => record.id)
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-link", kiss.ux.Link)

/**
 * Shorthand to create a new Link field. See [kiss.ux.Link](kiss.ux.Link.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createLink = (config) => document.createElement("a-link").init(config)

;/**
 * 
 * The Wizard Panel derives from [Panel](kiss.ui.Panel.html).
 * 
 * It's a panel where items are displayed one at a time (each wizard page) with helper buttons (next, previous) to navigate through the pages.
 * The panel title is updated with the current page number.
 * 
 * @param {object} config
 * @param {function} config.action - Action triggered when the last page of the wizard is validated. The function is called with the wizard panel as context, so that this.getData() can be used to get the data of all fields of the wizard.
 * @param {object} [config.actionText] - Text of the action button of the last page, like "Done", "Proceed", "Let's go". Default = "OK"
 * @param {boolean} [config.pageValidation] - If true, validate each page when navigating next/previous. Default = false
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-wizardpanel class="a-panel">
 *  <div class="panel-header">
 *      <span class="panel-icon"></span>
 *      <span class="panel-title"></span>
 *      <span class="panel-custom-buttons"></span>
 *      <span class="panel-button-expand-collapse"></span>
 *      <span class="panel-button-maximize"></span>
 *      <span class="panel-button-close"></span>
 *  </div>
 *  <div class="panel-body">
 *      <!-- Panel items are inserted here -->
 *  </div>
 * </a-wizardpanel>
 * ```
 * 
 */
kiss.ux.WizardPanel = class WizardPanel extends kiss.ui.Panel {
    /**
     * 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 myWizardPanel = document.createElement("a-wizardpanel").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myWizardPanel = createWizardPanel({
     * 
     *   // Can have the same config properties as a panel
     *   title: "Setup"
     *   icon: "fas fa-wrench",
     *   headerBackgroundColor: "#00aaee",
     *   closable: true,
     *   draggable: true,
     *   modal: true,
     *   display: "flex"
     *   flexFlow: "column",
     *   padding: "10px",
     * 
     *   // Wizard pages
     *   items: [
     *      wizardPage1,
     *      wizardPage2,
     *      wizardPage3
     *   ],
     *   actionText: "Proceed",
     *   action: function() {
     *      // Get the data of all fields of the wizard
     *      const data = this.getData()
     *     // Do something with the data
     *   }
     * })
     * 
     * myWizardPanel.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myBlock = createBlock({
     *   items: [
     *       {
     *           type: "wizardpanel",
     *           title: "Foo",
     *           items: [
     *               wizardPage1,
     *               wizardPage2,
     *               wizardPage3
     *           ],
     *           actionText: "Proceed",
     *           action: function() {
     *              // Get the data of all fields of the wizard
     *              const data = this.getData()
     *              // Do something with the data
     *           }
     *       }
     *   ]
     * })
     * myBlock.render()
     * ```
     * 
     * If you need to validate a page before navigating to the next one, you can add a **validate** method to the page:
     * ```
     * const wizardPage1 = {
     *  type: "panel", // or "block"
     *  items: [
     *      // Page items
     *  ],
     *  methods: {
     *     validate: function() {
     *       // Validate the page
     *       return true // or false
     *     }
     *  }
     * }
     * 
     * Use this in combination with "pageValidation" property in the wizard panel config.
     * If you don't need a specific validation, "pageValidation" will validate all the pages as normal forms, checking for validation rules of each field.
     * ```
     * 
     */
    constructor() {
        super()
    }

    /**
     * Generates a Wizard Panel from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config) {
        config.id = config.id || "cmp-" + (kiss.global.componentCount++).toString()
        this.id = config.id
        this.currentPage = 0
        this.numberOfPages = config.items.length
        this.pageValidation = !!config.pageValidation

        this._initButtons(config)
        config.items = this._initStructure(config)

        super.init(config)
        this._updateTitle()

        this.classList.add("a-panel")
        return this
    }

    /**
     * Manage click event in the panel's header to perform various actions like "close", "expand", "collapse"...
     * 
     * @private
     * @ignore
     */
    _initHeaderClickEvent() {
        this.panelHeader.onclick = function(event) {
            const element = event.target
            let panel = element.closest("a-wizardpanel")

            if (element.classList.contains("panel-button-close")) {
                panel.close()
            }
            else if (element.classList.contains("panel-button-expand")) {
                panel.maximize(20)
            }
            else if (element.classList.contains("panel-button-expand-collapse") || element.classList.contains("panel-header-collapsible")) {
                panel.expandCollapse()
            }
            else if ((element.classList.contains("panel-title") || element.classList.contains("panel-icon")) && panel.config.collapsible === true && panel.config.draggable !== true) {
                panel.expandCollapse()
            }
        }
    }    

    /**
     * Initialize the DOM structure of the wizard panel:
     * - original items are inserted into "pages" block
     * - a button bar is added to the bottom of the panel to navigate between pages
     * 
     * @private
     * @ignore
     * @param {object} config 
     * @returns {object} The final structure
     */
    _initStructure(config) {
        const items = [
            {
                id: this.id + "-pages",
                multiview: true,
                items: config.items
            },
            {
                id: this.id + "-buttons",
                layout: "horizontal",
                defaultConfig: {
                    type: "button",
                    margin: "1rem 0.5rem 0 0",
                    height: "4rem",
                    flex: 1
                },
                items: [
                    this.buttonCancel,
                    (this.numberOfPages > 1) ? this.buttonNext : this.buttonOK
                ]
            }
        ]
        return items
    }

    /**
     * Initialize the buttons of the wizard panel:
     * - cancel
     * - previous / next
     * - validate
     * 
     * @private
     * @ignore
     * @param {object} config 
     */
    _initButtons(config) {
        this.buttonCancel = {
            icon: "fas fa-times",
            text: txtTitleCase("cancel"),
            action: function () {
                this.closest("a-wizardpanel").close()
            }
        }

        this.buttonPrevious = {
            icon: "fas fa-chevron-left",
            text: txtTitleCase("previous"),
            action: function () {
                this.closest("a-wizardpanel").previous()
            }
        }

        this.buttonNext = {
            icon: "fas fa-chevron-right",
            iconPosition: "right",
            text: txtTitleCase("next"),
            action: function () {
                this.closest("a-wizardpanel").next()
            }
        }             

        this.buttonOK = {
            icon: "fas fa-check",
            text: config.actionText || "OK",
            action: () => {
                if (this.pageValidation && !this.validatePage()) return

                if (config.action) {
                    // If an action is defined, call it with the wizard panel as context
                    config.action.bind(this)() 
                }
                else {
                    // If no action is defined, just close the wizard panel
                    this.close()
                }
            }
        }     
    }

    /**
     * Update the buttons when navigating between pages
     * 
     * @private
     * @ignore
     */
    _updateButtons() {
        let buttons
        if (this.currentPage == 0) {
            buttons = [this.buttonCancel, (this.numberOfPages > 1) ? this.buttonNext : this.buttonOK]
        }
        else if (this.currentPage == this.numberOfPages - 1) {
            buttons = [this.buttonPrevious, this.buttonOK]
        }
        else {
            buttons = [this.buttonPrevious, this.buttonNext]
        }
        $(this.id + "-buttons").setItems(buttons)
    }

    /**
     * Update the title of the wizard panel with the current page number
     * 
     * @private
     * @ignore
     */
    _updateTitle() {
        this.setTitle((this.currentPage + 1) + "/" + this.numberOfPages + ((this.config.title) ? " - " + this.config.title : ""))
    }

    /**
     * Validates the form of a wizard page.
     * Prevents from navigating to the next page if the form is not validated.
     * 
     * @param {number} [pageIndex] - Optional wizard's page to validate. If not specified, tries to validate the current page.
     */
    validatePage(pageIndex) {
        this.pages = $(this.id + "-pages").children
        if (!this.pages) return true
        const currentPage = this.pages[pageIndex || this.currentPage]
        return (currentPage.validate) ? currentPage.validate() : true
    }

    /**
     * Navigate to the next wizard page
     */
    next() {
        if (this.pageValidation && !this.validatePage()) return

        this.currentPage++
        this._updateButtons()
        this._updateTitle()

        $(this.id + "-pages").showItem(this.currentPage, {
            name: "slideInRight",
            speed: "faster"
        })
    }

    /**
     * Navigate to the previous wizard page
     */
    previous() {
        this.currentPage--
        this._updateButtons()
        this._updateTitle()
        
        $(this.id + "-pages").showItem(this.currentPage, {
            name: "slideInLeft",
            speed: "faster"
        })
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-wizardpanel", kiss.ux.WizardPanel)

/**
 * Shorthand to create a new Wizard Panel. See [kiss.ux.WizardPanel](kiss.ux.WizardPanel.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createWizardPanel = (config) => document.createElement("a-wizardpanel").init(config)

;/**
 * 
 * A *SelectViewColumn* field allows to select values from a view column
 * 
 * @ignore
 * @param {object} config
 * @param {boolean} [config.multiple] - True to enable multi-select - Default to true
 * @param {string|string[]} [config.value] - Default value
 * @param {string} [config.optionsColor] - Default color for all options
 * @param {string} [config.valueSeparator] - Character used to display multiple values
 * @param {string} [config.inputSeparator] - Character used to input multiple values
 * @param {boolean} [config.stackValues] - True to render the values one on another
 * @param {boolean} [config.hideInput] - true (default) to automatically hide the input field after a completed search
 * @param {boolean} [config.allowValuesNotInList] - Allow to input a value which is not in the list of options
 * @param {boolean} [config.allowDuplicates] - Allow to input duplicate values. Default to false.
 * @param {boolean} [config.allowClickToDelete] - Add a "cross" icon over the values to delete them. Default to false.
 * @param {boolean} [config.allowSwitchOnOff] - Allow to click on a value to switch it on/off
 * @param {function} [config.optionRenderer] - Custom function to render each option in the list of options
 * @param {function} [config.valueRenderer] - Custom function to render the actual field values
 * @param {string} [config.label]
 * @param {string} [config.labelWidth]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {boolean} [config.autocomplete] - Set "off" to disable
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled]
 * @param {boolean} [config.required]
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {string} [config.display] - flex | inline flex
 * @param {string|number} [config.width]
 * @param {string|number} [config.minWidth]
 * @param {string|number} [config.height]
 * @returns this
 * 
 */
kiss.ux.SelectViewColumn = class SelectViewColumn extends kiss.ui.Select {
    constructor() {
        super()
    }

    /**
     * @ignore
     */
    init(config = {}) {
        // Generates the <Select> field
        super.init(config)

        // View used to retrieve data
        this.viewId = config.viewId

        // Field to retrieve in the view
        this.fieldId = config.fieldId
        return this
    }

    /**
     * Create the list of options
     * 
     * @private
     * @ignore
     */
    async _createOptions() {
        await this._loadOptions()
        super._createOptions()
    }

    /**
     * Get the list of possible values from the view column
     * 
     * @private
     * @ignore
     */
    async _loadOptions() {
        if (this.isLoaded) return
        this.options = []
        const viewRecord = kiss.app.collections.view.records.find(view => view.id == this.viewId)
        const collection = viewRecord.getCollection()

        await collection.find()
        this.options = collection.records

        // Exclude group records
        if (collection.group.length > 0) {
            this.options = this.options.filter(record => !record.$type)
        }

        // Exclude records with empty values
        this.options = this.options.filter(record => !!record[this.fieldId])

        // Convert records to options
        this.options = this.options.map(record => {
            const fieldValue = record[this.fieldId]
            return {
                value: (Array.isArray(fieldValue)) ? fieldValue[0] : fieldValue
            }
        })

        // Remove duplicates
        this.options = this.options.uniqueObject("value")

        // Sort alphabetically
        this.options = this.options.sortBy("value")

        this.isLoaded = true
    }
}

// Create a Custom Element
customElements.define("a-selectviewcolumn", kiss.ux.SelectViewColumn)

;/**
 * 
 * A *SelectViewColumns* field allows to select a record in a view, and assign values to multiple fields at once:
 * - this field
 * - other fields of the same record
 * 
 * The field value is set by getting the value of the foreign field which id is fieldId[0].
 * The other fields are set by comparing their label:
 * - if the foreign field has the same label as a field inside the record, it's a match: the local field is set
 * - otherwise, the foreign field is skipped
 * 
 * Example:
 * - you pick a product in a view showing all products
 * - it assigns the product name, product category and unit price at the same time
 * - if extra fields are present in the foreign record but not in the local record, they are skipped
 * 
 * @ignore
 * @param {object} config
 * @param {string} [config.viewId] - The view to pick records in. Use this or collectionId.
 * @param {string} [config.collectionId] - The collection to pick records in. Use this or viewId.
 * @param {string[]} config.fieldId - Ids of the fields which will be set when picking a record
 * @param {string|string[]} [config.value] - Default value
 * @param {string} [config.optionsColor] - Default color for all options
 * @param {boolean} [config.allowValuesNotInList] - Allow to create a new entry in the view
 * @param {function} [config.optionRenderer] - Custom function to render each option in the list of options
 * @param {function} [config.valueRenderer] - Custom function to render the actual field values
 * @param {string} [config.label]
 * @param {string} [config.labelWidth]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled]
 * @param {boolean} [config.required]
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {string} [config.display] - flex | inline flex
 * @param {string|number} [config.width]
 * @param {string|number} [config.minWidth]
 * @param {string|number} [config.height]
 * @returns this
 * 
 */
kiss.ux.SelectViewColumns = class SelectViewColumns extends kiss.ui.Select {
    constructor() {
        super()
    }

    /**
     * @ignore
     */
    init(config = {}) {
        // Generates the <Select> field
        super.init(config)

        // View used to retrieve data
        // OR
        // Collection used to retrieve data
        this.viewId = config.viewId
        this.collectionId = config.collectionId

        // Field to retrieve in the view
        this.fieldId = config.fieldId[0]

        // Other fields to set automatically
        this.otherFieldIds = config.fieldId.slice(1)

        // Allow to create a new value if necessary
        this.allowValuesNotInList = !!config.allowValuesNotInList

        // Overrides default click event
        this.onclick = this._handleClick

        // Disable the dropdown list that shows options
        this._showOptions = () => {}
        return this
    }

    /**
     * Handle the click event
     * 
     * @private
     * @ignore
     * @param {object} event 
     */    
    async _handleClick(event) {
        if (event.target.classList.contains("field-label")) return
        this._showView()
    }

    /**
     * Show the view to pick records in
     * 
     * @private
     * @ignore
     */
    async _showView() {
        const _this = this
        let collection, columns, sort, filter, group, viewRecord

        if (this.viewId) {
            viewRecord = await kiss.app.collections.view.findOne(this.viewId)
            this.viewModel = kiss.app.models[viewRecord.modelId]
            collection = this.viewModel.collection
            columns = this.viewModel.getFieldsAsColumns()
            sort = viewRecord.sort
            filter = viewRecord.filter
            group = viewRecord.group
        }
        else if (this.collectionId) {
            collection = kiss.app.collections[this.collectionId]
            this.viewModel = kiss.app.models[collection.modelId]
            columns = this.viewModel.getFieldsAsColumns()
            sort = []
            filter = {}
            group = []
        }
        else {
            // Exit if no viewId or collectionId have been provided
            return
        }
        
        // Build the datatable
        const datatable = createDatatable({
            collection: this.viewModel.collection,
            sort: sort,
            filter: filter,
            group: group,

            canEdit: false,
            canSelect: false,
            canAddField: false,
            canEditField: false,
            canCreateRecord: this.allowValuesNotInList,
            showActions: false,
            columns: columns,
            color: this.viewModel.color,
            height: () => "calc(100vh - 25rem)",

            methods: {
                selectRecord: async function(record) {
                    await _this.setValue(record)
                    this.closest("a-panel").close()
                },

                // Creates a new blank record
                async createRecord(model) {
                    const record = model.create()
                    const success = await record.save()
                    if (!success) return
                    createForm(record)
                }
            }
        })

        // Build the panel to embed the datatable
        createPanel({
            modal: true,
            closable: true,

            // Header
            title: "<b>" + this.viewModel.namePlural + "</b>",
            icon: this.viewModel.icon,
            headerBackgroundColor: this.viewModel.color,

            // Size and layout
            display: "flex",
            layout: "vertical",
            width: () => "calc(100vw - 20rem)",
            height: () => "calc(100vh - 20rem)",
            align: "center",
            verticalAlign: "center",
            autoSize: true,

            items: [datatable]
        }).render()
    }

    /**
     * Set the value of the field + other connected fields.
     * 
     * @ignore
     * @param {object} record
     * @returns this
     */
    async setValue(record) {
        let model = this.record.model

        let mapping = this.otherFieldIds.map(viewFieldId => {
            let label = this.viewModel.getField(viewFieldId).label
            let localField = model.getFieldByLabel(label) || {}
            return {
                label,
                id: localField.id,
                viewFieldId
            }
        }).filter(map => map.id)

        // Set the field itself
        let update = {}
        update[this.id] = record[this.fieldId]

        // Set the other fields
        mapping.forEach(map => {
            let localField = model.getField(map.id) || {}
            let recordValue = record[map.viewFieldId]
            
            if (kiss.tools.isNumericField(localField)) {
                // Number fields
                recordValue = parseFloat(recordValue)
                if (isNaN(recordValue)) recordValue = 0
            }
            else if (localField.type === "checkbox") {
                // Checkbox fields
                recordValue = !!recordValue
            }

            // Empty values are set to ""
            if (recordValue === undefined) recordValue = ""

            update[map.id] = recordValue
        })

        await this.record.updateDeep(update)
        return this
    }

    formatValue(value) {
        if (kiss.tools.isNumericField(this)) {
            recordValue = parseFloat(recordValue)
            if (isNaN(recordValue)) recordValue = 0
        }
        else if (this.type === "checkbox") {
            recordValue = !!recordValue
        }

        if (recordValue === undefined) recordValue = ""

    }
}

// Create a Custom Element
customElements.define("a-selectviewcolumns", kiss.ux.SelectViewColumns)

;