@flowfuse/node-red-dashboard 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +53 -0
  3. package/dist/css/app.d047b42b.css +1 -0
  4. package/dist/css/chunk-vendors.2378ce49.css +24 -0
  5. package/dist/fonts/materialdesignicons-webfont.3de8526e.woff +0 -0
  6. package/dist/fonts/materialdesignicons-webfont.477c6ab0.woff2 +0 -0
  7. package/dist/fonts/materialdesignicons-webfont.48a1ce0c.eot +0 -0
  8. package/dist/fonts/materialdesignicons-webfont.dfd403cf.ttf +0 -0
  9. package/dist/index.html +1 -0
  10. package/dist/js/app.854a8cd5.js +2 -0
  11. package/dist/js/app.854a8cd5.js.map +1 -0
  12. package/dist/js/chunk-vendors.174e8921.js +43 -0
  13. package/dist/js/chunk-vendors.174e8921.js.map +1 -0
  14. package/nodes/config/locales/en-US/ui_base.json +19 -0
  15. package/nodes/config/locales/en-US/ui_group.html +4 -0
  16. package/nodes/config/locales/en-US/ui_group.json +16 -0
  17. package/nodes/config/ui_base.html +807 -0
  18. package/nodes/config/ui_base.js +678 -0
  19. package/nodes/config/ui_group.html +55 -0
  20. package/nodes/config/ui_group.js +34 -0
  21. package/nodes/config/ui_page.html +84 -0
  22. package/nodes/config/ui_page.js +33 -0
  23. package/nodes/config/ui_theme.html +101 -0
  24. package/nodes/config/ui_theme.js +15 -0
  25. package/nodes/store/index.js +34 -0
  26. package/nodes/utils/index.js +35 -0
  27. package/nodes/widgets/locales/en-US/ui_button.html +7 -0
  28. package/nodes/widgets/locales/en-US/ui_button.json +24 -0
  29. package/nodes/widgets/locales/en-US/ui_chart.html +41 -0
  30. package/nodes/widgets/locales/en-US/ui_chart.json +17 -0
  31. package/nodes/widgets/locales/en-US/ui_dropdown.html +24 -0
  32. package/nodes/widgets/locales/en-US/ui_form.html +16 -0
  33. package/nodes/widgets/locales/en-US/ui_form.json +36 -0
  34. package/nodes/widgets/locales/en-US/ui_markdown.html +10 -0
  35. package/nodes/widgets/locales/en-US/ui_slider.html +9 -0
  36. package/nodes/widgets/locales/en-US/ui_switch.html +32 -0
  37. package/nodes/widgets/locales/en-US/ui_template.html +59 -0
  38. package/nodes/widgets/locales/en-US/ui_template.json +18 -0
  39. package/nodes/widgets/locales/en-US/ui_text.html +16 -0
  40. package/nodes/widgets/locales/en-US/ui_text_input.html +19 -0
  41. package/nodes/widgets/ui_button.html +146 -0
  42. package/nodes/widgets/ui_button.js +65 -0
  43. package/nodes/widgets/ui_chart.html +314 -0
  44. package/nodes/widgets/ui_chart.js +195 -0
  45. package/nodes/widgets/ui_dropdown.html +199 -0
  46. package/nodes/widgets/ui_dropdown.js +19 -0
  47. package/nodes/widgets/ui_form.html +368 -0
  48. package/nodes/widgets/ui_form.js +18 -0
  49. package/nodes/widgets/ui_markdown.html +134 -0
  50. package/nodes/widgets/ui_markdown.js +14 -0
  51. package/nodes/widgets/ui_notification.html +139 -0
  52. package/nodes/widgets/ui_notification.js +14 -0
  53. package/nodes/widgets/ui_radio_group.html +186 -0
  54. package/nodes/widgets/ui_radio_group.js +20 -0
  55. package/nodes/widgets/ui_slider.html +162 -0
  56. package/nodes/widgets/ui_slider.js +31 -0
  57. package/nodes/widgets/ui_switch.html +194 -0
  58. package/nodes/widgets/ui_switch.js +98 -0
  59. package/nodes/widgets/ui_table.html +149 -0
  60. package/nodes/widgets/ui_table.js +16 -0
  61. package/nodes/widgets/ui_template.html +283 -0
  62. package/nodes/widgets/ui_template.js +19 -0
  63. package/nodes/widgets/ui_text.html +358 -0
  64. package/nodes/widgets/ui_text.js +98 -0
  65. package/nodes/widgets/ui_text_input.html +141 -0
  66. package/nodes/widgets/ui_text_input.js +37 -0
  67. package/package.json +114 -0
