@d34dman/flowdrop 0.0.12 → 0.0.13

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.
@@ -14,6 +14,27 @@
14
14
  * - Systems that need to generate or modify workflows programmatically
15
15
  */
16
16
  import { v4 as uuidv4 } from 'uuid';
17
+ /**
18
+ * Generate a unique node ID based on node type and existing nodes
19
+ * Format: <node_type>.<number>
20
+ * Example: boolean_gateway.1, calculator.2
21
+ */
22
+ function generateStandardNodeId(nodeTypeId, existingNodes) {
23
+ // Count how many nodes of this type already exist
24
+ const existingNodeIds = existingNodes
25
+ .filter((node) => node.data?.metadata?.id === nodeTypeId)
26
+ .map((node) => node.id);
27
+ // Extract the numbers from existing IDs with the same prefix
28
+ const existingNumbers = existingNodeIds
29
+ .map((id) => {
30
+ const match = id.match(new RegExp(`^${nodeTypeId}\\.(\\d+)$`));
31
+ return match ? parseInt(match[1], 10) : 0;
32
+ })
33
+ .filter((num) => num > 0);
34
+ // Find the next available number (highest + 1)
35
+ const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
36
+ return `${nodeTypeId}.${nextNumber}`;
37
+ }
17
38
  /**
18
39
  * Workflow Adapter Class
19
40
  * Provides a clean API for workflow operations without exposing SvelteFlow internals
@@ -48,8 +69,10 @@ export class WorkflowAdapter {
48
69
  if (!metadata) {
49
70
  throw new Error(`Node type '${nodeType}' not found`);
50
71
  }
72
+ // Generate node ID based on node type and existing nodes
73
+ const nodeId = generateStandardNodeId(nodeType, workflow.nodes);
51
74
  const node = {
52
- id: uuidv4(),
75
+ id: nodeId,
53
76
  type: nodeType,
54
77
  position,
55
78
  data: {
@@ -313,9 +336,17 @@ export class WorkflowAdapter {
313
336
  const cloned = JSON.parse(JSON.stringify(workflow));
314
337
  // Generate new IDs for all nodes and edges
315
338
  const idMapping = new Map();
339
+ // Count nodes by type to generate proper sequential IDs
340
+ const nodeTypeCounts = new Map();
316
341
  cloned.nodes.forEach((node) => {
317
342
  const oldId = node.id;
318
- node.id = uuidv4();
343
+ const nodeTypeId = node.data.metadata.id;
344
+ // Get the current count for this node type
345
+ const currentCount = nodeTypeCounts.get(nodeTypeId) || 0;
346
+ const newCount = currentCount + 1;
347
+ nodeTypeCounts.set(nodeTypeId, newCount);
348
+ // Generate new ID with the sequential number
349
+ node.id = `${nodeTypeId}.${newCount}`;
319
350
  idMapping.set(oldId, node.id);
320
351
  });
321
352
  cloned.edges.forEach((edge) => {
@@ -167,19 +167,19 @@
167
167
  */
168
168
  async function testApiConnection(): Promise<void> {
169
169
  try {
170
- const baseUrl = endpointConfig?.baseUrl || apiBaseUrl || "/api/flowdrop";
170
+ const baseUrl = endpointConfig?.baseUrl || apiBaseUrl || '/api/flowdrop';
171
171
  const testUrl = `${baseUrl}/nodes`;
172
172
 
173
173
  const response = await fetch(testUrl);
174
174
  const data = await response.json();
175
175
 
176
176
  if (response.ok && data.success) {
177
- apiToasts.success("API connection test", "Connection successful");
177
+ apiToasts.success('API connection test', 'Connection successful');
178
178
  } else {
179
- apiToasts.error("API connection test", "Connection failed");
179
+ apiToasts.error('API connection test', 'Connection failed');
180
180
  }
181
181
  } catch (err) {
182
- apiToasts.error("API connection test", err instanceof Error ? err.message : "Unknown error");
182
+ apiToasts.error('API connection test', err instanceof Error ? err.message : 'Unknown error');
183
183
  }
184
184
  }
185
185
 
@@ -288,53 +288,69 @@
288
288
  // Wait for any pending DOM updates before saving
289
289
  await tick();
290
290
 
