<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>