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
 * 
 * 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 {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.
 * @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.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]
            ],
            [
                ["width", "minWidth", "height", "flex", "display", "margin", "padding"],
                [this.style]
            ],
            [
                ["fieldWidth=width", "maxHeight", "fieldPadding=padding", "boxShadow"],
                [this.field.style]
            ],
            [
                ["fontSize", "labelAlign=textAlign", "labelFlex=flex"],
                [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)
        }

        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 = () => {
            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._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")
        } 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.bubble")
        }
    }

    /**
     * Initialize the editor
     * 
     * @private
     * @ignore
     */
    _initRichTextField() {
        this.richTextField = new Quill("#container-" + this.id, {
            theme: "bubble",
            modules: {
                toolbar: [
                    ["clean", { "header": 1 }, { "header": 2 }, { "header": 3 }],
                    ["bold", "italic", "underline", {color: []}],
                    [{ "list": "ordered"}, { "list": "bullet" }, { "list": "check" }],
                    ["blockquote", "code-block"]
                ]
            }
        })
        this.richTextToolbar = this.querySelector(".ql-toolbar")
        this.richTextContainer = this.querySelector(".ql-container")
    }    

    /**
     * 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
     */
    setValue(newValue, fromBlurEvent) {
        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()
    }

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

// Create a Custom Element and add a shortcut to create it
customElements.define("a-richtextfield", kiss.ux.RichTextField)
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 {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]
 * @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"],
                [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)
        }

        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
     */
    setValue(newValue, fromBlurEvent) {
        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 Image. See [kiss.ui.Image](kiss.ui.Image.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"

        return createButton({
            icon: "far fa-lightbulb",
            iconSize: 16,
            iconColor: color,
            height: 17,
            margin: "0 0 0 5px",
            padding: "2px 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: 500,
                    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 || "-",
                                    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 || "-",
                                    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 || "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 || "-",
                                    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 || 1000, 2000) || 1000,
                                    max: 2000
                                },
                                // TEMPERATURE
                                {
                                    id: "temperature",
                                    label: txtTitleCase("creativity"),
                                    type: "slider",
                                    min: 0,
                                    max: 100,
                                    value: this.config?.ai?.temperature || 50
                                }
                            ]
                        },
                        // AI PROMPT
                        {
                            id: "prompt",
                            type: "textarea",
                            label: txtTitleCase("#AI prompt instructions"),
                            required: true,
                            rows: 10
                        },
                        // BUTTON TO SEND THE PROMPT
                        {
                            type: "button",
                            text: txtTitleCase("generate content..."),
                            icon: "fas fa-bolt",
                            iconColor: "var(--orange)",
                            margin: "20px 0 0 0",
                            height: 40,
                            action: async () => {
                                if (!$("AI-panel").validate()) {
                                    return
                                }

                                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: 2 * 60 * 1000, // Give OpenAI 2mn to answer
            body: JSON.stringify({
                prompt,
                temperature,
                max_tokens
            })
        })
    }
}

// Create a Custom Element
customElements.define("a-aitextarea", kiss.ux.AiTextarea)
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: 500,
            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: "20px 0 0 0",
                    height: 40,
                    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)
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)
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 * 1 / 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)
const createMapField = (config) => document.createElement("a-mapfield").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 {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 OpenLayers. Default is true.
 * @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 || 300
        config.height = config.height || 225
        this.useCDN = (config.useCDN === false) ? false : true

        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.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
     * 
     * @ignore
     */
    async _afterRender() {
        if (window.Chart) {
            this.initChart(this.config)
        } else {
            await this.initChartJS(this.config)
            this.initChart()
        }
    }

    /**
     * Load the OpenLayers library
     * 
     * @ignore
     */
    async initChartJS() {
        if (this.useCDN === false) {
            // Local
            await kiss.loader.loadScript("../../kissjs/client/ux/chart/chartjs")
            await kiss.loader.loadScript("../../kissjs/client/ux/chart/chartjs-moment")
            await kiss.loader.loadScript("../../kissjs/client/ux/chart/chartjs-moment-adapter")
        } else {
            // CDN
            await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/chart")
            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 translate the dates in time series
        window.moment.locale(kiss.language.current || "en")
    }

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

    /**
     * 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
     */
    refresh({chartType, data, options, width, height}) {
        if (chartType != this.charType) {
            this.setWidth(width)
            this.setHeight(height)
            log("=========================")
            console.log(width, height)

            this.chart.destroy()
            this.chart = new Chart(this.chartContainer, {
                type: chartType,
                data,
                options
            })
        }
        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
     * 
     * https://www.chartjs.org/docs/latest/developers/api.html
     */
    resize() {
        this.chart.resize()
    }

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

    /**
     * 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-chart", kiss.ux.Chart)
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% - 32px)" : 420

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

        createRecordSelectorWindow(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)
const createLink = (config) => document.createElement("a-link").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: () => kiss.screen.current.height - 250,

            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: () => kiss.screen.current.width - 200,
            height: () => kiss.screen.current.height - 200,
            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 mapping = this.otherFieldIds.map(viewFieldId => {
            let label = this.viewModel.getField(viewFieldId).label
            let localField = this.record.model.getFieldByLabel(label) || {}
            return {
                label,
                id: localField.id,
                viewFieldId
            }
        }).filter(map => map.id)
        
        let update = {}
        update[this.id] = record[this.fieldId]
        mapping.forEach(map => update[map.id] = record[map.viewFieldId])

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

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

;