291
- // Import necessary modules
292
- const { workflowApi } = await import('../services/api.js');
293
- const { v4: uuidv4 } = await import('uuid');
294
-
295
- // Use current workflow from global store
296
- const workflowToSave = $workflowStore;
291
+ // Show loading toast
292
+ const loadingToast = apiToasts.loading('Saving workflow');
297
293
 
298
- if (!workflowToSave) {
299
- return;
300
- }
294
+ try {
295
+ // Import necessary modules
296
+ const { workflowApi } = await import('../services/api.js');
297
+ const { v4: uuidv4 } = await import('uuid');
301
298
 
302
- // Determine the workflow ID
303
- let workflowId: string;
304
- if (workflowToSave.id) {
305
- workflowId = workflowToSave.id;
306
- } else {
307
- workflowId = uuidv4();
308
- }
299
+ // Use current workflow from global store
300
+ const workflowToSave = $workflowStore;
309
301
 
310
- // Create workflow object for saving
311
- const finalWorkflow = {
312
- id: workflowId,
313
- name: workflowToSave.name || 'Untitled Workflow',
314
- description: workflowToSave.description || '',
315
- nodes: workflowToSave.nodes || [],
316
- edges: workflowToSave.edges || [],
317
- metadata: {
318
- version: '1.0.0',
319
- createdAt: workflowToSave.metadata?.createdAt || new Date().toISOString(),
320
- updatedAt: new Date().toISOString()
302
+ if (!workflowToSave) {
303
+ dismissToast(loadingToast);
304
+ return;
321
305
  }
322
- };
323
306
 
324
- const savedWorkflow = await workflowApi.saveWorkflow(finalWorkflow);
307
+ // Determine the workflow ID
308
+ let workflowId: string;
309
+ if (workflowToSave.id) {
310
+ workflowId = workflowToSave.id;
311
+ } else {
312
+ workflowId = uuidv4();
313
+ }
325
314
 
326
- // Update the workflow ID if it changed (new workflow)
327
- // Keep our current workflow state, only update ID and metadata from backend
328
- if (savedWorkflow.id && savedWorkflow.id !== finalWorkflow.id) {
329
- workflowActions.batchUpdate({
330
- nodes: finalWorkflow.nodes,
331
- edges: finalWorkflow.edges,
332
- name: finalWorkflow.name,
315
+ // Create workflow object for saving
316
+ const finalWorkflow = {
317
+ id: workflowId,
318
+ name: workflowToSave.name || 'Untitled Workflow',
319
+ description: workflowToSave.description || '',
320
+ nodes: workflowToSave.nodes || [],
321
+ edges: workflowToSave.edges || [],
333
322
  metadata: {
334
- ...finalWorkflow.metadata,
335
- ...savedWorkflow.metadata
323
+ version: '1.0.0',
324
+ createdAt: workflowToSave.metadata?.createdAt || new Date().toISOString(),
325
+ updatedAt: new Date().toISOString()
336
326
  }
337
- });
327
+ };
328
+
329
+ const savedWorkflow = await workflowApi.saveWorkflow(finalWorkflow);
330
+
331
+ // Update the workflow ID if it changed (new workflow)
332
+ // Keep our current workflow state, only update ID and metadata from backend
333
+ if (savedWorkflow.id && savedWorkflow.id !== finalWorkflow.id) {
334
+ workflowActions.batchUpdate({
335
+ nodes: finalWorkflow.nodes,
336
+ edges: finalWorkflow.edges,
337
+ name: finalWorkflow.name,
338
+ metadata: {
339
+ ...finalWorkflow.metadata,
340
+ ...savedWorkflow.metadata
341
+ }
342
+ });
343
+ }
344
+
345
+ // Dismiss loading toast and show success
346
+ dismissToast(loadingToast);
347
+ apiToasts.success('Save workflow', 'Workflow saved successfully');
348
+ } catch (error) {
349
+ // Dismiss loading toast and show error
350
+ dismissToast(loadingToast);
351
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
352
+ apiToasts.error('Save workflow', errorMessage);
353
+ throw error; // Re-throw to allow calling code to handle if needed
338
354
  }
339
355
  }
340
356
 
@@ -616,6 +632,24 @@
616
632
  <div class="flowdrop-config-sidebar__section">
617
633
  <h3 class="flowdrop-config-sidebar__section-title">Node Details</h3>
618
634
  <div class="flowdrop-config-sidebar__details">
635
+ <div class="flowdrop-config-sidebar__detail">
636
+ <span class="flowdrop-config-sidebar__detail-label">Node ID:</span>
637
+ <div class="flowdrop-config-sidebar__detail-value-with-copy">
638
+ <span class="flowdrop-config-sidebar__detail-value" style="font-family: monospace;">
639
+ {selectedNodeForConfig().id}
640
+ </span>
641
+ <button
642
+ class="flowdrop-config-sidebar__copy-btn"
643
+ onclick={() => {
644
+ navigator.clipboard.writeText(selectedNodeForConfig().id);
645
+ }}
646
+ title="Copy Node ID"
647
+ aria-label="Copy node ID to clipboard"
648
+ >
649
+ 📋
650
+ </button>
651
+ </div>
652
+ </div>
619
653
  <div class="flowdrop-config-sidebar__detail">
620
654
  <span class="flowdrop-config-sidebar__detail-label">Type:</span>
621
655
  <span class="flowdrop-config-sidebar__detail-value"
@@ -942,6 +976,43 @@
942
976
  font-weight: 500;
943
977
  }
