Source

client/ui/fields/checkbox.js

/**
 * 
 * The Checkbox derives from [Component](kiss.ui.Component.html).
 * 
 * Provides a customizable checkbox.
 * 
 * @param {object} config
 * @param {string} config.label
 * @param {string} [config.labelWidth]
 * @param {string} [config.labelPosition] - left | right | top | bottom
 * @param {string} [config.labelAlign] - left | right
 * @param {string} [config.color]
 * @param {string} [config.fontSize]
 * @param {string} [config.shape] - check | square | circle | switch | star
 * @param {string} [config.iconSize]
 * @param {string} [config.iconOn]
 * @param {string} [config.iconOff]
 * @param {string} [config.iconColorOn]
 * @param {string} [config.iconColorOff]
 * @param {string} [config.formula]
 * @param {boolean} [config.checked] - Default state - Can use "checked" or "value" indifferently
 * @param {boolean} [config.value] - Default state - Can use "checked" or "value" indifferently
 * @param {string|number} [config.width]
 * @param {string|number} [config.height]
 * @param {string} [config.margin]
 * @param {string} [config.padding]
 * @param {boolean} [config.readOnly]
 * @param {boolean} [config.disabled]
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-checkbox class="a-checkbox">
 *  <label class="field-label"></label>
 *  <span class="field-checkbox-icon font-awesome-icon-class"></span>
 *  <input type="checkbox" class="field-checkbox">
 * </a-checkbox>
 * ```
 */
