/**
*
* The **Dashboard** derives from [DataComponent](kiss.ui.DataComponent.html).
*
* A dashboard is a group of charts that are displayed together in a single screen.
*
* @param {object} config
* @param {string} config.name - The dashboard title. We use the "name" property instead of "title" to be consistent with all view names.
* @param {Collection} config.collection - The data source collection
* @param {object} [config.record] - Record to persist the view configuration into the db
* @param {boolean} [config.canEditView] - true if the user can edit the view setup. Default is true.
* @param {boolean} [config.useCDN] - Set to false to use the local version of ChartJS. Default is true.
* @returns this
*
* ## Generated markup
* ```
* <a-dashboard class="a-dashboard">
* <div class="dashboard-header">
* <div class="dashboard-title">
* <!-- Dashboard title -->
* </div>
* <div class="dashboard-toolbar">
* <!-- Dashboard toolbar items -->
* </div>
* </div>
* <div class="dashboard-container">
* <!-- Embedded charts -->
* </div>
* </a-dashboard>
* ```
*/
kiss.ui.Dashboard = class Dashboard 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 myDashboard = document.createElement("a-dashboard").init(config)
* ```
*
* Or use the shorthand for it:
* ```
* const myDashboard = createDashboard({
* id: "my-dashboard",
* collection: kiss.app.collections["opportunity"]
* })
*
* myDashboard.render()
* ```
*/
constructor() {
super()
}
/**
* Generates a Dashboard 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.color = config.color || "#00aaee"
this.canEditView = (config.canEditView === false) ? false : true
this.useCDN = (config.useCDN === false) ? false : true
this.CHART_TOP_SPACE = 13 // Busy space at the top of each chart, in rem
// Build dashboard skeletton markup
let id = this.id
this.innerHTML = /*html*/
`<div class="dashboard-container">
<div class="dashboard-header">
<div class="dashboard-title">
${this.name || ""}
</div>
<div style="flex: 1"></div>
<div id="dashboard-toolbar:${id}" class="dashboard-toolbar">
<div id="setup:${id}"></div>
</div>
</div>
<div id="dashboard-groups:${id}" class="dashboard-groups"></div>
</div>`.removeExtraSpaces()
// Set dashboard components
this.header = this.querySelector(".dashboard-header")
this.headerTitle = this.querySelector(".dashboard-title")
this.toolbar = this.querySelector(".dashboard-toolbar")
this.dashboardGroups = this.querySelector(".dashboard-groups")
this._initClickEvents()
this._initSubscriptions()
return this
}
/**
* Load the dashboard
*
* @ignore
*/
async load() {
await this.collection.find()
this._render()
}
/**
* Render the dashboard
*
* @private
* @ignore
* @returns this
*/
_render() {
let isFirstGroup = false
this.dashboardGroups.innerHTML = ""
let items = this.record.config || []
// Init an empty group if no items are found
if (items.length == 0) {
isFirstGroup = true
const groupId = "dashboard-group:" + uid()
const viewId = uid()
items.push({
id: groupId,
class: "dashboard-group",
height: `calc(calc(calc(100vh - ${this.CHART_TOP_SPACE}rem) * 0.5) - 1px)`, // 50% of the container's available height
items: [{
id: viewId,
type: "chartview",
chartType: "bar",
dashboard: true,
name: txtTitleCase("untitled"),
color: this.color,
collection: this.collection.clone("memory"),
useCDN: this.useCDN
}]
})
}
// Hide the setup button of each chart if the user cannot update the dashboard
if (!this.canEditView) {
items.forEach(group => {
group.items.forEach(item => {
if (item.type == "chartview") {
item.showActions = false
}
})
})
}
// Constrain CDN parameter of each chart to reflect the dashboard
items.forEach(group => {
group.items.forEach(item => {
if (item.type == "chartview") {
item.useCDN = this.useCDN
}
})
})
// Inject the new group
this.dashboardGroups.appendChild(
createBlock({
id: "dashboard-groups-container:" + this.id,
width: "100%",
flex: 1,
overflow: "auto",
items
}
).render())
// Add buttons to manage the dashboard (= add a chart to a group)
this._manageDashboardButtons()
// Enable drag and drop
this._enableDragAndDrop()
// Adjust buttons color
this.setColor(this.color)
// Show the quick tips for the 1st created group
if (isFirstGroup) this._showQuickTips()
return this
}
/**
* Set the dashboard title
*
* @param {string} newTitle
*/
setTitle(newTitle) {
this.headerTitle.innerHTML = newTitle
}
/**
* Set the color of the dashboard (mainly buttons at the moment)
*
* @param {string} color
* @returns this
*/
setColor(color) {
const charts = this._getCharts()
for (const chart of charts) {
chart.setColor(color)
}
return this
}
/**
* Add a group of charts to the dashboard
*
* @async
* @returns this
*/
async addGroup() {
const newGroupId = uid()
const newGroup = createBlock({
id: "dashboard-group:" + newGroupId,
class: "dashboard-group",
items: []
}).render()
const groupsContainer = this._getGroupsContainer()
groupsContainer.appendChild(newGroup)
await this.saveConfig()
const escapedId = CSS.escape("dashboard-group:" + newGroupId)
kiss.tools.waitForElement("#" + escapedId).then(() => {
$("dashboard-group:" + newGroupId).scrollIntoView({
behavior: "smooth"
})
})
this.addChart("dashboard-group:" + newGroupId)
this._manageDashboardButtons()
this._enableDragAndDrop()
return this
}
/**
* Delete a group of charts from the dashboard
*
* @param {string} groupId - The group ID to delete
*/
async deleteGroup(groupId) {
createDialog({
title: txtTitleCase("delete this row"),
icon: "fas fa-trash",
headerBackgroundColor: "var(--red)",
message: txtTitleCase("#delete row"),
action: async () => {
const groupsContainer = this._getGroupsContainer()
groupsContainer.deleteItem(groupId)
await this.saveConfig()
this._render()
}
})
}
/**
* Move a group up in the dashboard
*
* @param {string} groupId
* @returns this
*/
moveGroupUp(groupId) {
const group = $(groupId)
if (!group) return
const previousGroup = group.previousElementSibling
if (!previousGroup || !previousGroup.classList.contains("dashboard-group")) return // Already at the top
previousGroup.before(group)
this.saveConfig()
return this
}
/**
* Move a group down in the dashboard
*
* @param {string} groupId
* @returns this
*/
moveGroupDown(groupId) {
const group = $(groupId)
if (!group) return
const nextGroup = group.nextElementSibling
if (!nextGroup || !nextGroup.classList.contains("dashboard-group")) return // Already at the bottom
nextGroup.after(group)
this.saveConfig()
return this
}
/**
* Checks whether a group can be moved up or down.
*
* @param {string} groupId - The ID of the group to check.
* @returns {object} - An object with `up` and `down` properties indicating the possible moves.
*/
checkGroupMove(groupId) {
const group = $(groupId)
if (!group) return false
const previousGroup = group.previousElementSibling;
const nextGroup = group.nextElementSibling;
return {
up: !!(previousGroup && previousGroup.classList.contains("dashboard-group")),
down: !!(nextGroup && nextGroup.classList.contains("dashboard-group")),
}
}
/**
* Get the groups of the dashboard
*
* @returns {HTMLElement[]} - The groups of charts (1 group = 1 row in the dashboard)
*/
getGroups() {
return Array.from(this.querySelectorAll(".dashboard-group"))
}
/**
* Get the number of groups in the dashboard
*
* @returns {number} - The number of groups
*/
getGroupCount() {
return this.getGroups().length
}
/**
* Get the charts of a group
*
* @param {string} groupId
* @returns {HTMLElement[]} - The charts of the group
*/
getGroupCharts(groupId) {
return Array.from($(groupId).querySelectorAll(".a-chartview"))
}
/**
* Get a chart from its ID
*
* @param {string} chartId
* @returns {HTMLElement} - The chart element
*/
getChart(chartId) {
const dashboardGroups = $("dashboard-groups:" + this.id)
let chart = null
for (const dashboardGroup of dashboardGroups.firstChild.items) {
chart = dashboardGroup.items.find(item => item.id == chartId)
if (chart) break
}
return chart
}
/**
* Add a chart to a group of charts
*
* @async
* @param {string} groupId
* @returns this
*/
async addChart(groupId) {
const viewId = uid()
const dashboardGroup = $(groupId)
dashboardGroup.insertItem({
id: viewId,
type: "chartview",
chartType: "bar",
dashboard: true,
name: txtTitleCase("untitled"),
color: this.color,
collection: this.collection.clone("memory"),
useCDN: this.useCDN
}, dashboardGroup.items.length - 1)
this._manageDashboardButtons()
this._enableDragAndDrop()
await this.saveConfig()
return this
}
/**
* Move a chart to another position in the dashboard
*
* @param {string} chartId
* @param {string} targetChartId
* @returns this
*/
moveChart(chartId, targetChartId) {
const sourceChart = $(chartId)
const targetChart = $(targetChartId)
const sourceGroup = $(chartId).closest(".dashboard-group")
const targetGroup = $(targetChartId).closest(".dashboard-group")
// Case 1: same group
if (sourceGroup.id == targetGroup.id) {
const sourceIndex = this.getChartIndexInGroup(chartId)
const targetIndex = this.getChartIndexInGroup(targetChartId)
if (sourceIndex < targetIndex) {
targetChart.after(sourceChart)
} else {
targetChart.before(sourceChart)
}
this.saveConfig()
return this
}
// Case 2: different groups
const chartsInTargetGroup = targetGroup.querySelectorAll(".a-chartview").length
if (chartsInTargetGroup >= 4) {
return createNotification(txtTitleCase("this row is full"))
}
const targetIndex = [...targetGroup.children].indexOf(targetChart)
if (targetIndex !== -1) {
targetChart.before(sourceChart)
} else {
targetGroup.appendChild(sourceChart)
}
// Recompute the size of the charts
sourceChart.updateSize()
this._updateGroupLayout(sourceGroup)
// If the source group is empty, remove it
if (sourceGroup.children.length == 1) {
sourceGroup.deepDelete()
}
this.saveConfig()
return this
}
/**
* Remove a chart from the dashboard
*
* @async
* @param {string} chartId - The chart ID to remove
* @returns this
*/
async deleteChart(chartId) {
const dashboardGroup = $(chartId).closest(".dashboard-group")
dashboardGroup.deleteItem(chartId)
this._manageDashboardButtons()
await this.saveConfig()
return this
}
/**
* Get the index of a chart in a group
*
* @param {string} chartId
* @returns {number} - The chart index in the group
*/
getChartIndexInGroup(chartId) {
const sourceChart = $(chartId)
const sourceGroup = $(chartId).closest(".dashboard-group")
return [...sourceGroup.children].indexOf(sourceChart)
}
/**
* Get the current dashboard configuration
*
* @returns {object[]} - The dashboard configuration, as an array of groups of charts
*/
getConfig() {
const dashboardGroupsContainer = $("dashboard-groups:" + this.id)
let dashboardGroups = []
const dashboardGroupElements = Array.from(dashboardGroupsContainer.firstChild.children)
dashboardGroupElements.forEach(dashboardGroup => {
let group = {
id: dashboardGroup.id,
height: dashboardGroup.config.height,
class: "dashboard-group",
}
let items = []
const groupItems = [...dashboardGroup.children]
groupItems.forEach(item => {
if (item.type == "chartview") {
let newItem = {
type: "chartview",
dashboard: true,
id: item.id,
name: item.name,
modelId: item.model.id,
chartType: item.chartType,
sort: item.sort,
filter: item.filter,
isTimeSeries: item.isTimeSeries,
categoryField: item.categoryField,
timeField: item.timeField,
timePeriod: item.timePeriod,
operationType: item.operationType,
summaryOperation: item.summaryOperation,
valueField: item.valueField,
startAtZero: item.startAtZero,
showLegend: item.showLegend,
legendPosition: item.legendPosition,
showValues: item.showValues,
showLabels: item.showLabels,
centerLabels: item.centerLabels,
labelColor: item.labelColor,
precision: item.precision,
unit: item.unit,
useCDN: this.useCDN
}
items.push(newItem)
}
})
group.items = items
dashboardGroups.push(group)
})
return dashboardGroups
}
/**
* Save the current dashboard configuration
*
* @async
* @returns this
*/
async saveConfig() {
const config = this.getConfig()
await this.record.update({
config
})
return this
}
/**
* Update the layout of the dashboard
*
* @returns this
*/
updateLayout() {
return this
}
/**
* Print the dashboard - Work in progress
*/
async print() {
// const charts = document.querySelectorAll("a-chart")
// charts.forEach((chartElement) => {
// const chartInstance = chartElement.chart
// if (chartInstance) {
// const img = document.createElement("img")
// img.src = chartInstance.canvas.toDataURL()
// img.style.display = "block"
// img.style.width = "100%"
// img.style.maxWidth = "fit-content"
// img.style.height = "100%"
// img.style.maxHeight = "fit-content"
// chartElement.replaceChild(img, chartInstance.canvas)
// }
// })
// setTimeout(() => window.print(), 500)
}
/**
* Initialize click events
*
* @private
* @ignore
*/
_initClickEvents() {
if (!this.canEditView) return
// this.onclick = (e) => {
// const target = e.target
// log("---------")
// log(target)
// const chartButton = target.closest(".a-button")
// if (!chartButton && target.closest(".dashboard-group")) {
// const groupId = target.closest(".dashboard-group").id
// this._showGroupSetup(groupId, e)
// }
// }
}
/**
* Initialize subscriptions to PubSub
*
* @private
* @ignore
* @returns this
*/
_initSubscriptions() {
// React to database mutations
this.subscriptions = this.subscriptions.concat([
subscribe("EVT_DB_UPDATE:VIEW", (msgData) => this._updateTitle(msgData)),
// React to events coming from individual charts
subscribe("EVT_DASHBOARD_SETUP", (chartId) => {
if (!this.isConnected) return
const chartIds = this._getChartIds()
if (chartIds.length == 0) return
if (chartIds.includes(chartId)) {
this.saveConfig()
}
}),
// React when one of the charts is deleted
subscribe("EVT_DASHBOARD_CHART_DELETED", (chartId) => {
if (!this.isConnected) return
const chartIds = this._getChartIds()
if (chartIds.length == 0) return
if (chartIds.includes(chartId)) {
this.deleteChart(chartId)
}
}),
// React when one of the charts is clicked
subscribe("EVT_CHART_CLICKED", (msg) => {
if (!this.isConnected) return
const chartIds = this._getChartIds()
if (chartIds.length == 0) return
if (chartIds.includes(msg.chartId)) {
const group = this._getChartGroup(msg.chartId)
this._showGroupSetup(group.id, msg.event)
}
})
])
return this
}
/**
* Initialize the charts drag and drop
*
* @private
* @ignore
*/
_enableDragAndDrop() {
if (!this.canEditView) return
// Drag and drop helpers
const getCharts = () => this.querySelectorAll(".a-chartview")
const resetCharts = () => getCharts().forEach(chart => chart.classList.remove("chartview-highlight"))
const resetChart = (chart) => chart.classList.remove("chartview-highlight")
const highlightChart = (chart) => chart.classList.add("chartview-highlight")
// Drag and drop events
const dndEvents = {
ondragstart: (event) => {
const chart = event.target.closest(".a-chartview")
kiss.context.chartId = chart.id
},
ondragover: (event) => {
event.preventDefault()
resetCharts()
const chart = event.target.closest(".a-chartview")
if (!chart) return
highlightChart(chart)
},
ondrop: (event) => {
event.preventDefault()
resetCharts()
const chart = event.target.closest(".a-chartview")
if (!chart) return
const chartId = kiss.context.chartId
this.moveChart(chartId, chart.id)
},
ondragleave: (event) => {
const chart = event.target.closest(".a-chartview")
if (!chart) return
resetChart(chart)
}
}
this.querySelectorAll(".a-chartview").forEach(chart => {
chart.draggable = true
chart.ondragstart = dndEvents.ondragstart
Object.assign(chart, dndEvents)
})
}
/**
* Show the menu to setup a group of charts
*
* @param {string} groupId
* @param {object} event - The click event
*/
_showGroupSetup(groupId, event) {
const _this = this
const group = $(groupId)
const groupMoves = this.checkGroupMove(groupId)
createMenu({
items: [
// SIZE
`<h3>${txtTitleCase("row height")}</h3>`,
"-",
{
text: "20%",
icon: "fas fa-circle",
iconSize: "0.2rem",
action: async function() {
group.config.height = group.style.height = `calc(calc(calc(100vh - ${_this.CHART_TOP_SPACE}rem) * 0.2) - 1px)`
await _this.saveConfig()
}
},
{
text: "25%",
icon: "fas fa-circle",
iconSize: "0.5rem",
action: async function() {
group.config.height = group.style.height = `calc(calc(calc(100vh - ${_this.CHART_TOP_SPACE}rem) * 0.25) - 1px)`
await _this.saveConfig()
}
},
{
text: "33%",
icon: "fas fa-circle",
iconSize: "0.66rem",
action: async function() {
group.config.height = group.style.height = `calc(calc(calc(100vh - ${_this.CHART_TOP_SPACE}rem) * 0.33) - 1px)`
await _this.saveConfig()
}
},
{
text: "40%",
icon: "fas fa-circle",
iconSize: "0.8rem",
action: async function() {
group.config.height = group.style.height = `calc(calc(calc(100vh - ${_this.CHART_TOP_SPACE}rem) * 0.40) - 1px)`
await _this.saveConfig()
}
},
{
text: "50%",
icon: "fas fa-circle",
iconSize: "1rem",
action: async function() {
group.config.height = group.style.height = `calc(calc(calc(100vh - ${_this.CHART_TOP_SPACE}rem) * 0.5) - 1px)`
await _this.saveConfig()
}
},
{
text: "66%",
icon: "fas fa-circle",
iconSize: "1.3rem",
action: async function() {
group.config.height = group.style.height = `calc(calc(calc(100vh - ${_this.CHART_TOP_SPACE}rem) * 0.66) - 1px)`
await _this.saveConfig()
}
},
{
text: "75%",
icon: "fas fa-circle",
iconSize: "1.5rem",
action: async function() {
group.config.height = group.style.height = `calc(calc(calc(100vh - ${_this.CHART_TOP_SPACE}rem) * 0.75) - 1px)`
await _this.saveConfig()
}
},
{
text: "100%",
icon: "fas fa-circle",
iconSize: "2rem",
action: async function() {
group.config.height = group.style.height = `calc(100vh - ${_this.CHART_TOP_SPACE}rem)`
await _this.saveConfig()
}
},
"-",
// MOVE UP
{
hidden: groupMoves.up ? false : true,
text: txtTitleCase("move up"),
icon: "fas fa-arrow-up",
action: async () => this.moveGroupUp(groupId)
},
// MOVE DOWN
{
hidden: groupMoves.down ? false : true,
text: txtTitleCase("move down"),
icon: "fas fa-arrow-down",
action: async () => this.moveGroupDown(groupId)
},
// ADD A ROW OF CHARTS
{
text: txtTitleCase("add row"),
icon: "fas fa-plus",
action: async () => this.addGroup()
},
// DELETE
"-",
{
text: txtTitleCase("delete this row"),
icon: "fas fa-trash",
iconColor: "var(--red)",
action: async () => this.deleteGroup(groupId)
}
]
}).render().showAt(event.clientX - 20, event.clientY - 20)
}
/**
* Show quick tips for the 1st created chart
*
* @private
* @ignore
*/
_showQuickTips() {
const dashboardGroup = this.getGroups()[0]
setTimeout(() => {
const buttonSetup = $("actions:" + dashboardGroup.items[0].id)
const buttonAdd = dashboardGroup.items[dashboardGroup.items.length - 1]
kiss.tools.highlightElements([
{
element: buttonSetup,
text: txtTitleCase("#help setup chart"),
position: "left"
},
{
element: buttonAdd,
text: txtTitleCase("#help add chart"),
position: "left"
}
])
}, 1000)
}
/**
* Manage the dashboard buttons
*
* @private
* @ignore
*/
_manageDashboardButtons() {
if (!this.canEditView) return
setTimeout(() => {
const dashboardGroupsContainer = $("dashboard-groups:" + this.id)
const dashboardGroupElements = Array.from(dashboardGroupsContainer.firstChild.children)
// Remove all buttons from the dashboard
dashboardGroupElements.forEach(dashboardGroup => {
dashboardGroup.items.forEach((item, index) => {
if (item.type == "button") {
dashboardGroup.deleteItem(item.id)
}
})
})
// Add a button to add a chart to each group
dashboardGroupElements.forEach(dashboardGroup => {
if (dashboardGroup.items.length > 3) return
let buttonConfig = {
type: "button",
icon: "fas fa-plus",
width: "3.2rem",
height: "3.2rem",
margin: "0 0 0 -2.5rem",
tip: txtTitleCase("add chart"),
action: () => this.addChart(dashboardGroup.id)
}
dashboardGroup.addItem(buttonConfig)
})
}, 0)
}
/**
* Update the dashboard title
*
* @private
* @ignore
*/
_updateTitle(msgData) {
if (!this.record) return
if (msgData.id == this.record.id && msgData.data.name) {
this.setTitle(msgData.data.name)
}
}
/**
* Get the charts of the dashboard
*
* @returns {HTMLElement[]} - The charts
*/
_getCharts() {
return Array.from(this.querySelectorAll(".a-chartview"))
}
/**
* Get the chart IDs from the dashboard
*
* @private
* @ignore
* @returns {string[]} - The chart IDs
*/
_getChartIds() {
return this._getCharts().map(chart => chart.id)
}
/**
* Get the group of a chart
*
* @private
* @ignore
* @param {string} chartId
* @returns {HTMLElement} - The group element
*/
_getChartGroup(chartId) {
return $(chartId).closest(".dashboard-group")
}
/**
* Get the index of a group in the dashboard
*
* @private
* @ignore
* @param {string} groupId
* @returns {number} - The group index, or -1 if not found
*/
_getGroupIndex(groupId) {
const dashboardGroups = $("dashboard-groups:" + this.id)
return dashboardGroups.firstChild.items.findIndex(group => group.id == groupId)
}
/**
* Get the groups container
*
* @private
* @ignore
* @returns {HTMLElement} - The groups container
*/
_getGroupsContainer() {
return $("dashboard-groups-container:" + this.id)
}
/**
* Refresh the sizes of the charts in a group
*
* @private
* @ignore
* @param {HTMLElement} group - The group element
*/
_updateGroupLayout(group) {
group.querySelectorAll(".a-chartview").forEach(chart => chart.updateSize())
}
}
// Create a Custom Element and add a shortcut to create it
customElements.define("a-dashboard", kiss.ui.Dashboard)
/**
* Shorthand to create a new Dashboard. See [kiss.ui.Dashboard](kiss.ui.Dashboard.html)
*
* @param {object} config
* @returns HTMLElement
*/
const createDashboard = (config) => document.createElement("a-dashboard").init(config)
;
Source