944
978
 
979
+ .flowdrop-config-sidebar__detail-value-with-copy {
980
+ display: flex;
981
+ align-items: center;
982
+ gap: 0.5rem;
983
+ background-color: #f3f4f6;
984
+ padding: 0.5rem;
985
+ border-radius: 0.375rem;
986
+ border: 1px solid #e5e7eb;
987
+ }
988
+
989
+ .flowdrop-config-sidebar__detail-value-with-copy .flowdrop-config-sidebar__detail-value {
990
+ flex: 1;
991
+ font-size: 0.8125rem;
992
+ word-break: break-all;
993
+ }
994
+
995
+ .flowdrop-config-sidebar__copy-btn {
996
+ background: white;
997
+ border: 1px solid #d1d5db;
998
+ border-radius: 0.25rem;
999
+ padding: 0.25rem 0.5rem;
1000
+ cursor: pointer;
1001
+ font-size: 1rem;
1002
+ transition: all 0.2s;
1003
+ flex-shrink: 0;
1004
+ }
1005
+
1006
+ .flowdrop-config-sidebar__copy-btn:hover {
1007
+ background-color: #f9fafb;
1008
+ border-color: #9ca3af;
1009
+ transform: scale(1.05);
1010
+ }
1011
+
1012
+ .flowdrop-config-sidebar__copy-btn:active {
1013
+ transform: scale(0.95);
1014
+ }
1015
+
945
1016
  .flowdrop-config-sidebar__detail-description {
946
1017
  margin: 0;
947
1018
  font-size: 0.875rem;
@@ -5,96 +5,89 @@
5
5
  -->
6
6
 
7
7
  <script lang="ts">
8
- import type { ConfigSchema, WorkflowNode } from "../types/index.js"
8
+ import type { ConfigSchema, WorkflowNode } from '../types/index.js';
9
9
 
10
10
  interface Props {
11
- node: WorkflowNode
12
- onSave: (config: Record<string, unknown>) => void
13
- onCancel: () => void
11
+ node: WorkflowNode;
12
+ onSave: (config: Record<string, unknown>) => void;
13
+ onCancel: () => void;
14
14
  }
15
15
 
16
- let { node, onSave, onCancel }: Props = $props()
16
+ let { node, onSave, onCancel }: Props = $props();
17
17
 
18
18
  /**
19
19
  * Get the configuration schema from node metadata
20
20
  */
21
- const configSchema = $derived(
22
- node.data.metadata?.configSchema as ConfigSchema | undefined
23
- )
21
+ const configSchema = $derived(node.data.metadata?.configSchema as ConfigSchema | undefined);
24
22
 
25
23
  /**
26
24
  * Get the current node configuration
27
25
  */
28
- const nodeConfig = $derived(node.data.config || {})
26
+ const nodeConfig = $derived(node.data.config || {});
29
27
 
30
28
  /**
31
29
  * Create reactive configuration values using $state
32
30
  * This fixes the Svelte 5 reactivity warnings
33
31
  */
34
- let configValues = $state<Record<string, unknown>>({})
32
+ let configValues = $state<Record<string, unknown>>({});
35
33
 
36
34
  /**
37
35
  * Initialize config values when node or schema changes
38
36
  */
39
37
  $effect(() => {
40
38
  if (configSchema?.properties) {
41
- const mergedConfig: Record<string, unknown> = {}
39
+ const mergedConfig: Record<string, unknown> = {};
42
40
  Object.entries(configSchema.properties).forEach(([key, field]) => {
43
- const fieldConfig = field as any
41
+ const fieldConfig = field as any;
44
42
  // Use existing value if available, otherwise use default
45
- mergedConfig[key] =
46
- nodeConfig[key] !== undefined ? nodeConfig[key] : fieldConfig.default
47
- })
48
- configValues = mergedConfig
43
+ mergedConfig[key] = nodeConfig[key] !== undefined ? nodeConfig[key] : fieldConfig.default;
44
+ });
45
+ configValues = mergedConfig;
49
46
  }
50
- })
47
+ });
51
48
 
