next version of visual workflow editor with Clickup Connector

This commit is contained in:
idittrich-valueon 2026-03-25 09:39:19 +01:00
parent cc8a699e58
commit 0f791a53fb
75 changed files with 9926 additions and 1394 deletions

1
Untitled Normal file
View file

@ -0,0 +1 @@
s

View file

@ -27,6 +27,8 @@ export interface NodeType {
parameters: NodeTypeParameter[];
inputs: number;
outputs: number;
/** Labels per output (e.g. ["Ja", "Nein"] for flow.ifElse) */
outputLabels?: string[];
executor: string;
meta?: {
icon?: string;
@ -76,11 +78,24 @@ export interface ExecuteGraphResponse {
nodeId?: string;
}
/** Entry point / start configured outside the canvas (manual, form, schedule, …) */
export interface WorkflowEntryPoint {
id: string;
kind: string;
category: 'on_demand' | 'always_on';
enabled: boolean;
title: Record<string, string> | string;
description?: Record<string, string>;
config: Record<string, unknown>;
}
export interface Automation2Workflow {
id: string;
label: string;
graph: Automation2Graph;
active?: boolean;
/** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[];
/** Enriched: run count */
runCount?: number;
/** Enriched: has active (running/paused) run */
@ -128,22 +143,36 @@ export async function fetchNodeTypes(
* Execute an automation2 graph.
* POST /api/automation2/{instanceId}/execute
*/
export interface ExecuteGraphOptions {
/** Use a configured start on the saved workflow */
entryPointId?: string;
/** Full run envelope (overrides entry point mapping) */
runEnvelope?: Record<string, unknown>;
/** Merged into envelope.payload */
payload?: Record<string, unknown>;
}
export async function executeGraph(
request: ApiRequestFunction,
instanceId: string,
graph: Automation2Graph,
workflowId?: string
workflowId?: string,
options?: ExecuteGraphOptions
): Promise<ExecuteGraphResponse> {
console.log(
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
{ nodes: graph.nodes, connections: graph.connections }
{ nodes: graph.nodes, connections: graph.connections, options }
);
const start = performance.now();
try {
const data: Record<string, unknown> = { graph, workflowId };
if (options?.entryPointId) data.entryPointId = options.entryPointId;
if (options?.runEnvelope) data.runEnvelope = options.runEnvelope;
if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload;
const result = await request({
url: `/api/automation2/${instanceId}/execute`,
method: 'post',
data: { graph, workflowId },
data,
});
const ms = Math.round(performance.now() - start);
console.log(
@ -167,11 +196,13 @@ export async function executeGraph(
export async function fetchWorkflows(
request: ApiRequestFunction,
instanceId: string
instanceId: string,
params?: { active?: boolean }
): Promise<Automation2Workflow[]> {
const data = await request({
url: `/api/automation2/${instanceId}/workflows`,
method: 'get',
params: params?.active !== undefined ? { active: params.active } : undefined,
});
return data?.workflows ?? [];
}
@ -190,7 +221,7 @@ export async function fetchWorkflow(
export async function createWorkflow(
request: ApiRequestFunction,
instanceId: string,
body: { label: string; graph: Automation2Graph }
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
): Promise<Automation2Workflow> {
return await request({
url: `/api/automation2/${instanceId}/workflows`,
@ -203,7 +234,12 @@ export async function updateWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
body: { label?: string; graph?: Automation2Graph }
body: {
label?: string;
graph?: Automation2Graph;
invocations?: WorkflowEntryPoint[];
active?: boolean;
}
): Promise<Automation2Workflow> {
return await request({
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
@ -243,6 +279,25 @@ export async function fetchWorkflowRuns(
return data?.runs ?? [];
}
export interface CompletedRun extends Automation2Run {
workflowLabel?: string;
_modifiedAt?: number;
_createdAt?: number;
}
export async function fetchCompletedRuns(
request: ApiRequestFunction,
instanceId: string,
limit = 20
): Promise<CompletedRun[]> {
const data = await request({
url: `/api/automation2/${instanceId}/runs/completed`,
method: 'get',
params: { limit },
});
return data?.runs ?? [];
}
// -------------------------------------------------------------------------
// Tasks
// -------------------------------------------------------------------------
@ -354,3 +409,124 @@ export async function fetchBrowse(
});
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
}
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
export async function fetchClickupTask(
request: ApiRequestFunction,
connectionId: string,
taskId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
export async function fetchClickupList(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
export async function fetchClickupTeam(
request: ApiRequestFunction,
connectionId: string,
teamId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/teams/${teamId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
export async function fetchClickupListFields(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/fields`,
method: 'get',
});
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
}
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
export interface ClickupListTaskItem {
id?: string;
name?: string;
}
export async function fetchClickupListTasks(
request: ApiRequestFunction,
connectionId: string,
listId: string,
options?: { page?: number; includeClosed?: boolean }
): Promise<
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
method: 'get',
params: {
page: options?.page ?? 0,
include_closed: options?.includeClosed ?? false,
},
});
return (data && typeof data === 'object' ? data : {}) as {
tasks?: ClickupListTaskItem[];
last_page?: boolean;
} & Record<string, unknown>;
}
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */
export async function loadClickupListTasksForDropdown(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Array<{ id: string; name: string }>> {
const acc: Array<{ id: string; name: string }> = [];
const seen = new Set<string>();
const maxPages = 12;
const pageSizeHint = 100;
for (let page = 0; page < maxPages; page++) {
const data = await fetchClickupListTasks(request, connectionId, listId, {
page,
includeClosed: false,
});
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
const err = (data as { error?: unknown }).error;
const body = (data as { body?: string }).body;
throw new Error(
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
);
}
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
for (const t of tasks) {
const id = t?.id != null ? String(t.id) : '';
if (!id || seen.has(id)) continue;
seen.add(id);
acc.push({ id, name: String(t.name ?? id) });
}
const rawLast = (data as Record<string, unknown>).last_page;
const last =
rawLast === true ||
rawLast === 'true' ||
tasks.length === 0 ||
tasks.length < pageSizeHint;
if (last) break;
}
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}

View file

@ -7,7 +7,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface Connection {
id: string;
userId: string;
authority: 'local' | 'google' | 'msft';
authority: 'local' | 'google' | 'msft' | 'clickup';
externalId: string;
externalUsername: string;
externalEmail?: string;
@ -52,8 +52,8 @@ export interface PaginatedResponse<T> {
export interface CreateConnectionData {
id?: string;
userId?: string;
authority?: 'msft' | 'google';
type?: 'msft' | 'google'; // Backend expects this field
authority?: 'msft' | 'google' | 'clickup';
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority
externalId?: string;
externalUsername?: string;
externalEmail?: string;

View file

@ -1,499 +0,0 @@
/**
* Automation2 Flow Editor Styles
* Sidebar with node list + canvas area.
*/
.container {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* =============================================================================
SIDEBAR - Node List
============================================================================= */
.sidebar {
flex-shrink: 0;
width: 280px;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #f8f9fa);
border-right: 1px solid var(--border-color, #e0e0e0);
overflow: hidden;
}
.sidebarHeader {
padding: 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff);
}
.sidebarTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.sidebarSearch {
margin-top: 0.75rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.sidebarSearch::placeholder {
color: var(--text-tertiary, #999);
}
.sidebarSearch:focus {
outline: none;
border-color: var(--primary-color, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
}
.nodeList {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Category Groups */
.categoryGroup {
margin-bottom: 1rem;
}
.categoryHeader {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #666);
}
.categoryIcon {
margin-right: 0.5rem;
font-size: 0.875rem;
}
.categoryLabel {
flex: 1;
}
.categoryCount {
background: var(--bg-tertiary, #e9ecef);
color: var(--text-secondary, #666);
padding: 0.125rem 0.5rem;
border-radius: 10px;
font-size: 0.7rem;
}
/* Node Items */
.nodeItem {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
margin-bottom: 0.25rem;
border-radius: 6px;
cursor: grab;
transition: background 0.15s;
border: 1px solid transparent;
}
.nodeItem:hover {
background: var(--bg-hover, #e9ecef);
}
.nodeItem:active {
cursor: grabbing;
}
.nodeItemIcon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
margin-right: 0.75rem;
font-size: 0.875rem;
}
.nodeItemInfo {
flex: 1;
min-width: 0;
}
.nodeItemLabel {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.nodeItemDesc {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Loading / Error */
.loading,
.error {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
.error {
color: var(--danger-color, #dc3545);
}
.retryButton {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.retryButton:hover {
background: var(--primary-hover, #0056b3);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* =============================================================================
CANVAS
============================================================================= */
.canvas {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--canvas-bg, #fafafa);
}
.canvasHeader {
flex-shrink: 0;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff);
}
.canvasTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #666);
}
.canvasArea {
flex: 1;
padding: 2rem;
min-height: 400px;
overflow: hidden;
}
.canvasDropZone {
position: relative;
min-height: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
background-repeat: repeat;
}
.canvasContent {
position: absolute;
left: 0;
top: 0;
will-change: transform;
background: transparent;
}
.canvasGrab {
cursor: grab;
}
.canvasPanning {
cursor: grabbing;
user-select: none;
}
.canvasPlaceholder {
position: absolute;
left: 2rem;
top: 2rem;
text-align: center;
color: var(--text-tertiary, #999);
border: 2px dashed var(--border-color, #dee2e6);
border-radius: 8px;
padding: 2rem 3rem;
}
.canvasPlaceholder p {
margin: 0.25rem 0;
font-size: 0.875rem;
}
/* Canvas Nodes */
.canvasNode {
position: absolute;
border-radius: 8px;
border: 2px solid;
cursor: grab;
overflow: visible;
}
.canvasNode:active {
cursor: grabbing;
}
.canvasNodeSelected {
box-shadow: 0 0 0 2px var(--primary-color, #007bff);
}
.canvasNodeContent {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
height: 100%;
box-sizing: border-box;
}
.canvasNodeIcon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.9rem;
}
.canvasNodeText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.canvasNodeTitle {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.canvasNodeTitle:hover {
text-decoration: underline;
}
.canvasNodeComment {
font-size: 0.7rem;
color: var(--text-tertiary, #999);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
min-height: 1em;
}
.canvasNodeComment:hover {
text-decoration: underline;
}
.canvasNodeInput {
width: 100%;
padding: 0.15rem 0.25rem;
font-size: 0.875rem;
border: 1px solid var(--primary-color, #007bff);
border-radius: 4px;
outline: none;
}
/* Connection Handles */
.handle {
position: absolute;
border-radius: 50%;
background: var(--bg-primary, #fff);
border: 2px solid var(--border-color, #666);
cursor: crosshair;
z-index: 2;
}
.handle:hover,
.handleConnectable {
border-color: var(--primary-color, #007bff);
background: var(--primary-color, #007bff);
}
.handleInput {
cursor: copy;
}
/* Node Config Panel */
.nodeConfigPanel {
padding: 1rem;
background: var(--bg-primary, #fff);
border-left: 1px solid var(--border-color, #e0e0e0);
width: 280px;
flex-shrink: 0;
overflow-y: auto;
min-width: 0;
}
.nodeConfigPanel h4 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
}
.nodeConfigPanel label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #666);
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
.nodeConfigPanel input[type='text'],
.nodeConfigPanel input[type='number'],
.nodeConfigPanel select,
.nodeConfigPanel textarea {
width: 100%;
padding: 0.4rem;
font-size: 0.875rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
}
.nodeConfigPanel textarea {
min-height: 60px;
}
.nodeConfigPanel button {
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
/* Form fields editor (input.form) */
.formFieldsList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.formFieldRow {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem;
background: var(--bg-secondary, #f8f9fa);
border-radius: 6px;
border: 1px solid var(--border-color, #e0e0e0);
}
.formFieldRowHeader {
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.formFieldDragHandle {
flex-shrink: 0;
padding: 0.25rem;
cursor: grab;
color: var(--text-tertiary, #999);
align-self: stretch;
display: flex;
align-items: center;
}
.formFieldDragHandle:active {
cursor: grabbing;
}
.formFieldDragHandle:hover {
color: var(--text-secondary, #666);
}
.formFieldInputs {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.formFieldRowFooter {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.formFieldRequiredLabel {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
cursor: pointer;
}
.formFieldRemoveButton {
margin-left: auto;
padding: 0.25rem 0.4rem;
border: none;
background: transparent;
color: var(--text-tertiary, #999);
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
}
.formFieldRemoveButton:hover {
color: var(--danger-color, #dc3545);
background: rgba(220, 53, 69, 0.1);
}

View file

@ -1,70 +0,0 @@
/**
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
* Delegates to config components from configs/.
*/
import React, { useState, useEffect } from 'react';
import type { CanvasNode } from './FlowCanvas';
import type { NodeType } from '../../api/automation2Api';
import type { ApiRequestFunction } from '../../api/automation2Api';
import { getLabel } from './utils';
import { NODE_CONFIG_REGISTRY } from './configs';
import styles from './Automation2FlowEditor.module.css';
interface NodeConfigPanelProps {
node: CanvasNode | null;
nodeType: NodeType | undefined;
language: string;
onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void;
instanceId?: string;
request?: ApiRequestFunction;
}
const CONFIGURABLE_PREFIXES = ['input.', 'ai.', 'email.', 'sharepoint.'];
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
node,
nodeType,
language,
onParametersChange,
instanceId,
request,
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
useEffect(() => {
setParams(node?.parameters ?? {});
}, [node?.id, node?.parameters]);
const updateParam = (key: string, value: unknown) => {
const next = { ...params, [key]: value };
setParams(next);
if (node) onParametersChange(node.id, next);
};
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
if (!node || !isConfigurable) return null;
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
if (!ConfigRenderer) {
return (
<div className={styles.nodeConfigPanel}>
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<p>No configuration for {node.type}</p>
</div>
);
}
return (
<div className={styles.nodeConfigPanel}>
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<ConfigRenderer
params={params}
updateParam={updateParam}
instanceId={instanceId}
request={request}
nodeType={node.type}
/>
</div>
);
};

View file

@ -1,68 +0,0 @@
/**
* AI node config - prompt, query, document options per node type.
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
const AI_FIELD_CONFIG: Record<string, { label: string; key: string; type: 'textarea' | 'input' | 'select'; options?: string[] }[]> = {
'ai.prompt': [
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
{ label: 'Output format', key: 'resultType', type: 'select', options: ['txt', 'json', 'md', 'html', 'csv'] },
],
'ai.webResearch': [{ label: 'Query', key: 'query', type: 'textarea' }],
'ai.summarizeDocument': [
{ label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] },
],
'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }],
'ai.convertDocument': [
{ label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] },
],
'ai.generateDocument': [
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
{ label: 'Format', key: 'format', type: 'select', options: ['docx', 'txt', 'md'] },
],
'ai.generateCode': [
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
{ label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] },
],
};
export const AiNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam, nodeType = 'ai.prompt' }) => {
const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt'];
return (
<>
{fields.map((f) => (
<div key={f.key}>
<label>{f.label}</label>
{f.type === 'textarea' ? (
<textarea
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
rows={4}
/>
) : f.type === 'select' ? (
<select
value={(params[f.key] as string) ?? (f.options?.[0] ?? '')}
onChange={(e) => updateParam(f.key, e.target.value)}
>
{(f.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
/>
)}
</div>
))}
</>
);
};

View file

@ -1,126 +0,0 @@
/**
* Form node config - draggable fields, types, required toggle
*/
import React from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from './types';
import styles from '../Automation2FlowEditor.module.css';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const fields = (params.fields as FormField[]) ?? [];
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 (
<div>
<label>Felder</label>
<div className={styles.formFieldsList}>
{fields.map((f, i) => (
<div
key={i}
className={styles.formFieldRow}
onDragOver={(e) => {
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);
}}
>
<div className={styles.formFieldRowHeader}>
<span
className={styles.formFieldDragHandle}
title="Zum Verschieben ziehen"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', String(i));
e.dataTransfer.effectAllowed = 'move';
}}
>
<FaGripVertical />
</span>
<div className={styles.formFieldInputs}>
<input
placeholder="name"
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder="label"
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], label: e.target.value };
updateParam('fields', next);
}}
/>
</div>
</div>
<div className={styles.formFieldRowFooter}>
<select
value={f.type ?? 'string'}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], type: e.target.value };
updateParam('fields', next);
}}
style={{ width: 'auto', minWidth: 90 }}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Checkbox</option>
</select>
<label className={styles.formFieldRequiredLabel}>
<input
type="checkbox"
checked={f.required ?? false}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], required: e.target.checked };
updateParam('fields', next);
}}
/>
Pflichtfeld
</label>
<button
type="button"
onClick={() => removeField(i)}
title="Feld entfernen"
className={styles.formFieldRemoveButton}
>
<FaTimes />
</button>
</div>
</div>
))}
<button
type="button"
onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
}
>
+ Feld
</button>
</div>
</div>
);
};

View file

@ -1,17 +0,0 @@
/**
* Review node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ReviewNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<div>
<label>Content-Referenz</label>
<input
value={(params.contentRef as string) ?? ''}
onChange={(e) => updateParam('contentRef', e.target.value)}
placeholder="{{nodeId.field}}"
/>
</div>
);

View file

@ -1,245 +0,0 @@
/**
* SharePoint node config - connection selector, path, search query.
* Uses SharepointBrowseTree (FolderTree-style) for file selection.
*/
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';
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
nodeType = 'sharepoint.findFile',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
const [browseExpanded, setBrowseExpanded] = useState(false);
const [copySourceExpanded, setCopySourceExpanded] = useState(false);
const [copyDestExpanded, setCopyDestExpanded] = useState(false);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const connectionId = (params.connectionId as string) ?? '';
const pathParam = 'path';
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<BrowseEntry[]> => {
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(pathParam, p);
setBrowseExpanded(false);
},
[updateParam, pathParam]
);
const selectSourcePath = useCallback(
(p: string) => {
updateParam('sourcePath', p);
setCopySourceExpanded(false);
},
[updateParam]
);
const selectDestPath = useCallback(
(p: string) => {
updateParam('destPath', p);
setCopyDestExpanded(false);
},
[updateParam]
);
const needsPath = !['sharepoint.findFile'].includes(nodeType);
const needsSearch = nodeType === 'sharepoint.findFile';
const needsSiteId = false;
const hasPathInput = ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile', 'sharepoint.copyFile'].includes(nodeType);
return (
<>
<div>
<label>Connection</label>
<select
value={connectionId}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={connectionsLoading}
>
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{needsSearch && (
<div>
<label>Search query / path</label>
<input
value={(params.searchQuery as string) ?? ''}
onChange={(e) => updateParam('searchQuery', e.target.value)}
placeholder="/sites/SiteName/Shared Documents or search term"
/>
</div>
)}
{needsPath && nodeType === 'sharepoint.listFiles' && (
<div>
<label>Folder path</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
placeholder="/ or /sites/SiteName/Shared Documents/Folder"
/>
</div>
)}
{needsPath && ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile'].includes(nodeType) && (
<div>
<label>{nodeType === 'sharepoint.uploadFile' ? 'Target folder path' : 'Path'}</label>
<input
value={(params.path as string) ?? (params.filePath as string) ?? ''}
onChange={(e) => updateParam('path', e.target.value)}
placeholder={
nodeType === 'sharepoint.downloadFile'
? '/sites/SiteName/Shared Documents/file.pdf'
: nodeType === 'sharepoint.uploadFile'
? '/sites/.../Shared Documents/TargetFolder/'
: 'File or folder path'
}
/>
</div>
)}
{needsSiteId && (
<div>
<label>Site ID</label>
<input
value={(params.siteId as string) ?? ''}
onChange={(e) => updateParam('siteId', e.target.value)}
placeholder="SharePoint site ID"
/>
</div>
)}
{nodeType === 'sharepoint.copyFile' && (
<>
<div>
<label>Source file</label>
<input
value={(params.sourcePath as string) ?? ''}
onChange={(e) => updateParam('sourcePath', e.target.value)}
placeholder="/sites/.../folder/file.pdf"
/>
</div>
<div>
<label>Destination folder</label>
<input
value={(params.destPath as string) ?? ''}
onChange={(e) => updateParam('destPath', e.target.value)}
placeholder="/sites/.../target-folder/"
/>
</div>
{connectionId && (
<>
<details
open={copySourceExpanded}
onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
style={{
marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
}}
>
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
📂 Source file durchsuchen
</summary>
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={selectSourcePath} selectedPath={(params.sourcePath as string) || null} />
</div>
</details>
<details
open={copyDestExpanded}
onToggle={(e) => setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
style={{
marginTop: 8,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
}}
>
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
📂 Zielordner durchsuchen
</summary>
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={() => {}} onSelectFolder={selectDestPath} selectedPath={(params.destPath as string) || null} />
</div>
</details>
</>
)}
</>
)}
{connectionId && needsPath && hasPathInput && !['sharepoint.copyFile'].includes(nodeType) && (
<details
open={browseExpanded}
onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
style={{
marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
}}
>
<summary
style={{
padding: '0.5rem 0.75rem',
cursor: 'pointer',
fontWeight: 500,
fontSize: '0.875rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
userSelect: 'none',
}}
>
<span style={{ opacity: browseExpanded ? 0.7 : 1 }}>📂</span>
SharePoint durchsuchen
</summary>
<div
style={{
padding: '0.5rem 0.75rem',
borderTop: '1px solid var(--border-color, #e0e0e0)',
maxHeight: 280,
overflowY: 'auto',
}}
>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
onSelectFile={selectPath}
selectedPath={path || null}
/>
</div>
</details>
)}
</>
);
};

View file

@ -1,37 +0,0 @@
/**
* Upload node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Accept (MIME)</label>
<input
value={(params.accept as string) ?? ''}
onChange={(e) => updateParam('accept', e.target.value)}
placeholder=".pdf,image/*"
/>
</div>
<div>
<label>Max Größe (MB)</label>
<input
type="number"
value={(params.maxSize as number) ?? 10}
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 0)}
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={(params.multiple as boolean) ?? false}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrere Dateien
</label>
</div>
</>
);

View file

@ -1,16 +0,0 @@
/**
* Shared types for node config renderers
*/
import type { ApiRequestFunction } from '../../../api/automation2Api';
export type FormField = { name?: string; type?: string; label?: string; required?: boolean };
export interface NodeConfigRendererProps {
params: Record<string, unknown>;
updateParam: (key: string, value: unknown) => void;
/** For Email/SharePoint: fetch connections and browse */
instanceId?: string;
request?: ApiRequestFunction;
nodeType?: string;
}

View file

@ -1,38 +0,0 @@
/**
* Automation2 Flow Editor - Constants
* Category ordering for node sidebar.
*/
/** Node type IDs hidden from the sidebar (hidden, not removed still work when present in saved graphs) */
export const HIDDEN_NODE_IDS = new Set([
'trigger.schedule', // zeitplan
'trigger.formSubmit', // formular-absendung
'flow.ifElse',
'flow.switch',
'flow.merge',
'flow.loop',
'flow.wait',
'flow.stop', // alle abschnitt ablauf
'data.setFields',
'data.filter',
'data.parseJson',
'data.template', // alle abschnitt daten
'ai.webResearch',
'ai.summarizeDocument',
'ai.translateDocument',
'ai.convertDocument',
'ai.generateDocument',
'ai.generateCode', // alle KI ausser ai.prompt
'sharepoint.listFiles', // dateien auflisten
]);
/** Default category display order */
export const CATEGORY_ORDER = [
'trigger',
'input',
'flow',
'data',
'ai',
'email',
'sharepoint',
] as const;

View file

@ -0,0 +1,66 @@
/**
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
*/
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { NodeType } from '../../../api/automation2Api';
export interface Automation2DataFlowContextValue {
currentNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeOutputsPreview: Record<string, unknown>;
nodeTypes: NodeType[];
language: string;
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
getAvailableSourceIds: () => string[];
}
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null {
return useContext(Automation2DataFlowContext);
}
interface Automation2DataFlowProviderProps {
node: CanvasNode | null;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeOutputsPreview: Record<string, unknown>;
nodeTypes: NodeType[];
language: string;
children: React.ReactNode;
}
export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({
node,
nodes,
connections,
nodeOutputsPreview,
nodeTypes,
language,
children,
}) => {
const value = useMemo((): Automation2DataFlowContextValue | null => {
if (!node) return null;
return {
currentNodeId: node.id,
nodes,
connections,
nodeOutputsPreview,
nodeTypes,
language,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
};
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language]);
return (
<Automation2DataFlowContext.Provider value={value}>
{children}
</Automation2DataFlowContext.Provider>
);
};

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,12 @@
* Automation2FlowEditor
*
* n8n-style flow builder with backend-driven node list.
* Composes: NodeSidebar, FlowCanvas, NodeConfigPanel, CanvasHeader.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FaSpinner } from 'react-icons/fa';
import { useApiRequest } from '../../hooks/useApi';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchNodeTypes,
executeGraph,
@ -20,17 +20,28 @@ import {
type Automation2Graph,
type Automation2Workflow,
type ExecuteGraphResponse,
} from '../../api/automation2Api';
type WorkflowEntryPoint,
} from '../../../api/automation2Api';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { getCategoryIcon } from './utils';
import { fromApiGraph, toApiGraph } from './graphUtils';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
const LOG = '[Automation2]';
const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], 'Jetzt ausführen');
interface Automation2FlowEditorProps {
instanceId: string;
language?: string;
@ -50,7 +61,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint'])
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup'])
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
@ -60,14 +71,40 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
const nodeOutputsPreview = useMemo(
() =>
buildNodeOutputsPreview(canvasNodes, executeResult?.nodeOutputs as Record<string, unknown> | undefined),
[canvasNodes, executeResult?.nodeOutputs]
);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : DEFAULT_INVOCATIONS();
setInvocations(inv);
if (!graph?.nodes?.length) {
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
return;
}
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
},
[nodeTypes, language]
);
const handleFromApiGraph = useCallback(
(graph: Automation2Graph) => {
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
setCanvasNodes(nodes);
setCanvasConnections(connections);
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations);
},
[nodeTypes]
[applyGraphWithSync]
);
const handleExecute = useCallback(async () => {
@ -79,29 +116,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
setExecuting(true);
setExecuteResult(null);
try {
const result = await executeGraph(
request,
instanceId,
graph,
currentWorkflowId ?? undefined
);
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
...(ep ? { entryPointId: ep } : {}),
});
setExecuteResult(result);
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try {
const items = await fetchWorkflows(request, instanceId);
setWorkflows(items);
} catch (e) {
console.error(`${LOG} loadWorkflows failed`, e);
}
}, [instanceId, request]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
@ -112,12 +137,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph });
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse);
} else {
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
const created = await createWorkflow(request, instanceId, { label, graph });
const created = await createWorkflow(request, instanceId, { label, graph, invocations });
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]);
setExecuteResult({ success: true } as ExecuteGraphResponse);
}
@ -126,13 +152,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
const handleLoad = useCallback(
async (workflowId: string) => {
try {
const wf = await fetchWorkflow(request, instanceId, workflowId);
if (wf.graph) handleFromApiGraph(wf.graph);
if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations);
} else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
}
} catch (err: unknown) {
setExecuteResult({
success: false,
@ -140,7 +170,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
});
}
},
[request, instanceId, handleFromApiGraph]
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
);
const handleWorkflowSelect = useCallback(
@ -148,25 +178,69 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
setCurrentWorkflowId(workflowId);
if (workflowId) handleLoad(workflowId);
else {
setCanvasNodes([]);
setCanvasConnections([]);
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
}
},
[handleLoad]
[handleLoad, applyGraphWithSync]
);
const handleNew = useCallback(() => {
setCanvasNodes([]);
setCanvasConnections([]);
setCurrentWorkflowId(null);
setExecuteResult(null);
}, []);
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
}, [applyGraphWithSync]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, parameters } : n)));
setCanvasNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
}
return next;
})
);
}, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
}
return next;
})
);
}, []);
const handleNodeUpdate = useCallback(
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
setCanvasNodes((prev) =>
prev.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
);
},
[]
);
const handleApplyWorkflowConfiguration = useCallback(
(next: WorkflowEntryPoint[]) => {
setInvocations(next);
setCanvasNodes((nodes) => {
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
setCanvasConnections(r.connections);
return r.nodes;
});
},
[canvasConnections, nodeTypes, language]
);
const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
@ -184,6 +258,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
}
}, [instanceId, language, request]);
const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try {
const items = await fetchWorkflows(request, instanceId);
setWorkflows(items);
} catch (e) {
console.error(`${LOG} loadWorkflows failed`, e);
}
}, [instanceId, request]);
useEffect(() => {
loadNodeTypes();
}, [loadNodeTypes]);
@ -193,10 +277,24 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
}, [loadWorkflows]);
useEffect(() => {
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId) {
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId && nodeTypes.length > 0) {
handleWorkflowSelect(initialWorkflowId);
}
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect]);
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect, nodeTypes.length]);
useEffect(() => {
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
}, [
loading,
nodeTypes.length,
currentWorkflowId,
initialWorkflowId,
canvasNodes.length,
applyGraphWithSync,
]);
const toggleCategory = useCallback((id: string) => {
setExpandedCategories((prev) => {
@ -209,6 +307,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@ -271,10 +370,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
language={language}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
/>
);
};
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) =>
selectedNode.type.startsWith(p)
);
return (
<div className={styles.container}>
{renderSidebar()}
@ -287,6 +393,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
@ -306,21 +413,36 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onSelectionChange={setSelectedNode}
/>
</div>
{selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.'].some((p) =>
selectedNode.type.startsWith(p)
) && (
{configurableSelected && selectedNode && (
<Automation2DataFlowProvider
node={selectedNode}
nodes={canvasNodes}
connections={canvasConnections}
nodeOutputsPreview={nodeOutputsPreview}
nodeTypes={nodeTypes}
language={language}
>
<NodeConfigPanel
node={selectedNode}
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
language={language}
onParametersChange={handleNodeParametersChange}
onMergeNodeParameters={handleMergeNodeParameters}
onNodeUpdate={handleNodeUpdate}
instanceId={instanceId}
request={request}
/>
</Automation2DataFlowProvider>
)}
</div>
</div>
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
</div>
);
};

View file

@ -3,8 +3,8 @@
*/
import React from 'react';
import { FaPlay, FaSpinner } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse } from '../../api/automation2Api';
import { FaCog, FaPlay, FaSpinner } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/automation2Api';
import styles from './Automation2FlowEditor.module.css';
interface CanvasHeaderProps {
@ -14,6 +14,7 @@ interface CanvasHeaderProps {
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onWorkflowSettings?: () => void;
saving: boolean;
executing: boolean;
hasNodes: boolean;
@ -27,6 +28,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
onNew,
onSave,
onExecute,
onWorkflowSettings,
saving,
executing,
hasNodes,
@ -37,6 +39,17 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
Workflow-Editor
</h4>
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title="Workflow-Konfiguration (Einstieg / Starts)"
aria-label="Workflow-Konfiguration"
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
<button type="button" className={styles.retryButton} onClick={onNew}>
Neu
</button>

View file

@ -4,7 +4,7 @@
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { NodeType } from '../../api/automation2Api';
import type { NodeType } from '../../../api/automation2Api';
import styles from './Automation2FlowEditor.module.css';
export interface CanvasNode {
@ -58,9 +58,17 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
onSelectionChange,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [selectionBox, setSelectionBox] = useState<{
startX: number;
startY: number;
endX: number;
endY: number;
} | null>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [editingField, setEditingField] = useState<'title' | 'comment' | null>(null);
const [editingField, setEditingField] = useState<'title' | null>(null);
const [connectingFrom, setConnectingFrom] = useState<{
nodeId: string;
handleIndex: number;
@ -72,8 +80,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const [dragOffset, setDragOffset] = useState({
startClientX: 0,
startClientY: 0,
startNodeX: 0,
startNodeY: 0,
nodesInitial: {} as Record<string, { x: number; y: number }>,
});
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
@ -99,6 +106,18 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
}
}, [selectedNodeId, nodes, onSelectionChange]);
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
e.stopPropagation();
setSelectedConnectionId(connId);
setSelectedNodeIds(new Set());
}, []);
const handleDeleteConnection = useCallback(() => {
if (!selectedConnectionId) return;
onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId));
setSelectedConnectionId(null);
}, [selectedConnectionId, connections, onConnectionsChange]);
const getHandlePosition = useCallback(
(node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
const isOutput = handleIndex >= node.inputs;
@ -171,6 +190,34 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const handleHandleMouseUp = useCallback(
(e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => {
e.stopPropagation();
const targetNode = nodes.find((n) => n.id === targetNodeId);
if (!targetNode || targetHandleIndex >= targetNode.inputs) return;
if (selectedConnectionId) {
const sel = connections.find((c) => c.id === selectedConnectionId);
if (sel) {
const key = `${targetNodeId}-${targetHandleIndex}`;
const currentTargetKey = `${sel.targetId}-${sel.targetHandle}`;
if (key === currentTargetKey) {
setSelectedConnectionId(null);
return;
}
if (getUsedTargetHandles.has(key)) {
setSelectedConnectionId(null);
return;
}
onConnectionsChange(
connections.map((c) =>
c.id === selectedConnectionId
? { ...c, targetId: targetNodeId, targetHandle: targetHandleIndex }
: c
)
);
setSelectedConnectionId(null);
}
return;
}
if (!connectingFrom || connectingFrom.nodeId === targetNodeId) {
setConnectingFrom(null);
setDragPos(null);
@ -182,9 +229,6 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
setDragPos(null);
return;
}
const targetNode = nodes.find((n) => n.id === targetNodeId);
if (!targetNode) return;
if (targetHandleIndex >= targetNode.inputs) return;
const newConn: CanvasConnection = {
id: `c_${Date.now()}`,
sourceId: connectingFrom.nodeId,
@ -196,13 +240,20 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
setConnectingFrom(null);
setDragPos(null);
},
[connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange]
[connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId]
);
React.useEffect(() => {
if (!connectingFrom || !dragPos) return;
const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY });
const onUp = () => {
const onUp = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest(`.${styles.handleOutput}`)) {
return;
}
if (target.closest(`.${styles.handleInput}`)) {
return;
}
setConnectingFrom(null);
setDragPos(null);
};
@ -214,17 +265,32 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
};
}, [connectingFrom, dragPos]);
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
const handleNodeMouseDown = useCallback(
(e: React.MouseEvent, nodeId: string) => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) return;
const idsToMove = selectedNodeIds.has(nodeId)
? selectedNodeIds
: new Set([nodeId]);
if (!selectedNodeIds.has(nodeId)) {
setSelectedNodeIds(idsToMove);
}
const nodesInitial: Record<string, { x: number; y: number }> = {};
nodes.forEach((n) => {
if (idsToMove.has(n.id)) nodesInitial[n.id] = { x: n.x, y: n.y };
});
setDraggingNodeId(nodeId);
setDragOffset({
startClientX: e.clientX,
startClientY: e.clientY,
startNodeX: node.x,
startNodeY: node.y,
nodesInitial,
});
}, [nodes]);
},
[nodes, selectedNodeIds]
);
React.useEffect(() => {
if (!draggingNodeId) return;
@ -232,11 +298,11 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const dx = (e.clientX - dragOffset.startClientX) / zoom;
const dy = (e.clientY - dragOffset.startClientY) / zoom;
onNodesChange(
nodes.map((n) =>
n.id === draggingNodeId
? { ...n, x: dragOffset.startNodeX + dx, y: dragOffset.startNodeY + dy }
: n
)
nodes.map((n) => {
const init = dragOffset.nodesInitial[n.id];
if (!init) return n;
return { ...n, x: init.x + dx, y: init.y + dy };
})
);
};
const onUp = () => setDraggingNodeId(null);
@ -248,16 +314,48 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
};
}, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]);
const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => {
const r = el.getBoundingClientRect();
setContainerBounds({ left: r.left, top: r.top });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
const clientToCanvas = useCallback(
(clientX: number, clientY: number) => ({
x: (clientX - containerBounds.left - panOffset.x) / zoom,
y: (clientY - containerBounds.top - panOffset.y) / zoom,
}),
[containerBounds, panOffset, zoom]
);
const handleCanvasMouseDown = useCallback(
(e: React.MouseEvent) => {
const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
if (hitNode || connectingFrom) return;
if (e.shiftKey) {
e.preventDefault();
const pt = clientToCanvas(e.clientX, e.clientY);
setSelectionBox({ startX: pt.x, startY: pt.y, endX: pt.x, endY: pt.y });
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
} else {
setPanning({
startX: e.clientX,
startY: e.clientY,
startPanX: panOffset.x,
startPanY: panOffset.y,
});
}, [connectingFrom, panOffset]);
}
},
[connectingFrom, panOffset, clientToCanvas]
);
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault();
@ -289,18 +387,46 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
};
}, [panning]);
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
const selectionBoxRef = useRef<typeof selectionBox>(null);
const marqueeJustEndedRef = useRef(false);
selectionBoxRef.current = selectionBox;
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => {
const r = el.getBoundingClientRect();
setContainerBounds({ left: r.left, top: r.top });
if (!selectionBox) return;
const onMove = (e: MouseEvent) => {
const pt = clientToCanvas(e.clientX, e.clientY);
setSelectionBox((prev) =>
prev ? { ...prev, endX: pt.x, endY: pt.y } : null
);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
const onUp = () => {
const box = selectionBoxRef.current;
setSelectionBox(null);
marqueeJustEndedRef.current = true;
if (!box) return;
const minX = Math.min(box.startX, box.endX);
const maxX = Math.max(box.startX, box.endX);
const minY = Math.min(box.startY, box.endY);
const maxY = Math.max(box.startY, box.endY);
const ids = new Set<string>();
nodes.forEach((n) => {
const nx = n.x;
const ny = n.y;
const nRight = nx + NODE_WIDTH;
const nBottom = ny + NODE_HEIGHT;
const overlaps =
nx < maxX && nRight > minX && ny < maxY && nBottom > minY;
if (overlaps) ids.add(n.id);
});
setSelectedNodeIds(ids);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [selectionBox, nodes, clientToCanvas]);
const CANVAS_SIZE = 8000;
const svgBounds = useMemo(() => {
@ -313,37 +439,42 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
}, [nodes]);
const screenToSvg = useCallback(
(clientX: number, clientY: number) => ({
x: (clientX - containerBounds.left - panOffset.x) / zoom,
y: (clientY - containerBounds.top - panOffset.y) / zoom,
}),
[containerBounds, panOffset, zoom]
);
const handleDeleteNode = useCallback(() => {
if (!selectedNodeId) return;
onNodesChange(nodes.filter((n) => n.id !== selectedNodeId));
if (selectedNodeIds.size === 0) return;
const ids = selectedNodeIds;
onNodesChange(nodes.filter((n) => !ids.has(n.id)));
onConnectionsChange(
connections.filter((c) => c.sourceId !== selectedNodeId && c.targetId !== selectedNodeId)
connections.filter(
(c) => !ids.has(c.sourceId) && !ids.has(c.targetId)
)
);
setSelectedNodeId(null);
setSelectedNodeIds(new Set());
setEditingNodeId(null);
setEditingField(null);
}, [selectedNodeId, nodes, connections, onNodesChange, onConnectionsChange]);
}, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange]);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
if (e.key === 'Escape') {
setConnectingFrom(null);
setDragPos(null);
setSelectedConnectionId(null);
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedConnectionId) {
e.preventDefault();
handleDeleteConnection();
} else if (selectedNodeIds.size > 0) {
e.preventDefault();
handleDeleteNode();
}
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [handleDeleteNode, selectedNodeId]);
}, [handleDeleteNode, handleDeleteConnection, selectedNodeIds.size, selectedConnectionId]);
const handleNodeUpdate = useCallback(
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
@ -357,7 +488,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
return (
<div
ref={containerRef}
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab}`}
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab} ${selectionBox || draggingNodeId ? styles.canvasSelecting : ''}`}
style={{
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
@ -366,8 +497,32 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
onDrop={handleDrop}
onMouseDown={handleCanvasMouseDown}
tabIndex={0}
onClick={() => setSelectedNodeId(null)}
onClick={() => {
if (marqueeJustEndedRef.current) {
marqueeJustEndedRef.current = false;
return;
}
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
setConnectingFrom(null);
setDragPos(null);
}}
>
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
<div className={styles.connectionHint}>
{selectedNodeIds.size} Nodes ausgewählt <kbd>Entf</kbd> zum Löschen Ziehen zum Verschieben <kbd>Shift</kbd>+Klick zum Hinzufügen/Entfernen
</div>
)}
{connectingFrom && !selectedConnectionId && (
<div className={styles.connectionHint}>
Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang <kbd>Esc</kbd> zum Abbrechen
</div>
)}
{selectedConnectionId && (
<div className={styles.connectionHint}>
Pfeil ausgewählt <kbd>Entf</kbd> zum Löschen Klicken Sie auf einen anderen Eingang zum Umleiten
</div>
)}
<div
className={styles.canvasContent}
style={{
@ -381,7 +536,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
className={styles.connectionsLayer}
width={svgBounds.width}
height={svgBounds.height}
style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}
style={{ position: 'absolute', left: 0, top: 0 }}
>
<defs>
<marker
@ -394,6 +549,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
>
<polygon points="0 0, 10 3.5, 0 7" fill="var(--text-secondary, #666)" />
</marker>
<marker
id="arrowhead-selected"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" />
</marker>
</defs>
{connections.map((c) => {
const srcNode = nodes.find((n) => n.id === c.sourceId);
@ -402,20 +567,37 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const src = getHandlePosition(srcNode, c.sourceHandle);
const tgt = getHandlePosition(tgtNode, c.targetHandle);
const dx = tgt.x - src.x;
const path = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
const isSelected = selectedConnectionId === c.id;
return (
<path
<g
key={c.id}
d={path}
onClick={(e) => handleConnectionClick(e, c.id)}
style={{ cursor: 'pointer' }}
role="button"
tabIndex={-1}
aria-label="Verbindung auswählen (Entf zum Löschen, klicken Sie auf einen anderen Eingang zum Umleiten)"
>
<path
d={pathD}
fill="none"
stroke="var(--text-secondary, #666)"
strokeWidth="2"
markerEnd="url(#arrowhead)"
stroke="transparent"
strokeWidth="16"
pointerEvents="stroke"
/>
<path
d={pathD}
fill="none"
stroke={isSelected ? 'var(--primary-color, #007bff)' : 'var(--text-secondary, #666)'}
strokeWidth={isSelected ? 3 : 2}
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
pointerEvents="none"
/>
</g>
);
})}
{connectingFrom && dragPos && (() => {
const end = screenToSvg(dragPos.x, dragPos.y);
const end = clientToCanvas(dragPos.x, dragPos.y);
return (
<path
d={`M ${connectingFrom.x} ${connectingFrom.y} L ${end.x} ${end.y}`}
@ -423,6 +605,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
stroke="var(--primary-color, #007bff)"
strokeWidth="2"
strokeDasharray="4 4"
pointerEvents="none"
/>
);
})()}
@ -435,11 +618,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
const isSelected = selectedNodeId === node.id;
const isSelected = selectedNodeIds.has(node.id);
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
const isEditingComment = editingNodeId === node.id && editingField === 'comment';
const displayTitle = node.title ?? node.label ?? getLabel(node);
const displayComment = node.comment ?? '';
return (
<div
@ -456,28 +637,65 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => {
e.stopPropagation();
setSelectedNodeId(node.id);
setSelectedConnectionId(null);
if (e.shiftKey) {
setSelectedNodeIds((prev) => {
const next = new Set(prev);
if (next.has(node.id)) next.delete(node.id);
else next.add(node.id);
return next;
});
return;
}
if (!selectedNodeIds.has(node.id)) {
setSelectedNodeIds(new Set([node.id]));
}
handleNodeMouseDown(e, node.id);
}}
>
{handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index);
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
const canConnect = isOutput || (!used && connectingFrom);
const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
const isCurrentTargetOfSelection =
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
const canConnect =
isOutput ||
(!used && connectingFrom) ||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
const nt = nodeTypeMap[node.type];
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;
return (
<div
key={index}
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
className={styles.handleWrapper}
style={{
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
top: pos.y - node.y - HANDLE_OFFSET,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
}}
>
{outputLabel && pos.side === 'right' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
<div
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
title={
outputLabel ??
(selectedConnectionId && !isOutput
? used
? 'Aktuelles Ziel klicken zum Abwählen'
: 'Klicken zum Umleiten'
: undefined)
}
/>
{outputLabel && pos.side === 'left' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
</div>
);
})}
<div className={styles.canvasNodeContent}>
@ -525,49 +743,22 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
{displayTitle}
</span>
)}
{isEditingComment ? (
<input
type="text"
className={styles.canvasNodeInput}
placeholder="Kommentar..."
value={node.comment ?? ''}
autoFocus
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleNodeUpdate(node.id, { comment: (e.target as HTMLInputElement).value });
setEditingNodeId(null);
setEditingField(null);
}
if (e.key === 'Escape') {
setEditingNodeId(null);
setEditingField(null);
}
}}
onBlur={(e) => {
handleNodeUpdate(node.id, { comment: e.target.value });
setEditingNodeId(null);
setEditingField(null);
}}
onChange={(e) => handleNodeUpdate(node.id, { comment: e.target.value })}
/>
) : (
<span
className={styles.canvasNodeComment}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingNodeId(node.id);
setEditingField('comment');
}}
>
{displayComment || 'Doppelklick für Kommentar'}
</span>
)}
</div>
</div>
</div>
);
})}
{selectionBox && (
<div
className={styles.selectionBox}
style={{
left: Math.min(selectionBox.startX, selectionBox.endX),
top: Math.min(selectionBox.startY, selectionBox.endY),
width: Math.abs(selectionBox.endX - selectionBox.startX),
height: Math.abs(selectionBox.endY - selectionBox.startY),
}}
/>
)}
{nodes.length === 0 && (
<div className={styles.canvasPlaceholder}>
<p>Nodes aus der Liste links hierher ziehen.</p>

View file

@ -0,0 +1,120 @@
/**
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
* Delegates to config components from nodes/configs.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas';
import type { NodeType } from '../../../api/automation2Api';
import type { ApiRequestFunction } from '../../../api/automation2Api';
import { getLabel } from '../nodes/shared/utils';
import { NODE_CONFIG_REGISTRY } from '../nodes/configs';
import styles from './Automation2FlowEditor.module.css';
interface NodeConfigPanelProps {
node: CanvasNode | null;
nodeType: NodeType | undefined;
language: string;
onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void;
onMergeNodeParameters?: (nodeId: string, patch: Record<string, unknown>) => void;
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
instanceId?: string;
request?: ApiRequestFunction;
}
const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.'];
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
node,
nodeType,
language,
onParametersChange,
onMergeNodeParameters,
onNodeUpdate,
instanceId,
request,
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const nodeIdRef = useRef<string | undefined>(undefined);
nodeIdRef.current = node?.id;
const notifyParentTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setParams(node?.parameters ?? {});
}, [node?.id, node?.parameters]);
useEffect(() => {
return () => {
if (notifyParentTimeoutRef.current != null) {
clearTimeout(notifyParentTimeoutRef.current);
notifyParentTimeoutRef.current = null;
}
};
}, [node?.id]);
/** Do not call onParametersChange (parent setState) inside setParams updater — React forbids updating a parent during a child's state update. */
const updateParam = useCallback(
(key: string, value: unknown) => {
setParams((prev) => {
const next = { ...prev, [key]: value };
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
clearTimeout(notifyParentTimeoutRef.current);
}
notifyParentTimeoutRef.current = setTimeout(() => {
notifyParentTimeoutRef.current = null;
onParametersChange(id, next);
}, 0);
}
return next;
});
},
[onParametersChange]
);
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
if (!node || !isConfigurable) return null;
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
if (!ConfigRenderer) {
return (
<div className={styles.nodeConfigPanel}>
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<p>No configuration for {node.type}</p>
</div>
);
}
const isTrigger = node.type.startsWith('trigger.');
const showNameField = onNodeUpdate && !isTrigger;
return (
<div className={styles.nodeConfigPanel}>
{showNameField && (
<div className={styles.nodeConfigNameRow}>
<label htmlFor="node-config-name">Bezeichnung</label>
<input
id="node-config-name"
type="text"
value={node.title ?? ''}
onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })}
placeholder="z.B. Kundenformular, Prüfen Land"
/>
<p className={styles.nodeConfigNameHint}>
Wird im Data Picker angezeigt, um diesen Node zu identifizieren.
</p>
</div>
)}
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<ConfigRenderer
params={params}
updateParam={updateParam}
instanceId={instanceId}
request={request}
nodeType={node.type}
mergeNodeParameters={onMergeNodeParameters}
/>
</div>
);
};

View file

@ -4,9 +4,9 @@
*/
import React from 'react';
import type { NodeType } from '../../api/automation2Api';
import { getCategoryIcon } from './utils';
import type { GetLabelFn } from './utils';
import type { NodeType } from '../../../api/automation2Api';
import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css';
interface NodeListItemProps {

View file

@ -5,9 +5,9 @@
import React, { useMemo } from 'react';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from './constants';
import { getLabel } from './utils';
import type { NodeType, NodeTypeCategory } from '../../../api/automation2Api';
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
import { getLabel } from '../nodes/shared/utils';
import { NodeListItem } from './NodeListItem';
import styles from './Automation2FlowEditor.module.css';
@ -19,6 +19,8 @@ interface NodeSidebarProps {
language: string;
expandedCategories: Set<string>;
onToggleCategory: (id: string) => void;
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
excludedCategories?: Set<string>;
}
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
@ -29,9 +31,14 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
language,
expandedCategories,
onToggleCategory,
excludedCategories,
}) => {
const filteredNodeTypes = useMemo(() => {
const visible = nodeTypes.filter((n) => !HIDDEN_NODE_IDS.has(n.id));
const visible = nodeTypes.filter(
(n) =>
!HIDDEN_NODE_IDS.has(n.id) &&
!(excludedCategories?.has(n.category || ''))
);
if (!filter.trim()) return visible;
const q = filter.toLowerCase();
return visible.filter(

View file

@ -0,0 +1,115 @@
/**
* Workflow configuration primary start kind drives the canvas start node.
*/
import React, { useState, useEffect } from 'react';
import type { WorkflowEntryPoint } from '../../../api/automation2Api';
import {
getPrimaryStartKind,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import styles from './Automation2FlowEditor.module.css';
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
const KIND_OPTIONS: { value: string; label: string }[] = [
{ value: 'manual', label: 'Manueller Trigger' },
{ value: 'form', label: 'Formular' },
{ value: 'schedule', label: 'Zeitplan' },
{ value: 'always_on', label: 'Immer aktiv' },
];
interface WorkflowConfigurationModalProps {
open: boolean;
onClose: () => void;
invocations: WorkflowEntryPoint[];
onApply: (next: WorkflowEntryPoint[]) => void;
}
function normalizeLoadedKind(k: string): string {
if (KIND_OPTIONS.some((o) => o.value === k)) return k;
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
if (k === 'api') return 'manual';
return 'manual';
}
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({
open,
onClose,
invocations,
onApply,
}) => {
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
const [titleDe, setTitleDe] = useState('');
useEffect(() => {
if (!open) return;
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
setKind(k);
const entry = invocations[0];
const t = entry?.title;
if (typeof t === 'string') setTitleDe(t);
else if (t && typeof t === 'object') setTitleDe(t.de || t.en || '');
else setTitleDe('');
}, [open, invocations]);
if (!open) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const label =
titleDe.trim() || KIND_OPTIONS.find((o) => o.value === kind)?.label || 'Start';
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
onApply(next);
onClose();
};
return (
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
<div className={styles.workflowModal}>
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
Workflow-Konfiguration
</h3>
<p className={styles.workflowModalHint}>
Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem
gewählten Einstieg an (z.&nbsp;B. Formular-Felder auf der Start-Node bearbeiten).
</p>
<form onSubmit={handleSubmit}>
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
Titel der Start Node
</label>
<input
id="wf-start-title"
className={styles.workflowModalInput}
value={titleDe}
onChange={(e) => setTitleDe(e.target.value)}
placeholder="z. B. Angebot anlegen"
/>
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label="Einstiegsart">
{KIND_OPTIONS.map((o) => (
<label key={o.value} className={styles.workflowModalRadio}>
<input
type="radio"
name="kind"
value={o.value}
checked={kind === o.value}
onChange={() => setKind(o.value)}
/>
{o.label}
</label>
))}
</div>
<div className={styles.workflowModalActions}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
Abbrechen
</button>
<button type="submit" className={styles.workflowModalBtnPrimary}>
Übernehmen
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -1,9 +1,11 @@
export { Automation2FlowEditor } from './Automation2FlowEditor';
export { FlowCanvas } from './FlowCanvas';
export { NodeConfigPanel } from './NodeConfigPanel';
export { NodeSidebar } from './NodeSidebar';
export { NodeListItem } from './NodeListItem';
export { CanvasHeader } from './CanvasHeader';
export * from './utils';
export * from './constants';
export * from './graphUtils';
export { Automation2FlowEditor } from './editor/Automation2FlowEditor';
export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader';
export * from './nodes/shared/utils';
export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils';
export { getAcceptStringFromConfig } from './nodes/configs/UploadNodeConfig';

View file

@ -0,0 +1,90 @@
/**
* AI node config - prompt, query, document options per node type.
* Prompt/query fields support static value or node reference (Data Picker).
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { DynamicValueField } from '../shared/DynamicValueField';
const AI_FIELD_CONFIG: Record<string, { label: string; key: string; type: 'textarea' | 'input' | 'select' | 'dynamic'; options?: string[] }[]> = {
'ai.prompt': [{ label: 'Prompt', key: 'prompt', type: 'textarea' }],
'ai.webResearch': [{ label: 'Query', key: 'query', type: 'dynamic' }],
'ai.summarizeDocument': [
{ label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] },
],
'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }],
'ai.convertDocument': [
{ label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] },
],
'ai.generateDocument': [{ label: 'Prompt', key: 'prompt', type: 'dynamic' }],
'ai.generateCode': [
{ label: 'Prompt', key: 'prompt', type: 'dynamic' },
{ label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] },
],
};
export const AiNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam, nodeType = 'ai.prompt' }) => {
const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt'];
return (
<>
{fields.map((f) => {
if (f.type === 'dynamic') {
return (
<DynamicValueField
key={f.key}
paramKey={f.key}
value={params[f.key]}
onChange={updateParam}
label={f.label}
fieldType="textarea"
rows={4}
placeholder={f.label}
/>
);
}
if (f.type === 'textarea') {
return (
<div key={f.key}>
<label>{f.label}</label>
<textarea
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
rows={4}
/>
</div>
);
}
if (f.type === 'select') {
return (
<div key={f.key}>
<label>{f.label}</label>
<select
value={(params[f.key] as string) ?? (f.options?.[0] ?? '')}
onChange={(e) => updateParam(f.key, e.target.value)}
>
{(f.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}
return (
<div key={f.key}>
<label>{f.label}</label>
<input
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
/>
</div>
);
})}
</>
);
};

File diff suppressed because it is too large Load diff

View file

@ -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<NodeConfigRendererProps> = ({
params,

View file

@ -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<NodeConfigRendererProps> = ({ 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 (
<>
<div className={styles.fileCreateContentSources}>
<label>Inhalte (welche Kontexte nacheinander in die Datei?)</label>
{contentSources.map((ref, i) => (
<div key={i} className={styles.contentSourceRow}>
<RefSourceSelect
value={ref}
onChange={(r) => setItem(i, r)}
placeholder="Quelle wählen…"
/>
<button
type="button"
className={styles.contentSourceRemoveBtn}
onClick={() => removeItem(i)}
title="Entfernen"
aria-label="Inhalt entfernen"
>
×
</button>
</div>
))}
<button type="button" className={styles.contentSourceAddBtn} onClick={addItem}>
+ Inhalt hinzufügen
</button>
{contentSources.length === 0 && (
<p className={styles.dynamicValueEmptyHint}>
Leer = Kontext vom verbundenen Node. Fügen Sie Inhalte hinzu, um mehrere Quellen zu kombinieren.
</p>
)}
</div>
<div>
<label>Ausgabeformat</label>
<select
value={(params.outputFormat as string) ?? 'docx'}
onChange={(e) => updateParam('outputFormat', e.target.value)}
>
{OUTPUT_FORMATS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Dokumenttitel"
/>
</div>
<div>
<label>Vorlage / Stil</label>
<select
value={(params.templateName as string) ?? 'default'}
onChange={(e) => updateParam('templateName', e.target.value)}
>
{TEMPLATE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div>
<label>Sprache</label>
<select
value={(params.language as string) ?? 'de'}
onChange={(e) => updateParam('language', e.target.value)}
>
{LANGUAGES.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
</>
);
};

View file

@ -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<NodeConfigRendererProps> = ({ params, updateParam }) => (
<DynamicValueField
paramKey="contentRef"
value={params.contentRef}
onChange={updateParam}
label="Content-Referenz"
fieldType="input"
placeholder="{{nodeId.field}}"
/>
);

View file

@ -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<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
nodeType = 'sharepoint.findFile',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
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<BrowseEntry[]> => {
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 (
<>
<div>
<label>Connection</label>
<select
value={connectionId}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={connectionsLoading}
>
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{needsSearch && (
<div>
<label>Search query / path</label>
<input
value={(params.searchQuery as string) ?? ''}
onChange={(e) => updateParam('searchQuery', e.target.value)}
placeholder="/sites/SiteName/Shared Documents or search term"
/>
</div>
)}
{showPathFieldsForList && (
<div>
<label>Folder path</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
placeholder="/ or /sites/SiteName/Shared Documents/Folder"
/>
</div>
)}
{showPathFieldsForFileUploadDownload && (
<div>
<label>
{nodeType === 'sharepoint.uploadFile'
? 'Target folder path'
: nodeType === 'sharepoint.downloadFile'
? 'File path'
: 'Path'}
</label>
<input
value={(params.path as string) ?? (params.filePath as string) ?? ''}
onChange={(e) => updateParam('path', e.target.value)}
placeholder={
nodeType === 'sharepoint.downloadFile'
? '/sites/SiteName/Shared Documents/file.pdf'
: nodeType === 'sharepoint.uploadFile'
? '/sites/.../Shared Documents/TargetFolder/'
: 'File path'
}
/>
</div>
)}
{needsSiteId && (
<div>
<label>Site ID</label>
<input
value={(params.siteId as string) ?? ''}
onChange={(e) => updateParam('siteId', e.target.value)}
placeholder="SharePoint site ID"
/>
</div>
)}
{nodeType === 'sharepoint.copyFile' && (
<>
<div>
<label>Source file</label>
<input
value={(params.sourcePath as string) ?? ''}
onChange={(e) => updateParam('sourcePath', e.target.value)}
placeholder="/sites/.../folder/file.pdf"
/>
</div>
<div>
<label>Destination folder</label>
<input
value={(params.destPath as string) ?? ''}
onChange={(e) => updateParam('destPath', e.target.value)}
placeholder="/sites/.../target-folder/"
/>
</div>
{connectionId && (
<>
<details
open={copySourceExpanded}
onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
style={browseDetailsStyle}
>
<summary style={{ ...browseSummaryStyle, padding: '0.5rem 0.75rem' }}>
<span style={{ opacity: copySourceExpanded ? 0.7 : 1 }}>📂</span>
Quelldatei durchsuchen
</summary>
<div style={browseBodyStyle}>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
foldersOnly={false}
onSelectFile={selectSourcePath}
selectedPath={(params.sourcePath as string) || null}
/>
</div>
</details>
<details
open={copyDestExpanded}
onToggle={(e) => setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
style={{ ...browseDetailsStyle, marginTop: 8 }}
>
<summary style={{ ...browseSummaryStyle, padding: '0.5rem 0.75rem' }}>
<span style={{ opacity: copyDestExpanded ? 0.7 : 1 }}>📂</span>
Zielordner durchsuchen
</summary>
<div style={browseBodyStyle}>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
foldersOnly
onSelectFile={() => {}}
onSelectFolder={selectDestPath}
selectedPath={(params.destPath as string) || null}
/>
</div>
</details>
</>
)}
</>
)}
{showStandardPathBrowse && (
<details
open={browseExpanded}
onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
style={browseDetailsStyle}
>
<summary style={browseSummaryStyle}>
<span style={{ opacity: browseExpanded ? 0.7 : 1 }}>📂</span>
{browsePanelTitle(nodeType)}
</summary>
<div style={browseBodyStyle}>
{isFolderPickerNode(nodeType) && (
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
foldersOnly
onSelectFile={() => {}}
onSelectFolder={selectPath}
selectedPath={path || null}
/>
)}
{(nodeType === 'sharepoint.readFile' || nodeType === 'sharepoint.downloadFile') && (
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
onSelectFile={selectPath}
selectedPath={path || null}
/>
)}
</div>
</details>
)}
{showFindFileBrowse && (
<details
open={findFileBrowseExpanded}
onToggle={(e) => setFindFileBrowseExpanded((e.target as HTMLDetailsElement).open)}
style={browseDetailsStyle}
>
<summary style={browseSummaryStyle}>
<span style={{ opacity: findFileBrowseExpanded ? 0.7 : 1 }}>📂</span>
{browsePanelTitle('sharepoint.findFile')}
</summary>
<div style={browseBodyStyle}>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
onSelectFile={selectSearchQueryFromFile}
selectedPath={(params.searchQuery as string) || null}
/>
</div>
</details>
)}
</>
);
};

View file

@ -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, unknown>): string {
const types = parseAllowedTypes(config);
return buildAcceptString(types);
}
const FILE_TYPE_CHIP_OPTIONS = getAcceptValues();
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ 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 (
<div className={styles.uploadNodeConfig}>
<div className={styles.configBlock}>
<label>Erlaubte Dateitypen</label>
<p className={styles.configHint}>
Mehrfachauswahl möglich. Keine Auswahl = alle Typen erlaubt.
</p>
<div className={styles.fileTypeChips}>
{FILE_TYPE_CHIP_OPTIONS.map((opt) => (
<label key={opt.value} className={styles.fileTypeChip}>
<input
type="checkbox"
checked={allowedTypes.includes(opt.value)}
onChange={() => toggleType(opt.value)}
/>
<span>{opt.label}</span>
</label>
))}
</div>
</div>
<div className={styles.configBlock}>
<label>Max. Größe (MB)</label>
<input
type="number"
min={0.1}
max={500}
step={1}
value={maxSize}
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 10)}
/>
</div>
<div className={styles.configBlock}>
<label>
<input
type="checkbox"
checked={multiple}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrere Dateien erlauben
</label>
</div>
</div>
);
};

View file

@ -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<NodeConfigRendererProps>;
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
'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<string, NodeConfigComponent> = {
'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<string, NodeConfigComponent> = {
'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,
};

View file

@ -0,0 +1 @@
export type { NodeConfigRendererProps, FormField } from '../shared/types';

View file

@ -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<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
}) => {
const fields = (params.fields as FormField[]) ?? [];
const [connections, setConnections] = useState<UserConnection[]>([]);
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 (
<div>
<label>Felder</label>
<div className={styles.formFieldsList}>
{fields.map((f, i) => (
<div
key={i}
className={styles.formFieldRow}
onDragOver={(e) => {
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);
}}
>
<div className={styles.formFieldRowHeader}>
<span
className={styles.formFieldDragHandle}
title="Zum Verschieben ziehen"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', String(i));
e.dataTransfer.effectAllowed = 'move';
}}
>
<FaGripVertical />
</span>
<div className={styles.formFieldInputs}>
<input
placeholder="name"
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder="label"
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], label: e.target.value };
updateParam('fields', next);
}}
/>
</div>
</div>
<div className={styles.formFieldRowFooter}>
<select
value={f.type ?? 'string'}
onChange={(e) => {
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 }}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Checkbox</option>
<option value="clickup_tasks">ClickUp-Aufgabe (Referenz)</option>
<option value="clickup_status">ClickUp-Status (Liste)</option>
</select>
<label className={styles.formFieldRequiredLabel}>
<input
type="checkbox"
checked={f.required ?? false}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], required: e.target.checked };
updateParam('fields', next);
}}
/>
Pflichtfeld
</label>
<button
type="button"
onClick={() => removeField(i)}
title="Feld entfernen"
className={styles.formFieldRemoveButton}
>
<FaTimes />
</button>
</div>
{f.type === 'clickup_status' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
<p style={{ margin: '0 0 6px' }}>
Dropdown mit {f.clickupStatusOptions.length} Status aus der ClickUp-Liste (Wert = exakter
Status-Name für die API).
</p>
) : (
<p style={{ margin: '0 0 6px' }}>
Keine Optionen im ClickUp-Knoten Aufgabe erstellen Liste wählen und Formular mit Liste
abgleichen.
</p>
)}
</div>
) : null}
{f.type === 'clickup_tasks' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
ClickUp-Verbindung
</label>
<select
value={f.clickupConnectionId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupConnectionId: e.target.value };
updateParam('fields', next);
}}
disabled={connectionsLoading || !instanceId}
style={{ width: '100%', marginBottom: 8 }}
>
<option value="">{connectionsLoading ? 'Lade…' : 'Verbindung wählen…'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
Listen-ID (verknüpfte Liste / Ziel-Liste)
</label>
<input
placeholder="z. B. aus ClickUp-URL …/list/123456789"
value={f.clickupListId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupListId: e.target.value };
updateParam('fields', next);
}}
style={{ width: '100%' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:{' '}
<code>{'{ add: [taskId], rem: [] }'}</code> im ClickUp-Node per Datenquelle auf das
Formularfeld mappen.
</p>
</div>
) : null}
</div>
))}
<button
type="button"
onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
}
>
+ Feld
</button>
</div>
</div>
);
};

View file

@ -0,0 +1 @@
export { FormNodeConfig } from './FormNodeConfig';

View file

@ -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<NodeConfigRendererProps> = ({ 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<string, unknown> }) => n.id === ref.nodeId)
: null;
const mimeTypeOptions =
isMimeTypeRef && sourceNode?.type === 'input.upload' && sourceNode.parameters
? getMimeTypeOptionsFromUploadParams(sourceNode.parameters as Record<string, unknown>)
: [];
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 (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>Datenquelle</label>
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder="Formular-Feld wählen…" />
</div>
<div className={styles.ifElseConditionRow}>
<label>Vergleich</label>
<select value={operator} onChange={(e) => handleOperatorChange(e.target.value)}>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{needsValue && (
<div className={styles.ifElseConditionRow}>
<label>Wert</label>
{mimeTypeOptions.length > 0 ? (
<select
value={String(value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
>
<option value=""> MIME-Type wählen </option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
</option>
))}
</select>
) : (
<input
type={fieldType === 'number' ? 'number' : fieldType === 'date' ? 'date' : 'text'}
value={String(value ?? '')}
onChange={(e) =>
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'
}
/>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1 @@
export { IfElseNodeConfig } from './IfElseNodeConfig';

View file

@ -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<NodeConfigRendererProps> = ({ 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 (
<div className={styles.ifElseConditionEditor}>
<LoopItemsSelect value={selectValue} onChange={handleChange} />
</div>
);
};

View file

@ -0,0 +1 @@
export { LoopNodeConfig } from './LoopNodeConfig';

View file

@ -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, unknown>): 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<string, unknown>): Array<{ value: string; label: string }> {
const allowedTypes = parseAllowedTypes(params);
const seen = new Set<string>();
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 }));
}

View file

@ -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;
/** 06, cron DOW; nur bei mode === 'weekly' */
weekdays: number[];
/** Monatlich: Tag 131; Jährlich: Tag im gewählten Monat */
monthDay: number;
/** 112, 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 MoSo (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<string, unknown>): ScheduleSpec {
const raw = params.schedule;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const o = raw as Record<string, unknown>;
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();
}

View file

@ -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<string>, 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<string, unknown> = {};
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<string>,
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<string, unknown> };
});
}
/** 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<string, unknown> {
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<string, unknown> = {};
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];
}

View file

@ -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<string, unknown>;
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<string, unknown>)) {
result.push(...buildPickablePaths(v, [...basePath, k]));
}
return result;
}
return [{ path: [...basePath], label: pathLabel }];
}
export const DataPicker: React.FC<DataPickerProps> = ({
open,
onClose,
onPick,
availableSourceIds,
nodes,
nodeOutputsPreview,
getNodeLabel,
}) => {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(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 (
<div className={styles.dataPickerOverlay} onClick={onClose}>
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.dataPickerHeader}>
<h4 className={styles.dataPickerTitle}>Datenquelle wählen</h4>
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label="Schließen">
×
</button>
</div>
<div className={styles.dataPickerBody}>
{(() => {
const filteredIds = availableSourceIds.filter((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
return node?.type !== 'trigger.manual';
});
if (filteredIds.length === 0) {
return <p className={styles.dataPickerEmpty}>Keine vorherigen Nodes verfügbar.</p>;
}
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 (
<div key={nodeId} className={styles.dataPickerNodeSection}>
<button
type="button"
className={styles.dataPickerNodeHeader}
onClick={() => toggleExpand(nodeId)}
>
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{label}</span>
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{paths.map((p, i) => (
<button
key={`${p.path.join('.')}-${i}`}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(nodeId, p.path)}
>
{p.label}
</button>
))}
</div>
)}
</div>
);
});
})()}
</div>
</div>
</div>
);
};

View file

@ -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<DynamicValueFieldProps> = ({
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 (
<div className={styles.dynamicValueField}>
<label>{label}</label>
<p className={styles.dynamicValueEmptyHint}>Keine vorherigen Nodes verfügbar.</p>
</div>
);
}
if (variant === 'dropdown') {
return (
<div className={styles.dynamicValueField}>
<label>{label}</label>
<RefSourceSelect
value={ref}
onChange={handleSetRef}
placeholder={placeholder ?? label}
/>
</div>
);
}
return (
<div className={styles.dynamicValueField}>
<label>{label}</label>
<div className={styles.dynamicValueRefDisplay}>
<span className={styles.dynamicValueRefLabel}>
{ref
? formatRefLabel(
ref,
dataFlow?.nodes ?? [],
(nid) => dataFlow?.nodes.find((n) => n.id === nid)?.title ?? nid
)
: '—'}
</span>
<button
type="button"
className={styles.dynamicValuePickBtn}
onClick={() => setPickerOpen(true)}
>
Wählen
</button>
</div>
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={(r) => {
handleSetRef(r);
setPickerOpen(false);
}}
availableSourceIds={dataFlow.getAvailableSourceIds()}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
/>
)}
</div>
);
};

View file

@ -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<HybridStaticRefFieldProps> = ({
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 (
<div className={styles.dynamicValueField}>
<label>{label}</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
pathPickMode={pathPickMode}
/>
</div>
) : null}
{shouldShowStaticControl(value, Boolean(hasSources)) &&
(multiline ? (
<textarea
rows={4}
value={staticStr}
onChange={(e) => handleStaticChange(e.target.value)}
placeholder={placeholder}
/>
) : (
<input
type={inputType === 'number' ? 'number' : 'text'}
value={staticStr}
onChange={(e) => handleStaticChange(e.target.value)}
placeholder={placeholder}
/>
))}
</div>
);
};

View file

@ -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<string, unknown>)[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<string, unknown> }>,
nodeOutputsPreview: Record<string, unknown>,
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<string, unknown>)) {
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<string, unknown>;
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<string>();
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<LoopItemsSelectProps> = ({
value,
onChange,
placeholder = 'Über was soll iteriert werden?',
}) => {
const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null;
const sourceIds = dataFlow.getAvailableSourceIds();
if (sourceIds.length === 0) {
return (
<p className={styles.dynamicValueEmptyHint}>
Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife.
</p>
);
}
const options = buildLoopOptions(
sourceIds,
dataFlow.nodes,
dataFlow.nodeOutputsPreview,
dataFlow.getNodeLabel
);
const ref = isRef(value) ? value : null;
const currentValue = ref ? refToOptionValue(ref) : '';
return (
<div className={styles.ifElseConditionRow}>
<label>Datenquelle für Iteration</label>
<select
value={currentValue}
onChange={(e) => {
const v = e.target.value;
if (!v) {
onChange(null);
return;
}
const r = optionValueToRef(v);
if (r) onChange(r);
}}
className={styles.startsInput}
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
</option>
))}
</select>
<p className={styles.nodeConfigNameHint}>
Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.
</p>
</div>
);
};

View file

@ -0,0 +1,405 @@
/**
* Inline dropdown to select a data source (node + path) - no popup.
* Form nodes (trigger.form / input.form): only payload.<fieldName> 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<string, unknown>;
const ct = p.clickupTask;
if (ct && typeof ct === 'object' && !Array.isArray(ct)) {
const o = ct as Record<string, unknown>;
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<string, unknown>)) {
result.push(...buildPickablePaths(v, [...basePath, k]));
}
return result;
}
return [{ path: [...basePath], pathLabel }];
}
/** Nur Formular-Felder: ein Eintrag pro Feld unter payload.<name> — kein rekursives Durchwandern. */
function buildFormSchemaPayloadPaths(params: Record<string, unknown>): 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<string, unknown>).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<string, unknown> } | 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<StatischKontextSelectProps> = ({
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 (
<select
value={currentSelect}
onChange={(e) => {
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);
}}
>
<option value="">{placeholder}</option>
<option value={STATIC_SOURCE_VALUE}>{staticLabel}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
</option>
))}
</select>
);
};
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<RefSourceSelectProps> = ({
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 (
<select
value={currentValue}
onChange={(e) => {
const v = e.target.value;
if (!v) {
onChange(null);
return;
}
const ref = optionValueToRef(v);
if (ref) onChange(ref);
}}
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
</option>
))}
</select>
);
};
/** Inferred field type for operator selection and value input */
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'email' | 'file' | 'unknown';
function getFormFieldType(
node: { parameters?: Record<string, unknown>; 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<string, unknown>).name === fieldName);
if (!field || typeof field !== 'object') return null;
const t = String((field as Record<string, unknown>).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<string, unknown>; type?: string }>,
nodeOutputsPreview: Record<string, unknown>
): 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<string, unknown>)[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';
}

View file

@ -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<string, React.ReactNode> = {
trigger: <FaPlay />,
@ -11,8 +11,10 @@ export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
flow: <FaCodeBranch />,
data: <FaDatabase />,
ai: <FaRobot />,
file: <FaFileAlt />,
email: <FaEnvelope />,
sharepoint: <FaCloud />,
clickup: <FaTasks />,
human: <FaUser />,
};

View file

@ -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<string, unknown>;
function buildReverseAdjacency(connections: CanvasConnection[]): Record<string, string[]> {
const rev: Record<string, string[]> = {};
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<string>();
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<string, unknown>;
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<string, unknown>).id);
if (nested) return nested;
}
}
const rel = tc.relationship;
if (rel && typeof rel === 'object' && rel !== null) {
const r = rel as Record<string, unknown>;
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<string, unknown>;
}
/**
* 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 (14)', 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 (14)', 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<string, unknown> = {};
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<string, unknown> = {
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 };
}

View file

@ -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;
}

View file

@ -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<string>();
/** Default category display order */
export const CATEGORY_ORDER = [
'trigger',
'input',
'flow',
'data',
'ai',
'file',
'email',
'sharepoint',
'clickup',
] as const;

View file

@ -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<string, string[]> {
const rev: Record<string, string[]> = {};
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<string>();
const queue = [currentNodeId];
const visited = new Set<string>([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<string, string[]> = {};
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<string>();
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);
}

View file

@ -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<string, unknown>
): 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<string, unknown>)[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}`;
}

View file

@ -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;

View file

@ -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<string, OutputPreviewBuilder> = {};
function parseFormFields(
params: Record<string, unknown>
): 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<string, unknown>;
return {
name: String(o.name ?? `field${i + 1}`),
type: typeof o.type === 'string' ? o.type : undefined,
};
}
return { name: `field${i + 1}` };
});
}
function runEnvelopeBase(): Record<string, unknown> {
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<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown> = {};
for (const f of fields) {
if (f.type === 'clickup_tasks') {
payload[f.name] = { add: ['…'], rem: [] };
} else {
payload[f.name] = '';
}
}
return { ...runEnvelopeBase(), payload };
});
registerOutputPreview('input.form', (node) => {
const params = node.parameters ?? {};
const fields = parseFormFields(params);
const payload: Record<string, unknown> = {};
for (const f of fields) {
if (f.type === 'clickup_tasks') {
payload[f.name] = '';
} else {
payload[f.name] = '';
}
}
/** Nur payload — kein Spread der Keys nach oben (vermeidet doppelte / verwirrende Pfade im Data Picker). */
return { payload };
});
registerOutputPreview('input.upload', () => ({
file: { id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' },
files: [{ id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' }],
fileIds: ['...'],
}));
registerOutputPreview('ai.*', () => ({ prompt: '...', context: '...', result: '...' }));
registerOutputPreview('file.*', () => ({
documents: [{ documentName: '...', documentData: '...' }],
documentList: [{ documentName: '...', documentData: '...' }],
}));
registerOutputPreview('email.*', () => ({ subject: '...', body: '...' }));
registerOutputPreview('email.searchEmail', () => ({
data: { searchResults: { results: [{ subject: '...', from: '...' }] } },
}));
registerOutputPreview('email.checkEmail', () => ({
data: { emails: { emails: [{ subject: '...', from: '...' }] } },
}));
registerOutputPreview('sharepoint.*', () => ({ file: { url: '...', name: '...' } }));
registerOutputPreview('sharepoint.listFiles', () => ({
files: [{ url: '...', name: '...' }],
}));
registerOutputPreview('clickup.createTask', () => ({
success: true,
taskId: '…',
clickupTask: { id: '…', name: '…' },
documents: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
documentList: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
}));
registerOutputPreview('clickup.*', () => ({
success: true,
taskId: '…',
clickupTask: { id: '…' },
documents: [{ documentName: 'clickup_result.json', documentData: '{}' }],
}));
registerOutputPreview('flow.ifElse', () => ({ branch: 0, conditionResult: true, input: {} }));
registerOutputPreview('flow.switch', () => ({ match: 0, value: '...' }));
registerOutputPreview('flow.loop', () => ({
items: [],
count: 0,
currentItem: { name: 'field', value: '...' },
currentIndex: 0,
}));

View file

@ -0,0 +1,28 @@
/**
* Shared types for node config renderers
*/
import type { ApiRequestFunction } from '../../../../api/automation2Api';
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
export type FormField = {
name?: string;
type?: string;
label?: string;
required?: boolean;
clickupConnectionId?: string;
clickupListId?: string;
/** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */
clickupStatusOptions?: Array<{ value: string; label: string }>;
};
export interface NodeConfigRendererProps {
params: Record<string, unknown>;
updateParam: (key: string, value: unknown) => void;
/** For Email/SharePoint: fetch connections and browse */
instanceId?: string;
request?: ApiRequestFunction;
nodeType?: string;
/** Merge into another node's parameters (e.g. sync form from ClickUp list). */
mergeNodeParameters?: (nodeId: string, patch: Record<string, unknown>) => void;
}

View file

@ -0,0 +1,122 @@
/**
* Start node (Formular) define fields that appear at run time (payload.*).
*/
import React, { useMemo } from 'react';
import type { NodeConfigRendererProps } from '../configs/types';
import styles from '../../editor/Automation2FlowEditor.module.css';
type FormField = {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
statusOptions?: Array<{ value: string; label: string }>;
};
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
function parseFields(params: Record<string, unknown>): FormField[] {
const raw = params.formFields;
if (!Array.isArray(raw)) return [{ name: 'field1', label: 'Feld 1', type: 'text' }];
return raw.map((f, i) => {
if (f && typeof f === 'object' && !Array.isArray(f)) {
const o = f as Record<string, unknown>;
const t = String(o.type ?? 'text');
const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `Feld ${i + 1}`);
const type = (
FORM_FIELD_TYPES.includes(t as (typeof FORM_FIELD_TYPES)[number]) ? t : 'text'
) as FormField['type'];
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
return {
name,
label,
type: 'clickup_status',
statusOptions: o.statusOptions as Array<{ value: string; label: string }>,
};
}
return { name, label, type };
}
return { name: `field${i + 1}`, label: `Feld ${i + 1}`, type: 'text' as const };
});
}
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const fields = useMemo(() => parseFields(params), [params]);
const setFields = (next: FormField[]) => {
updateParam('formFields', next);
};
return (
<div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}>
<strong>Formular-Felder</strong> werden beim Start ausgefüllt und liegen unter{' '}
<code>payload.&lt;name&gt;</code> in der Start-Ausgabe.
</p>
<div className={styles.formFieldsList}>
{fields.map((f, idx) => (
<div key={idx} className={styles.formFieldRow}>
<input
className={styles.startsInput}
placeholder="Name (Payload-Key)"
value={f.name}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, name: e.target.value };
setFields(next);
}}
/>
<input
className={styles.startsInput}
placeholder="Beschriftung"
value={f.label}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, label: e.target.value };
setFields(next);
}}
/>
<select
className={styles.startsSelect}
value={f.type}
onChange={(e) => {
const next = [...fields];
const t = e.target.value as FormField['type'];
if (t === 'clickup_status') {
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
} else {
next[idx] = { name: f.name, label: f.label, type: t };
}
setFields(next);
}}
>
<option value="text">Text</option>
<option value="number">Zahl</option>
<option value="email">E-Mail</option>
<option value="date">Datum</option>
<option value="boolean">Ja/Nein</option>
<option value="clickup_status">ClickUp-Status (Liste)</option>
</select>
<button
type="button"
className={styles.formFieldRemoveButton}
onClick={() => setFields(fields.filter((_, j) => j !== idx))}
>
</button>
</div>
))}
<button
type="button"
className={styles.startsAddBtn}
onClick={() =>
setFields([...fields, { name: `field${fields.length + 1}`, label: 'Neues Feld', type: 'text' }])
}
>
+ Feld
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,435 @@
/**
* Start node (Zeitplan) Karten-UI mit Konfiguration unter der gewählten Option.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
import type { NodeConfigRendererProps } from '../configs/types';
import {
type ScheduleSpec,
type ScheduleMode,
type IntervalUnit,
type CalendarPeriod,
buildCronFromSpec,
scheduleSpecFromParams,
WEEKDAYS_MO_SO,
} from '../runtime/scheduleCron';
import styles from '../../editor/Automation2FlowEditor.module.css';
const MODE_OPTIONS: { value: ScheduleMode; title: string; subtitle: string }[] = [
{ value: 'daily', title: 'Täglich', subtitle: 'Jeden Tag zur gleichen Zeit' },
{ value: 'weekdays', title: 'Werktage', subtitle: 'Montag bis Freitag' },
{ value: 'weekly', title: 'Bestimmte Tage', subtitle: 'Wochentage auswählen' },
{ value: 'calendar', title: 'Ein anderer Zeitraum', subtitle: 'Monatlich oder jährlich wiederkehrend' },
{ value: 'interval', title: 'Intervall', subtitle: 'In regelmäßigen Abständen' },
];
const MONTH_NAMES_DE = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
];
const INTERVAL_UNITS: { value: IntervalUnit; label: string; title: string }[] = [
{ value: 'seconds', label: 'sek', title: 'Sekunden' },
{ value: 'minutes', label: 'min', title: 'Minuten' },
{ value: 'hours', label: 'h', title: 'Stunden' },
{ value: 'days', label: 'd', title: 'Tage' },
{ value: 'years', label: 'a', title: 'Jahre' },
];
function timeString(hour: number, minute: number): string {
return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`;
}
function commitSpec(next: ScheduleSpec, updateParam: (key: string, value: unknown) => void) {
updateParam('schedule', next);
updateParam('cron', buildCronFromSpec(next));
}
function clampInterval(value: number, unit: IntervalUnit): number {
const v = Math.max(1, Math.floor(value) || 1);
switch (unit) {
case 'seconds':
return Math.min(59, v);
case 'minutes':
return Math.min(59, v);
case 'hours':
return Math.min(23, v);
case 'days':
return Math.min(31, v);
case 'years':
return Math.min(99, v);
default:
return v;
}
}
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
const prefersReducedMotion = useReducedMotion();
const specModeRef = useRef(spec.mode);
specModeRef.current = spec.mode;
useEffect(() => {
const derived = scheduleSpecFromParams(params as Record<string, unknown>);
console.log('[ScheduleStartNode] useEffect params → setSpec', {
paramsCron: params.cron,
paramsSchedule: params.schedule,
derivedMode: derived.mode,
previousSpecMode: specModeRef.current,
});
setSpec(derived);
}, [params.cron, params.schedule]);
useEffect(() => {
console.log('[ScheduleStartNode] spec.mode changed (UI sollte passen)', {
specMode: spec.mode,
cssBlockBase: styles.scheduleModeBlock,
cssBlockActive: styles.scheduleModeBlockActive,
});
}, [spec.mode]);
const push = useCallback(
(next: ScheduleSpec) => {
setSpec(next);
commitSpec(next, updateParam);
},
[updateParam]
);
const setMode = (mode: ScheduleMode) => {
console.log('[ScheduleStartNode] setMode', {
from: spec.mode,
to: mode,
refMode: specModeRef.current,
});
const base: ScheduleSpec = { ...spec, mode };
if (mode === 'weekly' && base.weekdays.length === 0) {
base.weekdays = [1, 2, 3, 4, 5];
}
if (mode === 'calendar') {
base.calendarPeriod = base.calendarPeriod ?? 'monthly';
}
push(base);
};
const onModeCardPointerEvent = (
phase: 'pointerdown' | 'click',
e: React.PointerEvent | React.MouseEvent,
o: (typeof MODE_OPTIONS)[number]
) => {
const el = e.target as HTMLElement;
const cur = e.currentTarget as HTMLElement;
const isLast = o.value === 'interval';
if (!isLast) return;
const cx = 'clientX' in e ? e.clientX : 0;
const cy = 'clientY' in e ? e.clientY : 0;
const hit = typeof document !== 'undefined' ? document.elementFromPoint(cx, cy) : null;
const hitH = hit as HTMLElement | null;
console.log(`[ScheduleStartNode] ${phase} — unterstes Element (Intervall)`, {
intendedMode: o.value,
title: o.title,
specModeClosure: spec.mode,
specModeRef: specModeRef.current,
eventPhase: e.nativeEvent.eventPhase,
target: { tag: el?.tagName, className: el?.className, id: el?.id },
currentTarget: { tag: cur?.tagName, className: cur?.className },
clientX: cx,
clientY: cy,
pointerId: 'pointerId' in e ? (e as React.PointerEvent).pointerId : undefined,
isTrusted: e.nativeEvent.isTrusted,
elementFromPoint: hitH
? {
tag: hitH.tagName,
className: hitH.className,
dataScheduleMode: hitH.closest?.('[data-schedule-mode]')?.getAttribute('data-schedule-mode'),
textSlice: (hitH.textContent ?? '').slice(0, 60),
}
: null,
});
};
const onTimeChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const v = ev.target.value;
if (!v) return;
const [hs, ms] = v.split(':');
const hour = Number(hs);
const minute = Number(ms);
if (Number.isNaN(hour) || Number.isNaN(minute)) return;
push({ ...spec, hour, minute });
};
const toggleWeekday = (cronDow: number) => {
const set = new Set(spec.weekdays);
if (set.has(cronDow)) set.delete(cronDow);
else set.add(cronDow);
let weekdays = [...set];
if (weekdays.length === 0) weekdays = [cronDow];
push({ ...spec, weekdays });
};
const setCalendarPeriod = (calendarPeriod: CalendarPeriod) => {
push({ ...spec, calendarPeriod });
};
const setIntervalUnit = (intervalUnit: IntervalUnit) => {
const next = { ...spec, intervalUnit };
next.intervalValue = clampInterval(next.intervalValue, intervalUnit);
push(next);
};
const panelTransition = prefersReducedMotion
? { duration: 0 }
: {
height: { duration: 0.44, ease: EASE_SMOOTH },
opacity: { duration: 0.3, ease: 'easeOut' as const },
};
return (
<div className={styles.schedulePanel}>
<p className={styles.startNodeDocIntro}>
Legen Sie fest, <strong>wann</strong> dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird
unten automatisch erzeugt.
</p>
<LayoutGroup>
<div className={styles.scheduleModeStack}>
{MODE_OPTIONS.map((o) => (
<motion.div
key={o.value}
data-schedule-mode={o.value}
data-active={spec.mode === o.value ? 'true' : 'false'}
className={
spec.mode === o.value
? `${styles.scheduleModeBlock} ${styles.scheduleModeBlockActive}`
: styles.scheduleModeBlock
}
>
<motion.button
type="button"
className={styles.scheduleModeCard}
onPointerDown={(e) => onModeCardPointerEvent('pointerdown', e, o)}
onClick={(e) => {
onModeCardPointerEvent('click', e, o);
setMode(o.value);
}}
whileTap={prefersReducedMotion ? undefined : { scale: 0.992 }}
transition={{ type: 'spring', stiffness: 520, damping: 32 }}
>
<span className={styles.scheduleModeCardTitle}>{o.title}</span>
<span className={styles.scheduleModeCardSubtitle}>{o.subtitle}</span>
</motion.button>
<AnimatePresence initial={false}>
{spec.mode === o.value && (
<motion.div
key={`panel-${o.value}`}
className={styles.scheduleModeConfigShell}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={panelTransition}
style={{ overflow: 'hidden' }}
>
<div className={styles.scheduleModeConfig}>
{o.value === 'daily' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
)}
{o.value === 'weekdays' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
)}
{o.value === 'weekly' && (
<>
<div className={styles.scheduleFieldCol}>
<span className={styles.scheduleFieldLabel}>Wochentage</span>
<div className={styles.scheduleWeekdayToggles}>
{WEEKDAYS_MO_SO.map(({ cronDow, label }) => (
<button
key={cronDow}
type="button"
className={
spec.weekdays.includes(cronDow) ? styles.scheduleDayOn : styles.scheduleDayOff
}
onClick={() => toggleWeekday(cronDow)}
>
{label}
</button>
))}
</div>
</div>
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
</>
)}
{o.value === 'calendar' && (
<>
<div className={styles.scheduleSubModes}>
<button
type="button"
className={
spec.calendarPeriod === 'monthly'
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
: styles.scheduleSubModeBtn
}
onClick={() => setCalendarPeriod('monthly')}
>
Monatlich
</button>
<button
type="button"
className={
spec.calendarPeriod === 'yearly'
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
: styles.scheduleSubModeBtn
}
onClick={() => setCalendarPeriod('yearly')}
>
Jährlich
</button>
</div>
{spec.calendarPeriod === 'monthly' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Monatstag</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
)}
{spec.calendarPeriod === 'yearly' && (
<div className={styles.scheduleYearlyRow}>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>Monat</span>
<select
className={styles.scheduleSelect}
value={spec.monthIndex}
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
>
{MONTH_NAMES_DE.map((name, i) => (
<option key={i + 1} value={i + 1}>
{name}
</option>
))}
</select>
</label>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>Tag</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
</div>
)}
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
</>
)}
{o.value === 'interval' && (
<div className={styles.scheduleIntervalRow}>
<span className={styles.scheduleFieldLabel}>Alle</span>
<input
type="number"
min={1}
className={styles.scheduleNumberInput}
value={spec.intervalValue}
onChange={(e) =>
push({
...spec,
intervalValue: clampInterval(Number(e.target.value) || 1, spec.intervalUnit),
})
}
/>
<select
className={styles.scheduleUnitSelect}
value={spec.intervalUnit}
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
title={INTERVAL_UNITS.find((u) => u.value === spec.intervalUnit)?.title}
>
{INTERVAL_UNITS.map((u) => (
<option key={u.value} value={u.value} title={u.title}>
{u.label}
</option>
))}
</select>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</LayoutGroup>
</div>
);
};

View file

@ -0,0 +1,40 @@
/**
* Start node (trigger.manual) documentation only. Entry points are configured
* on the workflow (Starts), not on this node.
*/
import React from 'react';
import type { NodeConfigRendererProps } from '../configs/types';
import styles from '../../editor/Automation2FlowEditor.module.css';
const SCHEMA_EXAMPLE = `{
"trigger": {
"type": "manual | form | schedule | email | webhook | api | event",
"entryPointId": "…",
"label": "…"
},
"payload": {},
"context": {},
"files": [],
"user": {},
"metadata": {},
"raw": {}
}`;
export const StartNodeConfig: React.FC<NodeConfigRendererProps> = () => {
return (
<div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}>
Die <strong>Start</strong>-Node liefert beim Ausführen immer dieselbe Struktur. Den <strong>Einstiegstyp</strong>{' '}
(manuell, Formular, Zeitplan, ) wählen Sie über das <strong>Zahnrad</strong> oben in der
Workflow-Konfiguration.
</p>
<p className={styles.startNodeDocSub}>Nachgelagerte Nodes können z.B. auf <code>payload</code> und{' '}
<code>trigger.type</code> zugreifen.</p>
<div className={styles.startNodeSchema}>
<div className={styles.startNodeSchemaTitle}>Ausgabe-Schema</div>
<pre className={styles.startNodePre}>{SCHEMA_EXAMPLE}</pre>
</div>
</div>
);
};

View file

@ -0,0 +1,3 @@
export { StartNodeConfig } from './StartNodeConfig';
export { FormStartNodeConfig } from './FormStartNodeConfig';
export { ScheduleStartNodeConfig } from './ScheduleStartNodeConfig';

View file

@ -0,0 +1,247 @@
/**
* Switch node config - RefSourceSelect für Datenquelle, Fälle mit Operator + Wert.
* Gleicher Kontext wie IfElse: typabhängige Operatoren (z.B. Alter < 19, = 30).
*/
import React from 'react';
import type { NodeConfigRendererProps } from '../configs/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, createValue } from '../shared/dataRef';
import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMapping';
import { operatorsForType } from '../shared/conditionOperators';
import styles from '../../editor/Automation2FlowEditor.module.css';
export interface SwitchCase {
operator: string;
value?: string | number | boolean;
}
function normalizeCase(c: unknown): SwitchCase {
if (c && typeof c === 'object' && 'operator' in (c as object)) {
const o = c as SwitchCase;
const v = o.value;
const safeValue: string | number | boolean | undefined =
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined;
return { operator: o.operator ?? 'eq', value: safeValue };
}
const fallbackValue: string | number | boolean | undefined =
typeof c === 'string' || typeof c === 'number' || typeof c === 'boolean' ? c : undefined;
return { operator: 'eq', value: fallbackValue };
}
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const dataFlow = useAutomation2DataFlow();
const valueParam = params.value;
const ref = isRef(valueParam) ? valueParam : null;
let staticValue: string | number = '';
if (!ref && valueParam != null) {
if (typeof valueParam === 'object' && 'value' in valueParam) {
const v = (valueParam as { value: unknown }).value;
staticValue = v !== undefined && v !== null ? String(v) : '';
} else if (typeof valueParam === 'string' || typeof valueParam === 'number') {
staticValue = valueParam;
}
}
const rawCases = (params.cases as unknown[]) ?? [];
const cases: SwitchCase[] = rawCases.map(normalizeCase);
const fieldType = dataFlow ? getFieldType(ref, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown';
const operators = operatorsForType(fieldType);
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<string, unknown> }) => n.id === ref.nodeId)
: null;
const mimeTypeOptions =
isMimeTypeRef && sourceNode?.type === 'input.upload' && sourceNode.parameters
? getMimeTypeOptionsFromUploadParams(sourceNode.parameters as Record<string, unknown>)
: [];
const setValue = (val: unknown) => {
updateParam('value', val);
};
const setCases = (next: SwitchCase[]) => {
updateParam('cases', next);
};
const handleRefChange = (newRef: { type: 'ref'; nodeId: string; path: (string | number)[] } | null) => {
if (newRef) {
setValue(newRef);
} else {
setValue(createValue(staticValue));
}
};
const handleStaticValueChange = (v: string) => {
setValue(createValue(fieldType === 'number' ? parseFloat(v) || 0 : v));
};
const handleCaseOperatorChange = (index: number, op: string) => {
const opDef = operators.find((o) => o.value === op);
const next = [...cases];
next[index] = {
operator: op,
value: opDef?.needsValue ? cases[index]?.value : undefined,
};
setCases(next);
};
const handleCaseValueChange = (index: number, v: string | number | boolean) => {
const next = [...cases];
next[index] = {
...next[index],
value: fieldType === 'number' ? (typeof v === 'number' ? v : parseFloat(String(v)) || 0)
: fieldType === 'boolean' ? (v === true || v === 'true')
: String(v),
};
setCases(next);
};
const renderCaseValueInput = (caseItem: SwitchCase, index: number) => {
const val = caseItem.value;
const valStr = String(val ?? '');
if (mimeTypeOptions.length > 0) {
return (
<select
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
className={styles.startsInput}
>
<option value=""> MIME-Type wählen </option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
</option>
))}
</select>
);
}
if (fieldType === 'number') {
return (
<input
type="number"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, parseFloat(e.target.value) || 0)}
placeholder="0"
/>
);
}
if (fieldType === 'date') {
return (
<input
type="date"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
/>
);
}
if (fieldType === 'boolean') {
return (
<select
value={val === true ? 'true' : val === false ? 'false' : ''}
onChange={(e) => {
const v = e.target.value;
handleCaseValueChange(index, v === 'true' ? true : v === 'false' ? false : '');
}}
className={styles.startsInput}
>
<option value=""> wählen </option>
<option value="true">Ja / wahr</option>
<option value="false">Nein / falsch</option>
</select>
);
}
return (
<input
type="text"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
placeholder={isMimeTypeRef ? 'z.B. application/pdf' : `Wert`}
/>
);
};
const addCase = () => {
const opDef = operators[0];
const defaultVal = opDef?.needsValue
? (fieldType === 'number' ? 0 : fieldType === 'boolean' ? false : '')
: undefined;
setCases([
...cases,
{ operator: opDef?.value ?? 'eq', value: defaultVal },
]);
};
return (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>Datenquelle</label>
<RefSourceSelect
value={ref}
onChange={handleRefChange}
placeholder="Feld zum Vergleichen wählen…"
/>
</div>
{!ref && (
<div className={styles.ifElseConditionRow}>
<label>Fester Wert (falls keine Referenz)</label>
<input
type="text"
value={String(staticValue ?? '')}
onChange={(e) => handleStaticValueChange(e.target.value)}
placeholder="z.B. CH oder 42"
/>
</div>
)}
<div className={styles.ifElseConditionRow}>
<label>Fälle (Reihenfolge = Ausgang)</label>
<div className={styles.formFieldsList}>
{cases.map((c, i) => {
const opDef = operators.find((o) => o.value === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} className={styles.formFieldRow}>
<select
value={c.operator}
onChange={(e) => handleCaseOperatorChange(i, e.target.value)}
className={styles.startsInput}
style={{ minWidth: 140 }}
>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{needsValue && (
<div style={{ flex: 1 }}>
{renderCaseValueInput(c, i)}
</div>
)}
<button
type="button"
className={styles.formFieldRemoveButton}
onClick={() => setCases(cases.filter((_, j) => j !== i))}
>
</button>
</div>
);
})}
<button type="button" className={styles.startsAddBtn} onClick={addCase}>
+ Fall
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1 @@
export { SwitchNodeConfig } from './SwitchNodeConfig';

