Source

client/ui/fields/field.js

/**
 * 
 * The Field derives from [Component](kiss.ui.Component.html).
 * 
 * Build an input or textarea field with a label.
 * 
 * TODO: make computed fields with formula work even when it's not bound to a model
 * 
 * TODO: implement config.validationMessage
 * 
 * @param {object} config
 * @param {string} config.type - text | textarea | number | date | password
 * @param {*} [config.value] - Default value
 * @param {string} [config.label]
 * @param {*} [config.labelWidth]
 * @param {*} [config.fieldWidth]
 * @param {*} [config.fieldHeight]
 * @param {*} [config.fieldPadding]
 * @param {*} [config.fontSize]
 * @param {*} [config.lineHeight]
 * @param {number} [config.fieldFlex]
 * @param {string} [config.textAlign] - left | right
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {number} [config.labelFlex]
 * @param {string} [config.formula] - For computed fields only
 * @param {string} [config.validationType] - Pre-built validation type: alpha | alphanumeric | email | url | ip
 * @param {string} [config.validationRegex] - Regexp
 * @param {function} [config.validationFunction] - Async function that must return true if the value is valid, false otherwise
 * @param {string} [config.validationMessage] - TODO
 * @param {string} [config.placeholder]
 * @param {boolean} [config.autocomplete] - Set "off" to disable
 * @param {number} [config.minLength]
 * @param {number} [config.maxLength]
 * @param {number} [config.maxHeight]
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled]
 * @param {boolean} [config.required]
 * @param {boolean} [config.draggable]
 * @param {boolean} [config.autocomplete] - set to "off" to disable native browser autocomplete feature
 * @param {string} [config.min] - (for number only)
 * @param {string} [config.max] - (for number only)
 * @param {number} [config.precision] - (for number only)
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {string} [config.fontFamily] - Font used for the input field
 * @param {boolean} [config.rows] - For textarea only
 * @param {boolean} [config.cols] - For textarea only
 * @param {boolean} [config.autoGrow] - For textarea only
 * @param {string} [config.display] - flex | inline flex
 * @param {string} [config.border]
 * @param {string} [config.borderStyle]
 * @param {string} [config.borderWidth]
 * @param {string} [config.borderColor]
 * @param {string} [config.borderRadius]
 * @param {string|number} [config.width]
 * @param {string|number} [config.height]
 * @returns this
 * 
 * ## Generated markup
 * For all input fields:
 * ```
 * <a-mapfield class="a-mapfield a-field">
 *  <label class="field-label"></label>
 *  <input type="text" class="field-input"></input>
 *  <a-map class="a-map"></a-map>
 * </a-mapfield>
 * ```
 * For textarea:
 * ```
 * <a-field class="a-field">
 *  <span class="field-label"></span>
 *  <textarea class="field-input"></textarea>
 * </a-field>
 * ```
 */
