Source

client/ui/abstract/dataComponent.js

/**
 * 
 * The DataComponent derives from [Component](kiss.ui.Component.html).
 * 
 * This is an **abstract class**: don't use it directly.
 * It's the base class for all the components used to display a collection of records, like:
 * - Datatable
 * - Calendar
 * - Kanban
 * - Timeline
 * - ChartView
 * - Dashboard
 * 
 * Each **DataComponent** is associated with its own Collection.
 * 
 * The Collection can be provided directly in the config.
 * If not, a Model must be passed instead, and a new Collection will be created from this Model.
 * 
 * A **DataComponent** can manipulate its collection:
 * - selecting the fields to display
 * - filtering the data
 * - sorting the data
 * - grouping the data
 * 
 * These operations are achieved thanks to built-in windows that you can display using:
 * ```
 * // Display the window to select fields
 * myComponent.showFieldsWindow(x, y, color)
 * 
 * // Display the window to sort data
 * myComponent.showSortWindow(x, y, color)
 * 
 * // Display the window to filter data
 * myComponent.showFilterWindow(x, y, color)
 * ```
 * 
 * A **DataComponent** can persist its configuration inside its associated record, using updateConfig method.
 * 
 * A **DataComponent** needs to be setup with either a collection or a model.
 * For this, you can directly pass the collection or the model in the config.
 * Or you can pass a collectionId or a modelId, and the component will retrieve the collection or the model from the ones declared in kiss.app.models and kiss.app.collections.
 * 
 * @param {object} config
 * @param {object} [config.collection] - Optional collection.
 * @param {string} [config.collectionId] - Optional collectionId.
 * @param {object} [config.model] - Optional model.
 * @param {string} [config.modelId] - Optional modelId.
 * @param {object} [config.record] - Optional record to persist the view configuration.
 * @returns {HTMLElement}
 */
