/**
*
* The **Gallery** derives from [DataComponent](kiss.ui.DataComponent.html).
*
* It's a [simple gallery](https://kissjs.net/#ui=start§ion=gallery) with the following features:
* - choosing the fields to display in the cards
* - multi-fields sorting
* - complex filtering with combination of AND/OR filters
* - mutli-level grouping
* - virtual scrolling which also works with grouped data
* - possibility to choose the featured image to display in the card
*
* @param {object} config
* @param {Collection} config.collection - The data source collection
* @param {string} [config.imageFieldId] - The field to use as the image in the gallery. If not set, the first attachment field will be used.
* @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 {boolean} [config.showToolbar] - false to hide the toolbar (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.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.canSelectFields] - Can we select the fields (= columns) to display in the gallery? (default = true)
* @param {boolean} [config.canCreateRecord] - Can we create new records from the gallery?
* @param {boolean} [config.createRecordText] - Optional text to insert in the button to create a new record, instead of the default model's name
* @param {object[]} [config.actions] - Array of menu actions, where each menu entry is: {text: "abc", icon: "fas fa-check", action: function() {}}
* @param {number|string} [config.width]
* @param {number|string} [config.height]
* @returns this
*
* ## Generated markup
* ```
* <a-gallery class="a-gallery">
* <div class="gallery-toolbar">
* <!-- Gallery toolbar items -->
* </div>
* <div class="gallery-body-container">
* <div class="gallery-body">
* <!-- Body columns -->
* </div>
* </div>
* </a-gallery>
* ```
*/
kiss.ui.Gallery = class Gallery extends kiss.ui.DataComponent {
/**
* Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
* Instead, use one of the following methods:
*
* Create the Web Component and call its **init** method:
* ```
* const myGallery = document.createElement("a-gallery").init(config)
* ```
*
* Or use the shorthand for it:
* ```
* const myGallery = createGallery({
* id: "my-gallery",
* color: "#00aaee",
* collection: kiss.app.collections["contact"],
*
* // We can define a menu with custom actions
* actions: [
* {
* text: "Group by status",
* icon: "fas fa-sort",
* action: () => $("my-gallery").groupBy(["Status"])
* }
* ],
*
* // 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"),
* }
* })
*
* myGallery.render()
* ```
*/
constructor() {
super()
}
/**
* Generates a Gallery 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)
// Options
this.showToolbar = (config.showToolbar !== false)
this.showActions = (config.showActions !== false)
this.showSetup = (config.showSetup !== false)
this.showLayoutButton = (config.showLayoutButton !== false)
this.showGroupButtons = (config.showGroupButtons !== false)
this.canSearch = (config.canSearch !== false)
this.canSort = (config.canSort !== false)
this.canFilter = (config.canFilter !== false)
this.canGroup = (config.canGroup !== false)
this.canSelect = (config.canSelect !== false)
this.canSelectFields = (config.canSelectFields !== false)
this.actions = config.actions || []
this.buttons = config.buttons || []
this.color = config.color || "#00aaee"
this.defaultColumnWidth = 20 // in rem
// Manage groups state
this.collapsedGroups = new Set()
// Build gallery skeletton markup
let id = this.id
this.innerHTML = /*html*/
`<div class="gallery">
<div id="gallery-toolbar:${id}" class="gallery-toolbar">
<div id="create:${id}"></div>
<div id="actions:${id}"></div>
<div id="setup:${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-field:${id}"></div>
<div id="search:${id}"></div>
<div class="spacer"></div>
<div id="layout:${id}"></div>
</div>
<div class="gallery-body-container">
<div id="gallery-body:${id}" class="gallery-body"></div>
</div>
</div>`.removeExtraSpaces()
// Set gallery components
this.gallery = this.querySelector(".gallery")
this.galleryToolbar = this.querySelector(".gallery-toolbar")
this.galleryBodyContainer = this.querySelector(".gallery-body-container")
this.galleryBody = this.querySelector(".gallery-body")
this._initColumns(config.columns)
._initGalleryParams(config)
._initSize(config)
._initElementsVisibility()
._initEvents()
._initSubscriptions()
return this
}
/**
*
* GALLERY METHODS
*
*/
/**
* Load data into the gallery.
*
* Remark:
* - rendering time is proportional to the number of cards and visible fields (cards x fields)
* - rendering takes an average of 0.03 millisecond per card on an Intel i7-4790K
*
* @ignore
*/
async load() {
try {
log(`kiss.ui - Gallery ${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()
// Get the selected records
this.getSelection()
// Render the gallery toolbar
this._renderToolbar()
} catch (err) {
log(err)
log(`kiss.ui - Gallery ${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 gallery color (toolbar buttons + modal windows)
*
* @param {string} newColor
*/
async setColor(newColor) {
this.color = newColor
Array.from(this.galleryToolbar.children).forEach(item => {
if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
})
}
/**
* Show the window to setup the gallery:
* - field used to display the image
*/
showSetupWindow() {
let attachmentFields = this.model.getFieldsByType(["attachment", "aiImage"])
.filter(field => !field.deleted)
.map(field => {
return {
value: field.id,
label: field.label.toTitleCase()
}
})
createPanel({
icon: "fas fa-image",
title: txtTitleCase("setup the gallery"),
headerBackgroundColor: this.color,
modal: true,
backdropFilter: true,
draggable: true,
closable: true,
align: "center",
verticalAlign: "center",
width: "40rem",
defaultConfig: {
labelPosition: "top",
optionsColor: this.color
},
items: [
// Show images ?
{
type: "checkbox",
id: "gallery-showimage:" + this.id,
label: txtTitleCase("#gallery show image"),
labelPosition: "right",
shape: "switch",
iconColorOn: this.color,
value: this.showImage,
events: {
change: async function () {
let showImage = this.getValue()
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_SETUP:" + viewId, {
showImage
})
if (showImage == true) {
$("gallery-imagefield:" + viewId).show()
} else {
$("gallery-imagefield:" + viewId).hide()
}
}
}
},
// Source image field
{
hidden: !this.showImage,
type: "select",
id: "gallery-imagefield:" + this.id,
label: txtTitleCase("#gallery image field"),
options: attachmentFields,
maxHeight: () => kiss.screen.current.height - 200,
value: this.imageFieldId,
events: {
change: async function () {
let imageFieldId = this.getValue()
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_SETUP:" + viewId, {
imageFieldId
})
}
}
}
]
}).render()
}
/**
* 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)
}
/**
* Update the gallery size (recomputes its width and height functions)
*/
updateLayout() {
if (this.isConnected) {
this._setWidth()
this._setHeight()
this._render()
}
}
/**
* Set the gallery column width
*
* @param {number} width - The column width in pixels
*/
setColumnWidth(width) {
this.columnWidth = width
document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")
// Save new row height locally
const localStorageId = "config-view-gallery-" + this.id + "-column-width"
localStorage.setItem(localStorageId, this.columnWidth)
this.reload()
}
/**
* Reset all the columns to their default width
*/
async resetColumnsWidth() {
this.columnWidth = this.defaultColumnWidth
document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")
const localStorageId = "config-view-gallery-" + this.id + "-column-width"
localStorage.removeItem(localStorageId)
}
/**
* Collapse a group
*
* @param {string} groupId
* returns this
*/
collapseGroup(groupId, group) {
group.classList.add("gallery-group-collapsed")
group.classList.remove("gallery-group-expanded")
this.collapsedGroups.add(groupId)
this.galleryBody.querySelectorAll("[groupid]").forEach(el => {
const gid = el.getAttribute("groupid")
// Hide records of the group and all subgroups
if (gid !== groupId && gid.startsWith(groupId + ".")) {
el.style.display = "none"
} else if (gid === groupId && !el.classList.contains("gallery-group")) {
el.style.display = "none"
}
})
this._renderDetailsOfVisibleCards()
return this
}
/**
* Expand a group
*
* @param {string} groupId
* @returns this
*/
expandGroup(groupId, group) {
group.classList.add("gallery-group-expanded")
group.classList.remove("gallery-group-collapsed")
this.collapsedGroups.delete(groupId)
this.galleryBody.querySelectorAll("[groupid]").forEach(el => {
const gid = el.getAttribute("groupid")
if (!(gid === groupId || gid.startsWith(groupId + "."))) return
const isGroup = el.classList.contains("gallery-group")
const isRecord = el.classList.contains("gallery-record")
// Skip if hidden by an ancestor that is still collapsed (excluding self)
if (this._isHiddenByCollapsedParent(gid, true)) return
// If it"s a subgroup still collapsed: show the group header but hide its records
if (this.collapsedGroups.has(gid)) {
if (isGroup) {
el.style.display = "" // show the group header
} else if (isRecord) {
el.style.display = "none" // keep record hidden
}
return
}
// Otherwise show it
el.style.display = ""
})
this._renderDetailsOfVisibleCards()
return this
}
/**
* Collapse all groups
*
* @returns this
*/
collapseAll() {
this.galleryBody.querySelectorAll(".gallery-group").forEach(el => {
let groupId = el.getAttribute("groupid")
this.collapseGroup(groupId, el)
})
return this
}
/**
* Expand all groups
*
* @returns this
*/
expandAll() {
this.collapsedGroups.clear()
this.galleryBody.querySelectorAll("[groupid]").forEach(el => {
el.classList.add("gallery-group-expanded")
el.classList.remove("gallery-group-collapsed")
el.style.display = ""
})
this._renderDetailsOfVisibleCards()
return this
}
/**
* Define the specific gallery params
*
* @private
* @ignore
* @param {object} config
* @param {string} config.imageFieldId - The field to use as the image in the gallery. If not set, the first attachment field will be used.
* @returns this
*/
_initGalleryParams(config) {
if (this.record) {
this.imageFieldId = config.imageFieldId || this.record.config.imageFieldId
this.showImage = (config.hasOwnProperty("showImage")) ? !!config.showImage : (this.record.config.showImage !== false)
} else {
this.imageFieldId = config.imageFieldId || this.config.imageFieldId
this.showImage = (config.hasOwnProperty("showImage")) ? !!config.showImage : (this.config.showImage !== false)
}
// Defaults to the first attachment field
if (!this.imageFieldId) {
let modelAttachmentFields = this.model.getFieldsByType(["attachment"])
if (modelAttachmentFields.length != 0) {
this.imageFieldId = modelAttachmentFields[0].id
}
}
return this
}
/**
* Set toolbar visibility
*
* @private
* @ignore
* @returns this
*/
_initElementsVisibility() {
if (this.showToolbar === false) this.galleryToolbar.style.display = "none"
return this
}
/**
* Initialize gallery sizes
*
* @private
* @ignore
* @returns this
*/
_initSize(config) {
if (config.width) {
this._setWidth()
} else {
this.style.width = this.config.width = "100%"
}
if (config.height) {
this._setHeight()
} else {
this.style.height = this.config.height = "100%"
}
return this
}
/**
* Init the columns width according to local settings and/or config.
* If the gallery is displayed on a mobile device, the column width is set to the screen width.
*
* @private
* @ignore
*/
_initColumnWidth(config = {}) {
const isMobile = kiss.screen.isMobile
const isPortrait = kiss.screen.isVertical()
if (isMobile && isPortrait) {
this.columnWidth = kiss.screen.current.width - 20
this.columnWidth = kiss.tools.pxToRem(this.columnWidth)
document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")
} else {
this.columnWidth = this.columnWidth || config.columnWidth || this._getColumnsWidthFromLocalStorage()
document.documentElement.style.setProperty("--gallery-column-width", this.columnWidth + "rem")
}
}
/**
* Initialize all gallery events
*
* @private
* @ignore
* @eturns this
*/
_initEvents() {
this.onclick = async (event) => {
const clickedElement = event.target
const card = clickedElement.closest(".gallery-record")
const group = clickedElement.closest(".gallery-group")
const checkbox = clickedElement.closest(".gallery-checkbox")
// Open a record
if (checkbox) {
const recordId = card.getAttribute("recordid")
this._toggleSelect(recordId)
} else if (card) {
const recordId = card.getAttribute("recordid")
const record = await this.collection.getRecord(recordId)
await this.selectRecord(record)
} else if (group) {
const groupId = group.getAttribute("groupId")
const groupState = this._getGroupState(groupId)
if (groupState == "expanded") {
this.collapseGroup(groupId, group)
} else {
this.expandGroup(groupId, group)
}
}
}
return this
}
/**
* Highlight the records that are selected in the rendered page
*
* @private
* @ignore
*/
_renderSelection() {
if (!this.selectedRecords) return
this.selectedRecords.forEach(recordId => {
this._select(recordId)
})
}
/**
* 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._renderGalleryBody()
this._renderDetailsOfVisibleCards()
}
/**
* Check / Uncheck a card with the card checkbox.
*
* @private
* @ignore
* @param {string} recordId - The record id to select / deselect
*/
_toggleSelect(recordId) {
let isSelected = (this.selectedRecords.indexOf(recordId) != -1)
if (isSelected) {
this._deselect(recordId)
kiss.selection.delete(this.id, recordId)
} else {
this._select(recordId)
kiss.selection.insertOne(this.id, recordId)
}
// Update the datatable
this.selectedRecords = kiss.selection.get(this.id)
return recordId
}
/**
* Select a card, and add it to the collection selection.
*
* @private
* @ignore
* @param {string} recordId - The record id to select
*/
_select(recordId) {
const card = this.querySelector('.gallery-record[recordid="' + recordId + '"]')
const checkbox = card.querySelector(".gallery-checkbox")
if (checkbox) {
checkbox.classList.remove("fa-square")
checkbox.classList.add("fa-check-square")
checkbox.setAttribute("selected", "true")
card.classList.add("gallery-record-selected")
}
}
/**
* Deselect a card, and remove it from the collection selection.
*
* @private
* @ignore
* @param {string} recordId - The record id to deselect
*/
_deselect(recordId) {
const card = this.querySelector('.gallery-record[recordid="' + recordId + '"]')
const checkbox = card.querySelector(".gallery-checkbox")
if (checkbox) {
checkbox.classList.remove("fa-check-square")
checkbox.classList.add("fa-square")
checkbox.setAttribute("selected", "false")
card.classList.remove("gallery-record-selected")
}
}
/**
* Get the state of a group
*
* @private
* @ignore
* @param {string} groupId
* @returns {string} "collapsed" or "expanded"
*/
_getGroupState(groupId) {
return this.collapsedGroups.has(groupId) ? "collapsed" : "expanded"
}
/**
* Check if a group is hidden by a collapsed parent
*
* @private
* @ignore
* @param {string} groupId
* @param {boolean} excludeSelf
* @returns {boolean} true if the group is hidden by a collapsed parent
*/
_isHiddenByCollapsedParent(groupId, excludeSelf = false) {
const parts = groupId.split(".")
while (parts.length > 0) {
const parentId = parts.join(".")
if (this.collapsedGroups.has(parentId)) {
if (excludeSelf && parentId === groupId) {
// Ignore self if requested
} else {
return true
}
}
parts.pop()
}
return false
}
/**
* 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([
// Local events (not coming from websocket)
subscribe("EVT_VIEW_SETUP:" + this.id, (msgData) => this._updateConfig(msgData)),
// React to database mutations
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._reloadWhenNeeded(msgData, 2000)),
])
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
}
this._updateRecord(msgData.id)
if (sortHasChanged || filterHasChanged || groupHasChanged) {
this.reload()
}
}
/**
* Update a single record of the gallery.
*
* @private
* @ignore
* @param {string} recordId
*/
_updateRecord(recordId) {
const record = this.collection.getRecord(recordId)
const recordNode = document.querySelector(`.gallery-record[recordid="${recordId}"]`)
if (recordNode) {
const replacementNode = document.createElement("div")
const recordIndex = recordNode.getAttribute("row")
replacementNode.setAttribute("row", recordIndex)
replacementNode.classList.add("gallery-record")
let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
if (isSelected) replacementNode.classList.add("gallery-record-selected")
replacementNode.innerHTML = this._renderRecordAsCard(record, recordIndex, isSelected)
recordNode.parentNode.replaceChild(replacementNode, recordNode)
replacementNode.setAttribute("recordid", recordId)
}
}
/**
* Update the gallery configuration
*
* @private
* @ignore
* @param {object} newConfig
*/
async _updateConfig(newConfig) {
if (newConfig.hasOwnProperty("showImage")) this.showImage = newConfig.showImage
if (newConfig.hasOwnProperty("imageFieldId")) this.imageFieldId = newConfig.imageFieldId
this._render()
let currentConfig
if (this.record) {
currentConfig = this.record.config
} else {
currentConfig = {
showImage: this.showImage,
imageFieldId: this.imageFieldId,
columns: this.columns
}
}
let config = Object.assign(currentConfig, newConfig)
await this.updateConfig({
config
})
}
/**
* 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.gallery.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.gallery.style.height = newHeight
}
/**
* Get the columns width config stored locally
*
* @private
* @ignore
*/
_getColumnsWidthFromLocalStorage() {
const localStorageId = "config-view-gallery-" + this.id + "-column-width"
const columnWidth = localStorage.getItem(localStorageId)
if (!columnWidth) return this.defaultColumnWidth
return Number(columnWidth)
}
/**
*
* DATA GROUPING MANAGEMENT
*
*/
/**
* Group data by a list of fields
*
* @private
* @ignore
* @param {string[]} groupFields - Array of fields to group by.
*/
async _dataGroupBy(groupFields) {
this.group = groupFields
// Generates the groups, then get the grouped records
await this.collection.groupBy(groupFields)
this._render()
// Save the new group config
await this.updateConfig({
group: this.group
})
}
/**
*
* RENDERING THE GALLERY
*
*/
/**
* Render the gallery
*
* @private
* @ignore
* @returns this
*/
_render() {
// Adjust size
this._initColumnWidth()
// Filters out hidden and deleted columns
this.visibleColumns = this.columns.filter(column => column.hidden != true && column.deleted != true)
// Render body
this._renderGalleryBody()
this._observeCards()
return this
}
/**
* Observe the cards to render them only when they are visible
*
* @private
* @ignore
*/
_observeCards() {
const galleryColumnContainers = this.querySelectorAll(".gallery-group-container")
galleryColumnContainers.forEach(container => {
container.onscroll = () => {
if (kiss.global.galleryScrollStop) return
clearTimeout(this.scrollTimeout)
this.scrollTimeout = setTimeout(() => this._renderDetailsOfVisibleCards(), 10)
// requestAnimationFrame(() => this._renderDetailsOfVisibleCards())
}
})
this.galleryBodyContainer.onscroll = () => {
clearTimeout(this.scrollTimeout)
this.scrollTimeout = setTimeout(() => this._renderDetailsOfVisibleCards(), 10)
// requestAnimationFrame(() => this._renderDetailsOfVisibleCards())
}
this._renderDetailsOfVisibleCards()
}
/**
* Render the details of the visible cards
*
* @private
* @ignore
*/
_renderDetailsOfVisibleCards() {
const _this = this
const collection = this.collection
requestAnimationFrame(() => {
document.querySelectorAll(".gallery-record").forEach(card => {
if (_this._isElementVisible(card)) {
const isRendered = card.getAttribute("rendered")
if (isRendered == "true") return
const recordId = card.getAttribute("recordid")
const rowIndex = card.getAttribute("row")
const record = collection.getRecord(recordId)
let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
if (isSelected) card.classList.add("gallery-record-selected")
const cardContent = _this._renderRecordAsCard(record, rowIndex, isSelected)
const cardElement = _this.querySelector('.gallery-record[recordid="' + recordId + '"]')
cardElement.innerHTML = cardContent
cardElement.setAttribute("rendered", "true")
}
})
})
}
/**
* Check if an element is partly visible in the viewport
*
* @private
* @ignore
*/
_isElementVisible(el) {
const rect = el.getBoundingClientRect()
const windowHeight = (window.innerHeight || document.documentElement.clientHeight)
const windowWidth = (window.innerWidth || document.documentElement.clientWidth)
const vertInView = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0)
const horInView = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0)
return (vertInView && horInView)
}
/**
* Render the gallery body
*
* Tech note: we don't use string litterals to build the HTML because it's slower than native String concatenation
*
* @private
* @ignore
*/
_renderGalleryBody() {
let cardIndex = 0
let gallery = ""
if (this.collection.group.length === 0) {
// RENDER WITH NO GROUPING
this.galleryBody.classList.add("gallery-body-no-group")
for (let rowIndex = 0; rowIndex < this.collection.records.length; rowIndex++) {
let record = this.collection.records[rowIndex]
cardIndex++
gallery += this._renderGalleryCardContainer(record, cardIndex)
if (rowIndex == this.collection.records.length - 1) gallery += "</div>"
}
if (this.collection.records.length == "0") {
this.galleryBodyContainer.classList.add("gallery-body-container-empty")
} else {
this.galleryBodyContainer.classList.remove("gallery-body-container-empty")
}
} else {
// RENDER WITH GROUPING
this.galleryBody.classList.remove("gallery-body-no-group")
let groupId = ""
let lastCellType = "group"
for (let rowIndex = 0; rowIndex < this.collection.records.length; rowIndex++) {
let record = this.collection.records[rowIndex]
if (record.$type == "group" && record.$groupLevel == 0) {
groupId = record.$groupId
cardIndex = 0
if (lastCellType == "record") { // Close the last column container
gallery += "</div>"
}
lastCellType = "group"
// Group container
gallery += this._renderGalleryGroupContainer(record, rowIndex)
} else if (record.$type == "group") {
// Sub-category
groupId = record.$groupId
const marginSize = groupId.length + "rem"
const marginStyle = `style="margin-left: ${marginSize};"`
const value = this._renderGroupValue(record)
gallery += "<div class=\"gallery-group gallery-group-expanded\" groupId=\"" + groupId + "\" " + marginStyle + ">" + groupId + " - " + value + "</div>"
} else {
cardIndex++
lastCellType = "record"
// Regular row
gallery += this._renderGalleryCardContainer(record, cardIndex, groupId)
}
// Close the last group container
if (rowIndex == this.collection.records.length - 1) {
gallery += "</div>"
}
}
if (this.collection.records.length == "0") {
this.galleryBodyContainer.classList.add("gallery-body-container-empty")
} else {
this.galleryBodyContainer.classList.remove("gallery-body-container-empty")
}
}
this.galleryBody.innerHTML = gallery
}
/**
* Get the color of a category, if any
*
* @private
* @ignore
* @param {string} groupFieldId
* @param {*} columnValue
* @returns {string} The color of the category
*/
_getCategoryColor(groupFieldId, columnValue) {
const field = this.model.getField(groupFieldId)
const options = field.options || []
const option = options.find(option => option.value == columnValue)
return (option) ? option.color : "#cccccc"
}
/**
* Render a Gallery group
*
* @private
* @ignore
* @param {object} record
* @param {number} rowIndex
* @returns {string} Html source for Gallery column container
*/
_renderGalleryGroupContainer(record, rowIndex) {
const value = this._renderGroupValue(record)
return "<div row=\"" + rowIndex + "\" class=\"gallery-group-container\">" +
"<div class=\"gallery-group gallery-group-expanded\" groupId=\"" + record.$groupId + "\">" + record.$groupId + " - " + value + "</div>"
}
/**
* Render the HTML value of a group
*
* @private
* @ignore
* @param {object} record
* @returns {*} The HTML value of the group
*/
_renderGroupValue(record) {
const groupFieldId = this.group[record.$groupLevel]
const field = this.model.getField(groupFieldId)
if (field) {
return this._renderSingleValue(field, record.$name, record)
}
else {
return record.$name
}
}
/**
* Render a single row of the gallery
*
* @private
* @ignore
* @param {number} rowIndex
* @returns {HTMLDivElement} The div containing the row
*/
_renderGalleryCardContainer(record, cardIndex, groupId) {
return "<div row=\"" + cardIndex + "\" class=\"gallery-record\" groupId=\"" + (groupId || "") + "\" recordid=\"" + record.id + "\"></div>"
}
/**
* Render a single record as a Card
*
* @private
* @ignore
* @param {object} record
* @param {number} index - The index of the record in the gallery
* @returns {string} Html for a single record
*/
_renderRecordAsCard(record, index, isSelected = false) {
let recordHtml =
((this.canSelect) ? "<span class=\"gallery-checkbox " + ((isSelected) ? "far fa-check-square" : "far fa-square") + "\"></span>" : "") + // Selection checkbox
"<span class=\"gallery-record-index\">" + index + "</span>"
if (this.showImage) {
const imageFieldId = this.imageFieldId
let images = record[imageFieldId] || [record]
if (images && Array.isArray(images) && images.length != 0) {
const thumbSize = (this.columnWidth > 20) ? "l" : "m"
let imageHtml = this._renderCardImage({
value: [images[0]],
config: {
thumbSize
}
})
recordHtml += "<div class=\"gallery-record-image-container\">" + imageHtml + "</div>"
} else {
recordHtml += "<div class=\"gallery-record-image-container\"><span class=\"fas fa-archive gallery-record-empty\"></span></div>"
}
}
this.columns
.filter(column => column.hidden !== true)
.forEach(column => {
let field = this.model.getField(column.id)
if (!field) return
if (["password", "link"].includes(field.type)) return
let value = record[column.id]
if (!value && value !== false && value !== 0) return
let valueHtml = this._renderSingleValue(field, value, record)
recordHtml += /*html*/ `
<div class="gallery-record-field">
<div class="gallery-record-label">${field.label} ${(field.unit) ? `(${field.unit})` : ""}</div>
<div class="gallery-record-value">${valueHtml}</div>
</div>
`.removeExtraSpaces()
})
return recordHtml
}
/**
* Render the image of a card
*
* @private
* @ignore
* @param {object} params
* @param {Array} params.value - The value of the field, should be an array of files
* @param {object} [params.config] - Additional configuration
* @returns {string} Html for the card image
*/
_renderCardImage({
value,
config = {}
}) {
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, config.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="gallery-record-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} gallery-record-icon"></span>`
}
return preview
}).join("")
return attachmentItems
}
/**
* Render a single value inside a card
*
* @private
* @ignore
* @param {object} field - Field to render
* @param {*} value - Field value
* @param {object} record - The record, useful for custom renderers
* @returns {string} Html for the value
*/
_renderSingleValue(field, value, record, config) {
const renderer = kiss.fields.renderers[this.model.id][field.id]
const type = kiss.fields.getFieldType(field)
switch (type) {
case "date":
case "textarea":
case "aiTextarea":
case "select":
case "directory":
case "checkbox":
case "rating":
case "color":
case "icon":
case "attachment":
case "aiImage":
case "selectViewColumn":
return renderer({
value,
record,
config
})
case "number":
case "slider":
return renderer({
value,
record,
config: {
unit: false
}
})
default:
return value
}
}
/**
* Render the toolbar
*
* @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: "gallery-create-record",
target: "create:" + this.id,
text: this.config.createRecordText || this.model.name.toTitleCase(),
icon: "fas fa-plus",
iconColor: this.color,
borderWidth: 3,
borderRadius: "3.2rem",
maxWidth: (kiss.screen.isMobile && kiss.screen.isVertical()) ? "16rem" : 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: "3.2rem",
action: () => this._buildActionMenu()
}).render()
// Setup the gallery
createButton({
hidden: !this.showSetup,
target: "setup:" + this.id,
tip: txtTitleCase("setup the gallery"),
icon: "fas fa-cog",
iconColor: this.color,
width: "3.2rem",
action: () => this.showSetupWindow()
}).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: "3.2rem",
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: "3.2rem",
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: "3.2rem",
action: () => this.showFilterWindow()
}).render()
// Layout button
createButton({
hidden: !this.showLayoutButton,
target: "layout:" + this.id,
tip: {
text: txtTitleCase("layout"),
minWidth: "10rem"
},
icon: "fas fa-ellipsis-v",
iconColor: this.color,
width: "3.2rem",
action: () => this._buildLayoutMenu()
}).render()
// Grouping
let groupingFields = this._groupGetModelFields({
excludeSystemFields: true,
excludePluginFields: true
})
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: "20rem",
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: "3.2rem",
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: "3.2rem",
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: "3.2rem",
events: {
click: () => this.reload()
}
}).render()
}
// Search button
createButton({
hidden: !this.canSearch,
target: "search:" + this.id,
icon: "fas fa-search",
iconColor: this.color,
width: "3.2rem",
events: {
click: () => this.showSearchBar()
}
}).render()
// Flag the toolbar as "rendered", so that the method _renderToolbar() is idempotent
this.isToolbarRendered = true
}
/**
*
* OTHER MISC METHODS
*
*/
/**
* Render the menu to change gallery 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: "0.2rem",
text: txtTitleCase("compact"),
action: () => {
this.columnWidth = 15
this.setColumnWidth(this.columnWidth)
}
},
// Change row height to NORMAL
{
icon: "fas fa-circle",
iconSize: "0.6rem",
text: txtTitleCase("normal"),
action: () => {
this.columnWidth = this.defaultColumnWidth
this.setColumnWidth(this.columnWidth)
}
},
// Change row height to MEDIUM
{
icon: "fas fa-circle",
iconSize: "1rem",
text: txtTitleCase("medium"),
action: () => {
this.columnWidth = 25
this.setColumnWidth(this.columnWidth)
}
},
// Change row height to TALL
{
icon: "fas fa-circle",
iconSize: "1.4rem",
text: txtTitleCase("tall"),
action: () => {
this.columnWidth = 30
this.setColumnWidth(this.columnWidth)
}
},
// Change row height to VERY TALL
{
icon: "fas fa-circle",
iconSize: "1.8rem",
text: txtTitleCase("very tall"),
action: () => {
this.columnWidth = 35
this.setColumnWidth(this.columnWidth)
}
},
"-",
// Reset columns width
{
icon: "fas fa-undo-alt",
text: txtTitleCase("#reset view params"),
action: () => this.resetLocalViewParameters()
}
]
}).render()
}
}
// Create a Custom Element and add a shortcut to create it
customElements.define("a-gallery", kiss.ui.Gallery)
/**
* Shorthand to create a new Gallery. See [kiss.ui.Gallery](kiss.ui.Gallery.html)
*
* @param {object} config
* @returns HTMLElement
*/
const createGallery = (config) => document.createElement("a-gallery").init(config)
;
Source