/**
*
* 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]
* @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
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.layoutButtons = this.querySelector(".field-attachment-layout")
// Set properties
this._setProperties(config, [
[
["margin", "padding"],
[this.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()
this._initSubscriptions()
return this
}
/**
* Handle click event
*
* @private
* @ignore
*/
_initClickEvent() {
this.onclick = function (event) {
if (event.target.classList.contains("field-upload-button")) {
this.showUploadWindow()
} 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")
}
}
}
/**
* 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)
})
}
/**
* Init subscriptions
* Bind the field to upload events
*
* @private
* @ignore
*/
_initSubscriptions() {
this.subscriptions.push(
subscribe("EVT_FILE_UPLOAD", msgData => {
if ((msgData.modelId == this.modelId) && (msgData.recordId == this.recordId) && (msgData.fieldId == this.id) && msgData.files) {
let newValues = msgData.files.map(file => {
return {
id: file.id,
filename: file.originalname,
path: file.path,
size: file.size,
type: file.type,
mimeType: file.mimeType,
thumbnails: file.thumbnails,
accessReaders: file.accessReaders,
createdAt: file.createdAt,
createdBy: file.createdBy
}
})
this.setValue(this.getValue().concat(newValues))
}
})
)
}
/**
* Display the upload window of the attachment field
*/
showUploadWindow() {
createFileUploadWindow({
modelId: this.record?.model.id,
recordId: this.record?.id,
fieldId: this.id
})
}
/**
* 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
*
* @private
* @ignore
* @param {*} updates
*/
_updateField(updates) {
if (this.id in updates) {
const newValue = updates[this.id]
if (newValue) {
this.value = newValue
this._renderValues()
}
}
}
/**
* 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
}
/**
* Update the field's value internally
*
* @private
* @ignore
* @param {object[]} newValues
* @param {boolean} [rawUpdate]
* @returns this
*/
_updateValue(newValues, rawUpdate) {
// const updateValue = (newValues) => {
// this.value = newValues
// this._renderValues()
// if (this.onchange) this.onchange(newValues)
// }
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 (always true because Attachment fields can't have wrong values)
*
* @returns {boolean}
*/
validate() {
return true
}
/**
* 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"
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"
}
// 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 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>
<span style="flex:1"></span>
${(this.readOnly) ? "" : `<span class="field-attachment-delete fas fa-trash" index="${i}" onclick="$('${this.id}')._deleteFile(event, '${file.id}')"></span>`}
${(this.readOnly) ? "" : `<span class="field-attachment-access ${lockIcon}" index="${i}" onclick="$('${this.id}')._switchFileACL(event, '${file.id}')"></span>`}
<a href="${filePath}" download public="${isPublic}" target="_blank"><span class="field-attachment-download far fa-arrow-alt-circle-down"></span></a>
</span>
</div>`
// Add this line *inside* the div to show the file menu icon
// <span class="field-attachment-menu fas fa-ellipsis-v" index="${i}" onclick="$('${this.id}')._openFileMenu(event, '${file.id}')"></span>
}
/**
* !Not used yet
* Open a menu with the file operations
*
* @private
* @ignore
* @param {*} event
* @param {string} fileId
*/
_openFileMenu(event, fileId) {
event.stop()
const currentValues = this.getValue()
const file = currentValues.get(fileId)
const ACL = (file.accessReaders.includes("*")) ? "private" : "public"
const lockIcon = (ACL == "public") ? "fas fa-lock-open" : "fas fa-lock"
createMenu({
top: event.clientY - 10,
left: event.clientX - 10,
items: [{
icon: lockIcon,
text: txtTitleCase("#update file ACL", null, {
access: txtUpperCase(ACL)
}),
action: async () => {
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
})
})
if (response.success) {
const message = "<center>" + txtTitleCase("#updating ACL") + "<br><b>" + txtUpperCase(ACL)
createNotification({
message,
duration: 2000
})
}
}
}]
}).render()
}
/**
* 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()
createPreviewWindow(cellAttachments, attachmentId)
}
/**
* 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"),
buttonOKPosition: "left",
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 success = await fileCollection.deleteOne(fileId)
// Update the field value if the file could be physically deleted
if (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)
;
Source