Skip to content

Commit bf79995

Browse files
authored
Merge pull request #192 from FlowFuse/171-add-flow
Support add flow action
2 parents 5065773 + 5c5f227 commit bf79995

File tree

7 files changed

+462
-217
lines changed

7 files changed

+462
-217
lines changed

resources/expertActionsInterface.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
import { RedOps } from './redOps.js'
2+
13
// abstract class for other classes to implement invokable actions
24
export class ExpertActionsInterface {
35
/** @type {import('node-red').NodeRedInstance} - Node RED client API */
46
RED = null
57
/** @type {import('./expertComms.js').ExpertComms} - Owner Instance for comms to Expert */
68
expertComms = null
9+
/** @type { RedOps } */
10+
redOps = null
711

812
init (expertComms, RED) {
913
this.expertComms = expertComms
1014
this.RED = RED
15+
this.redOps = new RedOps()
16+
this.redOps.init(RED)
1117
}
1218

1319
get supportedActions () {
@@ -18,7 +24,7 @@ export class ExpertActionsInterface {
1824
throw new Error('hasAction method not implemented')
1925
}
2026

21-
invokeAction (actionName, { event, params } = {}, result = {}) {
27+
async invokeAction (actionName, { event, params } = {}, result = {}) {
2228
throw new Error('invokeAction method not implemented')
2329
}
2430
}

resources/expertAutomations.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ const SELECT_NODES = 'automation/select-nodes'
55
const GET_NODES = 'automation/get-nodes'
66
const EDIT_NODE = 'automation/open-node-edit'
77
const SEARCH = 'automation/search'
8+
const ADD_FLOW_TAB = 'automation/add-flow-tab'
89

910
/**
10-
* @typedef {SELECT_NODES|GET_NODES|EDIT_NODE|SEARCH} ExpertAutomationsActionsEnum
11+
* @typedef {SELECT_NODES|GET_NODES|EDIT_NODE|SEARCH|ADD_FLOW_TAB} ExpertAutomationsActionsEnum
1112
*/
1213

1314
export class ExpertAutomations extends ExpertActionsInterface {
@@ -85,6 +86,17 @@ export class ExpertAutomations extends ExpertActionsInterface {
8586
}
8687
}
8788
}
89+
},
90+
[ADD_FLOW_TAB]: {
91+
params: {
92+
type: 'object',
93+
properties: {
94+
title: {
95+
type: 'string',
96+
description: 'Optional title for the new flow tab'
97+
}
98+
}
99+
}
88100
}
89101
})
90102

@@ -165,6 +177,58 @@ export class ExpertAutomations extends ExpertActionsInterface {
165177
}
166178
}
167179