kiss.ui.Field = class Field 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 myField = document.createElement("a-field").init(config)
     * ```
     * 
     * Or use a shorthand to create one the various field types:
     * ```
     * const myText = createTextField({
     *   label: "I'm a text field"
     * })
     * 
     * const myTextArea = createTextareaField({
     *   label: "I'm a long text field",
     *   cols: 100,
     *   rows: 10
     * })
     * 
     * const myNumber = createNumberField({
     *   label: "I'm a number field",
     *   value: 250,
     *   min: 0
     * })
     * 
     * const myDate = createDateField({
     *   label: "I'm a date field",
     *   value: new Date()
     * })
     * 
     * // You can also use the generic constructor, but then you'll have to specify the field type in the config, like this:
     * const myText = createField({
     *   type: "number", // <= Field type, which can be: text | textarea | number | date
     *   label: "foo",
     *   value: 123
     * })
     * 
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *           type: "text",
     *           label: "I'm a text"
     *       },
     *       {
     *           type: "number":
     *           label: "I'm a number"
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */
    constructor() {
        super()
    }

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

        // Overwrite default value if the field is binded to a record
        // (default value must not override record's value)
        if (config.record) config.value = config.record[this.id]

        // Force computed fields to be read-only
        config.readOnly = !!config.readOnly || !!config.computed

        if (this.type != "textarea" && this.type != "aiTextarea") {

            // Template for text, number, date
            this.innerHTML = `
                ${ (config.label) ? `<label id="field-label-${id}" for="${id}" class="field-label">
                    ${ (this.isLocked()) ? this.locker : "" }
                    ${ config.label || ""} ${(config.unit) ? " (" + config.unit + ")" : "" }
                    ${ (this.isRequired()) ? this.asterisk : "" }
                </label>` : "" }

                <input type="${this.type}" id="field-input-${id}" name="${id}" class="field-input ${(!!config.readOnly) ? "field-input-read-only" : ""}">
                `.removeExtraSpaces()

        } else {

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

                <textarea id="field-textarea-${id}" name="${id}"
                    class="field-input ${(!!config.readOnly) ? "field-input-read-only" : ""}"
                    ${(config.rows) ? `rows="${config.rows}"` : ""}
                    ${(config.cols) ? `cols="${config.cols}"` : ""}
                >${(config.value) ? config.value : ""}</textarea>
                `.removeExtraSpaces()
        }

        this.field = this.querySelector(".field-input")
        this.label = this.querySelector(".field-label")

        // Cancel max property if set to 0
        if (config.max == 0) delete config.max
        if (config.maxLength == 0) delete config.maxLength

        // Set properties
        this._setProperties(config, [
            [
                ["draggable"],
                [this]
            ],
            [
                ["width", "minWidth", "height", "flex", "display", "margin", "padding"],
                [this.style]
            ],
            [
                ["value", "minLength", "maxLength", "min", "max", "placeholder", "readOnly", "disabled", "required", "autocomplete"],
                [this.field]
            ],
            [
                ["textAlign", "fontSize", "lineHeight", "fieldWidth=width", "fieldHeight=height", "fieldPadding=padding", "fieldFlex=flex", "maxHeight", "fontFamily", "border", "borderStyle", "borderWidth", "borderColor", "borderRadius", "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)
        }

        // Add field base class
        this.classList.add("a-field")

        // Propapate field changes
        this.field.onchange = (event) => {
            const newValue = event.target.value

            // Check if the value is valid before updating the database
            if (this.validate()) {
                this.setValue(newValue)
            }
        }

        // Attach focus and blur events, if any
        if (config.events) {
            if (config.events.focus) this.field.onfocus = config.events.focus
            else if (config.events.onfocus) this.field.onfocus = config.events.onfocus

            if (config.events.blur) this.field.onblur = config.events.blur
            else if (config.events.onblur) this.field.onblur = config.events.onblur
        }

        if (config.type == "number") {
            // Prevent number fields to be changed with arrow keys (and spam the update process)
            this.field.onkeydown = (event) => {
                if (event.key == "ArrowDown" || event.key == "ArrowUp") event.stop()
            }

        } else if (config.type == "textarea" && config.autoGrow) {
            // Auto-grow textarea field at startup
            this._initTextareaHeight()

        } else if (config.type == "text" && config.computed == true) {
            // URL field that are computed can be clicked to open the URL
            this.field.onclick = () => {
                const value = this.getValue()
                const urlRegex = new RegExp(`^(http(s?):\\/)?\\/(.)+$`)
                if (value && value.match(urlRegex)) window.open(value)
            }
        }

        // Auto-compose phone number on mobile devices
        this._initMobileAutoCompose()

        // Field validation
        this.isValid = true
        this._initValidationRules()

        // Bind the field to a record, if any
        if (config.record) this._bindRecord(config.record)

        return this
    }

    /**
     * Enables auto-composition for phone numbers, on Mobile devices
     * 
     * TODO: evaluate if it's useful to explicitly add a config option to define phone number fields
     * 
     * @private
     * @ignore
     */
    _initMobileAutoCompose() {
        if (!kiss.screen.isMobile) return
        if (!this.label) return
        if (this.config.type != "text" && this.config.type != "number") return

        const phoneLabels = ["telephone", "phone", "mobile", "tel."]
        if (!phoneLabels.some(phoneLabel => this.config.label.toLowerCase().includes(phoneLabel))) return
        this.label.onmousedown = () => window.location.href = "tel:" + this.getValue()
    }

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

        if (record[this.id]) {
            this.field.value = this.initialValue = record[this.id]
        }

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

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

    /**
     * Init basic validation rules according to the following field parameters:
     * - validationType: a predefined regex
     * - validationRegex: a custom regex
     * - validationFormula: a javascript formula that must return true to be valid
     * 
     * So far, pre-defined validation types are:
     * - alpha
     * - alphanumeric
     * - email
     * - url
     * - ip
     * 
     * @private
     * @ignore
     */
    _initValidationRules() {
        this.required = this.config.required
        this.validationRegex = this.config.validationRegex
        this.validationType = this.config.validationType
        this.validationFormula = this.config.validationFormula

        this.field.onkeyup = () => {
            this.validate()

            // Auto grow textarea
            if (this.config.type == "textarea" && this.config.autoGrow) {
                this._updateTextareaHeight()
            }
        }

        // A required field is immediately invalid if it's empty
        if (this.required && this.field.value == "") this.isValid = false
    }

    /**
     * Init the height of the textarea at startup
     * 
     * @private
     * @ignore
     */
    _initTextareaHeight() {
        this._afterRender = () => this._updateTextareaHeight()
    }

    /**
     * Automatically grow the textarea to fit the content
     * 
     * @private
     * @ignore
     */
    _updateTextareaHeight() {
        this.field.style.height = this.field.scrollHeight + "px"
    }

    /**
     * Validate the field value and apply UI style accordingly
     * 
     * @returns {boolean} true is the field is valid, false otherwise
     */
    validate() {
        const isValid = kiss.tools.validateValue(this.type, this.config, this.field.value)
        if (isValid) {
            this.setValid()
        }
        else {
            this.setInvalid()
        }
        return isValid
    }

    /**
     * Set the field value
     * 
     * @param {string|number|date} newValue - The new field value
     * @param {boolean} [rawUpdate] - If true, it doesn't update the associated record and doesn't trigger "change" event 
     * @returns this
     */
    setValue(newValue, rawUpdate) {
        if (rawUpdate) {
            this.field.value = newValue
            return this
        }

        // Cast number field value
        if (this.getDataType() == "number") newValue = Number(newValue)

        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.field.value = this.initialValue || ""
                }
                else {
                    this.initialValue = newValue
                }
            })
        } else {
            // Otherwise, we just change the field value
            this.field.value = newValue
        }

        // If it's a textarea, we scroll down to the last row
        if (this.type == "textarea") this.field.scrollTop = this.field.scrollHeight

        return this
    }

    /**
     * Get the field value.
     * 
     * @returns {string|number|date} - The field value
     */
    getValue() {
        const fieldType = this.getDataType()

        if (fieldType == "number") {
            return Number(this.field.value)
        } else {
            return this.field.value || ""
        }
    }

    /**
     * Get the data type of a field, depending on its configuration:
     * text => text
     * number => number
     * date => date
     * textarea => text
     * password => text
     * summary => data type of the foreign field
     * lookup => data type of the foreign field
     * 
     * @returns {string} The field data type: "text" | "number" | "date"
     */
    getDataType() {
        if (this.type == "summary") return this.config.summary.type
        if (this.type == "lookup") return this.config.lookup.type
        if (this.type == "textarea") return "text"
        if (this.type == "password") return "text"
        return this.type
    }

    /**
     * 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 input 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.minWidth = 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 wrap" // Allow Map field to be displayed correctly
                this.field.style.order = -1
                break
            default:
                this.style.flexFlow = "row wrap" // Allow Map field to be displayed correctly
                this.field.style.order = 1
        }
        return this
    }

    /**
     * Change the field default display mode
     * 
     * @param {string} [displayMode] - "flex" (default) | "inline-flex"
     */
    setDisplayMode(displayMode = "flex") {
        this.config.display = this.style.display = displayMode
    }

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

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

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

    /**
     * Remove the invalid style
     * 
     * @returns this
     */
    setValid() {
        this.isValid = true
        this.field.classList.remove("field-input-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.field.classList.add("field-input-invalid")
        return this
    }
}

// Create a Custom Element and add somes shortcuts to create the various field types
customElements.define("a-field", kiss.ui.Field)

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

/**
 * Shorthand to create a new **text** Field. See [kiss.ui.Field](kiss.ui.Field.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createTextField = (config) => document.createElement("a-field").init(Object.assign(config, {
    type: "text"
}))

/**
 * Shorthand to create a new **textarea** Field. See [kiss.ui.Field](kiss.ui.Field.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createTextareaField = (config) => document.createElement("a-field").init(Object.assign(config, {
    type: "textarea"
}))

/**
 * Shorthand to create a new **number** Field. See [kiss.ui.Field](kiss.ui.Field.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createNumberField = (config) => document.createElement("a-field").init(Object.assign(config, {
    type: "number"
}))

/**
 * Shorthand to create a new **date** Field. See [kiss.ui.Field](kiss.ui.Field.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createDateField = (config) => document.createElement("a-field").init(Object.assign(config, {
    type: "date"
}))

/**
 * Shorthand to create a new **password** Field. See [kiss.ui.Field](kiss.ui.Field.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createPasswordField = (config) => document.createElement("a-field").init(Object.assign(config, {
    type: "password"
}))

;