kiss.ui.Checkbox = class Checkbox 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 myCheckbox = document.createElement("a-checkbox").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myCheckbox = createCheckbox({
     *  text: "Check me!",
     *  shape: "switch"
     * })
     * 
     * myCheckbox.render()
     * ```
     * 
     * Or directly declare the config inside a container component:
     * ```
     * const myPanel = createPanel({
     *   title: "My panel",
     *   items: [
     *       {
     *           type: "checkbox",
     *           text: "Check me!",
     *           shape: "switch"
     *       }
     *   ]
     * })
     * myPanel.render()
     * ```
     */
    constructor() {
        super()
    }

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

        const id = this.id

        // Checkbox shape
        config.shape = config.shape || "square"
        const iconClasses = this.getIconClasses()
        const defaultIconOn = iconClasses[config.shape]["on"]
        const defaultIconOff = iconClasses[config.shape]["off"]

        // Accept "value" or "checked" as default value to keep it uniform with all other field types
        let isChecked = config.checked || config.value

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

        this.iconOn = config.iconOn || defaultIconOn
        this.iconOff = config.iconOff || defaultIconOff
        this.iconColorOn = config.iconColorOn || "#20c933"
        this.iconColorOff = config.iconColorOff || "#aaaaaa"
        const defaultIcon = (isChecked == true) ? this.iconOn : this.iconOff
        const defaultIconColor = (isChecked == true) ? this.iconColorOn : this.iconColorOff

        // Disable the field if it's readOnly
        this.readOnly = !!config.readOnly || !!config.computed
        if (this.readOnly) config.disabled = true
        
        // Template
        this.innerHTML = /*html*/
            `${(config.label) ? `<label id="field-label-${id}" for="${id}" class="field-label">
                    ${ (this.isLocked()) ? this.locker : "" }
                    ${ config.label || "" }
                    ${ (this.isRequired()) ? this.asterisk : "" }
                </label>` : "" }

                <span id="" style="color: ${defaultIconColor}" class="field-checkbox-icon ${defaultIcon} ${(this.readOnly) ? "field-checkbox-read-only" : ""}"></span>
                <input type="checkbox" id="${id}" name="${id}" ${(isChecked) ? `checked="${isChecked}"` : ""} class="field-checkbox" ${(config.disabled == true) ? "disabled" : ""}>
            `.removeExtraSpaces()

        // Set properties
        this.label = this.querySelector(".field-label")
        this.field = this.querySelector(".field-checkbox")
        this.icon = this.querySelector(".field-checkbox-icon")

        // Other W3C properties
        this._setProperties(config, [
            [
                ["draggable"],
                [this]
            ],
            [
                ["width", "height", "display", "margin", "padding", "flex"],
                [this.style]
            ],
            [
                ["value"],
                [this.field]
            ],
            [
                ["fieldWidth=width", "height=lineHeight", "iconSize=fontSize"],
                [this.icon.style]
            ],
            [
                ["color", "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"

        // Listen to click events if the field is *not* disabled or *readonly*
        if (config.disabled != true && !this.readOnly) {
            this.icon.onclick = () => {
                this.field.checked = !this.field.checked
                this.setValue(this.field.checked)
            }
        }

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

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

            // Listen to click events if the field is *not* disabled or *readOnly*
            if (config.disabled != true) this.label.onclick = this.icon.onclick
        }

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

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

        // Render default value
        this._renderValues()

        return this
    }

    /**
     * Get the icon classes for each checkbox shape
     * 
     * @returns {object}
     */
    getIconClasses() {
        return {
            check: {
                on: "far fa-check-square",
                off: "far fa-square"
            },            
            square: {
                on: "far fa-check-square",
                off: "far fa-square"
            },
            circle: {
                on: "far fa-check-circle",
                off: "far fa-circle"
            },
            switch: {
                on: "fas fa-toggle-on",
                off: "fas fa-toggle-off"
            },
            star: {
                on: "fas fa-star",
                off: "far fa-star"
            }
        }
    }    

    /**
     * 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.checked = 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 === false)) {
                this.field.checked = newValue
                this._renderValues()
            }
        }
    }    

    /**
     * Render the current value(s) of the widget.
     * 
     * @private
     * @ignore
     */
    _renderValues() {
        const newState = this.field.checked
        const iconAdd = (newState == true) ? this.iconOn : this.iconOff
        const iconRemove = (newState == true) ? this.iconOff : this.iconOn
        iconRemove.split(" ").forEach(className => this.icon.classList.remove(className))
        iconAdd.split(" ").forEach(className => this.icon.classList.add(className))
        this.icon.style.color = (newState == true) ? this.iconColorOn : this.iconColorOff
    }

    /**
     * Set the field value
     * 
     * @param {boolean} newState - 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(newState, rawUpdate) {
        if (rawUpdate) return this._updateValue(newState, rawUpdate)

        if (this.record) {
            // If the field is connected to a record, we update the database
            this.record.updateFieldDeep(this.id, newState).then(success => {
                if (success) {
                    this._updateValue(newState)
                }
                else {
                    // Rollback the initial value if the update failed (ACL)
                    this._updateValue(this.initialValue)
                }
            })
        } else {
            this._updateValue(newState)
        }
        
        return this
    }

    /**
     * Update the field's value internally
     * 
     * @private
     * @ignore
     * @param {boolean} newState
     * @param {boolean} [rawUpdate]
     * @returns this
     */
    _updateValue(newState, rawUpdate) {
        // const updateValue = (newState) => {
        //     this.field.checked = newState
        //     this._renderValues()
        //     this.dispatchEvent(new Event("change"))
        // }

        this.field.checked = newState
        this._renderValues()
        if (!rawUpdate) this.dispatchEvent(new Event("change"))
        return this
    }


    /**
     * Get the field value
     * 
     * @returns {boolean} - The field value
     */
    getValue() {
        return this.field.checked
    }

    /**
     * Validate the field (always true because Checkbox fields can't have wrong values)
     * 
     * @ignore
     * @returns {boolean}
     */
    validate() {
        return true
    }    

    /**
     * Toggle the value true / false
     */
    toggleValue() {
        this.setValue(!this.getValue())
    }

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

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

    /**
     * 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.icon.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")
        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.style.alignItems = "unset"
                this.icon.style.order = 1
                break
            case "bottom":
                this.style.flexFlow = "column"
                this.style.alignItems = "unset"
                this.icon.style.order = -1
                break
            case "right":
                this.style.flexFlow = "row"
                this.style.alignItems = "center"
                this.icon.style.order = -1
                break
            default:
                this.style.flexFlow = "row"
                this.style.alignItems = "center"
                this.icon.style.order = 1
        }
        return this
    }
}

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

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

;