@d34dman/flowdrop 0.0.12 → 0.0.14

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;
@@ -0,0 +1,68 @@
1
+ <!--
2
+ Flow Drop Zone Component
3
+ Handles drag and drop with proper coordinate transformation
4
+ Must be used inside SvelteFlowProvider
5
+ -->
6
+
7
+ <script lang="ts">
8
+ import { useSvelteFlow } from "@xyflow/svelte";
9
+ import type { Snippet } from "svelte";
10
+
11
+ interface Props {
12
+ ondrop: (nodeTypeData: string, position: { x: number; y: number }) => void;
13
+ children: Snippet;
14
+ }
15
+
16
+ let props: Props = $props();
17
+
18
+ // Access SvelteFlow instance for coordinate transformation
19
+ const { screenToFlowPosition } = useSvelteFlow();
20
+
21
+ /**
22
+ * Handle drag over event
23
+ */
24
+ function handleDragOver(e: DragEvent): void {
25
+ e.preventDefault();
26
+ if (e.dataTransfer) {
27
+ e.dataTransfer.dropEffect = "copy";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Handle drop event with proper coordinate transformation
33
+ */
34
+ function handleDrop(e: DragEvent): void {
35
+ e.preventDefault();
36
+
37
+ // Get the data from the drag event
38
+ const nodeTypeData = e.dataTransfer?.getData("application/json");
39
+ if (nodeTypeData) {
40
+ // Convert screen coordinates to flow coordinates (accounts for zoom and pan)
41
+ const position = screenToFlowPosition({
42
+ x: e.clientX,
43
+ y: e.clientY
44
+ });
45
+
46
+ // Call the parent handler with the converted position
47
+ props.ondrop(nodeTypeData, position);
48
+ }
49
+ }
50
+ </script>
51
+
52
+ <div
53
+ class="flow-drop-zone"
54
+ role="application"
55
+ aria-label="Workflow canvas"
56
+ ondragover={handleDragOver}
57
+ ondrop={handleDrop}
58
+ >
59
+ {@render props.children()}
60
+ </div>
61
+
62
+ <style>
63
+ .flow-drop-zone {
64
+ width: 100%;
65
+ height: 100%;
66
+ }
67
+ </style>
68
+
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from "svelte";
2
+ interface Props {
3
+ ondrop: (nodeTypeData: string, position: {
4
+ x: number;
5
+ y: number;
6
+ }) => void;
7
+ children: Snippet;
8
+ }
9
+ declare const FlowDropZone: import("svelte").Component<Props, {}, "">;
10
+ type FlowDropZone = ReturnType<typeof FlowDropZone>;
11
+ export default FlowDropZone;
@@ -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');
@@ -22,6 +22,7 @@
22
22
  WorkflowEdge
23
23
  } from '../types/index.js';
24
24
  import CanvasBanner from './CanvasBanner.svelte';
25
+ import FlowDropZone from './FlowDropZone.svelte';
25
26
  import { tick } from 'svelte';
26
27
  import type { EndpointConfig } from '../config/endpoints.js';
27
28
  import ConnectionLine from './ConnectionLine.svelte';
@@ -34,11 +35,7 @@
34
35
  ConfigurationHelper
35
36
  } from '../helpers/workflowEditorHelper.js';
36
37
  import type { NodeExecutionInfo } from '../types/index.js';
37
- import {
38
- areNodeArraysEqual,
39
- areEdgeArraysEqual,
40
- throttle
41
- } from '../utils/performanceUtils.js';
38
+ import { areNodeArraysEqual, areEdgeArraysEqual, throttle } from '../utils/performanceUtils.js';
42
39
 
