=> {
+ if (!instanceId || !request || !connectionId) return [];
+ const r = await fetchBrowse(request, instanceId, connectionId, 'clickup', pathToLoad);
+ return r?.items ?? [];
+ },
+ [instanceId, request, connectionId]
+ );
+
+ const selectPath = useCallback(
+ (p: string) => {
+ updateParam('path', p);
+ setBrowseOpen(false);
+ },
+ [updateParam]
+ );
+
+ const setCustomField = useCallback(
+ (fieldId: string, v: unknown) => {
+ updateParam('customFieldValues', { ...customFieldValues, [fieldId]: v });
+ },
+ [customFieldValues, updateParam]
+ );
+
+ const showBrowse = connectionId && (isListPicker(nodeType) || isTaskPicker(nodeType));
+
+ return (
+ <>
+
+ Connection (ClickUp)
+ updateParam('connectionId', e.target.value)}
+ disabled={connectionsLoading}
+ >
+ {connectionsLoading ? 'Loading...' : 'Select connection'}
+ {clickupConnections.map((c) => (
+
+ {c.externalUsername ?? c.id}
+
+ ))}
+
+
+
+ {nodeType === 'clickup.searchTasks' && (
+ <>
+
+ Workspace
+ {
+ const v = e.target.value;
+ updateParam('teamId', v);
+ updateParam('listId', '');
+ }}
+ disabled={searchTeamsLoading || !connectionId}
+ >
+
+ {searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
+
+ {searchTeams.map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+ Liste (Tabelle)
+ updateParam('listId', e.target.value)}
+ disabled={!teamIdParam || searchListsLoading}
+ >
+
+ {searchListsLoading
+ ? 'Listen werden geladen…'
+ : teamIdParam
+ ? 'Alle Listen im Workspace'
+ : 'Zuerst Workspace wählen'}
+
+ {searchLists.map((L) => (
+
+ {L.name}
+
+ ))}
+
+
+
+ Suchbegriff
+ updateParam('query', e.target.value)}
+ placeholder="Stichwort für die Aufgabensuche"
+ />
+
+
+
+ Erweitert
+
+
+
+ >
+ )}
+
+ {nodeType === 'clickup.createTask' && (
+ <>
+
+ Workspace
+ {
+ const v = e.target.value;
+ updateParam('teamId', v);
+ updateParam('listId', '');
+ updateParam('path', '');
+ updateParam('customFieldValues', {});
+ updateParam('taskStatus', '');
+ updateParam('taskAssigneeIds', []);
+ }}
+ disabled={searchTeamsLoading || !connectionId}
+ >
+
+ {searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
+
+ {searchTeams.map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+ Liste
+ {
+ const lid = e.target.value;
+ updateParam('listId', lid);
+ updateParam('customFieldValues', {});
+ updateParam('taskStatus', '');
+ if (teamIdParam && lid) {
+ updateParam('path', `/team/${teamIdParam}/list/${lid}`);
+ } else {
+ updateParam('path', '');
+ }
+ }}
+ disabled={!teamIdParam || searchListsLoading}
+ >
+ {searchListsLoading ? 'Listen werden geladen…' : 'Liste wählen…'}
+ {searchLists.map((L) => (
+
+ {L.name}
+
+ ))}
+
+
+
+
+ {mergeNodeParameters && teamIdParam && listIdParam ? (
+
+
+ Formular mit dieser Liste abgleichen
+
+
+ Sucht den nächsten Formular -Knoten (input.form oder{' '}
+ trigger.form) stromaufwärts, legt dessen Felder wie die ClickUp-Liste an und
+ setzt hier die Datenquellen (payload.…) auf dieses Formular. Anschließend
+ können Sie Felder im Formular-Knoten anpassen.
+
+
+ updateParam('autoSyncFormWithList', e.target.checked)}
+ />
+ Bei Listenwahl automatisch abgleichen
+
+
runFormSyncFromList(true)}
+ disabled={listFieldsLoading}
+ >
+ Jetzt Formular & Referenzen setzen
+
+ {!dataFlow ||
+ !findClosestUpstreamFormNode(
+ dataFlow.currentNodeId,
+ dataFlow.nodes,
+ dataFlow.connections
+ ) ? (
+
+ Kein Formular-Knoten stromaufwärts verbunden — zuerst ein Formular vor diesen Node ziehen und
+ verbinden.
+
+ ) : null}
+
+ ) : null}
+
+ {teamIdParam && listIdParam ? (
+
+
+ Standardfelder (wie in ClickUp)
+
+
+ Status, Priorität, Fälligkeit, Zuweisungen und Zeitschätzung — dieselben Spalten wie in der
+ Listenansicht (nicht die benutzerdefinierten Felder darunter).
+
+
+
+ Status
+ {listStatusesLoading ? (
+ Status wird geladen…
+ ) : (
+ updateParam('taskStatus', e.target.value)}
+ >
+ — Standard (ClickUp) —
+ {listStatuses.map((s) => (
+
+ {s.status}
+
+ ))}
+
+ )}
+
+
+
+ Priorität
+ updateParam('taskPriority', e.target.value)}
+ >
+ — keine —
+ 1 — Dringend
+ 2 — Hoch
+ 3 — Normal
+ 4 — Niedrig
+
+
+
+
+ updateParam('taskDueDateMs', v)}
+ />
+
+
+
+
Zugewiesene
+ {teamMembersLoading ? (
+
Mitglieder werden geladen…
+ ) : teamMembers.length === 0 ? (
+
Keine Mitglieder geladen.
+ ) : (
+
+ {teamMembers.map((m) => (
+
+ {
+ const set = new Set(taskAssigneeIds);
+ if (set.has(m.id)) set.delete(m.id);
+ else set.add(m.id);
+ updateParam('taskAssigneeIds', [...set]);
+ }}
+ />
+ {m.username}
+
+ ))}
+
+ )}
+
+
+
+ {
+ updateParam('taskTimeEstimateHours', v);
+ updateParam('taskTimeEstimateMs', createValue(''));
+ }}
+ />
+
+
+ ) : null}
+
+ {listIdParam ? (
+
+
Felder der Liste
+ {listFieldsLoading ? (
+
Felder werden geladen…
+ ) : listFields.length === 0 ? (
+
+ Keine benutzerdefinierten Felder oder keine Berechtigung.
+
+ ) : (
+ listFields.map((f) => {
+ const id = String(f.id ?? '');
+ if (!id) return null;
+ return (
+
setCustomField(id, v)}
+ connectionId={connectionId}
+ request={request}
+ parentListId={listIdParam}
+ />
+ );
+ })
+ )}
+
+ ) : null}
+
+
+
+ Erweitert (JSON)
+
+
+ Zusätzliche Felder (JSON)
+
+
+ >
+ )}
+
+ {nodeType === 'clickup.listTasks' && (
+ <>
+
+ List path
+ updateParam('path', e.target.value)}
+ placeholder="/team/{teamId}/list/{listId}"
+ />
+
+
+
+ updateParam('includeClosed', e.target.checked)}
+ />{' '}
+ Include closed tasks
+
+
+ >
+ )}
+
+ {(nodeType === 'clickup.getTask' ||
+ nodeType === 'clickup.updateTask' ||
+ nodeType === 'clickup.uploadAttachment') && (
+ <>
+ {nodeType === 'clickup.updateTask' &&
+ !isRef(params.taskId) &&
+ connectionId &&
+ listIdParam &&
+ request ? (
+ updateParam('taskId', v)}
+ />
+ ) : null}
+ updateParam('taskId', v)}
+ placeholder="Referenz: voriger Knoten „Aufgabe erstellen“ → taskId"
+ pathPickMode={nodeType === 'clickup.updateTask' ? 'clickup_task_id' : 'default'}
+ />
+
+ Path (optional)
+ updateParam('path', e.target.value)}
+ placeholder=".../task/{taskId}"
+ />
+
+ >
+ )}
+
+ {nodeType === 'clickup.updateTask' && (
+
+
+ Liste für Status & Felder
+
+
+ Ohne diese Auswahl bleiben die Status leer, es sei denn, die Task-ID ist gesetzt und die
+ Vorschau liefert eine gültige ClickUp-Aufgaben-ID — dann wird die Liste aus der Aufgabe
+ ermittelt.
+
+
+ Workspace
+ {
+ const v = e.target.value;
+ updateParam('teamId', v);
+ updateParam('listId', '');
+ updateParam('path', '');
+ }}
+ disabled={searchTeamsLoading || !connectionId}
+ >
+
+ {searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
+
+ {searchTeams.map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+ Liste
+ {
+ const lid = e.target.value;
+ updateParam('listId', lid);
+ if (teamIdParam && lid) {
+ updateParam('path', `/team/${teamIdParam}/list/${lid}`);
+ } else {
+ updateParam('path', '');
+ }
+ }}
+ disabled={!teamIdParam || searchListsLoading}
+ >
+ {searchListsLoading ? 'Listen werden geladen…' : 'Liste wählen…'}
+ {searchLists.map((L) => (
+
+ {L.name}
+
+ ))}
+
+
+
+ )}
+
+ {nodeType === 'clickup.updateTask' && request && (
+ <>
+ updateParam('taskUpdateEntries', next)}
+ connectionId={connectionId}
+ listId={effectiveListIdForStatuses}
+ pathForListId={path}
+ request={request}
+ taskIdParam={params.taskId}
+ teamMembers={teamMembers}
+ teamMembersLoading={teamMembersLoading}
+ parentListStatuses={listStatuses}
+ parentListStatusesLoading={listStatusesLoading}
+ />
+
+
+ Erweitert: JSON (optional, wird mit den Zeilen zusammengeführt)
+
+
+ taskUpdate (JSON)
+
+
+ >
+ )}
+
+ {nodeType === 'clickup.uploadAttachment' && (
+
+ File name (optional)
+ updateParam('fileName', e.target.value)}
+ placeholder="report.pdf"
+ />
+
+ )}
+
+ {showBrowse && (
+ setBrowseOpen((e.target as HTMLDetailsElement).open)}
+ style={browseDetailsStyle}
+ >
+ {browseTitle(nodeType)}
+
+ {}}
+ onSelectFolder={isListPicker(nodeType) ? selectPath : undefined}
+ foldersOnly={isListPicker(nodeType)}
+ selectedPath={path || null}
+ />
+
+
+ )}
+ >
+ );
+};
diff --git a/src/components/Automation2FlowEditor/configs/CommentNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/CommentNodeConfig.tsx
similarity index 100%
rename from src/components/Automation2FlowEditor/configs/CommentNodeConfig.tsx
rename to src/components/Automation2FlowEditor/nodes/configs/CommentNodeConfig.tsx
diff --git a/src/components/Automation2FlowEditor/configs/ConfirmationNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/ConfirmationNodeConfig.tsx
similarity index 100%
rename from src/components/Automation2FlowEditor/configs/ConfirmationNodeConfig.tsx
rename to src/components/Automation2FlowEditor/nodes/configs/ConfirmationNodeConfig.tsx
diff --git a/src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/EmailNodeConfig.tsx
similarity index 99%
rename from src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
rename to src/components/Automation2FlowEditor/nodes/configs/EmailNodeConfig.tsx
index eb18827..f0303c2 100644
--- a/src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
+++ b/src/components/Automation2FlowEditor/nodes/configs/EmailNodeConfig.tsx
@@ -4,7 +4,7 @@
import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
-import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
+import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
export const EmailNodeConfig: React.FC = ({
params,
diff --git a/src/components/Automation2FlowEditor/nodes/configs/FileCreateNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/FileCreateNodeConfig.tsx
new file mode 100644
index 0000000..bf6fec6
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/configs/FileCreateNodeConfig.tsx
@@ -0,0 +1,121 @@
+/**
+ * File Create node config - multiple content sources, output format, title, template, language.
+ * Contents are concatenated in order (nacheinander geschrieben).
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+import { RefSourceSelect } from '../shared/RefSourceSelect';
+import { isRef, type DataRef } from '../shared/dataRef';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+const OUTPUT_FORMATS = ['docx', 'pdf', 'txt', 'md', 'html', 'xlsx', 'csv', 'json'];
+const TEMPLATE_OPTIONS = ['default', 'corporate', 'minimal'];
+const LANGUAGES = ['de', 'en', 'fr', 'it', 'es'];
+
+function normalizeContentSources(v: unknown): (DataRef | null)[] {
+ if (Array.isArray(v)) {
+ return v.map((x) => (isRef(x) ? x : null));
+ }
+ if (isRef(v)) return [v];
+ return [];
+}
+
+export const FileCreateNodeConfig: React.FC = ({ params, updateParam }) => {
+ const contentSources = normalizeContentSources(params.contentSources ?? params.contentSource ?? []);
+
+ const setContentSources = (next: (DataRef | null)[]) => {
+ updateParam('contentSources', next);
+ if (params.contentSource !== undefined) updateParam('contentSource', undefined);
+ };
+
+ const setItem = (index: number, ref: DataRef | null) => {
+ const next = [...contentSources];
+ next[index] = ref;
+ setContentSources(next);
+ };
+
+ const addItem = () => setContentSources([...contentSources, null]);
+ const removeItem = (index: number) => setContentSources(contentSources.filter((_, i) => i !== index));
+
+ return (
+ <>
+
+
Inhalte (welche Kontexte nacheinander in die Datei?)
+ {contentSources.map((ref, i) => (
+
+ setItem(i, r)}
+ placeholder="Quelle wählen…"
+ />
+ removeItem(i)}
+ title="Entfernen"
+ aria-label="Inhalt entfernen"
+ >
+ ×
+
+
+ ))}
+
+ + Inhalt hinzufügen
+
+ {contentSources.length === 0 && (
+
+ Leer = Kontext vom verbundenen Node. Fügen Sie Inhalte hinzu, um mehrere Quellen zu kombinieren.
+
+ )}
+
+
+ Ausgabeformat
+ updateParam('outputFormat', e.target.value)}
+ >
+ {OUTPUT_FORMATS.map((opt) => (
+
+ {opt}
+
+ ))}
+
+
+
+ Titel
+ updateParam('title', e.target.value)}
+ placeholder="Dokumenttitel"
+ />
+
+
+ Vorlage / Stil
+ updateParam('templateName', e.target.value)}
+ >
+ {TEMPLATE_OPTIONS.map((opt) => (
+
+ {opt}
+
+ ))}
+
+
+
+ Sprache
+ updateParam('language', e.target.value)}
+ >
+ {LANGUAGES.map((opt) => (
+
+ {opt}
+
+ ))}
+
+
+ >
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/configs/ReviewNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/ReviewNodeConfig.tsx
new file mode 100644
index 0000000..20d0651
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/configs/ReviewNodeConfig.tsx
@@ -0,0 +1,18 @@
+/**
+ * Review node config - content reference supports static value or node reference.
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+import { DynamicValueField } from '../shared/DynamicValueField';
+
+export const ReviewNodeConfig: React.FC = ({ params, updateParam }) => (
+
+);
diff --git a/src/components/Automation2FlowEditor/configs/SelectionNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/SelectionNodeConfig.tsx
similarity index 100%
rename from src/components/Automation2FlowEditor/configs/SelectionNodeConfig.tsx
rename to src/components/Automation2FlowEditor/nodes/configs/SelectionNodeConfig.tsx
diff --git a/src/components/Automation2FlowEditor/nodes/configs/SharePointNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/SharePointNodeConfig.tsx
new file mode 100644
index 0000000..7065ee0
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/configs/SharePointNodeConfig.tsx
@@ -0,0 +1,340 @@
+/**
+ * SharePoint node config — connection selector, paths, search.
+ * All nodes use SharepointBrowseTree with the selected connection (fetchBrowse + onLoadChildren).
+ * Folder-style nodes (list, upload target, copy destination): folders only, folder selection.
+ * File-style nodes (read, download, find path, copy source): file selection; folders expand only.
+ */
+
+import React, { useEffect, useState, useCallback } from 'react';
+import type { NodeConfigRendererProps } from './types';
+import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
+import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
+
+const browseDetailsStyle: React.CSSProperties = {
+ marginTop: 12,
+ border: '1px solid var(--border-color, #e0e0e0)',
+ borderRadius: 6,
+ background: 'var(--bg-secondary, #f8f9fa)',
+ overflow: 'hidden',
+};
+
+const browseSummaryStyle: React.CSSProperties = {
+ padding: '0.5rem 0.75rem',
+ cursor: 'pointer',
+ fontWeight: 500,
+ fontSize: '0.875rem',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ userSelect: 'none',
+};
+
+const browseBodyStyle: React.CSSProperties = {
+ padding: '0.5rem 0.75rem',
+ borderTop: '1px solid var(--border-color, #e0e0e0)',
+ maxHeight: 280,
+ overflowY: 'auto',
+};
+
+function browsePanelTitle(nodeType: string): string {
+ switch (nodeType) {
+ case 'sharepoint.uploadFile':
+ return 'Zielordner durchsuchen';
+ case 'sharepoint.listFiles':
+ return 'Ordner durchsuchen';
+ case 'sharepoint.readFile':
+ return 'Datei auswählen';
+ case 'sharepoint.downloadFile':
+ return 'Datei auswählen';
+ case 'sharepoint.findFile':
+ return 'Pfad aus Bibliothek wählen';
+ default:
+ return 'SharePoint durchsuchen';
+ }
+}
+
+/** Folder / location pickers — tree shows folders only; selecting sets folder path. */
+function isFolderPickerNode(nodeType: string): boolean {
+ return nodeType === 'sharepoint.uploadFile' || nodeType === 'sharepoint.listFiles';
+}
+
+export const SharePointNodeConfig: React.FC = ({
+ params,
+ updateParam,
+ instanceId,
+ request,
+ nodeType = 'sharepoint.findFile',
+}) => {
+ const [connections, setConnections] = useState([]);
+ const [browseExpanded, setBrowseExpanded] = useState(false);
+ const [findFileBrowseExpanded, setFindFileBrowseExpanded] = useState(false);
+ const [copySourceExpanded, setCopySourceExpanded] = useState(false);
+ const [copyDestExpanded, setCopyDestExpanded] = useState(false);
+ const [connectionsLoading, setConnectionsLoading] = useState(false);
+
+ const connectionId = (params.connectionId as string) ?? '';
+ const path =
+ (params.path as string) ?? (params.filePath as string) ?? '';
+
+ useEffect(() => {
+ if (instanceId && request) {
+ setConnectionsLoading(true);
+ fetchConnections(request, instanceId)
+ .then(setConnections)
+ .catch(() => setConnections([]))
+ .finally(() => setConnectionsLoading(false));
+ }
+ }, [instanceId, request]);
+
+ const loadChildren = useCallback(
+ async (pathToLoad: string): Promise => {
+ if (!instanceId || !request || !connectionId) return [];
+ const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
+ return r?.items ?? [];
+ },
+ [instanceId, request, connectionId]
+ );
+
+ const selectPath = useCallback(
+ (p: string) => {
+ updateParam('path', p);
+ setBrowseExpanded(false);
+ },
+ [updateParam]
+ );
+
+ const selectSearchQueryFromFile = useCallback(
+ (p: string) => {
+ updateParam('searchQuery', p);
+ setFindFileBrowseExpanded(false);
+ },
+ [updateParam]
+ );
+
+ const selectSourcePath = useCallback(
+ (p: string) => {
+ updateParam('sourcePath', p);
+ setCopySourceExpanded(false);
+ },
+ [updateParam]
+ );
+
+ const selectDestPath = useCallback(
+ (p: string) => {
+ updateParam('destPath', p);
+ setCopyDestExpanded(false);
+ },
+ [updateParam]
+ );
+
+ const needsSearch = nodeType === 'sharepoint.findFile';
+ const needsSiteId = false;
+
+ const showPathFieldsForList =
+ nodeType === 'sharepoint.listFiles';
+ const showPathFieldsForFileUploadDownload =
+ nodeType === 'sharepoint.readFile' ||
+ nodeType === 'sharepoint.uploadFile' ||
+ nodeType === 'sharepoint.downloadFile';
+
+ /** Path + browse (same tree wiring) for these types — not copyFile (copy uses its own trees). */
+ const showStandardPathBrowse =
+ connectionId &&
+ (showPathFieldsForList || showPathFieldsForFileUploadDownload);
+
+ const showFindFileBrowse = connectionId && needsSearch;
+
+ return (
+ <>
+
+ Connection
+ updateParam('connectionId', e.target.value)}
+ disabled={connectionsLoading}
+ >
+ {connectionsLoading ? 'Loading...' : 'Select connection'}
+ {connections.map((c) => (
+
+ {c.externalUsername ?? c.id}
+
+ ))}
+
+
+
+ {needsSearch && (
+
+ Search query / path
+ updateParam('searchQuery', e.target.value)}
+ placeholder="/sites/SiteName/Shared Documents or search term"
+ />
+
+ )}
+
+ {showPathFieldsForList && (
+
+ Folder path
+ updateParam('path', e.target.value)}
+ placeholder="/ or /sites/SiteName/Shared Documents/Folder"
+ />
+
+ )}
+
+ {showPathFieldsForFileUploadDownload && (
+
+
+ {nodeType === 'sharepoint.uploadFile'
+ ? 'Target folder path'
+ : nodeType === 'sharepoint.downloadFile'
+ ? 'File path'
+ : 'Path'}
+
+ updateParam('path', e.target.value)}
+ placeholder={
+ nodeType === 'sharepoint.downloadFile'
+ ? '/sites/SiteName/Shared Documents/file.pdf'
+ : nodeType === 'sharepoint.uploadFile'
+ ? '/sites/.../Shared Documents/TargetFolder/'
+ : 'File path'
+ }
+ />
+
+ )}
+
+ {needsSiteId && (
+
+ Site ID
+ updateParam('siteId', e.target.value)}
+ placeholder="SharePoint site ID"
+ />
+
+ )}
+
+ {nodeType === 'sharepoint.copyFile' && (
+ <>
+
+ Source file
+ updateParam('sourcePath', e.target.value)}
+ placeholder="/sites/.../folder/file.pdf"
+ />
+
+
+ Destination folder
+ updateParam('destPath', e.target.value)}
+ placeholder="/sites/.../target-folder/"
+ />
+
+ {connectionId && (
+ <>
+ setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
+ style={browseDetailsStyle}
+ >
+
+ 📂
+ Quelldatei durchsuchen
+
+
+
+
+
+ setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
+ style={{ ...browseDetailsStyle, marginTop: 8 }}
+ >
+
+ 📂
+ Zielordner durchsuchen
+
+
+ {}}
+ onSelectFolder={selectDestPath}
+ selectedPath={(params.destPath as string) || null}
+ />
+
+
+ >
+ )}
+ >
+ )}
+
+ {showStandardPathBrowse && (
+ setBrowseExpanded((e.target as HTMLDetailsElement).open)}
+ style={browseDetailsStyle}
+ >
+
+ 📂
+ {browsePanelTitle(nodeType)}
+
+
+ {isFolderPickerNode(nodeType) && (
+ {}}
+ onSelectFolder={selectPath}
+ selectedPath={path || null}
+ />
+ )}
+ {(nodeType === 'sharepoint.readFile' || nodeType === 'sharepoint.downloadFile') && (
+
+ )}
+
+
+ )}
+
+ {showFindFileBrowse && (
+ setFindFileBrowseExpanded((e.target as HTMLDetailsElement).open)}
+ style={browseDetailsStyle}
+ >
+
+ 📂
+ {browsePanelTitle('sharepoint.findFile')}
+
+
+
+
+
+ )}
+ >
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/configs/UploadNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/configs/UploadNodeConfig.tsx
new file mode 100644
index 0000000..e5c6bcd
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/configs/UploadNodeConfig.tsx
@@ -0,0 +1,80 @@
+/**
+ * Upload node config – allowed file types (multi-select), max size, multiple files.
+ * Uses shared fileTypeMimeMapping for option definitions.
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+import { getAcceptValues, parseAllowedTypes } from '../runtime/fileTypeMimeMapping';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+function buildAcceptString(allowedTypes: string[]): string {
+ if (allowedTypes.length === 0) return '';
+ return allowedTypes.join(',');
+}
+
+/** Get HTML accept string from node config (for file input). */
+export function getAcceptStringFromConfig(config: Record): string {
+ const types = parseAllowedTypes(config);
+ return buildAcceptString(types);
+}
+
+const FILE_TYPE_CHIP_OPTIONS = getAcceptValues();
+
+export const UploadNodeConfig: React.FC = ({ params, updateParam }) => {
+ const allowedTypes = parseAllowedTypes(params);
+ const maxSize = (params.maxSize as number) ?? 10;
+ const multiple = (params.multiple as boolean) ?? false;
+
+ const toggleType = (value: string) => {
+ const next = allowedTypes.includes(value)
+ ? allowedTypes.filter((v) => v !== value)
+ : [...allowedTypes, value];
+ updateParam('allowedTypes', next);
+ updateParam('accept', next.length ? buildAcceptString(next) : ''); // legacy compat for backend
+ };
+
+ return (
+
+
+
Erlaubte Dateitypen
+
+ Mehrfachauswahl möglich. Keine Auswahl = alle Typen erlaubt.
+
+
+ {FILE_TYPE_CHIP_OPTIONS.map((opt) => (
+
+ toggleType(opt.value)}
+ />
+ {opt.label}
+
+ ))}
+
+
+
+ Max. Größe (MB)
+ updateParam('maxSize', parseFloat(e.target.value) || 10)}
+ />
+
+
+
+ updateParam('multiple', e.target.checked)}
+ />
+ Mehrere Dateien erlauben
+
+
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/configs/index.ts b/src/components/Automation2FlowEditor/nodes/configs/index.ts
similarity index 61%
rename from src/components/Automation2FlowEditor/configs/index.ts
rename to src/components/Automation2FlowEditor/nodes/configs/index.ts
index d674c75..0e2f6ad 100644
--- a/src/components/Automation2FlowEditor/configs/index.ts
+++ b/src/components/Automation2FlowEditor/nodes/configs/index.ts
@@ -1,10 +1,10 @@
/**
- * Node config renderers - one per node type (input, ai, email, sharepoint).
+ * Node config renderers - one per node type (input, ai, email, sharepoint, clickup).
*/
import type { ComponentType } from 'react';
import type { NodeConfigRendererProps } from './types';
-import { FormNodeConfig } from './FormNodeConfig';
+import { FormNodeConfig } from '../form/FormNodeConfig';
import { ApprovalNodeConfig } from './ApprovalNodeConfig';
import { UploadNodeConfig } from './UploadNodeConfig';
import { CommentNodeConfig } from './CommentNodeConfig';
@@ -14,10 +14,21 @@ import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
import { AiNodeConfig } from './AiNodeConfig';
import { EmailNodeConfig } from './EmailNodeConfig';
import { SharePointNodeConfig } from './SharePointNodeConfig';
+import { ClickUpNodeConfig } from './ClickUpNodeConfig';
+import { StartNodeConfig } from '../start/StartNodeConfig';
+import { IfElseNodeConfig } from '../ifElse/IfElseNodeConfig';
+import { SwitchNodeConfig } from '../switch/SwitchNodeConfig';
+import { LoopNodeConfig } from '../loop/LoopNodeConfig';
+import { FormStartNodeConfig } from '../start/FormStartNodeConfig';
+import { ScheduleStartNodeConfig } from '../start/ScheduleStartNodeConfig';
+import { FileCreateNodeConfig } from './FileCreateNodeConfig';
export type NodeConfigComponent = ComponentType;
export const NODE_CONFIG_REGISTRY: Record = {
+ 'trigger.manual': StartNodeConfig,
+ 'trigger.form': FormStartNodeConfig,
+ 'trigger.schedule': ScheduleStartNodeConfig,
'input.form': FormNodeConfig,
'input.approval': ApprovalNodeConfig,
'input.upload': UploadNodeConfig,
@@ -32,6 +43,7 @@ export const NODE_CONFIG_REGISTRY: Record = {
'ai.convertDocument': AiNodeConfig,
'ai.generateDocument': AiNodeConfig,
'ai.generateCode': AiNodeConfig,
+ 'file.create': FileCreateNodeConfig,
'email.checkEmail': EmailNodeConfig,
'email.searchEmail': EmailNodeConfig,
'email.draftEmail': EmailNodeConfig,
@@ -41,4 +53,13 @@ export const NODE_CONFIG_REGISTRY: Record = {
'sharepoint.listFiles': SharePointNodeConfig,
'sharepoint.downloadFile': SharePointNodeConfig,
'sharepoint.copyFile': SharePointNodeConfig,
+ 'clickup.searchTasks': ClickUpNodeConfig,
+ 'clickup.listTasks': ClickUpNodeConfig,
+ 'clickup.getTask': ClickUpNodeConfig,
+ 'clickup.createTask': ClickUpNodeConfig,
+ 'clickup.updateTask': ClickUpNodeConfig,
+ 'clickup.uploadAttachment': ClickUpNodeConfig,
+ 'flow.ifElse': IfElseNodeConfig,
+ 'flow.switch': SwitchNodeConfig,
+ 'flow.loop': LoopNodeConfig,
};
diff --git a/src/components/Automation2FlowEditor/nodes/configs/types.ts b/src/components/Automation2FlowEditor/nodes/configs/types.ts
new file mode 100644
index 0000000..ca2074c
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/configs/types.ts
@@ -0,0 +1 @@
+export type { NodeConfigRendererProps, FormField } from '../shared/types';
diff --git a/src/components/Automation2FlowEditor/nodes/form/FormNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/form/FormNodeConfig.tsx
new file mode 100644
index 0000000..2d02a1f
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/form/FormNodeConfig.tsx
@@ -0,0 +1,228 @@
+/**
+ * Form node config - draggable fields, types, required toggle
+ */
+
+import React, { useEffect, useState } from 'react';
+import { FaGripVertical, FaTimes } from 'react-icons/fa';
+import type { FormField, NodeConfigRendererProps } from '../configs/types';
+import { fetchConnections, type UserConnection } from '../../../../api/automation2Api';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+export const FormNodeConfig: React.FC = ({
+ params,
+ updateParam,
+ instanceId,
+ request,
+}) => {
+ const fields = (params.fields as FormField[]) ?? [];
+ const [connections, setConnections] = useState([]);
+ const [connectionsLoading, setConnectionsLoading] = useState(false);
+
+ useEffect(() => {
+ if (!instanceId || !request) {
+ setConnections([]);
+ return;
+ }
+ let cancelled = false;
+ setConnectionsLoading(true);
+ fetchConnections(request, instanceId)
+ .then((rows) => {
+ if (!cancelled) setConnections(rows.filter((c) => c.authority === 'clickup'));
+ })
+ .catch(() => {
+ if (!cancelled) setConnections([]);
+ })
+ .finally(() => {
+ if (!cancelled) setConnectionsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [instanceId, request]);
+
+ const moveField = (fromIndex: number, toIndex: number) => {
+ if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
+ const next = [...fields];
+ const [removed] = next.splice(fromIndex, 1);
+ next.splice(toIndex, 0, removed);
+ updateParam('fields', next);
+ };
+
+ const removeField = (index: number) => {
+ const next = fields.filter((_, i) => i !== index);
+ updateParam('fields', next);
+ };
+
+ return (
+
+
Felder
+
+ {fields.map((f, i) => (
+
{
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
+ if (!Number.isNaN(from) && from !== i) moveField(from, i);
+ }}
+ >
+
+
+ {
+ const next = [...fields];
+ const t = e.target.value;
+ next[i] = {
+ ...next[i],
+ type: t,
+ ...(t === 'clickup_tasks'
+ ? { clickupStatusOptions: undefined }
+ : t === 'clickup_status'
+ ? { clickupConnectionId: undefined, clickupListId: undefined }
+ : {
+ clickupConnectionId: undefined,
+ clickupListId: undefined,
+ clickupStatusOptions: undefined,
+ }),
+ };
+ updateParam('fields', next);
+ }}
+ style={{ width: 'auto', minWidth: 90 }}
+ >
+ Text
+ Number
+ Date
+ Checkbox
+ ClickUp-Aufgabe (Referenz)
+ ClickUp-Status (Liste)
+
+
+ {
+ const next = [...fields];
+ next[i] = { ...next[i], required: e.target.checked };
+ updateParam('fields', next);
+ }}
+ />
+ Pflichtfeld
+
+ removeField(i)}
+ title="Feld entfernen"
+ className={styles.formFieldRemoveButton}
+ >
+
+
+
+ {f.type === 'clickup_status' ? (
+
+ {Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
+
+ Dropdown mit {f.clickupStatusOptions.length} Status aus der ClickUp-Liste (Wert = exakter
+ Status-Name für die API).
+
+ ) : (
+
+ Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste
+ abgleichen“.
+
+ )}
+
+ ) : null}
+ {f.type === 'clickup_tasks' ? (
+
+
+ ClickUp-Verbindung
+
+
{
+ const next = [...fields];
+ next[i] = { ...next[i], clickupConnectionId: e.target.value };
+ updateParam('fields', next);
+ }}
+ disabled={connectionsLoading || !instanceId}
+ style={{ width: '100%', marginBottom: 8 }}
+ >
+ {connectionsLoading ? 'Lade…' : 'Verbindung wählen…'}
+ {connections.map((c) => (
+
+ {c.externalUsername ?? c.id}
+
+ ))}
+
+
+ Listen-ID (verknüpfte Liste / Ziel-Liste)
+
+
{
+ const next = [...fields];
+ next[i] = { ...next[i], clickupListId: e.target.value };
+ updateParam('fields', next);
+ }}
+ style={{ width: '100%' }}
+ />
+
+ Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:{' '}
+ {'{ add: [taskId], rem: [] }'} — im ClickUp-Node per Datenquelle auf das
+ Formularfeld mappen.
+
+
+ ) : null}
+
+ ))}
+
+ updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
+ }
+ >
+ + Feld
+
+
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/form/index.ts b/src/components/Automation2FlowEditor/nodes/form/index.ts
new file mode 100644
index 0000000..6d9b94e
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/form/index.ts
@@ -0,0 +1 @@
+export { FormNodeConfig } from './FormNodeConfig';
diff --git a/src/components/Automation2FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx
new file mode 100644
index 0000000..4ce3008
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx
@@ -0,0 +1,151 @@
+/**
+ * If/Else node config - inline UI: source dropdown, operator (type-dependent), value.
+ * Kein Popup, alles in einer Zeile.
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from '../configs/types';
+import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { isRef } from '../shared/dataRef';
+import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMapping';
+import { operatorsForType } from '../shared/conditionOperators';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+export interface StructuredCondition {
+ type: 'condition';
+ ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null;
+ operator: string;
+ value?: string | number;
+}
+
+function parseCondition(v: unknown): StructuredCondition | null {
+ if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') {
+ const c = v as StructuredCondition;
+ if (c.ref === null || isRef(c.ref)) return c;
+ }
+ return null;
+}
+
+export const IfElseNodeConfig: React.FC = ({ params, updateParam }) => {
+ const dataFlow = useAutomation2DataFlow();
+
+ const cond = parseCondition(params.condition);
+ const ref = cond?.ref ?? null;
+ const operator = cond?.operator ?? 'eq';
+ const value = cond?.value ?? '';
+
+ const fieldType = dataFlow ? getFieldType(ref, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown';
+ const operators = operatorsForType(fieldType);
+ const currentOp = operators.find((o) => o.value === operator) ?? operators[0];
+ const needsValue = currentOp?.needsValue ?? true;
+
+ const isMimeTypeRef =
+ ref && ref.path?.length >= 2 && ref.path[ref.path.length - 1] === 'mimeType';
+ const sourceNode = ref && dataFlow
+ ? dataFlow.nodes.find((n: { id: string; type?: string; parameters?: Record }) => n.id === ref.nodeId)
+ : null;
+ const mimeTypeOptions =
+ isMimeTypeRef && sourceNode?.type === 'input.upload' && sourceNode.parameters
+ ? getMimeTypeOptionsFromUploadParams(sourceNode.parameters as Record)
+ : [];
+
+ const setCondition = (next: StructuredCondition) => {
+ updateParam('condition', next);
+ };
+
+ const handleRefChange = (newRef: { type: 'ref'; nodeId: string; path: (string | number)[] } | null) => {
+ if (!newRef) {
+ setCondition({
+ type: 'condition',
+ ref: null,
+ operator: 'eq',
+ value: '',
+ });
+ return;
+ }
+ const newType = dataFlow ? getFieldType(newRef, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown';
+ const newOps = operatorsForType(newType);
+ setCondition({
+ type: 'condition',
+ ref: newRef,
+ operator: newOps[0]?.value ?? 'eq',
+ value: cond?.value ?? '',
+ });
+ };
+
+ const handleOperatorChange = (op: string) => {
+ const opDef = operators.find((o) => o.value === op);
+ setCondition({
+ type: 'condition',
+ ref: cond?.ref ?? null,
+ operator: op,
+ value: opDef?.needsValue ? value : undefined,
+ });
+ };
+
+ const handleValueChange = (v: string | number) => {
+ setCondition({
+ type: 'condition',
+ ref: cond?.ref ?? null,
+ operator,
+ value: fieldType === 'number' ? (parseFloat(String(v)) || 0) : String(v),
+ });
+ };
+
+ return (
+
+
+ Datenquelle
+
+
+
+ Vergleich
+ handleOperatorChange(e.target.value)}>
+ {operators.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ {needsValue && (
+
+ Wert
+ {mimeTypeOptions.length > 0 ? (
+ handleValueChange(e.target.value)}
+ >
+ — MIME-Type wählen —
+ {mimeTypeOptions.map((o) => (
+
+ {o.label} ({o.value})
+
+ ))}
+
+ ) : (
+
+ handleValueChange(
+ fieldType === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
+ )
+ }
+ placeholder={
+ fieldType === 'number'
+ ? '0'
+ : fieldType === 'date'
+ ? 'TT.MM.JJJJ'
+ : isMimeTypeRef
+ ? 'z.B. application/pdf'
+ : 'z.B. CH'
+ }
+ />
+ )}
+
+ )}
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/ifElse/index.ts b/src/components/Automation2FlowEditor/nodes/ifElse/index.ts
new file mode 100644
index 0000000..c9f658e
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/ifElse/index.ts
@@ -0,0 +1 @@
+export { IfElseNodeConfig } from './IfElseNodeConfig';
diff --git a/src/components/Automation2FlowEditor/nodes/loop/LoopNodeConfig.tsx b/src/components/Automation2FlowEditor/nodes/loop/LoopNodeConfig.tsx
new file mode 100644
index 0000000..3b69109
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/loop/LoopNodeConfig.tsx
@@ -0,0 +1,26 @@
+/**
+ * Loop node config - Datenquelle für Iteration mit benutzerfreundlichen Labels.
+ * Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from '../configs/types';
+import { LoopItemsSelect } from '../shared/LoopItemsSelect';
+import { createValue, isRef, isValue } from '../shared/dataRef';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+export const LoopNodeConfig: React.FC = ({ params, updateParam }) => {
+ const value = params.items;
+ const ref = isRef(value) ? value : null;
+ const selectValue = ref ?? (isValue(value) ? value : null);
+
+ const handleChange = (newRef: { type: 'ref'; nodeId: string; path: (string | number)[] } | null) => {
+ updateParam('items', newRef ?? createValue([]));
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/loop/index.ts b/src/components/Automation2FlowEditor/nodes/loop/index.ts
new file mode 100644
index 0000000..7118154
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/loop/index.ts
@@ -0,0 +1 @@
+export { LoopNodeConfig } from './LoopNodeConfig';
diff --git a/src/components/Automation2FlowEditor/nodes/runtime/fileTypeMimeMapping.ts b/src/components/Automation2FlowEditor/nodes/runtime/fileTypeMimeMapping.ts
new file mode 100644
index 0000000..f85d50f
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/runtime/fileTypeMimeMapping.ts
@@ -0,0 +1,98 @@
+/**
+ * Shared mapping: file type options (accept strings) → MIME types.
+ * Used by Upload node config (allowed types) and IfElse node (mimeType comparison).
+ * Single source of truth – nichts hardcoden.
+ */
+
+export interface FileTypeOption {
+ acceptValue: string;
+ label: string;
+ mimeTypes: Array<{ value: string; label: string }>;
+}
+
+/** Predefined file type options with their MIME type mappings. */
+export const FILE_TYPE_OPTIONS: FileTypeOption[] = [
+ { acceptValue: '.pdf', label: 'PDF', mimeTypes: [{ value: 'application/pdf', label: 'PDF' }] },
+ {
+ acceptValue: 'image/*',
+ label: 'Bilder (alle)',
+ mimeTypes: [
+ { value: 'image/jpeg', label: 'JPEG' },
+ { value: 'image/png', label: 'PNG' },
+ { value: 'image/gif', label: 'GIF' },
+ { value: 'image/webp', label: 'WebP' },
+ ],
+ },
+ {
+ acceptValue: '.jpg,.jpeg,.png,.gif,.webp',
+ label: 'Bilder (JPG, PNG, …)',
+ mimeTypes: [
+ { value: 'image/jpeg', label: 'JPEG' },
+ { value: 'image/png', label: 'PNG' },
+ { value: 'image/gif', label: 'GIF' },
+ { value: 'image/webp', label: 'WebP' },
+ ],
+ },
+ {
+ acceptValue: '.doc,.docx',
+ label: 'Word (DOC, DOCX)',
+ mimeTypes: [
+ { value: 'application/msword', label: 'DOC' },
+ { value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', label: 'DOCX' },
+ ],
+ },
+ {
+ acceptValue: '.xls,.xlsx',
+ label: 'Excel (XLS, XLSX)',
+ mimeTypes: [
+ { value: 'application/vnd.ms-excel', label: 'XLS' },
+ { value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', label: 'XLSX' },
+ ],
+ },
+ { acceptValue: '.txt', label: 'Text (TXT)', mimeTypes: [{ value: 'text/plain', label: 'Text' }] },
+ { acceptValue: '.csv', label: 'CSV', mimeTypes: [{ value: 'text/csv', label: 'CSV' }] },
+ { acceptValue: '.json', label: 'JSON', mimeTypes: [{ value: 'application/json', label: 'JSON' }] },
+ {
+ acceptValue: '.xml',
+ label: 'XML',
+ mimeTypes: [
+ { value: 'application/xml', label: 'XML' },
+ { value: 'text/xml', label: 'XML (text)' },
+ ],
+ },
+ { acceptValue: '.zip', label: 'ZIP', mimeTypes: [{ value: 'application/zip', label: 'ZIP' }] },
+];
+
+/** Parse allowedTypes array or accept string from node params. */
+export function parseAllowedTypes(params: Record): string[] {
+ const t = params.allowedTypes;
+ if (Array.isArray(t) && t.every((x) => typeof x === 'string')) return t as string[];
+ const a = params.accept;
+ if (typeof a === 'string' && a.trim()) return a.split(',').map((s) => s.trim()).filter(Boolean);
+ return [];
+}
+
+/** Get MIME type options for comparison from an Upload node's parameters. */
+export function getMimeTypeOptionsFromUploadParams(params: Record): Array<{ value: string; label: string }> {
+ const allowedTypes = parseAllowedTypes(params);
+ const seen = new Set();
+ const result: Array<{ value: string; label: string }> = [];
+ const sources = allowedTypes.length > 0 ? allowedTypes : FILE_TYPE_OPTIONS.map((o) => o.acceptValue);
+ for (const acceptVal of sources) {
+ const opt = FILE_TYPE_OPTIONS.find((o) => o.acceptValue === acceptVal);
+ if (opt) {
+ for (const m of opt.mimeTypes) {
+ if (!seen.has(m.value)) {
+ seen.add(m.value);
+ result.push(m);
+ }
+ }
+ }
+ }
+ return result;
+}
+
+/** Get accept values for HTML file input (for UploadNodeConfig). */
+export function getAcceptValues(): Array<{ value: string; label: string }> {
+ return FILE_TYPE_OPTIONS.map((o) => ({ value: o.acceptValue, label: o.label }));
+}
diff --git a/src/components/Automation2FlowEditor/nodes/runtime/scheduleCron.ts b/src/components/Automation2FlowEditor/nodes/runtime/scheduleCron.ts
new file mode 100644
index 0000000..6355d56
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/runtime/scheduleCron.ts
@@ -0,0 +1,296 @@
+/**
+ * User-friendly schedule ↔ cron
+ * Standard: 5 Felder (minute hour dom month dow), DOW 0=So … 6=Sa
+ * Intervall Sekunden: 6 Felder (sec min hour dom month dow)
+ */
+
+export type ScheduleMode = 'daily' | 'weekdays' | 'weekly' | 'calendar' | 'interval';
+
+export type CalendarPeriod = 'monthly' | 'yearly';
+
+/** sek, min, h, T (Tage), a (Jahre) — Cron nur näherungsweise für T/a */
+export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
+
+export interface ScheduleSpec {
+ mode: ScheduleMode;
+ hour: number;
+ minute: number;
+ /** 0–6, cron DOW; nur bei mode === 'weekly' */
+ weekdays: number[];
+ /** Monatlich: Tag 1–31; Jährlich: Tag im gewählten Monat */
+ monthDay: number;
+ /** 1–12, nur bei calendar + yearly */
+ monthIndex: number;
+ calendarPeriod: CalendarPeriod;
+ intervalValue: number;
+ intervalUnit: IntervalUnit;
+}
+
+export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
+
+/** Anzeige Mo–So (cronDow wie oben) */
+export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
+ { cronDow: 1, label: 'Mo' },
+ { cronDow: 2, label: 'Di' },
+ { cronDow: 3, label: 'Mi' },
+ { cronDow: 4, label: 'Do' },
+ { cronDow: 5, label: 'Fr' },
+ { cronDow: 6, label: 'Sa' },
+ { cronDow: 0, label: 'So' },
+];
+
+export function defaultScheduleSpec(): ScheduleSpec {
+ return {
+ mode: 'daily',
+ hour: 8,
+ minute: 0,
+ weekdays: [1, 2, 3, 4, 5],
+ monthDay: 1,
+ monthIndex: 1,
+ calendarPeriod: 'monthly',
+ intervalValue: 15,
+ intervalUnit: 'minutes',
+ };
+}
+
+function clamp(n: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, n));
+}
+
+/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
+export function buildCronFromSpec(spec: ScheduleSpec): string {
+ const m = clamp(Math.floor(spec.minute), 0, 59);
+ const h = clamp(Math.floor(spec.hour), 0, 23);
+
+ switch (spec.mode) {
+ case 'daily':
+ return `${m} ${h} * * *`;
+ case 'weekdays':
+ return `${m} ${h} * * 1-5`;
+ case 'weekly': {
+ const days = [...new Set(spec.weekdays)]
+ .filter((d) => d >= 0 && d <= 6)
+ .sort((a, b) => {
+ const order = (x: number) => (x === 0 ? 7 : x);
+ return order(a) - order(b);
+ });
+ if (days.length === 0) return `${m} ${h} * * 1`;
+ return `${m} ${h} * * ${days.join(',')}`;
+ }
+ case 'calendar': {
+ const dom = clamp(Math.floor(spec.monthDay), 1, 31);
+ if (spec.calendarPeriod === 'monthly') {
+ return `${m} ${h} ${dom} * *`;
+ }
+ const month = clamp(Math.floor(spec.monthIndex), 1, 12);
+ return `${m} ${h} ${dom} ${month} *`;
+ }
+ case 'interval': {
+ const v = Math.max(1, Math.floor(spec.intervalValue));
+ switch (spec.intervalUnit) {
+ case 'seconds': {
+ const s = clamp(v, 1, 59);
+ return `*/${s} * * * * *`;
+ }
+ case 'minutes': {
+ const mm = clamp(v, 1, 59);
+ return `*/${mm} * * * *`;
+ }
+ case 'hours': {
+ const hh = clamp(v, 1, 23);
+ return `0 */${hh} * * *`;
+ }
+ case 'days': {
+ if (v <= 1) return `0 0 * * *`;
+ const d = clamp(v, 2, 31);
+ return `0 0 */${d} * *`;
+ }
+ case 'years':
+ default:
+ // Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
+ return `0 0 1 1 *`;
+ }
+ }
+ default:
+ return `${m} ${h} * * *`;
+ }
+}
+
+/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
+export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
+ if (!cron || typeof cron !== 'string') return null;
+ const p = cron.trim().split(/\s+/);
+
+ if (p.length === 6) {
+ const [secS, minS, hourS, domS, monthS, dowS] = p;
+ if (
+ secS.startsWith('*/') &&
+ minS === '*' &&
+ hourS === '*' &&
+ domS === '*' &&
+ monthS === '*' &&
+ (dowS === '*' || dowS === '?')
+ ) {
+ const iv = parseInt(secS.slice(2), 10);
+ if (!Number.isNaN(iv)) {
+ return {
+ ...defaultScheduleSpec(),
+ mode: 'interval',
+ intervalValue: iv,
+ intervalUnit: 'seconds',
+ minute: 0,
+ hour: 0,
+ };
+ }
+ }
+ return null;
+ }
+
+ if (p.length < 5) return null;
+ const [minS, hourS, domS, monthS, dowS] = p;
+ const minute = parseInt(minS, 10);
+ const hour = parseInt(hourS, 10);
+ if (Number.isNaN(minute) || Number.isNaN(hour)) return null;
+
+ if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
+ const iv = parseInt(minS.slice(2), 10);
+ if (!Number.isNaN(iv)) {
+ return {
+ ...defaultScheduleSpec(),
+ mode: 'interval',
+ intervalValue: iv,
+ intervalUnit: 'minutes',
+ minute: 0,
+ hour: 0,
+ };
+ }
+ }
+
+ if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
+ const iv = parseInt(hourS.slice(2), 10);
+ if (!Number.isNaN(iv)) {
+ return {
+ ...defaultScheduleSpec(),
+ mode: 'interval',
+ intervalValue: iv,
+ intervalUnit: 'hours',
+ minute: 0,
+ hour: 0,
+ };
+ }
+ }
+
+ if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
+ const iv = parseInt(domS.slice(2), 10);
+ if (!Number.isNaN(iv)) {
+ return {
+ ...defaultScheduleSpec(),
+ mode: 'interval',
+ intervalValue: iv,
+ intervalUnit: 'days',
+ minute: 0,
+ hour: 0,
+ };
+ }
+ }
+
+ if (domS === '*' && dowS === '*') {
+ return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
+ }
+
+ if (domS === '*' && dowS === '1-5') {
+ return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute };
+ }
+
+ if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
+ const parts = dowS.split(',').map((x) => parseInt(x.trim(), 10));
+ const days = parts.filter((x) => !Number.isNaN(x) && x >= 0 && x <= 7);
+ if (days.length > 0) {
+ const norm = days.map((d) => (d === 7 ? 0 : d));
+ return {
+ ...defaultScheduleSpec(),
+ mode: 'weekly',
+ hour,
+ minute,
+ weekdays: norm,
+ };
+ }
+ }
+
+ const dom = parseInt(domS, 10);
+ const month = monthS === '*' ? NaN : parseInt(monthS, 10);
+
+ if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
+ return {
+ ...defaultScheduleSpec(),
+ mode: 'calendar',
+ calendarPeriod: 'monthly',
+ hour,
+ minute,
+ monthDay: dom,
+ };
+ }
+
+ if (
+ !Number.isNaN(dom) &&
+ dom >= 1 &&
+ dom <= 31 &&
+ !Number.isNaN(month) &&
+ month >= 1 &&
+ month <= 12 &&
+ (dowS === '*' || dowS === '?')
+ ) {
+ return {
+ ...defaultScheduleSpec(),
+ mode: 'calendar',
+ calendarPeriod: 'yearly',
+ hour,
+ minute,
+ monthDay: dom,
+ monthIndex: month,
+ };
+ }
+
+ return null;
+}
+
+const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval'];
+
+function normalizeIntervalUnit(u: unknown): IntervalUnit {
+ if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
+ return 'minutes';
+}
+
+/** Liest Spec aus Node-Parametern (schedule-Objekt bevorzugt, sonst Cron parsen) */
+export function scheduleSpecFromParams(params: Record): ScheduleSpec {
+ const raw = params.schedule;
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
+ const o = raw as Record;
+ let mode = o.mode as string;
+ if (mode === 'monthly') {
+ mode = 'calendar';
+ }
+ if (VALID_MODES.includes(mode as ScheduleMode)) {
+ const base = defaultScheduleSpec();
+ let calendarPeriod: CalendarPeriod = base.calendarPeriod;
+ if (mode === 'calendar') {
+ calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
+ }
+ return {
+ mode: mode as ScheduleMode,
+ hour: clamp(Number(o.hour) || base.hour, 0, 23),
+ minute: clamp(Number(o.minute) || base.minute, 0, 59),
+ weekdays: Array.isArray(o.weekdays)
+ ? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
+ : base.weekdays,
+ monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31),
+ monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12),
+ calendarPeriod,
+ intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
+ intervalUnit: normalizeIntervalUnit(o.intervalUnit),
+ };
+ }
+ }
+ const cron = typeof params.cron === 'string' ? params.cron : '';
+ return parseCronToSpec(cron) ?? defaultScheduleSpec();
+}
diff --git a/src/components/Automation2FlowEditor/nodes/runtime/workflowStartSync.ts b/src/components/Automation2FlowEditor/nodes/runtime/workflowStartSync.ts
new file mode 100644
index 0000000..e20926d
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/runtime/workflowStartSync.ts
@@ -0,0 +1,222 @@
+/**
+ * Single canonical start node on the canvas — id and type follow workflow primary entry kind.
+ */
+
+import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
+import type { NodeType } from '../../../../api/automation2Api';
+import type { WorkflowEntryPoint } from '../../../../api/automation2Api';
+import { getLabel } from '../shared/utils';
+
+export const CANVAS_START_NODE_ID = 'start';
+
+/** Primary entry is always the first invocation (gear configures index 0). */
+export function getPrimaryEntry(invocations: WorkflowEntryPoint[] | undefined): WorkflowEntryPoint | undefined {
+ return invocations?.[0];
+}
+
+/** Kind of the primary entry (drives canvas node type) */
+export function getPrimaryStartKind(invocations: WorkflowEntryPoint[] | undefined): string {
+ return getPrimaryEntry(invocations)?.kind ?? 'manual';
+}
+
+function entryTitle(entry: WorkflowEntryPoint | undefined, language: string): string {
+ if (!entry?.title) return '';
+ const t = entry.title;
+ if (typeof t === 'string') return t.trim();
+ const s = t[language] || t.de || t.en || Object.values(t)[0];
+ return (s != null ? String(s) : '').trim();
+}
+
+export function mapKindToNodeType(kind: string): string {
+ if (kind === 'form') return 'trigger.form';
+ if (kind === 'schedule') return 'trigger.schedule';
+ // Immer aktiv: zunächst Standard-Start; Listener (E-Mail, Webhook, …) folgt separat
+ if (kind === 'always_on') return 'trigger.manual';
+ return 'trigger.manual';
+}
+
+function categoryForKind(kind: string): 'on_demand' | 'always_on' {
+ if (kind === 'manual' || kind === 'form') return 'on_demand';
+ return 'always_on';
+}
+
+function titleForStartNode(
+ kind: string,
+ invocations: WorkflowEntryPoint[],
+ nodeTypes: NodeType[],
+ language: string
+): string {
+ const custom = entryTitle(getPrimaryEntry(invocations), language);
+ if (custom) return custom;
+ const nt = nodeTypes.find((n) => n.id === mapKindToNodeType(kind));
+ if (nt) return getLabel(nt.label, language);
+ return 'Start';
+}
+
+/** Rewire connections when replacing node ids */
+function rewireConnections(
+ connections: CanvasConnection[],
+ fromId: string,
+ toId: string
+): CanvasConnection[] {
+ if (fromId === toId) return connections;
+ return connections.map((c) => ({
+ ...c,
+ sourceId: c.sourceId === fromId ? toId : c.sourceId,
+ targetId: c.targetId === fromId ? toId : c.targetId,
+ }));
+}
+
+/** Deep-rewrite ref.nodeId in parameters (e.g. flow.ifElse condition.ref) */
+function rewireRefInParams(params: unknown, fromIds: Set, toId: string): unknown {
+ if (params == null) return params;
+ if (typeof params === 'object' && params !== null && 'type' in params && 'nodeId' in params) {
+ const obj = params as { type?: string; nodeId?: string; path?: unknown };
+ if (obj.type === 'ref' && typeof obj.nodeId === 'string' && fromIds.has(obj.nodeId)) {
+ return { ...obj, nodeId: toId };
+ }
+ }
+ if (Array.isArray(params)) {
+ return params.map((item) => rewireRefInParams(item, fromIds, toId));
+ }
+ if (typeof params === 'object' && params !== null) {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(params)) {
+ out[k] = rewireRefInParams(v, fromIds, toId);
+ }
+ return out;
+ }
+ return params;
+}
+
+/** Rewrite refs in all nodes' parameters when trigger id changes */
+function rewireRefsInNodes(
+ nodes: CanvasNode[],
+ fromIds: Set,
+ toId: string
+): CanvasNode[] {
+ if (fromIds.size === 0) return nodes;
+ return nodes.map((n) => {
+ const p = n.parameters;
+ if (!p || typeof p !== 'object') return n;
+ const next = rewireRefInParams(p, fromIds, toId);
+ if (next === p) return n;
+ return { ...n, parameters: next as Record };
+ });
+}
+
+/** Remove duplicate trigger nodes; keep first, merge connections onto it */
+function dedupeTriggers(
+ nodes: CanvasNode[],
+ connections: CanvasConnection[]
+): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
+ const triggers = nodes.filter((n) => n.type.startsWith('trigger.'));
+ if (triggers.length <= 1) return { nodes, connections };
+
+ const keep = triggers[0];
+ const removeIds = new Set(triggers.slice(1).map((n) => n.id));
+ let nextConn = connections;
+ for (const rid of removeIds) {
+ nextConn = rewireConnections(nextConn, rid, keep.id);
+ }
+ const newNodes = nodes.filter((n) => !removeIds.has(n.id));
+ return { nodes: newNodes, connections: nextConn };
+}
+
+/** Normalize canonical id `start` and update type/labels from primary kind */
+export function syncCanvasStartNode(
+ nodes: CanvasNode[],
+ connections: CanvasConnection[],
+ invocations: WorkflowEntryPoint[],
+ nodeTypes: NodeType[],
+ language: string
+): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
+ const kind = getPrimaryStartKind(invocations);
+ const targetType = mapKindToNodeType(kind);
+ const title = titleForStartNode(kind, invocations, nodeTypes, language);
+ const nt = nodeTypes.find((n) => n.id === targetType);
+ const inputs = nt?.inputs ?? 0;
+ const outputs = nt?.outputs ?? 1;
+
+ const triggerIdsBeforeDedupe = new Set(nodes.filter((n) => n.type.startsWith('trigger.')).map((n) => n.id));
+ let { nodes: ns, connections: cs } = dedupeTriggers(nodes, connections);
+
+ let startIdx = ns.findIndex((n) => n.type.startsWith('trigger.'));
+ if (startIdx === -1) {
+ const newNode: CanvasNode = {
+ id: CANVAS_START_NODE_ID,
+ type: targetType,
+ x: 100,
+ y: 120,
+ title,
+ label: title,
+ inputs,
+ outputs,
+ color: nt?.meta?.color as string | undefined,
+ parameters: {},
+ };
+ ns = rewireRefsInNodes([newNode, ...ns], triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
+ return { nodes: ns, connections: cs };
+ }
+
+ const current = ns[startIdx];
+ const oldId = current.id;
+ let nextConn = cs;
+ if (oldId !== CANVAS_START_NODE_ID) {
+ nextConn = rewireConnections(nextConn, oldId, CANVAS_START_NODE_ID);
+ }
+ ns = rewireRefsInNodes(ns, triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
+
+ const updated: CanvasNode = {
+ ...current,
+ id: CANVAS_START_NODE_ID,
+ type: targetType,
+ title,
+ label: title,
+ inputs,
+ outputs,
+ color: nt?.meta?.color as string | undefined,
+ parameters:
+ targetType === current.type ? current.parameters ?? {} : preserveParametersForTypeSwitch(current, targetType),
+ };
+
+ const nextNodes = [...ns];
+ nextNodes[startIdx] = updated;
+ return { nodes: nextNodes, connections: nextConn };
+}
+
+function preserveParametersForTypeSwitch(node: CanvasNode, newType: string): Record {
+ const p = node.parameters ?? {};
+ if (newType === 'trigger.form' && p.formFields) return { formFields: p.formFields };
+ if (newType === 'trigger.schedule' && (p.cron || p.schedule)) {
+ const out: Record = {};
+ if (p.cron != null) out.cron = p.cron;
+ if (p.schedule != null) out.schedule = p.schedule;
+ return out;
+ }
+ return {};
+}
+
+/** Build invocations: replace primary (index 0), keep further entries (e.g. listener config later). */
+export function buildInvocationsForPrimaryKind(
+ kind: string,
+ existing: WorkflowEntryPoint[] | undefined,
+ titleDe: string
+): WorkflowEntryPoint[] {
+ const list = existing ?? [];
+ const primaryId =
+ list[0]?.id ??
+ (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `ep-${Date.now()}`);
+ const category = categoryForKind(kind);
+ const primary: WorkflowEntryPoint = {
+ id: primaryId,
+ kind,
+ category,
+ enabled: true,
+ title: { de: titleDe, en: titleDe, fr: titleDe },
+ description: {},
+ config: {},
+ };
+ const rest = list.slice(1).filter((x) => x.id !== primaryId);
+ return [primary, ...rest];
+}
diff --git a/src/components/Automation2FlowEditor/nodes/shared/DataPicker.tsx b/src/components/Automation2FlowEditor/nodes/shared/DataPicker.tsx
new file mode 100644
index 0000000..d003b64
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/DataPicker.tsx
@@ -0,0 +1,126 @@
+/**
+ * Automation2 Flow Editor - Data Picker for selecting node output references.
+ */
+
+import React, { useState } from 'react';
+import { createRef, type DataRef } from './dataRef';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+interface DataPickerProps {
+ open: boolean;
+ onClose: () => void;
+ onPick: (ref: DataRef) => void;
+ availableSourceIds: string[];
+ nodes: Array<{ id: string; title?: string; type?: string }>;
+ nodeOutputsPreview: Record;
+ getNodeLabel: (node: { id: string; title?: string }) => string;
+}
+
+/** Collect all pickable paths (each leads to a value the user can reference) */
+function buildPickablePaths(obj: unknown, basePath: (string | number)[] = []): Array<{ path: (string | number)[]; label: string }> {
+ const pathLabel = basePath.length ? basePath.map(String).join(' → ') : '(ganze Ausgabe)';
+ if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
+ return [{ path: [...basePath], label: pathLabel }];
+ }
+ if (Array.isArray(obj)) {
+ const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
+ for (let i = 0; i < Math.min(obj.length, 10); i++) {
+ result.push(...buildPickablePaths(obj[i], [...basePath, i]));
+ }
+ return result;
+ }
+ if (typeof obj === 'object') {
+ const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
+ for (const [k, v] of Object.entries(obj as Record)) {
+ result.push(...buildPickablePaths(v, [...basePath, k]));
+ }
+ return result;
+ }
+ return [{ path: [...basePath], label: pathLabel }];
+}
+
+export const DataPicker: React.FC = ({
+ open,
+ onClose,
+ onPick,
+ availableSourceIds,
+ nodes,
+ nodeOutputsPreview,
+ getNodeLabel,
+}) => {
+ const [expandedNodes, setExpandedNodes] = useState>(new Set());
+
+ if (!open) return null;
+
+ const toggleExpand = (nodeId: string) => {
+ setExpandedNodes((prev) => {
+ const next = new Set(prev);
+ if (next.has(nodeId)) next.delete(nodeId);
+ else next.add(nodeId);
+ return next;
+ });
+ };
+
+ const handlePick = (nodeId: string, path: (string | number)[]) => {
+ onPick(createRef(nodeId, path));
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
Datenquelle wählen
+
+ ×
+
+
+
+ {(() => {
+ const filteredIds = availableSourceIds.filter((nodeId) => {
+ const node = nodes.find((n) => n.id === nodeId);
+ return node?.type !== 'trigger.manual';
+ });
+ if (filteredIds.length === 0) {
+ return
Keine vorherigen Nodes verfügbar.
;
+ }
+ return filteredIds.map((nodeId) => {
+ const node = nodes.find((n) => n.id === nodeId);
+ const preview = nodeOutputsPreview[nodeId];
+ const label = node ? getNodeLabel(node) : nodeId;
+ const paths = buildPickablePaths(preview);
+ const isExpanded = expandedNodes.has(nodeId);
+
+ return (
+
+
toggleExpand(nodeId)}
+ >
+ {isExpanded ? '▼' : '▶'}
+ {label}
+
+ {isExpanded && (
+
+ {paths.map((p, i) => (
+ handlePick(nodeId, p.path)}
+ >
+ {p.label}
+
+ ))}
+
+ )}
+
+ );
+ });
+ })()}
+
+
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/shared/DynamicValueField.tsx b/src/components/Automation2FlowEditor/nodes/shared/DynamicValueField.tsx
new file mode 100644
index 0000000..d942a1f
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/DynamicValueField.tsx
@@ -0,0 +1,114 @@
+/**
+ * Automation2 Flow Editor - Field that supports node reference only (no static value).
+ */
+
+import React, { useState } from 'react';
+import {
+ isRef,
+ createValue,
+ formatRefLabel,
+ type DataRef,
+} from './dataRef';
+import { RefSourceSelect } from './RefSourceSelect';
+import { DataPicker } from './DataPicker';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+export type FieldType = 'textarea' | 'input';
+
+interface DynamicValueFieldProps {
+ paramKey: string;
+ value: unknown;
+ onChange: (key: string, value: unknown) => void;
+ label: string;
+ fieldType?: FieldType;
+ placeholder?: string;
+ rows?: number;
+ /** Inline dropdown instead of popup picker */
+ variant?: 'picker' | 'dropdown';
+}
+
+export const DynamicValueField: React.FC = ({
+ paramKey,
+ value,
+ onChange,
+ label,
+ placeholder,
+ variant = 'picker',
+}) => {
+ const dataFlow = useAutomation2DataFlow();
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ const ref: DataRef | null = isRef(value) ? value : null;
+ const availableIds = dataFlow?.getAvailableSourceIds() ?? [];
+ const hasUsefulSources = availableIds.some((id) => {
+ const n = dataFlow?.nodes.find((x) => x.id === id);
+ return n?.type !== 'trigger.manual';
+ });
+ const canUseRef = dataFlow !== null && hasUsefulSources;
+
+ const handleSetRef = (newRef: DataRef | null) => {
+ onChange(paramKey, newRef ?? createValue(''));
+ };
+
+ if (!canUseRef) {
+ return (
+
+
{label}
+
Keine vorherigen Nodes verfügbar.
+
+ );
+ }
+
+ if (variant === 'dropdown') {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ return (
+
+
{label}
+
+
+ {ref
+ ? formatRefLabel(
+ ref,
+ dataFlow?.nodes ?? [],
+ (nid) => dataFlow?.nodes.find((n) => n.id === nid)?.title ?? nid
+ )
+ : '—'}
+
+ setPickerOpen(true)}
+ >
+ Wählen…
+
+
+
+ {dataFlow && (
+
setPickerOpen(false)}
+ onPick={(r) => {
+ handleSetRef(r);
+ setPickerOpen(false);
+ }}
+ availableSourceIds={dataFlow.getAvailableSourceIds()}
+ nodes={dataFlow.nodes}
+ nodeOutputsPreview={dataFlow.nodeOutputsPreview}
+ getNodeLabel={dataFlow.getNodeLabel}
+ />
+ )}
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/shared/HybridStaticRefField.tsx b/src/components/Automation2FlowEditor/nodes/shared/HybridStaticRefField.tsx
new file mode 100644
index 0000000..f65695b
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/HybridStaticRefField.tsx
@@ -0,0 +1,109 @@
+/**
+ * Text/number field: „Quelle wählen“ → Statisch (Eingabe) oder Kontext-Ref.
+ * Textfeld nur bei „Statisch“, nicht bei Kontext-Referenz.
+ */
+
+import React from 'react';
+import {
+ StatischKontextSelect,
+ shouldShowStaticControl,
+ type PathPickMode,
+} from './RefSourceSelect';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { isRef, isValue, createValue } from './dataRef';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+function parseHybrid(value: unknown): { staticStr: string } {
+ if (isRef(value)) return { staticStr: '' };
+ if (isValue(value)) {
+ const v = value.value;
+ if (v === null || v === undefined) return { staticStr: '' };
+ return { staticStr: String(v) };
+ }
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return { staticStr: String(value) };
+ }
+ return { staticStr: '' };
+}
+
+export interface HybridStaticRefFieldProps {
+ label: string;
+ value: unknown;
+ onChange: (value: unknown) => void;
+ multiline?: boolean;
+ inputType?: 'text' | 'number';
+ placeholder?: string;
+ /** Passed to StatischKontextSelect — use clickup_task_id for Task-ID fields. */
+ pathPickMode?: PathPickMode;
+}
+
+export const HybridStaticRefField: React.FC = ({
+ label,
+ value,
+ onChange,
+ multiline,
+ inputType = 'text',
+ placeholder,
+ pathPickMode = 'default',
+}) => {
+ const dataFlow = useAutomation2DataFlow();
+ const hasSources =
+ dataFlow &&
+ dataFlow.getAvailableSourceIds().some((id) => {
+ const n = dataFlow.nodes.find((x) => x.id === id);
+ if (n?.type === 'trigger.manual') return false;
+ if (
+ pathPickMode === 'exclude_forms' &&
+ (n?.type === 'input.form' || n?.type === 'trigger.form')
+ ) {
+ return false;
+ }
+ if (pathPickMode === 'clickup_task_id') {
+ return Boolean(n?.type?.startsWith('clickup.'));
+ }
+ return true;
+ });
+
+ const { staticStr } = parseHybrid(value);
+
+ const handleStaticChange = (v: string) => {
+ if (inputType === 'number') {
+ const n = parseFloat(v);
+ onChange(createValue(Number.isFinite(n) ? n : ''));
+ } else {
+ onChange(createValue(v));
+ }
+ };
+
+ return (
+
+
{label}
+ {hasSources ? (
+
+
+
+ ) : null}
+ {shouldShowStaticControl(value, Boolean(hasSources)) &&
+ (multiline ? (
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/shared/LoopItemsSelect.tsx b/src/components/Automation2FlowEditor/nodes/shared/LoopItemsSelect.tsx
new file mode 100644
index 0000000..694fb6c
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/LoopItemsSelect.tsx
@@ -0,0 +1,211 @@
+/**
+ * Loop node - Datenquelle für Iteration mit benutzerfreundlichen Labels.
+ * Zeigt nur iterierbare Quellen: Arrays und Objekte (Formularfelder → {name, value}).
+ */
+
+import React from 'react';
+import { createRef, isRef, type DataRef } from './dataRef';
+import { refToOptionValue, optionValueToRef } from './RefSourceSelect';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+interface LoopOption {
+ ref: DataRef;
+ label: string;
+}
+
+function getValueAtPath(obj: unknown, path: (string | number)[]): unknown {
+ let current: unknown = obj;
+ for (const seg of path) {
+ if (current == null) return undefined;
+ const key = typeof seg === 'number' ? String(seg) : seg;
+ if (Array.isArray(current) && /^\d+$/.test(key)) {
+ current = current[parseInt(key, 10)];
+ } else if (typeof current === 'object' && key in (current as object)) {
+ current = (current as Record)[key];
+ } else return undefined;
+ }
+ return current;
+}
+
+/** Build iterable options with friendly labels for Loop node */
+function buildLoopOptions(
+ sourceIds: string[],
+ nodes: Array<{ id: string; type?: string; title?: string; parameters?: Record }>,
+ nodeOutputsPreview: Record,
+ getNodeLabel: (n: { id: string; type?: string; title?: string }) => string
+): LoopOption[] {
+ const options: LoopOption[] = [];
+
+ for (const nodeId of sourceIds) {
+ const node = nodes.find((n) => n.id === nodeId);
+ if (node?.type === 'trigger.manual') continue;
+
+ const nodeLabel = getNodeLabel(node ?? { id: nodeId });
+ const preview = nodeOutputsPreview[nodeId];
+
+ // Special cases with friendly labels
+ if (node?.type === 'trigger.form') {
+ options.push({
+ ref: createRef(nodeId, ['payload']),
+ label: `Alle Formularfelder (${nodeLabel})`,
+ });
+ const filesVal = getValueAtPath(preview, ['files']);
+ if (Array.isArray(filesVal)) {
+ options.push({
+ ref: createRef(nodeId, ['files']),
+ label: `Alle Dateien aus Formular (${nodeLabel})`,
+ });
+ }
+ continue;
+ }
+
+ if (node?.type === 'input.form') {
+ options.push({
+ ref: createRef(nodeId, []),
+ label: `Alle Formularfelder (${nodeLabel})`,
+ });
+ continue;
+ }
+
+ if (node?.type === 'input.upload') {
+ options.push({
+ ref: createRef(nodeId, ['files']),
+ label: `Alle hochgeladenen Dateien (${nodeLabel})`,
+ });
+ options.push({
+ ref: createRef(nodeId, ['fileIds']),
+ label: `Alle Datei-IDs (${nodeLabel})`,
+ });
+ continue;
+ }
+
+ if (node?.type === 'flow.loop') {
+ options.push({
+ ref: createRef(nodeId, ['items']),
+ label: `Alle Elemente aus Schleife (${nodeLabel})`,
+ });
+ continue;
+ }
+
+ if (node?.type === 'email.searchEmail') {
+ options.push({
+ ref: createRef(nodeId, ['data', 'searchResults', 'results']),
+ label: `Alle gefundenen E-Mails (${nodeLabel})`,
+ });
+ continue;
+ }
+
+ if (node?.type === 'email.checkEmail') {
+ options.push({
+ ref: createRef(nodeId, ['data', 'emails', 'emails']),
+ label: `Alle E-Mails (${nodeLabel})`,
+ });
+ continue;
+ }
+
+ if (node?.type === 'sharepoint.listFiles') {
+ options.push({
+ ref: createRef(nodeId, ['files']),
+ label: `Alle Dateien (${nodeLabel})`,
+ });
+ continue;
+ }
+
+ // Generic: find top-level arrays and root object in preview
+ if (preview != null && typeof preview === 'object') {
+ for (const [k, v] of Object.entries(preview as Record)) {
+ const path: (string | number)[] = [k];
+ const pathStr = path.join('.');
+ if (Array.isArray(v)) {
+ options.push({
+ ref: createRef(nodeId, path),
+ label: `${nodeLabel}.${pathStr}`,
+ });
+ } else if (v != null && typeof v === 'object' && !Array.isArray(v)) {
+ const inner = v as Record;
+ for (const [k2, v2] of Object.entries(inner)) {
+ if (Array.isArray(v2)) {
+ options.push({
+ ref: createRef(nodeId, [k, k2]),
+ label: `${nodeLabel}.${k}.${k2}`,
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Deduplicate by ref (path might repeat from different collection)
+ const seen = new Set();
+ return options.filter((o) => {
+ const key = refToOptionValue(o.ref);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+interface LoopItemsSelectProps {
+ value: DataRef | { type: 'value'; value: unknown } | null;
+ onChange: (ref: DataRef | null) => void;
+ placeholder?: string;
+}
+
+export const LoopItemsSelect: React.FC = ({
+ value,
+ onChange,
+ placeholder = 'Über was soll iteriert werden?',
+}) => {
+ const dataFlow = useAutomation2DataFlow();
+ if (!dataFlow) return null;
+
+ const sourceIds = dataFlow.getAvailableSourceIds();
+ if (sourceIds.length === 0) {
+ return (
+
+ Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife.
+
+ );
+ }
+
+ const options = buildLoopOptions(
+ sourceIds,
+ dataFlow.nodes,
+ dataFlow.nodeOutputsPreview,
+ dataFlow.getNodeLabel
+ );
+
+ const ref = isRef(value) ? value : null;
+ const currentValue = ref ? refToOptionValue(ref) : '';
+
+ return (
+
+
Datenquelle für Iteration
+
{
+ const v = e.target.value;
+ if (!v) {
+ onChange(null);
+ return;
+ }
+ const r = optionValueToRef(v);
+ if (r) onChange(r);
+ }}
+ className={styles.startsInput}
+ >
+ {placeholder}
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.
+
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/nodes/shared/RefSourceSelect.tsx b/src/components/Automation2FlowEditor/nodes/shared/RefSourceSelect.tsx
new file mode 100644
index 0000000..3301b8d
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/RefSourceSelect.tsx
@@ -0,0 +1,405 @@
+/**
+ * Inline dropdown to select a data source (node + path) - no popup.
+ * Form nodes (trigger.form / input.form): only payload. paths (no duplicate tree).
+ */
+
+import React from 'react';
+import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+
+/** How to build path options for StatischKontextSelect / RefSourceSelect. */
+export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
+
+/** Only task IDs from ClickUp nodes — single path (taskId === clickupTask.id at runtime). */
+function buildClickUpTaskIdPaths(): Array<{ path: (string | number)[]; pathLabel: string }> {
+ return [{ path: ['taskId'], pathLabel: 'Aufgaben-ID' }];
+}
+
+/** Curated paths for clickup.* outputs — avoids huge documentData / payload trees. */
+function buildClickUpOutputPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
+ const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
+ { path: ['taskId'], pathLabel: 'Aufgaben-ID' },
+ { path: ['clickupTask', 'name'], pathLabel: 'clickupTask.name' },
+ { path: ['success'], pathLabel: 'success' },
+ { path: ['error'], pathLabel: 'error' },
+ { path: ['documents', 0, 'documentName'], pathLabel: 'documents[0].documentName' },
+ ];
+ if (preview && typeof preview === 'object') {
+ const p = preview as Record;
+ const ct = p.clickupTask;
+ if (ct && typeof ct === 'object' && !Array.isArray(ct)) {
+ const o = ct as Record;
+ for (const k of Object.keys(o)) {
+ if (k === 'id' || k === 'name') continue;
+ const v = o[k];
+ if (v != null && typeof v !== 'object') {
+ paths.push({ path: ['clickupTask', k], pathLabel: `clickupTask.${k}` });
+ }
+ if (k === 'status' && v && typeof v === 'object') {
+ paths.push({
+ path: ['clickupTask', 'status', 'status'],
+ pathLabel: 'clickupTask.status.status',
+ });
+ }
+ }
+ }
+ }
+ return paths;
+}
+
+function buildPickablePaths(
+ obj: unknown,
+ basePath: (string | number)[] = []
+): Array<{ path: (string | number)[]; pathLabel: string }> {
+ const pathLabel = basePath.length ? basePath.map(String).join('.') : '';
+ if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
+ return [{ path: [...basePath], pathLabel }];
+ }
+ if (Array.isArray(obj)) {
+ const result: Array<{ path: (string | number)[]; pathLabel: string }> = [{ path: [...basePath], pathLabel }];
+ for (let i = 0; i < Math.min(obj.length, 10); i++) {
+ result.push(...buildPickablePaths(obj[i], [...basePath, i]));
+ }
+ return result;
+ }
+ if (typeof obj === 'object') {
+ const result: Array<{ path: (string | number)[]; pathLabel: string }> = [{ path: [...basePath], pathLabel }];
+ for (const [k, v] of Object.entries(obj as Record)) {
+ result.push(...buildPickablePaths(v, [...basePath, k]));
+ }
+ return result;
+ }
+ return [{ path: [...basePath], pathLabel }];
+}
+
+/** Nur Formular-Felder: ein Eintrag pro Feld unter payload. — kein rekursives Durchwandern. */
+function buildFormSchemaPayloadPaths(params: Record): Array<{
+ path: (string | number)[];
+ pathLabel: string;
+}> {
+ const raw = params.formFields ?? params.fields;
+ if (!Array.isArray(raw)) return [];
+ const out: Array<{ path: (string | number)[]; pathLabel: string }> = [];
+ for (let i = 0; i < raw.length; i++) {
+ const row = raw[i];
+ if (!row || typeof row !== 'object') continue;
+ const name = String((row as Record).name ?? `field${i + 1}`).trim();
+ if (!name) continue;
+ out.push({ path: ['payload', name], pathLabel: `payload.${name}` });
+ }
+ return out;
+}
+
+export function pickPathsForNode(
+ node: { type?: string; parameters?: Record } | undefined,
+ preview: unknown,
+ mode: PathPickMode = 'default'
+): Array<{ path: (string | number)[]; pathLabel: string }> {
+ if (!node) return buildPickablePaths(preview);
+ const nt = node.type ?? '';
+ if (mode === 'clickup_task_id') {
+ if (nt.startsWith('clickup.')) {
+ return buildClickUpTaskIdPaths();
+ }
+ return [];
+ }
+ if (nt === 'trigger.form' || nt === 'input.form') {
+ return buildFormSchemaPayloadPaths(node.parameters ?? {});
+ }
+ if (node.type === 'input.upload') {
+ return buildPickablePathsForUpload();
+ }
+ if (nt.startsWith('clickup.')) {
+ return buildClickUpOutputPaths(preview);
+ }
+ return buildPickablePaths(preview);
+}
+
+/** Für input.upload: nur relevante Pfade für If/Else – MIME-Type, Dateiname, Datei vorhanden. */
+function buildPickablePathsForUpload(): Array<{ path: (string | number)[]; pathLabel: string }> {
+ return [
+ { path: [], pathLabel: '' },
+ { path: ['file'], pathLabel: 'file' },
+ { path: ['file', 'mimeType'], pathLabel: 'file.mimeType' },
+ { path: ['file', 'fileName'], pathLabel: 'file.fileName' },
+ { path: ['files'], pathLabel: 'files' },
+ { path: ['fileIds'], pathLabel: 'fileIds' },
+ ];
+}
+
+export function refToOptionValue(ref: DataRef): string {
+ return JSON.stringify(ref);
+}
+
+export function optionValueToRef(s: string): DataRef | null {
+ try {
+ const o = JSON.parse(s) as unknown;
+ if (o && typeof o === 'object' && (o as DataRef).type === 'ref' && typeof (o as DataRef).nodeId === 'string') {
+ return o as DataRef;
+ }
+ } catch {
+ /* ignore */
+ }
+ return null;
+}
+
+/** Option value for „Statisch (manuell)“ in StatischKontextSelect. */
+export const STATIC_SOURCE_VALUE = '__static__';
+
+function parseHybridLocal(value: unknown): { ref: DataRef | null; staticStr: string } {
+ if (isRef(value)) return { ref: value, staticStr: '' };
+ if (isValue(value)) {
+ const v = value.value;
+ if (v === null || v === undefined) return { ref: null, staticStr: '' };
+ return { ref: null, staticStr: String(v) };
+ }
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return { ref: null, staticStr: String(value) };
+ }
+ return { ref: null, staticStr: '' };
+}
+
+/** Aktueller Wert des Quellen-Dropdowns: '' | STATIC_SOURCE_VALUE | ref-JSON. */
+export function getStaticContextSelectValue(value: unknown): string {
+ if (isRef(value)) return refToOptionValue(value);
+ if (value === undefined || value === null) return '';
+ return STATIC_SOURCE_VALUE;
+}
+
+/** Statische Eingabe (Text, Checkbox, ClickUp-Option, …) nur bei „Statisch“ oder ohne vorgelagerte Nodes. */
+export function shouldShowStaticControl(value: unknown, hasSources: boolean): boolean {
+ if (!hasSources) return true;
+ if (isRef(value)) return false;
+ return getStaticContextSelectValue(value) === STATIC_SOURCE_VALUE;
+}
+
+interface StatischKontextSelectProps {
+ value: unknown;
+ onChange: (v: unknown) => void;
+ placeholder?: string;
+ /** Label für die manuelle Option (Default: Statisch). */
+ staticLabel?: string;
+ /** default: full tree; clickup_task_id: only taskId from ClickUp nodes; exclude_forms: skip form nodes. */
+ pathPickMode?: PathPickMode;
+}
+
+/**
+ * Ein Dropdown: zuerst „Quelle wählen“, dann „Statisch“, dann Kontextpfade.
+ * Bei Kontext-Ref kein paralleles Textfeld (nur in HybridStaticRefField / ClickUp bei shouldShowStaticControl).
+ */
+export const StatischKontextSelect: React.FC = ({
+ value,
+ onChange,
+ placeholder = '— Quelle wählen —',
+ staticLabel = 'Statisch',
+ pathPickMode = 'default',
+}) => {
+ const dataFlow = useAutomation2DataFlow();
+ if (!dataFlow) return null;
+
+ const sourceIds = dataFlow.getAvailableSourceIds();
+ const options: Array<{ ref: DataRef; label: string }> = [];
+
+ for (const nodeId of sourceIds) {
+ const node = dataFlow.nodes.find((n) => n.id === nodeId);
+ if (node?.type === 'trigger.manual') continue;
+ if (
+ pathPickMode === 'exclude_forms' &&
+ (node?.type === 'input.form' || node?.type === 'trigger.form')
+ ) {
+ continue;
+ }
+ const preview = dataFlow.nodeOutputsPreview[nodeId];
+ const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
+ const paths = pickPathsForNode(node, preview, pathPickMode);
+ for (const p of paths) {
+ const displayLabel = p.pathLabel ? `${nodeLabel} → ${p.pathLabel}` : nodeLabel;
+ options.push({
+ ref: createRef(nodeId, p.path),
+ label: displayLabel,
+ });
+ }
+ }
+
+ const currentSelect = isRef(value)
+ ? refToOptionValue(value)
+ : value === undefined || value === null
+ ? ''
+ : STATIC_SOURCE_VALUE;
+
+ return (
+ {
+ const v = e.target.value;
+ if (v === '') {
+ onChange(createValue(''));
+ return;
+ }
+ if (v === STATIC_SOURCE_VALUE) {
+ const { staticStr } = parseHybridLocal(value);
+ onChange(createValue(isRef(value) ? '' : staticStr));
+ return;
+ }
+ const ref = optionValueToRef(v);
+ if (ref) onChange(ref);
+ }}
+ >
+ {placeholder}
+ {staticLabel}
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ );
+};
+
+interface RefSourceSelectProps {
+ value: DataRef | null;
+ onChange: (ref: DataRef | null) => void;
+ placeholder?: string;
+ pathPickMode?: PathPickMode;
+}
+
+/** Nur Kontext-Referenzen (ohne Statisch) — für If/Else, Switch, DynamicValueField. */
+export const RefSourceSelect: React.FC = ({
+ value,
+ onChange,
+ placeholder = 'Datenquelle wählen…',
+ pathPickMode = 'default',
+}) => {
+ const dataFlow = useAutomation2DataFlow();
+ if (!dataFlow) return null;
+
+ const sourceIds = dataFlow.getAvailableSourceIds();
+ const options: Array<{ ref: DataRef; label: string }> = [];
+
+ for (const nodeId of sourceIds) {
+ const node = dataFlow.nodes.find((n) => n.id === nodeId);
+ if (node?.type === 'trigger.manual') continue;
+ if (
+ pathPickMode === 'exclude_forms' &&
+ (node?.type === 'input.form' || node?.type === 'trigger.form')
+ ) {
+ continue;
+ }
+ const preview = dataFlow.nodeOutputsPreview[nodeId];
+ const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
+ const paths = pickPathsForNode(node, preview, pathPickMode);
+ for (const p of paths) {
+ const displayLabel = p.pathLabel ? `${nodeLabel} → ${p.pathLabel}` : nodeLabel;
+ options.push({
+ ref: createRef(nodeId, p.path),
+ label: displayLabel,
+ });
+ }
+ }
+
+ const currentValue = value ? refToOptionValue(value) : '';
+
+ return (
+ {
+ const v = e.target.value;
+ if (!v) {
+ onChange(null);
+ return;
+ }
+ const ref = optionValueToRef(v);
+ if (ref) onChange(ref);
+ }}
+ >
+ {placeholder}
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ );
+};
+
+/** Inferred field type for operator selection and value input */
+export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'email' | 'file' | 'unknown';
+
+function getFormFieldType(
+ node: { parameters?: Record; type?: string },
+ path: (string | number)[]
+): FieldType | null {
+ const params = node.parameters ?? {};
+ const raw = params.formFields ?? params.fields;
+ if (!Array.isArray(raw)) return null;
+ const isFormPayload =
+ (node.type === 'trigger.form' || node.type === 'input.form') && path[0] === 'payload';
+ const fieldName =
+ isFormPayload && path.length >= 2
+ ? String(path[1])
+ : path.length >= 1
+ ? String(path[0])
+ : null;
+ if (!fieldName) return null;
+ const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record).name === fieldName);
+ if (!field || typeof field !== 'object') return null;
+ const t = String((field as Record).type ?? 'text').toLowerCase();
+ if (t === 'number') return 'number';
+ if (t === 'email') return 'email';
+ if (t === 'date' || t === 'datetime') return 'date';
+ if (t === 'boolean' || t === 'checkbox') return 'boolean';
+ if (t === 'clickup_tasks') return 'string';
+ if (t === 'clickup_status') return 'string';
+ return 'string';
+}
+
+function getNodeOutputFieldType(
+ node: { type?: string },
+ path: (string | number)[]
+): FieldType | null {
+ if (node.type === 'input.upload') {
+ if (path.length === 0 || (path.length === 1 && path[0] === 'file')) return 'file';
+ if (path[0] === 'file' && path[1] === 'mimeType') return 'string';
+ if (path[0] === 'file' && path[1] === 'fileName') return 'string';
+ if (path.length === 1 && (path[0] === 'files' || path[0] === 'fileIds')) return 'file';
+ }
+ if ((node.type?.startsWith('sharepoint.') || node.type?.startsWith('email.')) && path.includes('file')) {
+ const last = path[path.length - 1];
+ if (last === 'mimeType' || last === 'fileName') return 'string';
+ return 'file';
+ }
+ return null;
+}
+
+/** Infer field type from ref: form schema, node output shape, or preview value. */
+export function getFieldType(
+ ref: DataRef | null,
+ nodes: Array<{ id: string; parameters?: Record; type?: string }>,
+ nodeOutputsPreview: Record
+): FieldType {
+ if (!ref) return 'unknown';
+ const node = nodes.find((n) => n.id === ref.nodeId);
+ if (node) {
+ const fromForm = getFormFieldType(node, ref.path);
+ if (fromForm) return fromForm;
+ const fromNode = getNodeOutputFieldType(node, ref.path);
+ if (fromNode) return fromNode;
+ }
+ const root = nodeOutputsPreview[ref.nodeId];
+ if (root === undefined) return 'unknown';
+ let current: unknown = root;
+ for (const seg of ref.path) {
+ if (current == null) return 'unknown';
+ const key = typeof seg === 'number' ? String(seg) : seg;
+ if (Array.isArray(current) && /^\d+$/.test(key)) {
+ current = current[parseInt(key, 10)];
+ } else if (typeof current === 'object' && key in current) {
+ current = (current as Record)[key];
+ } else return 'unknown';
+ }
+ if (typeof current === 'string') return 'string';
+ if (typeof current === 'number') return 'number';
+ if (typeof current === 'boolean') return 'boolean';
+ if (current && typeof current === 'object' && 'url' in (current as object)) return 'file';
+ return 'unknown';
+}
diff --git a/src/components/Automation2FlowEditor/categoryIcons.tsx b/src/components/Automation2FlowEditor/nodes/shared/categoryIcons.tsx
similarity index 79%
rename from src/components/Automation2FlowEditor/categoryIcons.tsx
rename to src/components/Automation2FlowEditor/nodes/shared/categoryIcons.tsx
index 53c19e4..a0ba719 100644
--- a/src/components/Automation2FlowEditor/categoryIcons.tsx
+++ b/src/components/Automation2FlowEditor/nodes/shared/categoryIcons.tsx
@@ -3,7 +3,7 @@
*/
import React from 'react';
-import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud } from 'react-icons/fa';
+import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa';
export const CATEGORY_ICONS: Record = {
trigger: ,
@@ -11,8 +11,10 @@ export const CATEGORY_ICONS: Record = {
flow: ,
data: ,
ai: ,
+ file: ,
email: ,
sharepoint: ,
+ clickup: ,
human: ,
};
diff --git a/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts b/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts
new file mode 100644
index 0000000..48faf0a
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts
@@ -0,0 +1,277 @@
+/**
+ * Sync input.form / trigger.form fields + ClickUp "Aufgabe erstellen" refs from a selected ClickUp list.
+ */
+
+import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
+import type { FormField } from './types';
+import { createRef } from './dataRef';
+
+export type ClickUpFieldLike = Record;
+
+function buildReverseAdjacency(connections: CanvasConnection[]): Record {
+ const rev: Record = {};
+ for (const c of connections) {
+ if (!rev[c.targetId]) rev[c.targetId] = [];
+ rev[c.targetId].push(c.sourceId);
+ }
+ return rev;
+}
+
+/** Nearest form node upstream (toward triggers) of the ClickUp node. */
+export function findClosestUpstreamFormNode(
+ targetNodeId: string,
+ nodes: CanvasNode[],
+ connections: CanvasConnection[]
+): CanvasNode | null {
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
+ const rev = buildReverseAdjacency(connections);
+ const queue: string[] = [...(rev[targetNodeId] ?? [])];
+ const visited = new Set();
+ while (queue.length > 0) {
+ const nid = queue.shift()!;
+ if (visited.has(nid)) continue;
+ visited.add(nid);
+ const n = nodeById.get(nid);
+ if (!n) continue;
+ if (n.type === 'input.form' || n.type === 'trigger.form') return n;
+ for (const p of rev[nid] ?? []) {
+ if (!visited.has(p)) queue.push(p);
+ }
+ }
+ return null;
+}
+
+export function normalizeClickUpFieldType(raw: unknown): string {
+ return String(raw ?? 'short_text')
+ .trim()
+ .toLowerCase()
+ .replace(/-/g, '_')
+ .replace(/\s+/g, '_');
+}
+
+function linkedListIdFromRelationshipField(field: ClickUpFieldLike): string | null {
+ const tc = (field.type_config ?? {}) as Record;
+ const asId = (v: unknown): string | null => {
+ if (typeof v === 'string' && v.trim()) return v.trim();
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
+ return null;
+ };
+ const keys = [
+ 'linked_list_id',
+ 'list_id',
+ 'related_list_id',
+ 'relationship_list_id',
+ 'resource_id',
+ ];
+ for (const k of keys) {
+ const raw = tc[k];
+ const id = asId(raw);
+ if (id) return id;
+ if (raw && typeof raw === 'object' && raw !== null) {
+ const nested = asId((raw as Record).id);
+ if (nested) return nested;
+ }
+ }
+ const rel = tc.relationship;
+ if (rel && typeof rel === 'object' && rel !== null) {
+ const r = rel as Record;
+ const fromRel = asId(r.list_id ?? r.id ?? r.target_id ?? r.linked_list_id ?? r.resource_id);
+ if (fromRel) return fromRel;
+ }
+ return null;
+}
+
+function fieldUnsupported(ft: string): boolean {
+ return ['tasks', 'user', 'users'].includes(ft);
+}
+
+function mapCuToInputFormField(
+ field: ClickUpFieldLike,
+ connectionId: string,
+ parentListId: string
+): FormField | null {
+ const fid = String(field.id ?? '');
+ if (!fid) return null;
+ const fname = String(field.name ?? fid);
+ const ft = normalizeClickUpFieldType(field.type);
+ if (fieldUnsupported(ft)) return null;
+ const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
+ const label = fname || name;
+
+ if (ft === 'list_relationship') {
+ const lid = linkedListIdFromRelationshipField(field) ?? parentListId;
+ return {
+ name,
+ label,
+ type: 'clickup_tasks',
+ required: false,
+ clickupConnectionId: connectionId,
+ clickupListId: lid,
+ };
+ }
+ if (
+ ft === 'drop_down' ||
+ ft === 'dropdown' ||
+ ft === 'text' ||
+ ft === 'long_text' ||
+ ft === 'short_text' ||
+ ft === 'email' ||
+ ft === 'phone' ||
+ ft === 'url'
+ ) {
+ return { name, label, type: 'string', required: false };
+ }
+ if (ft === 'number' || ft === 'currency') {
+ return { name, label, type: 'number', required: false };
+ }
+ if (ft === 'date') {
+ return { name, label, type: 'date', required: false };
+ }
+ if (ft === 'checkbox') {
+ return { name, label, type: 'boolean', required: false };
+ }
+ return { name, label, type: 'string', required: false };
+}
+
+/** trigger.form row; `clickup_status` carries options from the same list API as the ClickUp node dropdown. */
+export type TriggerFormFieldRow = {
+ name: string;
+ label: string;
+ type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
+ statusOptions?: Array<{ value: string; label: string }>;
+};
+
+function mapCuToTriggerFormField(field: ClickUpFieldLike, _connectionId: string, _parentListId: string): TriggerFormFieldRow | null {
+ const fid = String(field.id ?? '');
+ if (!fid) return null;
+ const fname = String(field.name ?? fid);
+ const ft = normalizeClickUpFieldType(field.type);
+ if (fieldUnsupported(ft)) return null;
+ const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
+ const label = fname || name;
+ if (ft === 'list_relationship') {
+ return { name, label, type: 'text' };
+ }
+ if (ft === 'number' || ft === 'currency') {
+ return { name, label, type: 'number' };
+ }
+ if (ft === 'date') {
+ return { name, label, type: 'date' };
+ }
+ if (ft === 'checkbox') {
+ return { name, label, type: 'boolean' };
+ }
+ if (ft === 'email') {
+ return { name, label, type: 'email' };
+ }
+ return { name, label, type: 'text' };
+}
+
+export const PAYLOAD_TITLE = 'title';
+export const PAYLOAD_DESCRIPTION = 'description';
+export const PAYLOAD_STATUS = 'clickup_status';
+export const PAYLOAD_PRIORITY = 'clickup_priority';
+export const PAYLOAD_DUE = 'clickup_due_date';
+export const PAYLOAD_TIME_H = 'clickup_time_estimate_h';
+
+/** Same ordering as ClickUp list `statuses` (GET /list/{id}). */
+export function statusOptionsFromListStatuses(
+ listStatuses: Array<{ status: string; orderindex: number }>
+): Array<{ value: string; label: string }> {
+ return [...listStatuses]
+ .sort((a, b) => a.orderindex - b.orderindex)
+ .map((s) => ({ value: s.status, label: s.status }));
+}
+
+export interface SyncFromListResult {
+ inputFormFields: FormField[];
+ triggerFormFields: TriggerFormFieldRow[];
+ clickupPatch: Record;
+}
+
+/**
+ * Build form field rows + ClickUp createTask parameter patch (refs → payload.*).
+ */
+export function buildSyncFromClickUpList(args: {
+ formNodeId: string;
+ listFields: ClickUpFieldLike[];
+ /** From GET /list/{id} → list.statuses (same source as the ClickUp node status dropdown). */
+ listStatuses: Array<{ status: string; orderindex: number }>;
+ connectionId: string;
+ teamId: string;
+ listId: string;
+}): SyncFromListResult {
+ const { formNodeId, listFields, listStatuses, connectionId, teamId, listId } = args;
+ const ref = (key: string) => createRef(formNodeId, ['payload', key]);
+
+ const statusOpts = statusOptionsFromListStatuses(listStatuses);
+
+ const standardInput: FormField[] = [
+ { name: PAYLOAD_TITLE, label: 'Titel', type: 'string', required: true },
+ { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'string', required: false },
+ ...(statusOpts.length > 0
+ ? [
+ {
+ name: PAYLOAD_STATUS,
+ label: 'Status',
+ type: 'clickup_status',
+ required: false,
+ clickupStatusOptions: statusOpts,
+ } as FormField,
+ ]
+ : []),
+ { name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number', required: false },
+ { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false },
+ { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
+ ];
+
+ const standardTrigger: TriggerFormFieldRow[] = [
+ { name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
+ { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
+ ...(statusOpts.length > 0
+ ? [{ name: PAYLOAD_STATUS, label: 'Status', type: 'clickup_status', statusOptions: statusOpts }]
+ : []),
+ { name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' },
+ { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
+ { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
+ ];
+
+ const customInput: FormField[] = [];
+ const customTrigger: TriggerFormFieldRow[] = [];
+ const customRefs: Record = {};
+
+ for (const f of listFields) {
+ if (!f || typeof f !== 'object') continue;
+ const inf = mapCuToInputFormField(f as ClickUpFieldLike, connectionId, listId);
+ const tr = mapCuToTriggerFormField(f as ClickUpFieldLike, connectionId, listId);
+ if (inf) customInput.push(inf);
+ if (tr) customTrigger.push(tr);
+ const fid = String((f as ClickUpFieldLike).id ?? '');
+ if (fid && inf) {
+ customRefs[fid] = createRef(formNodeId, ['payload', inf.name]);
+ }
+ }
+
+ const inputFormFields = [...standardInput, ...customInput];
+ const triggerFormFields = [...standardTrigger, ...customTrigger];
+
+ const clickupPatch: Record = {
+ connectionId,
+ teamId,
+ listId,
+ path: `/team/${teamId}/list/${listId}`,
+ name: ref(PAYLOAD_TITLE),
+ description: ref(PAYLOAD_DESCRIPTION),
+ taskPriority: ref(PAYLOAD_PRIORITY),
+ taskDueDateMs: ref(PAYLOAD_DUE),
+ taskTimeEstimateHours: ref(PAYLOAD_TIME_H),
+ };
+ if (statusOpts.length > 0) {
+ clickupPatch.taskStatus = ref(PAYLOAD_STATUS);
+ }
+ if (Object.keys(customRefs).length) {
+ clickupPatch.customFieldValues = customRefs;
+ }
+
+ return { inputFormFields, triggerFormFields, clickupPatch };
+}
diff --git a/src/components/Automation2FlowEditor/nodes/shared/conditionOperators.ts b/src/components/Automation2FlowEditor/nodes/shared/conditionOperators.ts
new file mode 100644
index 0000000..1b00956
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/conditionOperators.ts
@@ -0,0 +1,66 @@
+/**
+ * Shared condition operators for If/Else and Switch nodes.
+ * Type-dependent: number gets <, >, etc.; string gets contains, equals, etc.
+ */
+
+import type { FieldType } from './RefSourceSelect';
+
+export interface OperatorDef {
+ value: string;
+ label: string;
+ needsValue: boolean;
+}
+
+export const STRING_OPERATORS: OperatorDef[] = [
+ { value: 'eq', label: 'ist gleich', needsValue: true },
+ { value: 'neq', label: 'ist ungleich', needsValue: true },
+ { value: 'contains', label: 'enthält', needsValue: true },
+ { value: 'not_contains', label: 'enthält nicht', needsValue: true },
+ { value: 'empty', label: 'ist leer', needsValue: false },
+ { value: 'not_empty', label: 'ist nicht leer', needsValue: false },
+];
+
+export const NUMBER_OPERATORS: OperatorDef[] = [
+ { value: 'eq', label: '=', needsValue: true },
+ { value: 'neq', label: '≠', needsValue: true },
+ { value: 'lt', label: '<', needsValue: true },
+ { value: 'lte', label: '≤', needsValue: true },
+ { value: 'gt', label: '>', needsValue: true },
+ { value: 'gte', label: '≥', needsValue: true },
+];
+
+export const DATE_OPERATORS: OperatorDef[] = [
+ { value: 'eq', label: 'ist gleich', needsValue: true },
+ { value: 'neq', label: 'ist ungleich', needsValue: true },
+ { value: 'before', label: 'vor', needsValue: true },
+ { value: 'after', label: 'nach', needsValue: true },
+];
+
+export const BOOLEAN_OPERATORS: OperatorDef[] = [
+ { value: 'is_true', label: 'ist wahr', needsValue: false },
+ { value: 'is_false', label: 'ist falsch', needsValue: false },
+];
+
+export const FILE_OPERATORS: OperatorDef[] = [
+ { value: 'exists', label: 'vorhanden', needsValue: false },
+ { value: 'not_exists', label: 'nicht vorhanden', needsValue: false },
+ { value: 'not_empty', label: 'nicht leer', needsValue: false },
+ { value: 'empty', label: 'ist leer', needsValue: false },
+];
+
+const ALL_OPERATORS: OperatorDef[] = [
+ ...STRING_OPERATORS,
+ ...NUMBER_OPERATORS,
+ ...DATE_OPERATORS,
+ ...BOOLEAN_OPERATORS,
+ ...FILE_OPERATORS,
+];
+
+export function operatorsForType(t: FieldType): OperatorDef[] {
+ if (t === 'string' || t === 'email') return STRING_OPERATORS;
+ if (t === 'number') return NUMBER_OPERATORS;
+ if (t === 'date') return DATE_OPERATORS;
+ if (t === 'boolean') return BOOLEAN_OPERATORS;
+ if (t === 'file') return FILE_OPERATORS;
+ return ALL_OPERATORS;
+}
diff --git a/src/components/Automation2FlowEditor/nodes/shared/constants.ts b/src/components/Automation2FlowEditor/nodes/shared/constants.ts
new file mode 100644
index 0000000..b323fec
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/constants.ts
@@ -0,0 +1,20 @@
+/**
+ * Automation2 Flow Editor - Constants
+ * Category ordering for node sidebar.
+ */
+
+/** Node type IDs hidden from the sidebar (empty = show all registered types) */
+export const HIDDEN_NODE_IDS = new Set();
+
+/** Default category display order */
+export const CATEGORY_ORDER = [
+ 'trigger',
+ 'input',
+ 'flow',
+ 'data',
+ 'ai',
+ 'file',
+ 'email',
+ 'sharepoint',
+ 'clickup',
+] as const;
diff --git a/src/components/Automation2FlowEditor/nodes/shared/dataFlowGraph.ts b/src/components/Automation2FlowEditor/nodes/shared/dataFlowGraph.ts
new file mode 100644
index 0000000..71755e2
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/dataFlowGraph.ts
@@ -0,0 +1,97 @@
+/**
+ * Automation2 Flow Editor - Graph helpers for data flow (ancestors, topo order).
+ */
+
+import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
+
+/** Build reverse adjacency: targetId -> sourceId[] */
+function buildReverseAdjacency(connections: CanvasConnection[]): Record {
+ const rev: Record = {};
+ for (const c of connections) {
+ if (!rev[c.targetId]) rev[c.targetId] = [];
+ rev[c.targetId].push(c.sourceId);
+ }
+ return rev;
+}
+
+/** BFS backward from node to collect all ancestor node IDs */
+export function getAncestorNodeIds(
+ currentNodeId: string,
+ nodes: CanvasNode[],
+ connections: CanvasConnection[]
+): string[] {
+ const nodeIds = new Set(nodes.map((n) => n.id));
+ const rev = buildReverseAdjacency(connections);
+ const result = new Set();
+ const queue = [currentNodeId];
+ const visited = new Set([currentNodeId]);
+
+ while (queue.length > 0) {
+ const nid = queue.shift()!;
+ const sources = rev[nid] ?? [];
+ for (const src of sources) {
+ if (!visited.has(src) && nodeIds.has(src)) {
+ visited.add(src);
+ result.add(src);
+ queue.push(src);
+ }
+ }
+ }
+ return Array.from(result);
+}
+
+/** Topological order: triggers first, then BFS by connections (mirrors backend topoSort) */
+export function topologicalOrder(
+ nodes: CanvasNode[],
+ connections: CanvasConnection[]
+): CanvasNode[] {
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
+ const triggers = nodes.filter((n) => n.type.startsWith('trigger.'));
+ if (triggers.length === 0) return [...nodes];
+
+ const fwd: Record = {};
+ for (const c of connections) {
+ if (!fwd[c.sourceId]) fwd[c.sourceId] = [];
+ fwd[c.sourceId].push(c.targetId);
+ }
+
+ const order: CanvasNode[] = [];
+ const visited = new Set();
+
+ const q = [...triggers.map((t) => t.id)];
+ for (const tid of triggers.map((t) => t.id)) {
+ if (!visited.has(tid)) {
+ visited.add(tid);
+ const n = nodeById.get(tid);
+ if (n) order.push(n);
+ }
+ }
+
+ let i = 0;
+ while (i < q.length) {
+ const nid = q[i++];
+ const targets = fwd[nid] ?? [];
+ for (const tgt of targets) {
+ if (!visited.has(tgt)) {
+ visited.add(tgt);
+ q.push(tgt);
+ const n = nodeById.get(tgt);
+ if (n) order.push(n);
+ }
+ }
+ }
+
+ for (const n of nodes) {
+ if (n.id && !visited.has(n.id)) order.push(n);
+ }
+ return order;
+}
+
+/** Node IDs that are valid sources for the current node (ancestors in DAG) */
+export function getAvailableSources(
+ currentNodeId: string,
+ nodes: CanvasNode[],
+ connections: CanvasConnection[]
+): string[] {
+ return getAncestorNodeIds(currentNodeId, nodes, connections);
+}
diff --git a/src/components/Automation2FlowEditor/nodes/shared/dataRef.ts b/src/components/Automation2FlowEditor/nodes/shared/dataRef.ts
new file mode 100644
index 0000000..5b5a91e
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/dataRef.ts
@@ -0,0 +1,91 @@
+/**
+ * Automation2 Flow Editor - Data reference format and helpers.
+ * All dynamic values use structured ref/value objects, not plain strings.
+ */
+
+/** Structured reference to another node's output (path = JSON path segments) */
+export interface DataRef {
+ type: 'ref';
+ nodeId: string;
+ path: (string | number)[];
+}
+
+/** Explicit static value wrapper */
+export interface DataValue {
+ type: 'value';
+ value: unknown;
+}
+
+/** Union: either a reference or a static value */
+export type DynamicValue = DataRef | DataValue;
+
+/** Type guards */
+export function isRef(v: unknown): v is DataRef {
+ return (
+ typeof v === 'object' &&
+ v !== null &&
+ (v as DataRef).type === 'ref' &&
+ typeof (v as DataRef).nodeId === 'string' &&
+ Array.isArray((v as DataRef).path)
+ );
+}
+
+export function isValue(v: unknown): v is DataValue {
+ return (
+ typeof v === 'object' &&
+ v !== null &&
+ (v as DataValue).type === 'value'
+ );
+}
+
+export function isDynamicValue(v: unknown): v is DynamicValue {
+ return isRef(v) || isValue(v);
+}
+
+/** Create a reference object */
+export function createRef(nodeId: string, path: (string | number)[] = []): DataRef {
+ return { type: 'ref', nodeId, path };
+}
+
+/** Create a value wrapper */
+export function createValue(value: unknown): DataValue {
+ return { type: 'value', value };
+}
+
+/** Resolve a ref against nodeOutputsPreview for UI preview; returns resolved value or undefined if missing */
+export function resolvePreview(
+ ref: DataRef,
+ nodeOutputsPreview: Record
+): unknown {
+ const root = nodeOutputsPreview[ref.nodeId];
+ if (root === undefined) return undefined;
+ let current: unknown = root;
+ for (const seg of ref.path) {
+ if (current == null) return undefined;
+ const key = typeof seg === 'number' ? String(seg) : seg;
+ if (Array.isArray(current) && /^\d+$/.test(key)) {
+ const idx = parseInt(key, 10);
+ if (idx >= 0 && idx < current.length) current = current[idx];
+ else return undefined;
+ } else if (typeof current === 'object' && key in current) {
+ current = (current as Record)[key];
+ } else return undefined;
+ }
+ return current;
+}
+
+/** Format a ref for human display: "Node Title → path.segment" */
+export function formatRefLabel(
+ ref: DataRef,
+ nodes: Array<{ id: string; title?: string }>,
+ nodeLabelFallback?: (nodeId: string) => string
+): string {
+ const node = nodes.find((n) => n.id === ref.nodeId);
+ const nodeLabel =
+ node?.title?.trim() ||
+ nodeLabelFallback?.(ref.nodeId) ||
+ ref.nodeId;
+ if (ref.path.length === 0) return nodeLabel;
+ const pathStr = ref.path.map((p) => String(p)).join(' → ');
+ return `${nodeLabel} → ${pathStr}`;
+}
diff --git a/src/components/Automation2FlowEditor/graphUtils.ts b/src/components/Automation2FlowEditor/nodes/shared/graphUtils.ts
similarity index 80%
rename from src/components/Automation2FlowEditor/graphUtils.ts
rename to src/components/Automation2FlowEditor/nodes/shared/graphUtils.ts
index 252e4f5..e1b417e 100644
--- a/src/components/Automation2FlowEditor/graphUtils.ts
+++ b/src/components/Automation2FlowEditor/nodes/shared/graphUtils.ts
@@ -3,9 +3,13 @@
* Converts between API graph format and canvas internal format.
*/
-import type { NodeType } from '../../api/automation2Api';
-import type { CanvasNode, CanvasConnection } from './FlowCanvas';
-import type { Automation2Graph } from '../../api/automation2Api';
+import type {
+ NodeType,
+ Automation2Graph,
+ Automation2GraphNode,
+ Automation2Connection,
+} from '../../../../api/automation2Api';
+import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
export function fromApiGraph(
graph: Automation2Graph,
@@ -16,8 +20,13 @@ export function fromApiGraph(
nodeMap.set(nt.id, { inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1 });
});
- const nodes: CanvasNode[] = (graph.nodes || []).map((n) => {
+ const nodes: CanvasNode[] = (graph.nodes || []).map((n: Automation2GraphNode) => {
const io = nodeMap.get(n.type) ?? { inputs: 1, outputs: 1 };
+ let outputs = io.outputs;
+ if (n.type === 'flow.switch') {
+ const cases = (n.parameters?.cases as unknown[]) ?? [];
+ outputs = Math.max(1, cases.length);
+ }
return {
id: n.id,
type: n.type,
@@ -26,13 +35,13 @@ export function fromApiGraph(
title: (n as { title?: string }).title ?? (typeof n.type === 'string' ? n.type : ''),
comment: (n as { comment?: string }).comment,
inputs: io.inputs,
- outputs: io.outputs,
+ outputs,
parameters: n.parameters ?? {},
};
});
const connId = (s: string, t: string, so: number, ti: number) => `c_${s}_${so}_${t}_${ti}`;
- const connections: CanvasConnection[] = (graph.connections || []).map((c) => {
+ const connections: CanvasConnection[] = (graph.connections || []).map((c: Automation2Connection) => {
const srcNode = nodes.find((n) => n.id === c.source);
const sourceOutput = c.sourceOutput ?? 0;
const sourceHandle = srcNode ? srcNode.inputs + sourceOutput : 0;
diff --git a/src/components/Automation2FlowEditor/nodes/shared/outputPreviewRegistry.ts b/src/components/Automation2FlowEditor/nodes/shared/outputPreviewRegistry.ts
new file mode 100644
index 0000000..ac28052
--- /dev/null
+++ b/src/components/Automation2FlowEditor/nodes/shared/outputPreviewRegistry.ts
@@ -0,0 +1,153 @@
+/**
+ * Automation2 Flow Editor - Output preview builders per node type.
+ * Derives example output trees from node parameters for Data Picker.
+ * Extensible: register builders for new node types without changing core logic.
+ */
+
+import type { CanvasNode } from '../../editor/FlowCanvas';
+
+export type OutputPreviewBuilder = (node: CanvasNode) => unknown;
+
+const builders: Record = {};
+
+function parseFormFields(
+ params: Record
+): Array<{ name: string; type?: string }> {
+ const raw = params.formFields ?? params.fields;
+ if (!Array.isArray(raw)) return [];
+ return raw.map((f, i) => {
+ if (f && typeof f === 'object' && !Array.isArray(f)) {
+ const o = f as Record;
+ return {
+ name: String(o.name ?? `field${i + 1}`),
+ type: typeof o.type === 'string' ? o.type : undefined,
+ };
+ }
+ return { name: `field${i + 1}` };
+ });
+}
+
+function runEnvelopeBase(): Record {
+ return {
+ trigger: { type: 'manual' },
+ payload: {},
+ context: {},
+ files: [],
+ user: {},
+ metadata: {},
+ raw: {},
+ };
+}
+
+/** Register a builder for a node type id (exact match) or prefix (use '*' suffix) */
+export function registerOutputPreview(typeIdOrPrefix: string, builder: OutputPreviewBuilder): void {
+ builders[typeIdOrPrefix] = builder;
+}
+
+/** Build preview for a single node; returns {} for unknown types */
+export function buildNodeOutputPreview(node: CanvasNode): unknown {
+ const exact = builders[node.type];
+ if (exact) return exact(node);
+
+ const prefix = node.type.split('.')[0];
+ const prefixBuilder = builders[`${prefix}.*`];
+ if (prefixBuilder) return prefixBuilder(node);
+
+ return {};
+}
+
+/** Build full nodeOutputsPreview map from graph */
+export function buildNodeOutputsPreview(
+ nodes: CanvasNode[],
+ nodeOutputsFromRun?: Record
+): Record {
+ const result: Record = {};
+ for (const n of nodes) {
+ const fromRun = nodeOutputsFromRun?.[n.id];
+ if (fromRun !== undefined) {
+ result[n.id] = fromRun;
+ } else {
+ result[n.id] = buildNodeOutputPreview(n);
+ }
+ }
+ return result;
+}
+
+// ---- Built-in builders (extensible, no hardcoding in core) ----
+
+registerOutputPreview('trigger.manual', () => runEnvelopeBase());
+registerOutputPreview('trigger.schedule', () => runEnvelopeBase());
+
+registerOutputPreview('trigger.form', (node) => {
+ const params = node.parameters ?? {};
+ const fields = parseFormFields(params);
+ const payload: Record