View file

@ -26,6 +26,8 @@ export interface SharepointBrowseTreeProps {
onSelectFile: (path: string) => void;
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
onSelectFolder?: (path: string) => void;
/** If true, file rows are not shown — only folders (for list/upload/destination folder pickers). */
foldersOnly?: boolean;
/** Currently selected path (for highlight) */
selectedPath?: string | null;
/** Optional: pre-seed root children (e.g. from initial load) */
@ -86,6 +88,7 @@ function _FolderRow({
onToggle,
onSelectFile,
onSelectFolder,
foldersOnly,
}: {
entry: BrowseEntry;
selectedPath: string | null | undefined;
@ -95,6 +98,7 @@ function _FolderRow({
onToggle: (path: string) => void;
onSelectFile: (path: string) => void;
onSelectFolder?: (path: string) => void;
foldersOnly: boolean;
}) {
const isExpanded = expandedPaths.has(entry.path);
const isSelected = selectedPath === entry.path;
@ -159,9 +163,11 @@ function _FolderRow({
onToggle={onToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
foldersOnly={foldersOnly}
/>
))}
{files.map((child) => (
{!foldersOnly &&
files.map((child) => (
<_FileRow
key={child.path}
entry={child}
@ -189,6 +195,7 @@ export function SharepointBrowseTree({
onLoadChildren,
onSelectFile,
onSelectFolder,
foldersOnly = false,
selectedPath,
initialChildren = [],
}: SharepointBrowseTreeProps) {
@ -282,9 +289,11 @@ export function SharepointBrowseTree({
onToggle={handleToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
foldersOnly={foldersOnly}
/>
))}
{rootFiles.map((entry) => (
{!foldersOnly &&
rootFiles.map((entry) => (
<_FileRow
key={entry.path}
entry={entry}

View file

@ -292,7 +292,11 @@ export function useConnections() {
return;
}
if (event.data.type === 'msft_connection_success' || event.data.type === 'google_connection_success') {
if (
event.data.type === 'msft_connection_success' ||
event.data.type === 'google_connection_success' ||
event.data.type === 'clickup_connection_success'
) {
// Clean up
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
@ -302,7 +306,11 @@ export function useConnections() {
// Refresh connections
fetchConnections();
resolve();
} else if (event.data.type === 'msft_connection_error' || event.data.type === 'google_connection_error') {
} else if (
event.data.type === 'msft_connection_error' ||
event.data.type === 'google_connection_error' ||
event.data.type === 'clickup_connection_error'
) {
// Handle error
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
@ -402,6 +410,77 @@ export function useConnections() {
}
};
// Create ClickUp connection and open OAuth popup
const createClickupConnectionAndAuth = async (): Promise<void> => {
try {
const newConnection = await createConnection({
type: 'clickup',
authority: 'clickup',
});
const connectResponse = await connectServiceApi(request, newConnection.id);
if (!connectResponse.authUrl) {
throw new Error('No OAuth URL received from backend');
}
const apiBaseUrl = getApiBaseUrl();
let authUrl = connectResponse.authUrl;
if (authUrl.startsWith('/')) {
authUrl = `${apiBaseUrl}${authUrl}`;
}
return new Promise((resolve, reject) => {
const popup = window.open(
authUrl,
'clickup-connection',
'width=500,height=600,scrollbars=yes,resizable=yes'
);
if (!popup) {
reject(new Error('Popup was blocked. Please allow popups and try again.'));
return;
}
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
console.log('ClickUp OAuth popup closed');
fetchConnections();
resolve();
}
}, 1000);
const messageListener = (event: MessageEvent) => {
const apiUrl = new URL(apiBaseUrl);
if (event.origin !== apiUrl.origin) {
return;
}
if (event.data.type === 'clickup_connection_success') {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
console.log('ClickUp connection successful');
fetchConnections();
resolve();
} else if (event.data.type === 'clickup_connection_error') {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
reject(new Error(event.data.error || 'ClickUp connection failed'));
}
};
window.addEventListener('message', messageListener);
});
} catch (error) {
console.error('Error creating ClickUp connection:', error);
throw error;
}
};
// Create Microsoft connection and open OAuth popup
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
try {
@ -600,6 +679,7 @@ export function useConnections() {
connectWithPopup,
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth,
isLoading,
loading: isLoading, // Alias for FormGenerator compatibility
isConnecting,
@ -681,7 +761,11 @@ export function useOAuthConnect() {
return;
}
if (event.data.type === 'msft_connection_success' || event.data.type === 'google_connection_success') {
if (
event.data.type === 'msft_connection_success' ||
event.data.type === 'google_connection_success' ||
event.data.type === 'clickup_connection_success'
) {
// Clean up - IMPORTANT: clear the checkClosed interval first
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
@ -691,7 +775,11 @@ export function useOAuthConnect() {
// Refresh connections
fetchConnections();
resolve();
} else if (event.data.type === 'msft_connection_error' || event.data.type === 'google_connection_error') {
} else if (
event.data.type === 'msft_connection_error' ||
event.data.type === 'google_connection_error' ||
event.data.type === 'clickup_connection_error'
) {
// Handle error - also clear the checkClosed interval
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);

View file

@ -508,11 +508,8 @@ export function useFileOperations() {
// FormData is now correctly configured for backend
const response = await api.post('/api/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
// Do NOT set Content-Type manually axios sets multipart/form-data with boundary for FormData
const response = await api.post('/api/files/upload', formData);
const fileData = response.data;
// Check if the response indicates a duplicate file

View file

@ -128,6 +128,34 @@
cursor: not-allowed;
}
.clickupButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #7b68ee;
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.clickupButton:hover {
background: #6a5acd;
}
.clickupButton:active {
transform: scale(0.98);
}
.clickupButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Filter Section Styles */
.filterSection {
display: flex;
@ -714,7 +742,9 @@
.primaryButton,
.secondaryButton,
.dangerButton {
.dangerButton,
.googleButton,
.clickupButton {
min-height: 40px;
white-space: normal;
}

View file

@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt } from 'react-icons/fa';
import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css';
@ -29,6 +29,7 @@ export const ConnectionsPage: React.FC = () => {
handleInlineUpdate,
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth,
connectWithPopup,
refreshMicrosoftToken,
refreshGoogleToken,
@ -100,7 +101,12 @@ export const ConnectionsPage: React.FC = () => {
// Validate and set authority if present
if (data.authority) {
if (data.authority === 'local' || data.authority === 'google' || data.authority === 'msft') {
if (
data.authority === 'local' ||
data.authority === 'google' ||
data.authority === 'msft' ||
data.authority === 'clickup'
) {
updateData.authority = data.authority;
} else {
// Remove invalid authority value
@ -184,6 +190,16 @@ export const ConnectionsPage: React.FC = () => {
}
};
// Handle create ClickUp connection
const handleCreateClickup = async () => {
try {
await createClickupConnectionAndAuth();
refetch();
} catch (error) {
console.error('Error creating ClickUp connection:', error);
}
};
// Open Microsoft Admin Consent flow in a popup
const handleAdminConsent = () => {
setAdminConsentPending(true);
@ -228,7 +244,7 @@ export const ConnectionsPage: React.FC = () => {
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Verbindungen</h1>
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten</p>
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten (Google, Microsoft, ClickUp)</p>
</div>
<div className={styles.headerActions}>
<button
@ -262,6 +278,14 @@ export const ConnectionsPage: React.FC = () => {
>
<FaMicrosoft /> Microsoft
</button>
<button
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title="ClickUp-Konto verbinden"
>
<FaTasks /> ClickUp
</button>
</>
)}
</div>
@ -278,10 +302,10 @@ export const ConnectionsPage: React.FC = () => {
<FaPlug className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
<p className={styles.emptyDescription}>
Verbinden Sie Ihr Google- oder Microsoft-Konto, um loszulegen.
Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.
</p>
{canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<button
className={styles.googleButton}
onClick={handleCreateGoogle}
@ -296,6 +320,13 @@ export const ConnectionsPage: React.FC = () => {
>
<FaMicrosoft /> Mit Microsoft verbinden
</button>
<button
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
>
<FaTasks /> Mit ClickUp verbinden
</button>
</div>
)}
</div>

View file

@ -1,13 +1,14 @@
/**
* Automation2WorkflowsPage
* List of saved workflows with FormGeneratorTable.
* Shows: label, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
* Actions: Edit (navigate to editor), Delete, Execute.
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
* Filter: Alle | Aktiv | Inaktiv.
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync } from 'react-icons/fa';
import { FaPlay, FaSync, FaCheck, FaBan } from 'react-icons/fa';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
@ -15,6 +16,7 @@ import {
fetchWorkflows,
deleteWorkflow,
executeGraph,
updateWorkflow,
type Automation2Workflow,
} from '../../../api/automation2Api';
import { useToast } from '../../../contexts/ToastContext';
@ -44,12 +46,16 @@ export const Automation2WorkflowsPage: React.FC = () => {
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
try {
const list = await fetchWorkflows(request, instanceId);
const params =
activeFilter === 'active' ? { active: true } : activeFilter === 'inactive' ? { active: false } : undefined;
const list = await fetchWorkflows(request, instanceId, params);
setWorkflows(list);
} catch (e) {
console.error('[Automation2] load workflows failed', e);
@ -57,7 +63,7 @@ export const Automation2WorkflowsPage: React.FC = () => {
} finally {
setLoading(false);
}
}, [instanceId, request, showError]);
}, [instanceId, request, showError, activeFilter]);
useEffect(() => {
load();
@ -87,12 +93,41 @@ export const Automation2WorkflowsPage: React.FC = () => {
[mandateId, instanceId, navigate]
);
const hasManualTrigger = useCallback((row: Automation2Workflow): boolean => {
const invs = row.invocations || [];
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
}, []);
const handleToggleActive = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const next = !(row.active !== false);
setTogglingId(row.id);
try {
await updateWorkflow(request, instanceId, row.id, { active: next });
showSuccess(next ? 'Workflow aktiviert' : 'Workflow deaktiviert');
await load();
} catch (e: any) {
showError(`Fehler: ${e?.message || 'Status-Update fehlgeschlagen'}`);
} finally {
setTogglingId(null);
}
},
[instanceId, request, showSuccess, showError, load]
);
const handleExecute = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
setExecutingId(row.id);
try {
const result = await executeGraph(request, instanceId, row.graph!, row.id);
const invs = row.invocations || [];
const primary =
invs.find((i) => i.enabled && i.kind === 'manual') ||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
const result = await executeGraph(request, instanceId, row.graph!, row.id, {
...(primary ? { entryPointId: primary.id } : {}),
});
if (result?.success) {
if (result?.paused) {
showSuccess('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.');
@ -114,6 +149,18 @@ export const Automation2WorkflowsPage: React.FC = () => {
const columns: ColumnConfig[] = [
{ key: 'label', label: 'Workflow', type: 'string', width: 200, sortable: true },
{
key: 'active',
label: 'Aktiv',
type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value !== false ? (
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>Ja</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
),
},
{
key: 'isRunning',
label: 'Läuft',
@ -181,7 +228,19 @@ export const Automation2WorkflowsPage: React.FC = () => {
Workflows verwalten, ausführen und bearbeiten
</p>
</div>
<div className={styles.headerActions}>
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
{(['all', 'active', 'inactive'] as const).map((f) => (
<button
key={f}
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveFilter(f)}
disabled={loading}
>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktiv' : 'Inaktiv'}
</button>
))}
</div>
<button
className={styles.secondaryButton}
onClick={() => load()}
@ -215,12 +274,29 @@ export const Automation2WorkflowsPage: React.FC = () => {
},
]}
customActions={[
{
id: 'activate',
icon: <FaCheck />,
title: 'Aktivieren',
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active === false,
},
{
id: 'deactivate',
icon: <FaBan />,
title: 'Deaktivieren',
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active !== false,
},
{
id: 'execute',
icon: <FaPlay />,
title: 'Ausführen',
onClick: (row) => handleExecute(row),
loading: (row) => executingId === row.id,
visible: (row) => hasManualTrigger(row),
},
]}
onDelete={(row) => handleDelete(row.id)}

View file

@ -1,6 +1,121 @@
.container {
.pageLayout {
display: flex;
align-items: flex-start;
gap: 1.5rem;
padding: 1.5rem;
max-width: 900px;
max-width: 1400px;
}
.mainColumn {
flex: 1;
min-width: 0;
}
.startSidebar {
flex: 0 0 300px;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: var(--bg-secondary, #f8f9fa);
overflow: hidden;
}
.startSidebarTitle {
margin: 0;
padding: 0.75rem 1rem;
font-size: 0.95rem;
font-weight: 600;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff);
}
.startSidebarList {
margin: 0;
padding: 0.5rem;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.startWorkflowRow {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border-radius: 6px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
}
.startWorkflowInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.startWorkflowName {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.startWorkflowKind {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary, #666);
}
.startButton {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.65rem;
border: none;
border-radius: 6px;
background: var(--primary-color, #007bff);
color: white;
cursor: pointer;
font-size: 0.8rem;
}
.startButton:hover:not(:disabled) {
opacity: 0.9;
}
.startButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 900px) {
.pageLayout {
flex-direction: column;
}
.startSidebar {
position: static;
max-height: none;
width: 100%;
flex: none;
}
}
.container {
padding: 0;
width: 100%;
}
.container h2 {
@ -195,6 +310,7 @@
.formFields input[type='text'],
.formFields input[type='number'],
.formFields input[type='date'],
.formFields select,
.taskCard input[type='text'],
.taskCard input[type='number'],
.taskCard textarea {
@ -279,3 +395,72 @@
opacity: 0.6;
cursor: not-allowed;
}
/* Upload task */
.uploadTaskBlock {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.5rem;
}
.uploadTaskBlock .uploadButton {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
align-self: flex-start;
}
.uploadTaskBlock .uploadButton:hover:not(:disabled) {
opacity: 0.9;
}
.uploadTaskBlock .uploadButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.uploadTaskBlock .uploadError {
margin: 0;
font-size: 0.875rem;
color: var(--danger-color, #dc3545);
}
.uploadTaskBlock .uploadedList {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
/* Output section */
.outputContent {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.outputContent .metaLabel {
margin-top: 0.25rem;
}
.outputContent .uploadedList {
margin-top: 0.2rem;
}
.downloadLink {
color: var(--primary-color, #007bff);
text-decoration: none;
}
.downloadLink:hover {
text-decoration: underline;
}

View file

@ -3,17 +3,29 @@
* Tasks only (no workflow grouping).
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
* Each task shows workflow, created, due, step, type, and action.
* Right column: active workflows with manual or form entry point start via execute (same as Workflows page).
*/
import React, { useState, useEffect, useCallback } from 'react';
import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaUpload } from 'react-icons/fa';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchTasks,
completeTask,
fetchCompletedRuns,
fetchWorkflows,
executeGraph,
loadClickupListTasksForDropdown,
type Automation2Task,
type Automation2Workflow,
type CompletedRun,
type ApiRequestFunction,
} from '../../../api/automation2Api';
import { useToast } from '../../../contexts/ToastContext';
import { Popup } from '../../../components/UiComponents/Popup';
import { getAcceptStringFromConfig } from '../../../components/Automation2FlowEditor';
import { useFileOperations } from '../../../hooks/useFiles';
import styles from './Automation2WorkflowsTasks.module.css';
const NODE_TYPE_LABELS: Record<string, string> = {
@ -49,20 +61,66 @@ function getNodeStepLabel(config: Record<string, unknown>): string {
return '';
}
/** Active workflow with at least one enabled manual or form start (same idea as Tasks / editor on-demand). */
function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
const invs = wf.invocations || [];
return invs.some(
(i) => i.enabled !== false && (i.kind === 'manual' || i.kind === 'form')
);
}
/**
* Primary entry for execute matches Automation2WorkflowsPage.handleExecute
* (manual first, then form or api).
*/
function getPrimaryEntryPoint(wf: Automation2Workflow) {
const invs = wf.invocations || [];
return (
invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api'))
);
}
function primaryKindLabel(kind: string): string {
if (kind === 'form') return 'Formular';
if (kind === 'manual') return 'Manuell';
return kind;
}
export const Automation2WorkflowsTasksPage: React.FC = () => {
const instanceId = useInstanceId();
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const [tasks, setTasks] = useState<Automation2Task[]>([]);
const [completedRuns, setCompletedRuns] = useState<CompletedRun[]>([]);
const [startableWorkflows, setStartableWorkflows] = useState<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [completedExpanded, setCompletedExpanded] = useState(false);
const [outputExpanded, setOutputExpanded] = useState(true);
const [submitting, setSubmitting] = useState<string | null>(null);
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
try {
const taskList = await fetchTasks(request, instanceId);
const [taskList, runs] = await Promise.all([
fetchTasks(request, instanceId),
fetchCompletedRuns(request, instanceId, 20),
]);
setTasks(taskList);
setCompletedRuns(runs);
try {
const activeWfs = await fetchWorkflows(request, instanceId, { active: true });
setStartableWorkflows(
(activeWfs ?? []).filter(
(w) => w.active !== false && hasManualOrFormInvocation(w)
)
);
} catch (we) {
console.error('[Automation2] load startable workflows failed', we);
setStartableWorkflows([]);
}
} catch (e) {
console.error('[Automation2] load failed', e);
} finally {
@ -87,6 +145,36 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
}
};
const handleStartWorkflow = useCallback(
async (wf: Automation2Workflow) => {
if (!instanceId || !wf.graph) return;
const primary = getPrimaryEntryPoint(wf);
setExecutingWorkflowId(wf.id);
try {
const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
...(primary ? { entryPointId: primary.id } : {}),
});
if (result?.success) {
if (result?.paused) {
showSuccess('Workflow gestartet und bei Human Task pausiert.');
} else {
showSuccess('Workflow gestartet');
}
await load();
} else {
showError(result?.error || 'Ausführung fehlgeschlagen');
}
} catch (e: unknown) {
const msg =
(e as { message?: string })?.message ?? 'Ausführung fehlgeschlagen';
showError(msg);
} finally {
setExecutingWorkflowId(null);
}
},
[instanceId, request, showSuccess, showError, load]
);
const openTasks = tasks.filter((t) => t.status === 'pending');
const completedTasks = tasks.filter((t) => t.status !== 'pending');
@ -109,6 +197,8 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
}
return (
<div className={styles.pageLayout}>
<div className={styles.mainColumn}>
<div className={styles.container}>
<h2>Tasks</h2>
@ -126,6 +216,7 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
<TaskCard
key={task.id}
task={task}
instanceId={instanceId ?? undefined}
onSubmit={(result) => handleComplete(task.id, result)}
submitting={submitting === task.id}
/>
@ -156,6 +247,7 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
<TaskCard
key={task.id}
task={task}
instanceId={instanceId ?? undefined}
onSubmit={(result) => handleComplete(task.id, result)}
submitting={submitting === task.id}
readOnly
@ -165,40 +257,307 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
</div>
)}
</section>
{/* Output abgeschlossene Workflows mit Ergebnis */}
<section className={styles.section}>
<button
type="button"
className={styles.completedHeader}
onClick={() => setOutputExpanded((p) => !p)}
>
{outputExpanded ? <FaChevronDown /> : <FaChevronRight />}
<span>Output</span>
{completedRuns.length > 0 && (
<span className={styles.badge}>{completedRuns.length}</span>
)}
</button>
{outputExpanded && (
<div className={styles.completedList}>
{completedRuns.length === 0 ? (
<p className={styles.empty}>
Keine abgeschlossenen Workflows. Führen Sie einen Workflow aus (z.B. im Editor), um hier die Ergebnisse zu sehen.
</p>
) : (
completedRuns.map((run) => (
<OutputCard key={run.id} run={run} instanceId={instanceId ?? undefined} />
))
)}
</div>
)}
</section>
</div>
</div>
<aside className={styles.startSidebar} aria-label="Workflows starten">
<h3 className={styles.startSidebarTitle}>Workflow starten</h3>
<div className={styles.startSidebarList}>
{startableWorkflows.length === 0 ? (
<p className={styles.empty}>
Keine aktiven Workflows mit manuellem oder Formular-Start.
</p>
) : (
startableWorkflows.map((wf) => {
const primary = getPrimaryEntryPoint(wf);
const kind = primary?.kind ?? 'manual';
return (
<div key={wf.id} className={styles.startWorkflowRow}>
<div className={styles.startWorkflowInfo}>
<span className={styles.startWorkflowName} title={wf.label}>
{wf.label || wf.id}
</span>
<span className={styles.startWorkflowKind}>
{primaryKindLabel(kind)}
</span>
</div>
<button
type="button"
className={styles.startButton}
title="Workflow ausführen"
disabled={executingWorkflowId === wf.id}
onClick={() => handleStartWorkflow(wf)}
>
{executingWorkflowId === wf.id ? (
<FaSpinner className={styles.spinner} />
) : (
<FaPlay />
)}
</button>
</div>
);
})
)}
</div>
</aside>
</div>
);
};
/** Output card for completed workflow runs zeigt nur die erstellten Dateien (mit fileId). */
const OutputCard: React.FC<{
run: CompletedRun;
instanceId?: string;
}> = ({ run }) => {
const ts = run._modifiedAt ?? run._createdAt ?? 0;
const files: Array<{ name: string; fileId: string }> = [];
const nodeOutputs = run.nodeOutputs ?? {};
for (const [, out] of Object.entries(nodeOutputs)) {
if (!out || typeof out !== 'object') continue;
const o = out as Record<string, unknown>;
const docs = (o.documents ?? o.documentList ?? []) as Array<Record<string, unknown>>;
if (!Array.isArray(docs)) continue;
for (const d of docs) {
const fileId = (d.validationMetadata as Record<string, unknown>)?.fileId as string | undefined;
if (fileId) {
files.push({
name: String(d.documentName ?? d.fileName ?? 'Datei'),
fileId,
});
}
}
}
return (
<div className={styles.taskCard}>
<div className={styles.taskMeta}>
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Workflow</span>
<span className={styles.metaValue}>{run.workflowLabel || run.workflowId || '—'}</span>
</div>
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Abgeschlossen</span>
<span className={styles.metaValue}>{formatTimestamp(ts)}</span>
</div>
</div>
{files.length > 0 ? (
<div className={styles.outputContent}>
<span className={styles.metaLabel}>Dateien</span>
<ul className={styles.uploadedList}>
{files.map((f, j) => (
<li key={j}>
<Link
to="/basedata/files"
className={styles.downloadLink}
>
{f.name}
</Link>
</li>
))}
</ul>
</div>
) : (
<p className={styles.empty}>Kein Output (z.B. Workflow ohne file.create)</p>
)}
</div>
);
};
interface TaskCardProps {
task: Automation2Task;
instanceId?: string;
onSubmit: (result: Record<string, unknown>) => void;
submitting: boolean;
readOnly?: boolean;
}
/** Check if file matches accept string (e.g. ".pdf,image/*"). */
function relationshipTaskIdFromFormValue(v: unknown): string {
if (v && typeof v === 'object' && !Array.isArray(v) && 'add' in v) {
const a = (v as { add?: unknown[] }).add;
if (Array.isArray(a) && a[0] != null && String(a[0]).trim()) return String(a[0]);
}
return '';
}
function InputFormClickupTaskField({
connectionId,
listId,
value,
onChange,
request,
}: {
connectionId: string;
listId: string;
value: unknown;
onChange: (v: unknown) => void;
request: ApiRequestFunction;
}) {
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const cid = connectionId.trim();
const lid = listId.trim();
if (!cid || !lid) {
setTasks([]);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
loadClickupListTasksForDropdown(request, cid, lid)
.then((rows) => {
if (!cancelled) setTasks(rows);
})
.catch(() => {
if (!cancelled) {
setTasks([]);
setErr('Aufgaben konnten nicht geladen werden.');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [request, connectionId, listId]);
const sel = relationshipTaskIdFromFormValue(value);
if (!connectionId.trim() || !listId.trim()) {
return (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt bitte Workflow
prüfen.
</p>
);
}
return (
<>
{err ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>Lade Aufgaben</p>
) : (
<select
value={sel}
onChange={(e) => {
const tid = e.target.value;
if (!tid) onChange({ add: [], rem: [] });
else onChange({ add: [tid], rem: [] });
}}
>
<option value=""> Aufgabe wählen </option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
</>
);
}
function fileMatchesAccept(file: File, accept: string): boolean {
if (!accept || !accept.trim()) return true;
const parts = accept.split(',').map((s) => s.trim()).filter(Boolean);
const ext = '.' + (file.name.split('.').pop() ?? '').toLowerCase();
const mime = (file.type ?? '').toLowerCase();
for (const p of parts) {
const pp = p.toLowerCase();
if (pp.startsWith('.')) {
if (ext === pp) return true;
const exts = pp.split(',').map((x) => (x.trim().startsWith('.') ? x.trim() : '.' + x.trim()));
if (exts.some((e) => e === ext)) return true;
} else if (pp.endsWith('/*')) {
const prefix = pp.slice(0, -2);
if (mime.startsWith(prefix)) return true;
} else if (mime === pp || mime.startsWith(pp + '/')) return true;
}
return false;
}
const TaskCard: React.FC<TaskCardProps> = ({
task,
instanceId,
onSubmit,
submitting,
readOnly = false,
}) => {
const { request } = useApiRequest();
const { handleFileUpload } = useFileOperations();
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [formPopupOpen, setFormPopupOpen] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<Array<{ id: string; fileName: string; file?: Record<string, unknown> }>>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const config = task.config ?? {};
const nodeType = task.nodeType;
const stepLabel = getNodeStepLabel(config);
useEffect(() => {
setUploadedFiles([]);
setUploadError(null);
}, [task.id]);
const renderInput = () => {
if (readOnly) return null;
switch (nodeType) {
case 'input.form': {
const fields =
(config.fields as Array<{ name: string; type: string; label: string; required?: boolean }>) ??
[];
(config.fields as Array<{
name: string;
type: string;
label: string;
required?: boolean;
clickupConnectionId?: string;
clickupListId?: string;
clickupStatusOptions?: Array<{ value: string; label: string }>;
}>) ?? [];
const requiredFields = fields.filter((f) => f.required);
const allRequiredFilled = requiredFields.every((f) => {
const v = formData[f.name];
if (f.type === 'boolean') return true;
if (f.type === 'clickup_tasks') {
return relationshipTaskIdFromFormValue(v) !== '';
}
if (f.type === 'clickup_status') {
return v !== undefined && v !== null && String(v).trim() !== '';
}
return v !== undefined && v !== null && String(v).trim() !== '';
});
const formContent = (
@ -217,6 +576,30 @@ const TaskCard: React.FC<TaskCardProps> = ({
setFormData((p) => ({ ...p, [f.name]: e.target.checked }))
}
/>
) : f.type === 'clickup_tasks' && request ? (
<InputFormClickupTaskField
connectionId={f.clickupConnectionId ?? ''}
listId={f.clickupListId ?? ''}
value={formData[f.name]}
onChange={(v) => setFormData((p) => ({ ...p, [f.name]: v }))}
request={request}
/>
) : f.type === 'clickup_status' &&
Array.isArray(f.clickupStatusOptions) &&
f.clickupStatusOptions.length > 0 ? (
<select
value={(formData[f.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
}
>
<option value=""> Status wählen </option>
{f.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={
@ -251,7 +634,8 @@ const TaskCard: React.FC<TaskCardProps> = ({
<button
type="button"
onClick={() => {
onSubmit(formData);
// Match node output shape used by refs (payload.*) and outputPreviewRegistry.input.form
onSubmit({ payload: formData });
setFormPopupOpen(false);
}}
disabled={submitting || !allRequiredFilled}
@ -368,19 +752,123 @@ const TaskCard: React.FC<TaskCardProps> = ({
</div>
</div>
);
case 'input.upload':
case 'input.upload': {
const acceptStr = getAcceptStringFromConfig(config);
const maxSizeMB = (config.maxSize as number) ?? 10;
const allowMultiple = (config.multiple as boolean) ?? false;
const maxSizeBytes = maxSizeMB * 1024 * 1024;
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files?.length || !instanceId) return;
if (!allowMultiple && files.length > 1) {
setUploadError('Nur eine Datei erlaubt.');
return;
}
setUploadError(null);
setUploading(true);
const results: Array<{ id: string; fileName: string; file?: Record<string, unknown> }> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > maxSizeBytes) {
setUploadError(`Datei "${file.name}" zu groß (max. ${maxSizeMB} MB).`);
setUploading(false);
e.target.value = '';
return;
}
if (acceptStr && !fileMatchesAccept(file, acceptStr)) {
setUploadError(`Dateityp von "${file.name}" nicht erlaubt.`);
setUploading(false);
e.target.value = '';
return;
}
try {
const result = await handleFileUpload(
file,
task.workflowId ?? undefined,
instanceId ?? undefined
);
if (result?.success && result?.fileData) {
const fileMeta = result.fileData?.file ?? result.fileData;
const fileId = fileMeta?.id ?? fileMeta?.fileName;
if (fileId) {
results.push({
id: fileId,
fileName: fileMeta?.fileName ?? file.name,
file: fileMeta,
});
}
} else if (result?.error) {
setUploadError(result.error);
setUploading(false);
e.target.value = '';
return;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })?.response?.data?.detail ?? (err as Error)?.message ?? 'Upload fehlgeschlagen';
setUploadError(msg);
setUploading(false);
e.target.value = '';
return;
}
}
setUploadedFiles((prev) => (allowMultiple ? [...prev, ...results] : results));
setUploading(false);
e.target.value = '';
};
const handleSubmitUpload = () => {
if (uploadedFiles.length === 0) {
setUploadError('Bitte mindestens eine Datei hochladen.');
return;
}
const file = uploadedFiles[0]?.file ?? { id: uploadedFiles[0]?.id, fileName: uploadedFiles[0]?.fileName };
const files = uploadedFiles.map((u) => u.file ?? { id: u.id, fileName: u.fileName });
const fileIds = uploadedFiles.map((u) => u.id);
onSubmit({
file,
files,
fileIds,
});
};
return (
<div>
<p>Upload-Komponente noch nicht implementiert</p>
<div className={styles.uploadTaskBlock}>
<input
ref={fileInputRef}
type="file"
accept={acceptStr || undefined}
multiple={allowMultiple}
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
type="button"
onClick={() => onSubmit({ uploaded: [] })}
disabled={submitting}
onClick={() => fileInputRef.current?.click()}
disabled={submitting || uploading}
className={styles.uploadButton}
>
Platzhalter absenden
<FaUpload /> {uploading ? 'Wird hochgeladen…' : 'Datei(en) auswählen'}
</button>
{uploadError && <p className={styles.uploadError}>{uploadError}</p>}
{uploadedFiles.length > 0 && (
<ul className={styles.uploadedList}>
{uploadedFiles.map((u) => (
<li key={u.id}>{u.fileName}</li>
))}
</ul>
)}
<button
type="button"
onClick={handleSubmitUpload}
disabled={submitting || uploading || uploadedFiles.length === 0}
className={styles.popupSubmitButton}
>
{submitting ? 'Wird gesendet…' : 'Absenden'}
</button>
</div>
);
}
case 'input.review':
return (
<div>
@ -448,12 +936,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
{NODE_TYPE_LABELS[nodeType] ?? nodeType}
</span>
</div>
{task.nodeId && (
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Node</span>
<span className={styles.metaValueMono}>{task.nodeId}</span>
</div>
)}
</div>
{renderInput()}
</div>

View file

@ -71,6 +71,7 @@ interface DataSourcePanelProps {
const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
clickup: '\uD83D\uDCCB',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
};

View file

@ -35,8 +35,18 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
if (isText && file.fileSize < 500_000) {
setLoading(true);
api.get(`/api/files/${file.id}/download`, { responseType: 'text' })
.then(res => setContent(typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2)))
api.get(`/api/files/${file.id}/download`, { responseType: 'arraybuffer' })
.then((res) => {
const buf = res.data;
if (buf instanceof ArrayBuffer) {
const decoder = new TextDecoder('utf-8');
let text = decoder.decode(new Uint8Array(buf));
if (text.startsWith('\uFEFF')) text = text.slice(1);
setContent(text);
} else {
setContent(typeof buf === 'string' ? buf : JSON.stringify(buf, null, 2));
}
})
.catch(() => setContent(null))
.finally(() => setLoading(false));
} else if (isImage) {