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.labelFontSize] - Any valid CSS value
 * @param {string} [config.labelFontWeight] - Any valid CSS value
 * @param {string} [config.labelColor] - Any valid CSS value
 * @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]
 * @param {object} [config.record] - The record to bind the field to. This will automatically update the record when the field value changes, and the field will listen to database changes on the record.
 * @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]
			],
			[
				["labelAlign=textAlign", "labelFlex=flex", "labelFontSize=fontSize", "labelFontWeight=fontWeight", "labelColor=color"],
				[this?.label?.style]
			]
		])

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

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

		// 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.hasOwnProperty(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)

		this._updateValue(newState)

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

	/**
	 * Clear the field value
	 * 
	 * @returns this
	 */
	clearValue() {
		this.setValue(false)
		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 value and apply UI style accordingly
	 * 
	 * @returns {boolean} true is the field is valid, false otherwise
	 */
	validate() {
		if (this.isHidden()) return true

		const isValid = kiss.tools.validateValue(this.type, this.config, this.field.checked)
		if (isValid) {
			this.setValid()
		}
		else {
			this.setInvalid()
		}
		return isValid
	}    

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

	/**
	 * Change the color of the checkbox icon
	 * 
	 * @param {string} color - The new color
	 */
	setColor(color) {
		this.icon.style.color = color
	}

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