1999 lines
84 KiB
HTML

<style>
.red-ui-editor {
--nrdb-node-light: rgb(160, 230, 236);
--nrdb-node-medium: rgb(90, 210, 220);
--nrdb-node-dark: rgb(39, 183, 195);
--nrdb-node-darkest: rgb(32 160 170);
}
.red-ui-editor .form-row-flex {
display: flex;
align-items: baseline;
gap: 4px;
}
.red-ui-editor .form-row-flex input,
.red-ui-editor .form-row-flex label:not(:first-child) {
margin: 0;
width: auto;
}
.nrdb2-helptext {
font-size: 0.75rem;
line-height: 0.825rem;
color: var(--red-ui-tertiary-text-color);
font-style: italic;
}
.w-16 {
width: 16px;
}
#ff-node-red-dashboard {
--ff-grey-50: #F9FAFB;
--ff-grey-100: #F3F4F6;
--ff-grey-200: #E5E7EB;
position: absolute;
top: 1px;
bottom: 2px;
left: 1px;
right: 1px;
overflow-y: auto;
}
#ff-node-red-dashboard .red-ui-sidebar-header {
display: flex;
justify-content: space-between;
}
#ff-node-red-dashboard .red-ui-sidebar-header label {
margin-bottom: 0;
}
#ff-node-red-dashboard .red-ui-sidebar-header-actions {
gap: 4px;
display: flex;
}
#ff-node-red-dashboard .red-ui-editableList-container {
padding: 0;
}
/* don't show border for nexted editable lists */
.red-ui-editableList-border .red-ui-editableList-border {
border: 0;
}
#node-config-client-constraints-nodes .red-ui-treeList-label.selected {
background-color: #e3f2fd;
}
/* Dashboard 2.0 Sidebar */
.nrdb2-sb-list-button-group {
display: flex;
gap: 4px;
}
.nrdb2-sidebar-tab-content {
padding: 8px 10px;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
#dashboard-2-client-constraints a {
color: blue;
}
#dashboard-2-client-constraints a:hover {
text-decoration: underline;
}
.nrdb2-layout-helptext {
padding: 0 0 9px;
font-style: italic;
color: #a2a2a2;
font-size: 8pt;
line-height: 12pt;
}
.nrdb2-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 9px;
}
.nrdb2-sb-pages-list li {
padding: 0;
border-bottom: 0;
}
.nrdb2-sb-unattached-groups-list li {
padding: 0;
border-bottom: 0;
background-color: #ffefef;
}
.nrdb2-sb-list-header {
display: flex;
gap: 6px;
align-items: center;
padding: 9px 6px;
cursor: pointer;
}
.nrdb2-sb-list-header.nrdb2-sb-pages-list-header {
border-top: 1px solid var(--red-ui-primary-border-color, --ff-grey-200);
border-bottom: 1px solid var(--red-ui-primary-border-color, --ff-grey-200);
}
.nrdb2-sb-list-header .nrdb2-sb-title {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.nrdb2-sb-list-header .nrdb2-sb-info {
font-size: 0.75rem;
color: var(--red-ui-tertiary-text-color);
}
.nrdb2-sb-list-header .nrdb2-sb-palette {
display: flex;
gap: 2px;
}
.nrdb2-sb-list-header .nrdb2-sb-palette-color {
width: 12px;
height: 12px;
background-color: black;
border: 1px solid #d4d4d4;
border-radius: 2px;
}
.nrdb2-sb-list-header-actions {
position: absolute;
gap: 4px;
right: 1rem;
display: flex;
gap: 4px;
}
.nrdb2-sb-list-header-actions a {
cursor: pointer;
}
.nrdb2-sb-list-header-state-options {
display: flex;
gap: 4px;
}
.nrdb2-sb-list-header-button-group {
right: 1rem;
display: flex;
gap: 4px;
}
.nrdb2-sb-list-header-button-group,
.nrdb2-sb-list-handle {
opacity: 0;
transition: 0.15s opacity;
}
.nrdb2-sb-list-header:hover {
background-color: var(--red-ui-secondary-background-hover, --ff-grey-100);
}
.nrdb2-sb-list-header:hover .nrdb2-sb-list-handle,
.nrdb2-sb-list-header:hover .nrdb2-sb-list-header-button-group {
opacity: 1;
}
.nrdb2-sb-item-hidden .nrdb2-sb-title {
text-decoration: line-through;
}
.nrdb2-sb-item-disabled .nrdb2-sb-title,
.nrdb2-sb-item-disabled .nrdb2-sb-info {
opacity: 0.5;
}
/* indent the groups */
.nrdb2-sb-groups-list-header .nrdb2-sb-list-chevron {
margin-left: 1.5rem;
}
/* indent the widgets */
.nrdb2-sb-widgets-list-header .nrdb2-sb-widget-icon {
margin-left: 3.5rem;
display: inline-block;
height: 14px;
width: 14px;
text-align: center;
}
/* common styles for images */
.nrdb2-sb-widgets-list-header .nrdb2-sb-widget-icon.nrdb2-sb-widget-icon-img {
background-color: currentColor;
display: inline-block;
mask-size: contain;
mask-position: center;
mask-repeat: no-repeat;
height: 18px;
width: 18px;
margin-right: -0.15rem;
margin-left: 3.35rem;
}
#nrdb2-sb-client-data-providers {
padding-left: 24px;
}
#nrdb2-sb-client-data-providers label {
cursor: default;
}
#nrdb2-sb-client-data-providers-list {
list-style: none;
margin: 0;
margin-bottom: 12px;
border-radius: 4px;
padding: 4px;
border: 1px solid var(--red-ui-form-input-border-color);
}
#nrdb2-sb-client-data-providers-list li {
display: flex;
gap: 6px;
align-items: center;
}
#nrdb2-sb-client-data-providers-list li:not(:first-child){
border-top: 1px solid #e6e6e6;
}
#nrdb2-sb-client-data-providers-list li .fa {
color: var(--red-ui-tertiary-text-color);
font-size: 0.75rem;
}
#nrdb2-sb-client-data-providers-list li label {
margin: 0;
white-space: nowrap;
}
.nrdb2-sb-client-data-provider-package {
color: var(--red-ui-tertiary-text-color);
font-size: 0.75rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#node-config-client-constraints-nodes {
flex-grow: 1;
width: 100%;
}
#node-config-client-constraints-nodes-container {
flex-grow: 1;
display: flex;
flex-direction: column;
}
</style>
<script type="text/javascript">
// #region typedefs for intellisense and better code completion/DX
/**
* @typedef {Object} DashboardItem - A widget/group/page/subflow item
* @property {String} itemType - The type of item (e.g. 'widget', 'group', 'page', 'link')
* @property {String} id - The unique id of the item
* @property {String} name - The name of the item
* @property {String} type - The type of the item (e.g. 'ui-button', 'ui-template', 'ui-group', 'ui-page')
* @property {Number} order
* @property {String} label
* @property {String} color
* @property {String} [group] - The group id that this widget belongs to
* @property {String} [page] - The page id that this group belongs to
* @property {String} [theme] - The theme id that this page belongs to
* @property {Boolean} [isSubflowInstance] - Whether or not this item is a subflow instance
* @property {String} [subflowName] - The name give to the subflow template (only applicable when `isSubflowInstance` is `true`)
* @property {Object} node - The actual node or subflow instance that this DashboardItem represents
* @global
*/
/** @typedef {Object<string, Array<DashboardItem>>} DashboardItemLookup */
// #endregion
(function () {
const sidebarContainer = '<div style="position: relative; height: 100%;"></div>'
const sidebarContentTemplate = $('<div id="ff-node-red-dashboard"></div>').appendTo(sidebarContainer)
const sidebar = $(sidebarContentTemplate)
// convert to i18 text
function c_ (x) {
return RED._('@flowfuse/node-red-dashboard/ui-base:ui-base.' + x)
}
function hasProperty (obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop)
}
function debounce (func, wait, immediate) {
let timeout
return function () {
const context = this; const args = arguments
const later = function () {
timeout = null
if (!immediate) func.apply(context, args)
}
const callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
RED.nodes.registerType('ui-base', {
category: 'config',
defaults: {
name: {
value: c_('label.uiName'),
required: true
},
path: {
value: '/dashboard',
required: true
},
includeClientData: {
value: true
},
acceptsClientConfig: {
value: ['ui-notification', 'ui-control']
},
showPathInSidebar: {
value: false
},
showPageTitle: {
value: true
},
navigationStyle: {
value: 'default'
},
titleBarStyle: {
value: 'default'
}
},
label: function () {
return `${this.name} [${this.path}]` || 'UI Config'
},
oneditprepare: function () {
// backward compatibility for navigation style
if (!this.titleBarStyle) {
// set to default
this.titleBarStyle = 'default'
// update the jquery dropdown
$('#node-config-input-titleBarStyle').val('default')
}
// backward compatibility for including page name
if (this.showPageTitle === undefined) {
// set to true
this.showPageTitle = true
// update the jquery checkbox
$('#node-config-input-showPageTitle').prop('checked', true)
}
},
onpaletteadd: function () {
// add the Dashboard 2.0 sidebar
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: onpaletteadd ()') }
addSidebar()
}
})
/**
* Utility function to convert a dashboard node to a DashboardItem
* Provides a better DX with type checking and intellisense
* @param {Object} node - The node to convert
* @returns {DashboardItem}
*/
function toDashboardItem (node) {
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: toDashboardItem (node)', node) }
/** @type {DashboardItem} */
const item = {
itemType: 'widget',
id: node.id,
name: node.name,
type: node.type,
order: node.order,
label: null,
icon: null,
color: null,
isSubflowInstance: false,
node
}
if (hasProperty(node, 'group')) { item.group = node.group }
if (hasProperty(node, 'page')) { item.page = node.page }
if (hasProperty(node, 'link')) { item.link = node.link }
if (hasProperty(node, 'theme')) { item.theme = node.theme }
if (hasProperty(node, 'env') && Array.isArray(node.env) && /subflow:.+/.test(node.type)) {
const envOrder = node.env.find(e => e.key === 'DB2_SF_ORDER')
if (envOrder) {
item.order = envOrder.value
}
}
switch (node.type) {
case 'ui-page':
case 'ui-link':
case 'ui-group':
case 'ui-theme':
case 'ui-base':
item.itemType = node.type.replace('ui-', '')
break
default:
item.itemType = 'widget'
break
}
try {
item.order = parseInt(item.order)
} finally {
item.order = isNaN(item.order) ? 0 : item.order
}
try {
item.label = RED.utils.getNodeLabel(node)
} finally {
item.label = item.label || node.type || node.id
}
try {
item.color = RED.utils.getNodeColor(node)
} catch (_err) { }
return item
}
/**
* Add Custom Dashboard Side Menu
* */
function dashboardLink (id, name, path) {
const base = RED.settings.httpNodeRoot || '/'
const basePart = base.endsWith('/') ? base : `${base}/`
const dashPart = path.startsWith('/') ? path.slice(1) : path
const fullPath = `${basePart}${dashPart}`
const header = $('<div class="red-ui-sidebar-header"></div>')
const label = $('<label></label>').text(name)
const actions = $('<div class="red-ui-sidebar-header-actions"></div>')
const editSettingsButton = $('<a id="edit-ui-base" class="editor-button editor-button-small nr-db-sb-list-header-button">' +
c_('label.editSettings') + ' <i style="margin-left: 3px;" class="fa fa-cog"></i></a>')
editSettingsButton.on('click', function () {
RED.editor.editConfig('', 'ui-base', id)
})
const openDashboardButton = $(`<a id="open-dashboard" href="${fullPath}" target="nr-dashboard" class="editor-button editor-button-small nr-db-sb-list-header-button">` +
c_('label.openDashboard') + ' <i style="margin-left: 3px;" class="fa fa-external-link"></i></a>')
label.appendTo(header)
editSettingsButton.appendTo(actions)
openDashboardButton.appendTo(actions)
actions.appendTo(header)
return header
}
/**
* Add an editor to control the ordering of groups & widgets
*/
function updateItemOrder (items, events) {
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: updateItemOrder (items, events)', items, events) }
items.each((i, el) => {
/** @type {DashboardItem} */
const dbItem = el.data('data')
const node = dbItem?.node || {}
const setNodeOrder = (newOrder) => {
dbItem.order = newOrder
if (dbItem.isSubflowInstance) {
node.env = node.env || []
const envOrder = node.env.find(e => e.key === 'DB2_SF_ORDER')
if (envOrder) {
envOrder.value = newOrder
} else {
node.env.push({ key: 'DB2_SF_ORDER', value: '' + newOrder, type: 'str' }) // db2 sort order
}
} else {
node.order = newOrder
}
}
const getNodeOrder = () => {
let order = dbItem.order // node.order
if (dbItem.isSubflowInstance && node.env) {
const envOrder = node.env.find(e => e.key === 'DB2_SF_ORDER')
if (envOrder) { order = envOrder.value }
}
if (typeof order === 'string') { order = parseInt(order) }
if (isNaN(order)) { order = 0 }
return order || 0
}
const oldOrder = getNodeOrder()
if (oldOrder !== i + 1) {
const wasDirty = node.dirty
const wasChanged = node.changed
// update Node-RED node properties
setNodeOrder(i + 1)
node.dirty = true
node.changed = true
// generate a history event
const hev = {
t: 'edit',
node,
changes: {
order: oldOrder
},
dirty: wasDirty,
changed: wasChanged
}
events.push(hev)
}
})
}
// toggle slide tab group content
const titleToggle = function (id, content, chevron) {
return function (evt) {
if (content.is(':visible')) {
content.slideUp()
chevron.css({ transform: 'rotate(-90deg)' })
content.addClass('nr-db-sb-collapsed')
} else {
content.slideDown()
chevron.css({ transform: '' })
content.removeClass('nr-db-sb-collapsed')
}
}
}
// Utility function to store events in NR history, trigger a redraw, and detect if a re-deploy is necessary
function recordEvents (events) {
if (events.length === 0) { return } // nothing to record
// note the state of the editor before pushing to history
const isDirty = RED.nodes.dirty()
if (RED._db2debug) { console.log('dashboard 2: recordEvents ()', isDirty, events) }
// add our changes to NR history and trigger whether or not we need to redeploy
RED.history.push({
t: 'multi',
events,
dirty: isDirty
})
RED.nodes.dirty(true)
RED.view.redraw()
}
function checkDuplicateUiBases () {
// check how many ui-bases we have and trim if already too many
const bases = []
const pages = []
RED.nodes.eachConfig((n) => {
if (n.type === 'ui-base') { bases.push(n) }
if (n.type === 'ui-page') { pages.push(n) }
})
// the eachConfig is in creation order, so we can remove from the end
// and keep the oldest ones
while (bases.length > 1) {
const n = bases.pop()
if (RED._db2debug) { console.log('ui-base removed', n) }
RED.nodes.remove(n.id)
RED.nodes.dirty(true)
}
if (bases.length > 0) {
const baseId = bases[0].id
// loop over pages and re-map the ui-base to the only ui-base available
pages.forEach((page) => {
page.ui = baseId
})
RED.nodes.eachNode((node) => {
if (node.type.startsWith('ui-')) {
// check if the widgets are ui-scoped, and have a ui set to the removed ui-base nodes
if (node.ui && node.ui !== '' && node.ui !== baseId) {
node.ui = baseId
}
}
})
}
}
function addConfigNode (node) {
if (!node.users) {
node.users = []
}
node.dirty = true
RED.nodes.add(node)
RED.editor.validateNode(node)
RED.history.push({
t: 'add',
nodes: [node.id],
dirty: RED.nodes.dirty()
})
RED.nodes.dirty(true)
}
function mapDefaults (defaults) {
const values = {}
for (const key in defaults) {
if (Object.prototype.hasOwnProperty.call(defaults, key)) {
values[key] = defaults[key].value
}
}
return values
}
function getConfigNodesByType (type) {
const nodes = []
RED.nodes.eachConfig((n) => {
if ((type instanceof String) && type === n.type) {
nodes.push(n)
} else {
// we have an array of types
if (type.includes(n.type)) {
nodes.push(n)
}
}
})
return nodes
}
function addDefaultPage (baseId, themeId) {
const page = RED.nodes.getType('ui-page')
// get all pages
const entries = getConfigNodesByType(['ui-page', 'ui-link'])
const pageNumber = entries.length + 1
const pageNode = {
_def: page,
id: RED.nodes.id(),
type: 'ui-page',
...mapDefaults(page.defaults),
path: `/page${pageNumber}`, // TODO: generate a unique path
name: `Page ${pageNumber}`,
ui: baseId,
theme: themeId,
layout: 'grid',
order: pageNumber
}
addConfigNode(pageNode)
return pageNode
}
function addDefaultLink (baseId) {
const link = RED.nodes.getType('ui-link')
const linkNode = {
_def: link,
id: RED.nodes.id(),
type: 'ui-link',
...mapDefaults(link.defaults),
path: '/',
name: 'Link',
ui: baseId
}
addConfigNode(linkNode)
return linkNode
}
function addDefaultGroup (pageId) {
const group = RED.nodes.getType('ui-group')
const groupNode = {
_def: group,
id: RED.nodes.id(),
type: 'ui-group',
...mapDefaults(group.defaults),
name: 'My Group',
page: pageId
}
addConfigNode(groupNode)
return groupNode
}
function addDefaultTheme () {
const theme = RED.nodes.getType('ui-theme')
const themeNode = {
_def: theme,
id: RED.nodes.id(),
type: 'ui-theme',
...mapDefaults(theme.defaults),
name: 'Default Theme'
}
addConfigNode(themeNode)
return themeNode
}
function addLayoutsDefaults () {
const cNodes = ['ui-base', 'ui-page', 'ui-group', 'ui-theme']
let exists = false
RED.nodes.eachConfig((n) => {
if (cNodes.includes(n.type)) {
exists = true
}
})
// check if we haven't got any of these yet
if (!exists) {
// Add Single Base Node
const base = RED.nodes.getType('ui-base')
const baseNode = {
_def: base,
id: RED.nodes.id(),
type: 'ui-base',
...mapDefaults(base.defaults),
name: 'My Dashboard'
}
addConfigNode(baseNode)
const theme = addDefaultTheme()
const page = addDefaultPage(baseNode.id, theme.id)
const group = addDefaultGroup(page.id)
// update existing `ui-` nodes to use the new base/page/theme/group
RED.nodes.eachNode((node) => {
if (node.type.startsWith('ui-')) {
// if node has a group property
if (hasProperty(node._def.defaults, 'group') && !node.group) {
// group-scoped widgets - which is most of them
node.group = group.id
} else if (hasProperty(node._def.defaults, 'page') && !node.page) {
// page-scoped widgets
node.page = page.id
} else if (hasProperty(node._def.defaults, 'ui') && !node.ui) {
// base-scoped widgets, e.g. ui-notification/control
node.ui = baseNode.id
}
RED.editor.validateNode(node)
}
})
RED.view.redraw()
RED.sidebar.config.refresh()
}
}
// watch for nodes changed, added, removed - use this to refresh the sidebar
let refreshBusy = false // this is set/reset inside refreshSidebarEditors
const refreshSidebarEditorDebounced = debounce(refreshSidebarEditors, 300)
/**
* Conditional refresh of the sidebar editor
* The refresh is only triggered if the event is a ui- node or a subflow that contains a ui- node
* Calls are debounced to prevent multiple calls to refreshSidebarEditors
* @param {Object} node - The node that was changed/added/removed
* @param {String} eventName - The name of the event that was fired
*/
const conditionalSidebarRefresh = function (node, eventName) {
// if the layout editor is not in view, don't refresh
if ($('#ff-node-red-dashboard').parent().css('display') === 'none') { return }
// if a refresh is in progress, don't refresh
if (refreshBusy) { return }
if (RED._db2debug) { console.log('dashboard 2: conditionalSidebarRefresh (node, eventName)', node, eventName) }
// first, check if the node.dirty flag is set or if the event is a nodes:remove (also ensure the node has a type property)
if ((node.dirty || eventName === 'nodes:remove') && node.type) {
// check if the node is a ui- node
let refresh = node.type.startsWith('ui-')
// if this is not a ui- node, it is perhaps a subflow?
if (!refresh && node.type.startsWith('subflow:')) {
// lets see if it has env vars linked to a `ui-` config or it has a DB2_SF_ORDER sort order key?
const subflowId = node.type.split(':')[1]
if (node.env && node.env.find(e => e.type?.startsWith('ui-') || node.env.find(e => e.key === 'DB2_SF_ORDER'))) {
refresh = true
}
if (!refresh) {
// check if the subflow definition contains any ui- nodes
const subflowChildren = RED.nodes.filterNodes({ z: subflowId })
if (subflowChildren.some(n => n.type.startsWith('ui-'))) {
refresh = true
}
}
}
if (refresh) {
if (RED._db2debug) { console.log(`dashboard 2: ${eventName} - this is a ui- node! queuing a call to refreshSidebarEditors`) }
// debounce the call to refreshSidebarEditors as multiple events can be fired in quick succession
refreshSidebarEditorDebounced()
}
}
}
RED.events.on('nodes:change', function (event) {
conditionalSidebarRefresh(event, 'nodes:change')
})
const addLayoutsDefaultsDebounced = debounce(addLayoutsDefaults, 25)
const checkDuplicateUiBasesDebounced = debounce(checkDuplicateUiBases, 25)
RED.events.on('nodes:add', function (node) {
if (RED._db2debug) { console.log('nodes:add', node) }
if (node.dirty && node.type && node.type.startsWith('ui-')) {
if (RED._db2debug) { console.log('nodes:add - this is a ui- node! queuing a call to refreshSidebarEditors') }
// debounce the call to refreshSidebarEditors as multiple events can be fired in quick succession
refreshSidebarEditorDebounced()
}
// if we're adding a ui-base
if (node.type.startsWith('ui-')) {
// action on all ui- elements to ensure we've remapped (now) missing ui-base nodes
checkDuplicateUiBasesDebounced()
addLayoutsDefaultsDebounced()
}
})
RED.events.on('nodes:remove', function (event) {
conditionalSidebarRefresh(event, 'nodes:remove')
})
/**
* Add group of actions to the right-side of a row in the sidebar editable list.
* @param {Object} parent - jQuery object to add this button group as a child element to
* @param {DashboardItem} item - The page/group/widget that these actions are bound to
*/
function addRowActions (parent, item, list) {
const configNodes = ['ui-base', 'ui-page', 'ui-link', 'ui-group', 'ui-theme']
const btnGroup = $('<div>', { class: 'nrdb2-sb-list-header-button-group', id: item.id }).appendTo(parent)
if (!configNodes.includes(item.type)) {
const focusButton = $('<a href="#" class="nr-db-sb-tab-focus-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-bullseye"></i> ' + c_('layout.focus') + '</a>').appendTo(btnGroup)
focusButton.on('click', function (evt) {
RED.view.reveal(item.id)
evt.stopPropagation()
evt.preventDefault()
})
}
const editButton = $('<a href="#" class="nr-db-sb-tab-edit-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-pencil"></i> ' + c_('layout.edit') + '</a>').appendTo(btnGroup)
editButton.on('click', function (evt) {
if (configNodes.includes(item.type)) {
RED.editor.editConfig('', item.type, item.id)
} else {
RED.editor.edit(item?.node)
}
evt.stopPropagation()
evt.preventDefault()
})
if (item.type === 'ui-page') {
// add the "+ group" button
$('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('layout.group') + '</a>')
.click(function (evt) {
list.editableList('addItem')
evt.preventDefault()
})
.appendTo(btnGroup)
}
}
/**
* Add group of actions to the right-side of a row in the sidebar editable list.
* @param {Object} parent - jQuery object to add this button group as a child element to
* @param {DashboardItem} dashboardItem - The page/group/widget that these actions are bound to
*/
function addRowStateOptions (parent, dashboardItem) {
const item = dashboardItem.node
const nodes = ['ui-page', 'ui-link', 'ui-group']
const titleRow = parent.closest('.nrdb2-sb-list-header')
const btnGroup = $('<div>', { class: 'nrdb2-sb-list-header-state-options', id: item.id }).appendTo(parent)
if (nodes.includes(item.type)) {
const visibleIcon = (item.visible === 'false' || item.visible === false) ? 'fa-eye-slash' : 'fa-eye'
const visibleBtn = $('<a href="#" title="Hide" class="nr-db-sb-tab-visible-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa ' + visibleIcon + '"></i></a>').appendTo(btnGroup)
visibleBtn.on('click', function (evt) {
const events = []
evt.stopPropagation()
evt.preventDefault()
if (item.visible === 'true' || item.visible === true || item.visible === undefined) {
// toggle to hidden
item.visible = false
titleRow.addClass('nrdb2-sb-item-hidden')
$(this).prop('title', 'Show')
$(this).find('i').addClass('fa-eye-slash').removeClass('fa-eye')
events.push({
t: 'edit',
node: item,
changes: {
visible: true
},
dirty: item.dirty,
changed: item.changed
})
} else {
// toggle to shown
item.visible = true
titleRow.removeClass('nrdb2-sb-item-hidden')
$(this).prop('title', 'Hide')
$(this).find('i').addClass('fa-eye').removeClass('fa-eye-slash')
events.push({
t: 'edit',
node: item,
changes: {
visible: false
},
dirty: item.dirty,
changed: item.changed
})
}
recordEvents(events)
})
const disabledIcon = (item.disabled === 'true' || item.disabled === true) ? 'fa-lock' : 'fa-unlock'
const disabledBtn = $('<a href="#" class="nr-db-sb-tab-visible-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa ' + disabledIcon + '"></i></a>').appendTo(btnGroup)
disabledBtn.on('click', function (evt) {
const events = []
evt.stopPropagation()
evt.preventDefault()
if (item.disabled === 'true' || item.disabled === true) {
// toggle to hidden
item.disabled = false
titleRow.removeClass('nrdb2-sb-item-disabled')
$(this).prop('title', 'Disable')
$(this).find('i').addClass('fa-unlock').removeClass('fa-lock')
events.push({
t: 'edit',
node: item,
changes: {
disabled: true
},
dirty: item.dirty,
changed: item.changed
})
} else {
// toggle to shown
item.disabled = true
titleRow.addClass('nrdb2-sb-item-disabled')
$(this).prop('title', 'Enable')
$(this).find('i').addClass('fa-lock').removeClass('fa-unlock')
events.push({
t: 'edit',
node: item,
changes: {
disabled: false
},
dirty: item.dirty,
changed: item.changed
})
}
recordEvents(events)
})
}
}
function setStateClasses (item, row) {
if (!item.node.visible === 'true' || item.node.visible === false) {
row.addClass('nrdb2-sb-item-hidden')
}
if (item.node.disabled === 'true' || item.node.disabled === true) {
row.addClass('nrdb2-sb-item-disabled')
}
}
/**
* Adds child list of groups for a given page
* @param {String} pageId - The id of the page that these groups belong to
* @param {Object} container - The jQuery object to append the groups list to
* @param {Object[]} groups - The list of groups to add to the list
* @param {DashboardItemLookup} widgetsByGroup - The lookup of widgets by group
*/
function addGroupOrderingList (pageId, container, groups, widgetsByGroup) {
// sort groups by order
groups.sort((a, b) => a.order - b.order)
// ordered list of groups to live within a container (e.g. page list item)
const groupsOL = $('<ol>', { class: 'nrdb2-sb-group-list' }).appendTo(container).editableList({
sortable: '.nrdb2-sb-groups-list-header',
addButton: false,
height: 'auto',
connectWith: '.nrdb2-sb-group-list',
addItem: function (container, i, group) {
if (!group || !group.id) {
// this is a new page that's been added and we need to setup the basics
group = addDefaultGroup(pageId)
RED.editor.editConfig('', group.type, group.id)
}
const widgets = widgetsByGroup[group.id] || []
// sort widgets by order
widgets.sort((a, b) => a.order - b.order)
const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-groups-list-header' }).appendTo(container)
$('<i class="nrdb2-sb-list-handle nrdb2-sb-group-list-handle fa fa-bars"></i>').appendTo(titleRow)
const chevron = $('<i class="fa fa-angle-down nrdb2-sb-list-chevron">', { style: 'width:10px;' }).appendTo(titleRow)
const groupicon = 'fa-table'
$('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-group-icon fa ' + groupicon }).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-title' }).text(group.name || group.id).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-info' }).text(`${widgets.length} Widgets`).appendTo(titleRow)
const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
addRowActions(actions, group)
addRowStateOptions(actions, group)
// adds widgets within this group
const widgetsList = $('<div>', { class: 'nrdb2-sb-widget-list-container' }).appendTo(container)
// add chevron/list toggle
titleRow.click(titleToggle(group.id, widgetsList, chevron))
addWidgetToList(group.id, widgetsList, widgets)
const events = []
updateItemOrder(groupsOL.editableList('items'), events)
// add our changes to NR history and trigger whether or not we need to redeploy
recordEvents(events)
},
sortItems: function (items) {
// track any changes
const events = []
// check if we have any new widgets added to this list
items.each((i, el) => {
const dbItem = el.data('data')
const widget = dbItem?.node || {}
if (widget.page !== pageId) {
const oldPageId = widget.page
widget.page = pageId
events.push({
t: 'edit',
node: widget,
changes: {
page: oldPageId
},
dirty: widget.changed,
changed: widget.dirty
})
}
})
updateItemOrder(items, events)
// add our changes to NR history and trigger whether or not we need to redeploy
recordEvents(events)
},
sort: function (a, b) {
return Number(a.order) - Number(b.order)
}
})
groups.forEach(function (group) {
if (RED._db2debug) { if (RED._db2debug) { console.log('dashboard 2: ui_base.html: addGroupOrderingList: adding group', group) } }
groupsOL.editableList('addItem', group)
})
return groupsOL
}
/**
* Adds list of widgets underneath a group
* @param {String} groupId - The id of the group that these widgets belong to
* @param {JQuery} container - The jQuery object to append the widgets list to
* @param {DashboardItem[]} widgets - The list of widgets to add to the list
*/
function addWidgetToList (groupId, container, widgets) {
// ordered list of groups to live within a container (e.g. page list item)
const widgetsOL = $('<ol>', { class: 'nrdb2-sb-widget-list' }).appendTo(container).editableList({
sortable: '.nrdb2-sb-widgets-list-header',
addButton: false,
height: 'auto',
connectWith: '.nrdb2-sb-widget-list',
addItem: function (container, i, /** @type {DashboardItem} */ widget) {
const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-widgets-list-header' }).appendTo(container)
$('<i class="nrdb2-sb-list-handle nrdb2-sb-widget-list-handle fa fa-bars"></i>').appendTo(titleRow)
// Set the icon
const ico = $('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-widget-icon' }).appendTo(titleRow)
let widgetIcon = RED.utils.getNodeIcon(widget.node?._def, widget.node) || 'fa-question'
if (widgetIcon.startsWith('font-awesome/')) {
widgetIcon = widgetIcon.replace(/^font-awesome\//, 'fa ')
}
if (widget.isSubflowInstance) {
// In this MVP, subflow instances are constrained to stay within own group.
// This is achieved using the class `red-ui-editableList-item-constrained`
container.parent().addClass('red-ui-editableList-item-constrained')
titleRow.attr('title', `${(widget.subflowName || '').trim()} (subflow instance)`.trim())
}
if (/.*\.(png|gif|jpg|jpeg|svg)$/.test(widgetIcon)) {
ico.css('mask-image', `url("${widgetIcon}")`)
ico.addClass('nrdb2-sb-widget-icon-img')
} else if (widgetIcon.startsWith('fa fa-')) {
ico.addClass(widgetIcon)
}
$('<span>', { class: 'nrdb2-sb-title' }).text(widget.label?.trim() || widget.id).appendTo(titleRow)
const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
addRowActions(actions, widget)
const events = []
updateItemOrder(widgetsOL.editableList('items'), events)
// add our changes to NR history and trigger whether or not we need to redeploy
recordEvents(events)
},
sortItems: function (items) {
// track any changes
const events = []
// check if we have any new widgets added to this list
items.each((i, el) => {
const dbItem = el.data('data')
const widget = dbItem?.node || {}
if (widget.group !== groupId) {
const oldGroupId = widget.group
widget.group = groupId
events.push({
t: 'edit',
node: widget,
changes: {
group: oldGroupId
},
dirty: widget.dirty,
changed: widget.changed
})
}
})
updateItemOrder(items, events)
// add our changes to NR history and trigger whether or not we need to redeploy
recordEvents(events)
},
sort: function (a, b) {
return Number(a.order) - Number(b.order)
}
})
widgets.forEach(function (w) {
widgetsOL.editableList('addItem', w)
})
}
// expand / collapse buttons
let layoutDisplayLevel = 2 // all open by default
const getGroupsInLayout = function () {
const content = $('.nrdb2-layout-order-editor > .red-ui-editableList .nrdb2-sb-widget-list-container')
return {
content,
chevrons: content.parent().find('div.nrdb2-sb-list-header > .nrdb2-sb-list-chevron')
}
}
const getPagesInLayout = function () {
const content = $('.nrdb2-layout-order-editor > .red-ui-editableList .nrdb2-sb-group-list-container')
return {
content,
chevrons: content.parent().find('div.nrdb2-sb-pages-list-header > .nrdb2-sb-list-chevron')
}
}
const collapseLayoutItems = function ({ chevrons, content }) {
chevrons.css({ transform: 'rotate(-90deg)' })
content.slideUp()
content.addClass('nr-db-sb-collapsed')
}
const expandLayoutItems = function ({ chevrons, content }) {
chevrons.css({ transform: '' })
content.slideDown()
content.removeClass('nr-db-sb-collapsed')
}
/**
* Update the visibility of the layout editor expandable lists
* @param {0|1|2} level - 0 = collapse all, 1 = expand pages (groups collapsed), 2 = expand pages and groups (to expose widgets)
*/
const updateLayoutVisibility = function (level) {
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: updateLayoutVisibility(level)', level) }
if (level === 2) {
expandLayoutItems(getGroupsInLayout())
expandLayoutItems(getPagesInLayout())
} else if (level === 1) {
expandLayoutItems(getPagesInLayout())
collapseLayoutItems(getGroupsInLayout())
} else {
collapseLayoutItems(getGroupsInLayout())
collapseLayoutItems(getPagesInLayout())
}
}
/**
* Sidebar Functions
*/
function buildLayoutOrderEditor (parent) {
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: buildLayoutOrderEditor (parent)', parent) }
// layout/order editor
const divTabs = $('.nrdb2-layout-order-editor').length ? $('.nrdb2-layout-order-editor') : $('<div>', { class: 'nrdb2-layout-order-editor nrdb2-sidebar-tab-content' }).appendTo(parent)
// section header - Pages
const pagesHeader = $('<div>', { class: 'nrdb2-sidebar-header' }).appendTo(divTabs)
$('<b>').html(c_('layout.pages')).appendTo(pagesHeader)
// toggle "all" buttons
const buttonGroup = $('<div>', { class: 'nrdb2-sb-list-button-group' }).appendTo(pagesHeader)
const buttonCollapse = $('<a href="#" class="editor-button editor-button-small nrdb2-sb-list-header-button"><i class="fa fa-angle-double-up"></i></a>')
.click(function (evt) {
evt.preventDefault()
if (--layoutDisplayLevel < 0) { layoutDisplayLevel = 0 }
updateLayoutVisibility(layoutDisplayLevel)
})
.appendTo(buttonGroup)
RED.popover.tooltip(buttonCollapse, c_('layout.collapse'))
// expand button
const buttonExpand = $('<a href="#" class="editor-button editor-button-small nrdb2-sb-list-header-button"><i class="fa fa-angle-double-down"></i></a>')
.click(function (evt) {
if (++layoutDisplayLevel > 2) { layoutDisplayLevel = 2 }
updateLayoutVisibility(layoutDisplayLevel)
}).appendTo(buttonGroup)
.appendTo(buttonGroup)
RED.popover.tooltip(buttonExpand, c_('layout.expand'))
// add link button
$('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('layout.link') + '</a>')
.click(function (evt) {
pagesOL.editableList('addItem', { type: 'ui-link' })
evt.preventDefault()
})
.appendTo(buttonGroup)
// add page button
$('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('layout.page') + '</a>')
.click(function (evt) {
pagesOL.editableList('addItem')
evt.preventDefault()
})
.appendTo(buttonGroup)
divTabs.append('<div class="nrdb2-layout-helptext">' + c_('label.layoutMessage') + '</div>')
/** @type {DashboardItemLookup} */
const pages = {}
const links = {}
/** @type {DashboardItemLookup} */
const groupsByPage = {}
const unattachedGroups = []
/** @type {DashboardItemLookup} */
const widgetsByGroup = {}
const subflowDefinitions = new Map()
// get all pages & all groups
RED.nodes.eachConfig(function (n) {
if (n.type === 'ui-page' && !!n.ui) {
pages[n.id] = toDashboardItem(n)
} else if (n.type === 'ui-link') {
links[n.id] = toDashboardItem(n)
} else if (n.type === 'ui-group') {
const p = n.page
if (!p) {
unattachedGroups.push(toDashboardItem(n))
} else {
if (!groupsByPage[p]) {
groupsByPage[p] = []
}
groupsByPage[p].push(toDashboardItem(n))
}
}
})
// get all widgets
const uiNodesWithGroupProp = []
RED.nodes.eachNode(function (n) {
if (/^ui-/.test(n.type) && n.group) {
uiNodesWithGroupProp.push(n)
if (!widgetsByGroup[n.group]) {
widgetsByGroup[n.group] = []
}
widgetsByGroup[n.group].push(toDashboardItem(n))
if (n.z) {
const subflowDef = RED.nodes.subflow(n.z)
if (subflowDef) {
subflowDefinitions.set('subflow:' + subflowDef.id, subflowDef)
}
}
}
})
// update `widgetsByGroup` for subflow instances where its env has an entry which
// is a ui-group
RED.nodes.eachNode(function (n) {
if (subflowDefinitions.has(n.type)) {
const subflowDef = subflowDefinitions.get(n.type)
const subflowInstance = n
if (subflowDef && subflowDef.env && subflowDef.env.length > 0) {
/** @type {{name:String, type:String, value:String}[]} */
const envDefsWithUIGroup = subflowDef.env.filter(env => env.type === 'ui-group' && env.ui?.type === 'conf-types')
for (const envDef of envDefsWithUIGroup) {
const groupNameAsEnv = '${' + envDef.name + '}'
if (widgetsByGroup[groupNameAsEnv]) {
// at this point, we know that the widgets in widgetsByGroup[groupNameAsEnv] belong to the group defined by the env var of the subflow instance
// get the actual group id from the subflow instance env vars where the name matches the envDef.name
if (subflowInstance.env) {
const groupEnvVar = subflowInstance.env.find(env => env.name === envDef.name)
if (groupEnvVar) {
const groupId = groupEnvVar.value
if (!widgetsByGroup[groupId]) {
widgetsByGroup[groupId] = []
}
const sfItem = toDashboardItem(n)
sfItem.isSubflowInstance = true
sfItem.subflowName = RED.utils.getNodeLabel(subflowDef)
if (!sfItem.label?.trim() || sfItem.label === sfItem.id) {
// label was defaulted to id, so we should use the label from the subflowDef
sfItem.label = sfItem.subflowName
}
widgetsByGroup[groupId].push(sfItem)
}
}
}
}
}
}
})
const pagesOL = $('<ol>', { class: 'nrdb2-sb-pages-list' }).appendTo(divTabs).editableList({
sortable: '.nrdb2-sb-pages-list-header',
addButton: false,
addItem: function (container, i, item) {
if (item && item.type === 'ui-link') {
// want to create a new link
if (!item || !item.id) {
// create a default link
item = addDefaultLink()
RED.editor.editConfig('', item.type, item.id)
}
// add it to the list of pages/links
container.addClass('nrdb2-sb-pages-list-item')
const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-pages-list-header' }).appendTo(container)
setStateClasses(item, titleRow)
// build title row
$('<i class="nrdb2-sb-list-handle nrdb2-sb-page-list-handle fa fa-bars"></i>').appendTo(titleRow)
const linkIcon = 'fa-link'
$('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + linkIcon }).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-title' }).text(item.name || item.id).appendTo(titleRow)
// link - actions
const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
// add "Edit" and "Focus" buttons
addRowActions(actions, item)
// Add visibility/disabled options
addRowStateOptions(actions, item)
} else {
// is a page, with groups and widgets inside
if (!item || !item.id) {
// this is a new page that's been added and we need to setup the basics
item = addDefaultPage()
RED.editor.editConfig('', item.type, item.id)
}
const groups = groupsByPage[item.id] || []
container.addClass('nrdb2-sb-pages-list-item')
const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-pages-list-header' }).appendTo(container)
setStateClasses(item, titleRow)
const groupsList = $('<div>', { class: 'nrdb2-sb-group-list-container' }).appendTo(container)
// build title row
$('<i class="nrdb2-sb-list-handle nrdb2-sb-page-list-handle fa fa-bars"></i>').appendTo(titleRow)
const chevron = $('<i class="fa fa-angle-down nrdb2-sb-list-chevron">', { style: 'width:10px;' }).appendTo(titleRow)
const tabicon = 'fa-object-group'
$('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + tabicon }).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-title' }).text(item.name || item.id).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-info' }).text(`${groups.length} Groups`).appendTo(titleRow)
// adds groups within this page
titleRow.click(titleToggle(item.id, groupsList, chevron))
const groupsOL = addGroupOrderingList(item.id, groupsList, groups, widgetsByGroup)
// page - actions
const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
addRowActions(actions, item, groupsOL)
addRowStateOptions(actions, item)
}
// ensure all pages/links have the correct order value
const events = []
updateItemOrder(pagesOL.editableList('items'), events)
// add our changes to NR history and trigger whether or not we need to redeploy
recordEvents(events)
},
sortItems: function (items) {
// runs when an item is explicitly moved in the order
// track any changes
const events = []
updateItemOrder(items, events)
// add our changes to NR history and trigger whether or not we need to redeploy
recordEvents(events)
},
sort: function (a, b) {
return Number(a.order) - Number(b.order)
}
})
const items = {
...pages,
...links
}
Object.values(items).sort((a, b) => a.order - b.order).forEach(function (item) {
let groups = []
if (item.type === 'ui-page' && item.id) {
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: buildLayoutOrderEditor: adding groups', groups) }
groups = groupsByPage[item.id] || []
}
if (item) {
pagesOL.editableList('addItem', item)
}
})
// add Unattached Groups to the bottom
if (unattachedGroups.length > 0) {
const unattachedGroupsSection = $('<div>', { class: 'nrdb2-sidebar-header', style: 'padding-top: 12px;' }).appendTo(divTabs)
$('<b>').html(c_('layout.unattachedGroups')).appendTo(unattachedGroupsSection)
divTabs.append('<div class="nrdb2-layout-helptext">' + c_('label.unattachedMessage') + '</div>')
// we have some groups bound to a page that no longer exists
const unattachedGroupsOL = $('<ol>', { class: 'nrdb2-sb-unattached-groups-list' }).appendTo(divTabs).editableList({
sortable: '.nrdb2-sb-unattached-groups-list-header',
addButton: false,
addItem: function (container, i, group) {
if (!group || !group.id) {
// this is a new group that's been added and we need to setup the basics
group = addDefaultGroup()
RED.editor.editConfig('', group.type, group.id)
}
container.addClass('nrdb2-sb-unattached-groups-list-item')
const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-unattached-groups-list-header' }).appendTo(container)
const tabicon = 'fa-table'
$('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + tabicon }).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-title' }).text(group.name || group.id).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-info' }).text('No Page Assigned').appendTo(titleRow)
// group - actions
const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
addRowActions(actions, group)
}
})
unattachedGroups.forEach(function (group) {
unattachedGroupsOL.editableList('addItem', group)
})
}
// call updateLayoutVisibility to sync display level
updateLayoutVisibility(layoutDisplayLevel)
}
function buildThemesEditor (parent) {
// layout/order editor
const divTabs = $('.nrdb2-themes-editor').length ? $('.nrdb2-themes-editor') : $('<div>', { class: 'nrdb2-themes-editor nrdb2-sidebar-tab-content' }).appendTo(parent)
// section header - Pages
const themeHeader = $('<div>', { class: 'nrdb2-sidebar-header' }).appendTo(divTabs)
$('<b>').html(c_('themes.header')).appendTo(themeHeader)
// button group
const buttonGroup = $('<div>', { class: 'nrdb2-sb-list-button-group' }).appendTo(themeHeader)
// add theme button
$('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('themes.theme') + '</a>')
.click(function (evt) {
themesOL.editableList('addItem')
evt.preventDefault()
})
.appendTo(buttonGroup)
divTabs.append('<div class="nrdb2-layout-helptext">' + c_('label.themingMessage') + '</div>')
const themes = {}
// get all themes
RED.nodes.eachConfig(function (n) {
if (n.type === 'ui-theme') {
themes[n.id] = n
}
})
const themesOL = $('<ol>', { class: 'nrdb2-sb-pages-list' }).appendTo(divTabs).editableList({
sortable: '.nrdb2-sb-pages-list-header',
addButton: false,
addItem: function (container, i, theme) {
if (!theme || !theme.id) {
// this is a new theme that's been added and we need to setup the basics
theme = addDefaultTheme()
RED.editor.editConfig('', theme.type, theme.id)
}
container.addClass('nrdb2-sb-pages-list-item')
const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-themes-list-header' }).appendTo(container)
const tabicon = 'fa-paint-brush'
$('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + tabicon }).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-title' }).text(theme.name || theme.id).appendTo(titleRow)
$('<span>', { class: 'nrdb2-sb-info' }).text(theme.users.length + ' ' + (theme.users.length > 1 ? c_('label.pages') : c_('label.page'))).appendTo(titleRow)
const palette = $('<div>', { class: 'nrdb2-sb-palette' }).appendTo(titleRow)
const colors = theme.colors
palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.surface}` }))
palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.primary}` }))
palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.bgPage}` }))
palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.groupBg}` }))
palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.groupOutline}` }))
// theme - actions
const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
addRowActions(actions, theme)
}
})
Object.values(themes).forEach(function (theme) {
themesOL.editableList('addItem', theme)
})
}
/**
* Builds the client constraints editor
* @param {Object} base - The ui-base node we are representing
* @param {Object} parent - The parent element to append the client constraints editor to
*/
function buildClientConstraintsEditor (base, parent) {
const html = `<div class="nrdb2-sidebar-tab-content">
<div style="margin-bottom: 9px;border-bottom: 1px solid #ddd;">
<p>
This tab allows you to control whether or not client-specific data is included in messages,
and which nodes accept it in order to constrain communication to specific clients.
You can read more about it <a href="https://dashboard.flowfuse.com/user/sidebar.html#client-data" target="_blank">here</a>
</p>
</div>
<div class="form-row form-row-flex">
<input type="checkbox" id="node-config-input-includeClientData">
<label style="width:auto" for="node-config-input-includeClientData"><i class="fa fa-id-card-o" style="margin-right:4px;"></i>Include Client Data</label>
</div>
<p class="nrdb2-helptext" style="margin-top: -12px;">
<i class="fa fa-info-circle"></i> This option includes client data in all messages transmitted
by Dashboard 2.0 (e.g. Socket ID and information about any logged in user)
</p>
<div id="nrdb2-sb-client-data-providers">
<label>Client Data Providers:</label>
<ul id="nrdb2-sb-client-data-providers-list"></ul>
</div>
<div id="node-config-client-constraints-nodes-container" class="form-row">
<label style="display: block; width: auto;"><i class="fa fa-id-card-o" style="margin-right:4px;"></i>Accept Client Data</label>
<p class="nrdb2-helptext" style="margin-top: -4px;">
<i class="fa fa-info-circle"></i> Defines whether the respective node type will use client data
(e.g. socketid), and constrain communications to just that client.
</p>
<div id="node-config-client-constraints-nodes"></div>
</div></div>`
$(html).appendTo(parent)
if (base.includeClientData === undefined) {
// older ui-base nodes may not have this property
base.includeClientData = true
const events = [{
t: 'edit',
node: base,
changes: {
includeClientData: undefined
},
dirty: true,
changed: true
}]
recordEvents(events)
}
// set initial state
$('#node-config-input-includeClientData')
.prop('checked', base.includeClientData)
// add event handler to our checkbox for including client data
$('#node-config-input-includeClientData')
.on('change', function (event) {
const value = event.target.checked
base.includeClientData = value
// store hte previous value in history
const events = [{
t: 'edit',
node: base,
changes: {
includeClientData: !value
},
dirty: true,
changed: true
}]
recordEvents(events)
})
// add core as a data provider as it provides socketId
const coreLi = $('<li>').appendTo('#nrdb2-sb-client-data-providers-list')
$('<i class="fa fa-bar-chart"></i>').appendTo(coreLi)
$('<label></label>').text('Socket ID').appendTo(coreLi)
$('<span class="nrdb2-sb-client-data-provider-package"></span>').text('@flowfuse/node-red-dashboard').appendTo(coreLi)
// detail any auth providers we have
// check for any third-party tab definitions
RED.plugins.getPluginsByType('node-red-dashboard-2').forEach(plugin => {
if (plugin.auth) {
const li = $('<li>').appendTo('#nrdb2-sb-client-data-providers-list')
$('<i class="fa fa-plug"></i>').appendTo(li)
$('<label></label>').text(plugin.description).appendTo(li)
$('<span class="nrdb2-sb-client-data-provider-package"></span>').text(plugin.module).appendTo(li)
$('<li>').text(plugin.description).appendTo('#node-config-client-data-providers-list')
}
})
// build list of node types
const widgets = RED.nodes.registry.getNodeTypes().filter((nodeType) => {
const type = RED.nodes.getType(nodeType)
return /^ui-/.test(nodeType) && type.category !== 'config' && type.inputs > 0
})
if (base.acceptsClientConfig === undefined) {
// older ui-base nodes may not have this property
base.acceptsClientConfig = ['ui-control', 'ui-notification']
const events = [{
t: 'edit',
node: base,
changes: {
acceptsClientConfig: undefined
},
dirty: true,
changed: true
}]
recordEvents(events)
}
$('#node-config-client-constraints-nodes')
.treeList({
data: widgets.map((w) => {
const isSelected = base.acceptsClientConfig?.includes(w)
return {
label: w,
checkbox: true,
selected: isSelected
}
})
})
.on('treelistselect', function (event, node) {
const events = [{
t: 'edit',
node: base,
changes: {
acceptsClientConfig: [...base.acceptsClientConfig]
},
dirty: true,
changed: true
}]
let changed = false
if (!node.selected && base.acceptsClientConfig?.includes(node.label)) {
// remove it from the list
base.acceptsClientConfig = base.acceptsClientConfig.filter((w) => w !== node.label)
changed = true
} else if (node.selected && !base.acceptsClientConfig?.includes(node.label)) {
// add it to the list
base.acceptsClientConfig.push(node.label)
changed = true
}
if (changed) {
recordEvents(events)
}
})
}
function refreshSidebarEditors () {
try {
refreshBusy = true
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: refreshSidebarEditors ()') }
const layoutOrderDiv = $('.nrdb2-layout-order-editor')
const themesDiv = $('.nrdb2-themes-editor')
// empty the list if any items exist
if (layoutOrderDiv.length) {
layoutOrderDiv.empty()
}
if (themesDiv.length) {
themesDiv.empty()
}
// now rebuild any sidebars that are dependent upon other nodes
buildLayoutOrderEditor()
buildThemesEditor()
// finally, restore previous state of expanded/collapsed items
// TODO: expand/collapse any items that were expanded before
// for now, we will just re-sync the display level
updateLayoutVisibility(layoutDisplayLevel)
} finally {
refreshBusy = false
}
}
function addSidebarTab (label, id, tabs, sidebar) {
$(`<li id="dashboard-2-tab-${id}" class="red-ui-tab" style="min-width: 90px; width: fit-content"><a class="red-ui-tab-label" style="padding-inline: 12px" title="Layout"><span>${label}</span></a></li>`).appendTo(tabs)
// Add in Tab Content
const content = $(`<div id="dashboard-2-${id}" class="red-ui-tab-content" style="height: calc(100% - 72px);"></div>`).appendTo(sidebar)
return content
}
function addSidebar () {
if (RED._db2debug) { console.log('dashboard 2: ui_base.html: addSidebar ()') }
RED.sidebar.addTab({
id: 'dashboard-2.0',
label: c_('label.dashboard2'),
name: c_('label.dashboard2'),
content: sidebar,
closeable: true,
pinned: true,
disableOnEdit: true,
iconClass: 'fa fa-bar-chart',
action: '@flowfuse/node-red-dashboard:show-dashboard-2.0-tab',
onchange: () => {
sidebar.empty()
// UI Base Header
RED.nodes.eachConfig(function (n) {
if (n.type === 'ui-base') {
const base = n
sidebar.append(dashboardLink(base.id, base.name, base.path))
const divTab = $('<div class="red-ui-tabs">').appendTo(sidebar)
const ulDashboardTabs = $('<ul id="dashboard-tabs-list"></ul>').appendTo(divTab)
// Add in Tabs
// Tab - Pages
const pagesContent = addSidebarTab(c_('label.layout'), 'pages', ulDashboardTabs, sidebar)
const themesContent = addSidebarTab(c_('label.theming'), 'themes', ulDashboardTabs, sidebar)
const cConstraintsContent = addSidebarTab(c_('label.constraints'), 'client-constraints', ulDashboardTabs, sidebar)
// check for any third-party tab definitions
RED.plugins.getPluginsByType('node-red-dashboard-2').forEach(plugin => {
if (plugin.tabs) {
plugin.tabs.forEach(tab => {
// add tab to sidebar
const container = addSidebarTab(tab.label, tab.id, ulDashboardTabs, sidebar)
container.hide()
tab.init(base, container)
})
}
})
// on tab click, show the tab content, and hide the others
ulDashboardTabs.children().on('click', function (evt) {
const tab = $(this)
const tabContent = $('#' + tab.attr('id').replace('-tab', ''))
ulDashboardTabs.children().removeClass('active')
tab.addClass('active')
$('.red-ui-tab-content').hide()
tabContent.show()
evt.preventDefault()
})
// default to first tab
ulDashboardTabs.children().first().trigger('click')
// add page/layout editor
buildLayoutOrderEditor(pagesContent)
// add Themes View
buildThemesEditor(themesContent)
// add Themes View
buildClientConstraintsEditor(base, cConstraintsContent)
}
})
}
})
RED.actions.add('@flowfuse/node-red-dashboard:show-dashboard-2.0-tab', function () {
RED.sidebar.show('flowfuse-nr-tools')
})
}
/**
* jQuery widget to provide a selector for the sizing (width & height) of a widget & group
*/
$.widget('nodereddashboard.elementSizer', {
_create: function () {
// convert to i18 text
function c_ (x) {
return RED._(`@flowfuse/node-red-dashboard/ui-base:ui-base.${x}`)
}
const thisWidget = this
let gridWidth = 6
const width = parseInt($(this.options.width).val() || 0)
const height = parseInt(hasProperty(this.options, 'height') ? $(this.options.height).val() : '1') || 0
const hasAuto = (!hasProperty(this.options, 'auto') || this.options.auto)
this.element.css({
minWidth: this.element.height() + 4
})
const autoText = c_('auto')
const sizeLabel = (width === 0 && height === 0) ? autoText : width + (hasProperty(this.options, 'height') ? ' x ' + height : '')
this.element.text(sizeLabel).on('mousedown', function (evt) {
evt.stopPropagation()
evt.preventDefault()
const width = parseInt($(thisWidget.options.width).val() || 0)
const height = parseInt(hasProperty(thisWidget.options, 'height') ? $(thisWidget.options.height).val() : '1') || 0
let maxWidth = 0
let maxHeight
let fixedWidth = false
const fixedHeight = false
const group = $(thisWidget.options.group).val()
if (group) {
const groupNode = RED.nodes.node(group)
if (groupNode) {
gridWidth = Math.max(6, groupNode.width, +width)
maxWidth = groupNode.width || gridWidth
fixedWidth = true
}
maxHeight = Math.max(6, +height + 1)
} else {
gridWidth = Math.max(12, +width)
maxWidth = gridWidth
maxHeight = height + 1
// fixedHeight = false;
}
const pos = $(this).offset()
const container = $('<div>').css({
position: 'absolute',
background: 'var(--red-ui-secondary-background, white)',
padding: '5px 10px 10px 10px',
border: '1px solid var(--red-ui-primary-border-color, grey)',
zIndex: '20',
borderRadius: '4px',
display: 'none'
}).appendTo(document.body)
let closeTimer
container.on('mouseleave', function (evt) {
closeTimer = setTimeout(function () {
container.fadeOut(200, function () { $(this).remove() })
}, 100)
})
container.on('mouseenter', function () {
clearTimeout(closeTimer)
})
const label = $('<div>').css({
fontSize: '13px',
color: 'var(--red-ui-tertiary-text-color, #aaa)',
float: 'left',
paddingTop: '1px'
}).appendTo(container).text((width === 0 && height === 0) ? autoText : (width + (hasProperty(thisWidget.options, 'height') ? ' x ' + height : '')))
label.hover(function () {
$(this).css('text-decoration', 'underline')
}, function () {
$(this).css('text-decoration', 'none')
})
label.click(function (e) {
const group = $(thisWidget.options.group).val()
let groupNode = null
if (group) {
groupNode = RED.nodes.node(group)
if (groupNode === null) {
return
}
}
$(thisWidget).elementSizerByNum({
width: thisWidget.options.width,
height: thisWidget.options.height,
groupNode,
pos,
label: thisWidget.element,
has_height: hasProperty(thisWidget.options, 'height')
})
closeTimer = setTimeout(function () {
container.fadeOut(200, function () {
$(this).remove()
})
}, 100)
})
const buttonRow = $('<div>', { style: 'text-align:right; height:25px;' }).appendTo(container)
if (hasAuto) {
$('<a>', { href: '#', class: 'editor-button editor-button-small', style: 'margin-bottom:5px' })
.text(autoText)
.appendTo(buttonRow)
.on('mouseup', function (evt) {
thisWidget.element.text(autoText)
$(thisWidget.options.width).val(0).change()
$(thisWidget.options.height).val(0).change()
evt.preventDefault()
container.fadeOut(200, function () { $(this).remove() })
})
}
const cellBorder = '1px dashed var(--red-ui-secondary-border-color, lightGray)'
const cellBorderExisting = '1px solid gray'
const cellBorderHighlight = '1px dashed var(--red-ui-primary-border-color, black)'
const rows = []
const cells = []
function addRow (i) {
const row = $('<div>').css({ padding: 0, margin: 0, height: '25px', 'box-sizing': 'border-box' }).appendTo(container)
rows.push(row)
cells.push([])
for (let j = 0; j < gridWidth; j++) {
addCell(i, j)
}
}
function addCell (i, j) {
const row = rows[i]
const cell = $('<div>').css({
display: 'inline-block',
width: '25px',
height: '25px',
borderRight: (j === (width - 1) && i < height) ? cellBorderExisting : cellBorder,
borderBottom: (i === (height - 1) && j < width) ? cellBorderExisting : cellBorder,
boxSizing: 'border-box',
cursor: 'pointer',
background: (j < maxWidth) ? 'var(--red-ui-secondary-background, #fff)' : 'var(--red-ui-node-background-placeholder, #eee)'
}).appendTo(row)
cells[i].push(cell)
if (j === 0) {
cell.css({ borderLeft: ((i <= height - 1) ? cellBorderExisting : cellBorder) })
}
if (i === 0) {
cell.css({ borderTop: ((j <= width - 1) ? cellBorderExisting : cellBorder) })
}
if (j < maxWidth) {
cell.data('w', j)
cell.data('h', i)
cell.on('mouseup', function () {
thisWidget.element.text(($(this).data('w') + 1) + (hasProperty(thisWidget.options, 'height') ? ' x ' + ($(this).data('h') + 1) : ''))
$(thisWidget.options.width).val($(this).data('w') + 1).change()
$(thisWidget.options.height).val($(this).data('h') + 1).change()
container.fadeOut(200, function () { $(this).remove() })
})
cell.on('mouseover', function () {
const w = $(this).data('w')
const h = $(this).data('h')
label.text((w + 1) + (hasProperty(thisWidget.options, 'height') ? ' x ' + (h + 1) : ''))
for (let y = 0; y < maxHeight; y++) {
for (let x = 0; x < maxWidth; x++) {
cells[y][x].css({
background: (y <= h && x <= w) ? 'var(--red-ui-secondary-background-selected, #ddd)' : 'var(--red-ui-secondary-background, #fff)',
borderLeft: (x === 0 && y <= h) ? cellBorderHighlight : (x === 0) ? ((y <= height - 1) ? cellBorderExisting : cellBorder) : '',
borderTop: (y === 0 && x <= w) ? cellBorderHighlight : (y === 0) ? ((x <= width - 1) ? cellBorderExisting : cellBorder) : '',
borderRight: (x === w && y <= h) ? cellBorderHighlight : ((x === width - 1 && y <= height - 1) ? cellBorderExisting : cellBorder),
borderBottom: (y === h && x <= w) ? cellBorderHighlight : ((y === height - 1 && x <= width - 1) ? cellBorderExisting : cellBorder)
})
}
}
if (!fixedHeight && h === maxHeight - 1) {
addRow(maxHeight++)
}
if (!fixedWidth && w === maxWidth - 1) {
maxWidth++
gridWidth++
for (let r = 0; r < maxHeight; r++) {
addCell(r, maxWidth - 1)
}
}
})
}
}
for (let i = 0; i < maxHeight; i++) {
addRow(i)
}
container.css({
top: (pos.top) + 'px',
left: (pos.left) + 'px'
})
container.fadeIn(200)
})
}
})
})()
</script>
<script type="text/html" data-template-name="ui-base">
<div class="form-row">
<label for="node-config-input-name"><i class="fa fa-tag"></i> <span data-i18n="node-red:common.label.name"></label>
<input type="text" id="node-config-input-name" data-i18n="[placeholder]node-red:common.label.name">
</div>
<div class="form-row">
<label for="node-config-input-path"><i class="fa fa-bookmark"></i> <span data-i18n="ui-base.label.path"></label>
<input type="text" id="node-config-input-path" disabled>
<span style="display: block; margin-left: 105px; margin-top: 0px; font-style: italic; color: #bbb; font-size: 8pt;">This option is currently disabled and still in-development.</span>
</div>
<div class="form-row" style="margin-bottom: 0;">
<label style="font-weight: 600; width: auto;" data-i18n="ui-base.label.header"></label>
</div>
<div class="form-row" style="align-items: center;">
<label style="margin-right: 5px; margin-bottom: 0px;" for="node-config-input-titleBarStyle"><span data-i18n="ui-base.label.titleBarStyle"></span></label>
<select id="node-config-input-titleBarStyle">
<option value="default" data-i18n="ui-base.label.titleBarStyleDefault"></option>
<option value="hidden" data-i18n="ui-base.label.titleBarStyleHidden"></option>
<option value="fixed" data-i18n="ui-base.label.titleBarStyleFixed"></option>
</select>
</div>
<div class="form-row form-row-flex" style="align-items: center;">
<input style="margin: 8px 0 10px 16px; width:20px;" type="checkbox" id="node-config-input-showPageTitle">
<label style="width:auto" for="node-config-input-showPageTitle"><span data-i18n="ui-base.label.showPageTitle"></span></label>
</div>
<div class="form-row" style="margin-bottom: 0;">
<label style="font-weight: 600; width: auto;" data-i18n="ui-base.label.sidebar"></label>
</div>
<div class="form-row" style="align-items: center;">
<label style="margin-right: 5px; margin-bottom: 0px;" for="node-config-input-navigationStyle"><span data-i18n="ui-base.label.navigationStyle"></span></label>
<select id="node-config-input-navigationStyle">
<option value="default" data-i18n="ui-base.label.navigationStyleDefault"></option>
<option value="fixed" data-i18n="ui-base.label.navigationStyleFixed"></option>
<option value="icon" data-i18n="ui-base.label.navigationStyleIcon"></option>
<option value="temporary" data-i18n="ui-base.label.navigationStyleTemporary"></option>
<option value="none" data-i18n="ui-base.label.navigationStyleNone"></option>
</select>
</div>
<div class="form-row form-row-flex" style="align-items: center;">
<input style="margin: 8px 0 10px 16px; width:20px;" type="checkbox" id="node-config-input-showPathInSidebar">
<label style="width:auto" for="node-config-input-showPathInSidebar"><span data-i18n="ui-base.label.showPath"></span></label>
</div>
</script>