Source

client/ui/fields/attachment.js

/**
 * 
 * The Attachment derives from [Component](kiss.ui.Component.html).
 * 
 * It allow to manipulate files:
 * - upload files
 * - preview uploaded files
 * - delete files
 * 
 * @param {object} config
 * @param {string} [config.label]
 * @param {string} [config.buttonText] - Text of the upload button. Defaults to "attach files"
 * @param {object[]} [config.value] - Default value
 * @param {string} [config.layout] - "" (default) | "thumbnails" | "thumbnails-large"
 * @param {boolean} [config.allowLayout] - true (default) to display the buttons to change the layout
 * @param {boolean} [config.multiple] - TODO: true to enable multi-select
 * @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.labelFontSize] - Any valid CSS value
 * @param {string} [config.labelFontWeight] - Any valid CSS value
 * @param {string} [config.labelColor] - Any valid CSS value
 * @param {string} [config.ACL] - The access control list for the uploaded files. "private" or "public". Defaults to "private"
 * @param {number} [config.maxSize] - The maximum size of the file to upload, in bytes. Defaults to 0 (no limit).
 * @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
 * The field is a composition of various elements:
 * - a span for the field label
 * - a span for the button to upload new files, which includes a span for the button icon
 * - a div to display the gallery of thumbnails
 * 
 * ```
 * <a-attachment class="a-attachment">
 *   <span class="field-label"></span>
 *   <span class="a-button attachment-button field-upload-button">
 *       <span class="fas fa-paperclip attachment-icon"></span> Attach files
 *   </span>
 *   <div class="field-attachment-gallery">
 *   </div>
 * </a-attachment>
 * ```
 */