kiss.ui.DataComponent = class DataComponent extends kiss.ui.Component {
	constructor() {
		super()
	}

	/**
	 *
	 * @param config
	 */
	init(config = {}) {
		super.init(config)

		if (config.collection) {
			// Case a) a Collection is given in the config
			// In that case, the Model is taken from the Collection definition
			this.collection = config.collection
			this.model = config.model || this.collection.model
			this.modelId = this.model.id
		} else if (config.model) {
			// Case b) a Model is given in the config
			// In that case, create and register a new Collection from the Model
			this.model = config.model
			this.modelId = this.model.id
			this.collection = new kiss.data.Collection({
				model: this.model,
				sort: [{
					[this.model.getPrimaryKeyField().id]: "asc" // Sort on the primary key field by default
				}]
			})
		} else if (config.collectionId) {
			// Case c) a CollectionId is given in the config
			// In that case, create and register a new Collection from the CollectionId
			this.collection = kiss.app.collections[config.collectionId]
			this.model = this.collection.model
			this.modelId = this.model.id
		}
		else if (config.modelId) {
			// Case d) a ModelId is given in the config
			// In that case, create and register a new Collection from the ModelId
			this.model = kiss.app.models[config.modelId]
			this.modelId = this.model.id
			this.collection = new kiss.data.Collection({
				model: this.model,
				sort: [{
					[this.model.getPrimaryKeyField().id]: "asc" // Sort on the primary key field by default
				}]
			})
		}

		this._initParameters()
		return this
	}

	/**
	 * Apply filter, sort, group, projection, groupUnwind.
	 * If a record is binded to persist the view configuration, we take the view parameters from this record.
	 * Priority is given to local config, then to the passed collection, then to default.
	 * 
	 * @private
	 * @ignore
	 * @returns this
	 */
	_initParameters() {
		if (this.config.record) {
			this.record = this.config.record
			this.id = this.config.id || this.record.id || kiss.tools.shortUid()
			this.name = this.record.name
			this.filterSyntax = this.record.filterSyntax || this.collection.filterSyntax || "normalized"
			this.filter = this.record.filter || this.collection.filter || {}
			this.sortSyntax = this.record.sortyntax || this.collection.sortyntax || "normalized"
			this.sort = this.record.sort || this.collection.sort || (this.sortSyntax === "normalized" ? [] : {})
			this.group = this.record.group || this.collection.group || []
			this.projection = this.record.projection || this.collection.projection || {}
			this.groupUnwind = this.record.groupUnwind || this.collection.groupUnwind || false
			this.canCreateRecord = (this.record.canCreateRecord !== false)
		} else {
			this.id = this.config.id || kiss.tools.shortUid()
			this.name = this.config.name
			this.filterSyntax = this.config.filterSyntax || this.collection.filterSyntax || "normalized"
			this.filter = this.config.filter || this.collection.filter || {}
			this.sortSyntax = this.config.sortyntax || this.collection.sortyntax || "normalized"
			this.sort = this.config.sort || this.collection.sort || (this.sortSyntax === "normalized" ? [] : {})
			this.group = this.config.group || this.collection.group || []
			this.projection = this.config.projection || this.collection.projection || {}
			this.groupUnwind = this.config.groupUnwind || this.collection.groupUnwind || false
			this.canCreateRecord = (this.config.canCreateRecord !== false)
		}

		// If the collection is not initialized, we initialize it with the current parameters
		this.collection.init({
			filterSyntax: this.filterSyntax,
			filter: this.filter,
			sortSyntax: this.sortSyntax,
			sort: this.sort,
			group: this.group,
			projection: this.projection,
			groupUnwind: this.groupUnwind
		})

		// Apply local configuration, if any
		this.localConfig = this.getLocalConfig()
		if (this.localConfig) {
			if (this.localConfig.filter) this.filter = this.localConfig.filter
			if (this.localConfig.sort) this.sort = this.localConfig.sort
			if (this.localConfig.group) this.group = this.localConfig.group
		}

		return this
	}

	/**
	 * show search bar immediately after being connected, if there is an active search
	 * 
	 * @private
	 * @ignore
	 */
	_afterConnected() {
		if (this.currentSearchTerm) {
			this.showSearchBar()
		} else {
			this.resetSearchBar()
		}
	}

	/**
	 * Hide search bar immediately after being disconnected
	 * 
	 * @private
	 * @ignore
	 */
	_afterDisconnected() {
		this.hideSearchBar()
	}

	/**
	 * Initialize subscriptions to PubSub
	 * 
	 * @private
	 * @ignore
	 */
	_initSubscriptions() {
		this.subscriptions = [
			// Local events (not coming from websocket)
			subscribe("EVT_VIEW_SORTING:" + this.id, (msgData) => this._dataSort(msgData)),
			subscribe("EVT_VIEW_FILTERING:" + this.id, (msgData) => this._dataFilterBy(msgData)),
			subscribe("EVT_VIEW_GROUPING:" + this.id, (msgData) => this._dataGroupBy(msgData)),

			subscribe("EVT_VIEW_FIELD_TOGGLED_ONE:" + this.id, (fieldId) => this._columnsToggleOne(fieldId)),
			subscribe("EVT_VIEW_FIELD_TOGGLED_ALL:" + this.id, (newState) => this._columnsToggleAll(newState)),
			subscribe("EVT_VIEW_FIELD_MOVING:" + this.id, (msgData) => this._columnsMove(msgData.sourceFieldId, msgData.targetFieldId, msgData.position)),

			/*
             * View changes must be propagated to all **other** connected clients.
             * View changes come from various events, like:
             * 
             * I) Model updates
             * - field add
             * - field update
             * - field delete
             * 
             * II) View updates
             * - filter
             * - sort
             * - group
             * - projection (not implemented yet: all columns are loaded then hidden/shown)
             * - move column
             * - show/hide column
             * - show/hide record creation button
             * - ACL
             */
			subscribe("EVT_DB_UPDATE:VIEW", (msgData) => {
				if (msgData.id != this.id) return

				// Update the component setup
				if (msgData.data.filter) this.filter = msgData.data.filter
				if (msgData.data.sort) this.sort = msgData.data.sort
				if (msgData.data.group) this.group = msgData.data.group
				if (msgData.data.projection) this.projection = msgData.data.projection
                
				// Update the component ACL
				if (msgData.data.hasOwnProperty("authenticatedCanRead") && this.record) this.record.authenticatedCanRead = msgData.data.authenticatedCanRead
				if (msgData.data.hasOwnProperty("accessRead") && this.record) this.record.accessRead = msgData.data.accessRead
				if (msgData.data.hasOwnProperty("authenticatedCanReadDetails") && this.record) this.record.authenticatedCanReadDetails = msgData.data.authenticatedCanReadDetails
				if (msgData.data.hasOwnProperty("accessReadDetails") && this.record) this.record.accessReadDetails = msgData.data.accessReadDetails

				// Update the component toolbar
				if (msgData.data.hasOwnProperty("canCreateRecord")) {
					this.canCreateRecord = !!msgData.data.canCreateRecord
					this._updateToolbar()
				}

				// Will force the collection to reload for the next find() request
				this.collection.hasChanged = true

				// Re-compute columns (a combination of existing views columns, model fields, and plugin fields)
				// Patch columns in the record or locally
				if (msgData.data.config && msgData.data.config.columns) {
					if (this.record) {
						this.record.config.columns = msgData.data.config.columns
					} else {
						this.config.columns = msgData.data.config.columns
					}
					this._initColumns()

					// If we only changed the columns, it's not necessary to reload the collection
					this.collection.hasChanged = false
				}
			}),

			// TODO: Update the view => mostly used for ACL change, but redundant most of the time with EVT_DB_UPDATE:VIEW
			// TODO: Remove this and add an ACL change event (less generic than a model update)
			subscribe("EVT_DB_UPDATE:MODEL", async (msgData) => {
				if (msgData.id == this.model.id) {
					// Re-check create/delete permissions when model ACL changes
					const aclFields = ["authenticatedCanCreate", "accessCreate", "authenticatedCanDelete", "accessDelete"]
					const hasAclChange = aclFields.some(field => msgData.data.hasOwnProperty(field))

					if (hasAclChange) {
						const fakeRecord = this.model.create()

						// Update create button
						const canCreate = await kiss.acl.check({action: "create", record: fakeRecord})
						this.canCreateRecord = (canCreate !== false)
						this._updateToolbar()

						// Update delete action
						const canDelete = await kiss.acl.check({action: "delete", record: fakeRecord})
						this._updateDeleteAction(canDelete)
					}

					this.reload()
				}
			}),

			// When records are deleted, we need to remove them from the view selection
			subscribe("EVT_DB_DELETE:" + this.model.id.toUpperCase(), (msgData) => {
				kiss.selection.delete(this.id, msgData.id)
			}),

			subscribe("EVT_DB_DELETE_MANY:" + this.model.id.toUpperCase(), (msgData) => {
				const filter = msgData.data
				if (filter && filter._id && filter._id.$in) {
					const ids = filter._id.$in
					ids.forEach(id => kiss.selection.delete(this.id, id))
				}
			})
		]
	}

	/**
	 * Reload the view when needed.
	 * 
	 * It depends:
	 * - if the view is connected to the DOM
	 * - if the update has been done by the active user
	 * 
	 * @private
	 * @ignore
	 * @param {object} msgData - The original pubsub message
	 * @param {number} [delay] - Delay to retard the reload, when the back-end update needs time
	 */
	async _reloadWhenNeeded(msgData, delay) {
		// If the datatable exists but is not connected, it means it's in the cache.
		// We can't reload it, but we put a flag on it so it will be reloaded when displayed again
		if (!this.isConnected) {
			this.hasChanged = true
			return
		}

		// Reload the view only if the application global state allows to refresh the view
		// For example, this is forbidden while batch updates
		if (kiss.global.preventViewRefresh) {
			return
		}

		// Reload the view only under certain conditions:
		// - if the user is the author of the updates
		// - if the event is a deletion
		let shouldReload = false
		if (kiss.session.getUserId() == msgData.userId) shouldReload = true
		if (msgData.channel.startsWith("EVT_DB_DELETE")) shouldReload = true
		
		if (shouldReload) {
			if (delay) await kiss.tools.wait(delay)
			await this.reload()
		}
		else {
			this._showRefreshNotification()
		}
	}

	/**
	 * Show a red dot on the "Refresh" button of the view when there are updates
	 * 
	 * @private
	 * @ignore
	 */
	_showRefreshNotification(msgData) {
		const btn = $("refresh:" + this.id)
		if (!btn) return

		const notif = btn.querySelector(".notif")
		if (notif) return

		if (!btn.firstChild.querySelector(".notif")) {
			const n = document.createElement("div")
			n.className = "notif"

			Object.assign(n.style, {
				position: "absolute",
				top: "0px",
				right: "0px",
				width: "10px",
				height: "10px",
				background: "red",
				borderRadius: "50%",
				pointerEvents: "none"
			})

			btn.style.position = "relative"
			btn.appendChild(n)

			n.setAnimation({
				name: "shakeX",
				speed: "fast"
			})
		}		
	}

	/**
	 * Hide the red dot on the "Refresh" button of the view
	 * 
	 * @private
	 * @ignore
	 */
	_hideRefreshNotification() {
		const btn = $("refresh:" + this.id)
		if (!btn) return

		const n = btn.querySelector(".notif")
		if (n) n.remove()
	}

	/**
	 * 
	 * DATA SORT MANAGEMENT
	 * 
	 */

	/**
	 * Sort by an array of fields
	 * 
	 * @async
	 * @param {object[]} sortFields - Array where each object is a sort option, like: {firstName: "asc"}
	 * 
	 * @example
	 * myDatatable.sortBy([
	 *  {
	 *      birthDate: "desc"
	 *  },
	 *  {
	 *      lastName: "asc"
	 *  }
	 * ])
	 */
	async sortBy(sortFields) {
		this.sort = sortFields
		await this._dataSortUpdate()
	}

	/**
	 * Sort by a single field
	 * 
	 * @async
	 * @param {string} fieldId 
	 * @param {string} direction - "asc" | "desc"
	 * 
	 * @example
	 * myDatatable.sortByField("birthDate", "desc")
	 */
	async sortByField(fieldId, direction) {
		const currentSortFields = this.sort
		let isSortUpdated = false

		currentSortFields.forEach(sortField => {
			if (Object.keys(sortField)[0] == fieldId) {
				sortField[fieldId] = direction
				isSortUpdated = true
			}
		})

		if (!isSortUpdated) {
			currentSortFields.push({
				[fieldId]: direction
			})
		}

		await this.sortBy(currentSortFields)
	}

	/**
	 * Update the sort according to the message received in the PubSub
	 * 
	 * @private
	 * @ignore
	 * @param {object} msgData 
	 */
	async _dataSort(msgData) {
		if (msgData.sortAction == "remove") {
			await this._dataSortRemove(msgData.sortIndex)
		} else {
			await this._dataSortBy(msgData.sortFieldName, msgData.sortDirection, msgData.sortIndex)
		}
	}

	/**
	 * Sort the table by a specific field.
	 * If the field hasn't been used yet to sort the datatable, then a new sort option is added.
	 * If the field has already been used to sort the datatable, then the sort option is updated.
	 * 
	 * @private
	 * @ignore
	 * @param {string} fieldId - Field used to sort the datatable
	 * @param {string} sortOrder - "asc" or "desc"
	 * @param {number} sortIndex - position of the sort option to add/update
	 */
	async _dataSortBy(fieldId, sortOrder, sortIndex) {
		let newSortOption = {}
		newSortOption[fieldId] = sortOrder

		if (this.sort.length == 0) {
			this.sort.push(newSortOption)
		} else {
			this.sort[sortIndex] = newSortOption
		}

		await this._dataSortUpdate()
	}

	/**
	 * Remove one of the sort options
	 * 
	 * @private
	 * @ignore
	 * @param {number} sortIndex - Index of the sort option to remove
	 */
	async _dataSortRemove(sortIndex) {
		this.sort.splice(sortIndex, 1)
		await this._dataSortUpdate()
	}

	/**
	 * Update the sort options
	 * 
	 * @private
	 * @ignore
	 */
	async _dataSortUpdate() {
		// Sort view data with new sort params
		await this.collection.sortBy(this.sort)
		this._render()

		// Save new sort options
		await this.updateConfig({
			sort: this.sort
		})

		// Broadcast changes for local and offline, so that "dataSortWindow" can be updated (check dataSortWindow.js)
		kiss.pubsub.publish("EVT_VIEW_SORTED:" + this.id)

		// Broadcast changes for the parent dashboard, if any
		if (this.dashboard) kiss.pubsub.publish("EVT_DASHBOARD_SETUP", this.id)
	}

	/**
	 * 
	 * DATA GROUPING MANAGEMENT
	 * 
	 */

	/**
	 * Group by a list of fields
	 * 
	 * @param {string[]} groupFields - List of field names (not ids)
	 * 
	 * @param groupFieldIds
	 * @example
	 * myDatatable.groupBy(["Country", "City", "Age"])
	 */
	groupBy(groupFieldIds) {
		$("grouping-field:" + this.id).setValue(groupFieldIds)
	}

	/**
	 * Group data by a list of fields
	 * 
	 * @private
	 * @ignore
	 * @param {string[]} groupFields - Array of fields to group by.
	 */
	async _dataGroupBy(groupFields) {
		// Generates the groups, then get the grouped records
		this.skip = 0
		await this.collection.groupBy(groupFields)
		this._render()

		// Show / hide:
		// - Expand and Collapse buttons
		// - Switch hierarchy button
		if (groupFields.length === 0) {
			this.buttonExpand.hide()
			this.buttonCollapse.hide()
		} else {
			this.buttonExpand.show()
			this.buttonCollapse.show()
		}

		// Save the new group config
		this.group = groupFields
		await this.updateConfig({
			group: this.group
		})
	}

	/**
	 * Expand / Collapse a group
	 * 
	 * @private
	 * @ignore
	 * @param {string} groupId - Id of the group to expand/collapse. Example: 3.10.7
	 * @param {number} rowIndex - Index of the group row into the datatable
	 */
	_groupToggle(groupId, groupLevel, rowIndex) {
		if (this.collection.collapsedGroups.includes(groupId)) {
			this._groupExpand(groupId, rowIndex)
		} else {
			this._groupCollapse(groupId)
		}
	}

	/**
	 * Expand a group
	 * 
	 * @private
	 * @ignore
	 * @param {string} groupId - Id of the group to expand/collapse. Example: 3.10.7
	 * @param {number} rowIndex - Index of the group row into the datatable
	 */
	_groupExpand(groupId, rowIndex) {
		this.collection.groupExpand(groupId, rowIndex)
		this._render()
		this._renderScroller()
	}

	/**
	 * Collapse a group
	 * 
	 * @private
	 * @ignore
	 * @param {string} groupId - Id of the group to expand/collapse. Example: 3.10.7
	 */
	_groupCollapse(groupId) {
		this.collection.groupCollapse(groupId)
		this._render()
	}

	/**
	 * Expand all groups
	 * 
	 * @ignore
	 */
	expandAll() {
		this.collection.groupExpandAll()
		this._render()
	}

	/**
	 * Collapse all groups
	 * 
	 * @ignore
	 */
	collapseAll() {
		this.skip = 0
		this.collection.groupCollapseAll()
		this._render()
	}    

	/**
	 * Update the list of grouping fields that appear in the "Group by" field
	 * 
	 * @private
	 * @ignore
	 */
	_groupUpdateGroupingFields() {
		if (!this.isConnected) return

		const groupingField = $("grouping-field:" + this.id)
		const modelFields = this._groupGetModelFields()

		groupingField.value = this.group
		groupingField.updateOptions(modelFields)
	}

	/**
	 * Get the list of grouping fields that appear in the "Group by" field
	 * 
	 * @private
	 * @ignore
	 * @param {object} config
	 * @param {boolean} config.excludeSystemFields - Exclude system fields from the list. Default to false
	 * @param {boolean} config.excludePluginFields - Exclude plugin fields from the list. Default to false
	 * @param {boolean} config.excludeMultiValueFields - Exclude multi-value fields from the list. Default to false
	 */
	_groupGetModelFields(config = {}) {
		const isDynamicModel = kiss.tools.isUid(this.model.id)
		const excludedFields = ["password", "link", "attachment", "aiImage", "textarea", "aiTextarea", "richTextField", "codeEditor", "mapField"]
        
		let modelFields = this.model.fields.filter(field => !excludedFields.includes(field.type) && field.label && field.deleted != true)

		if (config.excludeSystemFields) modelFields = modelFields.filter(field => !field.isSystem)
		if (config.excludePluginFields) modelFields = modelFields.filter(field => !field.isFromPlugin)
		if (config.excludeMultiValueFields) modelFields = modelFields.filter(field => !field.multiple == true)

		return modelFields.map(field => {
			return {
				value: field.id,
				label: (isDynamicModel && !field.isSystem) ? field.label.toTitleCase() : txtTitleCase(field.label)
			}
		})
	}

	/**
	 * Update the filter
	 * 
	 * @private
	 * @ignore
	 * @param {object} filterConfig 
	 */
	async _dataFilterBy(filterConfig) {
		// Reset ftsearch
		this.resetSearchBar()
        
		// Save the new filter config
		this.filter = filterConfig
		await this.updateConfig({
			filter: this.filter
		})
        
		// Filter view data with new filter params
		this.skip = 0
		await this.collection.filterBy(filterConfig)
		this._render()

		// Broadcast changes for the parent dashboard, if any
		if (this.dashboard) kiss.pubsub.publish("EVT_DASHBOARD_SETUP", this.id)
	}

	/**
	 * Update view configuration:
	 * - while offline, just reload
	 * - while online, check ACL prior to updating
	 * - if ACL check is successful, save the new configuration into db
	 * 
	 * @param {object} update - The new configuration
	 * @param {boolean} [needsDataReload] - If false, the data won't be reloaded. Default to true
	 * @async
	 */
	async updateConfig(update, needsDataReload = true) {
		try {

			// Prevents the data from being reloaded if it doesn't need to
			if (needsDataReload === false) {
				this.collection.hasChanged = false
			}

			// If the view is not persisted into a record,
			// we just reload the view locally and we don't save the updates permanently
			if (!this.record) {
				this.updateLocalConfig(update)
				this.reload()
				return
			}

			// If the user has insufficient access to update the view configration,
			// we just reload the view locally and we don't save the updates permanently
			const canUpdate = await kiss.acl.check({
				action: "update",
				record: this.record
			})

			if (!canUpdate) {
				this.updateLocalConfig(update)
				this.reload()
				return
			}

			// The user has sufficient access: the view configuration is updated permanently
			const newConfig = this._buildConfig(this.record, update)
			await this.record.update(newConfig)

		} catch (err) {
			log("kiss.ui - dataComponent - Didn't save new view config", 4, err)
		}
	}

	/**
	 * Reload the component's data and re-render it
	 */
	async reload() {
		if (!this.isConnected) return
		if (this.columns) this._initColumns()

		await this.load()

		this._render()
		this._hideRefreshNotification()
	}

	/**
	 * When the view configuration can't be persisted into db,
	 * we tried to store its parameters in the local storage.
	 * 
	 * @param {object} update 
	 */
	updateLocalConfig(update) {
		let currentConfig = this.getLocalConfig() || {}
		let newConfig = this._buildConfig(currentConfig, update)
		Object.assign(currentConfig, newConfig)
		const storageId = "config-view-" + this.id
		localStorage.setItem(storageId, JSON.stringify(currentConfig))
	}

	/**
	 * Build a new configuration by keeping only what is updated.
	 * 
	 * A dataComponent has basically 4 properties: sort, filter, group, config.
	 * Sort, filter and group are generic properties for every dataComponent, while config is specific to the view.
	 * For this, we need to merge the new config with the current one, and keep only the updated properties.
	 * 
	 * @private
	 * @ignore
	 * @param {object} record 
	 * @param {object} update 
	 * @returns {object} The new configuration, with only the updated properties
	 */
	_buildConfig(record, update) {
		let config = {}
		if (update.hasOwnProperty("name")) config.name = update.name
		if (update.hasOwnProperty("sort")) config.sort = update.sort
		if (update.hasOwnProperty("filter")) config.filter = update.filter
		if (update.hasOwnProperty("group")) config.group = update.group
		if (update.hasOwnProperty("config")) {
			let newConfig = record.config || {}
			Object.assign(newConfig, update.config)
			config.config = newConfig
		}
		return config
	}
   
	/**
	 * When a view configuration can't be persisted into db,
	 * it can be stored and retrieved from the local storage
	 * 
	 * @returns {object} The view configuration stored locally
	 */
	getLocalConfig() {
		const storageId = "config-view-" + this.id
		const localConfig = localStorage.getItem(storageId)
		if (!localConfig) return false

		let setup = JSON.parse(localConfig)

		// Clean local config columns according to the model's fields
		if (setup.config && setup.config.columns && Array.isArray(setup.config.columns)) {
            
			// For each model's field, update the corresponding column
			this.model.getFields().forEach(field => {
				let column = setup.config.columns.get(field.id)

				if (column) {
					// The column exists: we udpate it
					column.type = this.model.getFieldType(field)
					column.title = field.label || field.id
					if (column.title.startsWith("#")) column.title = txtTitleCase(column.title)
					column.title = column.title.toTitleCase()
					column.deleted = !!field.deleted
				} else {
					// The column doesn't exist: we add it
					if (field.label && field.type && !field.deleted) {
						setup.config.columns.push({
							id: field.id,
							type: this.model.getFieldType(field),
							title: field.label.toTitleCase(),
							hidden: (field.type == "link") ? true : false
						})
					}
				}
			})
		}

		// Filters out deleted fields from sorts
		if (setup.sort) {
			const sortableFields = this.model.getSortableFields().map(field => field.id)
			setup.sort = setup.sort.filter(sort => sortableFields.includes(Object.keys(sort)[0]))
		}

		// Filters out deleted fields from groups
		if (setup.group) {
			const groupableFields = this.model.getGroupableFields().map(field => field.id)
			setup.group = setup.group.filter(fieldId => groupableFields.includes(fieldId))
		}

		return setup
	}

	/**
	 * Reset all local component parameters:
	 * - collection configurations (sort, filter, group)
	 * - columns configuration (visibility, width, colors, aggregation)
	 * 
	 * When the component's configuration is persisted into local storage,
	 * it's useful to be able to reset it
	 * 
	 * @returns this
	 */
	async resetLocalViewParameters() {
		// Reset local storage
		const storageId = "config-view-" + this.id
		localStorage.removeItem(storageId)

		// Get last version of the record that holds the config
		if (this.record) await this.record.read()

		// Reset the columns, if any
		if (this.columns) this.resetColumnsWidth()

		// Restore base component parameters (filter, sort, group...)
		this._initParameters()

		// Reload data and render
		this.collection.hasChanged = true
		await this.reload()
		return this
	}

	/**
	 * Reload the toolbar, if any
	 * In the generic case, the toolbar is a set of buttons that can be hidden or shown according to its settings.
	 * For now, we only manage the "create" button.
	 */
	async _updateToolbar() {
		const createButton = this.querySelector("." + this.type + "-create-record")
		if (!createButton) return

		if (this.canCreateRecord === false) {
			createButton.hide()
		}
		else {
			createButton.show()
		}
	}

	/**
	 * Add or remove the delete action from the actions menu based on permissions.
	 * The delete action is identified by its id "delete-{viewId}".
	 *
	 * @param {boolean} canDelete - Whether the user can delete records
	 */
	_updateDeleteAction(canDelete) {
		if (!this.actions) return

		const deleteActionId = "delete-" + this.id
		const deleteIndex = this.actions.findIndex(action => action && action.id === deleteActionId)

		if (canDelete && deleteIndex === -1) {
			// Add the delete action
			this.actions.push("-")
			this.actions.push({
				id: deleteActionId,
				text: txtTitleCase("delete selected documents"),
				icon: "fas fa-trash",
				iconColor: "var(--red)",
				action: () => kiss.selection.deleteSelectedRecords()
			})
		} else if (!canDelete && deleteIndex !== -1) {
			// Remove the delete action and its separator
			if (deleteIndex > 0 && this.actions[deleteIndex - 1] === "-") {
				this.actions.splice(deleteIndex - 1, 2)
			} else {
				this.actions.splice(deleteIndex, 1)
			}
		}
	}

	/**
	 *
	 * COLUMNS MANAGEMENT
	 * 
	 */

	/**
	 * Initialize (or reset) the view columns.
	 * If no config is provided, the columns are auto-generated according to the model.
	 * 
	 * @private
	 * @ignore
	 * @param {object[]} columns - Array of column configurations
	 * @returns this
	 */
	_initColumns(columns) {
		if (columns) {
			this.columns = columns
		} else {
			this.columns = (this.record) ? this.record.config.columns : this.config.columns
			const localConfig = this.getLocalConfig()
			if (localConfig && localConfig.config && localConfig.config.columns) this.columns = localConfig.config.columns
		}

		if (this.columns) {
			// A config is provided
			// Filters out the columns which field doesn't exist anymore in the model
			this.columns = this.columns.filter(column => {
				if (column.type == "button") return true

				const field = this.model.getField(column.id)
				if (field && !field.deleted) {
					return true
				}
				return false
			})

			this._appendMissingColumns()

		} else {
			// No config provided
			// Auto-configure the columns from the model
			this.columns = this.model.getFieldsAsColumns()
		}

		// Apply ACL and hide unwanted columns
		this.columns = this.columns.filter(column => {
			if (column.type == "button") return true
			if (column.type == "link" && this.config.showLinks === false) return false

			const field = this.model.getField(column.id)
			if (field && field.acl && field.acl.read === false) {
				return false
			}
			return true
		})

		// - Apply renderers, if any
		// - Reset the column title
		// - Adjust the column width depending on the unit
		this.columns.forEach(column => {
			if (!column.id) return
            
			const field = this.model.getField(column.id)
			if (!field) return
            
			column.title = this.model.getFieldLabel(field)

			if (field && field.valueRenderer) {
				column.renderer = field.valueRenderer
			}

			if (column.widthUnit != "rem") {
				column.widthUnit = "rem"
				column.width = kiss.tools.pxToRem(column.width)
			}
		})

		return this
	}

	/**
	 * Sync view columns with model fields and extra plugin fields.
	 * 
	 * Context: a view has a set of columns representing the model's fields.
	 * Then, a plugin is enabled on the model, and this plugin adds some extra fields to the model.
	 * The view columns must reflect these extra fields.
	 * This function checks the missings fields and creates a new column for each missing field.
	 * 
	 * @private
	 * @ignore
	 */
	_appendMissingColumns() {
		// Adds the model fields which are not inside the saved list of columns
		const fieldIds = this.model.fields.filter(field => !field.deleted).map(field => field.id)
		const columnIds = this.columns.filter(column => !column.deleted).map(column => column.id)
		const missingIds = fieldIds.filter(item => !columnIds.includes(item))

		if (missingIds.length > 0) {
			missingIds.forEach(fieldId => {
				const field = this.model.getField(fieldId)
				if (!field.label) return

				let columnConfig = {
					id: field.id,
					type: this.model.getFieldType(field),
					title: field.label
				}

				// Plugin columns
				if (field.isFromPlugin) {
					columnConfig.isFromPlugin = true
					columnConfig.pluginId = field.pluginId
					columnConfig.title = txtTitleCase(field.label)
					columnConfig.hidden = true
				}

				// System columns
				if (field.isSystem) {
					columnConfig.isSystem = true
					columnConfig.title = txtTitleCase(field.label)
					columnConfig.hidden = (field.hidden === true)
				}

				this.columns.push(columnConfig)
			})
		}
	}

	/**
	 * Get the view columns
	 * 
	 * @returns {object[]} Array of column definitions
	 */
	getColumns() {
		return this.columns
	}

	/**
	 * Get the view fields.
	 * 
	 * Note: in a some view (like datatables), fields are the same thing as columns.
	 * 
	 * @returns {object[]} Array of column definitions
	 */
	getFields() {
		return this.columns
	}

	/**
	 * Toggle one column on/off
	 * 
	 * @private
	 * @ignore
	 * @param {string} columnId - Id of the column to show/hide
	 */
	_columnsToggleOne(columnId) {
		this._columnsToggle(columnId)
		this._render()
		this.updateConfig({
			config: {
				columns: this.columns
			}
		})
	}

	/**
	 * Switch on / off all the columns at the same time
	 * 
	 * @private
	 * @ignore
	 * @param {string} newState - hide|show
	 */
	_columnsToggleAll(newState) {
		let hidden = (newState == "hide")
		this.columns.forEach(column => this._columnsToggle(column.id, hidden))
		this._render()
		this.updateConfig({
			config: {
				columns: this.columns
			}
		})
	}

	/**
	 * Toggle a column to be hidden/visible
	 * 
	 * @private
	 * @ignore
	 * @param {string} columnId - Id of the column to show/hide
	 * @param {boolean} [hidden] - Force the column state to be hidden or visible
	 * @returns this
	 */
	_columnsToggle(columnId, hidden = null) {
		let columnIndex = this.columns.findIndex(column => column.id == columnId)
		let newState = (hidden != null) ? hidden : !this.columns[columnIndex].hidden
		this.columns[columnIndex].hidden = newState
		return this
	}

	/**
	 * Check if there are hidden columns
	 * 
	 * @private
	 * @ignore
	 * @returns {boolean}
	 */
	_columnsHasHiddenColumns() {
		let hasHiddenColumns = false
		this.columns.forEach(column => {
			if (column.hidden == true) hasHiddenColumns = true
		})
		return hasHiddenColumns
	}

	/**
	 * Move a "source" column before /after a "target" column
	 * 
	 * @private
	 * @ignore
	 * @param {string} sourceColumnId - Source column id
	 * @param {string} targetColumnId - Target column id
	 * @param {string} position - "before" or "after"
	 */
	async _columnsMove(sourceColumnId, targetColumnId, position) {
		let currentColumn = this.getColumn(sourceColumnId)
		let currentColumnIndex = this.columns.findIndex(column => column.id == sourceColumnId)

		let newColumns = this.columns.filter(column => column.id != currentColumn.id)
		let targetColumnIndex = newColumns.findIndex(column => column.id == targetColumnId)

		// Same position? => Exit!
		let adjustPositionIndex = (position == "before") ? 0 : 1
		if ((targetColumnIndex == -1) || (currentColumnIndex == (targetColumnIndex + adjustPositionIndex))) return

		// Update column config
		newColumns.splice(targetColumnIndex + adjustPositionIndex, 0, currentColumn)

		await this.updateConfig({
			config: {
				columns: newColumns
			}
		})

		this.columns = newColumns
		this._render()

		// Broadcast changes for local and offline, so that "dataFieldsWindow" can be updated (check dataFieldsWindow.js)
		kiss.pubsub.publish("EVT_VIEW_FIELD_MOVED:" + this.id)
	}

	/**
	 * Get the column config used to display a field
	 * 
	 * @param {string} fieldId 
	 * @returns {object} The column config
	 */
	getColumn(fieldId) {
		return this.columns.find(column => column.id == fieldId)
	}

	/**
	 * 
	 * SELECT FIELDS, SORT, FILTER options
	 * 
	 */

	/**
	 * Show a modal window to select / deselect fields
	 * 
	 * @param {number} [x] - x coordinate
	 * @param {number} [Y] - y coordinate
	 * @param y
	 * @param {string} [color] - Window color, in hexa: "#00aaee"
	 */
	showFieldsWindow(x, y, color = "#00aaee") {
		const selectionWindow = createDataFieldsWindow(this.id, color)
		this.selectFieldWindowId = selectionWindow.id

		if (!y || !x) {
			selectionWindow.top = () => kiss.screen.current.height / 3 - selectionWindow.offsetHeight / 2
			selectionWindow.left = () => kiss.screen.current.width / 2 - selectionWindow.offsetWidth / 2
			selectionWindow.render()
		} else {
			selectionWindow.showAt(x, y).render()
		}
	}

	/**
	 * Show a modal window to sort data
	 * 
	 * @param {number} [x] - x coordinate
	 * @param {number} [Y] - y coordinate
	 * @param y
	 * @param {string} [color] - Window color, in hexa: "#00aaee"
	 */
	showSortWindow(x, y, color = "#00aaee") {
		const sortWindow = createDataSortWindow(this.id, color)
		this.sortWindowId = sortWindow.id

		if (!y || !x) {
			sortWindow.top = () => kiss.screen.current.height / 3 - sortWindow.offsetHeight / 2
			sortWindow.left = () => kiss.screen.current.width / 2 - sortWindow.offsetWidth / 2
			sortWindow.render()
		} else {
			sortWindow.render().showAt(x, y)
		}
	}

	/**
	 * Show a modal window to filter data
	 * 
	 * @param {number} [x] - x coordinate
	 * @param {number} [Y] - y coordinate
	 * @param y
	 * @param {string} [color] - Window color, in hexa: "#00aaee"
	 */
	showFilterWindow(x, y, color = "#00aaee") {
		const filterWindow = createDataFilterWindow(this.id, color)
		this.filterWindowId = filterWindow.id

		if (!y || !x) {
			filterWindow.top = () => kiss.screen.current.height / 3 - filterWindow.offsetHeight / 2
			filterWindow.left = () => kiss.screen.current.width / 2 - filterWindow.offsetWidth / 2
			filterWindow.render()
		} else {
			filterWindow.render().showAt(x, y)
		}
	}

	/**
	 * 
	 * SEARCH MANAGEMENT
	 * 
	 */

	/**
	 * Show the search bar
	 */
	showSearchBar() {
		if (kiss.screen.isMobile) {
			return this.showMobileSearchBar()
		}

		if ($("search-bar-" + this.id)) return

		const id = this.id
		const searchButton = $("search:" + id)
		const searchButtonTop = searchButton.getBoundingClientRect().top

		this.searchBar = createPanel({
			id: "search-bar-" + id,
			title: txtTitleCase("#ftsearch title"),
			icon: "fas fa-search",
			headerStyle: "flat",
			draggable: true,
			closable: false,
			autoSize: true,
			opacity: 0.8,
			zIndex: 2,

			top: () => {
				if (!searchButton) return 0
				return searchButtonTop + 40
			},
			left: "calc(100vw - 30rem)",

			position: "absolute",
			width: "28rem",
			height: "10rem",
			layout: "horizontal",
			alignItems: "center",

			animation: {
				name: "zoomIn",
				speed: "faster"
			},

			items: [
				// Input field to enter search term
				{
					id: "search-term-" + id,
					type: "text",
					fieldWidth: "22rem",
					borderColor: "var(--body-1)",
					autocomplete: "off",
					events: {
						keydown: function (event) {
							let view = $(id)
							if (!view) return

							if (this.getValue() == view.currentSearchTerm) return

							if (event.key == "Enter") {
								view.currentSearchTerm = this.getValue()
								view.ftsearch(view.currentSearchTerm)
							}
						}
					}
				},
				// Button to close the search bar
				{
					type: "button",
					icon: "fas fa-times",
					width: "3rem",
					height: "3rem",
					action: async () => this.closeSearchBar()
				}
			],

			methods: {
				// Focus on the search term field
				_afterRender() {
					setTimeout(() => {
						if ($("search-term-" + id)) $("search-term-" + id).focus()
						this.restoreSearchTerm()
					}, 100)
				},
				restoreSearchTerm: () => {
					if ($("search-term-" + id)) $("search-term-" + id).setValue(this.currentSearchTerm || "")
				}
			}
		}).render()
	}

	/**
	 * Show the mobile search bar
	 */
	showMobileSearchBar() {
		if ($("search-term-" + this.id)) return

		this.switchToSearchMode()
		const id = this.id

		this.searchBar = createBlock({
			target: "search-field:" + id,
			layout: "horizontal",
			alignItems: "center",

			animation: {
				name: "slideInLeft",
				speed: "faster"
			},

			items: [
				// Button to close the search bar
				{
					type: "button",
					icon: "fas fa-times",
					width: "3rem",
					height: "3rem",
					action: async () => this.closeSearchBar()
				},
				// Input field to enter search term
				{
					id: "search-term-" + id,
					type: "text",
					placeholder: txtTitleCase("search"),
					borderColor: "var(--body-1)",
					autocomplete: "off",
					flex: 1,
					fieldWidth: "100%",
					events: {
						keydown: function (event) {
							let view = $(id)
							if (!view) return

							if (this.getValue() == view.currentSearchTerm) return

							if (event.key == "Enter") {
								view.currentSearchTerm = this.getValue()
								view.ftsearch(view.currentSearchTerm)
							}
						}
					},
					methods: {
						// Focus on the search term field
						_afterRender() {
							setTimeout(() => {
								if ($("search-term-" + id)) $("search-term-" + id).focus()
								this.restoreSearchTerm()
							}, 100)
						},
						restoreSearchTerm: () => {
							if ($("search-term-" + id)) $("search-term-" + id).setValue(this.currentSearchTerm || "")
						}
					}

				}
			]
		}).render()
	}

	/**
	 * Reset the search made from the search bar
	 */
	hideSearchBar() {
		if (this.searchBar) {
			if (kiss.screen.isMobile)
				this.searchBar.remove()
			else
				this.searchBar.close()
		}
	}

	/**
	 * Close the search bar and reset the search term.
	 */
	async closeSearchBar() {
		if (this.currentSearchTerm !== undefined && this.currentSearchTerm !== "") {
			this.skip = 0

			await this.collection.find({
				filterSyntax: this.filterSyntax,
				filter: this.filter,
				sortSyntax: this.sortSyntax,
				sort: this.sort,
				group: this.group,
				projection: this.projection,
				groupUnwind: this.groupUnwind
			}, true)

			this._render()
		}

		this.resetSearchBar()
	}

	/**
	 * Reset the search made from the search bar
	 */
	resetSearchBar() {
		this.currentSearchTerm = ""
		this.hideSearchBar()
		if (this.resetSearchMode) this.resetSearchMode()
	}

	/**
	 * Full-text search on all text fields
	 * 
	 * @param {string} value 
	 */
	async ftsearch(value) {
		const searchFilter = this.createSearchFilter(value)
		this.skip = 0

		await this.collection.find({
			filterSyntax: this.filterSyntax,
			filter: searchFilter,
			sortSyntax: this.sortSyntax,
			sort: this.sort,
			group: this.group,
			projection: this.projection,
			groupUnwind: this.groupUnwind
		}, true)

		this._render()
	}

	/**
	 * Create a filter that appends the search term to the existing filters
	 * 
	 * @param {string} value 
	 * @returns {object} The filter configuration
	 */
	createSearchFilter(value) {
		const model = this.model || this.collection.model
		const textFields = model.getFieldsByType(["text", "textarea", "aiTextarea", "richTextField", "select", "selectViewColumn", "selectViewColumns", "date", "lookup", "directory", "map"])

		const textFilters = textFields.map(field => {
			return {
				type: "filter",
				fieldId: field.id,
				fieldLabel: field.label,
				operator: "contains",
				value
			}
		})

		return {
			type: "group",
			operator: "and",
			filters: [{
				type: "group",
				operator: "or",
				filters: textFilters
			},
			this.filter
			]
		}
	}

	/**
	 * 
	 * SELECTION MANAGEMENT
	 * 
	 */

	/**
	 * Show a single record (passing its id)
	 * 
	 * @param {string} recordId - id of the record to show
	 */
	async selectRecordById(recordId) {
		let record = await this.collection.findOne(recordId)
		await this.selectRecord(record)
	}

	/**
	 * Show a single record
	 * 
	 * Important: this method can be overriden in the instanced component
	 * 
	 * @async
	 * @param {object} record - Record to show
	 */
	async selectRecord(record) {
		createForm(record)
	}

	/**
	 * Open the form to create a new record
	 * 
	 * Important: this method can be overriden in the instanced component
	 * 
	 * @async
	 * @param {object} model - Model to create the record
	 */
	async createRecord(model) {
		const newRecord = model.create()
		await newRecord.save()
		createForm(newRecord)
	}

	/**
	 * 
	 * SELECTION MANAGEMENT
	 * 
	 */

	/**
	 * Get the list of ids of selected records
	 * 
	 * @returns {string[]} The list of selected record ids
	 */
	getSelection() {
		this.selectedRecords = kiss.selection.get(this.id)
		return this.selectedRecords
	}

	/**
	 * Get the list of selected records
	 * 
	 * @returns {array} Array of records
	 */
	getSelectedRecords() {
		let records = []
		for (let id of this.getSelection()) records.push(this.collection.getRecord(id))
		return records
	}

	/**
	 * Get the next record after the provided one
	 *
	 * @param {object} record - The current record
	 * @param index
	 * @returns {object|null} The next record, or null if not found
	 */
	getNextRecord(record, index = null) {
		const currentIndex = index || this.collection.records.findIndex(r => r.id == record.id)
		if (currentIndex >= this.collection.records.length - 1) return null

		const nextRecord = this.collection.records[currentIndex + 1]

		if (nextRecord && nextRecord.$type != "group") {
			return nextRecord
		}
		else if (nextRecord && nextRecord.$type == "group") {
			// Skip group rows
			return this.getNextRecord(null, currentIndex + 1)
		}
		return null
	}

	/**
	 * Get the previous record before the provided one
	 *
	 * @param {object} record
	 * @param index
	 * @returns {object|null} The previous record, or null if not found
	 */
	getPreviousRecord(record, index = null) {
		if (index === 0) return null
		const currentIndex = index || this.collection.records.findIndex(r => r.id == record.id)
		if (currentIndex <= 0) return null

		const previousRecord = this.collection.records[currentIndex - 1]

		if (previousRecord && previousRecord.$type != "group") {
			return previousRecord
		}
		else if (previousRecord && previousRecord.$type == "group") {
			// Skip group rows
			return this.getPreviousRecord(null, currentIndex - 1)
		}
		return null
	}

	/**
	 * Select / Deselect records
	 * 
	 * TODO: at the moment, the toggleSelection function only deselects all records.
	 */
	toggleSelection() {
		// if (this._pageHasUnselectedRows()) {
		// 	const ids = this._getVisibleIds()
		// 	kiss.selection.insertMany(this.id, ids)
		// } else {
		// 	kiss.selection.reset(this.id)
		// }

		kiss.selection.reset(this.id)
		this._renderSelectionRestore()
	}

	/**
	 * Deselect records
	 */
	deselectAll() {
		kiss.selection.reset(this.id)
		this._renderSelectionRestore()
	}

	/**
	 * Select multiple records (using the SHIFT key)
	 * 
	 * @param {number} rangeStart 
	 * @param {number} rangeEnd 
	 */
	selectRange(rangeStart, rangeEnd) {
		const loadingId = kiss.loadingSpinner.show()

		setTimeout(() => {
			let selectedIds = []
			this.collection.records.forEach((record, index) => {
				if (record.$type == "group") return
				if (index >= rangeStart && index <= rangeEnd) selectedIds.push(record.id)
			})
    
			kiss.selection.insertMany(this.id, selectedIds)
			this._renderSelectionRestore()
			kiss.loadingSpinner.hide(loadingId)
		}, 0)
	}

	/**
	 * Export records as XLS or JSON
	 */
	async export() {
		const exportSelection = {
			datatable: true,
			calendar: false,
			kanban: false,
			timeline: true,
			gallery: true
		}

		const canExportSelection = exportSelection[this.type]

		createPanel({
			id: "export-setup",
			title: txtTitleCase("#export view"),
			icon: "fas fa-cloud-download-alt",
			modal: true,
			backdropFilter: true,
			closable: true,
			draggable: true,
			align: "center",
			verticalAlign: "center",
			width: "50rem",
			headerStyle: "flat",
			padding: "2rem",

			layout: "vertical",
			items: [
				// Export type
				{
					id: "export-type",
					label: txtTitleCase("format"),
					labelPosition: "top",
					type: "select",
					value: "xls",
					options: [{
						label: "EXCEL",
						color: "var(--green)",
						value: "xls"
					}, {
						label: "CSV",
						color: "var(--blue)",
						value: "csv"
					}, {
						label: "JSON",
						color: "var(--orange)",
						value: "json"
					}]
				},
				// Export set (all or selection)
				{
					hidden: !canExportSelection,
					id: "export-set",
					label: txtTitleCase("#export target"),
					labelPosition: "top",
					type: "select",
					value: "selection",
					options: [{
						label: txtTitleCase("#all view records"),
						value: "all"
					}, {
						label: txtTitleCase("#selected documents"),
						value: "selection"
					}]
				},
				// Button to export
				{
					type: "button",
					text: txtTitleCase("export"),
					icon: "fas fa-bolt",
					margin: "2rem 0 0 0",
					class: "button-ok",

					action: async () => {
						const exportType = $("export-type").getValue()
						const exportSet = (canExportSelection) ? $("export-set").getValue() : "all"
						let exportString
						let records
						let mimeType
						let extension

						// Define the set of target records (all, or a subset)
						if (exportSet == "selection") {
							records = this.getSelectedRecords()
							if (records.length == 0) return createNotification(txtTitleCase("#no selection"))
						} else {
							records = await this.collection.find()
							records = records.filter(record => record.$type != "group")
						}

						if (exportType == "xls") {
							// XLS export
							mimeType = "application/application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
							extension = "xls"

							const columns = this.columns.filter(column => !column.hidden && column.type != "link")
							exportString = "<table border=1 borderColor=#cccccc><tr>"
							exportString += columns.map(column => `<th style="background-color: #00aaee; color: #ffffff; font-size: 1.6rem;">${column.title}</th>`).join("")
							exportString += "<tr>"

							for (let record of records) {
								const data = await record.getData({
									convertNames: true,
									includeLinks: false
								})
								exportString += "<tr>"
								exportString += columns.map(column => `<td>${data[column.id]}</td>`).join("")
								exportString += "</tr>"
							}
							exportString += "</table>"

						} else if (exportType == "json") {
							// JSON export
							mimeType = "application/json"
							extension = "json"

							let exportRecords = []
							for (let record of records) exportRecords.push(await record.getData({
								useLabels: true,
								convertNames: true,
								includeLinks: false
							}))
							exportString = JSON.stringify(exportRecords)
						} else {
							// CSV export
							mimeType = "text/csv"
							extension = "csv"

							const columns = this.columns.filter(column => !column.hidden && column.type != "link")
							exportString = columns.map(column => column.title).join(",")
							exportString += "\n"

							for (let record of records) {
								const data = await record.getData({
									convertNames: true,
									includeLinks: false
								})
								exportString += columns.map(column => data[column.id]).join(",")
								exportString += "\n"
							}
						}

						// Encode data and create a download link
						const encodedData = encodeURIComponent("\ufeff" + exportString)
						const dataURI = `data:${mimeType};charset=utf-8,${encodedData}`
						const sourceUrl = `<br><br><center><a href="${dataURI}" download="export.${extension}">${txtTitleCase("download file")}</a></center>`

						createDialog({
							type: "message",
							title: txtTitleCase("#export view"),
							message: txtTitleCase("#click to download") + sourceUrl,
							noOK: true
						})

						$("export-setup").close()
					}
				}
			]
		}).render()
	}

	/**
	 * Copy the selection to the clipboard
	 */
	async copySelectionToClipboard() {
		const ids = kiss.selection.get(this.id)
		if (ids.length == 0) return createNotification(txtTitleCase("#no selection"))

		const records = this.collection.records.filter(record => ids.includes(record.id))

		let clipboardData = ""
		for (const record of records) {
			const recordData = await record.getData({
				convertNames: true
			})

			this.columns.forEach(column => {
				if (column.hidden) return
				let value = recordData[column.id]
				value = this.formatValueForClipboard(column, value)
				clipboardData += value + "\t"
			})

			clipboardData += "\n"
		}

		kiss.tools.copyTextToClipboard(clipboardData)
		createNotification(txtTitleCase("copied to clipboard"))
	}

	/**
	 * Format a value for the clipboard
	 * 
	 * @param {object} column - Column configuration
	 * @param {*} value - Value to format
	 * @returns {*} The formatted value so it can be used in the clipboard for copy/paste
	 */
	formatValueForClipboard(column, value) {
		if (value == undefined) return ""

		if (column.type == "link" || column.type == "password") {
			value = ""
		}
		else if (typeof value == "string" && (value.includes("\n") || value.includes("\""))) {
			value = `"${value.replace(/"/g, "\"\"")}"`
		}
		else if (Array.isArray(value)) {
			if (column.type == "attachment" || column.type == "aiImage") {
				value = value.map(attachment => attachment.filename).join(", ")
			}
			else {
				value = value.join(", ")
			}                    
		}
		else if (value === true) {
			value = "☑"
		}
		else if (value === false) {
			value = "☐"
		}
		return value
	}

	/**
	 * Check if the current page has unselected rows
	 * 
	 * @private
	 * @ignore
	 * @returns {boolean}
	 */
	_pageHasUnselectedRows() {
		let hasUnselectedRows = false
		const rows = this.querySelectorAll("." + this.type + "-row")
		Array.from(rows).every(row => {
			if (row.classList.contains(this.type + "-row-selected")) {
				return true
			} else {
				hasUnselectedRows = true
				return false
			}
		})
		return hasUnselectedRows
	}

	/**
	 * Get the list of ids of visible records
	 * 
	 * @private
	 * @ignore
	 * @returns {string[]}
	 */
	_getVisibleIds() {
		const rows = this.querySelectorAll("." + this.type + "-row")
		return Array.from(rows).map(row => row.getAttribute("recordid"))
	}

	/**
	 * Render the menu of actions
	 * 
	 * @private
	 * @ignore
	 */
	async _buildActionMenu() {
		let actions = []
		let buttonLeftPosition = $("actions:" + this.id).offsetLeft
		let buttonTopPosition = $("actions:" + this.id).offsetTop

		// Inject specific actions
		if (this.actions.length) {
			actions = actions.concat(this.actions)
		}

		if (actions.length == 0) {
			return
		}

		// Inject advanced actions, if any
		if (kiss.app.customActions && kiss.app.customActions.length > 0) {
			const userACL = kiss.session.getACL()

			let customActions = kiss.app.customActions.filter(action => {
				const isTargetView = action.type && action.type.includes("view") && action.viewIds && (action.viewIds.includes(this.id) || action.viewIds.includes("*"))
				const isTargetUser = kiss.tools.intersects(userACL, action.accessRead)
				return isTargetView && isTargetUser
			})

			actions.push("-")
			customActions.forEach(action => {
				const menuAction = {
					id: action.id,
					text: action.name,
					icon: action.icon,
					iconColor: action.color,
					action: () => {
						// Inject the view in the context so that custom actions can use it
						kiss.context.view = this
						eval(action.code)
					}
				}
				actions.push(menuAction)
			})
		}

		createMenu({
			top: buttonTopPosition,
			left: buttonLeftPosition,
			items: actions
		}).render()
	}    
}