/**
*
* The **Datatable** derives from [DataComponent](kiss.ui.DataComponent.html).
*
* It's a [powerful datatable](../../client/site/index.html#ui=start§ion=datatables&anchor=Introduction%20about%20KissJS%20datatables) with the following features:
* - display / hide columns
* - move columns with drag&drop
* - resize columns
* - multi-column sorting
* - complex filtering with combination of AND/OR filters
* - multi-column grouping
* - virtual scrolling which also works with grouped data
* - customizable behavior when opening a record: overriding the selectRecord method
* - customizable action menu: using actions property
* - inline cell editing
* - pre-built cell renderers like text, number, date, select, buttons...
* - custom cell renderers
*
* Tech note:
* - rendering time is proportional to the number of cells (= rows x columns) that are visible in the viewport (other cells are not rendered at all)
* - rendering takes an average of 0.05 to 0.07 millisecond (depending on the cell type) per visible cell on an Intel i7-4790K
*
* @param {object} config
* @param {Collection} config.collection - The data source collection
* @param {object} [config.record] - Record to persist the view configuration into the db
* @param {object[]} [config.columns] - Where each column is: {title: "abc", type: "text|number|integer|float|date|button", id: "fieldId", button: {config}, renderer: function() {}}
* @param {string} [config.color] - Hexa color code. Ex: #00aaee
* @param {string} [config.rowHeight] - CSS row height in pixels. Ex: 40px
* @param {boolean} [config.showHeader] - false to hide the header (default = true)
* @param {boolean} [config.showColumnType] - true to display an icon in the header indicating the column type (default = false)
* @param {boolean} [config.showToolbar] - false to hide the toolbar (default = true)
* @param {boolean} [config.showPagerIndex] - false to hide the pager index (default = true)
* @param {boolean} [config.showScroller] - false to hide the virtual scroller (default = true)
* @param {boolean} [config.showActions] - false to hide the custom actions menu (default = true)
* @param {boolean} [config.showLayoutButton] - false to hide the button to adjust the layout (default = true)
* @param {boolean} [config.showGroupButtons] - false to hide the button to expand/collapse groups (default = true)
* @param {boolean} [config.showGroupHierarchyButton] - false to hide the button to show group hierarchy (default = true)
* @param {boolean} [config.showLinks] - false to hide the columns which field type is "link"
* @param {boolean} [config.canSearch] - false to hide the search button (default = true)
* @param {boolean} [config.canSelect] - false to hide the selection checkboxes (default = true)
* @param {boolean} [config.canSort] - false to hide the sort button (default = true)
* @param {boolean} [config.canFilter] - false to hide the filter button (default = true)
* @param {boolean} [config.canGroup] - false to hide the group button (default = true)
* @param {boolean} [config.canEdit] - Can we edit the cells?
* @param {boolean} [config.canSelectFields] - Can we select the fields (= columns) to display in the table? (default = true)
* @param {boolean} [config.canAddField] - Can we add a field (= column) to the table?
* @param {boolean} [config.canEditField] - Can we edit an existing field (= column)?
* @param {boolean} [config.canCreateRecord] - Can we create new records from the datatable?
* @param {boolean} [config.createRecordText] - Optional text to insert in the button to create a new record, instead of the default model's name
* @param {boolean} [config.iconAction] - Font Awesome icon class to display the "open record" symbol. Defaults to "far fa-file-alt"
* @param {object[]} [config.actions] - Array of menu actions, where each menu entry is: {text: "abc", icon: "fas fa-check", action: function() {}}
* @param {object[]} [config.buttons] - Array of custom buttons, where each button is: {position: 3, text: "button 3", icon: "fas fa-check", action: function() {}}
* @param {number|string} [config.width]
* @param {number|string} [config.height]
* @returns this
*
* ## Generated markup
* ```
* <a-datatable class="a-datatable">
* <div class="datatable-toolbar">
* <!-- Datatable toolbar items -->
* </div>
* <div class="datatable-header-container">
* <div class="datatable-header-1st-column">
* <!-- Header 1st column -->
* </div>
* <div class="datatable-header">
* <!-- Header other columns -->
* </div>
* </div>
* <div class="datatable-body-container">
* <div class="datatable-body-1st-column">
* <!-- Body 1st column -->
* </div>
* <div class="datatable-body">
* <!-- Body other columns -->
* </div>
* </div>
* <div class="datatable-virtual-scroller-container">
* <div class="datatable-virtual-scroller"></div>
* </div>
* </a-datatable>
* ```
*/
kiss.ui.Datatable = class Datatable extends kiss.ui.DataComponent {
/**
* 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 myDatatable = document.createElement("a-datatable").init(config)
* ```
*
* Or use the shorthand for it:
* ```
* const myDatatable = createDatatable({
* id: "my-table",
* color: "#00aaee",
* collection: kiss.app.collections["contact"],
*
* // Columns must match the Model's fields
* columns: [
* {
* id: "firstName", // Must match the model's field id
* type: "text",
* title: "First name",
* },
* {
* id: "lastName",
* type: "text",
* title: "Last name",
* },
* {
* id: "birthDate",
* type: "date",
* title: "Birth date"
* }
* ],
*
* // We can define a menu with custom actions
* actions: [
* {
* text: "Group by country and city",
* icon: "fas fa-sort",
* action: () => $("my-table").groupBy(["Country", "City"])
* }
* ],
*
* // We can add custom methods, and also override default ones
* methods: {
*
* // Override the createRecord method
* createRecord(model) {
* // Create a record from this model
* console.log(model)
* },
*
* // Override the selectRecord method
* selectRecord(record) {
* // Show the clicked record
* console.log(record)
* },
*
* sayHello: () => console.log("Hello"),
* }
* })
*
* myDatatable.render()
* ```
*/
constructor() {
super()
}
/**
* Generates a Datatable from a JSON config
*
* @ignore
* @param {object} config - JSON config
* @returns {HTMLElement}
*/
init(config) {
// This component must be resized with its parent container
config.autoSize = true
// Init the parent DataComponent
super.init(config)
// Display options
this.showHeader = (config.showHeader !== false)
this.showColumnType = !!config.showColumnType
this.showToolbar = (config.showToolbar !== false)
this.showPagerIndex = (config.showPagerIndex !== false)
this.showScroller = (config.showScroller !== false)
this.showActions = (config.showActions !== false)
this.showLayoutButton = (config.showLayoutButton !== false)
this.showGroupButtons = (config.showGroupButtons !== false)
this.showGroupHierarchy = !!config.showGroupHierarchy
this.showGroupHierarchyButton = (config.showGroupHierarchyButton !== false)
this.canSearch = (config.canSearch !== false)
this.canSort = (config.canSort !== false)
this.canFilter = (config.canFilter !== false)
this.canGroup = (config.canGroup !== false)
this.color = config.color || "#00aaee"
this.iconAction = config.iconAction || "far fa-file-alt"
this.defaultRowHeight = 40
// Behaviour options
this.canSelect = (config.canSelect !== false)
this.canEdit = !!config.canEdit
this.canAddField = !!config.canAddField
this.canEditField = !!config.canEditField
this.canSelectFields = (config.canSelectFields !== false)
this.actions = config.actions || []
this.buttons = config.buttons || []
// Build datatable skeletton markup
let id = this.id
this.innerHTML =
/*html*/
`<div class="datatable">
<div id="datatable-toolbar:${id}" class="datatable-toolbar">
<div id="search-field:${id}"></div>
<div id="create:${id}"></div>
<div id="actions:${id}"></div>
<div id="select:${id}"></div>
<div id="sort:${id}"></div>
<div id="filter:${id}"></div>
<div id="group:${id}"></div>
<div id="collapse:${id}"></div>
<div id="expand:${id}"></div>
<div id="refresh:${id}"></div>
<div id="search:${id}"></div>
<div id="hierarchy:${id}"></div>
<div id="add:${id}"></div>
<div id="explode:${id}"></div>
<div class="spacer"></div>
<div id="pager-index:${id}" class="datatable-toolbar-pager-index"></div>
<div id="pager-first:${id}"></div>
<div id="pager-previous:${id}"></div>
<div id="pager-next:${id}"></div>
<div id="pager-last:${id}"></div>
<div id="layout:${id}"></div>
</div>
<div class="datatable-header-container">
<div class="datatable-header-1st-column"></div>
<div id="datatable-header:${id}" class="datatable-header"></div>
</div>
<div class="datatable-body-container">
<div class="datatable-body-1st-column"></div>
<div id="datatable-body:${id}" class="datatable-body"></div>
</div>
<div class="datatable-virtual-scroller-container">
<div class="datatable-virtual-scroller"></div>
</div>
</div>`.removeExtraSpaces()
// Set datatable components
this.datatable = this.querySelector(".datatable")
this.datatableToolbar = this.querySelector(".datatable-toolbar")
this.datatableHeader = this.querySelector(".datatable-header")
this.datatableBody = this.querySelector(".datatable-body")
this.datatableBodyContainer = this.querySelector(".datatable-body-container")
this.datatableHeader1stColumn = this.querySelector(".datatable-header-1st-column")
this.datatableBody1stColumn = this.querySelector(".datatable-body-1st-column")
this.datatableScrollerContainer = this.querySelector(".datatable-virtual-scroller-container")
this.datatableScroller = this.querySelector(".datatable-virtual-scroller")
this.datatablePagerIndex = this.querySelector(".datatable-toolbar-pager-index")
// Set header, toolbar and scroller visibility
if (this.showHeader === false) this.datatableHeader.style.display = "none"
if (this.showToolbar === false) this.datatableToolbar.style.display = "none"
if (this.showScroller === false) this.datatableScrollerContainer.style.display = "none"
if (this.showPagerIndex === false) this.datatablePagerIndex.style.display = "none"
this._initColumnsDefaultWidth()
._initColumns()
._initSize(config)
._initEvents()
._initSubscriptions()
return this
}
/**
*
* DATATABLE METHODS
*
*/
/**
* Load data into the datatable.
*
* Remark:
* - rendering time is proportional to the number of visible cells (= rows x columns)
* - rendering takes an average of 0.06 millisecond per cell on an Intel i7-4790K
*
* @ignore
*/
async load() {
try {
log(`kiss.ui - Datatable ${this.id} - Loading collection <${this.collection.id} (changed: ${this.collection.hasChanged})>`)
// Apply filter, sort, group, projection
// Priority is given to local config, then to the passed collection, then to default
this.collection.filter = this.filter
this.collection.filterSyntax = this.filterSyntax
this.collection.sort = this.sort
this.collection.sortSyntax = this.sortSyntax
this.collection.group = this.group
this.collection.projection = this.projection
this.collection.groupUnwind = this.groupUnwind
// Load records
await this.collection.find()
// Hide the virtual scroller while the datatable is being built
this._hideScroller()
// Try to adjust columns width from local config
this._columnsAdjustWidthFromLocalStorage()
// Get the selected records
this.getSelection()
// Render the datatable toolbar
this._renderToolbar()
// Get paging params (skip & limit)
this.skip = 0
this._setLimit()
// Show onboarding tutorial if needed
if (kiss.context.onboard == true) {
this.showTutorial()
}
} catch (err) {
log(`kiss.ui - Datatable ${this.id} - Couldn't load data properly`)
}
}
/**
* Generic method to refresh / re-render the view
*
* Note: used in dataComponent (parent class) showSearchBar method.
* This method is invoked to refresh the view after a full-text search has been performed
*/
refresh() {
this._render()
}
/**
* Switch to search mode
*
* Show/hide only the necessary buttons in this mode.
*/
switchToSearchMode() {
if (kiss.screen.isMobile) {
$("create:" + this.id).hide()
$("search:" + this.id).hide()
$("expand:" + this.id).hide()
$("collapse:" + this.id).hide()
}
}
/**
* Reset search mode
*/
resetSearchMode() {
if (kiss.screen.isMobile) {
$("create:" + this.id).show()
$("search:" + this.id).show()
$("expand:" + this.id).show()
$("collapse:" + this.id).show()
}
}
/**
* Update the datatable color (toolbar buttons + modal windows)
*
* @param {string} newColor
*/
async setColor(newColor) {
this.color = newColor
Array.from(this.datatableToolbar.children).forEach(item => {
if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
})
}
/**
* Set the datatable row height
*
* @param {number} height - The row height in pixels
*/
setRowHeight(height) {
this.rowHeight = height
document.documentElement.style.setProperty("--datatable-cell-height", this.rowHeight + "px")
document.documentElement.style.setProperty("--datatable-group-cell-height", this.rowHeight + "px")
this._setThumbSize()
// Save new row height locally
const localStorageId = "config-datatable-" + this.id + "-row-height"
localStorage.setItem(localStorageId, this.rowHeight)
this.reload()
}
/**
* Reset all the columns to their default width
*/
async resetColumnsWidth() {
this.columns.forEach(column => {
let width = this.defaultColumnWidth[column.type]
if (!width) width = this.defaultColumnWidth.default
this._columnsSetWidth(column.id, width)
})
}
/**
* Update the datatable size (recomputes its width and height functions)
*/
updateLayout() {
if (this.isConnected) {
this._setWidth()
this._setHeight()
this._setLimit()
this._renderPagerIndex()
this._render()
this._renderSelectionRestore()
}
}
/**
* Highlight a chosen record
*
* @param {string} recordId
*/
highlightRecord(recordId) {
let index = this.goToRecord(recordId)
if (index != -1) this._rowHighlight(index)
}
/**
* Scroll to a chosen record
*
* @param {string} recordId
* @returns {number} The index of the found record, or -1 if not found
*/
goToRecord(recordId) {
let index = this._rowFindIndex(recordId)
if (index != -1) this.goToIndex(index)
return index
}
/**
* Scroll to a chosen index
*
* @param {number} index
*/
goToIndex(index) {
this.skip = index
this._render()
}
/**
* Show the first page
*/
showFirstPage() {
this.skip = 0
this._renderPage(0)
}
/**
* Show the previous page
*/
showPreviousPage() {
this._renderPage(-this.limit)
}
/**
* Show the next page
*/
showNextPage() {
this._renderPage(this.limit)
}
/**
* Show the last page
*/
showLastPage() {
this.skip = this.collection.count - this.limit - 1
this._renderPage(this.limit)
}
/**
* Show the window just under the sorting button
*/
showSortWindow() {
let sortButton = $("sort:" + this.id)
const box = sortButton.getBoundingClientRect()
super.showSortWindow(box.left, box.top + 40, this.color)
}
/**
* Show the window just under the fields selector button
*/
showFieldsWindow() {
let selectionButton = $("select:" + this.id)
const box = selectionButton.getBoundingClientRect()
super.showFieldsWindow(box.left, box.top + 40, this.color)
}
/**
* Show the window just under the filter button
*/
showFilterWindow() {
super.showFilterWindow(null, null, this.color)
}
/**
* Show the window to adjust the color of a column
*/
showColorWindow(column) {
const picker = createPanel({
modal: true,
header: false,
width: 670,
align: "center",
verticalAlign: "center",
items: [{
id: "column-color",
type: "colorPicker",
value: column.color,
palette: kiss.global.palette.slice(0, 20),
selectorBorderRadius: "32px",
height: 100,
events: {
change: () => {
let color = $("column-color").getValue()
column.color = color
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
picker.close()
}
}
}]
}).render()
}
/**
* Initialize datatable sizes
*
* @private
* @ignore
* @returns this
*/
_initSize(config) {
this._initRowHeight(config)
if (config.width) {
this._setWidth()
} else {
this.style.width = this.config.width = `calc(100%)`
}
if (config.height) {
this._setHeight()
} else {
this.style.height = this.config.height = `calc(100% - 10px)`
}
return this
}
/**
* Init the row height according to local settings and/or config
*
* @private
* @ignore
*/
_initRowHeight(config = {}) {
this.rowHeight = config.rowHeight || this._getRowHeightFromLocalStorage()
document.documentElement.style.setProperty("--datatable-cell-height", this.rowHeight + "px")
document.documentElement.style.setProperty("--datatable-group-cell-height", this.rowHeight + "px")
this._setThumbSize()
}
/**
* Initialize all datatable events
*
* @private
* @ignore
* @returns this
*/
_initEvents() {
// Clicked on the checkbox to deselect all records
this.datatableHeader1stColumn.onclick = (event) => {
if (event.target.classList.contains("datatable-header-checkbox")) {
this.toggleSelection()
}
}
this.datatableHeader.onclick = (event) => {
const clickedElement = event.target
// Clicked on a column menu
if (clickedElement.classList.contains("datatable-column-header-properties")) {
const columnId = clickedElement.id.split(":")[1]
this._showColumnMenu(columnId, clickedElement, event)
}
// Clicked on the last header to create a new column
if (clickedElement.classList.contains("datatable-header-last-column") || clickedElement.parentNode.classList.contains("datatable-header-last-column")) {
if (this.canAddField) {
this._showColumnSetup()
return event
}
}
}
// Clicked on the 1st column resizer
this.datatableHeader1stColumn.onmousedown = (event) => {
const clickedElement = event.target
if (clickedElement.classList.contains("datatable-column-header-resizer")) {
this._columnsResizeWithDragAndDrop(event, clickedElement)
}
}
// Clicked on a column resizer
this.datatableHeader.onmousedown = (event) => {
const clickedElement = event.target
if (clickedElement.classList.contains("datatable-column-header-resizer")) {
this._columnsResizeWithDragAndDrop(event, clickedElement)
}
}
// Clicked somewhere in the datatable
this.onclick = async (event) => {
const clickedElement = event.target
const clickedParent = clickedElement.parentNode
// CLICKED INSIDE A BLANK CELL (last column)
if (clickedElement.classList.contains("datatable-cell-blank")) {
return event
}
// CLICKED INSIDE A CELL WHICH IS ALREADY IN EDIT MODE
if (clickedElement.tagName.toLowerCase() == "input") {
return event
}
// CLICKED ON A COMPUTED CELL CONTAINING AN URL
if (clickedElement.classList.contains("datatable-cell-selected-locked")) {
const cellValue = clickedElement.innerText
if (cellValue && cellValue.match(kiss.tools.regex.url)) {
window.open(cellValue)
return
}
}
// CLICKED A LINKED FIELD
if (clickedElement.classList.contains("field-link-value-cell") || clickedParent.classList.contains("field-link-value-cell")) {
const cell = clickedElement.closest("div")
const fieldId = this._cellGetFieldId(cell)
const recordId = clickedElement.closest(".datatable-row").getAttribute("recordId")
this._cellOpenLinkedRecord(fieldId, recordId)
return event
}
// CLICKED A WORKFLOW HISTORY
if (clickedElement.classList.contains("form-feature-workflow-history-button") || clickedParent.classList.contains("form-feature-workflow-history-button")) {
const cell = clickedElement.closest("div")
const recordId = clickedElement.closest(".datatable-row").getAttribute("recordId")
displayWorkflowHistoryFromView(this.id, recordId)
return event
}
// SELECT / DESELECT A ROW
// = clicked on the checkbox to select a record
if (clickedElement.classList.contains("datatable-row-checkbox")) {
const rowIndex = clickedParent.getAttribute("row")
this._rowToggleSelect(rowIndex)
return event
}
// SELECT A RECORD (GENERALLY TO OPEN IT AS A FORM)
// = clicked on the 1st column cell to expand a record and display it as a form
if (Array.from(clickedParent.classList).concat(Array.from(clickedElement.classList)).indexOf("datatable-cell-1st") != -1) {
const cell = clickedElement.closest("div")
const recordId = cell.getAttribute("recordid")
const record = await this.collection.getRecord(recordId)
await this.selectRecord(record)
return event
}
// EXPAND / COLLAPSE A GROUP
// = clicked on a group section
if (clickedElement.classList.contains("datatable-group-summary") || clickedParent.classList.contains("datatable-group-summary")) {
const colIndex = clickedElement.closest("div").getAttribute("col")
const visibleColumn = this.visibleColumns[colIndex]
const columnId = visibleColumn.id
const column = this.columns.get(columnId)
await this._columnsSetAggregationType(column, event.pageX - 32, event.pageY - 32)
return event
}
if (clickedParent.classList[0] && clickedParent.classList[0].indexOf("datatable-group") != -1) {
const rowIndex = this._cellGetRowIndex(clickedElement)
const record = this.collection.records[Number(rowIndex)]
const groupId = record.$groupId
const groupLevel = record.$groupLevel
this._groupToggle(groupId, groupLevel, rowIndex)
return event
}
if (clickedElement.classList.contains("datatable-group")) {
const rowIndex = clickedElement.getAttribute("row")
const record = this.collection.records[Number(rowIndex)]
const groupId = record.$groupId
const groupLevel = record.$groupLevel
this._groupToggle(groupId, groupLevel, rowIndex)
return event
}
// OPEN A RECORD
// (when "canEdit" property == false)
if (!this.canEdit) {
if ((clickedElement.classList.contains("datatable-cell")) || clickedParent.classList.contains("datatable-cell")) {
const row = clickedElement.closest(".datatable-row")
const recordId = row.getAttribute("recordid")
const record = await this.collection.getRecord(recordId)
await this.selectRecord(record)
}
return event
}
// EDIT A CELL (or exit if the datatable is not editable)
if (clickedParent.classList.contains("datatable-cell-selected") && !clickedParent.classList.contains("datatable-cell-selected-locked")) {
this._cellSwitchToEditMode(clickedParent, event)
return event
}
if (clickedElement.classList.contains("datatable-cell-selected") && !clickedElement.classList.contains("datatable-cell-selected-locked")) {
this._cellSwitchToEditMode(clickedElement, event)
return event
}
// SELECT A CELL (prior to edition)
let selectedCell
if (clickedParent.classList.contains("datatable-cell")) {
selectedCell = clickedParent
} else if (clickedElement.classList.contains("datatable-cell")) {
selectedCell = clickedElement
}
if (selectedCell) {
// OPEN A LINK if the cell contains an URL
const cellValue = selectedCell.innerText
if (cellValue && cellValue.match(kiss.tools.regex.url)) {
createMenu({
items: [
{
text: txtTitleCase("#open link"),
icon: "fas fa-external-link-alt",
action: () => window.open(cellValue)
}
]
}).render().showAt(event.pageX + 10, event.pageY + 10)
}
// Exclude attachment cells from being selected
const classes = selectedCell.classList
if (classes.contains("datatable-type-attachment") || classes.contains("datatable-type-link")) {
return event
}
selectedCell.setAttribute("tabindex", "0")
selectedCell.focus()
selectedCell.classList.add("datatable-cell-selected")
// Check is the record is locked and if it's an editable cell
const recordId = clickedElement.closest(".datatable-row").getAttribute("recordId")
const record = await this.collection.getRecord(recordId)
const isEditable = (record.isLocked) ? false : this._cellIsEditable(selectedCell)
if (!isEditable) {
selectedCell.classList.add("datatable-cell-selected-locked")
} else {
// Manage keyboard
selectedCell.onkeydown = (event) => {
if (!["Escape"].includes(event.key)) this._cellSwitchToEditMode(selectedCell)
selectedCell.classList.remove("datatable-cell-selected")
selectedCell.classList.remove("datatable-cell-selected-locked")
selectedCell.blur()
selectedCell.removeAttribute("tabindex")
selectedCell.onkeydown = null
}
}
// Clean on exit
selectedCell.onmouseleave = () => {
selectedCell.classList.remove("datatable-cell-selected")
selectedCell.classList.remove("datatable-cell-selected-locked")
selectedCell.blur()
selectedCell.removeAttribute("tabindex")
selectedCell.onmouseleave = null
}
}
}
// Sync horizontal scrolling between body and header
this.datatableBody.onscroll = () => {
this.datatableHeader.scrollLeft = this.datatableBody.scrollLeft
}
/*
* VIRTUAL SCROLLING MANAGEMENT
*/
//
// Observe mousewheel event to scroll
//
this.onmousewheel = this.onwheel = (event) => {
// Scroll must happen inside the datatable body
if (!event.target.closest(".datatable-body-container")) return
if (event.wheelDelta > 0) {
this._virtualScrollUp()
} else {
this._virtualScrollDown()
}
// Sync the virtual scrollbar position
this._renderScrollerPosition()
// Update pager
this._renderPagerIndex()
}
//
// Observe touchmove event to scroll
// TODO: prevent pull-to-refresh
// TODO: allow smooth vertical & horizontal scroll
//
// this.ontouchstart = (e) => {
// this.startTouchY = e.touches[0].clientY
// }
// this.ontouchmove = (e) => {
// let currentY = e.changedTouches[0].clientY
// let deltaY = currentY - this.startTouchY
// if (deltaY > 0) {
// if (deltaY % 5 == 0) {
// e.preventDefault()
// this._virtualScrollUp()
// }
// } else if (deltaY < 0) {
// if (deltaY % 5 == 0) {
// e.preventDefault()
// this._virtualScrollDown()
// }
// }
// }
//
// Enable onscroll event when clicking on the virtual scrollbar
//
this.datatableScrollerContainer.onmousedown = (event) => {
this.preventScroll = false
}
//
// Render the datatable at the correct row index when moving the virtual scrollbar
//
this.datatableScrollerContainer.onscroll = (event) => {
if (this.preventScroll == true) return false
// Clear our timeout throughout the scroll
window.clearTimeout(this.isScrolling)
// Set a timeout to run after scrolling ends, in order to smooth the rendering
this.isScrolling = null
this.isScrolling = setTimeout(() => {
// Compute the scroll as a percentage of the total height
let percent = event.target.scrollTop / (this.datatableScroller.offsetHeight - this.datatableBody.offsetHeight)
// Deduce how many records to skip
let recordIndex = Math.round((this.collection.count - this.limit) * percent)
let newSkip = Math.min(recordIndex, this.collection.records.length - this.limit)
// Re-render the datatable if the skip value has changed
if (newSkip != this.skip) {
this.skip = Math.max(newSkip, 0)
this._render()
}
}, 10)
}
return this
}
/**
* Initialize subscriptions to PubSub
*
* @private
* @ignore
* @returns this
*/
_initSubscriptions() {
super._initSubscriptions()
const viewModelId = this.modelId.toUpperCase()
// React to database mutations
this.subscriptions = this.subscriptions.concat([
subscribe("EVT_DB_INSERT:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
subscribe("EVT_DB_UPDATE:" + viewModelId, (msgData) => this._updateOneAndReload(msgData)),
subscribe("EVT_DB_DELETE:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
subscribe("EVT_DB_INSERT_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
subscribe("EVT_DB_UPDATE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
subscribe("EVT_DB_DELETE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
subscribe("EVT_DB_UPDATE_BULK", (msgData) => this._updateManyAndReload(msgData))
])
return this
}
/**
* Update a single record then reload the view if required
*
* @private
* @ignore
* @param {object} msgData - The original pubsub message
*/
async _updateOneAndReload(msgData) {
const sortFields = this.sort.map(sort => Object.keys(sort)[0])
const filterFields = kiss.db.mongo.getFilterFields(this.filter)
let groupHasChanged = false
let sortHasChanged = false
let filterHasChanged = false
let updates = msgData.data
for (let fieldId of Object.keys(updates)) {
if (this.group.indexOf(fieldId) != -1) groupHasChanged = true
if (sortFields.indexOf(fieldId) != -1) sortHasChanged = true
if (filterFields.indexOf(fieldId) != -1) filterHasChanged = true
let newValue = updates[fieldId]
this._cellSetValue(msgData.id, fieldId, newValue)
}
if (sortHasChanged || filterHasChanged || groupHasChanged) {
this._reloadWhenNeeded(msgData)
}
}
/**
* Update multiple records then reload the view
*
* @private
* @ignore
* @param {object} msgData - The original pubsub message
*/
async _updateManyAndReload(msgData) {
const sortFields = this.sort.map(sort => Object.keys(sort)[0])
const filterFields = kiss.db.mongo.getFilterFields(this.filter)
let groupHasChanged = false
let sortHasChanged = false
let filterHasChanged = false
let operations = msgData.data
operations.forEach(operation => {
if (operation.modelId == this.modelId) {
for (let fieldId of Object.keys(operation.updates)) {
if (this.group.indexOf(fieldId) != -1) groupHasChanged = true
if (sortFields.indexOf(fieldId) != -1) sortHasChanged = true
if (filterFields.indexOf(fieldId) != -1) filterHasChanged = true
let newValue = operation.updates[fieldId]
this._cellSetValue(operation.recordId, fieldId, newValue)
}
}
})
if (sortHasChanged || filterHasChanged || groupHasChanged) {
this._reloadWhenNeeded(msgData)
}
}
/**
* Re-render the virtual scrollbar when the datatable is re-connected to the DOM
*
* @private
* @ignore
*/
_afterConnected() {
super._afterConnected()
this._hideScroller()
this._renderScroller()
}
/**
* Initialize the default width, which depend on the field type
*
* @private
* @ignore
* @eturns this
*/
_initColumnsDefaultWidth() {
this.defaultColumnWidth = {
text: 180,
number: 180,
date: 180,
select: 180,
textarea: 350,
checkbox: 100,
color: 100,
icon: 100,
attachment: 150,
directory: 200,
firstColumn: (kiss.screen.isMobile) ? 50 : 90, // First column used for selection
default: 180 // Any other type
}
return this
}
/**
* Scroll up by one line with the virtual scroller
* Remove the last row and insert a new one at the beginning
*
* @private
* @ignore
*/
_virtualScrollUp() {
if ((this.skip - 1) < 0) return
this.skip -= 1
this.lastIndex = Math.min(this.skip + this.limit - 1, this.collection.records.length)
this.datatableBody.lastChild.remove()
this.datatableBody.insertBefore(this._renderRowDiv(this.skip), this.datatableBody.children[0])
this.datatableBody1stColumn.lastChild.remove()
this.datatableBody1stColumn.insertBefore(this._renderRowDiv1stColumn(this.skip), this.datatableBody1stColumn.children[0])
}
/**
* Scroll up by one line with the virtual scroller
* Remove the last row and insert a new one at the beginning
*
* @private
* @ignore
*/
_virtualScrollDown() {
if ((this.lastIndex + 1) >= this.collection.records.length) return
this.skip += 1
this.lastIndex = Math.min(this.skip + this.limit - 1, this.collection.records.length)
this.datatableBody.children[0].remove()
this.datatableBody.appendChild(this._renderRowDiv(this.lastIndex))
this.datatableBody1stColumn.children[0].remove()
this.datatableBody1stColumn.appendChild(this._renderRowDiv1stColumn(this.lastIndex))
}
/**
* Open the column menu to:
* - sort asc | desc
* - edit field properties
*
* @private
* @ignore
* @param {string} fieldId
* @param {object} columnMenu - The menu HTMLElement
* @param {object} event - The original event that opened the column menu
*/
_showColumnMenu(fieldId, columnMenu, event) {
const field = this.model.getField(fieldId)
const column = this.columns.find(column => column.id == fieldId)
const isGrouped = this.group.length > 0
const colorableFields = ["text", "textarea", "number", "date"]
const isColorable = colorableFields.includes(field.type) || (field.type == "lookup" && colorableFields.includes(field.lookup.type)) || (field.type == "summary" && colorableFields.includes(field.summary.type))
const isNumeric = kiss.tools.isNumericField(field)
const canBeAggregated = isNumeric && isGrouped
// Action
let columnActions = [
// Sort ASC
{
icon: "fas fa-arrow-up",
text: txtTitleCase("sort ascending"),
action: async () => {
await this.sortByField(fieldId, "asc")
this.showSortWindow()
}
},
// Sort DESC
{
icon: "fas fa-arrow-down",
text: txtTitleCase("sort descending"),
action: async () => {
await this.sortByField(fieldId, "desc")
this.showSortWindow()
}
},
(this.canEditField && isColorable) ? "-" : "",
// Column color
{
hidden: !isColorable,
icon: "fas fa-palette",
text: txtTitleCase("column color"),
action: async () => {
this.showColorWindow(column)
}
},
// Remove column color
{
hidden: !this.canEditField || !isColorable,
icon: "fas fa-times",
text: txtTitleCase("remove color"),
action: async () => {
if (!column.color) return
delete column.color
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
}
}
]
// Actions for the field: edit / delete
if (this.canEditField && !field.isFromPlugin && !field.isSystem) {
// Separator
columnActions.splice(0, 0, "-")
// Delete the field
if (!field.primary) {
columnActions.splice(0, 0, {
icon: "fas fa-trash",
iconColor: "var(--red)",
text: txtTitleCase("delete this field"),
action: () => {
// Open a confirmation window to delete the field
const deleteDialog = createDialog({
type: "danger",
title: txtTitleCase("delete a field"),
message: txtTitleCase("#delete field warning"),
buttonOKPosition: "left",
action: async () => {
this.model.deleteField(fieldId)
deleteDialog.close()
this.reload()
}
})
}
})
}
// Edit the field properties
columnActions.splice(0, 0, {
icon: "fas fa-edit",
text: txtTitleCase("edit this field"),
action: async () => this._showColumnSetup(fieldId)
})
}
// Aggregation options
if (canBeAggregated) {
// Separator
columnActions.push("-")
// Sum
columnActions.push({
text: txtTitleCase("#summary sum"),
icon: "fas fa-chart-bar",
action: async () => {
column.summary = "sum"
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
}
})
// Average
columnActions.push({
text: txtTitleCase("#summary avg"),
icon: "fas fa-tachometer-alt",
action: async () => {
column.summary = "avg"
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
}
})
// No aggregation
columnActions.push({
text: txtTitleCase("#no summary"),
icon: "fas fa-ban",
action: async () => {
delete column.summary
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
}
})
}
createMenu({
top: columnMenu.getBoundingClientRect().y - 10 + "px",
left: columnMenu.getBoundingClientRect().x - 10 + "px",
items: columnActions
}).render()
}
/**
* Set up a column (= opens the field property window)
*
* @ignore
* @param {string} [fieldId] - Field id to set up. Creates a new field if no param.
*/
_showColumnSetup(fieldId) {
kiss.context.dockFieldProperties = false
kiss.router.updateUrlHash({
fieldId: (fieldId) ? fieldId : ""
})
kiss.views.show("model-field")
}
/**
* Adjust image thumbnail size according to the row height
*
* @private
* @ignore
*/
_setThumbSize() {
if (this.rowHeight <= this.defaultRowHeight) {
this.thumbSize = "s"
} else {
this.thumbSize = "m"
}
}
/**
*
* SIZE MANAGEMENT
*
*/
/**
* Adjust the component width
*
* @ignore
* @param {(number|string|function)} [width] - The width to set
*/
_setWidth() {
let newWidth = this._computeSize("width")
setTimeout(() => {
this.style.width = newWidth
this.datatable.style.width = this.clientWidth.toString() + "px"
}, 50)
}
/**
* Adjust the components height
*
* @private
* @ignore
* @param {(number|string|function)} [height] - The height to set
*/
_setHeight() {
let newHeight = this._computeSize("height")
this.style.height = this.datatable.style.height = newHeight
}
/**
* Compute the maximum number of rows that can fit in the datatable, then set the "limit" param.
* The limit depends on the global datatable height minus:
* - the datatable toolbar
* - the datatable header
*
* @private
* @ignore
*/
_setLimit() {
if (!this.isConnected) return
let tableHeight = this.offsetHeight
let headerHeight = $("datatable-header:" + this.id).offsetHeight
let toolbarHeight = $("datatable-toolbar:" + this.id).offsetHeight
let bodyHeight = tableHeight - toolbarHeight - headerHeight
this.limit = Math.floor(bodyHeight / (this.rowHeight + 1))
if (kiss.screen.isMobile) this.limit = this.limit - 1 // Keep a margin for Mobile UI
}
/**
*
* RENDERING THE TABLE
*
*/
/**
* Show N records, starting from the current position (given by the "skip" property)
*
* @private
* @ignore
* @param {number} size - Positive number for next page, and negative for previous page
*
* @example
* this._renderPage(-50)
*/
_renderPage(size) {
// If we've reach the beginning or the end of the recordset => exit!
if ((size < 0 && this.skip == 0) || (size > 0) && ((this.skip + size) >= this.collection.count)) return
// Update the number of records to skip
this.skip = ((this.skip + 2 * size) >= this.collection.count) ? (this.skip = this.collection.count - size) : this.skip + size
this.skip = Math.max(this.skip, 0)
// Render
this._render()
// Adjust the virtual scroller position according to the new "skip" value
this._renderScrollerPosition()
}
/**
* Render the pagination index.
* Display, for example: 0 - 50 / 1000
*
* @private
* @ignore
*/
_renderPagerIndex() {
if (!this.isConnected) return
if (kiss.screen.isMobile && kiss.screen.isVertical()) {
// Compact version for mobile phones
$("pager-index:" + this.id).innerHTML = Math.min(this.collection.count, (this.skip + this.limit)) + " / " + this.collection.count
} else {
$("pager-index:" + this.id).innerHTML = (this.skip + 1) + " - " + Math.min(this.collection.count, (this.skip + this.limit)) + " / " + this.collection.count
}
}
/**
* Render the datatable
*
* @private
* @ignore
* @returns this
*/
_render() {
// kiss.tools.timer.start()
// Reset 1st column
this.datatableHeader1stColumn.innerHTML = ""
this.datatableBody1stColumn.innerHTML = ""
// Filters out hidden and deleted columns
this.visibleColumns = this.columns.filter(column => column.hidden != true && column.deleted != true)
this._prepareCellRenderers()
._renderHeader()
._renderBody()
// kiss.tools.timer.show("Datatable rendered!")
return this
}
/**
* Render the datatable header.
*
* @private
* @ignore
* @returns this
*/
_renderHeader() {
// 1st column header
let firstCell = document.createElement("div")
firstCell.setAttribute("id", "header-1stColumn")
firstCell.setAttribute("col", "-1")
firstCell.classList.add("datatable-column-header", "datatable-column-header-1st")
firstCell.style.width = firstCell.style.minWidth = this.defaultColumnWidth.firstColumn + "px"
firstCell.innerHTML =
`<span id='toggle-selection' class='datatable-header-checkbox ${(this.canSelect) ? "datatable-header-checkbox-off" : ""}'></span>` + // Selection checkbox
"<span id='header-resizer-1st-column' class='datatable-column-header-resizer'> </span>" // Column resizer
this.datatableHeader1stColumn.appendChild(firstCell)
// Other columns headers
this._columnsAdjustWidthFromLocalStorage()
this.datatableHeader.innerHTML = this.visibleColumns.map(this._renderColumnHeader.bind(this)).join("") +
`<span class="datatable-column-header datatable-header-last-column">${(this.canAddField) ? `<span class="fas fa-plus"></span>` : ""}</span>` // Button to create a new column
return this
}
/**
* Render the datatable body
*
* Tech note: we don't use string litterals to build the HTML because it's slower than native String concatenation
*
* @private
* @ignore
* @returns this
*/
_renderBody() {
// Adjust sizes
this._initRowHeight()
this._columnsSetFirstColumnWidth(this.defaultColumnWidth.firstColumn)
// Build table BODY
let table = ""
let firstColumn = ""
this.startIndex = Math.max(0, this.skip)
this.lastIndex = Math.min(this.skip + this.limit - 1, this.collection.records.length)
if (this.collection.group.length === 0) {
// Rendering without grouping
for (let rowIndex = this.startIndex; rowIndex < this.lastIndex; rowIndex++) {
let record = this.collection.records[rowIndex]
firstColumn += "<div col=\"-1\" row=\"" + rowIndex + "\" recordId=\"" + record.id + "\" class=\"datatable-cell-1st\" style=\"width: " + this.defaultColumnWidth.firstColumn + "px; min-width: " + this.defaultColumnWidth.firstColumn + "px\">"
firstColumn += this._renderRowContent1stColumn(record, rowIndex)
firstColumn += "</div>"
table += "<div row=\"" + rowIndex + "\" recordId=\"" + record.id + "\" class=\"datatable-row\">"
table += this._renderRowContent(record, rowIndex)
table += "</div>"
}
} else {
// Rendering with grouping
let nbOfRows = 0
for (let rowIndex = this.skip; (nbOfRows < this.limit) && (rowIndex < this.collection.records.length); rowIndex++) {
let record = this.collection.records[rowIndex]
if (record.$type == "group") {
firstColumn += "<div col=\"-1\" row=\"" + rowIndex + "\" class=\"datatable-group datatable-group-level-" + record.$groupLevel + "\" style=\"width: " + this.defaultColumnWidth.firstColumn + "px; min-width: " + this.defaultColumnWidth.firstColumn + "px\">"
firstColumn += this._renderRowGroupContent1stColumn(record)
firstColumn += "</div>"
table += "<div row=\"" + rowIndex + "\" groupLevel=\"" + record.$groupLevel + "\" class=\"datatable-group-row\">"
table += this._renderRowGroupContent(record)
table += "</div>"
} else {
firstColumn += "<div col=\"-1\" row=\"" + rowIndex + "\" recordId=\"" + record.id + "\" class=\"datatable-cell-1st\" style=\"width: " + this.defaultColumnWidth.firstColumn + "px; min-width: " + this.defaultColumnWidth.firstColumn + "px\">"
firstColumn += this._renderRowContent1stColumn(record, rowIndex)
firstColumn += "</div>"
table += "<div row=\"" + rowIndex + "\" recordId=\"" + record.id + "\" class=\"datatable-row\">"
table += this._renderRowContent(record, rowIndex)
table += "</div>"
}
nbOfRows++
}
}
// Inject the table into the DOM
this.datatableBody.innerHTML = table
// Inject the table 1st column into the DOM
this.datatableBody1stColumn.innerHTML = firstColumn
// Update the pager index
this._renderPagerIndex()
// Highlight the selected rows
this._renderSelection()
// Add the virtual scroller
this._renderScroller()
// Show / hide empty icon
this._renderEmptyIcon()
return this
}
/**
* Show an "empty" icon if there are no records to render
*
* @private
* @ignore
*/
_renderEmptyIcon() {
if (this.collection.records.length == "0") {
this.datatableBodyContainer.classList.add("datatable-body-container-empty")
} else {
this.datatableBodyContainer.classList.remove("datatable-body-container-empty")
}
}
/**
* Render a single row of the datatable
*
* @private
* @ignore
* @param {number} rowIndex
* @returns {HTMLDivElement} The div containing the row
*/
_renderRowDiv(rowIndex) {
let record = this.collection.records[rowIndex]
// Fork if it's a grouping row
if (record.$type == "group") return this._renderRowGroupDiv(record, rowIndex)
// Build the div
let newRow = document.createElement("div")
newRow.setAttribute("row", rowIndex)
newRow.setAttribute("recordid", record.id)
newRow.classList.add("datatable-row")
// Apply the style "selected" if the row has been selected
// TODO: optimization => apply the "selected" style for all selected rows *after* the datatable has been fully rendered
let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
if (isSelected) newRow.classList.add("datatable-row-selected")
// Inject row content (= cells) into the div
newRow.innerHTML = this._renderRowContent(record, rowIndex)
return newRow
}
/**
* Render the 1st cell of a single row of the datatable
*
* @private
* @ignore
* @param {number} rowIndex
* @returns {HTMLDivElement} The div containing the cell
*/
_renderRowDiv1stColumn(rowIndex) {
let record = this.collection.records[rowIndex]
// Fork if it's a grouping row
if (record.$type == "group") return this._renderRowGroupDiv1stColumn(record, rowIndex)
let firstCell = document.createElement("div")
firstCell.setAttribute("col", "-1")
firstCell.setAttribute("row", rowIndex)
firstCell.setAttribute("recordid", record.id)
firstCell.classList.add("datatable-cell-1st")
firstCell.style.width = firstCell.style.minWidth = this.defaultColumnWidth.firstColumn + "px"
// Apply the style "selected" if the row has been selected
// TODO: possible optimization => apply the "selected" style for all selected rows *after* the datatable has been fully rendered
let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
firstCell.innerHTML = this._renderRowContent1stColumn(record, rowIndex, isSelected)
return firstCell
}
/**
* Render the content of a single row of the datatable.
*
* @private
* @ignore
* @param {number} record - The record to render in this row
* @param {number} rowIndex
* @returns {string} Html source for a row
*/
_renderRowContent(record, rowIndex) {
let row = ""
for (let colIndex = 0, length = this.visibleColumns.length; colIndex < length; colIndex++) {
let column = this.visibleColumns[colIndex]
let value = column.renderer(record[column.id], record, rowIndex, colIndex)
row += `<div col=${colIndex} class="datatable-cell datatable-type-${column.type}" style="${this._columnsConvertWidthToStyle(column.width)}; ${(column.color) ? `color: ${column.color}` : ""}">` + value + "</div>"
}
row += "<div class='datatable-cell datatable-cell-blank'></div>"
return row
}
/**
* Render the content of the 1st cell of a single row of the datatable.
*
* The 1st cell of the row includes:
* - a selection checkbox
* - the row number
* - a button to expand the record and see it in a form
*
* @private
* @ignore
* @param {number} record - The record to render in this row
* @param {number} rowIndex
* @param {boolean} [isSelected] - If true, render the row with its "selected" appearence
* @returns {string} Html source for a row
*/
_renderRowContent1stColumn(record, rowIndex, isSelected) {
return ((this.canSelect) ? "<span class=\"datatable-row-checkbox datatable-row-checkbox-" + ((isSelected) ? "on" : "off") + "\"></span>" : "") + // Selection checkbox
"<span class=\"datatable-row-number\">" + ((record.$index + 1) || Number(rowIndex + 1)) + "</span>" + // Row number
// "<span class=\"datatable-row-data\">BOB WILSON - 20221224 - 20221231</span>" + // Row data
"<span class=\"datatable-row-action " + this.iconAction + "\"></span>" // Row action button
}
/**
* Render a single *group* row of the datatable.
*
* @private
* @ignore
* @param {object} record
* @param {number} rowIndex
*/
_renderRowGroupDiv(record, rowIndex) {
let newRow = document.createElement("div")
newRow.setAttribute("row", rowIndex)
newRow.classList.add("datatable-group-row")
newRow.innerHTML = this._renderRowGroupContent(record)
return newRow
}
/**
* Render the first cell of a single *group* row of the datatable.
*
* @private
* @ignore
* @param {object} record
* @param {number} rowIndex
*/
_renderRowGroupDiv1stColumn(record, rowIndex) {
let firstCell = document.createElement("div")
firstCell.setAttribute("col", "-1")
firstCell.setAttribute("row", rowIndex)
firstCell.classList.add("datatable-group", "datatable-group-level-" + record.$groupLevel)
firstCell.style.width = firstCell.style.minWidth = this.defaultColumnWidth.firstColumn + "px"
firstCell.innerHTML = this._renderRowGroupContent1stColumn(record)
return firstCell
}
/**
* Render the content of a single *group* row of the datatable.
*
* @private
* @ignore
* @param {object} record
* @returns {string} Html source for a *group* row
*/
_renderRowGroupContent(record) {
let row = ""
for (let colIndex = 0, length = this.visibleColumns.length; colIndex < length; colIndex++) {
const column = this.visibleColumns[colIndex]
const field = this.model.getField(column.id)
const precision = (field && field.precision) || 0
const unit = (field && field.unit) || ""
let cellRawValue = record[column.id]
if (cellRawValue !== undefined) {
const aggregationType = column.summary || "summary"
const aggregationSettings = `<span class='fas fa-caret-down datatable-group-summary'><span>${txtTitleCase("#" + aggregationType)}</span></span>`
cellRawValue = (aggregationType != "summary") ? cellRawValue[aggregationType].format(precision) + " " + unit : " "
row += "<div col=\"" + colIndex + "\" class=\"datatable-group-cell\" style=\"" + this._columnsConvertWidthToStyle(column.width) + "\">" + aggregationSettings + cellRawValue + "</div>"
} else {
row += "<div col=\"" + colIndex + "\" class=\"datatable-group-cell\" style=\"" + this._columnsConvertWidthToStyle(column.width) + "\"></div>"
}
}
row += "<div class='datatable-cell datatable-cell-blank'></div>"
return row
}
/**
* Render the content of the 1st cell of a single *group* row of the datatable.
*
* @private
* @ignore
* @param {object} record
* @returns {string} Html source for a *group* row
*/
_renderRowGroupContent1stColumn(record) {
// Get group field
let groupFieldId = this.collection.group[record.$groupLevel]
let groupColumn = this.getColumn(groupFieldId)
// Check if it's a collapsed group
let groupClass = (this.collection.collapsedGroups.includes(record.$groupId)) ? "datatable-group-collapsed" : "datatable-group-expanded"
// The 1st cell of the row includes:
// - an icon to expand/collapse the group
// - the group hierarchy (ex: 1.3.7)
// - the group name
let groupRawValue = record.$name
let groupCellValue = (groupColumn) ? groupColumn.renderer(groupRawValue, record) : "..."
return "<span class='" + groupClass + "'></span>" + // Icon to expand/collapse the group
((this.showGroupHierarchy) ? "<span class='datatable-group-hierarchy'>" + record.$groupId + "</span>" : "") + // Group hierarchy
groupCellValue + " (" + record.$size + ")" // Group name
}
/**
* Render a single column header.
*
* @private
* @ignore
* @param {object} column - The column config
* @param {number} index - The column index
* @returns {string} Html source for a column header
*/
_renderColumnHeader(column, index) {
// Try to get local column config from localStorage, in case it exists
let localColumnWidthStyle = this._columnsConvertWidthToStyle(column.width)
let localColumnTitleWidthStyle = this._columnsConvertWidthToStyle(column.width - 16)
// Try to get column type icon
let columnTitle = column.title
if (this.showColumnType) {
const fieldType = kiss.global.fieldTypes.find(fieldType => fieldType.value == column.type)
const icon = (fieldType) ? fieldType.icon : ""
if (icon) columnTitle = `<i class="${icon} datatable-column-header-icon"></i>${column.title}`
}
// Build the final template for the column header
return /*html*/ `<div id="header-${column.id}" col="${index}" class="datatable-column-header" style="${localColumnWidthStyle}">
<span
id="header-title-${column.id}"
class="datatable-column-header-title"
draggable="true"
ondragstart="this.closest('a-datatable')._columnsMoveWithdragAndDrop('dragstart', event, this)"
ondragover="this.closest('a-datatable')._columnsMoveWithdragAndDrop('dragover', event, this)"
ondragleave="this.closest('a-datatable')._columnsMoveWithdragAndDrop('dragleave', event, this)"
ondrop="this.closest('a-datatable')._columnsMoveWithdragAndDrop('drop', event, this)"
style="${localColumnTitleWidthStyle}"
>
${columnTitle}
</span>
<span id="header-properties-for:${column.id}" class="datatable-column-header-properties fas fa-chevron-down"> </span>
<span id="header-resizer-for:${column.id}" class="datatable-column-header-resizer"> </span>
</div>`.removeExtraSpaces()
}
/**
* Prepare renderers for special column types:
* - number
* - date
* - select
* - checkbox
* - link
* - button
* - ...
*
* @private
* @ignore
* @returns this
*/
_prepareCellRenderers() {
// Cache all the <Select> fields options into a Map for a faster access when rendering
this._prepareColumnValuesForSelectFields()
// Attach renderers to columns
this.columns.forEach(column => {
// Some renderers don't need to be re-computed each time the view is rendered
if (column.renderer && !["checkbox", "select", "rating"].includes(column.type)) {
return
}
// Otherwise, the renderer depends on the field type
switch (column.type) {
case "number":
column.renderer = this._prepareCellRendererForNumbers(column)
break
case "date":
column.renderer = this._prepareCellRendererForDates(column)
break
case "checkbox":
column.renderer = this._prepareCellRendererForCheckboxes(column)
break
case "select":
column.renderer = this._prepareCellRendererForSelectFields(column)
break
case "selectViewColumn":
column.renderer = this._prepareCellRendererForSelectViewColumnFields(column)
break
case "directory":
column.renderer = this._prepareCellRendererForDirectory(column)
break
case "link":
column.renderer = this._prepareCellRendererForLinkFields(column)
break
case "color":
column.renderer = this._prepareCellRendererForColors(column)
break
case "icon":
column.renderer = this._prepareCellRendererForIcons(column)
break
case "password":
column.renderer = this._prepareCellRendererForPasswords(column)
break
case "attachment":
case "aiImage":
column.renderer = this._prepareCellRendererForAttachments(column)
break
case "button":
column.renderer = this._prepareCellRendererForButtons(column)
break
case "rating":
column.renderer = this._prepareCellRendererForRatings(column)
break
case "slider":
column.renderer = this._prepareCellRendererForSliders(column)
break
default:
column.renderer = this._prepareDefaultCellRenderer(column)
}
})
return this
}
/**
* Define the default column renderer
*
* @private
* @ignore
*/
_prepareDefaultCellRenderer(column) {
return function (value) {
return ((value || "") + "").escapeHtml()
}
}
/**
* Define the default column renderer
*
* @private
* @ignore
*/
_prepareCellRendererForPasswords() {
return function () {
return "***"
}
}
/**
* Define the column renderer for fields which type is "number"
*
* @private
* @ignore
*/
_prepareCellRendererForNumbers(column) {
const field = this.model.getField(column.id)
const precision = (field) ? field.precision : 2 // Default precision = 2
const unit = (field.unit) ? " " + field.unit : ""
return function (value) {
if (value === undefined) return ""
return Number(value).format(precision) + unit
}
}
/**
* Define the column renderer for fields which type is "date"
*
* @private
* @ignore
*/
_prepareCellRendererForDates(column) {
// const field = this.model.getField(column.id)
// const dateFormat = (field) ? field.dateFormat : "YYYY-MM-AA"
return function (value) {
if (!value) return ""
return new Date(value).toLocaleDateString()
}
}
/**
* Define the column renderer for fields which type is "checkbox"
*
* @private
* @ignore
*/
_prepareCellRendererForCheckboxes(column) {
const field = this.model.getField(column.id)
const shape = field.shape || "square"
const iconColorOn = field.iconColorOn || "#000000"
try {
if (field.type == "lookup") {
const linkId = field.lookup.linkId
const linkField = this.model.getField(linkId)
const foreignModelId = linkField.link.modelId
const foreignModel = kiss.app.models[foreignModelId]
const sourceField = foreignModel.getField(field.lookup.fieldId)
shape = sourceField.shape
iconColorOn = sourceField.iconColorOn
}
} catch (err) {
log("kiss.ui - Datatable - Couldn't generate renderer for checkboxes", 4)
return function (value) {
return value
}
}
const iconClasses = kiss.ui.Checkbox.prototype.getIconClasses()
const defaultIconOn = iconClasses[shape]["on"]
const defaultIconOff = iconClasses[shape]["off"]
return function (value) {
return `<span ${(value === true) ? `style="color: ${iconColorOn}"` : ""} class=\"${(value === true) ? defaultIconOn + " datatable-type-checkbox-checked" : defaultIconOff + " datatable-type-checkbox-unchecked"}\"/>`
}
}
/**
* Define the column renderer for fields which type is "rating"
*
* @private
* @ignore
* @returns {function} column renderer
*/
_prepareCellRendererForRatings(column) {
const field = this.model.getField(column.id)
const iconColorOn = field.iconColorOn || "#ffd139"
const iconColorOff = field.iconColorOff || "#dddddd"
const shape = field.shape || "star"
const iconClasses = kiss.ui.Rating.prototype.getIconClasses()
const icon = iconClasses[shape]
const max = field.max || 5
return function (value) {
let html = ""
for (let i = 0; i < max; i++) {
const color = (i < value) ? iconColorOn : iconColorOff
html += /*html*/ `<span class="rating ${icon}" style="color: ${color}" index=${i}></span>`
}
return html
}
}
/**
* Define the column renderer for fields which type is "slider"
*
* @private
* @ignore
* @returns {function} column renderer
*/
_prepareCellRendererForSliders(column) {
const field = this.model.getField(column.id)
const min = field.min || 0
const max = field.max || 100
const step = field.step || 5
const unit = field.unit || ""
return function (value) {
return /*html*/ `<span class="field-slider-container">
<input class="field-slider" type="range" value="${value || 0}" min="${min}" max="${max}" step="${step}" style="pointer-events: none;">
<span class="field-slider-value">${value || 0} ${unit}</span>
</span>`
}
}
/**
* Define the column renderer for fields which type is "color"
*
* @private
* @ignore
*/
_prepareCellRendererForColors() {
return function (value) {
if (!value) return ""
return `<span class="datatable-type-color" style="background: ${value}"></span>`
}
}
/**
* Define the column renderer for fields which type is "icon"
*
* @private
* @ignore
*/
_prepareCellRendererForIcons() {
return function (value) {
if (!value) return ""
return `<span class="${value}"/>`
}
}
/**
* Define the column renderer for fields which type is "directory"
*
* @private
* @ignore
* @param {object} column
* @returns {function} column renderer
*/
_prepareCellRendererForDirectory(column) {
return function (values) {
return [].concat(values).map(value => {
if (!value) return ""
let name
switch (value) {
case "*":
name = kiss.directory.roles.everyone.label
break
case "$authenticated":
name = kiss.directory.roles.authenticated.label
break
case "$creator":
name = kiss.directory.roles.creator.label
break
case "$nobody":
name = kiss.directory.roles.nobody.label
break
default:
name = kiss.directory.getEntryName(value)
}
return (name) ? `<span class="field-select-value">${name}</span>` : ""
}).join("")
}
}
/**
* Define the column renderer for fields which type is "link"
*
* @private
* @ignore
* @param {object} column
* @returns {function} column renderer
*/
_prepareCellRendererForLinkFields(column) {
const field = this.model.getField(column.id)
if (!field || !field.link) return () => ""
const linkModelId = field.link.modelId
if (!linkModelId) return () => ""
const linkModel = kiss.app.models[linkModelId]
if (!linkModel) return () => ""
return function () {
return `<span class="field-link-value-cell" modelId="${linkModelId}">
${(field.multiple)
? linkModel.namePlural + " <span class='fas fa-sitemap'></span>"
: linkModel.name + " <span class='fas fa-link'></span>"}
</span>`.removeExtraSpaces()
}
}
/**
* Define the column renderer for fields which type is "button"
*
* @private
* @ignore
*/
_prepareCellRendererForButtons(column) {
// Normalize column ids
let colIndex = "column_" + kiss.tools.shortUid()
if (!column.id) column.id = colIndex
// Normalize column titles to a string
column.title = column.text || txtTitleCase("action")
return function (value, record, rowIndex, colIndex) {
return `
<center>
<span id="column-button-${rowIndex}-${colIndex}" class="a-button datatable-cell-button" ${(column.button.tip) ? `onmouseover="this.attachTip('${column.button.tip}')"` : ""} onclick="this.getComponent()._rowTriggerButtonAction('${rowIndex}', '${column.id}', '${record.id}')">
${ (column.button.icon) ? `<span class="button-icon ${column.button.icon}"></span>` : "" }
${ (column.button.text) ? `<span class="button-text">${column.button.text}</span>` : "" }
</span>
</center>`.removeExtraSpaces()
}
}
/**
* Define the column renderer for fields which type is "select"
*
* @private
* @ignore
* @param {object} column
* @returns {function} column renderer
*/
_prepareCellRendererForSelectFields(column) {
// If the <Select> field has its own specific renderer, we use it
const field = this.model.getField(column.id)
if (field.valueRenderer) return field.valueRenderer
const options = this.cachedSelectFields.get(column.id)
// If no options, returns default layout
if (!options) {
return function (values) {
return [].concat(values).map(value => {
if (!value) return ""
return `<span class="field-select-value">${value}</span>`
}).join("")
}
}
// If options, returns values with the right option colors
return function (values) {
return [].concat(values).map(value => {
if (!value) return ""
let option = options.get(("" + value).toLowerCase())
if (!option) option = {
value: value
}
if (!option.value || option.value == " ") return ""
return `<span class="field-select-value" ${(option.color) ? `style="color: #ffffff; background-color: ${option.color}"` : ""}>${option.value}</span>`
}).join("")
}
}
/**
* Define the column renderer for fields which type is "selectViewColumn"
*
* @private
* @ignore
* @returns {function} column renderer
*/
_prepareCellRendererForSelectViewColumnFields() {
return function (values) {
return [].concat(values).map(value => {
if (!value) return ""
return `<span class="field-select-value">${value}</span>`
}).join("")
}
}
/**
* Cache all the <Select> fields options into a Map for a faster access when rendering
*
* @private
* @ignore
*/
_prepareColumnValuesForSelectFields() {
this.cachedSelectFields = new Map()
for (const field of this.model.fields) {
if (field.type == "select") {
const options = (typeof field.options == "function") ? field.options() : (field.options || [])
let mapOptions = new Map(options.map(option => [option.value.toLowerCase(), {
value: option.label || option.value,
color: option.color
}]))
this.cachedSelectFields.set(field.id, mapOptions)
}
}
}
/**
* Define the column renderer for fields which type is "attachment"
*
* @private
* @ignore
*/
_prepareCellRendererForAttachments(column) {
const _this = this
return function (value) {
if ((!value) || (value == " ") || !Array.isArray(value)) return ""
let attachmentItems = value.map((file, i) => {
if (!file.path) return ""
let preview
let filePath = kiss.tools.createFileURL(file, _this.thumbSize || "s")
const fileExtension = file.path.split(".").pop().toLowerCase()
if (["jpg", "jpeg", "png", "gif", "webp"].indexOf(fileExtension) != -1) {
// Image
// preview = `<img id="${file.id}" class="datatable-type-attachment-image" src="${filePath}" onmouseenter="$('${_this.id}')._cellShowAttachmentName(event, '${this.id}')"></img>`
preview = `<img id="${file.id}" class="datatable-type-attachment-image" src="${filePath}" loading="lazy"></img>`
} else {
// Other
const {
icon,
color
} = kiss.tools.fileToIcon(fileExtension)
// preview = `<span id="${file.id}" style="color: ${color}" class="fas ${icon} datatable-type-attachment-icon" onmouseenter="$('${_this.id}')._cellShowAttachmentName(event, '${this.id}')"></span>`
preview = `<span id="${file.id}" style="color: ${color}" class="fas ${icon} datatable-type-attachment-icon"></span>`
}
return /*html*/ `<span id="${file.id}" class="datatable-type-attachment">${preview}</span>`
}).join("")
return `<span class="datatable-type-attachments-container" onclick="$('${_this.id}')._cellPreviewAttachment(event, '${this.id}')">${attachmentItems}</span>`
}
}
/**
* Preview an attachment
*
* @private
* @ignore
* @param {object} event
* @param {string} fieldId
*/
_cellPreviewAttachment(event, fieldId) {
const attachmentId = event.target.id
const cell = event.target.closest(".datatable-cell")
const record = this._cellGetRecord(cell)
const cellAttachments = record[fieldId]
createPreviewWindow(cellAttachments, attachmentId)
}
/**
* Display a tooltip over a file attachment
*
* @private
* @ignore
*/
_cellShowAttachmentName(event, fieldId) {
const attachmentId = event.target.id
const cell = event.target.closest(".datatable-cell")
const record = this._cellGetRecord(cell)
const cellAttachments = record[fieldId]
const attachment = cellAttachments.get(attachmentId)
let tipId = uid()
createHtml({
id: tipId,
position: "absolute",
display: "block",
zIndex: 1000,
html: attachment.filename,
class: "a-tip",
methods: {
load: function () {
document.onmousemove = (event) => {
if ($(tipId)) $(tipId).showAt(event.pageX, event.pageY + 20)
}
$(event.target.parentNode.id).onmouseleave = () => {
document.onmousemove = null
this.destroy()
}
// Ensure the tip is destroyed in case the mouseleave event is not triggered (which unfortunately happens)
setTimeout(() => this.destroy(), 5000)
},
destroy: function () {
try {
this.deepDelete()
} catch (err) {}
}
}
}).render()
}
/**
* Render the virtual scrollbar
*
* @private
* @ignore
*/
_renderScroller() {
// getBoundingClientRect is a bit behind the dom rendering
setTimeout(() => {
this.datatableScrollerContainer.style.top = this.datatableBody.getBoundingClientRect().top + "px"
this.datatableScrollerContainer.style.left = this.getBoundingClientRect().right - this.datatableScrollerContainer.offsetWidth + "px"
this._showScroller()
}, 50)
// Set the virtual scrollbar height within the container.
// Setting it bigger than the container forces the browser to generate a real scrollbar.
this.datatableScrollerContainer.style.height = this.datatableBody.offsetHeight - 10 + "px"
this.datatableScroller.style.height = Math.min(this.collection.count * (this.rowHeight), 10000) + "px"
}
/**
* Show the virtual scroller
*
* @private
* @ignore
*/
_showScroller() {
if (this.showScroller !== false) {
setTimeout(() => {
this.datatableScrollerContainer.style.visibility = "visible"
}, 0)
}
}
/**
* Hide the virtual scroller
*
* @private
* @ignore
*/
_hideScroller() {
this.datatableScrollerContainer.style.visibility = "hidden"
}
/**
* Sync the virtual scrollbar position with the current datatable "skip" value
*
* @private
* @ignore
*/
_renderScrollerPosition() {
let percent = this.skip / (this.collection.records.length - this.limit)
let topPosition = Math.round((this.datatableScroller.offsetHeight - this.datatableBody.offsetHeight) * percent)
this.preventScroll = true // Disable onscroll event to avoid echo
this.datatableScrollerContainer.scrollTop = topPosition
}
/**
* Highlight the records that are selected in the rendered page
*
* @private
* @ignore
*/
_renderSelection() {
if (!this.selectedRecords) return
this.selectedRecords.forEach(recordId => {
let rowIndexes = this._rowGetAllIndexes(recordId)
rowIndexes.forEach(rowIndex => this._rowSelect(rowIndex))
})
}
/**
* Restore the selection of the rendered page.
* First clean the existing selection that might be obsolete,
* then add the active selection.
*
* @private
* @ignore
*/
_renderSelectionRestore() {
this.getSelection()
this._renderBody()
}
/**
*
* Render the toolbar
*
* The toolbar includes multiple components:
* - button to create a new record
* - button to select columns
* - button to sort
* - button to filter
* - field to group
* - button to expand groups
* - button to collapse groups
* - button to show/hide group hierarchy
* - buttons to paginate (start, previous, next, end) and actual page number
*
* @private
* @ignore
*/
_renderToolbar() {
// If the toolbar is already rendered, we just update it
if (this.isToolbarRendered) {
this._groupUpdateGroupingFields()
return
}
// New record creation button
createButton({
hidden: !this.canCreateRecord,
class: "datatable-create-record",
target: "create:" + this.id,
text: this.config.createRecordText || this.model.name.toTitleCase(),
icon: "fas fa-plus",
iconColor: this.color,
borderWidth: "3px",
borderRadius: "32px",
maxWidth: (kiss.screen.isMobile && kiss.screen.isVertical()) ? 160 : null,
action: async () => this.createRecord(this.model)
}).render()
// Actions button
createButton({
hidden: this.showActions === false,
target: "actions:" + this.id,
tip: txtTitleCase("actions"),
icon: "fas fa-bolt",
iconColor: this.color,
width: 32,
action: () => this._buildActionMenu()
}).render()
// Column selection button
createButton({
hidden: !this.canSelectFields,
target: "select:" + this.id,
tip: txtTitleCase("#display fields"),
icon: "fas fa-bars fa-rotate-90",
iconColor: this.color,
width: 32,
action: () => this.showFieldsWindow()
}).render()
// Sorting button
createButton({
hidden: !this.canSort,
target: "sort:" + this.id,
tip: txtTitleCase("to sort"),
icon: "fas fa-sort",
iconColor: this.color,
width: 32,
action: () => this.showSortWindow()
}).render()
// Filtering button
createButton({
hidden: !this.canFilter,
target: "filter:" + this.id,
tip: txtTitleCase("to filter"),
icon: "fas fa-filter",
iconColor: this.color,
width: 32,
action: () => this.showFilterWindow()
}).render()
// Layout button
createButton({
hidden: !this.showLayoutButton,
target: "layout:" + this.id,
tip: {
text: txtTitleCase("layout"),
minWidth: 100
},
icon: "fas fa-ellipsis-v",
iconColor: this.color,
width: 32,
action: () => this._buildLayoutMenu()
}).render()
// Column creation button
createButton({
hidden: !this.canAddField,
target: "add:" + this.id,
tip: txtTitleCase("add a column"),
icon: "fas fa-plus",
iconColor: this.color,
width: 32,
action: () => this._showColumnSetup()
}).render()
// Grouping
let groupingFields = this._groupGetModelFields()
let groupingFieldValues = []
this.collection.group.forEach(fieldId => {
let groupingField = groupingFields.find(field => field.value == fieldId)
if (groupingField) groupingFieldValues.push(groupingField.value)
})
createSelect({
hidden: !this.canGroup,
target: "group:" + this.id,
id: "grouping-field:" + this.id,
label: txtTitleCase("group by"),
multiple: true,
allowClickToDelete: true,
options: groupingFields,
minWidth: 200,
maxHeight: () => kiss.screen.current.height - 200,
optionsColor: this.color,
value: groupingFieldValues,
styles: {
"this": "align-items: center;",
"field-label": "white-space: nowrap;",
"field-select": "white-space: nowrap;",
},
events: {
change: async function (event) {
let groupFields = this.getValue()
// Restrict to 6 grouping fields
if (groupFields.length > 6) {
let fieldGroupSelect = $(this.id)
fieldGroupSelect.value = fieldGroupSelect.getValue().slice(0, 6)
fieldGroupSelect._renderValues()
createDialog({
type: "message",
title: txtTitleCase("seriously"),
icon: "fas fa-exclamation-triangle",
message: txtTitleCase("#too many groups"),
buttonOKText: txtTitleCase("#understood")
})
return
}
// Publish the "grouping" event
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_GROUPING:" + viewId, groupFields)
}
}
}).render()
// Expand button
this.buttonExpand = createButton({
hidden: (!this.showGroupButtons || this.collection.group.length === 0),
target: "expand:" + this.id,
tip: txtTitleCase("expand all"),
icon: "far fa-plus-square",
iconColor: this.color,
width: 32,
action: () => this.expandAll()
}).render()
// Collapse button
this.buttonCollapse = createButton({
hidden: (!this.showGroupButtons || this.collection.group.length === 0),
target: "collapse:" + this.id,
tip: txtTitleCase("collapse all"),
icon: "far fa-minus-square",
iconColor: this.color,
width: 32,
action: () => this.collapseAll()
}).render()
// View refresh button
if (!kiss.screen.isMobile) {
createButton({
target: "refresh:" + this.id,
tip: txtTitleCase("refresh"),
icon: "fas fa-undo-alt",
iconColor: this.color,
width: 32,
events: {
click: () => this.reload()
}
}).render()
}
// Button to switch the hierarchy numbers
// TODO: disabled at the moment: move the button to the setup menu
this.buttonShowHierarchy = createCheckbox({
hidden: true,
// hidden: (this.collection.group.length === 0) || (this.showGroupHierarchy === false),
target: "hierarchy:" + this.id,
tip: txtTitleCase("show group hierarchy"),
iconOff: "fas fa-list-ol",
iconOn: "fas fa-list-ol",
iconColorOn: this.color,
checked: this.showGroupHierarchy,
events: {
change: (event) => {
this.showGroupHierarchy = event.target.getValue()
this._render()
}
}
}).render()
// Search button
createButton({
hidden: !this.canSearch,
target: "search:" + this.id,
icon: "fas fa-search",
iconColor: this.color,
width: 32,
events: {
click: () => this.showSearchBar()
}
}).render()
// Pager first
createButton({
hidden: kiss.screen.isMobile && kiss.screen.isVertical(),
target: "pager-first:" + this.id,
icon: "fas fa-step-backward",
iconColor: this.color,
width: 32,
events: {
click: () => this.showFirstPage()
}
}).render()
// Pager previous
createButton({
target: "pager-previous:" + this.id,
icon: "fas fa-chevron-left",
iconColor: this.color,
width: 32,
events: {
click: () => this.showPreviousPage()
}
}).render()
// Pager next
createButton({
target: "pager-next:" + this.id,
icon: "fas fa-chevron-right",
iconColor: this.color,
width: 32,
events: {
click: () => this.showNextPage()
}
}).render()
// Pager last
createButton({
hidden: kiss.screen.isMobile && kiss.screen.isVertical(),
target: "pager-last:" + this.id,
icon: "fas fa-step-forward",
iconColor: this.color,
width: 32,
events: {
click: () => this.showLastPage()
}
}).render()
this._buildCustomButtons()
// Flag the toolbar as "rendered", so that the method _renderToolbar() is idempotent
this.isToolbarRendered = true
}
/**
*
* ROWS API
*
*/
/**
* Check / Uncheck a row with the row checkbox.
*
* @private
* @ignore
* @param {integer} rowIndex - The row number in the view
*/
_rowToggleSelect(rowIndex) {
let checkbox = this._rowGetCheckbox(rowIndex)
let recordId = checkbox.parentNode.getAttribute("recordId")
let rowIndexes = this._rowGetAllIndexes(recordId)
let isSelected = (this.selectedRecords.indexOf(recordId) != -1)
if (isSelected) {
rowIndexes.forEach(rowIndex => this._rowDeselect(rowIndex))
kiss.selection.delete(this.id, recordId)
} else {
rowIndexes.forEach(rowIndex => this._rowSelect(rowIndex))
kiss.selection.insertOne(this.id, recordId)
}
// Update the datatable
this.selectedRecords = kiss.selection.get(this.id)
return recordId
}
/**
* Select a row, and add it to the collection selection.
* The index is relative to the page (the row 0 can be the nth record in the collection).
*
* @private
* @ignore
* @param {number} rowIndex - The row number in the current page
*/
_rowSelect(rowIndex) {
// Update the checkbox
let checkbox = this._rowGetCheckbox(rowIndex)
if (!checkbox) return
checkbox.classList.add("datatable-row-checkbox-on")
checkbox.classList.remove("datatable-row-checkbox-off")
// Highlight the selected row
let row = this.datatableBody.querySelector("[row=\"" + rowIndex + "\"]")
row.classList.add("datatable-row-selected")
}
/**
* Deselect a row, and remove it from the collection selection.
* The index is relative to the page (the row 0 can be the nth record in the collection).
*
* @private
* @ignore
* @param {number} rowIndex - The row number in the current page
*/
_rowDeselect(rowIndex) {
// Update the checkbox
let checkbox = this._rowGetCheckbox(rowIndex)
if (!checkbox) return
checkbox.classList.add("datatable-row-checkbox-off")
checkbox.classList.remove("datatable-row-checkbox-on")
// Remove the highlight on the selected row
let row = this.datatableBody.querySelector("[row=\"" + rowIndex + "\"]")
row.classList.remove("datatable-row-selected")
}
/**
* Highlight a row
*
* @private
* @ignore
* @param {number} rowIndex - The row index to highlight
*/
_rowHighlight(rowIndex) {
let row = this.querySelector("[row=\"" + rowIndex + "\"]")
if (!row) return
row.classList.add("datatable-row-selected")
}
/**
* Trigger the action defined in a column which type is "button"
*
* @private
* @ignore
* @param {number} rowIndex
* @param {number} colId
* @param {string} recordId
*/
async _rowTriggerButtonAction(rowIndex, colId, recordId) {
const column = this.columns.get(colId)
let record = this.collection.records[rowIndex]
if (column.button.action) await column.button.action(rowIndex, colId, recordId, record)
}
/**
* Get the checkbox used to select/deselect a row.
* The index is relative to the page (the row 0 can be the nth record in the collection).
*
* @private
* @ignore
* @param {integer} rowIndex
* @returns {HTMLElement} The checkbox input element
*/
_rowGetCheckbox(rowIndex) {
return this.datatableBody1stColumn.querySelector("[row=\"" + rowIndex + "\"]").querySelector(".datatable-row-checkbox")
}
/**
* Get the index of a record in the active page.
*
* @private
* @ignore
* @param {string} recordId
* @returns {integer} The row index, or null if it wasn't found in the page
*/
_rowGetIndex(recordId) {
let row = this.datatableBody.querySelector("div[recordId='" + recordId + "']")
if (row) return row.getAttribute("row")
else return null
}
/**
* Find the index of a record in the datatable (including hidden rows)
*
* @private
* @ignore
* @param {string} recordId
* @returns {number} The index of the record in the datatable, or -1 if not found
*/
_rowFindIndex(recordId) {
return this.collection.records.findIndex(record => record.id == recordId)
}
/**
* Find all the indexes of a record in the active page.
*
* @private
* @ignore
* @param {string} recordId
* @returns {integer} The row index, or null if it wasn't found in the page
*/
_rowGetAllIndexes(recordId) {
let rows = this.datatableBody.querySelectorAll("div[recordId='" + recordId + "']")
if (rows) return Array.from(rows).map(row => row.getAttribute("row"))
else return null
}
/**
* Get the row height config stored locally
*
* @private
* @ignore
*/
_getRowHeightFromLocalStorage() {
const localStorageId = "config-datatable-" + this.id + "-row-height"
const rowHeight = localStorage.getItem(localStorageId)
if (!rowHeight) return this.defaultRowHeight
return Number(rowHeight)
}
/**
*
* COLUMNS MANAGEMENT
*
*/
/**
* Save the width of a column in the localStorage
*
* @private
* @ignore
* @param {string} columnId - Id of the column to resize
* @param {number} newWidth - New column width
*/
_columnsSetWidth(columnId, newWidth) {
let localStorageId
// 1st column
if (columnId == "1stColumn") {
localStorage.setItem("config-datatable-" + this.id + "-1st-column", newWidth)
return
}
// Other columns: get the column config and update it
let columnIndex = this.columns.findIndex(column => column.id == columnId)
if (newWidth <= 10) newWidth = 10
this.columns[columnIndex].width = newWidth
// Save new column size locally
localStorageId = "config-datatable-" + this.id + "-columns"
localStorage.setItem(localStorageId, JSON.stringify(this.columns))
}
/**
* Drag and drop a column
*
* @private
* @ignore
* @param {string} phase - dragstart | dragover | dragleave | drop
* @param {object} event - The drag Event: dragStart | dragOver | dragLeave | drop
* @param {object} element - The DOM element which is dragged
*/
_columnsMoveWithdragAndDrop(phase, event, element) {
let target = event.target
let targetCenterX = null
let colIndex = target.closest("div").getAttribute("col")
let columnCells = Array.from(this.querySelectorAll("div[col='" + colIndex + "']"))
switch (phase) {
case "dragstart":
// Store the column to be moved
this.sourceColumnId = target.id.split("title-")[1]
break
case "dragover":
// Adjust target column style to show where to drop the column
targetCenterX = target.offsetLeft + target.clientWidth / 2
if (event.x < targetCenterX) {
columnCells.forEach(cell => {
cell.classList.remove("datatable-column-dragover-right")
cell.classList.add("datatable-column-dragover-left")
})
} else {
columnCells.forEach(cell => {
cell.classList.remove("datatable-column-dragover-left")
cell.classList.add("datatable-column-dragover-right")
})
}
event.preventDefault()
return false
case "dragleave":
// Restore style of header and column
columnCells.forEach(cell => {
cell.classList.remove("datatable-column-dragover-left")
cell.classList.remove("datatable-column-dragover-right")
})
break
case "drop":
event.stopPropagation()
// Restore style of header and column
columnCells.forEach(cell => {
cell.classList.remove("datatable-column-dragover-left")
cell.classList.remove("datatable-column-dragover-right")
})
// Perform the drop action
targetCenterX = target.offsetLeft + target.clientWidth / 2
let position = (event.x < targetCenterX) ? "before" : "after"
this._columnsMove(this.sourceColumnId, target.id.split("title-")[1], position)
break
}
}
/**
* Resize a column
*
* @private
* @ignore
*/
_columnsResizeWithDragAndDrop(event, element) {
// Get column cells
let columnId = element.parentNode.id.split("header-")[1] // headers id are built like: header-columnId
let colIndex = element.parentNode.getAttribute("col")
let columnCells = Array.from(this.querySelectorAll("div[col='" + colIndex + "']"))
// Get column header elements
let columnHeader = element.parentNode
let columnHeaderTitle = columnHeader.children[0]
columnHeader.mouseStartX = event.x
let currentWidth = columnHeader.clientWidth
let newWidth
// Set minimum column size
let columnMinSize = (columnId == "1stColumn") ? 90 : 20
// !!!
// TODO: memory leak to solve here => listeners seem to not be garbage collected properly
// !!!
document.onmousemove = (event) => {
let _event = event
setTimeout(() => {
newWidth = (currentWidth + _event.x - columnHeader.mouseStartX)
if (newWidth > columnMinSize) {
// Resize the header
columnHeader.style.minWidth = columnHeader.style.width = newWidth + "px"
columnHeaderTitle.style.minWidth = columnHeaderTitle.style.width = newWidth - 16 + "px"
// Resize the column
columnCells.forEach(cell => cell.style.width = cell.style.minWidth = newWidth + "px")
if (columnId == "1stColumn") this._columnsSetFirstColumnWidth(newWidth)
}
}, 1)
}
// Remove listeners
document.onmouseup = () => {
this._columnsSetWidth(columnId, Math.max(columnMinSize, newWidth))
document.onmousemove = null
document.onmouseup = null
}
}
/**
* Convert a numeric width into a style
*
* @private
* @ignore
* @param {number} width
* @returns {string} The style - Example: style="width: 215px; min-width: 215px"
*/
_columnsConvertWidthToStyle(width) {
return "width: " + width + "px; min-width: " + width + "px;"
}
/**
* Convert a numeric width into a style, for the first column
*
* @private
* @ignore
* @param {number} width
* @returns {string} The style - Example: style="width: 215px; min-width: 215px"
*/
_columnsConvertFirstColumnWidthToStyle(width) {
return "width: " + width + "px; min-width: " + width + "px;"
}
/**
* Resize the datatable first column, used to display:
* - selection checkboxes
* - group names, when the view is grouped by a field
*
* @private
* @ignore
* @param {number} newWidth
*/
_columnsSetFirstColumnWidth(newWidth) {
this.defaultColumnWidth.firstColumn = newWidth
this.datatableHeader1stColumn.style.minWidth = newWidth + "px"
this.datatableBody1stColumn.style.minWidth = newWidth + "px"
}
/**
* Adjust columns according to a local configuration stored in the localStorage.
* If a configuration is found for a specific column, then it is applied.
* Otherwise, a DEFAULT_WIDTH width is applied.
*
* @private
* @ignore
*/
_columnsAdjustWidthFromLocalStorage() {
// Adjust 1st column width
let localStorageId = "config-datatable-" + this.id + "-1st-column"
let firstColumnWidth = localStorage.getItem(localStorageId)
this.defaultColumnWidth.firstColumn = (firstColumnWidth || this.defaultColumnWidth.firstColumn)
// Adjust other columns width
localStorageId = "config-datatable-" + this.id + "-columns"
let localColumns = JSON.parse(localStorage.getItem(localStorageId))
this.columns = this.columns.map(column => {
const defaultColumnWidth = this.defaultColumnWidth[column.type] || this.defaultColumnWidth.default
column.width = column.width || defaultColumnWidth
if (localColumns) {
// Check if there is a matching column in the local config
let localColumn = localColumns.find(localColumn => localColumn.id == column.id)
// If the local column has a width, then adjust the datatable column accordingly
if (localColumn && localColumn.width) column.width = localColumn.width
}
return column
})
}
/**
* Set the column aggregation type for grouped views
*
* @private
* @ignore
* @param {number} colIndex
* @param {number} x - x position to display the menu
* @param {number} Y - y position to display the menu
*/
_columnsSetAggregationType(column, x, y) {
createMenu({
items: [{
text: txtTitleCase("sum"),
icon: "fas fa-chart-bar",
action: async () => {
column.summary = "sum"
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
}
},
{
text: txtTitleCase("average"),
icon: "fas fa-tachometer-alt",
action: async () => {
column.summary = "avg"
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
}
},
{
text: txtTitleCase("#no summary"),
icon: "fas fa-ban",
action: async () => {
delete column.summary
this._render()
this.updateConfig({
config: {
columns: this.columns
}
})
}
},
]
})
.render()
.showAt(x, y)
}
/**
*
* ACCESS TO CELLS
*
*/
/**
* Set new data into a cell.
* Apply the existing cell renderer, if any.
* This method is always triggered asynchronously in the background by a collection change.
*
* @private
* @ignore
* @param {string} recordId - Target record id
* @param {string} fieldId - Target field id
* @param {*} value - Value to set
*/
_cellSetValue(recordId, fieldId, value) {
try {
// Get the column config
const colIndex = this.visibleColumns.findIndex(column => (column.id == fieldId))
if (colIndex == -1) return
const column = this.visibleColumns[colIndex]
// Get the record (for specific renderers that needs more than the field value to be computed)
const record = this.collection.getRecord(recordId)
// Find all the rows that match this record
const rowIndexes = this._rowGetAllIndexes(recordId)
// For each of these rows, we update the right cell value
rowIndexes.forEach(rowIndex => {
const row = this.datatableBody.querySelector("div[row='" + rowIndex + "']")
const cell = row.querySelector("div[col='" + colIndex + "']")
cell.innerHTML = column.renderer(value, record, rowIndex, colIndex)
})
} catch (err) {
log("kiss.ui - datatable - Couldn't set the cell value", 4, err)
}
}
/**
* Set the value of a checkbox cell
*
* @private
* @ignore
* @param {string} recordId - Target record id
* @param {string} fieldId - Target field id
*/
async _cellSetCheckboxValue(recordId, fieldId) {
let record = this.collection.getRecord(recordId)
let currentCellValue = record[fieldId] || false
await record.updateFieldDeep(fieldId, !currentCellValue)
}
/**
* Set the value of a rating cell
*
* @private
* @ignore
* @param {string} recordId - Target record id
* @param {string} fieldId - Target field id
* @param {event} event
*/
async _cellSetRatingValue(recordId, fieldId, event) {
if (!event.target.classList.contains("rating")) return
let record = this.collection.getRecord(recordId)
let currentCellValue = record[fieldId]
const index = event.target.getAttribute("index")
const newValue = Number(index) + 1
if (newValue != currentCellValue) await record.updateFieldDeep(fieldId, newValue)
}
/**
* Get the next cell up, down, left or right, starting from the current position
* If the next cell found is *not* editable, then return null
*
* @private
* @ignore
* @param {HTMLDivElement} cell - the current cell
* @param {string} direction - up|down|left|right
* @returns {HTMLDivElement|null}
*/
_cellGetNext(cell, direction) {
let shift = {
up: {
x: 0,
y: -1
},
down: {
x: 0,
y: 1
},
left: {
x: -1,
y: 0
},
right: {
x: 1,
y: 0
}
}
// Compute the next coordinates according to the direction
let rowIndex = this._cellGetRowIndex(cell) + shift[direction].y
let colIndex = this._cellGetColIndex(cell) + shift[direction].x
let row = this.datatableBody.querySelector("div[row='" + rowIndex + "']")
if (!row) return null
if (row.className == "datatable-group-row") return null
let nextCell = row.querySelector("div[col='" + colIndex + "']")
if (!nextCell) return null
// TODO: check cell security => if no access, then return null as well
if (this._cellIsEditable(nextCell)) {
return null
} else {
return nextCell
}
}
/**
*
* Simple helpers to get cell properties
*
* @private
* @ignore
*/
_cellGetRowIndex(cell) {
return Number(cell.parentNode.getAttribute("row"))
}
_cellGetColIndex(cell) {
return Number(cell.getAttribute("col"))
}
_cellGetRecordId(cell) {
let rowIndex = this._cellGetRowIndex(cell)
return this.collection.records[rowIndex].id
}
_cellGetRecordData(cell) {
let rowIndex = this._cellGetRowIndex(cell)
return this.collection.records[rowIndex]
}
_cellGetRecord(cell) {
let recordId = this._cellGetRecordId(cell)
return this.collection.getRecord(recordId)
}
_cellGetFieldId(cell) {
let col = cell.getAttribute("col")
let colHeader = this.datatableHeader.querySelector("div[col='" + col + "']")
return colHeader.id.split("header-")[1]
}
_cellGetColumn(cell) {
let colId = this._cellGetFieldId(cell)
return this.columns.get(colId)
}
_cellIsEditable(cell) {
const fieldId = this._cellGetFieldId(cell)
const field = this.model.getField(fieldId)
if (field.computed) return false
if (field.readOnly) return false
if (field.type == "lookup" || field.type == "summary") return false
if (field.acl) {
const acl = field.acl.update
return (acl !== false)
}
return true
}
/**
* Open a linked record from a cell
*
* @private
* @ignore
* @param {string} fieldId
* @param {string} recordId
*/
async _cellOpenLinkedRecord(fieldId, recordId) {
const record = this.collection.getRecord(recordId)
if (!record) return
const links = await kiss.data.relations.getLinksAndRecords(record.model.id, record.id, fieldId)
const foreignRecords = links.map(link => link.record)
// No linked records
if (links.length == 0) {
const field = record.model.getField(fieldId)
const foreignModelId = field.link.modelId
const foreignModel = kiss.app.models[foreignModelId]
const foreignModelName = foreignModel.namePlural
return createNotification(txtTitleCase("#no links", null, {
table: foreignModelName
}))
}
const field = this.model.getField(fieldId)
const foreignModel = kiss.app.models[field.link.modelId]
if (links.length == 1) {
// Single record, we open the form
const foreignRecord = foreignModel.create(foreignRecords[0])
this.selectRecord(foreignRecord)
} else {
// Multiple records, we open the window to select a record
kiss.context.records = foreignRecords
createRecordSelectorWindow(foreignModel, fieldId, foreignRecords, null, {
canSelect: false
})
}
}
/**
*
* CELL IN-PLACE EDITING
*
*/
/**
* Switch a cell to EDIT MODE
*
* @private
* @ignore
* @param {HTMLDivElement} cell - The cell to edit
*/
async _cellSwitchToEditMode(cell, event) {
const _this = this
let updateOnBlur = true
cell.onkeydown = null
// Exit immediately if its a special cell
const cellType = cell.classList[1].split("-")[2]
if (["attachment", "button", "custom"].indexOf(cellType) != -1) return
// Get main cell infos
const record = this._cellGetRecord(cell)
const recordId = record.id
const fieldId = this._cellGetFieldId(cell)
const field = this.model.getField(fieldId)
const fieldType = field.type
const column = this.getColumn(fieldId)
// Fields generated by plugins are static
if (column.isFromPlugin) return
// Cache the initial value, html, and style, to be able to roll back to it, in case we cancel the cell edition
const fieldInitialValue = record[fieldId] || ""
const cellInitialStyle = cell.getAttribute("style")
// Exit if it's a computed or readOnly cell
if (field.computed || field.readOnly) return
// Open the record for some specific field types
if (fieldType == "link") return await this.selectRecordById(recordId)
// It's a checkbox: directly switch the state
if (fieldType == "checkbox") return this._cellSetCheckboxValue(recordId, fieldId)
// It's a checkbox: directly switch the state
if (fieldType == "rating") return this._cellSetRatingValue(recordId, fieldId, event)
// Open a dialog to edit <textarea>
if (fieldType == "textarea" || fieldType == "aiTextarea") return this._cellEditTextarea(cell, field, fieldInitialValue)
// Open a dialog to edit <select> and <select view column>
if (fieldType == "select" || fieldType == "selectViewColumn") return this._cellEditSelect(cell, field)
// Open a dialog to edit <select view columns>
if (fieldType == "selectViewColumns") return this._cellEditSelectViewColumns(cell, field)
// Open a dialog to edit <directory>
if (fieldType == "directory") return this._cellEditSelect(cell, field)
// Open a palette to edit <color>
if (fieldType == "color") return this._cellEditColor(cell, field)
// Open a palette to edit <icon>
if (fieldType == "icon") return this._cellEditIcon(cell, field)
let cellWidth = cell.clientWidth + "px"
let cellHeight = cell.clientHeight + "px"
// Create a new input field inside the cell
let inputId = "input-" + recordId + "-" + fieldId
const dataType = (["number", "rating", "slider"].includes(field.type)) ? "number" : field.type
const cellValue = (typeof fieldInitialValue == "string") ? fieldInitialValue.escapeHtml() : fieldInitialValue
cell.innerHTML = `<input class="datatable-cell-edited" id="${inputId}" type="${dataType}" value="${cellValue}">`
let fieldInput = $(inputId)
// Adjust the cell and field styles so that the field occupies the whole space inside the cell
cell.style.width = fieldInput.style.width = cellWidth
cell.style.height = fieldInput.style.height = cellHeight
// Adjust field style
fieldInput.style.border = "none"
fieldInput.style.padding = "var(--datatable-cell-padding)"
fieldInput.style.color = "var(--datatable-cell)"
fieldInput.style.background = "var(--datatable-input-background)"
// Set focus and auto-edit content
fieldInput.focus()
fieldInput.select()
/**
* Observe key events for in-cell edition
*/
fieldInput.onkeydown = await async function (event) {
updateOnBlur = true // By default, we update a cell if we loose focus, like in Excel
let editNextCell = false
// SHIFT+TAB
if (event.shiftKey && event.key == "Tab") {
editNextCell = "left"
}
// TAB
else if (event.key == "Tab") {
editNextCell = "right"
}
// ENTER
else if (event.key == "Enter") {
editNextCell = "down"
}
// ESCAPE (= reset modifications)
else if (event.key == "Escape") {
updateOnBlur = false
this.reset()
}
// Edit the next cell
if (editNextCell) {
updateOnBlur = false
event.stop()
const result = this.updateCell()
if (!result) return false
const nextCell = _this._cellGetNext(cell, editNextCell)
if (nextCell) _this._cellSwitchToEditMode(nextCell)
}
}
// Update on cell exiting
fieldInput.updateCell = async function () {
// Entry validation
let success = kiss.tools.validateValue(fieldType, field, this.value)
if (!success) {
createNotification(txtTitleCase("#fields incorrect value"))
this.reset()
return false
}
const newValue = (fieldType == "number") ? Number(this.value) : this.value
if (newValue != fieldInitialValue) {
// cell.showLoading({
// size: 16
// })
// Update the record in the database using the db. This will:
// - overwrite the same cell with the same value, if the request was OK
// - rollback to another value, if the request was not OK (for example if the user didn't have enough rights)
success = await record.updateFieldDeep(fieldId, newValue)
// Rollback the value in case the operation is forbidden
if (!success) {
this.value = fieldInitialValue
this.reset()
}
// cell.hideLoading()
} else {
this.reset()
}
}
// Remove input field on exiting & restore cell style
fieldInput.reset = function () {
try {
cell.removeAttribute("style")
cell.setAttribute("style", cellInitialStyle)
cell.innerHTML = column.renderer(fieldInitialValue)
} catch (err) {
log("kiss.ui - datatable - Couldn't restore the cell value", 4, err)
}
}
// Save or discard the changes when exiting the field
fieldInput.onblur = async function () {
if (updateOnBlur) this.updateCell()
}
}
/**
* Edit a textarea cell
*
* @private
* @ignore
* @param {object} cell
* @param {object} field
* @param {string} initialValue
*/
_cellEditTextarea(cell, field, initialValue) {
const column = this._cellGetColumn(cell)
createPanel({
id: "panel-edit-textarea",
title: column.title,
headerBackgroundColor: this.color,
closable: true,
modal: true,
draggable: true,
width: 660,
height: 660,
align: "center",
verticalAlign: "center",
layout: "vertical",
items: [
// Textarea
{
id: "datatable-edit-textarea",
type: "textarea",
value: initialValue,
required: field.required,
minLength: field.minLength,
maxLength: field.maxLength,
fieldWidth: "100%",
flex: 1
},
// Buttons
{
layout: "horizontal",
defaultConfig: {
type: "button",
flex: 1,
margin: "0px 5px 0px 5px"
},
items: [
// Cancel
{
text: txtUpperCase("cancel"),
icon: "fas fa-times",
action: () => {
$("panel-edit-textarea").doNotModifyValue = true
$("panel-edit-textarea").close()
}
},
// OK
{
text: txtUpperCase("ok"),
icon: "fas fa-check",
color: "var(--green)",
iconColor: "var(--green)",
action: () => $("panel-edit-textarea").close()
}
]
}
],
events: {
// Update value on exit
onclose: () => {
if ($("panel-edit-textarea").doNotModifyValue) return
// Exit if the value didn't change
const textarea = $("datatable-edit-textarea")
let newTextareaValue = textarea.getValue()
if (newTextareaValue == initialValue) return
// Validate new value
const success = textarea.validate()
if (!success) {
createNotification(txtTitleCase("#fields incorrect value"))
return
}
// Otherwise update the record
let record = this._cellGetRecord(cell)
record.updateFieldDeep(field.id, newTextareaValue)
},
// Restore value on escape
onkeydown: function (event) {
if (event.key != "Escape") return
$("datatable-edit-textarea").setValue(initialValue)
$("panel-edit-textarea").doNotModifyValue = true
this.close()
}
},
methods: {
load: () => setTimeout(() => $("datatable-edit-textarea").focus(), 100)
}
}).render()
}
/**
* Edit a color cell
*
* @private
* @ignore
* @param {string} cell
* @param {object} field
*/
_cellEditColor(cell, field) {
let record = this._cellGetRecord(cell)
let initialValue = record[field.id]
const picker = createPanel({
modal: true,
header: false,
width: 705,
align: "center",
verticalAlign: "center",
items: [{
type: "colorPicker",
value: initialValue,
selectorBorderRadius: "32px",
events: {
change: function () {
let color = this.getValue()
record.updateFieldDeep(field.id, color)
picker.close()
}
}
}]
}).render()
}
/**
* Edit an icon cell
*
* @private
* @ignore
* @param {string} cell
* @param {object} field
*/
_cellEditIcon(cell, field) {
let record = this._cellGetRecord(cell)
let initialValue = record[field.id]
const picker = createPanel({
modal: true,
header: false,
width: 675,
align: "center",
verticalAlign: "center",
items: [{
type: "iconPicker",
value: initialValue,
autoFocus: true,
icons: kiss.webfonts.all,
selectorBorderRadius: "32px",
height: 660,
events: {
change: function () {
let icon = this.getValue()
record.updateFieldDeep(field.id, icon)
picker.close()
}
}
}]
}).render()
}
/**
* Edit a select cell
*
* @private
* @ignore
* @param {object} cell
* @param {object} field
*/
_cellEditSelect(cell, field) {
const record = this._cellGetRecord(cell)
const column = this._cellGetColumn(cell)
let initialValue = record[field.id]
createPanel({
id: "panel-edit-select",
title: column.title,
headerBackgroundColor: this.color,
modal: true,
draggable: true,
width: 660,
align: "center",
verticalAlign: "center",
layout: "vertical",
items: [
// Select
{
id: field.id,
type: field.type,
value: initialValue,
required: field.required,
fieldWidth: "100%",
maxHeight: 410,
flex: 1,
options: field.options,
roles: field.roles,
multiple: field.multiple,
template: field.template,
min: field.min,
max: field.max,
interval: field.interval,
allowClickToDelete: field.multiple,
allowSwitchOnOff: field.multiple,
allowValuesNotInList: field.allowValuesNotInList,
// Options for <Select View Column> field
viewId: field.viewId,
fieldId: field.fieldId
},
// Buttons
{
layout: "horizontal",
defaultConfig: {
type: "button",
flex: 1,
margin: "0px 5px 0px 5px"
},
items: [
// Cancel
{
text: txtUpperCase("cancel"),
icon: "fas fa-times",
action: () => {
$("panel-edit-select").doNotModifyValue = true
$("panel-edit-select").close()
}
},
// OK
{
text: txtUpperCase("ok"),
icon: "fas fa-check",
color: "var(--green)",
iconColor: "var(--green)",
action: () => $("panel-edit-select").close()
}
]
}
],
events: {
// Update value on exit
onclose: () => {
if ($("panel-edit-select").doNotModifyValue) return
const selectField = $(field.id)
// Exit if the value didn't change
let newValue = selectField.getValue()
if (newValue == initialValue) return
// Validate new value
const success = selectField.validate()
if (!success) {
createNotification(txtTitleCase("this field is required"))
return
}
// Update the record
record.updateFieldDeep(field.id, newValue)
},
// Abort with 'Escape' key
onkeydown: function (event) {
if (event.key != "Escape") return
$(field.id).setValue(initialValue)
$("panel-edit-select").doNotModifyValue = true
this.close()
}
}
}).render()
}
/**
* Edit a <select view columns> cell
*
* @private
* @ignore
* @param {object} cell
* @param {object} field
*/
async _cellEditSelectViewColumns(cell, field) {
const selectedRecord = this._cellGetRecord(cell)
const viewRecord = await kiss.app.collections.view.findOne(field.viewId)
const viewModel = kiss.app.models[viewRecord.modelId]
// Build the datatable
const datatable = createDatatable({
collection: viewModel.collection,
sort: viewRecord.sort,
filter: viewRecord.filter,
group: viewRecord.group,
canEdit: false,
canAddField: false,
canEditField: false,
canCreateRecord: false,
showActions: false,
columns: viewRecord.config.columns,
color: viewModel.color,
height: () => kiss.screen.current.height - 250,
methods: {
selectRecord: async function (record) {
const fieldId = field.fieldId[0]
const otherFieldIds = field.fieldId.slice(1)
let mapping = otherFieldIds.map(viewFieldId => {
let label = viewModel.getField(viewFieldId).label
let localField = selectedRecord.model.getFieldByLabel(label) || {}
return {
label,
id: localField.id,
viewFieldId
}
}).filter(map => map.id)
let update = {}
update[field.id] = record[fieldId]
mapping.forEach(map => update[map.id] = record[map.viewFieldId])
await selectedRecord.updateDeep(update)
this.closest("a-panel").close()
}
}
})
// Build the panel to embed the datatable
createPanel({
modal: true,
closable: true,
// Header
title: "<b>" + viewModel.namePlural + "</b>",
icon: viewModel.icon,
headerBackgroundColor: viewModel.color,
// Size and layout
display: "flex",
layout: "vertical",
width: () => kiss.screen.current.width - 200,
height: () => kiss.screen.current.height - 200,
align: "center",
verticalAlign: "center",
autoSize: true,
items: [datatable]
}).render()
}
/**
*
* OTHER MISC METHODS
*
*/
/**
* Render the menu to change datatable layout
*
* @private
* @ignore
*/
async _buildLayoutMenu() {
let buttonLeftPosition = $("layout:" + this.id).offsetLeft
let buttonTopPosition = $("layout:" + this.id).offsetTop
createMenu({
top: buttonTopPosition,
left: buttonLeftPosition,
items: [
// Title
txtTitleCase("cell size"),
"-",
// Change row height to COMPACT
{
icon: "fas fa-circle",
iconSize: "2px",
text: txtTitleCase("compact"),
action: () => {
this.rowHeight = 30
this.setRowHeight(this.rowHeight)
}
},
// Change row height to NORMAL
{
icon: "fas fa-circle",
iconSize: "6px",
text: txtTitleCase("normal"),
action: () => {
this.rowHeight = this.defaultRowHeight
this.setRowHeight(this.rowHeight)
}
},
// Change row height to MEDIUM
{
icon: "fas fa-circle",
iconSize: "10px",
text: txtTitleCase("medium"),
action: () => {
this.rowHeight = 80
this.setRowHeight(this.rowHeight)
}
},
// Change row height to TALL
{
icon: "fas fa-circle",
iconSize: "14px",
text: txtTitleCase("tall"),
action: () => {
this.rowHeight = 120
this.setRowHeight(this.rowHeight)
}
},
// Change row height to VERY TALL
{
icon: "fas fa-circle",
iconSize: "18px",
text: txtTitleCase("very tall"),
action: () => {
this.rowHeight = 160
this.setRowHeight(this.rowHeight)
}
},
"-",
// Reset columns width
{
icon: "fas fa-undo-alt",
text: txtTitleCase("#reset view params"),
action: () => this.resetLocalViewParameters()
}
]
}).render()
}
/**
* Add custom buttons to the toolbar.
* Buttons can be inserted at a custom position.
*
* @private
* @ignore
*
* @example
* const customButtons = [{
* position: 1,
* text: "Button 1",
* action: () => {console.log("Hello")}
* }, {
* position: 5,
* text: "Button 5",
* action: () => {console.log("World")}
* }]
*
* createDatatable({
* //... your datatable config here
* buttons: customButtons
* })
*/
async _buildCustomButtons() {
if (!this.buttons) return
this.buttons.forEach(button => {
const newButton = createButton(button)
const position = button.position || 1
const target = this.datatableToolbar.children[position]
this.datatableToolbar.insertBefore(newButton, target)
})
}
/**
* Show quick tips to onboard the user and explaind the basics
*/
showTutorial() {
setTimeout(() => {
kiss.tools.highlightElements([{
element: document.body.querySelector(".datatable-header-last-column"),
text: txtTitleCase("#add field help")
},
{
element: document.body.querySelector(".datatable-create-record"),
text: txtTitleCase("#create record help")
},
{
element: document.body.querySelector(".datatable-row-action"),
text: txtTitleCase("#open form help")
}
], () => {
createDialog({
message: txtTitleCase("#replay tips"),
buttonCancelText: txtTitleCase("no"),
action: () => this.showTutorial()
})
})
}, 200)
delete kiss.context.onboard
}
}
// Create a Custom Element and add a shortcut to create it
customElements.define("a-datatable", kiss.ui.Datatable)
/**
* Shorthand to create a new Datatable. See [kiss.ui.Datatable](kiss.ui.Datatable.html)
*
* @param {object} config
* @returns HTMLElement
*/
const createDatatable = (config) => document.createElement("a-datatable").init(config)
;
Source