/**
*
* The **Timeline** derives from [DataComponent](kiss.ui.DataComponent.html).
*
* @param {object} config
* @param {string} [config.date] - The initial date to display in the timeline (default = today)
* @param {string} [config.titleField] - The field to use as the title for the first column (default = first text field found in the model, or the record id if no text field was found)
* @param {boolean} [config.startDateField] - The field to use as the start date for the timeline (default = first date field found in the model, or the creation date if no date field was found)
* @param {boolean} [config.endDateField] - The field to use as the end date for the timeline (default = second date field found in the model, or the modification date if no date field was found)
* @param {string} [config.period] - "1 week" | "2 weeks" | "3 weeks" | "1 month" (default) | "2 months" | "3 months" | "4 months" | "6 months" | "1 year"
* @param {boolean} [config.colorField] - The field to use as the color for the timeline (default = none)
* @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.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.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 table? (default = true)
* @param {boolean} [config.canChangePeriod] - false to hide the possibility to change period (1 month, 2 weeks...) (default = true)
* @param {boolean} [config.canCreateRecord] - Can we create new records from the timeline?
* @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 {number|string} [config.width]
* @param {number|string} [config.height]
* @returns this
*
* ## Generated markup
* ```
* <a-timeline class="a-timeline">
* <div class="timeline-toolbar">
* <!-- Timeline toolbar items -->
* </div>
* <div class="timeline-header-container">
* <div class="timeline-header-1st-column">
* <!-- Header 1st column -->
* </div>
* <div class="timeline-header">
* <!-- Header other columns -->
* </div>
* </div>
* <div class="timeline-body-container">
* <div class="timeline-body-1st-column">
* <!-- Body 1st column -->
* </div>
* <div class="timeline-body">
* <!-- Body other columns -->
* </div>
* </div>
* <div class="timeline-virtual-scroller-container">
* <div class="timeline-virtual-scroller"></div>
* </div>
* </a-timeline>
* ```
*/
kiss.ui.Timeline = class Timeline 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 myTimeline = document.createElement("a-timeline").init(config)
* ```
*
* Or use the shorthand for it:
* ```
* const myTimeline = createTimeline({
* id: "my-table",
* color: "#00aaee",
* collection: kiss.app.collections["projects"],
*
* // Params that are specific to the timeline
* startDateField: "projectStartDate",
* endDateField: "projectEndDate",
* titleField: "projectName",
* period: "1 month",
*
* // 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"),
* }
* })
*
* myTimeline.render()
* ```
*/
constructor() {
super()
}
/**
* Generates a Timeline 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.showColumnType = !!config.showColumnType
this.showToolbar = (config.showToolbar !== false)
this.showPagerIndex = (config.showPagerIndex !== false)
this.showScroller = (config.showScroller !== 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.color = config.color || "#00aaee"
this.iconAction = config.iconAction || "far fa-file-alt"
this.canSelect = (config.canSelect !== false)
this.canSelectFields = (config.canSelectFields !== false)
this.canChangePeriod = (config.canChangePeriod !== false)
this.actions = config.actions || []
this.defaultRowHeight = 40
this.defaultDayWidth = 50
this.firstColumnWidth = 100
// Build timeline skeletton markup
let id = this.id
this.innerHTML =
/*html*/
`<div class="timeline">
<div id="timeline-toolbar:${id}" class="timeline-toolbar">
<div id="search-field:${id}"></div>
<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:${id}"></div>
<div id="hierarchy:${id}"></div>
<div id="explode:${id}"></div>
<div class="spacer"></div>
<div id="pager-index:${id}" class="timeline-toolbar-pager-index"></div>
<div id="pager-mode:${id}"></div>
<div id="pager-previous:${id}"></div>
<div id="pager-next:${id}"></div>
<div id="pager-today:${id}"></div>
<div id="layout:${id}"></div>
</div>
<div class="timeline-header-container">
<div class="timeline-header-1st-column"></div>
<div id="timeline-header:${id}" class="timeline-header"></div>
</div>
<div class="timeline-body-container">
<div class="timeline-body-1st-column"></div>
<div id="timeline-body:${id}" class="timeline-body"></div>
</div>
<div class="timeline-virtual-scroller-container">
<div class="timeline-virtual-scroller"></div>
</div>
</div>`.removeExtraSpaces()
// Set timeline components
this.timeline = this.querySelector(".timeline")
this.timelineToolbar = this.querySelector(".timeline-toolbar")
this.timelineHeader = this.querySelector(".timeline-header")
this.timelineBody = this.querySelector(".timeline-body")
this.timelineBodyContainer = this.querySelector(".timeline-body-container")
this.timelineHeader1stColumn = this.querySelector(".timeline-header-1st-column")
this.timelineBody1stColumn = this.querySelector(".timeline-body-1st-column")
this.timelineScrollerContainer = this.querySelector(".timeline-virtual-scroller-container")
this.timelineScroller = this.querySelector(".timeline-virtual-scroller")
this.timelinePagerIndex = this.querySelector(".timeline-toolbar-pager-index")
this._initTexts()
._initColumns(config.columns)
._initTimelineParams(config)
._initElementsVisibility()
._prepareCellRenderers()
._initEvents()
._initSubscriptions()
return this
}
/**
*
* TIMELINE METHODS
*
*/
/**
* Load data into the timeline.
*
* @ignore
*/
async load() {
try {
log(`kiss.ui - Timeline ${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 timeline is being built
this._hideScroller()
// Try to adjust columns width from local config
this._columnsAdjustWidthFromLocalStorage()
// Get the selected records
this.getSelection()
// Render the timeline toolbar
this._renderToolbar()
// Get paging params (skip & limit)
this.skip = 0
this._setLimit()
} catch (err) {
log(`kiss.ui - Timeline ${this.id} - Couldn't load data properly`)
}
}
/**
* Display the timeline one day before
*
* @returns this
*/
previous() {
let shift = 1
if (this.period == "1 year") shift = 2
let previousDate = kiss.formula.ADJUST_DATE(this.startDate, 0, 0, -shift, 0, 0, 0)
this.date = new Date(previousDate)
return this._render()
}
/**
* Display the timeline one day after
*
* @returns this
*/
next() {
let shift = 1
if (this.period == "1 year") shift = 2
let nextDate = kiss.formula.ADJUST_DATE(this.startDate, 0, 0, 2, 0, 0, 0)
this.date = new Date(nextDate)
return this._render()
}
/**
* 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()
}
/**
* Update the timeline size (recomputes its width and height functions)
*/
updateLayout() {
if (this.isConnected) {
this._setWidth()
this._setHeight()
this._setLimit()
this._renderPagerIndex()
this._render()
this._renderSelectionRestore()
}
}
/**
* Update the timeline color (toolbar buttons + modal windows)
*
* @param {string} newColor
*/
async setColor(newColor) {
this.color = newColor
Array.from(this.timelineToolbar.children).forEach(item => {
if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
})
}
/**
* 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 window to setup the timeline:
* - source field for the first column
* - source start date field
* - source end date field
* - prefered layout (1 month, 2 months, ...)
*/
showSetupWindow() {
const dateFields = this.model.getFieldsByType("date")
.filter(field => !field.deleted)
.map(field => {
return {
value: field.id,
label: field.label.toTitleCase()
}
})
const titleFields = this.model.fields
.filter(field => !field.deleted)
.map(field => {
return {
value: field.id,
label: field.label.toTitleCase()
}
})
const selectFields = this.model.getFieldsByType("select")
.filter(field => !field.deleted)
.map(field => {
return {
value: field.id,
label: field.label.toTitleCase()
}
})
createPanel({
icon: "fas fa-align-left",
title: txtTitleCase("setup the timeline"),
headerBackgroundColor: this.color,
modal: true,
draggable: true,
closable: true,
align: "center",
verticalAlign: "center",
width: 400,
defaultConfig: {
labelPosition: "top",
optionsColor: this.color
},
items: [
// Source start date field
{
type: "select",
id: "timeline-startDatefield:" + this.id,
label: txtTitleCase("#timeline start date"),
options: dateFields,
maxHeight: () => kiss.screen.current.height - 200,
value: this.startDateField,
events: {
change: async function () {
let startDateField = this.getValue()
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_SETUP:" + viewId, {
startDateField
})
}
}
},
// Source end date field
{
type: "select",
id: "timeline-endDateField:" + this.id,
label: txtTitleCase("#timeline end date"),
options: dateFields,
maxHeight: () => kiss.screen.current.height - 200,
value: this.endDateField,
events: {
change: async function () {
let endDateField = this.getValue()
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_SETUP:" + viewId, {
endDateField
})
}
}
},
// Source title field
{
type: "select",
id: "timeline-titleField:" + this.id,
label: txtTitleCase("#timeline title field"),
options: titleFields,
maxHeight: () => kiss.screen.current.height - 200,
value: this.titleField,
events: {
change: async function () {
let titleField = this.getValue()
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_SETUP:" + viewId, {
titleField
})
}
}
},
// Source color field
{
type: "select",
id: "timeline-colorField:" + this.id,
label: txtTitleCase("#timeline color field"),
options: selectFields,
maxHeight: () => kiss.screen.current.height - 200,
value: this.colorField,
events: {
change: async function () {
let colorField = this.getValue()
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_SETUP:" + viewId, {
colorField
})
}
}
},
// Default period
{
type: "select",
id: "timeline-period:" + this.id,
label: txtTitleCase("#timeline period"),
options: [
{
label: "1 " + txtTitleCase("week"),
value: "1 week"
},
{
label: "2 " + txtTitleCase("weeks"),
value: "2 weeks"
},
{
label: "3 " + txtTitleCase("weeks"),
value: "3 weeks"
},
{
label: "1 " + txtTitleCase("month"),
value: "1 month"
},
{
label: "2 " + txtTitleCase("months"),
value: "2 months"
},
{
label: "3 " + txtTitleCase("months"),
value: "3 months"
},
{
label: "4 " + txtTitleCase("months"),
value: "4 months"
},
{
label: "6 " + txtTitleCase("months"),
value: "6 months"
},
{
label: "1 " + txtTitleCase("year"),
value: "1 year"
},
],
value: this.period || "1 month",
events: {
change: async function () {
let period = this.getValue()
let viewId = this.id.split(":")[1]
publish("EVT_VIEW_SETUP:" + viewId, {
period
})
}
}
}
]
}).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)
}
/**
* Set the timeline row height
*
* @param {number} height - The row height in pixels
*/
setRowHeight(height) {
this.rowHeight = height
document.documentElement.style.setProperty("--timeline-cell-height", this.rowHeight + "px")
document.documentElement.style.setProperty("--timeline-group-cell-height", this.rowHeight + "px")
this._setThumbSize()
// Save new row height locally
const localStorageId = "config-timeline-" + this.id + "-row-height"
localStorage.setItem(localStorageId, this.rowHeight)
this.reload()
}
/**
* 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()
}
}
/**
* Re-render the virtual scrollbar when the timeline is re-connected to the DOM
*
* @private
* @ignore
*/
_afterConnected() {
super._afterConnected()
this._hideScroller()
this._renderScroller()
}
/**
* Init the localized texts
*
* @private
* @ignore
* @returns this
*/
_initTexts() {
this.months = [
txtTitleCase("january"),
txtTitleCase("february"),
txtTitleCase("march"),
txtTitleCase("april"),
txtTitleCase("may"),
txtTitleCase("june"),
txtTitleCase("july"),
txtTitleCase("august"),
txtTitleCase("september"),
txtTitleCase("october"),
txtTitleCase("november"),
txtTitleCase("december")
]
this.weekDays = [
txtTitleCase("sunday"),
txtTitleCase("monday"),
txtTitleCase("tuesday"),
txtTitleCase("wednesday"),
txtTitleCase("thursday"),
txtTitleCase("friday"),
txtTitleCase("saturday")
]
if (kiss.screen.isMobile && kiss.screen.isVertical()) {
this.months = this.months.map(month => month.substring(0, 4) + ".")
}
return this
}
/**
* Initialize all timeline events
*
* @private
* @ignore
*/
_initEvents() {
// Drag the timeline left and right
let startX
let isDragging = false
const handleMouseMove = (e) => {
if (isDragging) {
let currentX = e.clientX
let diffX = currentX - startX
const threshold = Math.max(this.dayWidth, 30)
if (diffX > threshold) {
this.previous()
startX = currentX
} else if (diffX < -threshold) {
this.next()
startX = currentX
}
}
}
function handleMouseUp(e) {
isDragging = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
this.timelineBody.addEventListener('mousedown', function(e) {
e.preventDefault()
startX = e.clientX
isDragging = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
})
// Clicked on the checkbox to deselect all records
this.timelineHeader1stColumn.onclick = (event) => {
if (event.target.classList.contains("timeline-header-checkbox")) {
this.toggleSelection()
}
}
// Clicked on the 1st column resizer
this.timelineHeader1stColumn.onmousedown = (event) => {
const clickedElement = event.target
if (clickedElement.classList.contains("timeline-column-header-resizer")) {
this._columnsResizeWithDragAndDrop(event, clickedElement)
}
}
// Clicked somewhere in the timeline
this.onclick = async (event) => {
const clickedElement = event.target
const clickedParent = clickedElement.parentNode
// SELECT / DESELECT A ROW
// = clicked on the checkbox to select a record
if (clickedElement.classList.contains("timeline-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("timeline-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 (clickedParent.classList.contains("timeline-group-row")) {
const rowIndex = clickedParent.getAttribute("row")
const record = this.collection.records[Number(rowIndex)]
const groupId = record.$groupId
const groupLevel = record.$groupLevel
this._groupToggle(groupId, groupLevel, rowIndex)
return event
}
// = clicked on the group expand/collapse
const clickedGroup = clickedElement.closest(".timeline-group")
if (clickedGroup) {
const rowIndex = clickedGroup.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
if ((clickedElement.classList.contains("timeline-cell")) || clickedParent.classList.contains("timeline-cell")) {
const row = clickedElement.closest(".timeline-row")
const recordId = row.getAttribute("recordid")
const record = await this.collection.getRecord(recordId)
await this.selectRecord(record)
return event
}
}
// Sync horizontal scrolling between body and header
this.timelineBody.onscroll = () => {
this.timelineHeader.scrollLeft = this.timelineBody.scrollLeft
}
/*
* VIRTUAL SCROLLING MANAGEMENT
*/
//
// Observe mousewheel event to scroll
//
this.onmousewheel = this.onwheel = (event) => {
// Scroll must happen inside the timeline body
if (!event.target.closest(".timeline-body-container")) return
if (event.wheelDelta > 0) {
this._virtualScrollUp()
} else {
this._virtualScrollDown()
}
// Sync the virtual scrollbar position
this._renderScrollerPosition()
// Update pager
this._renderPagerIndex()
}
//
// Enable onscroll event when clicking on the virtual scrollbar
//
this.timelineScrollerContainer.onmousedown = (event) => {
this.preventScroll = false
}
//
// Render the timeline at the correct row index when moving the virtual scrollbar
//
this.timelineScrollerContainer.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.timelineScroller.offsetHeight - this.timelineBody.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 timeline if the skip value has changed
if (newSkip != this.skip) {
this.skip = newSkip
this._render()
}
}, 10)
}
return this
}
/**
* Initialize subscriptions to PubSub
*
* @private
* @ignore
* @returns this
*/
_initSubscriptions() {
super._initSubscriptions()
const viewModelId = this.modelId.toUpperCase()
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
}
/**
* Define the specific timeline params:
* - the initial date
* - start date field used to display the timeline
* - end date field used to display the timeline
* - title field used to display in the first column
* - displayed period (1 month, 2 months...)
*
* @private
* @ignore
* @param {object} config - {date, startDateField, endDateField, titleField, period, colorField}
* @eturns this
*/
_initTimelineParams(config) {
this.date = this.config.date || new Date()
if (typeof this.date == "string") this.date = new Date(this.date)
if (this.record) {
this.startDateField = config.startDateField || this.record.config.startDateField
this.endDateField = config.endDateField || this.record.config.endDateField
this.titleField = config.titleField || this.record.config.titleField
this.colorField = config.colorField || this.record.config.colorField
this.period = config.period || this.record.config.period || "1 month"
} else {
this.startDateField = config.startDateField || this.config.startDateField
this.endDateField = config.endDateField || this.config.endDateField
this.titleField = config.titleField || this.config.titleField
this.colorField = config.colorField || this.config.colorField
this.period = config.period || this.config.period || "1 month"
}
// Defaults to the first date field, or the creation date if no date field was found
if (!this.startDateField) {
let modelDateFields = this.model.getFieldsByType(["date"])
if (modelDateFields.length != 0) {
this.startDateField = modelDateFields[0].id
} else {
this.startDateField = "createdAt"
}
}
// Defaults to the second date field, or the modification date if no date field was found
if (!this.endDateField) {
let modelDateFields = this.model.getFieldsByType(["date"])
if (modelDateFields.length > 1) {
this.endDateField = modelDateFields[1].id
} else {
this.endDateField = "updatedAt"
}
}
// Defaults to the first text field, or the record id if no text field was found
if (!this.titleField) {
let modelTextFields = this.model.getFieldsByType(["text"])
if (modelTextFields.length != 0) {
this.titleField = modelTextFields[0].id
} else {
this.titleField = "id"
}
}
this.periods = {
"1 week": 7,
"2 weeks": 14,
"3 weeks": 21,
"1 month": 31,
"2 months": 61,
"3 months": 92,
"4 months": 122,
"6 months": 182,
"1 year": 365
}
return this
}
/**
* Set header, toolbar and scroller visibility
*
* @private
* @ignore
* @returns this
*/
_initElementsVisibility() {
if (this.showToolbar === false) this.timelineToolbar.style.display = "none"
if (this.showScroller === false) this.timelineScrollerContainer.style.display = "none"
if (this.showPagerIndex === false) this.timelinePagerIndex.style.display = "none"
return this
}
/**
* Initialize timeline sizes
*
* @private
* @ignore
* @returns this
*/
_initSize(config) {
this._initRowHeight(config)
this._initDayWidth(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
* @returns this
*/
_initRowHeight(config = {}) {
this.rowHeight = config.rowHeight || this._getRowHeightFromLocalStorage()
document.documentElement.style.setProperty("--timeline-cell-height", this.rowHeight + "px")
document.documentElement.style.setProperty("--timeline-group-cell-height", this.rowHeight + "px")
return this
}
/**
* Init the day width according to local settings and/or config
*
* @private
* @ignore
* @param {object} config
* @returns this
*/
_initDayWidth(config = {}) {
this.dayWidth = config.dayWidth || this._computeDayWidth()
document.documentElement.style.setProperty("--timeline-day-width", this.dayWidth + "px")
return this
}
/**
* Set the timeline day width
*
* @private
* @ignore
* @param {number} height - The day width in pixels
*/
_setDayWidth(width) {
this.dayWidth = width
this._render()
}
/**
* Generates a simple day id
*
* @private
* @ignore
* @param {number} day
* @param {number} month
* @param {number} year
* @returns {string} String like "2023-07-17"
*/
_getDayId(day, month, year) {
return year + "-" + (month + "").padStart(2, "0") + "-" + (day + "").padStart(2, "0")
}
/**
* Compute the day width according to the current screen width and the number of days to display
*
* @private
* @ignore
* @returns {number} The day width in pixels
*/
_computeDayWidth() {
const bodyWidth = kiss.screen.current.width - this.timelineBody.getBoundingClientRect().left
return Math.max(bodyWidth / this.periods[this.period], 5)
}
/**
* Get the current timeline body width, depending on the screen width and the position of the timeline
*
* @private
* @ignore
* @returns {number} The body width in pixels
*/
_getBodyWidth() {
return kiss.screen.current.width - this.timelineBody.getBoundingClientRect().left
}
/**
* Get the number of days to display in the timeline
*
* @private
* @ignore
* @returns {number} The number of days
*/
_getNumberOfDays() {
return Math.floor(this._getBodyWidth() / this.dayWidth)
}
/**
* 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._reloadWhenNeeded(msgData)
}
}
/**
* Update a single record of the timeline.
*
* Does nothing if the record is not displayed on the active page.
*
* @private
* @ignore
* @param {string} recordId
*/
_updateRecord(recordId) {
const record = this.collection.getRecord(recordId)
const recordNode = document.querySelector(`.timeline-row[recordid="${recordId}"]`)
if (recordNode) recordNode.innerHTML = this._renderRowContent(record)
}
/**
* Update the timeline configuration in the database
*
* @private
* @ignore
* @param {object} newConfig
*/
async _updateConfig(newConfig) {
if (newConfig.hasOwnProperty("startDateField")) this.startDateField = newConfig.startDateField
if (newConfig.hasOwnProperty("endDateField")) this.endDateField = newConfig.endDateField
if (newConfig.hasOwnProperty("titleField")) this.titleField = newConfig.titleField
if (newConfig.hasOwnProperty("colorField")) this.colorField = newConfig.colorField
if (newConfig.hasOwnProperty("period")) this.period = newConfig.period
this._initTexts()
this._render()
let currentConfig
if (this.record) {
currentConfig = this.record.config
}
else {
currentConfig = {
startDateField: this.startDateField,
endDateField: this.endDateField,
titleField: this.titleField,
colorField: this.colorField,
period: this.period,
columns: this.columns
}
}
let config = Object.assign(currentConfig, newConfig)
await this.updateConfig({
config
})
}
/**
* 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 == 0) return
this.skip -= 1
this.lastIndex = Math.min(this.skip + this.limit - 1, this.collection.records.length)
this.timelineBody.lastChild.remove()
this.timelineBody.insertBefore(this._renderRowDiv(this.skip), this.timelineBody.children[0])
this.timelineBody1stColumn.lastChild.remove()
this.timelineBody1stColumn.insertBefore(this._renderRowDiv1stColumn(this.skip), this.timelineBody1stColumn.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.timelineBody.children[0].remove()
this.timelineBody.appendChild(this._renderRowDiv(this.lastIndex))
this.timelineBody1stColumn.children[0].remove()
this.timelineBody1stColumn.appendChild(this._renderRowDiv1stColumn(this.lastIndex))
}
/**
*
* 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.timeline.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.timeline.style.height = newHeight
}
/**
* Compute the maximum number of rows that can fit in the timeline, then set the "limit" param.
* The limit depends on the global timeline height minus:
* - the timeline toolbar
* - the timeline header
*
* @private
* @ignore
*/
_setLimit() {
if (!this.isConnected) return
let tableHeight = this.offsetHeight
let headerHeight = $("timeline-header:" + this.id).offsetHeight
let toolbarHeight = $("timeline-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
*
*/
/**
* 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 timeline
*
* @private
* @ignore
* @returns this
*/
_render() {
// kiss.tools.timer.start()
// Reset 1st column
this.timelineHeader1stColumn.innerHTML = ""
this.timelineBody1stColumn.innerHTML = ""
// Filters out hidden and deleted columns
this.visibleColumns = this.columns.filter(column => column.hidden != true && column.deleted != true)
this._initSize(this.config)
._columnsSetFirstColumnWidth(this.firstColumnWidth)
._renderHeader()
._renderHeaderToday()
._renderBody()
// kiss.tools.timer.show("Timeline rendered!")
return this
}
/**
* Render the timeline 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("timeline-column-header", "timeline-column-header-1st")
firstCell.style.width = firstCell.style.minWidth = this.firstColumnWidth + "px"
firstCell.innerHTML =
`<span id='toggle-selection' class='timeline-header-checkbox ${(this.canSelect) ? "timeline-header-checkbox-off" : ""}'></span>` + // Selection checkbox
`<span id='header-resizer-1st-column' class='fas fa-arrows-alt-h timeline-column-header-resizer'></span>` // Column resizer
this.timelineHeader1stColumn.appendChild(firstCell)
// Other columns headers
const numberOfDays = this._getNumberOfDays()
const htmlForDays = this._renderHeaderDays(numberOfDays)
const htmlForMonths = this._renderHeaderMonths()
this.timelineHeader.innerHTML = /*html*/ `
<div>
<span class="timeline-header-months">
${htmlForMonths}
</span>
<span class="timeline-header-days">
${htmlForDays}
</span>
</div>`
return this
}
/**
* Render the days of the timeline header
*
* @private
* @ignore
* @returns {string} Html source for the days
*/
_renderHeaderDays(max) {
this.numberOfDays = 0
let html = ""
this.startDate = new Date(this.date)
this.startDate.setHours(0, 0, 0, 0)
let currentDate = this.startDate
for (let i = 1; i <= max; i++) {
this.numberOfDays++
currentDate = new Date(kiss.formula.ADJUST_DATE(currentDate, 0, 0, 1, 0, 0, 0))
const day = currentDate.getDate()
const month = currentDate.getMonth()
const year = currentDate.getFullYear()
const dayId = this._getDayId(day, month + 1, year)
const currentDay = currentDate.getDay()
const dayLetter = this.weekDays[currentDay][0]
// Week-end => Alternate background color
let dayClass = ""
if (currentDay == 0 || currentDay == 6) dayClass = "timeline-header-weekend"
// Adjust the cell content according to the day width
if (this.dayWidth > 20) {
// Large cells
html += `<div id="${dayId}" style="min-width: ${this.dayWidth}px" class="timeline-header-day ${dayClass}">
<div>${dayLetter}</div><div>${day}</div>
</div>`
}
else if (this.dayWidth > 10) {
// Medium cells
html += `<div id="${dayId}" style="min-width: ${this.dayWidth}px" class="timeline-header-day ${dayClass}">
<div style="font-size: 8px">${day}</div>
</div>`
}
else {
// Small cells
html += `<div id="${dayId}" style="min-width: ${this.dayWidth}px" class="timeline-header-day ${dayClass}">
</div>`
}
}
this.endDate = currentDate
return html
}
/**
* Render the months of the timeline header
*
* @private
* @ignore
* @returns {string} Html source for the months
*/
_renderHeaderMonths() {
let html = ""
let currentDate = this.startDate
let currentMonth = currentDate.getMonth()
let currentYear = currentDate.getFullYear()
while (currentDate <= this.endDate) {
let lastDayOfMonth = new Date(currentYear, currentMonth + 1, 0)
if (lastDayOfMonth > this.endDate) lastDayOfMonth = this.endDate
let daysInMonthDisplayed = (lastDayOfMonth - currentDate) / (1000 * 60 * 60 * 24) + 1
let monthWidth = daysInMonthDisplayed * this.dayWidth
if (monthWidth > 120) {
// Full month, like "January 2023"
html += `<div style="width: ${monthWidth}px; color: ${this.color}" class="timeline-header-month">${this.months[currentMonth]} ${currentYear}</div>`
}
else if (monthWidth > 50) {
// Short month, like "01 / 2023"
html += `<div style="width: ${monthWidth}px; color: ${this.color}" class="timeline-header-month">${(currentMonth + 1).toString().padStart(2, "0")} / ${currentYear}</div>`
}
else {
// Very short month, like "01"
html += `<div style="width: ${monthWidth}px; color: ${this.color}" class="timeline-header-month">${(currentMonth + 1).toString().padStart(2, "0")}</div>`
}
currentDate = new Date(currentYear, currentMonth + 1, 1)
currentMonth = currentDate.getMonth()
currentYear = currentDate.getFullYear()
}
return html
}
/**
* Highlight today
*
* @private
* @ignore
* @returns this
*/
_renderHeaderToday() {
let today = new Date().toISO()
let todayCell = $(today)
if (!todayCell) return this
todayCell.style.color = "#ffffff"
todayCell.style.backgroundColor = this.color
return this
}
/**
* Render the content of a single row of the timeline.
*
* @private
* @ignore
* @param {number} record - The record to render in this row
* @returns {string} Html source for a row
*/
_renderRowContent(record) {
let startDate = record[this.startDateField]
let endDate = record[this.endDateField]
if (!startDate || !endDate) return ""
startDate = new Date(startDate)
endDate = new Date(endDate)
if (isNaN(startDate) || isNaN(endDate)) return ""
startDate = startDate.toISO()
endDate = endDate.toISO()
let recordHtml = ""
const blockWidth = this._computeBlockWidth(startDate, endDate)
if (blockWidth.middle != 0) {
const blockColor = this._computeBlockColor(record)
recordHtml = /*html*/
`<div class="timeline-cell-data" style="background-color: ${blockColor}">
${this._renderRecord(record).join(" ● ")}
</div>`
}
return `<div class="timeline-cell" style="width: ${blockWidth.start}px;"></div>
<div class="timeline-cell" style="width: ${blockWidth.middle}px;">${recordHtml}</div>
<div class="timeline-cell" style="width: ${blockWidth.end}px;"></div>`
}
/**
* Render a single record as a Card for 1 week view
*
* @private
* @ignore
* @param {object} record
* @returns {string} Html for a single record
*/
_renderRecord(record) {
return this.columns
.filter(column => column.hidden !== true)
.map(column => {
let field = this.model.getField(column.id)
if (!field) return
if (["attachment", "password", "link"].includes(field.type)) return
let value = record[column.id]
if (!value && value !== false) return
let valueHtml = this._renderSingleValue(field, value, record)
// let valueHtml = record[field.id]
return valueHtml
})
}
/**
* Render a single value inside a card
*
* @private
* @ignore
* @param {object} field - Field to render
* @param {*} value - Field value
* @param {object} value - The record, useful for custom renderers
* @returns {string} Html for the value
*/
_renderSingleValue(field, value, record) {
// Convert summary & lookup fields to mimic the type of their source field
let type = field.type
if (type == "lookup") {
type = field.lookup.type
} else if (type == "summary") {
if (field.summary.type == "directory" && field.summary.operation == "LIST_NAMES") type = "directory"
}
if (field.valueRenderer) return field.valueRenderer(value, record)
switch (type) {
case "date":
return new Date(value).toLocaleDateString()
case "directory":
return this._rendererForDirectoryFields(value)
case "checkbox":
return this._rendererForCheckboxFields(value)
case "attachment":
case "aiImage":
return "..."
case "password":
return "***"
default:
return value
}
}
/**
* Render for "Directory" fields
*
* @private
* @ignore
* @param {string|string[]} values - Select field values.
*/
_rendererForDirectoryFields(values) {
let listOfNames = "-"
if (values) {
listOfNames = [].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) ? name : ""
}).join(", ")
}
return listOfNames
}
/**
* Renderer for "Checkbox" fields
*
* @private
* @ignore
* @param {boolean} value
* @returns {string} Html for the value
*/
_rendererForCheckboxFields(value) {
if (value === true) return "✔"
return "✘"
}
/**
* Render the timeline 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() {
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=\"timeline-cell-1st\" style=\"width: " + this.firstColumnWidth + "px; min-width: " + this.firstColumnWidth + "px\">"
firstColumn += this._renderRowContent1stColumn(record, rowIndex)
firstColumn += "</div>"
table += "<div row=\"" + rowIndex + "\" recordId=\"" + record.id + "\" class=\"timeline-row\">"
table += this._renderRowContent(record)
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=\"timeline-group timeline-group-level-" + record.$groupLevel + "\" style=\"width: " + this.firstColumnWidth + "px; min-width: " + this.firstColumnWidth + "px\">"
firstColumn += this._renderRowGroupContent1stColumn(record)
firstColumn += "</div>"
table += "<div row=\"" + rowIndex + "\" groupLevel=\"" + record.$groupLevel + "\" class=\"timeline-group-row\">"
table += this._renderRowGroupContent(record)
table += "</div>"
} else {
firstColumn += "<div col=\"-1\" row=\"" + rowIndex + "\" recordId=\"" + record.id + "\" class=\"timeline-cell-1st\" style=\"width: " + this.firstColumnWidth + "px; min-width: " + this.firstColumnWidth + "px\">"
firstColumn += this._renderRowContent1stColumn(record, rowIndex)
firstColumn += "</div>"
table += "<div row=\"" + rowIndex + "\" recordId=\"" + record.id + "\" class=\"timeline-row\">"
table += this._renderRowContent(record)
table += "</div>"
}
nbOfRows++
}
}
// Inject the table into the DOM
this.timelineBody.innerHTML = table
// Inject the table 1st column into the DOM
this.timelineBody1stColumn.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.timelineBodyContainer.classList.add("timeline-body-container-empty")
} else {
this.timelineBodyContainer.classList.remove("timeline-body-container-empty")
}
}
/**
* Render a single row of the timeline
*
* @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("timeline-row")
// Apply the style "selected" if the row has been selected
// TODO: optimization => apply the "selected" style for all selected rows *after* the timeline has been fully rendered
let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
if (isSelected) newRow.classList.add("timeline-row-selected")
// Inject row content (= cells) into the div
newRow.innerHTML = this._renderRowContent(record)
return newRow
}
/**
* Render the 1st cell of a single row of the timeline
*
* @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("timeline-cell-1st")
firstCell.style.width = firstCell.style.minWidth = this.firstColumnWidth + "px"
// Apply the style "selected" if the row has been selected
let isSelected = !(this.selectedRecords.indexOf(record.id) == -1)
firstCell.innerHTML = this._renderRowContent1stColumn(record, rowIndex, isSelected)
return firstCell
}
/**
* Render the content of the 1st cell of a single row of the timeline.
*
* 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=\"timeline-row-checkbox timeline-row-checkbox-" + ((isSelected) ? "on" : "off") + "\"></span>" : "") + // Selection checkbox
"<span class=\"timeline-row-number\">" + ((record.$index + 1) || Number(rowIndex + 1)) + " ● </span>" + // Row number
"<span class=\"timeline-row-title\">" + record[this.titleField] + "</span>" // Row data
}
/**
* Render a single *group* row of the timeline.
*
* @private
* @ignore
* @param {object} record
* @param {number} rowIndex
*/
_renderRowGroupDiv(record, rowIndex) {
let newRow = document.createElement("div")
newRow.setAttribute("row", rowIndex)
newRow.classList.add("timeline-group-row")
newRow.innerHTML = this._renderRowGroupContent(record)
return newRow
}
/**
* Render the first cell of a single *group* row of the timeline.
*
* @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("timeline-group", "timeline-group-level-" + record.$groupLevel)
firstCell.style.width = firstCell.style.minWidth = this.firstColumnWidth + "px"
firstCell.innerHTML = this._renderRowGroupContent1stColumn(record)
return firstCell
}
/**
* Render the content of a single *group* row of the timeline.
*
* @private
* @ignore
* @param {object} record
* @returns {string} Html source for a *group* row
*/
_renderRowGroupContent(record) {
return "<div class=\"timeline-group-id\">" + record.$groupId + ". " + record.$name + "</div>"
}
/**
* Render the content of the 1st cell of a single *group* row of the timeline.
*
* @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)) ? "timeline-group-collapsed" : "timeline-group-expanded"
// The 1st cell of the row includes:
// - an icon to expand/collapse the group
// - 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
groupCellValue + " (" + record.$size + ")" // Group name and count
}
/**
* Compute the color of a block according to the colorField
*
* @private
* @ignore
* @param {object} record
* @returns {string} The color in hexadecimal format, like "#ff0000"
*/
_computeBlockColor(record) {
let colorValue = this.model.color
const colorField = (this.colorField) ? this.model.getField(this.colorField) : null
if (colorField) {
colorValue = record[this.colorField]
if (colorField.type == "select") {
colorValue = colorField.options.find(option => option.value == colorValue)
colorValue = (colorValue) ? colorValue.color : this.model.color
}
}
return colorValue
}
/**
* Compute the size of a timeline "block" according to the start and end dates.
*
* A block has 3 parts:
* - the start part
* - the middle part (the main content of the block, like a card or a record)
* - the end part
*
* @private
* @ignore
* @param {date} startDate
* @param {date} endDate
* @returns {object} An object with 3 properties defining each part width: start, middle, end
*/
_computeBlockWidth(startDate, endDate) {
const bodyWidth = this.numberOfDays * this.dayWidth
const unit = bodyWidth / this.numberOfDays
const startDifference = kiss.formula.DAYS_DIFFERENCE(this.startDate, startDate)
const endDifference = kiss.formula.DAYS_DIFFERENCE(endDate, this.endDate) - 1
let startX = startDifference * unit
if (startX < 0) startX = 0
else if (startX > bodyWidth) startX = bodyWidth
let endX = bodyWidth - endDifference * unit
if (endX < 0) endX = 0
else if (endX > bodyWidth) endX = bodyWidth
const startBlockWidth = Math.max(Math.floor(startX), 0)
const middleBlockWidth = Math.max(Math.floor(endX - startX), 0)
const endBlockWidth = Math.max(Math.floor(bodyWidth - endX), 0)
return {
start: startBlockWidth,
middle: middleBlockWidth,
end: endBlockWidth
}
}
/**
* 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 => {
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 "color":
column.renderer = this._prepareCellRendererForColors(column)
break
case "icon":
column.renderer = this._prepareCellRendererForIcons(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() {
return function (value) {
return ((value || "") + "").escapeHtml()
}
}
/**
* 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 - Timeline - 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 + " timeline-type-checkbox-checked" : defaultIconOff + " timeline-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.interval || 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="timeline-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 "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)
}
}
}
/**
* Render the virtual scrollbar
*
* @private
* @ignore
*/
_renderScroller() {
// getBoundingClientRect is a bit behind the dom rendering
setTimeout(() => {
this.timelineScrollerContainer.style.top = this.timelineBody.getBoundingClientRect().top + "px"
this.timelineScrollerContainer.style.left = this.getBoundingClientRect().right - this.timelineScrollerContainer.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.timelineScrollerContainer.style.height = this.timelineBody.offsetHeight - 10 + "px"
this.timelineScroller.style.height = Math.min(this.collection.count * (this.rowHeight), 10000) + "px"
}
/**
* Show the virtual scroller
*
* @private
* @ignore
*/
_showScroller() {
if (this.showScroller !== false) {
setTimeout(() => {
this.timelineScrollerContainer.style.visibility = "visible"
}, 0)
}
}
/**
* Hide the virtual scroller
*
* @private
* @ignore
*/
_hideScroller() {
this.timelineScrollerContainer.style.visibility = "hidden"
}
/**
* Sync the virtual scrollbar position with the current timeline "skip" value
*
* @private
* @ignore
*/
_renderScrollerPosition() {
let percent = this.skip / (this.collection.records.length - this.limit)
let topPosition = Math.round((this.timelineScroller.offsetHeight - this.timelineBody.offsetHeight) * percent)
this.preventScroll = true // Disable onscroll event to avoid echo
this.timelineScrollerContainer.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
* - buttons to paginate (previous, next) 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: "timeline-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()
// Setup the timeline
createButton({
hidden: !this.showSetup,
target: "setup:" + this.id,
tip: txtTitleCase("setup the timeline"),
icon: "fas fa-cog",
iconColor: this.color,
width: 32,
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: 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()
// 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()
}
// 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 display mode
if (this.canChangePeriod) {
let _this = this
createSelect({
target: "pager-mode:" + this.id,
id: "pager-mode:" + this.id,
options: [{
label: "1 " + txt("year"),
value: "1 year"
}, {
label: "6 " + txt("months"),
value: "6 months"
}, {
label: "4 " + txt("months"),
value: "4 months"
}, {
label: "3 " + txt("months"),
value: "3 months"
}, {
label: "2 " + txt("months"),
value: "2 months"
}, {
label: "1 " + txt("month"),
value: "1 month"
},
{
label: "3 " + txt("weeks"),
value: "3 weeks"
},
{
label: "2 " + txt("weeks"),
value: "2 weeks"
},
{
label: "1 " + txt("week"),
value: "1 week"
}
],
optionsColor: this.color,
value: this.period || "1 month",
fieldWidth: 150,
styles: {
"this": "align-items: center;",
"field-label": "white-space: nowrap;",
"field-select": "white-space: nowrap;",
},
events: {
change: async function () {
_this.period = this.getValue()
const numberOfDays = _this.periods[_this.period]
const bodyWidth = _this._getBodyWidth()
const newDayWidth = Math.floor(bodyWidth / numberOfDays)
_this._setDayWidth(newDayWidth)
}
}
}).render()
}
// Pager previous
createButton({
target: "pager-previous:" + this.id,
icon: "fas fa-chevron-left",
iconColor: this.color,
width: 32,
events: {
click: () => {
this.date = new Date(kiss.formula.ADJUST_DATE(this.startDate, 0, 0, -this.numberOfDays, 0, 0, 0))
this._render()
}
}
}).render()
// Pager next
createButton({
target: "pager-next:" + this.id,
icon: "fas fa-chevron-right",
iconColor: this.color,
width: 32,
events: {
click: () => {
this.date = new Date(kiss.formula.ADJUST_DATE(this.startDate, 0, 0, this.numberOfDays, 0, 0, 0))
this._render()
}
}
}).render()
// Pager today
const todayButton = {
target: "pager-today:" + this.id,
icon: "fas fa-stop",
iconColor: this.color,
events: {
click: () => {
this.date = new Date()
this._render()
}
}
}
if (!kiss.screen.isMobile) todayButton.text = txtTitleCase("today")
createButton(todayButton).render()
// 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 timeline
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)
checkbox.classList.add("timeline-row-checkbox-on")
checkbox.classList.remove("timeline-row-checkbox-off")
// Highlight the selected row
let row = this.timelineBody.querySelector("[row=\"" + rowIndex + "\"]")
row.classList.add("timeline-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)
checkbox.classList.add("timeline-row-checkbox-off")
checkbox.classList.remove("timeline-row-checkbox-on")
// Remove the highlight on the selected row
let row = this.timelineBody.querySelector("[row=\"" + rowIndex + "\"]")
row.classList.remove("timeline-row-selected")
}
/**
* Highlight a row
*
* @private
* @ignore
* @param {number} rowIndex - The row index to highlight
*/
_rowHighlight(rowIndex) {
let row = this.querySelector("[row=\"" + rowIndex + "\"]")
row.classList.add("timeline-row-selected")
}
/**
* 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.timelineBody1stColumn.querySelector("[row=\"" + rowIndex + "\"]").querySelector(".timeline-row-checkbox")
}
/**
* Find the index of a record in the timeline (including hidden rows)
*
* @private
* @ignore
* @param {string} recordId
* @returns {number} The index of the record in the timeline, 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.timelineBody.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-timeline-" + this.id + "-row-height"
const rowHeight = localStorage.getItem(localStorageId)
if (!rowHeight) return this.defaultRowHeight
return Number(rowHeight)
}
/**
*
* COLUMNS MANAGEMENT
*
*/
/**
* Save the width of the 1st column in the localStorage
*
* @private
* @ignore
* @param {number} newWidth - New column width
*/
_columnsSetWidth(newWidth) {
localStorage.setItem("config-timeline-" + this.id + "-1st-column", newWidth)
}
/**
* Resize the 1st column
*
* @private
* @ignore
*/
_columnsResizeWithDragAndDrop(event, element) {
let columnMinSize = 90
// 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
// !!!
// 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) {
columnHeader.style.minWidth = columnHeader.style.width = newWidth + "px"
columnHeaderTitle.style.minWidth = columnHeaderTitle.style.width = newWidth - 16 + "px"
columnCells.forEach(cell => cell.style.width = cell.style.minWidth = newWidth + "px")
this._columnsSetFirstColumnWidth(newWidth)
}
}, 1)
}
// Remove listeners & re-render
document.onmouseup = () => {
this._columnsSetWidth(Math.max(columnMinSize, newWidth))
document.onmousemove = null
document.onmouseup = null
this._render()
}
}
/**
* Resize the timeline first column, used to display:
* - selection checkboxes
* - group names, when the view is grouped by a field
*
* @private
* @ignore
* @param {number} newWidth
* @returns this
*/
_columnsSetFirstColumnWidth(newWidth) {
this.firstColumnWidth = newWidth
this.timelineHeader1stColumn.style.minWidth = newWidth + "px"
this.timelineBody1stColumn.style.minWidth = newWidth + "px"
return this
}
/**
* Adjust 1st column width from 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
* @returns this
*/
_columnsAdjustWidthFromLocalStorage() {
let localStorageId = "config-timeline-" + this.id + "-1st-column"
let firstColumnWidth = localStorage.getItem(localStorageId)
this.firstColumnWidth = (firstColumnWidth || this.firstColumnWidth)
return this
}
/**
*
* OTHER MISC METHODS
*
*/
/**
* Render the menu to change timeline 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()
}
}
// Create a Custom Element and add a shortcut to create it
customElements.define("a-timeline", kiss.ui.Timeline)
/**
* Shorthand to create a new Timeline. See [kiss.ui.Timeline](kiss.ui.Timeline.html)
*
* @param {object} config
* @returns HTMLElement
*/
const createTimeline = (config) => document.createElement("a-timeline").init(config)
;
Source