52
49
  /**
53
50
  * Handle form submission
54
51
  */
55
52
  function handleSave(): void {
56
53
  // Collect all form values including hidden fields
57
- const form = document.querySelector(".flowdrop-config-sidebar__form")
58
- const updatedConfig: Record<string, unknown> = { ...configValues }
54
+ const form = document.querySelector('.flowdrop-config-sidebar__form');
55
+ const updatedConfig: Record<string, unknown> = { ...configValues };
59
56
 
60
57
  if (form) {
61
- const inputs = form.querySelectorAll("input, select, textarea")
58
+ const inputs = form.querySelectorAll('input, select, textarea');
62
59
  inputs.forEach((input: any) => {
63
60
  if (input.id) {
64
- if (input.type === "checkbox") {
65
- updatedConfig[input.id] = input.checked
66
- } else if (input.type === "number") {
67
- updatedConfig[input.id] = input.value ? Number(input.value) : input.value
68
- } else if (input.type === "hidden") {
61
+ if (input.type === 'checkbox') {
62
+ updatedConfig[input.id] = input.checked;
63
+ } else if (input.type === 'number') {
64
+ updatedConfig[input.id] = input.value ? Number(input.value) : input.value;
65
+ } else if (input.type === 'hidden') {
69
66
  // Parse hidden field values that might be JSON
70
67
  try {
71
- const parsed = JSON.parse(input.value)
72
- updatedConfig[input.id] = parsed
68
+ const parsed = JSON.parse(input.value);
69
+ updatedConfig[input.id] = parsed;
73
70
  } catch {
74
71
  // If not JSON, use raw value
75
- updatedConfig[input.id] = input.value
72
+ updatedConfig[input.id] = input.value;
76
73
  }
77
74
  } else {
78
- updatedConfig[input.id] = input.value
75
+ updatedConfig[input.id] = input.value;
79
76
  }
80
77
  }
81
- })
78
+ });
82
79
  }
83
80
 
84
81
  // Preserve hidden field values from original config if not collected from form
85
82
  if (node.data.config && configSchema?.properties) {
86
83
  Object.entries(configSchema.properties).forEach(([key, property]: [string, any]) => {
87
- if (
88
- property.format === "hidden" &&
89
- !(key in updatedConfig) &&
90
- key in node.data.config
91
- ) {
92
- updatedConfig[key] = node.data.config[key]
84
+ if (property.format === 'hidden' && !(key in updatedConfig) && key in node.data.config) {
85
+ updatedConfig[key] = node.data.config[key];
93
86
  }
94
- })
87
+ });
95
88
  }
96
89
 
97
- onSave(updatedConfig)
90
+ onSave(updatedConfig);
98
91
  }
99
92
  </script>
100
93
 