180+
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
181+
/**
182+
* Performs the import of nodes, handling any conflicts that may arise
183+
* @param {string} nodesStr the nodes to import as a string
184+
* @param {object} importOptions
185+
* @param {boolean} importOptions.addFlow whether to add the nodes to a new flow or to the current flow
186+
* @param {boolean} [importOptions.notify=true] whether to show notifications for import success/failure (default true)
187+
*/
188+
importFlow (nodesStr, { addFlow = false, generateIds = true, notify = true } = { addFlow: false, generateIds: true, notify: true }) {
189+
let newNodes = nodesStr
190+
if (typeof nodesStr === 'string') {
191+
try {
192+
nodesStr = nodesStr.trim()
193+
if (nodesStr.length === 0) {
194+
return
195+
}
196+
newNodes = this.redOps.validateFlowString(nodesStr)
197+
} catch (err) {
198+
const e = new Error(this.RED._('clipboard.invalidFlow', { message: err.message }))
199+
e.code = 'NODE_RED'
200+
throw e
201+
}
202+
}
203+
this.RED.view.importNodes(newNodes, { generateIds, addFlow, notify })
204+
}
205+
206+
async addFlowTab (title) {
207+
const cmd = () => {
208+
if (!title) {
209+
// if no title is specified, we let the core action perform this (auto naming)
210+
// NOTE: core action does not support setting the flow name.
211+
this.redOps.invoke('core:add-flow')
212+
} else {
213+
// As a title is provided, we have take a different approach: import a new flow with the label prop set.
214+
const importOptions = { generateIds: true, addFlow: false, notify: false }
215+
this.importFlow([{ id: '', type: 'tab', label: title, disabled: false, info: '', env: [] }], importOptions)
216+
}
217+
}
218+
let newTab = await this.redOps.commandAndWait(cmd, 'flows:add')
219+
if (!newTab) {
220+
return null
221+
}
222+
if (Array.isArray(newTab)) {
223+
newTab = newTab[0]
224+
}
225+
if (newTab && newTab.type === 'tab') {
226+
// select the new tab
227+
RED.workspaces.show(newTab.id)
228+
}
229+
return newTab
230+
}
231+
168232
get supportedActions () {
169233
return this.actions
170234
}
@@ -182,7 +246,7 @@ export class ExpertAutomations extends ExpertActionsInterface {
182246
* @param {{[key:string]: any, success:boolean, handled:boolean}} result - an optional object that, if provided, will be mutated to include the result of the action execution
183247
* @returns {void}
184248
*/
185-
invokeAction (actionName, { event, params } = {}, result = {}) {
249+
async invokeAction (actionName, { event, params } = {}, result = {}) {
186250
if (!this.hasAction(actionName)) {
187251
throw new Error(`Action ${actionName} not found`)
188252
}
@@ -229,6 +293,12 @@ export class ExpertAutomations extends ExpertActionsInterface {
229293
result.success = true
230294
}
231295
break
296+
case ADD_FLOW_TAB: {
297+
const newFlowTab = await this.addFlowTab(params?.title || undefined)
298+
result.tab = this._formatNodes([newFlowTab], false)[0] || null
299+
result.success = true
300+
}
301+
break
232302

233303
default:
234304
result.handled = false

resources/expertComms.js

Lines changed: 18 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class ExpertComms {
7171
MESSAGE_SCOPE = 'flowfuse-expert'
7272

7373
/** @type {ExpertAutomations} */
74-
nodeRedAutomationHelper = new ExpertAutomations() // will set RED instance later in init
74+
nrAutomations = new ExpertAutomations() // will set RED instance later in init
7575

7676
/**
7777
* targetOrigin is set to '*' by default, which allows messages to be sent and received from any origin.
@@ -121,7 +121,7 @@ export class ExpertComms {
121121
'custom:close-search': { params: null },
122122
'custom:close-typeSearch': { params: null },
123123
'custom:close-actionList': { params: null },
124-
...this.nodeRedAutomationHelper.supportedActions
124+
...this.nrAutomations.supportedActions
125125
}
126126

127127
/**
@@ -201,7 +201,7 @@ export class ExpertComms {
201201
this.RED = RED
202202
this.RED.nrAssistant = this
203203
this.assistantOptions = assistantOptions
204-
this.nodeRedAutomationHelper.init(this, RED)
204+
this.nrAutomations.init(this, RED)
205205

206206
if (!window.parent?.postMessage || window.self === window.top) {
207207
console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available')
@@ -327,10 +327,20 @@ export class ExpertComms {
327327
}
328328

329329
for (const eventName in this.commandMap) {
330-
if (type === eventName && typeof this.commandMap[eventName] === 'function') {
330+
const handler = this.commandMap[eventName]
331+
// identify if the hander is a string, function or async function
332+
let handlerType = typeof handler
333+
if (handlerType === 'function' && handler.constructor.name === 'AsyncFunction') {
334+
handlerType = 'asyncfunction'
335+
}
336+
if (type === eventName && handlerType === 'function') {
331337
return this.commandMap[eventName](payload)
332338
}
333339

340+
if (type === eventName && handlerType === 'asyncfunction') {
341+
return await this.commandMap[eventName](payload)
342+
}
343+
334344
if (
335345
type === eventName &&
336346
typeof this.commandMap[eventName] === 'string' &&
@@ -495,7 +505,7 @@ export class ExpertComms {
495505
/**
496506
* FlowFuse Expert message handlers
497507
*/
498-
handleActionInvocation ({ event, type, action, params } = {}) {
508+
async handleActionInvocation ({ event, type, action, params } = {}) {
499509
this.debug(`Received request to invoke action "${action}" with params`, params)
500510
// handle action invocation requests (must be registered actions in supportedActions)
501511
if (typeof action !== 'string') {
@@ -536,9 +546,10 @@ export class ExpertComms {
536546
case 'custom:import-flow':
537547
// import-flow is a custom action - handle it here directly
538548
try {
539-
this.importNodes(params.flow, params.addFlow === true)
549+
this.nrAutomations.importFlow(params.flow, { addFlow: params.addFlow })
540550
this.postReply({ type, success: true }, event)
541551
} catch (err) {
552+
this.RED.notify('Import failed:' + err.message, 'error')
542553
this.postReply({ type, error: err?.message }, event)
543554
}
544555
return
@@ -547,7 +558,7 @@ export class ExpertComms {
547558
try {
548559
if (actionNamespace === 'automation') {
549560
// Handle supported automated actions
550-
this.nodeRedAutomationHelper.invokeAction(action, { event, params }, result)
561+
await this.nrAutomations.invokeAction(action, { event, params }, result)
551562
} else {
552563
// Handle supported native Node-RED actions
553564
this.RED.actions.invoke(action, params)
@@ -708,62 +719,6 @@ export class ExpertComms {
708719
return { valid: true }
709720
}
710721

711-
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
712-
/**
713-
* Performs the import of nodes, handling any conflicts that may arise
714-
* @param {string} nodesStr the nodes to import as a string
715-
* @param {boolean} addFlow whether to add the nodes to a new flow or to the current flow
716-
*/
717-
importNodes (nodesStr, addFlow) {
718-
let newNodes = nodesStr
719-
if (typeof nodesStr === 'string') {
720-
try {
721-
nodesStr = nodesStr.trim()
722-
if (nodesStr.length === 0) {
723-
return
724-
}
725-
newNodes = this.validateFlowString(nodesStr)
726-
} catch (err) {
727-
const e = new Error(this.RED._('clipboard.invalidFlow', { message: 'test' }))
728-
e.code = 'NODE_RED'
729-
throw e
730-
}
731-
}
732-
const importOptions = { generateIds: true, addFlow }
733-
try {
734-
this.RED.view.importNodes(newNodes, importOptions)
735-
} catch (error) {
736-
// Thrown for import_conflict
737-
this.RED.notify('Import failed:' + error.message, 'error')
738-
throw error
739-
}
740-
}
741-
742-
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
743-
/**
744-
* Validates if the provided string looks like valid flow json
745-
* @param {string} flowString the string to validate
746-
* @returns If valid, returns the node array
747-
*/
748-
validateFlowString (flowString) {
749-
const res = JSON.parse(flowString)
750-
if (!Array.isArray(res)) {
751-
throw new Error(this.RED._('clipboard.import.errors.notArray'))
752-
}
753-
for (let i = 0; i < res.length; i++) {
754-
if (typeof res[i] !== 'object') {
755-
throw new Error(this.RED._('clipboard.import.errors.itemNotObject', { index: i }))
756-
}
757-
if (!Object.hasOwn(res[i], 'id')) {
758-
throw new Error(this.RED._('clipboard.import.errors.missingId', { index: i }))
759-
}
760-
if (!Object.hasOwn(res[i], 'type')) {
761-
throw new Error(this.RED._('clipboard.import.errors.missingType', { index: i }))
762-
}
763-
}
764-
return res
765-
}
766-
767722
debug (...args) {
768723
if (this.RED.nrAssistant?.DEBUG) {
769724
const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below

resources/redOps.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
export class RedOps {
2+
RED = null
3+
4+
init (RED) {
5+
this.RED = RED
6+
}
7+
8+
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
9+
/**
10+
* Validates if the provided string looks like valid flow json
11+
* @param {string} flowString the string to validate
12+
* @returns If valid, returns the node array
13+
*/
14+
validateFlowString (flowString) {
15+
const res = JSON.parse(flowString)
16+
if (!Array.isArray(res)) {
17+
throw new Error(this.RED._('clipboard.import.errors.notArray'))
18+
}
19+
for (let i = 0; i < res.length; i++) {
20+
if (typeof res[i] !== 'object') {
21+
throw new Error(this.RED._('clipboard.import.errors.itemNotObject', { index: i }))
22+
}
23+
if (!Object.hasOwn(res[i], 'id')) {
24+
throw new Error(this.RED._('clipboard.import.errors.missingId', { index: i }))
25+
}
26+
if (!Object.hasOwn(res[i], 'type')) {
27+
throw new Error(this.RED._('clipboard.import.errors.missingType', { index: i }))
28+
}
29+
}
30+
return res
31+
}
32+
33+
/**
34+
* Invokes a Node-RED action by ID with the provided data.
35+
* @param {String} action - The action ID to invoke, e.g. 'core:add-flows'
36+
* @param {*} actionData - Data to pass to the action, e.g. { flow: [...] }
37+
*/
38+
invoke (action, actionData) {
39+
if (!this.RED) {
40+
throw new Error('RedOps is not initialized with RED instance')
41+
}
42+
this.RED.actions.invoke(action, actionData)
43+
}
44+
45+
/**
46+
* Invokes a Node-RED action and waits for a specific event to occur before resolving.
47+
* The event can be validated with an optional resolveValidator function to ensure it is the expected response for the action.
48+
* Event data (if any) will be returned when the promise resolves.
49+
* @param {string} action - The action ID to invoke.
50+
* @param {object} actionData - Data to pass to the action.
51+
* @param {string} event - The event name to listen for.
52+
* @param {object} [options] - Options for the invocation.
53+
* @param {number} [options.timeout] - Max time to wait in milliseconds (default 2s).
54+
* @param {function} [options.resolveValidator] - Optional function to validate that the event/eventData is ours before resolving.
55+
* @returns {Promise<any>} Resolves with eventData.
56+
*/
57+
async invokeActionAndWait (action, actionData, event, { timeout = 2000, resolveValidator = null } = {}) {
58+
const command = () => {
59+
this.invoke(action, actionData)
60+
}
61+
return this.commandAndWait(command, event, { timeout, resolveValidator })
62+
}
63+
64+
/**
65+
* Run a command then wait for a specific event response to occur before resolving.
66+
* The event can be validated with an optional resolveValidator function to ensure it is the expected response for the command.
67+
* Event data (if any) will be returned when the promise resolves.
68+
* @param {function} command - The command function to execute.
69+
* @param {string} event - The event name to listen for.
70+
* @param {object} [options] - Options for the invocation.
71+
* @param {number} [options.timeout] - Max time to wait in milliseconds (default 2s).
72+
* @param {function} [options.resolveValidator] - Optional function to validate that the event/eventData is ours before resolving.
73+
* @returns {Promise<any>} Resolves with eventData.
74+
*/
75+
async commandAndWait (command, event, { timeout = 2000, resolveValidator = null } = {}) {
76+
if (!this.RED) {
77+
throw new Error('RedOps is not initialized with RED instance')
78+
}
79+
resolveValidator = resolveValidator || ((data) => true)
80+
return new Promise((resolve, reject) => {
81+
let timer = null
82+
83+
// 1. Define handler
84+
const handler = (eventData) => {
85+
if (resolveValidator(eventData)) {
86+
clearTimeout(timer)
87+
this.RED.events.off(event, handler) // Clean up listener
88+
resolve(eventData)
89+
}
90+
}
91+
92+
// 2. Monitor for timeout
93+
timer = setTimeout(() => {
94+
this.RED.events.off(event, handler) // Clean up listener
95+
reject(new Error(`Timeout waiting for event '${event}'`))
96+
}, timeout)
97+
98+
// 3. Listen for response
99+
this.RED.events.on(event, handler)
100+
101+
// 4. Trigger command
102+
try {
103+
command()
104+
} catch (err) {
105+
clearTimeout(timer)
106+
this.RED.events.off(event, handler)
107+
reject(err)
108+
}
109+
})
110+
}
111+
}

0 commit comments

Comments
 (0)