UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.

517 lines (486 loc) 26.2 kB
/** Common functions and data for UIBUILDER nodes * Load as: ./resources/node-red-contrib-uibuilder/editor-common.js * Note that RED is available here */ /** @typedef {object} UibEditorObject * @property {string} paletteCategory - Standard palette category for all uibuilder nodes * @property {string} typedInputWidth - Standard width for typed input fields * @property {boolean} localHost - Are we running on a local device? * @property {string} nrServer - Server address of the Node-RED server * @property {string} nodeRoot - URL root if needed (set to '' if using a custom uib server) * @property {string} urlPrefix - URL prefix for all uib nodes * @property {string} serverType - uib server type ('Node-RED\'s' or 'a custom') * @property {Object<string, string>} editorUibInstances - Tracks ALL uibuilder editor instance URL's by node id (includes undeployed and disabled nodes) * @property {Object<string, string>} deployedUibInstances - Tracks all DEPLOYED uibuilder instances url's by node id * @property {Array} packages - Tracks uibuilder's installed front-end packages * @property {string[]} uibNodeTypes - List of uib node names * @property {boolean} debug - Debug output via log() - turn on/off with true/false * @property {Function} log - Console log function (only active when debug is true) * @property {Function} doTooltips - Add jQuery UI formatted tooltips * @property {Function} getDeployedUrls - Get all of the currently deployed uibuilder URL's * @property {Function} sortInstances - Sort an instances object by url */ // Register the plugin RED.plugins.registerPlugin('uib-editor-plugin', { type: 'uibuilder-editor-plugin', // optional plugin type onadd: function() { let _dbg = false /** Add a "uibuilder" object to the Node-RED Editor * To contain common functions, variables and constants for UIBUILDER nodes * @type {UibEditorObject} */ const uibuilder = window['uibuilder'] = /** @type {UibEditorObject} */ ({ // Standard palette category for all uibuilder nodes paletteCategory: 'uibuilder', // Common node color for all uibuilder nodes - set in each node to avoid v4.1.7 custom var bug paletteColor: 'hsl(248 100% 91%)', // node-red v4.1.7 broke using custom var from plugin // Standard width for typed input fields typedInputWidth: '68.5%', // Are we running on a local device? localHost: ['localhost', '127.0.0.1', '::1', ''].includes(window.location.hostname) || window.location.hostname.endsWith('.localhost'), // Server address of the Node-RED server nrServer: window.location.hostname, // URL root if needed (set below to '' if using a custom uib server) nodeRoot: RED.settings.httpNodeRoot.replace(/^\//, ''), // URL prefix for all uib nodes - set below urlPrefix: undefined, // uib server type serverType: undefined, /** Tracks ALL uibuilder editor instance URL's by node id by tracking changes to the Node-RED Editor - ONLY USE FOR URL TRACKING * These URL's may not actually be deployed. They also include disabled nodes (node.d=true) AND disabled flows. * NOTE: Nodes on disabled flows are not directly detectable and node.d will not be set. * @type {{string,string}|{}} */ editorUibInstances: {}, /** Tracks all DEPLOYED uibuilder instances url's by node id @type {{string,string}|{}} */ deployedUibInstances: {}, /** Tracks uibuilder's installed front-end packages - changes as packages added/removed (in uibuilder node) */ packages: [], /** List of uib node names */ uibNodeTypes: [ 'uibuilder', 'markweb', 'uib-cache', 'uib-element', 'uib-file-list', 'uib-html', 'uib-save', 'uib-sender', 'uib-sidebar', 'uib-tag', 'uib-update' ], // Debug output via log() - turn on/off with true/false get debug() { return _dbg }, set debug(dbg) { if (![true, false].includes(dbg)) return if (dbg === null) _dbg = !_dbg else _dbg = dbg this.log = _dbg ? console.log : function() {} }, // @ts-ignore log: function(...args) {}, /** Add jQuery UI formatted tooltips - add as the last line of oneditprepare in a node * @param {string} baseSelector CSS Selector that is the top of the hierarchy to impact */ doTooltips: function doTooltips(baseSelector) { // Select our page elements $(baseSelector).tooltip({ items: 'img[alt], [aria-label], [title]', track: true, content: function() { const element = $( this ) if ( element.is( '[title]' ) ) { return element.attr( 'title' ) } else if ( element.is( '[aria-label]' ) ) { return element.attr( 'aria-label' ) } else if ( element.is( 'img[alt]' ) ) { return element.attr( 'alt' ) } return '' }, }) }, /** Get all of the currently deployed uibuilder URL's & updates this.deployedUibInstances * NOTE that the uibuilder.editorUibInstances cannot be used as that includes disabled nodes/flows * @returns {{string,string}} URLs by node id of deployed uibuilder nodes */ getDeployedUrls: function getDeployedUrls() { $.ajax({ type: 'GET', async: false, dataType: 'json', url: './uibuilder/admin/dummy', data: { cmd: 'listinstances', }, beforeSend: function(jqXHR) { const authTokens = RED.settings.get('auth-tokens') if (authTokens) { jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token) } }, success: (instances) => { this.deployedUibInstances = this.sortInstances(instances) // Also pre-populate the editorUibInstances to avoid the problem that // that list is built too late during Editor load if (Object.keys(this.editorUibInstances).length === 0) this.editorUibInstances = this.deployedUibInstances // uibuilder.log('🌐[uibuilder] Deployed Instances >>', instances, this ) }, }) return this.deployedUibInstances }, // ---- end of getDeployedUrls ---- // /** Sort an instances object by url instead of the natural order added * @param {*} instances The instances object to sort * @returns {*} instances sorted by url */ sortInstances: function sortInstances(instances) { return Object.fromEntries( Object.entries(instances).sort(([, a], [, b]) => { const nameA = a.toUpperCase() const nameB = b.toUpperCase() if (nameA < nameB) return -1 if (nameA > nameB) return 1 return 0 }) ) }, }) // #region --- Calculate the node url root & the uibuilder FE url prefix const eUrlSplit = window.origin.split(':') // Is uibuilder using a custom server? if (RED.settings.uibuilderCustomServer.isCustom === true) { // Use the correct protocol (http or https) eUrlSplit[0] = RED.settings.uibuilderCustomServer.type.replace('http2', 'https') // Use the correct port eUrlSplit[2] = RED.settings.uibuilderCustomServer.port // When using custom server, no base path is used uibuilder.nodeRoot = '' uibuilder.serverType = 'a custom' } else { uibuilder.serverType = 'Node-RED\'s' } uibuilder.urlPrefix = `${eUrlSplit.join(':')}/${uibuilder.nodeRoot}` // #endregion ---- ---- ---- if (RED.settings.uibuilderNodeEnv) { uibuilder.debug = RED.settings.uibuilderNodeEnv.toLowerCase() === 'development' || RED.settings.uibuilderNodeEnv.toLowerCase() === 'dev' // uibuilder.localHost uibuilder.log(`🌐[uibuilder] DEBUG ON (because env NODE_ENV is '${RED.settings.uibuilderNodeEnv}')`) } /** Get initial list of installed FE packages via v2 API - save to master list */ $.ajax({ dataType: 'json', method: 'get', url: 'uibuilder/uibvendorpackages', async: false, // data: { url: node.url}, beforeSend: function(jqXHR) { const authTokens = RED.settings.get('auth-tokens') if (authTokens) { jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token) } }, success: function(vendorPaths) { uibuilder.packages = vendorPaths }, error: function(err) { console.error('ERROR', err) }, }) /** Get initial list of deployed uibuilder instances */ uibuilder.getDeployedUrls() /** Track which urls have been used - required to handle copy/paste and import * as these can contain duplicate urls before deployment. */ RED.events.on('nodes:add', function(node) { // For any newly added uib node, track what type of addition this is if ( uibuilder.uibNodeTypes.includes(node.type) ) { if (node.changed === false && !('moved' in node)) node.addType = 'load' else if (!('_config' in node)) node.addType = 'new' else if (node.changed === true && ('_config' in node)) node.addType = 'paste/import' } if ( node.type === 'uibuilder') { // Remove the URL on paste or import if (node.addType === 'paste/import') { delete node.url delete node.oldUrl // We have to change this if we want the display version to change (if the prop is part of the label) delete node._config.url } // Keep a list of ALL uibuilder nodes in the editor incl disabled, undeployed, etc. Different to the deployed list if (node.url) uibuilder.editorUibInstances[node.id] = node.url // Inform interested functions that something was added (and why) RED.events.emit('UIBUILDER/node-added', node) // -- IF uibuilderInstances <> editorInstances THEN there are undeployed instances. OR Disabled nodes/flows -- // uibuilder.log('🌐[uibuilder] node added:', node) } }) RED.events.on('nodes:change', function(node) { if ( node.type === 'uibuilder') { // Update list if (node.url) uibuilder.editorUibInstances[node.id] = node.url else delete uibuilder.editorUibInstances[node.id] // Inform interested functions that something was changed RED.events.emit('UIBUILDER/node-changed', node) uibuilder.log('🌐[uibuilder] node changed:', node) } }) RED.events.on('nodes:remove', function(node) { if ( node.type === 'uibuilder') { // update list delete uibuilder.editorUibInstances[node.id] // Inform interested functions that something was deleted RED.events.emit('UIBUILDER/node-deleted', node) uibuilder.log('🌐[uibuilder] node removed: ', node) } }) // RED.events.on('deploy', function() { // console.log('🌐[uibuilder] Deployed') // }) // RED.events.on('workspace:dirty', function(data) { // console.log('🌐[uibuilder] Workspace dirty:', data) // }) // RED.events.on('runtime-state', function(event) { // console.log('🌐[uibuilder] Runtime State:', event) // }) // #region --- Add uibuilder-specific actions to the RED editor --- // Action to open a selected uibuilder node's site in a new tab RED.actions.add('uibuilder:open-uibuilder-site', () => { // Get the selected node const selectedNode = RED.view.selection().nodes // If there is a single selected node and it is a uibuilder node, open if (selectedNode && selectedNode.length === 1 && selectedNode[0].type === 'uibuilder') { // Open the uibuilder site in a new tab const url = `${uibuilder.urlPrefix}${selectedNode[0].url}` window.open(url, '_blank') } else { RED.notify('🌐 Please select a single uibuilder node to open its site', 'error') } }) // Action to open a selected uibuilder node's front-end code in an IDE RED.actions.add('uibuilder:edit-uibuilder-site', () => { const selectedNode = RED.view.selection().nodes if (selectedNode && selectedNode.length === 1 && selectedNode[0].type === 'uibuilder') { const url = selectedNode[0].editurl if (url) { window.open(url, '_blank') } else { RED.notify('🌐 No IDE URL set for this uibuilder node', 'error') } } else { RED.notify('🌐 Please select a single uibuilder node to open its front-end code in your IDE', 'error') } }) // #endregion ---- ---- ---- /** If debug, dump out key information to console */ if (uibuilder.debug === true) { setTimeout( () => { console.groupCollapsed('🌐⚙️[uibuilder:editor-common] Settings ...') console.log( // The server's NODE_ENV environment var (e.g. PRODUCTION or DEVELOPMENT) 'NodeEnv: ', RED.settings.uibuilderNodeEnv, // Current version of uibuilder '\nCurrentVersion: ', RED.settings.uibuilderCurrentVersion, // Should the editor tell the user that a redeploy is needed (based on uib versions) '\nRedeployNeeded: ', RED.settings.uibuilderRedeployNeeded, // uibRoot folder '\nRootFolder: ', RED.settings.uibuilderRootFolder, // Available templates and details '\n\nTemplates: ', RED.settings.uibuilderTemplates, // Custom server details '\n\nCustomServer: ', RED.settings.uibuilderCustomServer, // List of the deployed uib instances at Editor load time [{node_id: url}] `\n\nDeployed Instances (${Object.keys(RED.settings.uibuilderInstances).length}): `, RED.settings.uibuilderInstances, // ALL possible nodes in the editor `\n\nEditor Instances (${Object.keys(uibuilder.editorUibInstances).length}, incl undeployed & disabled): `, uibuilder.editorUibInstances, // Currently installed FE packages `\n\nFE installed packages - (${Object.keys(uibuilder.packages).length}): `, uibuilder.packages, '\n\nRED Keys: ', Object.keys(RED), '\n\nRED.events Keys: ', Object.keys(RED.events), '\n\nRED.utils Keys: ', Object.keys(RED.utils) ) console.groupEnd() }, 1500) } // Check if the user has seen this version before - if not, show the changes const isSeen = localStorage.getItem('uibuilder.lastSeenVersion') || 'none' if (isSeen !== RED.settings.uibuilderCurrentVersion) { // Get the contents of the changelog-highlights.md file and log to console $.ajax({ type: 'GET', dataType: 'text', url: 'uibuilder/admin/isseen', data: { cmd: 'getChangeSummary', }, beforeSend: function(jqXHR) { const authTokens = RED.settings.get('auth-tokens') if (authTokens) { jqXHR.setRequestHeader('Authorization', 'Bearer ' + authTokens.access_token) } }, success: (data) => { // Get the version from the frontmatter const frontmatterMatch = data.match(/^---\s*\n([\s\S]*?)\n---/) if (frontmatterMatch) { const frontmatter = frontmatterMatch[1] const versionMatch = frontmatter.match(/version:\s*['"]?([^'"\n]+)['"]?/) if (versionMatch) { const version = versionMatch[1] uibuilder.log('🌐[uibuilder] Highlights frontmatter version:', version) // If the version is not the same as the current version, don't show anything if (version !== RED.settings.uibuilderCurrentVersion) return // Remove the frontmatter from the data & display the remainder in a node-red notification const changelog = RED.utils.renderMarkdown(data.replace(frontmatterMatch[0], '').trim()) const n = RED.notify( `🌐 UIBUILDER has been updated to version ${version}\n${changelog}`, { type: 'info', fixed: true, modal: true, buttons: [{ text: 'Close', click: (e) => { n.close() }, }], } ) console.info(`🌐[uibuilder] Welcome to uibuilder version ${version} - this is your first view`) localStorage.setItem('uibuilder.lastSeenVersion', RED.settings.uibuilderCurrentVersion) } } }, error: function(err) { console.error('ERROR', err) }, }) } // #region --- Add Monaco type declarations for RED.util.uib --- // ! NOTE: See the uib-runtime-plugin.js file for the actual implementations of these functions. If updating one, update the other. /** Wait for Monaco to be available then inject RED.util.uib type declarations * so function nodes get IntelliSense for uibuilder server-side utilities. * Uses Object.defineProperty to detect when window.monaco is assigned, with * a polling fallback in case the property is non-configurable. */ ;(function addUibMonacoTypes() { // eslint-disable-line @stylistic/no-extra-semi const typeDeclarations = ` declare namespace RED { namespace util { /** uibuilder server-side utilities available in function nodes */ namespace uib { /** Recursive object deep find * @param obj The object to be searched * @param matcher If returns true, cb(obj) is called * @param cb Callback receiving the matching object */ function deepObjFind(obj: any, matcher: (obj: any) => boolean, cb: (obj: any) => void): void /** Format a number to a given locale and decimal places * @param inp Input number * @param dp Decimal places (default=1) * @param locale Locale string (default='en-GB') */ function dp(inp: number, dp?: number, locale?: string): string /** Returns true/false or a default for truthy/falsy inputs * @param val The value to test * @param deflt Default if value is neither truthy nor falsy */ function truthy(val: string | number | boolean | any, deflt?: any): boolean | any /** Return a list of all deployed uibuilder instances */ function listAllApps(): object /** Send a message to a specific uibuilder instance * @param uibName The URL name of the target uibuilder instance * @param msg Message to send to the front-end */ function send(uibName: string, msg: object): void /** Render data to an HTML string using the json-viewer component renderer * @param data Any JavaScript value to render * @param opts Rendering options * @returns HTML string representing the data tree */ function renderToHTML(data: any, opts?: { /** Maximum auto-expand depth (default=2) */ maxDepth?: number /** Start all nodes collapsed (default=false) */ collapsed?: boolean /** Allow scalar leaf values to be edited (default=false) */ editable?: boolean /** Include search/collapse controls (default=false) */ interactive?: boolean /** Embed component CSS in output (default=true) */ includeStyles?: boolean }): string /** Safer JSON.stringify with circular reference handling */ function saferSerialize(data: any): string } } }` /** Inject the type declarations into Monaco for RED.util.uib as soon as Monaco is available, * so that function nodes get IntelliSense for uibuilder server-side utilities. */ function inject() { window.monaco.languages.typescript.javascriptDefaults.addExtraLib( typeDeclarations, 'file://types/uibuilder/uib-util.d.ts' ) } if (window.monaco) { inject() return } try { Object.defineProperty(window, 'monaco', { configurable: true, enumerable: true, set(value) { Object.defineProperty(window, 'monaco', { configurable: true, writable: true, enumerable: true, value, }) inject() }, }) } catch(_) { // Property is non-configurable; fall back to polling const t = setInterval(() => { if (window.monaco) { clearInterval(t) inject() } }, 500) } })() // #endregion ---- ---- ---- // TODO: EXPERIMENTAL - Maybe dynamically add things to the help panel? // // Create a mutation observer to watch for changes to the inner text of any element with the class '.red-ui-help.title' // const observer = new MutationObserver((mutations) => { // mutations.forEach((mutation) => { // if (mutation.type === 'childList') { // console.log('🌐 MutationObserver: Detected childList mutation:', mutation.target.classList, mutation) // // Check if the target element has the class 'red-ui-help title' // if (mutation.target.classList.contains('red-ui-help-title')) { // console.log('🌐 MutationObserver: Detected change in red-ui-help title element:', mutation.target); // // If it does, update the title text // const txt = mutation.target.innerText // if (txt === 'uibuilder') mutation.target.innerText = 'uibuilder - Node-RED Front-End Builder' // } // } else { // console.log('🌐 MutationObserver: Detected mutation of type:', mutation.type); // } // }) // }) // // start observing from `div.red-ui-help` // const helpDiv = document.querySelector('.red-ui-panel > .red-ui-help') // if (helpDiv) { // console.log('🌐 MutationObserver: Starting to observe red-ui-help element:', helpDiv); // observer.observe(helpDiv, { // childList: true, // Watch for changes to the children of the target node // subtree: true, // Watch for changes in all descendants of the target node // }) // } else { // console.warn('🌐 MutationObserver: No red-ui-help element found to observe.') // } }, // onremove: function() {}, })