43
40
  interface Props {
44
41
  nodes?: NodeMetadata[];
@@ -115,7 +112,7 @@
115
112
  previousPipelineId = props.pipelineId;
116
113
 
117
114
  // Use requestIdleCallback for non-critical updates (falls back to setTimeout)
118
- if (typeof requestIdleCallback !== "undefined") {
115
+ if (typeof requestIdleCallback !== 'undefined') {
119
116
  loadExecutionInfoTimeout = requestIdleCallback(
120
117
  () => {
121
118
  loadNodeExecutionInfo();
@@ -167,7 +164,7 @@
167
164
 
168
165
  // Default execution info for nodes without data
169
166
  const defaultExecutionInfo: NodeExecutionInfo = {
170
- status: "idle" as const,
167
+ status: 'idle' as const,
171
168
  executionCount: 0,
172
169
  isExecuting: false
173
170
  };
@@ -191,8 +188,8 @@
191
188
  executionInfoAbortController = null;
192
189
  } catch (error) {
193
190
  // 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);
191
+ if (error instanceof Error && error.name !== 'AbortError') {
192
+ console.error('Failed to load node execution info:', error);
196
193
  }
197
194
  }
198
195
  }
@@ -278,6 +275,23 @@
278
275
  }
279
276
  }
280
277
 
278
+ /**
279
+ * Handle node deletion - automatically remove connected edges
280
+ */
281
+ function handleNodesDelete(params: { nodes: WorkflowNodeType[]; edges: WorkflowEdge[] }): void {
282
+ const deletedNodeIds = new Set(params.nodes.map((node) => node.id));
283
+
284
+ // Filter out edges connected to deleted nodes
285
+ flowEdges = flowEdges.filter(
286
+ (edge) => !deletedNodeIds.has(edge.source) && !deletedNodeIds.has(edge.target)
287
+ );
288
+
289
+ // Update currentWorkflow
290
+ if (currentWorkflow) {
291
+ updateCurrentWorkflowFromSvelteFlow();
292
+ }
293
+ }
294
+
281
295
  /**
282
296
  * Update existing edges with our custom styling rules
283
297
  * This ensures all edges (including existing ones) follow our rules
@@ -316,6 +330,30 @@
316
330
  function checkWorkflowCycles(): boolean {
317
331
  return WorkflowOperationsHelper.checkWorkflowCycles(flowNodes, flowEdges);
318
332
  }
333
+
334
+ /**
335
+ * Handle drop event and add new node to canvas
336
+ * This will be called from the inner DropZone component
337
+ */
338
+ async function handleNodeDrop(
339
+ nodeTypeData: string,
340
+ position: { x: number; y: number }
341
+ ): Promise<void> {
342
+ // Create the node using the helper, passing existing nodes for ID generation
343
+ const newNode = NodeOperationsHelper.createNodeFromDrop(nodeTypeData, position, flowNodes);
344
+
345
+ if (newNode && currentWorkflow) {
346
+ currentWorkflow = WorkflowOperationsHelper.addNode(currentWorkflow, newNode);
347
+
348
+ // Update the global store
349
+ updateGlobalStore();
350
+
351
+ // Wait for DOM update to ensure SvelteFlow updates
352
+ await tick();
353
+ } else if (!currentWorkflow) {
354
+ console.warn('No currentWorkflow available for new node');
355
+ }
356
+ }
319
357
  </script>
320
358
 
321
359
  <SvelteFlowProvider>
@@ -323,75 +361,41 @@
323
361
  <!-- Main Editor Area -->
324
362
  <div class="flowdrop-workflow-editor__main">
325
363
  <!-- Flow Canvas -->
326
- <div
327
- class="flowdrop-canvas"
328
- role="application"
329
- aria-label="Workflow canvas"
330
- ondragover={(e: DragEvent) => {
331
- e.preventDefault();
332
- e.dataTransfer!.dropEffect = 'copy';
333
- }}
334
- ondrop={async (e: DragEvent) => {
335
- e.preventDefault();
336
-
337
- // Get the data from the drag event
338
- const nodeTypeData = e.dataTransfer?.getData('application/json');
339
- if (nodeTypeData) {
340
- // Get the position relative to the canvas
341
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
342
- const position = {
343
- x: e.clientX - rect.left,
344
- y: e.clientY - rect.top
345
- };
346
-
347
- // Create the node using the helper
348
- const newNode = NodeOperationsHelper.createNodeFromDrop(nodeTypeData, position);
349
-
350
- if (newNode && currentWorkflow) {
351
- currentWorkflow = WorkflowOperationsHelper.addNode(currentWorkflow, newNode);
352
-
353
- // Update the global store
354
- updateGlobalStore();
355
-
356
- // Wait for DOM update to ensure SvelteFlow updates
357
- await tick();
358
- } else if (!currentWorkflow) {
359
- console.warn('No currentWorkflow available for new node');
360
- }
361
- }
362
- }}
363
- >
364
- <SvelteFlow
365
- bind:nodes={flowNodes}
366
- bind:edges={flowEdges}
367
- {nodeTypes}
368
- {defaultEdgeOptions}
369
- onconnect={handleConnect}
370
- minZoom={0.2}
371
- maxZoom={3}
372
- clickConnect={true}
373
- elevateEdgesOnSelect={true}
374
- connectionLineType={ConnectionLineType.Bezier}
375
- connectionLineComponent={ConnectionLine}
376
- snapGrid={[10, 10]}
377
- fitView
378
- >
379
- <Controls />
380
- <Background
381
- gap={10}
382
- bgColor="var(--flowdrop-background-color)"
383
- variant={BackgroundVariant.Dots}
384
- />
385
- <MiniMap />
386
- </SvelteFlow>
387
- <!-- Drop Zone Indicator -->
388
- {#if flowNodes.length === 0}
389
- <CanvasBanner
390
- title="Drag components here to start building"
391
- description="Use the sidebar to add components to your workflow"
392
- iconName="mdi:graph"
393
- />
394
- {/if}
364
+ <div class="flowdrop-canvas">
365
+ <FlowDropZone ondrop={handleNodeDrop}>
366
+ <SvelteFlow
367
+ bind:nodes={flowNodes}
368
+ bind:edges={flowEdges}
369
+ {nodeTypes}
370
+ {defaultEdgeOptions}
371
+ onconnect={handleConnect}
372
+ ondelete={handleNodesDelete}
373
+ minZoom={0.2}
374
+ maxZoom={3}
375
+ clickConnect={true}
376
+ elevateEdgesOnSelect={true}
377
+ connectionLineType={ConnectionLineType.Bezier}
378
+ connectionLineComponent={ConnectionLine}
379
+ snapGrid={[10, 10]}
380
+ fitView
381
+ >
382
+ <Controls />
383
+ <Background
384
+ gap={10}
385
+ bgColor="var(--flowdrop-background-color)"
386
+ variant={BackgroundVariant.Dots}
387
+ />
388
+ <MiniMap />
389
+ </SvelteFlow>
390
+ <!-- Drop Zone Indicator -->
391
+ {#if flowNodes.length === 0}
392
+ <CanvasBanner
393
+ title="Drag components here to start building"
394
+ description="Use the sidebar to add components to your workflow"
395
+ iconName="mdi:graph"
396
+ />
397
+ {/if}
398
+ </FlowDropZone>
395
399
  </div>
396
400
 
397
401
  <!-- Status Bar -->
@@ -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.14",
6
6
  "scripts": {
7
7
  "dev": "vite dev",
8
8
  "build": "vite build && npm run prepack",