kiss.ui.Attachment = class Attachment 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 myAttachment = document.createElement("a-attachment").init(config)
	 * ```
	 * 
	 * Or use the shorthand for it:
	 * ```
	 * const myAttachment = createAttachment({
	 *  label: "My uploaded files",
	 *  multiple: true
	 * })
	 * 
	 * myAttachment.render()
	 * ```
	 * 
	 * Or directly declare the config inside a container component:
	 * ```
	 * const myPanel = createPanel({
	 *   title: "My panel",
	 *   items: [
	 *       {
	 *          type: "attachment",
	 *          label: "My uploaded files",
	 *          multiple: true
	 *       }
	 *   ]
	 * })
	 * myPanel.render()
	 * ```
	 */
	constructor() {
		super()
	}

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

		this.value = config.value || []
		this.multiple = (config.multiple === false) ? false : true
		this.readOnly = !!config.readOnly

		// The component only works with arrays
		if (!Array.isArray(this.value)) this.value = []

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

                ${(!this.readOnly)
		? `<span class="a-button field-attachment-button field-upload-button">
                        <span class="fas fa-paperclip field-attachment-icon"></span>
                        ${config.buttonText || txtTitleCase("attach files")}
                    </span>`
		: ""}
                
                <span class="field-attachment-layout">
                    <span class="a-button field-attachment-button display-as-list fas fa-th-list"></span>
                    <span class="a-button field-attachment-button display-as-thumbnails fas fa-th-large"></span>
                    <span class="a-button field-attachment-button display-as-thumbnails-large fas fa-stop"></span>
                </span>

                <div class="field-attachment-gallery"></div>
             `.removeExtraSpaces()

		this.label = this.querySelector(".field-label")
		this.fieldValues = this.querySelector(".field-attachment-gallery")
		this.uploadButton = this.querySelector(".field-upload-button")
		this.layoutButtons = this.querySelector(".field-attachment-layout")

		// Set properties
		this._setProperties(config, [
			[
				["margin", "padding"],
				[this.style]
			],
			[
				["labelColor=color", "labelFontSize=fontSize", "labelFontWeight=fontWeight", "labelAlign=textAlign", "labelFlex=flex"],
				[this.label?.style]
			]
		])

		// Set the default display mode
		this.displayMode = "flex"

		// Get the layout: list or thumbnails
		this._initLayout()

		// Add field base class
		this.classList.add("a-attachment")
		// if (this.readOnly) this.classList.add("field-attachment-read-only")

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

		// Render values after a short delay to not interfer with form rendering
		setTimeout(() => this._renderValues(), 200)

		this._initClickEvent()

		return this
	}

	/**
	 * Handle click event
	 * 
	 * @private
	 * @ignore
	 */
	_initClickEvent() {
		this.onclick = function (event) {
			if (event.target.classList.contains("field-upload-button")) {
				this.showUploadWindow({
					ACL: this.config.ACL || "private",
					maxSize: this.config.maxSize || 0
				})
			} 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")
			} else if (event.target.classList.contains("field-attachment-menu")) {
				this.showAttachmentMenu(event.target)
			}
		}
	}

	/**
	 * Render the attachments in different modes:
	 * - list
	 * - thumbnails
	 * - large thumbnails
	 * 
	 * @param {string} mode - "list" | "thumbnails" | "thumbnails-large"
	 */
	renderAs(mode) {
		if (mode == "list") {
			localStorage.removeItem("config-layout-" + this.id)
			mode = ""
		}
		else {
			localStorage.setItem("config-layout-" + this.id, mode)
			mode = "-" + mode
		}
        
		this._initLayout()
		this._setItemClass("item", "field-attachment-item" + mode)
		this._setItemClass("preview", "field-attachment-preview" + mode)
		this._setItemClass("filename", "field-attachment-filename" + mode)
	}

	/**
	 * @private
	 * @ignore
	 */
	_getItemsByRole(role) {
		return Array.from(this.querySelectorAll(`[role="${role}"]`))
	}

	/**
	 * @private
	 * @ignore
	 */
	_setItemClass(role, classname) {
		const items = this._getItemsByRole(role)
		items.forEach(item => {
			item.classList.remove("field-attachment-" + role)
			item.classList.remove("field-attachment-" + role + "-thumbnails")
			item.classList.remove("field-attachment-" + role + "-thumbnails-large")
			item.classList.add(classname)
		})
	}

	/**
	 * Switch 2 classes of an element
	 * 
	 * @private
	 * @ignore
	 */    
	_switchClass(fromClass, toClass, large) {
		Array.from(this.querySelectorAll(".field-attachment-item")).forEach(element => {
			element.classList.remove(fromClass)
			element.classList.add(toClass)
		})
	}

	/**
	 * Display the upload window of the attachment field
	 * 
	 * @param {object} [config] - Configuration object
	 * @param {string} [config.ACL] - The access control list for the uploaded files. "private" or "public". Defaults to "private"
	 * @param {number} [config.maxSize] - The maximum size of the file to upload, in bytes. Defaults to 0 (no limit)
	 */
	showUploadWindow(config = {}) {
		createFileUploadWindow({
			modelId: this.record?.model.id,
			recordId: this.record?.id,
			fieldId: this.id,
			ACL: config.ACL || "private",
			maxSize: config.maxSize || 0
		})
	}

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

		if (record[this.id]) {
			this.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, just to render the new value
	 * 
	 * @private
	 * @ignore
	 * @param {*} updates 
	 */
	_updateField(updates) {
		if (this.id in updates) {
			const newValue = updates[this.id]
			if (newValue) {
				this.value = newValue
				this._renderValues()
				if (this.onchange) this.onchange(newValue)
				this.validate()
			}
		}
	}

	/**
	 * Set the field value
	 * 
	 * @param {object[]} newValues - 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(newValues, rawUpdate) {
		newValues = [].concat(newValues)

		if (rawUpdate) return this._updateValue(newValues, rawUpdate)

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

		return this
	}

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

	/**
	 * Update the field's value
	 * 
	 * @private
	 * @ignore
	 * @param {object[]} newValues
	 * @param {boolean} [rawUpdate]
	 * @returns this
	 */
	_updateValue(newValues, rawUpdate) {
		this.value = newValues
		this._renderValues()
		if (this.onchange && !rawUpdate) this.onchange(newValues)
		return this
	}

	/**
	 * Get the field value
	 * 
	 * @returns {string[]} - The field value
	 */
	getValue() {
		return this.value || []
	}

	/**
	 * Reset the field value
	 * 
	 * @returns this
	 */
	resetValue() {
		this.value = []
		this._renderValues()
		return this
	}

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

	/**
	 * Validate the field
	 * 
	 * @returns {boolean}
	 */
	validate() {
		if (this.isHidden()) return true

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

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

	/**
	 * Restore the layout (list or thumbnails)
	 * 
	 * @private
	 * @ignore
	 */
	_initLayout() {
		this.layout = localStorage.getItem("config-layout-" + this.id)
		if (!this.layout) this.layout = this.config.layout || ""
	}

	/**
	 * Render the current value(s) of the widget.
	 * 
	 * @private
	 * @ignore
	 */
	_renderValues() {
		// Empty value
		if ((this.value == "") || (this.value == null) || (this.value == "undefined") || (this.value[0] == null) || (this.value[0] == "")) {
			this.fieldValues.innerHTML = ""
			this.fieldValues.style.display = "none"
			this.layoutButtons.style.display = "none"
			if (this.uploadButton) this.uploadButton.style.display = "inline-flex"
			return
		}

		// The component accepts values that are not arrays, but it only works with arrays internally
		if (!Array.isArray(this.value)) this.value = [this.value]

		this.fieldValues.innerHTML = this.value.map((file, i) => {
			return this._renderValue(file, i)

		}).join("")

		this.fieldValues.style.display = "flex"
		if (this.config.allowLayout !== false) {
			this.layoutButtons.style.display = "inline-block"
		}
		else {
			this.layoutButtons.style.display = "none"
		}

		// Hide the upload button if the field is not "multiple" and the value is not empty
		if (this.multiple === false && this.getValue().length > 0) this.uploadButton.style.display = "none"

		// All files that don't have a public URL will be handled by a specific and authenticated download link
		kiss.session.setupDownloadLink(...this.fieldValues.querySelectorAll("a[download]"))
		kiss.session.setupImg(...this.fieldValues.querySelectorAll("img"))
	}

	/**
	 * Render a single file
	 * 
	 * @private
	 * @ignore
	 */
	_renderValue(file, i) {
		if (!file.path) return ""
		const isPublic = (file.accessReaders && Array.isArray(file.accessReaders) && file.accessReaders.includes("*"))
		const isOffline = kiss.session.isOffline()
		const lockIcon = (isPublic) ? "fas fa-lock-open" : "fas fa-lock"
		const layout = (this.layout.includes("thumbnails")) ? "-" + this.layout : ""

		let preview
		let filePath = kiss.tools.createFileURL(file)
		let fileThumbnail = kiss.tools.createFileURL(file, "m")
		const fileExtension = file.path.split(".").pop().toLowerCase()

		if (["jpg", "jpeg", "png", "gif", "webp"].indexOf(fileExtension) != -1) {
			// Image
			preview = `<img role="preview" public="${isPublic}" class="field-attachment-preview${layout}" src="${fileThumbnail}" loading="lazy"></img>`
		} else {
			// Other
			const {
				icon,
				color
			} = kiss.tools.fileToIcon(fileExtension)
			preview = `<span role="preview" style="color: ${color}" class="fas ${icon} field-attachment-preview${layout}"></span>`
		}

		return /*html*/ `
                <div role="item" class="field-attachment-item${layout}" id="${file.id}" onclick="this.closest('.a-attachment')._previewAttachment(event)">
                    <div class="field-attachment-preview-container">${preview}</div>
                    <span role="filename" class="field-attachment-filename${layout}">${file.filename}</span>
                    <span class="field-attachment-buttons">
                        <span class="field-attachment-filesize">${file.size.toFileSize()}</span>
                        ${(this.readOnly || isOffline) ? "" : `<span class="field-attachment-access ${lockIcon}"></span>`}
                        <span style="flex:1"></span>
                        <span class="field-attachment-menu fas fa-ellipsis-v" index="${i}" onclick="$('${this.id}')._openFileMenu(event, '${file.id}')"></span>     
                    </span>
                </div>`
	}

	/**
	 * Open a menu with the file operations
	 * 
	 * @private
	 * @ignore
	 * @param {*} event 
	 * @param {string} fileId 
	 */
	_openFileMenu(event, fileId) {
		event.stop()
		if (kiss.session.isOffline()) return

		const currentValues = this.getValue()
		const file = currentValues.get(fileId)
		const newACL = (file.accessReaders && Array.isArray(file.accessReaders) && file.accessReaders.includes("*")) ? "private" : "public"
		const lockIcon = (newACL == "public") ? "fas fa-lock-open" : "fas fa-lock"

		// Get the plugin actions to insert in the attachment menu
		const pluginActions = this._getPluginActions(fileId)

		createMenu({
			id: "attachment-menu-" + fileId,
			top: event.clientY - 10,
			left: event.clientX - 10,
			items: [
				// Download file
				{
					icon: "far fa-arrow-alt-circle-down",
					text: txtTitleCase("download file"),
					action: () => {
						const filePath = kiss.tools.createFileURL(file)
						window.open(filePath, "_blank")
					}
				},
				// Change file ACL
				{
					hidden: this.readOnly,
					icon: lockIcon,
					text: txtTitleCase("#update file ACL", null, {
						access: txtUpperCase(newACL)
					}),
					action: async () => this._switchFileACL(event, fileId)
				},
				// Copy file PUBLIC URL
				{
					hidden: (newACL == "public"),
					icon: "fas fa-copy",
					text: txtTitleCase("copy file address"),
					action: () => {
						const filePath = kiss.tools.createFileURL(file)
						kiss.tools.copyTextToClipboard(filePath).then(() => {
							createNotification({
								message: txtTitleCase("copied to clipboard"),
								duration: 2000
							})
						})
					}
				},
                
				// Insert plugin actions, if any
				...pluginActions,
                
				// Delete file
				"-",
				{
					hidden: this.readOnly,
					icon: "fas fa-trash",
					iconColor: "var(--red)",
					text: txtTitleCase("delete file"),
					action: () => this._deleteFile(event, fileId)
				}
			]
		}).render()
	}

	/**
	 * Get the plugin actions that should be inserted in the attachment menu
	 * 
	 * @private
	 * @ignore
	 * @returns {Array} An array of plugin actions
	 */
	_getPluginActions(fileId) {
		if (!this.modelId) return []

		const model = kiss.app.models[this.modelId]
		if (!model || !model.features) return []
        
		const modelFeatures = model.features
		const activePlugins = kiss.plugins.get().filter(plugin => {
			if (!modelFeatures[plugin.id]) return false
			if (modelFeatures[plugin.id].active == false) return false
			return true
		})

		let menuActions = []
		activePlugins.forEach(plugin => {
			plugin.features.forEach(feature => {
				if (feature.type == "attachment-menu") {
					menuActions.push(feature.renderer(this, fileId))
				}
			})
		})

		return menuActions
	}

	/**
	 * Preview an attachment
	 * 
	 * @private
	 * @ignore
	 * @param {object} event
	 * @param {string} fieldId 
	 */
	_previewAttachment(event) {

		// Exit if clicked on the download link
		if ([...event.target.classList].includes("field-attachment-download")) return

		const itemClass = (this.layout) ? ".field-attachment-item-" + this.layout : ".field-attachment-item"
		const attachmentId = event.target.closest(itemClass).id
		const cellAttachments = this.getValue()

		if (this.record) {
			createPreviewWindow(cellAttachments, attachmentId, this.record.id, this.id)
		}
		else {
			createPreviewWindow(cellAttachments, attachmentId)
		}

		// Add a button to switch the display mode in the preview window
		// This allows to view the file on the right side of the screen
		// while keeping the form on the left side
		if (this.record && !kiss.screen.isMobile) {
			if ($("preview-window").panelCustomIcons.children.length > 0) return

			$("preview-window").addHeaderIcon(            {
				hidden: (kiss.screen.isMobile && kiss.screen.isVertical()),
				icon: "fas fa-columns",
				tip: txtTitleCase("#preview mode"),
				action: () => $("preview-window").switchDisplayMode()
			})
		}
	}

	/**
	 *
	 * @param event
	 */
	showAttachmentMenu(event) {
		createMenu({
			items: [
				{
					icon: "fas fa-eye",
					text: txtTitleCase("#preview file"),
					action: () => this._previewAttachment(event)
				},
				{
					icon: "fas fa-trash",
					text: txtTitleCase("#delete file"),
					action: () => this._deleteFile(event, event.target.closest(".field-attachment-item").id)
				},
				{
					icon: "fas fa-lock",
					text: txtTitleCase("#switch file ACL"),
					action: () => this._switchFileACL(event, event.target.closest(".field-attachment-item").id)
				}
			]
		}).render().showAt(event.clientX, event.clientY - 10)

		event.stop()
	}

	/**
	 * Switch a file ACL between public and private
	 * 
	 * @private
	 * @ignore
	 * @param {*} event 
	 * @param {string} fileId 
	 */
	async _switchFileACL(event, fileId) {
		event.stop()
		if (kiss.session.isOffline()) return

		const currentValues = this.getValue()
		const file = currentValues.get(fileId)
		const newACL = (file.accessReaders && Array.isArray(file.accessReaders) && file.accessReaders.includes("*")) ? "private" : "public"

		const response = await kiss.ajax.request({
			url: "/updateFileACL",
			method: "patch",
			showLoading: true,
			body: JSON.stringify({
				modelId: this.modelId,
				recordId: this.recordId,
				fieldId: this.id,
				file,
				ACL: newACL
			})
		})

		if (response.success) {
			const message = "<center>" + txtTitleCase("#updating ACL") + "<br><b>" + txtUpperCase(newACL)
			createNotification({
				message,
				duration: 2000
			})
		}
	}

	/**
	 * Delete a file from the attachment field
	 * 
	 * @private
	 * @ignore
	 * @param {object} event
	 * @param {string} fileId 
	 */
	_deleteFile(event, fileId) {
		event.stop()
		if (kiss.session.isOffline()) return

		createDialog({
			type: "danger",
			title: txtTitleCase("deleting a file"),
			message: txtTitleCase("#warning delete file"),
			action: async () => {
				let currentValues = this.getValue()
				let newValues = currentValues.removeById(fileId)

				// Delete the file from the "file" collection
				const fileCollection = kiss.app.collections.file

				const loadingId = kiss.loadingSpinner.show()
				const response = await fileCollection.deleteOne(fileId)

				// Update the field value if the file could be physically deleted
				if (response.success) {
					this.setValue(newValues)
				}
				kiss.loadingSpinner.hide(loadingId)
			}
		})
	}
}

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

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