@@ -103,7 +96,7 @@
103
96
  {#if configSchema.properties}
104
97
  {#each Object.entries(configSchema.properties) as [key, field] (key)}
105
98
  {@const fieldConfig = field as any}
106
- {#if fieldConfig.format !== "hidden"}
99
+ {#if fieldConfig.format !== 'hidden'}
107
100
  <div class="flowdrop-config-sidebar__field">
108
101
  <label class="flowdrop-config-sidebar__field-label" for={key}>
109
102
  {fieldConfig.title || fieldConfig.description || key}
@@ -120,18 +113,16 @@
120
113
  checked={Array.isArray(configValues[key]) &&
121
114
  configValues[key].includes(String(option))}
122
115
  onchange={(e) => {
123
- const checked = e.currentTarget.checked
116
+ const checked = e.currentTarget.checked;
124
117
  const currentValues = Array.isArray(configValues[key])
125
118
  ? [...(configValues[key] as unknown[])]
126
- : []
119
+ : [];
127
120
  if (checked) {
128
121
  if (!currentValues.includes(String(option))) {
129
- configValues[key] = [...currentValues, String(option)]
122
+ configValues[key] = [...currentValues, String(option)];
130
123
  }
131
124
  } else {
132
- configValues[key] = currentValues.filter(
133
- (v) => v !== String(option)
134
- )
125
+ configValues[key] = currentValues.filter((v) => v !== String(option));
135
126
  }
136
127
  }}
137
128
  />
@@ -152,42 +143,42 @@
152
143
  <option value={String(option)}>{String(option)}</option>
153
144
  {/each}
154
145
  </select>
155
- {:else if fieldConfig.type === "string" && fieldConfig.format === "multiline"}
146
+ {:else if fieldConfig.type === 'string' && fieldConfig.format === 'multiline'}
156
147
  <!-- Textarea for multiline strings -->
157
148
  <textarea
158
149
  id={key}
159
150
  class="flowdrop-config-sidebar__textarea"
160
151
  bind:value={configValues[key]}
161
- placeholder={String(fieldConfig.placeholder || "")}
152
+ placeholder={String(fieldConfig.placeholder || '')}
162
153
  rows="4"
163
154
  ></textarea>
164
- {:else if fieldConfig.type === "string"}
155
+ {:else if fieldConfig.type === 'string'}
165
156
  <input
166
157
  id={key}
167
158
  type="text"
168
159
  class="flowdrop-config-sidebar__input"
169
160
  bind:value={configValues[key]}
170
- placeholder={String(fieldConfig.placeholder || "")}
161
+ placeholder={String(fieldConfig.placeholder || '')}
171
162
  />
172
- {:else if fieldConfig.type === "number"}
163
+ {:else if fieldConfig.type === 'number'}
173
164
  <input
174
165
  id={key}
175
166
  type="number"
176
167
  class="flowdrop-config-sidebar__input"
177
168
  bind:value={configValues[key]}
178
- placeholder={String(fieldConfig.placeholder || "")}
169
+ placeholder={String(fieldConfig.placeholder || '')}
179
170
  />
180
- {:else if fieldConfig.type === "boolean"}
171
+ {:else if fieldConfig.type === 'boolean'}
181
172
  <input
182
173
  id={key}
183
174
  type="checkbox"
184
175
  class="flowdrop-config-sidebar__checkbox"
185
176
  checked={Boolean(configValues[key] || fieldConfig.default || false)}
186
177
  onchange={(e) => {
187
- configValues[key] = e.currentTarget.checked
178
+ configValues[key] = e.currentTarget.checked;
188
179
  }}
189
180
  />
190
- {:else if fieldConfig.type === "select" || fieldConfig.options}
181
+ {:else if fieldConfig.type === 'select' || fieldConfig.options}
191
182
  <select
192
183
  id={key}
193
184
  class="flowdrop-config-sidebar__select"
@@ -196,9 +187,7 @@
196
187
  {#if fieldConfig.options}
197
188
  {#each fieldConfig.options as option (String(option.value))}
198
189
  {@const optionConfig = option as any}
199
- <option value={String(optionConfig.value)}
200
- >{String(optionConfig.label)}</option
201
- >
190
+ <option value={String(optionConfig.value)}>{String(optionConfig.label)}</option>
202
191
  {/each}
203
192
  {/if}
204
193
  </select>
@@ -209,7 +198,7 @@
209
198
  type="text"
210
199
  class="flowdrop-config-sidebar__input"
211
200
  bind:value={configValues[key]}
212
- placeholder={String(fieldConfig.placeholder || "")}
201
+ placeholder={String(fieldConfig.placeholder || '')}
213
202
  />
214
203
  {/if}
215
204
  {#if fieldConfig.description}
@@ -1,4 +1,4 @@
1
- import type { WorkflowNode } from "../types/index.js";
1
+ import type { WorkflowNode } from '../types/index.js';
2
2
  interface Props {
3
3
  node: WorkflowNode;
4
4
  onSave: (config: Record<string, unknown>) => void;
@@ -29,10 +29,12 @@
29
29
  ) => void;
30
30
  }
31
31
 
32
- let { pipelineId, workflow, apiClient, baseUrl, endpointConfig, onActionsReady }: Props = $props();
32
+ let { pipelineId, workflow, apiClient, baseUrl, endpointConfig, onActionsReady }: Props =
33
+ $props();
33
34
 
34
35
  // Initialize API client if not provided
35
- const client = apiClient || new FlowDropApiClient(endpointConfig?.baseUrl || baseUrl || "/api/flowdrop");
36
+ const client =
37
+ apiClient || new FlowDropApiClient(endpointConfig?.baseUrl || baseUrl || '/api/flowdrop');
36
38
 
37
39
  // Pipeline status and job data
38
40
  let pipelineStatus = $state<string>('unknown');
@@ -34,11 +34,7 @@
34
34
  ConfigurationHelper
35
35
  } from '../helpers/workflowEditorHelper.js';
36
36
  import type { NodeExecutionInfo } from '../types/index.js';
37
- import {
38
- areNodeArraysEqual,
39
- areEdgeArraysEqual,
40
- throttle
41
- } from '../utils/performanceUtils.js';
37
+ import { areNodeArraysEqual, areEdgeArraysEqual, throttle } from '../utils/performanceUtils.js';
42
38
 
43
39
  interface Props {
44
40
  nodes?: NodeMetadata[];
@@ -115,7 +111,7 @@
115
111
  previousPipelineId = props.pipelineId;
116
112
 
117
113
  // Use requestIdleCallback for non-critical updates (falls back to setTimeout)
118
- if (typeof requestIdleCallback !== "undefined") {
114
+ if (typeof requestIdleCallback !== 'undefined') {
119
115
  loadExecutionInfoTimeout = requestIdleCallback(
120
116
  () => {
121
117
  loadNodeExecutionInfo();
@@ -167,7 +163,7 @@
167
163
 
168
164
  // Default execution info for nodes without data
169
165
  const defaultExecutionInfo: NodeExecutionInfo = {
170
- status: "idle" as const,
166
+ status: 'idle' as const,
171
167
  executionCount: 0,
172
168
  isExecuting: false
173
169
  };
@@ -191,8 +187,8 @@
191
187
  executionInfoAbortController = null;
192
188
  } catch (error) {
193
189
  // Only log if it's not an abort error
194
- if (error instanceof Error && error.name !== "AbortError") {
195
- console.error("Failed to load node execution info:", error);
190
+ if (error instanceof Error && error.name !== 'AbortError') {
191
+ console.error('Failed to load node execution info:', error);
196
192
  }
197
193
  }
198
194
  }
@@ -278,6 +274,23 @@
278
274
  }
279
275
  }
280
276
 
277
+ /**
278
+ * Handle node deletion - automatically remove connected edges
279
+ */
280
+ function handleNodesDelete(event: { detail: { nodes: WorkflowNodeType[] } }): void {
281
+ const deletedNodeIds = new Set(event.detail.nodes.map((node) => node.id));
282
+
283
+ // Filter out edges connected to deleted nodes
284
+ flowEdges = flowEdges.filter(
285
+ (edge) => !deletedNodeIds.has(edge.source) && !deletedNodeIds.has(edge.target)
286
+ );
287
+
288
+ // Update currentWorkflow
289
+ if (currentWorkflow) {
290
+ updateCurrentWorkflowFromSvelteFlow();
291
+ }
292
+ }
293
+
281
294
  /**
282
295
  * Update existing edges with our custom styling rules
283
296
  * This ensures all edges (including existing ones) follow our rules
@@ -344,8 +357,12 @@
344
357
  y: e.clientY - rect.top
345
358
  };
346
359
 
347
- // Create the node using the helper
348
- const newNode = NodeOperationsHelper.createNodeFromDrop(nodeTypeData, position);
360
+ // Create the node using the helper, passing existing nodes for ID generation
361
+ const newNode = NodeOperationsHelper.createNodeFromDrop(
362
+ nodeTypeData,
363
+ position,
364
+ flowNodes
365
+ );
349
366
 
350
367
  if (newNode && currentWorkflow) {
351
368
  currentWorkflow = WorkflowOperationsHelper.addNode(currentWorkflow, newNode);
@@ -367,6 +384,7 @@
367
384
  {nodeTypes}
368
385
  {defaultEdgeOptions}
369
386
  onconnect={handleConnect}
387
+ onnodesdelete={handleNodesDelete}
370
388
  minZoom={0.2}
371
389
  maxZoom={3}
372
390
  clickConnect={true}
@@ -4,6 +4,12 @@
4
4
  */
5
5
  import type { WorkflowNode as WorkflowNodeType, NodeMetadata, Workflow, WorkflowEdge, NodeExecutionInfo } from '../types/index.js';
6
6
  import type { EndpointConfig } from '../config/endpoints.js';
7
+ /**
8
+ * Generate a unique node ID based on node type and existing nodes
9
+ * Format: <node_type>.<number>
10
+ * Example: boolean_gateway.1, calculator.2
11
+ */
12
+ export declare function generateNodeId(nodeTypeId: string, existingNodes: WorkflowNodeType[]): string;
7
13
  /**
8
14
  * Edge styling configuration
9
15
  */
@@ -37,7 +43,7 @@ export declare class NodeOperationsHelper {
37
43
  static createNodeFromDrop(nodeTypeData: string, position: {
38
44
  x: number;
39
45
  y: number;
40
- }): WorkflowNodeType | null;
46
+ }, existingNodes?: WorkflowNodeType[]): WorkflowNodeType | null;
41
47
  }
42
48
  /**
43
49
  * Workflow operations helper
@@ -8,6 +8,27 @@ import { workflowApi, nodeApi, setEndpointConfig } from '../services/api.js';
8
8
  import { v4 as uuidv4 } from 'uuid';
9
9
  import { workflowActions } from '../stores/workflowStore.js';
10
10
  import { nodeExecutionService } from '../services/nodeExecutionService.js';
11
+ /**
12
+ * Generate a unique node ID based on node type and existing nodes
13
+ * Format: <node_type>.<number>
14
+ * Example: boolean_gateway.1, calculator.2
15
+ */
16
+ export function generateNodeId(nodeTypeId, existingNodes) {
17
+ // Count how many nodes of this type already exist
18
+ const existingNodeIds = existingNodes
19
+ .filter((node) => node.data?.metadata?.id === nodeTypeId)
20
+ .map((node) => node.id);
21
+ // Extract the numbers from existing IDs with the same prefix
22
+ const existingNumbers = existingNodeIds
23
+ .map((id) => {
24
+ const match = id.match(new RegExp(`^${nodeTypeId}\\.(\\d+)$`));
25
+ return match ? parseInt(match[1], 10) : 0;
26
+ })
27
+ .filter((num) => num > 0);
28
+ // Find the next available number (highest + 1)
29
+ const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
30
+ return `${nodeTypeId}.${nextNumber}`;
31
+ }
11
32
  /**
12
33
  * Edge styling configuration
13
34
  */
@@ -132,7 +153,7 @@ export class NodeOperationsHelper {
132
153
  /**
133
154
  * Create a new node from dropped data
134
155
  */
135
- static createNodeFromDrop(nodeTypeData, position) {
156
+ static createNodeFromDrop(nodeTypeData, position, existingNodes = []) {
136
157
  try {
137
158
  const parsedData = JSON.parse(nodeTypeData);
138
159
  // Handle both old format (with type: "node") and new format (direct NodeMetadata)
@@ -169,7 +190,8 @@ export class NodeOperationsHelper {
169
190
  metadata: nodeType
170
191
  };
171
192
  }
172
- const newNodeId = uuidv4();
193
+ // Generate node ID based on node type and existing nodes
194
+ const newNodeId = generateNodeId(nodeType.id, existingNodes);
173
195
  // All nodes use "universalNode" type
174
196
  // UniversalNode component handles internal switching based on metadata and config
175
197
  const newNode = {
@@ -51,10 +51,37 @@ async function apiRequest(endpointKey, endpointPath, params, options = {}) {
51
51
  headers,
52
52
  ...options
53
53
  });
54
- const data = await response.json();
54
+ // Check if response is JSON
55
+ const contentType = response.headers.get('content-type');
56
+ const isJson = contentType?.includes('application/json');
55
57
  if (!response.ok) {
56
- throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`);
58
+ // Try to get error details
59
+ let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
60
+ if (isJson) {
61
+ try {
62
+ const data = await response.json();
63
+ errorMessage = data.error || data.message || errorMessage;
64
+ }
65
+ catch {
66
+ // Failed to parse JSON, use default error message
67
+ }
68
+ }
69
+ else {
70
+ // Response is not JSON (probably HTML error page)
71
+ try {
72
+ const text = await response.text();
73
+ // Extract first 200 characters for debugging
74
+ const preview = text.substring(0, 200).trim();
75
+ errorMessage = `${errorMessage}. Server returned: ${preview}...`;
76
+ }
77
+ catch {
78
+ // Failed to read response text
79
+ }
80
+ }
81
+ throw new Error(errorMessage);
57
82
  }
83
+ // Parse successful response
84
+ const data = await response.json();
58
85
  return data;
59
86
  }
60
87
  /**
@@ -135,9 +162,16 @@ export const workflowApi = {
135
162
  if (!endpointConfig) {
136
163
  throw new Error('Endpoint configuration not set');
137
164
  }
165
+ // Transform workflow data for Drupal backend compatibility
166
+ // Drupal expects "label" instead of "name"
167
+ const drupalWorkflow = {
168
+ ...workflow,
169
+ label: workflow.name, // Map name to label for Drupal
170
+ name: workflow.name // Keep name as well for compatibility
171
+ };
138
172
  const response = await apiRequest('workflows.create', endpointConfig.endpoints.workflows.create, undefined, {
139
173
  method: 'POST',
140
- body: JSON.stringify(workflow)
174
+ body: JSON.stringify(drupalWorkflow)
141
175
  });
142
176
  if (!response.data) {
143
177
  throw new Error('Failed to create workflow');
@@ -151,9 +185,18 @@ export const workflowApi = {
151
185
  if (!endpointConfig) {
152
186
  throw new Error('Endpoint configuration not set');
153
187
  }
188
+ // Transform workflow data for Drupal backend compatibility
189
+ // Drupal expects "label" instead of "name"
190
+ const drupalWorkflow = workflow.name
191
+ ? {
192
+ ...workflow,
193
+ label: workflow.name, // Map name to label for Drupal
194
+ name: workflow.name // Keep name as well for compatibility
195
+ }
196
+ : workflow;
154
197
  const response = await apiRequest('workflows.update', endpointConfig.endpoints.workflows.update, { id }, {
155
198
  method: 'PUT',
156
- body: JSON.stringify(workflow)
199
+ body: JSON.stringify(drupalWorkflow)
157
200
  });
158
201
  if (!response.data) {
159
202
  throw new Error('Failed to update workflow');
@@ -2,7 +2,7 @@
2
2
  * Performance Utilities
3
3
  * Helper functions for optimizing performance in the FlowDrop app
4
4
  */
5
- import type { WorkflowNode, WorkflowEdge } from "../types/index.js";
5
+ import type { WorkflowNode, WorkflowEdge } from '../types/index.js';
6
6
  /**
7
7
  * Fast shallow comparison for workflow nodes
8
8
  * Avoids expensive JSON.stringify operations
@@ -16,8 +16,7 @@ export function areNodeArraysEqual(nodes1, nodes2) {
16
16
  if (node1?.id !== node2?.id)
17
17
  return false;
18
18
  // Check position (most common change during drag)
19
- if (node1?.position?.x !== node2?.position?.x ||
20
- node1?.position?.y !== node2?.position?.y) {
19
+ if (node1?.position?.x !== node2?.position?.x || node1?.position?.y !== node2?.position?.y) {
21
20
  return false;
22
21
  }
23
22
  // Check selected state
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@d34dman/flowdrop",
3
3
  "license": "MIT",
4
4
  "private": false,
5
- "version": "0.0.12",
5
+ "version": "0.0.13",
6
6
  "scripts": {
7
7
  "dev": "vite dev",
8
8
  "build": "vite build && npm run prepack",