@@ -0,0 +1,678 @@
1
+ // const Emitter = require('events').EventEmitter
2
+ const path = require('path')
3
+
4
+ const v = require('../../package.json').version
5
+ const datastore = require('../store/index.js')
6
+ const { appendTopic } = require('../utils/index.js')
7
+
8
+ // from: https://stackoverflow.com/a/28592528/3016654
9
+ function join (...paths) {
10
+ return paths.map(function (element) {
11
+ return element.replace(/^\/|\/$/g, '')
12
+ }).join('/')
13
+ }
14
+
15
+ module.exports = function (RED) {
16
+ const express = require('express')
17
+ const { Server } = require('socket.io')
18
+
19
+ /**
20
+ * @typedef {import('socket.io/dist').Socket} Socket
21
+ * @typedef {import('socket.io/dist').Server} Server
22
+ */
23
+
24
+ // store state that can maintain cross re-deployments
25
+ const uiShared = {
26
+ app: null,
27
+ httpServer: null,
28
+ /** @type { Server } */
29
+ ioServer: null,
30
+ /** @type {Object.<string, Socket>} */
31
+ connections: {},
32
+ settings: {}
33
+ }
34
+
35
+ /**
36
+ * Initialise the Express Server and SocketIO Server in Singleton Pattern
37
+ * @param {Object} node - Node-RED Node
38
+ * @param {Object} config - Node-RED Node Config
39
+ */
40
+ function init (node, config) {
41
+ node.uiShared = uiShared // ensure we have a uiShared object on the node (for testing mainly)
42
+ // eventually check if we have routes used, so we can support multiple base UIs
43
+ if (!uiShared.app) {
44
+ uiShared.app = RED.httpNode || RED.httpAdmin
45
+ uiShared.httpServer = RED.server
46
+
47
+ // Use the 'dashboard' settings if present, otherwise fallback
48
+ // to node-red-dashboard 'ui' settings object.
49
+ uiShared.settings = RED.settings.dashboard || RED.settings.ui || {}
50
+
51
+ // Default no-op middleware
52
+ uiShared.httpMiddleware = function (req, res, next) { next() }
53
+ if (uiShared.settings.middleware) {
54
+ if (typeof uiShared.settings.middleware === 'function' || Array.isArray(uiShared.settings.middleware)) {
55
+ uiShared.httpMiddleware = uiShared.settings.middleware
56
+ }
57
+ }
58
+ /**
59
+ * Configure Web Server to handle UI traffic
60
+ */
61
+
62
+ uiShared.app.use(config.path, uiShared.httpMiddleware, express.static(path.join(__dirname, '../../dist')))
63
+
64
+ uiShared.app.get(config.path, uiShared.httpMiddleware, (req, res) => {
65
+ res.sendFile(path.join(__dirname, '../../dist/index.html'))
66
+ })
67
+
68
+ uiShared.app.get(config.path + '/*', uiShared.httpMiddleware, (req, res) => {
69
+ res.sendFile(path.join(__dirname, '../../dist/index.html'))
70
+ })
71
+
72
+ node.log(`Node-RED Dashboard 2.0 (v${v}) started at ${config.path}`)
73
+
74
+ /**
75
+ * Create IO Server for comms between Node-RED and UI
76
+ */
77
+ if (RED.settings.httpNodeRoot !== false) {
78
+ const root = RED.settings.httpNodeRoot || '/'
79
+ const fullPath = join(root, config.path)
80
+ const socketIoPath = join('/', fullPath, 'socket.io')
81
+ /** @type {import('socket.io/dist').ServerOptions} */
82
+ const serverOptions = {
83
+ path: socketIoPath
84
+ }
85
+ // console.log('Creating socket.io server at path', socketIoPath) // disable - noisy in tests
86
+ // store reference to the SocketIO Server
87
+ uiShared.ioServer = new Server(uiShared.httpServer, serverOptions)
88
+ uiShared.ioServer.setMaxListeners(0) // prevent memory leak warning // TODO: be more smart about this!
89
+
90
+ if (typeof uiShared.settings.ioMiddleware === 'function') {
91
+ uiShared.ioServer.use(uiShared.settings.ioMiddleware)
92
+ } else if (Array.isArray(uiShared.settings.ioMiddleware)) {
93
+ uiShared.settings.ioMiddleware.forEach(function (ioMiddleware) {
94
+ uiShared.ioServer.use(ioMiddleware)
95
+ })
96
+ } else {
97
+ uiShared.ioServer.use(function (socket, next) {
98
+ if (socket.client.conn.request.url.indexOf('transport=websocket') !== -1) {
99
+ // Reject direct websocket requests
100
+ socket.client.conn.close()
101
+ return
102
+ }
103
+ if (socket.handshake.xdomain === false) {
104
+ return next()
105
+ } else {
106
+ socket.disconnect(true)
107
+ }
108
+ })
109
+ }
110
+ const bindOn = RED.server ? 'bound to Node-RED port' : 'on port ' + node.port
111
+ node.log('Created socket.io server ' + bindOn + ' at path ' + socketIoPath)
112
+ } else {
113
+ node.warn('Cannot create UI Base node when httpNodeRoot set to false')
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Close the SocketIO Server
120
+ */
121
+ function close (node, done) {
122
+ if (!uiShared.ioServer) {
123
+ done()
124
+ return
125
+ }
126
+
127
+ // determine if any ui-pages are left, if so, don't close the server
128
+ const baseNodes = []
129
+ const pageNodes = []
130
+ const themes = []
131
+ RED.nodes.eachNode(n => {
132
+ if (n.type === 'ui-page') {
133
+ pageNodes.push(n)
134
+ } else if (n.type === 'ui-base' && n.id !== node.id) {
135
+ baseNodes.push(n)
136
+ } else if (n.type === 'ui-theme') {
137
+ themes.push(n)
138
+ }
139
+ })
140
+
141
+ if (pageNodes.length > 0) {
142
+ // there are still ui-pages, so don't close the server
143
+ done()
144
+ return
145
+ }
146
+ node.ui.pages.clear()// ensure we clear out any pages that may have been left over
147
+ // since there are no pages, we can assume widgets and groups are also gone
148
+ node.ui.widgets.clear()
149
+ node.ui.groups.clear()
150
+
151
+ if (baseNodes.length > 0) {
152
+ // there are still other ui-base nodes, don't close the server
153
+ done()
154
+ return
155
+ }
156
+
157
+ // as there are no more instances of ui-page and this is the last ui-base, close the server
158
+ uiShared.ioServer.removeAllListeners()
159
+ uiShared.ioServer.disconnectSockets(true)
160
+ // tidy up
161
+ if (themes.length === 0) {
162
+ node.ui.themes.clear()
163
+ }
164
+ node.ui.dashboards.clear() // ensure we clear out any dashboards that may have been left over
165
+ node.uiShared = null // remove reference to ui object
166
+ done && done()
167
+ }
168
+
169
+ /**
170
+ * Emit an event to all connected UIs
171
+ * @param {String} event
172
+ * @param {Object} data
173
+ */
174
+ function emit (event, data) {
175
+ Object.values(uiShared.connections).forEach(conn => {
176
+ conn.emit(event, data)
177
+ })
178
+ }
179
+
180
+ /**
181
+ * UI Base Node Constructor. Called each time Node-RED deploy creates / recreates a u-base node.
182
+ * * _whether this constructor is called depends on if there are any changes to THIS node_
183
+ * * _A full Deploy will always call this function as every node is destroyed and re-created_
184
+ * @param {Object} n - Node-RED node configuration as entered in the nodes editor
185
+ */
186
+ function UIBaseNode (n) {
187
+ RED.nodes.createNode(this, n)
188
+ const node = this
189
+
190
+ node._created = Date.now()
191
+
192
+ /** @type {Object.<string, Socket>} */
193
+ // node.connections = {} // store socket.io connections for this node
194
+ // // re-map existing connections for this base node
195
+ for (const id in uiShared.connections) {
196
+ const socket = uiShared.connections[id]
197
+ if (uiShared.connections[id]._baseId === node.id) {
198
+ // re establish event handlers
199
+ socket.on('widget-action', onAction.bind(null, socket))
200
+ socket.on('widget-change', onChange.bind(null, socket))
201
+ socket.on('widget-load', onLoad.bind(null, socket))
202
+ }
203
+ }
204
+ /** @type {NodeJS.Timeout} */
205
+ node.emitConfigRequested = null // used to debounce requests to emitConfig
206
+
207
+ // Configure & Run Express Server
208
+ init(node, n)
209
+
210
+ /**
211
+ * Emit UI Config to all connected UIs
212
+ * @param {Socket} socket - socket.io socket connecting to the server
213
+ */
214
+ function emitConfig (socket) {
215
+ // pass the connected UI the UI config
216
+ socket.emit('ui-config', node.id, {
217
+ dashboards: Object.fromEntries(node.ui.dashboards),
218
+ heads: Object.fromEntries(node.ui.heads),
219
+ pages: Object.fromEntries(node.ui.pages),
220
+ themes: Object.fromEntries(node.ui.themes),
221
+ groups: Object.fromEntries(node.ui.groups),
222
+ widgets: Object.fromEntries(node.ui.widgets)
223
+ })
224
+ }
225
+
226
+ // remove event handler socket listeners for a given socket connection
227
+ function cleanupEventHandlers (socket) {
228
+ try {
229
+ socket.removeAllListeners('widget-action')
230
+ } catch (_error) { /* do nothing */ }
231
+ try {
232
+ socket.removeAllListeners('widget-change')
233
+ } catch (_error) { /* do nothing */ }
234
+ try {
235
+ socket.removeAllListeners('widget-load')
236
+ } catch (_error) { /* do nothing */ }
237
+ try {
238
+ socket.removeAllListeners('disconnect')
239
+ } catch (_error) { /* do nothing */ }
240
+
241
+ // check if any widgets have defined custom socket events
242
+ // remove their listeners to make sure we clean up properly
243
+ node.ui?.widgets?.forEach((widget) => {
244
+ if (widget.hooks?.onSocket) {
245
+ for (const [eventName] of Object.entries(widget.hooks.onSocket)) {
246
+ try {
247
+ socket.removeAllListeners(eventName)
248
+ } catch (_error) { /* do nothing */ }
249
+ }
250
+ }
251
+ })
252
+ }
253
+
254
+ function setupEventHandlers (socket) {
255
+ socket.on('widget-action', onAction.bind(null, socket))
256
+ socket.on('widget-change', onChange.bind(null, socket))
257
+ socket.on('widget-load', onLoad.bind(null, socket))
258
+
259
+ // check if any widgets have defined custom socket events
260
+ // most common with third-party widgets that are not part of core Dashboard 2.0
261
+ node.ui?.widgets?.forEach((widget) => {
262
+ if (widget.hooks?.onSocket) {
263
+ for (const [eventName, handler] of Object.entries(widget.hooks.onSocket)) {
264
+ socket.on(eventName, handler)
265
+ }
266
+ }
267
+ })
268
+
269
+ // handle disconnection
270
+ socket.on('disconnect', reason => {
271
+ cleanupEventHandlers(socket)
272
+ delete uiShared.connections[socket.id]
273
+ node.log(`Disconnected ${socket.id} due to ${reason}`)
274
+ })
275
+ }
276
+
277
+ /**
278
+ * on connection handler for SocketIO
279
+ * @param {Socket} socket socket.io socket connecting to the server
280
+ */
281
+ function onConnection (socket) {
282
+ // record mapping from connection to he ui-base node
283
+ socket._baseId = node.id
284
+
285
+ // node.connections[socket.id] = socket // store the connection for later use
286
+ uiShared.connections[socket.id] = socket // store the connection for later use
287
+ emitConfig(socket)
288
+
289
+ // clean up then re-register listeners
290
+ cleanupEventHandlers(socket)
291
+ setupEventHandlers(socket)
292
+ }
293
+ /**
294
+ * Handles a widget-action event from the UI
295
+ * @param {Socket} conn - socket.io socket connecting to the server
296
+ * @param {String} id - widget id sending the action
297
+ * @param {*} msg - The node-red msg object to forward
298
+ * @returns void
299
+ */
300
+ async function onAction (conn, id, msg) {
301
+ console.log('conn:' + conn.id, 'on:widget-action:' + id, msg)
302
+
303
+ // ensure msg is an object. Assume the incoming data is the payload if not
304
+ if (!msg || typeof msg !== 'object') {
305
+ msg = { payload: msg }
306
+ }
307
+
308
+ // get widget node and configuration
309
+ const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
310
+
311
+ // ensure we can get the requested widget from the runtime & that this widget has an onAction handler
312
+ if (!wNode || !widgetEvents.onAction) {
313
+ return // widget does not exist (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
314
+ }
315
+
316
+ // Wrap execution in a try/catch to ensure we don't crash Node-RED
317
+ try {
318
+ msg = await appendTopic(RED, widgetConfig, wNode, msg)
319
+
320
+ // pre-process the msg before send on the msg (if beforeSend is defined)
321
+ if (widgetEvents?.beforeSend && typeof widgetEvents.beforeSend === 'function') {
322
+ msg = await widgetEvents.beforeSend(msg)
323
+ }
324
+
325
+ // send the msg onwards
326
+ wNode.send(msg)
327
+ } catch (error) {
328
+ let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
329
+ errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
330
+ errorHandler && errorHandler(error)
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Handles a widget-change event from the UI
336
+ * @param {Socket} conn - socket.io socket connecting to the server
337
+ * @param {String} id - widget id sending the action
338
+ * @param {*} value - The value to send to node-red. Typically this is the payload
339
+ * @returns void
340
+ */
341
+ async function onChange (conn, id, value) {
342
+ console.log('conn:' + conn.id, 'on:widget-change:' + id, value)
343
+
344
+ // get widget node and configuration
345
+ const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id)
346
+
347
+ if (!wNode) {
348
+ return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
349
+ }
350
+ let msg = datastore.get(id) || {}
351
+ async function defaultHandler (value) {
352
+ if (typeof (value) === 'object' && value !== null && Object.hasOwn(value, 'payload')) {
353
+ msg.payload = value.payload
354
+ } else {
355
+ msg.payload = value
356
+ }
357
+
358
+ msg = await appendTopic(RED, widgetConfig, wNode, msg)
359
+
360
+ if (widgetEvents?.beforeSend) {
361
+ msg = await widgetEvents.beforeSend(msg)
362
+ }
363
+ datastore.save(id, msg)
364
+ wNode.send(msg) // send the msg onwards
365
+ }
366
+
367
+ // wrap execution in a try/catch to ensure we don't crash Node-RED
368
+ try {
369
+ // Most of the time, we can just use this default handler,
370
+ // but sometimes a node needs to do something specific (e.g. ui-switch)
371
+ const handler = typeof (widgetEvents.onChange) === 'function' ? widgetEvents.onChange : defaultHandler
372
+ await handler(value)
373
+ } catch (error) {
374
+ console.log(error)
375
+ let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
376
+ errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
377
+ errorHandler && errorHandler(error)
378
+ }
379
+ }
380
+
381
+ async function onLoad (conn, id, msg) {
382
+ console.log('conn:' + conn.id, 'on:widget-load:' + id, msg)
383
+
384
+ const { wNode, widgetEvents } = getWidgetAndConfig(id)
385
+ if (!wNode) {
386
+ console.log('widget does not exist any more')
387
+ return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
388
+ }
389
+ async function handler () {
390
+ // replicate receiving an input, so the widget can handle accordingly
391
+ const msg = datastore.get(id)
392
+ if (msg) {
393
+ // only emit something if we have something to send
394
+ // and only to this connection, not all connected clients
395
+ conn.emit('widget-load:' + id, msg)
396
+ }
397
+ }
398
+ // wrap execution in a try/catch to ensure we don't crash Node-RED
399
+ try {
400
+ handler()
401
+ } catch (error) {
402
+ let errorHandler = typeof (widgetEvents.onError) === 'function' ? widgetEvents.onError : null
403
+ errorHandler = errorHandler || (typeof wNode.error === 'function' ? wNode.error : node.error)
404
+ errorHandler && errorHandler(error)
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Get the widget node and associated configuration/event hooks
410
+ * @param {String} id - ID of the widget
411
+ * @returns {Object} - { wNode, widgetConfig, widgetEvents, widget }
412
+ */
413
+ function getWidgetAndConfig (id) {
414
+ // node.ui?.widgets is empty?
415
+ // themes, groups, etc. are not empty?
416
+ const wNode = RED.nodes.getNode(id)
417
+ const widget = node.ui?.widgets?.get(id)
418
+ const widgetConfig = widget?.props || {}
419
+ const widgetEvents = widget?.hooks || {}
420
+ return { wNode, widgetConfig, widgetEvents, widget }
421
+ }
422
+
423
+ // When a UI connects - send the UI Config from Node-RED to the UI
424
+ uiShared.ioServer.on('connection', onConnection)
425
+
426
+ // Make sure we clean up after ourselves
427
+ node.on('close', (removed, done) => {
428
+ uiShared.ioServer?.off('connection', onConnection)
429
+ for (const conn of Object.values(uiShared.connections)) {
430
+ cleanupEventHandlers(conn)
431
+ }
432
+ close(node, function (err) {
433
+ if (err) {
434
+ node.error(`Error closing socket.io server for ${node.id}`, err)
435
+ }
436
+ done()
437
+ })
438
+ })
439
+
440
+ /**
441
+ * External Functions for managing UI Components
442
+ */
443
+ // store ui config to be sent to UI
444
+ node.ui = {
445
+ heads: new Map(),
446
+ dashboards: new Map(),
447
+ pages: new Map(),
448
+ themes: new Map(),
449
+ groups: new Map(),
450
+ widgets: new Map()
451
+ }
452
+
453
+ /**
454
+ * Queue up a config emit to the UI. This is a debounced function
455
+ * NOTES:
456
+ * * only sockets connected to this node will receive the config
457
+ * * each ui-node will have it's own connections and will emit it's own config
458
+ * @returns {void}
459
+ */
460
+ node.requestEmitConfig = function () {
461
+ if (node.emitConfigRequested) {
462
+ return
463
+ }
464
+ node.emitConfigRequested = setTimeout(() => {
465
+ try {
466
+ // emit config to all connected UI for this ui-base
467
+ Object.values(uiShared.connections).forEach(socket => {
468
+ emitConfig(socket)
469
+ })
470
+ } finally {
471
+ node.emitConfigRequested = null
472
+ }
473
+ }, 300)
474
+ }
475
+
476
+ /**
477
+ * Register allows for pages, widgets, groups, etc. to register themselves with the Base UI Node
478
+ * @param {*} page
479
+ * @param {*} widget
480
+ */
481
+ node.register = function (page, group, widgetNode, widgetConfig, widgetEvents) {
482
+ /**
483
+ * Build UI Config
484
+ */
485
+
486
+ // strip widgetConfig of stuff we don't really care about (e.g. Node-RED x/y coordinates)
487
+ // and leave us just with the properties set inside the Node-RED Editor, store as "props"
488
+ // store our UI state properties under the .state key too
489
+ const widget = {
490
+ id: widgetConfig.id,
491
+ type: widgetConfig.type,
492
+ props: widgetConfig,
493
+ layout: {
494
+ width: widgetConfig.width || 3,
495
+ height: widgetConfig.height || 1,
496
+ order: widgetConfig.order || 0
497
+ },
498
+ state: {
499
+ enabled: datastore.get(widgetConfig.id)?.enabled || true,
500
+ visible: datastore.get(widgetConfig.id)?.visible || true
501
+ },
502
+ hooks: widgetEvents
503
+ }
504
+
505
+ delete widget.props.id
506
+ delete widget.props.type
507
+ delete widget.props.x
508
+ delete widget.props.y
509
+ delete widget.props.z
510
+ delete widget.props.wires
511
+
512
+ if (widget.props.width === '0') {
513
+ widget.props.width = null
514
+ }
515
+ if (widget.props.height === '0') {
516
+ widget.props.height = null
517
+ }
518
+
519
+ // loop over props and check if we have any function definitions (e.g. onMounted, onInput)
520
+ // and stringify them for transport over SocketIO
521
+ for (const [key, value] of Object.entries(widget.props)) {
522
+ // supported functions
523
+ const supported = ['onMounted', 'onInput']
524
+ if (supported.includes(key) && typeof value === 'function') {
525
+ widget.props[key] = value.toString()
526
+ } else if (key === 'methods') {
527
+ for (const [method, fcn] of Object.entries(widget.props.methods)) {
528
+ if (typeof fcn === 'function') {
529
+ widget.props.methods[method] = fcn.toString()
530
+ }
531
+ }
532
+ }
533
+ }
534
+
535
+ // map dashboards by their ID
536
+ if (!node.ui.dashboards.has(n.id)) {
537
+ node.ui.dashboards.set(n.id, n)
538
+ }
539
+
540
+ // map themes by their ID
541
+ if (page && !node.ui.themes.has(page.theme)) {
542
+ const theme = RED.nodes.getNode(page.theme)
543
+ if (theme) {
544
+ // eslint-disable-next-line no-unused-vars
545
+ const { _wireCount, _inputCallback, _inputCallbacks, _closeCallbacks, wires, type, ...t } = theme
546
+ node.ui.themes.set(page.theme, t)
547
+ } else {
548
+ node.warn(`Theme '${page.theme}' specified in page '${page.id}' does not exist`)
549
+ }
550
+ }
551
+
552
+ // map pages by their ID
553
+ if (page && !node.ui.pages.has(page?.id)) {
554
+ // eslint-disable-next-line no-unused-vars
555
+ const { _user, type, ...p } = page
556
+ node.ui.pages.set(page.id, p)
557
+ }
558
+
559
+ // map groups on a page-by-page basis
560
+ if (group && !node.ui.groups.has(group?.id)) {
561
+ // eslint-disable-next-line no-unused-vars
562
+ const { _user, type, ...g } = group
563
+ node.ui.groups.set(group.id, g)
564
+ }
565
+
566
+ // map widgets on a group-by-group basis
567
+ if (!node.ui.widgets.has(widget.id)) {
568
+ node.ui.widgets.set(widget.id, widget)
569
+ }
570
+
571
+ /**
572
+ * Helper Function for testing
573
+ */
574
+
575
+ widgetNode.getState = function () {
576
+ return datastore.get(widgetNode.id)
577
+ }
578
+
579
+ /**
580
+ * Event Handlers
581
+ */
582
+
583
+ // add Node-RED listener to the widget for when it's corresponding node receives a msg in Node-RED
584
+ widgetNode.on('input', async function (msg, send, done) {
585
+ // ensure we have latest instance of the widget's node
586
+ const wNode = RED.nodes.getNode(widgetNode.id)
587
+ if (!wNode) {
588
+ return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
589
+ }
590
+
591
+ try {
592
+ // pre-process the msg before running our onInput function
593
+ if (widgetEvents?.beforeSend) {
594
+ msg = await widgetEvents.beforeSend(msg)
595
+ }
596
+
597
+ // run any node-specific handler defined in the Widget's component
598
+ if (widgetEvents?.onInput) {
599
+ await widgetEvents?.onInput(msg, send)
600
+ } else {
601
+ // msg could be null if the beforeSend errors and returns null
602
+ if (msg) {
603
+ // store the latest msg passed to node
604
+ datastore.save(widgetNode.id, msg)
605
+
606
+ if (widgetConfig.topic || widgetConfig.topicType) {
607
+ msg = await appendTopic(RED, widgetConfig, wNode, msg)
608
+ }
609
+ if (Object.hasOwn(widgetConfig, 'passthru')) {
610
+ if (widgetConfig.passthru) {
611
+ send(msg)
612
+ }
613
+ } else {
614
+ send(msg)
615
+ }
616
+ }
617
+ }
618
+
619
+ // emit to all connected UIs
620
+ emit('msg-input:' + widget.id, msg)
621
+
622
+ done()
623
+ } catch (err) {
624
+ if (err.type === 'warn') {
625
+ wNode.warn(err.message)
626
+ done()
627
+ } else {
628
+ done(err)
629
+ }
630
+ }
631
+ })
632
+
633
+ // when a widget is "closed" remove it from this Base Node's knowledge
634
+ widgetNode.on('close', function (removed, done) {
635
+ if (removed) {
636
+ // widget has been removed from the Editor
637
+ // clear any data from datastore
638
+ datastore.clear(widgetNode.id)
639
+ }
640
+ node.deregister(null, null, widgetNode)
641
+ done()
642
+ })
643
+
644
+ node.requestEmitConfig() // queue up a config emit to the UI
645
+ }
646
+
647
+ node.deregister = function (page, group, widgetNode) {
648
+ let changes = false
649
+ // remove widget from our UI config
650
+ if (widgetNode) {
651
+ node.ui.widgets.delete(widgetNode.id)
652
+ changes = true
653
+ }
654
+
655
+ // if there are no more widgets on this group, remove the group from our UI config
656
+ if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) {
657
+ node.ui.groups.delete(group.id)
658
+ changes = true
659
+ }
660
+
661
+ // if there are no more groups on this page, remove the page from our UI config
662
+ if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) {
663
+ node.ui.pages.delete(page.id)
664
+ changes = true
665
+ }
666
+ if (changes) {
667
+ node.requestEmitConfig()
668
+ }
669
+ }
670
+
671
+ // Finally, queue up a config emit to the UI.
672
+ // NOTE: this is a cautionary measure only - typically the registration of nodes will queue up a config emit
673
+ // but in cases where the dashboard has no widgets registered, we still need to emit a config
674
+ node.requestEmitConfig()
675
+ }
676
+
677
+ RED.nodes.registerType('ui-base', UIBaseNode)
678
+ }