Source

client/core/modules/selection.js

/**
 * 
 * ## A simple selection manager
 * 
 * - keeps track of selected records for a specific view
 * - allow simple operations like add/delete/get/reset
 * - store in a localStorage object, which key is the viewId
 * - works in combination with datatables or other data components with selection capabilities
 * 
 * @namespace
 */
kiss.selection = {
	/**
	 * Insert one record into the view selection
	 * 
	 * @param {string} viewId 
	 * @param {string} recordId 
	 */
	insertOne(viewId, recordId) {
		let selection = localStorage.getItem("config-selection-" + viewId)
		if (!selection) {
			localStorage.setItem("config-selection-" + viewId, recordId)
			return
		}

		let records = selection.split(",")
		if (records.indexOf(recordId) != -1) return

		localStorage.setItem("config-selection-" + viewId, selection + "," + recordId)
	},

	/**
	 * Insert many records into the view selection
	 * 
	 * @param {string} viewId 
	 * @param {string[]} recordIds
	 */    
	insertMany(viewId, recordIds) {
		let selection = localStorage.getItem("config-selection-" + viewId)
		if (!selection) {
			localStorage.setItem("config-selection-" + viewId, recordIds.join(","))
			return
		}

		let ids = selection.split(",")
		ids = ids.concat(recordIds).unique()

		localStorage.setItem("config-selection-" + viewId, ids.join(","))
	},

	/**
	 * Delete a record from the view selection
	 * 
	 * @param {string} viewId 
	 * @param {string} recordId 
	 */    
	delete(viewId, recordId) {
		let selection = localStorage.getItem("config-selection-" + viewId)
		if (!selection) return

		let records = selection.split(",").remove(recordId)
		records = records.filter(recordId => recordId != "")
		localStorage.setItem("config-selection-" + viewId, records.join(","))
	},

	/**
	 * Reset the selection of a view
	 * 
	 * @param {string} viewId 
	 */       
	reset(viewId) {
		let selection = localStorage.getItem("config-selection-" + viewId)
		if (!selection) return

		localStorage.removeItem("config-selection-" + viewId)
	},    

	/**
	 * Get the current selection for a view
	 * 
	 * @param {string} viewId
	 * @returns {string[]} The list of ids of the selected records
	 */    
	get(viewId) {
		let selection = localStorage.getItem("config-selection-" + viewId)
		if (!selection) return []

		return selection.split(",")
	},

	/**
	 * Get the current selection for the active view
	 * 
	 * @param {string} viewId
	 * @returns {string[]} The list of ids of the selected records
	 */    
	getFromActiveView() {
		const viewId = kiss.context.viewId
		if (!viewId) return []
		let selection = localStorage.getItem("config-selection-" + viewId)
		if (!selection) return []

		return selection.split(",")
	},    

	/**
	 * Get the selected records in a view
	 * 
	 * @param {string} viewId
	 * @returns {object[]} The list of selected records
	 */      
	async getRecords(viewId) {
		const recordIds = kiss.selection.get(viewId)
		const viewCollection = $(viewId).collection
		return await viewCollection.findById(recordIds)
	},

	/**
	 * Get the selected records in the active view
	 * 
	 * @returns {object[]} The list of selected records
	 */
	async getRecordsFromActiveView() {
		const viewId = kiss.context.viewId
		return await kiss.selection.getRecords(viewId)
	},

	/**
	 * Opens a window to batch update the selected records
	 */
	updateSelectedRecords() {
		const ids = kiss.selection.getFromActiveView()
		if (ids.length == 0) return createNotification(txtTitleCase("#no selection"))

		const model = kiss.app.models[kiss.context.modelId]
		if (!model) return

		const fields = model.getUpdatableFields()

		createPanel({
			id: "selection-batch-update",
			title: txtTitleCase("update selected documents"),
			modal: true,
			backdropFilter: true,
			closable: true,
			draggable: true,
			icon: "fas fa-bolt",
			align: "center",
			verticalAlign: "center",
			layout: "vertical",
			width: "50rem",
			headerStyle: "flat",
			padding: "2rem",

			items: [
				// Field to update
				{
					id: "batch-field",
					type: "select",
					label: txtTitleCase("field to update"),
					labelPosition: "top",
					width: "100%",
					options: fields.map(field => {
						return {
							value: field.id,
							label: field.label
						}
					}),
					events: {
						change: function() {
							const fieldId = this.getValue()
							$("selection-batch-update").updateInput(fieldId)
						}
					}
				},
				// Value to set (will be replaced by the input field)
				{
					id: "batch-value-container"
				},
				// Button to update the records
				{
					hidden: true,
					id: "batch-update-button",
					type: "button",
					text: txtTitleCase("update"),
					icon: "fas fa-bolt",
					margin: "2rem 0.5rem 0 0.5rem",
					class: "button-ok",
					action: () => $("selection-batch-update").updateRecords(ids)
				}
			],

			methods: {
				updateRecords(ids) {
					// Warn the user that the operation is irreversible
					createDialog({
						title: txtTitleCase("update selected documents"),
						type: "danger",
						message: txtTitleCase("#warning update docs", null, {
							n: ids.length
						}),
						action: async () => {
							const fieldId = $("batch-field").getValue()
							const value = $("batch-value").getValue()

							if (value == "") {
								// Warn the user that the value is empty
								createDialog({
									title: txtTitleCase("empty value"),
									type: "danger",
									message: txtTitleCase("#warning empty value"),
									action: async () => {
										await kiss.selection._updateRecords(fieldId, value)
										$("selection-batch-update").close()

										const viewId = kiss.context.viewId
										$(viewId).deselectAll()
									}
								})
							}
							else {
								await kiss.selection._updateRecords(fieldId, value)
								$("selection-batch-update").close()

								const viewId = kiss.context.viewId
								$(viewId).deselectAll()
							}
						}
					})
				},

				/**
				 * Update the input field according to the selected field
				 * 
				 * @param {string} fieldId 
				 */
				updateInput(fieldId) {
					let currentField = $("batch-value")
					if (currentField) currentField.deepDelete()

					const fieldConfig = this.buildInput(fieldId)
					let input = fieldConfig.renderer(fieldConfig)
					input.render()

					$("batch-update-button").show()
				},

				/**
				 * Build an input field according to the selected field
				 * 
				 * @param {string} fieldId 
				 * @returns {object} The input field configuration
				 */
				buildInput(fieldId) {
					const fieldConfig = fields.find(field => field.id == fieldId)
					let fieldType = fieldConfig.type
					let fieldBuilderFunction = createField
					let allowValuesNotInList = true
					let iconColorOn
					let checked = false
					let options = []
					let optionsFilter
					let shape = ""
					let roles = []
					let multiple
					let unit
					let min
					let max
					let rows
            
					switch (fieldType) {
					case "textarea":
					case "aiTextarea":
						fieldType = "textarea"
						rows = 5
						break
					case "select":
						options = fieldConfig.options
						optionsFilter = fieldConfig.optionsFilter
						multiple = fieldConfig.multiple
						allowValuesNotInList = fieldConfig.allowValuesNotInList
						fieldBuilderFunction = createSelect
						break
					case "selectViewColumn":
						multiple = fieldConfig.multiple
						allowValuesNotInList = fieldConfig.allowValuesNotInList
						fieldBuilderFunction = createSelectViewColumn
						break
					case "checkbox":
						shape = fieldConfig.shape
						iconColorOn = fieldConfig.iconColorOn
						fieldBuilderFunction = createCheckbox
						break
					case "slider":
						min = fieldConfig.min || 0
						max = fieldConfig.max || 100
						fieldBuilderFunction = createSlider
						break
					case "rating":
						shape = fieldConfig.shape || "star"
						min = fieldConfig.min || 0
						max = fieldConfig.max || 10
						iconColorOn = fieldConfig.iconColorOn
						fieldBuilderFunction = createRating
						break
					case "color":
						fieldBuilderFunction = createColorField
						break
					case "icon":
						fieldBuilderFunction = createIconField
						break
					case "directory":
						roles = ["userId"]
						multiple = fieldConfig.multiple
						allowValuesNotInList = false
						fieldBuilderFunction = createDirectory
						break
					}
            
					// Create an input field configuration with the right type
					return {
						id: "batch-value",
						target: "batch-value-container",
						type: fieldType,
						label: txtTitleCase("new field value"),
						labelPosition: "top",
						width: "100%",

						// Field options
						rows,
						min,
						max,
						unit,
						checked,
						multiple,
						allowValuesNotInList,

						// For selectViewColumn fields
						fieldId: fieldConfig.fieldId,
						viewId: fieldConfig.viewId,

						// Special fields options
						renderer: fieldBuilderFunction, // renderer
						options, // select
						optionsFilter, // select
						optionsColor: model.color,
						roles, // directory
						shape, // checkbox
						iconColorOn, // checkbox
						iconSize: "2rem" // checkbox
					}
				}
			}
		}).render()
	},

	/**
	 * Private utility function to update records from a view
	 * 
	 * @private
	 * @ignore
	 * @param {string} fieldLabel 
	 * @param {*} value 
	 */
	async _updateRecords(fieldLabel, value) {
		const model = kiss.app.models[kiss.context.modelId]
		if (!model) return

		const field = model.getFieldByLabel(fieldLabel)
		if (!field) return

		const viewId = kiss.context.viewId
		if (!$(viewId)) return

		const recordIds = kiss.selection.get(viewId)
		const viewCollection = $(viewId).collection
		const selectedRecords = await viewCollection.findById(recordIds)
    
		// Prevent the view from refreshing while batch updates are in progress
		kiss.global.preventViewRefresh = true
        
		const loadingId = kiss.loadingSpinner.show()
		let counter = 0

		for (let record of selectedRecords) {
			record.updateDeep({
				[field.id]: value
			})

			await kiss.tools.wait(100)
            
			counter++
			createNotification(counter + " / " + selectedRecords.length)

			// Refresh the view when the last record has been updated
			if (counter == selectedRecords.length - 1) {
				kiss.global.preventViewRefresh = false
			}
		}

		kiss.loadingSpinner.hide(loadingId)
	},

	/**
	 * Delete the selected records (send them to the trash)
	 */
	deleteSelectedRecords() {
		const model = kiss.app.models[kiss.context.modelId]
		if (!model) return

		const ids = this.getFromActiveView()
		if (ids.length == 0) return createNotification(txtTitleCase("#no selection"))

		createDialog({
			title: txtTitleCase("delete selected documents"),
			type: "danger",
			message: txtTitleCase("#warning delete docs", null, {
				n: ids.length
			}),
			action: async () => {
				const response = await kiss.db.deleteMany(model.id, {
					_id: {
						"$in": ids
					}
				}, true)

				if (response.error) {
					return createNotification(txtTitleCase("#not authorized"))
				}

				const viewId = kiss.context.viewId
				$(viewId).deselectAll()
			}
		})
	}
}