next version of visual workflow editor with Clickup Connector
This commit is contained in:
parent
cc8a699e58
commit
0f791a53fb
75 changed files with 9926 additions and 1394 deletions
1
Untitled
Normal file
1
Untitled
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
s
|
||||||
|
|
@ -27,6 +27,8 @@ export interface NodeType {
|
||||||
parameters: NodeTypeParameter[];
|
parameters: NodeTypeParameter[];
|
||||||
inputs: number;
|
inputs: number;
|
||||||
outputs: number;
|
outputs: number;
|
||||||
|
/** Labels per output (e.g. ["Ja", "Nein"] for flow.ifElse) */
|
||||||
|
outputLabels?: string[];
|
||||||
executor: string;
|
executor: string;
|
||||||
meta?: {
|
meta?: {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
|
@ -76,11 +78,24 @@ export interface ExecuteGraphResponse {
|
||||||
nodeId?: string;
|
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 {
|
export interface Automation2Workflow {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
graph: Automation2Graph;
|
graph: Automation2Graph;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
/** Entry points (Starts) — how this workflow may be invoked */
|
||||||
|
invocations?: WorkflowEntryPoint[];
|
||||||
/** Enriched: run count */
|
/** Enriched: run count */
|
||||||
runCount?: number;
|
runCount?: number;
|
||||||
/** Enriched: has active (running/paused) run */
|
/** Enriched: has active (running/paused) run */
|
||||||
|
|
@ -128,22 +143,36 @@ export async function fetchNodeTypes(
|
||||||
* Execute an automation2 graph.
|
* Execute an automation2 graph.
|
||||||
* POST /api/automation2/{instanceId}/execute
|
* 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(
|
export async function executeGraph(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
graph: Automation2Graph,
|
graph: Automation2Graph,
|
||||||
workflowId?: string
|
workflowId?: string,
|
||||||
|
options?: ExecuteGraphOptions
|
||||||
): Promise<ExecuteGraphResponse> {
|
): Promise<ExecuteGraphResponse> {
|
||||||
console.log(
|
console.log(
|
||||||
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
|
`${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();
|
const start = performance.now();
|
||||||
try {
|
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({
|
const result = await request({
|
||||||
url: `/api/automation2/${instanceId}/execute`,
|
url: `/api/automation2/${instanceId}/execute`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: { graph, workflowId },
|
data,
|
||||||
});
|
});
|
||||||
const ms = Math.round(performance.now() - start);
|
const ms = Math.round(performance.now() - start);
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -167,11 +196,13 @@ export async function executeGraph(
|
||||||
|
|
||||||
export async function fetchWorkflows(
|
export async function fetchWorkflows(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
|
params?: { active?: boolean }
|
||||||
): Promise<Automation2Workflow[]> {
|
): Promise<Automation2Workflow[]> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/automation2/${instanceId}/workflows`,
|
url: `/api/automation2/${instanceId}/workflows`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
params: params?.active !== undefined ? { active: params.active } : undefined,
|
||||||
});
|
});
|
||||||
return data?.workflows ?? [];
|
return data?.workflows ?? [];
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +221,7 @@ export async function fetchWorkflow(
|
||||||
export async function createWorkflow(
|
export async function createWorkflow(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
body: { label: string; graph: Automation2Graph }
|
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
|
||||||
): Promise<Automation2Workflow> {
|
): Promise<Automation2Workflow> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/automation2/${instanceId}/workflows`,
|
url: `/api/automation2/${instanceId}/workflows`,
|
||||||
|
|
@ -203,7 +234,12 @@ export async function updateWorkflow(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
body: { label?: string; graph?: Automation2Graph }
|
body: {
|
||||||
|
label?: string;
|
||||||
|
graph?: Automation2Graph;
|
||||||
|
invocations?: WorkflowEntryPoint[];
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
): Promise<Automation2Workflow> {
|
): Promise<Automation2Workflow> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||||
|
|
@ -243,6 +279,25 @@ export async function fetchWorkflowRuns(
|
||||||
return data?.runs ?? [];
|
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
|
// Tasks
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -354,3 +409,124 @@ export async function fetchBrowse(
|
||||||
});
|
});
|
||||||
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
authority: 'local' | 'google' | 'msft';
|
authority: 'local' | 'google' | 'msft' | 'clickup';
|
||||||
externalId: string;
|
externalId: string;
|
||||||
externalUsername: string;
|
externalUsername: string;
|
||||||
externalEmail?: string;
|
externalEmail?: string;
|
||||||
|
|
@ -52,8 +52,8 @@ export interface PaginatedResponse<T> {
|
||||||
export interface CreateConnectionData {
|
export interface CreateConnectionData {
|
||||||
id?: string;
|
id?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
authority?: 'msft' | 'google';
|
authority?: 'msft' | 'google' | 'clickup';
|
||||||
type?: 'msft' | 'google'; // Backend expects this field
|
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
externalUsername?: string;
|
externalUsername?: string;
|
||||||
externalEmail?: string;
|
externalEmail?: string;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
|
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
|
|
@ -2,12 +2,12 @@
|
||||||
* Automation2FlowEditor
|
* Automation2FlowEditor
|
||||||
*
|
*
|
||||||
* n8n-style flow builder with backend-driven node list.
|
* 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 { FaSpinner } from 'react-icons/fa';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import {
|
import {
|
||||||
fetchNodeTypes,
|
fetchNodeTypes,
|
||||||
executeGraph,
|
executeGraph,
|
||||||
|
|
@ -20,17 +20,28 @@ import {
|
||||||
type Automation2Graph,
|
type Automation2Graph,
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
type ExecuteGraphResponse,
|
type ExecuteGraphResponse,
|
||||||
} from '../../api/automation2Api';
|
type WorkflowEntryPoint,
|
||||||
|
} from '../../../api/automation2Api';
|
||||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
import { getCategoryIcon } from './utils';
|
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
||||||
import { fromApiGraph, toApiGraph } from './graphUtils';
|
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';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
const LOG = '[Automation2]';
|
||||||
|
|
||||||
|
const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] =>
|
||||||
|
buildInvocationsForPrimaryKind('manual', [], 'Jetzt ausführen');
|
||||||
|
|
||||||
interface Automation2FlowEditorProps {
|
interface Automation2FlowEditorProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
|
@ -50,7 +61,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
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 [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||||
|
|
@ -60,14 +71,40 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
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(
|
const handleFromApiGraph = useCallback(
|
||||||
(graph: Automation2Graph) => {
|
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
|
||||||
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
applyGraphWithSync(graph, wfInvocations);
|
||||||
setCanvasNodes(nodes);
|
|
||||||
setCanvasConnections(connections);
|
|
||||||
},
|
},
|
||||||
[nodeTypes]
|
[applyGraphWithSync]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleExecute = useCallback(async () => {
|
const handleExecute = useCallback(async () => {
|
||||||
|
|
@ -79,29 +116,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
try {
|
try {
|
||||||
const result = await executeGraph(
|
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
|
||||||
request,
|
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
|
||||||
instanceId,
|
...(ep ? { entryPointId: ep } : {}),
|
||||||
graph,
|
});
|
||||||
currentWorkflowId ?? undefined
|
|
||||||
);
|
|
||||||
setExecuteResult(result);
|
setExecuteResult(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setExecuting(false);
|
setExecuting(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
|
@ -112,12 +137,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (currentWorkflowId) {
|
if (currentWorkflowId) {
|
||||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph });
|
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||||
} else {
|
} else {
|
||||||
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
|
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);
|
setCurrentWorkflowId(created.id);
|
||||||
|
if (created.invocations?.length) setInvocations(created.invocations);
|
||||||
setWorkflows((prev) => [...prev, created]);
|
setWorkflows((prev) => [...prev, created]);
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||||
}
|
}
|
||||||
|
|
@ -126,13 +152,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
|
||||||
|
|
||||||
const handleLoad = useCallback(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
try {
|
try {
|
||||||
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
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) {
|
} catch (err: unknown) {
|
||||||
setExecuteResult({
|
setExecuteResult({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -140,7 +170,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, handleFromApiGraph]
|
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkflowSelect = useCallback(
|
const handleWorkflowSelect = useCallback(
|
||||||
|
|
@ -148,25 +178,69 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
setCurrentWorkflowId(workflowId);
|
setCurrentWorkflowId(workflowId);
|
||||||
if (workflowId) handleLoad(workflowId);
|
if (workflowId) handleLoad(workflowId);
|
||||||
else {
|
else {
|
||||||
setCanvasNodes([]);
|
|
||||||
setCanvasConnections([]);
|
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
|
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleLoad]
|
[handleLoad, applyGraphWithSync]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNew = useCallback(() => {
|
const handleNew = useCallback(() => {
|
||||||
setCanvasNodes([]);
|
|
||||||
setCanvasConnections([]);
|
|
||||||
setCurrentWorkflowId(null);
|
setCurrentWorkflowId(null);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
}, []);
|
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
|
||||||
|
}, [applyGraphWithSync]);
|
||||||
|
|
||||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
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 () => {
|
const loadNodeTypes = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -184,6 +258,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
}
|
}
|
||||||
}, [instanceId, language, request]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadNodeTypes();
|
loadNodeTypes();
|
||||||
}, [loadNodeTypes]);
|
}, [loadNodeTypes]);
|
||||||
|
|
@ -193,10 +277,24 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
}, [loadWorkflows]);
|
}, [loadWorkflows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId) {
|
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId && nodeTypes.length > 0) {
|
||||||
handleWorkflowSelect(initialWorkflowId);
|
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) => {
|
const toggleCategory = useCallback((id: string) => {
|
||||||
setExpandedCategories((prev) => {
|
setExpandedCategories((prev) => {
|
||||||
|
|
@ -209,6 +307,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
|
|
||||||
const handleDropNodeType = useCallback(
|
const handleDropNodeType = useCallback(
|
||||||
(nodeTypeId: string, x: number, y: number) => {
|
(nodeTypeId: string, x: number, y: number) => {
|
||||||
|
if (nodeTypeId.startsWith('trigger.')) return;
|
||||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||||
if (!nt) return;
|
if (!nt) return;
|
||||||
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
@ -271,10 +370,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
language={language}
|
language={language}
|
||||||
expandedCategories={expandedCategories}
|
expandedCategories={expandedCategories}
|
||||||
onToggleCategory={toggleCategory}
|
onToggleCategory={toggleCategory}
|
||||||
|
excludedCategories={sidebarExcludedCategories}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const configurableSelected =
|
||||||
|
selectedNode &&
|
||||||
|
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) =>
|
||||||
|
selectedNode.type.startsWith(p)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{renderSidebar()}
|
{renderSidebar()}
|
||||||
|
|
@ -287,6 +393,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
onNew={handleNew}
|
onNew={handleNew}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
|
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
executing={executing}
|
executing={executing}
|
||||||
hasNodes={canvasNodes.length > 0}
|
hasNodes={canvasNodes.length > 0}
|
||||||
|
|
@ -306,21 +413,36 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
onSelectionChange={setSelectedNode}
|
onSelectionChange={setSelectedNode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedNode &&
|
{configurableSelected && selectedNode && (
|
||||||
['input.', 'ai.', 'email.', 'sharepoint.'].some((p) =>
|
<Automation2DataFlowProvider
|
||||||
selectedNode.type.startsWith(p)
|
|
||||||
) && (
|
|
||||||
<NodeConfigPanel
|
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
nodes={canvasNodes}
|
||||||
|
connections={canvasConnections}
|
||||||
|
nodeOutputsPreview={nodeOutputsPreview}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
language={language}
|
language={language}
|
||||||
onParametersChange={handleNodeParametersChange}
|
>
|
||||||
instanceId={instanceId}
|
<NodeConfigPanel
|
||||||
request={request}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WorkflowConfigurationModal
|
||||||
|
open={workflowSettingsOpen}
|
||||||
|
onClose={() => setWorkflowSettingsOpen(false)}
|
||||||
|
invocations={invocations}
|
||||||
|
onApply={handleApplyWorkflowConfiguration}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
import { FaCog, FaPlay, FaSpinner } from 'react-icons/fa';
|
||||||
import type { Automation2Workflow, ExecuteGraphResponse } from '../../api/automation2Api';
|
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/automation2Api';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
interface CanvasHeaderProps {
|
interface CanvasHeaderProps {
|
||||||
|
|
@ -14,6 +14,7 @@ interface CanvasHeaderProps {
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onExecute: () => void;
|
onExecute: () => void;
|
||||||
|
onWorkflowSettings?: () => void;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
executing: boolean;
|
executing: boolean;
|
||||||
hasNodes: boolean;
|
hasNodes: boolean;
|
||||||
|
|
@ -27,6 +28,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
onNew,
|
onNew,
|
||||||
onSave,
|
onSave,
|
||||||
onExecute,
|
onExecute,
|
||||||
|
onWorkflowSettings,
|
||||||
saving,
|
saving,
|
||||||
executing,
|
executing,
|
||||||
hasNodes,
|
hasNodes,
|
||||||
|
|
@ -37,6 +39,17 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
|
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
|
||||||
Workflow-Editor
|
Workflow-Editor
|
||||||
</h4>
|
</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}>
|
<button type="button" className={styles.retryButton} onClick={onNew}>
|
||||||
Neu
|
Neu
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
export interface CanvasNode {
|
export interface CanvasNode {
|
||||||
|
|
@ -58,9 +58,17 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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 [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<{
|
const [connectingFrom, setConnectingFrom] = useState<{
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
handleIndex: number;
|
handleIndex: number;
|
||||||
|
|
@ -72,8 +80,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
const [dragOffset, setDragOffset] = useState({
|
const [dragOffset, setDragOffset] = useState({
|
||||||
startClientX: 0,
|
startClientX: 0,
|
||||||
startClientY: 0,
|
startClientY: 0,
|
||||||
startNodeX: 0,
|
nodesInitial: {} as Record<string, { x: number; y: number }>,
|
||||||
startNodeY: 0,
|
|
||||||
});
|
});
|
||||||
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
|
|
@ -99,6 +106,18 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedNodeId, nodes, onSelectionChange]);
|
}, [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(
|
const getHandlePosition = useCallback(
|
||||||
(node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
|
(node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
|
||||||
const isOutput = handleIndex >= node.inputs;
|
const isOutput = handleIndex >= node.inputs;
|
||||||
|
|
@ -171,6 +190,34 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
const handleHandleMouseUp = useCallback(
|
const handleHandleMouseUp = useCallback(
|
||||||
(e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => {
|
(e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => {
|
||||||
e.stopPropagation();
|
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) {
|
if (!connectingFrom || connectingFrom.nodeId === targetNodeId) {
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
|
|
@ -182,9 +229,6 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetNode = nodes.find((n) => n.id === targetNodeId);
|
|
||||||
if (!targetNode) return;
|
|
||||||
if (targetHandleIndex >= targetNode.inputs) return;
|
|
||||||
const newConn: CanvasConnection = {
|
const newConn: CanvasConnection = {
|
||||||
id: `c_${Date.now()}`,
|
id: `c_${Date.now()}`,
|
||||||
sourceId: connectingFrom.nodeId,
|
sourceId: connectingFrom.nodeId,
|
||||||
|
|
@ -196,13 +240,20 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
},
|
},
|
||||||
[connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange]
|
[connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange, selectedConnectionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!connectingFrom || !dragPos) return;
|
if (!connectingFrom || !dragPos) return;
|
||||||
const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY });
|
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);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
};
|
};
|
||||||
|
|
@ -214,17 +265,32 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
};
|
};
|
||||||
}, [connectingFrom, dragPos]);
|
}, [connectingFrom, dragPos]);
|
||||||
|
|
||||||
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
|
const handleNodeMouseDown = useCallback(
|
||||||
const node = nodes.find((n) => n.id === nodeId);
|
(e: React.MouseEvent, nodeId: string) => {
|
||||||
if (!node) return;
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
setDraggingNodeId(nodeId);
|
if (!node) return;
|
||||||
setDragOffset({
|
|
||||||
startClientX: e.clientX,
|
const idsToMove = selectedNodeIds.has(nodeId)
|
||||||
startClientY: e.clientY,
|
? selectedNodeIds
|
||||||
startNodeX: node.x,
|
: new Set([nodeId]);
|
||||||
startNodeY: node.y,
|
if (!selectedNodeIds.has(nodeId)) {
|
||||||
});
|
setSelectedNodeIds(idsToMove);
|
||||||
}, [nodes]);
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
nodesInitial,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nodes, selectedNodeIds]
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!draggingNodeId) return;
|
if (!draggingNodeId) return;
|
||||||
|
|
@ -232,11 +298,11 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
const dx = (e.clientX - dragOffset.startClientX) / zoom;
|
const dx = (e.clientX - dragOffset.startClientX) / zoom;
|
||||||
const dy = (e.clientY - dragOffset.startClientY) / zoom;
|
const dy = (e.clientY - dragOffset.startClientY) / zoom;
|
||||||
onNodesChange(
|
onNodesChange(
|
||||||
nodes.map((n) =>
|
nodes.map((n) => {
|
||||||
n.id === draggingNodeId
|
const init = dragOffset.nodesInitial[n.id];
|
||||||
? { ...n, x: dragOffset.startNodeX + dx, y: dragOffset.startNodeY + dy }
|
if (!init) return n;
|
||||||
: n
|
return { ...n, x: init.x + dx, y: init.y + dy };
|
||||||
)
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const onUp = () => setDraggingNodeId(null);
|
const onUp = () => setDraggingNodeId(null);
|
||||||
|
|
@ -248,16 +314,48 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
};
|
};
|
||||||
}, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]);
|
}, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]);
|
||||||
|
|
||||||
const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
|
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
|
||||||
const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
|
React.useEffect(() => {
|
||||||
if (hitNode || connectingFrom) return;
|
const el = containerRef.current;
|
||||||
setPanning({
|
if (!el) return;
|
||||||
startX: e.clientX,
|
const update = () => {
|
||||||
startY: e.clientY,
|
const r = el.getBoundingClientRect();
|
||||||
startPanX: panOffset.x,
|
setContainerBounds({ left: r.left, top: r.top });
|
||||||
startPanY: panOffset.y,
|
};
|
||||||
});
|
update();
|
||||||
}, [connectingFrom, panOffset]);
|
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, clientToCanvas]
|
||||||
|
);
|
||||||
|
|
||||||
const handleWheel = useCallback((e: WheelEvent) => {
|
const handleWheel = useCallback((e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -289,18 +387,46 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
};
|
};
|
||||||
}, [panning]);
|
}, [panning]);
|
||||||
|
|
||||||
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
|
const selectionBoxRef = useRef<typeof selectionBox>(null);
|
||||||
|
const marqueeJustEndedRef = useRef(false);
|
||||||
|
selectionBoxRef.current = selectionBox;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const el = containerRef.current;
|
if (!selectionBox) return;
|
||||||
if (!el) return;
|
const onMove = (e: MouseEvent) => {
|
||||||
const update = () => {
|
const pt = clientToCanvas(e.clientX, e.clientY);
|
||||||
const r = el.getBoundingClientRect();
|
setSelectionBox((prev) =>
|
||||||
setContainerBounds({ left: r.left, top: r.top });
|
prev ? { ...prev, endX: pt.x, endY: pt.y } : null
|
||||||
|
);
|
||||||
};
|
};
|
||||||
update();
|
const onUp = () => {
|
||||||
window.addEventListener('resize', update);
|
const box = selectionBoxRef.current;
|
||||||
return () => window.removeEventListener('resize', update);
|
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 CANVAS_SIZE = 8000;
|
||||||
const svgBounds = useMemo(() => {
|
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) };
|
return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
|
||||||
}, [nodes]);
|
}, [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(() => {
|
const handleDeleteNode = useCallback(() => {
|
||||||
if (!selectedNodeId) return;
|
if (selectedNodeIds.size === 0) return;
|
||||||
onNodesChange(nodes.filter((n) => n.id !== selectedNodeId));
|
const ids = selectedNodeIds;
|
||||||
|
onNodesChange(nodes.filter((n) => !ids.has(n.id)));
|
||||||
onConnectionsChange(
|
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);
|
setEditingNodeId(null);
|
||||||
setEditingField(null);
|
setEditingField(null);
|
||||||
}, [selectedNodeId, nodes, connections, onNodesChange, onConnectionsChange]);
|
}, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) {
|
const target = e.target as HTMLElement;
|
||||||
const target = e.target as HTMLElement;
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
setConnectingFrom(null);
|
||||||
handleDeleteNode();
|
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);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [handleDeleteNode, selectedNodeId]);
|
}, [handleDeleteNode, handleDeleteConnection, selectedNodeIds.size, selectedConnectionId]);
|
||||||
|
|
||||||
const handleNodeUpdate = useCallback(
|
const handleNodeUpdate = useCallback(
|
||||||
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
|
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
|
||||||
|
|
@ -357,7 +488,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab}`}
|
className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab} ${selectionBox || draggingNodeId ? styles.canvasSelecting : ''}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
|
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
|
||||||
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
|
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
|
||||||
|
|
@ -366,8 +497,32 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onMouseDown={handleCanvasMouseDown}
|
onMouseDown={handleCanvasMouseDown}
|
||||||
tabIndex={0}
|
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
|
<div
|
||||||
className={styles.canvasContent}
|
className={styles.canvasContent}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -381,7 +536,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
className={styles.connectionsLayer}
|
className={styles.connectionsLayer}
|
||||||
width={svgBounds.width}
|
width={svgBounds.width}
|
||||||
height={svgBounds.height}
|
height={svgBounds.height}
|
||||||
style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}
|
style={{ position: 'absolute', left: 0, top: 0 }}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<marker
|
<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)" />
|
<polygon points="0 0, 10 3.5, 0 7" fill="var(--text-secondary, #666)" />
|
||||||
</marker>
|
</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>
|
</defs>
|
||||||
{connections.map((c) => {
|
{connections.map((c) => {
|
||||||
const srcNode = nodes.find((n) => n.id === c.sourceId);
|
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 src = getHandlePosition(srcNode, c.sourceHandle);
|
||||||
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
||||||
const dx = tgt.x - src.x;
|
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 (
|
return (
|
||||||
<path
|
<g
|
||||||
key={c.id}
|
key={c.id}
|
||||||
d={path}
|
onClick={(e) => handleConnectionClick(e, c.id)}
|
||||||
fill="none"
|
style={{ cursor: 'pointer' }}
|
||||||
stroke="var(--text-secondary, #666)"
|
role="button"
|
||||||
strokeWidth="2"
|
tabIndex={-1}
|
||||||
markerEnd="url(#arrowhead)"
|
aria-label="Verbindung auswählen (Entf zum Löschen, klicken Sie auf einen anderen Eingang zum Umleiten)"
|
||||||
/>
|
>
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
fill="none"
|
||||||
|
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 && (() => {
|
{connectingFrom && dragPos && (() => {
|
||||||
const end = screenToSvg(dragPos.x, dragPos.y);
|
const end = clientToCanvas(dragPos.x, dragPos.y);
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
d={`M ${connectingFrom.x} ${connectingFrom.y} L ${end.x} ${end.y}`}
|
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)"
|
stroke="var(--primary-color, #007bff)"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeDasharray="4 4"
|
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.inputs; i++) handles.push({ index: i, isOutput: false });
|
||||||
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
|
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 isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||||
const isEditingComment = editingNodeId === node.id && editingField === 'comment';
|
|
||||||
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||||||
const displayComment = node.comment ?? '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -456,28 +637,65 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation();
|
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);
|
handleNodeMouseDown(e, node.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{handles.map(({ index, isOutput }) => {
|
{handles.map(({ index, isOutput }) => {
|
||||||
const pos = getHandlePosition(node, index);
|
const pos = getHandlePosition(node, index);
|
||||||
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
|
className={styles.handleWrapper}
|
||||||
style={{
|
style={{
|
||||||
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
|
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
|
||||||
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
|
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
|
||||||
top: pos.y - node.y - HANDLE_OFFSET,
|
top: pos.y - node.y - HANDLE_OFFSET,
|
||||||
width: HANDLE_SIZE,
|
|
||||||
height: HANDLE_SIZE,
|
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
|
>
|
||||||
onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
|
{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}>
|
<div className={styles.canvasNodeContent}>
|
||||||
|
|
@ -525,49 +743,22 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</span>
|
</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>
|
</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 && (
|
{nodes.length === 0 && (
|
||||||
<div className={styles.canvasPlaceholder}>
|
<div className={styles.canvasPlaceholder}>
|
||||||
<p>Nodes aus der Liste links hierher ziehen.</p>
|
<p>Nodes aus der Liste links hierher ziehen.</p>
|
||||||
120
src/components/Automation2FlowEditor/editor/NodeConfigPanel.tsx
Normal file
120
src/components/Automation2FlowEditor/editor/NodeConfigPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeType } from '../../api/automation2Api';
|
import type { NodeType } from '../../../api/automation2Api';
|
||||||
import { getCategoryIcon } from './utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import type { GetLabelFn } from './utils';
|
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
interface NodeListItemProps {
|
interface NodeListItemProps {
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||||
import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
|
import type { NodeType, NodeTypeCategory } from '../../../api/automation2Api';
|
||||||
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from './constants';
|
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
||||||
import { getLabel } from './utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { NodeListItem } from './NodeListItem';
|
import { NodeListItem } from './NodeListItem';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
@ -19,6 +19,8 @@ interface NodeSidebarProps {
|
||||||
language: string;
|
language: string;
|
||||||
expandedCategories: Set<string>;
|
expandedCategories: Set<string>;
|
||||||
onToggleCategory: (id: string) => void;
|
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> = ({
|
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||||
|
|
@ -29,9 +31,14 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||||
language,
|
language,
|
||||||
expandedCategories,
|
expandedCategories,
|
||||||
onToggleCategory,
|
onToggleCategory,
|
||||||
|
excludedCategories,
|
||||||
}) => {
|
}) => {
|
||||||
const filteredNodeTypes = useMemo(() => {
|
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;
|
if (!filter.trim()) return visible;
|
||||||
const q = filter.toLowerCase();
|
const q = filter.toLowerCase();
|
||||||
return visible.filter(
|
return visible.filter(
|
||||||
|
|
@ -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. 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
export { Automation2FlowEditor } from './Automation2FlowEditor';
|
export { Automation2FlowEditor } from './editor/Automation2FlowEditor';
|
||||||
export { FlowCanvas } from './FlowCanvas';
|
export { FlowCanvas } from './editor/FlowCanvas';
|
||||||
export { NodeConfigPanel } from './NodeConfigPanel';
|
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
||||||
export { NodeSidebar } from './NodeSidebar';
|
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||||
export { NodeListItem } from './NodeListItem';
|
export { NodeSidebar } from './editor/NodeSidebar';
|
||||||
export { CanvasHeader } from './CanvasHeader';
|
export { NodeListItem } from './editor/NodeListItem';
|
||||||
export * from './utils';
|
export { CanvasHeader } from './editor/CanvasHeader';
|
||||||
export * from './constants';
|
export * from './nodes/shared/utils';
|
||||||
export * from './graphUtils';
|
export * from './nodes/shared/constants';
|
||||||
|
export * from './nodes/shared/graphUtils';
|
||||||
|
export { getAcceptStringFromConfig } from './nodes/configs/UploadNodeConfig';
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { NodeConfigRendererProps } from './types';
|
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> = ({
|
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
params,
|
params,
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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}}"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 { ComponentType } from 'react';
|
||||||
import type { NodeConfigRendererProps } from './types';
|
import type { NodeConfigRendererProps } from './types';
|
||||||
import { FormNodeConfig } from './FormNodeConfig';
|
import { FormNodeConfig } from '../form/FormNodeConfig';
|
||||||
import { ApprovalNodeConfig } from './ApprovalNodeConfig';
|
import { ApprovalNodeConfig } from './ApprovalNodeConfig';
|
||||||
import { UploadNodeConfig } from './UploadNodeConfig';
|
import { UploadNodeConfig } from './UploadNodeConfig';
|
||||||
import { CommentNodeConfig } from './CommentNodeConfig';
|
import { CommentNodeConfig } from './CommentNodeConfig';
|
||||||
|
|
@ -14,10 +14,21 @@ import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
|
||||||
import { AiNodeConfig } from './AiNodeConfig';
|
import { AiNodeConfig } from './AiNodeConfig';
|
||||||
import { EmailNodeConfig } from './EmailNodeConfig';
|
import { EmailNodeConfig } from './EmailNodeConfig';
|
||||||
import { SharePointNodeConfig } from './SharePointNodeConfig';
|
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 type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
|
||||||
|
|
||||||
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
||||||
|
'trigger.manual': StartNodeConfig,
|
||||||
|
'trigger.form': FormStartNodeConfig,
|
||||||
|
'trigger.schedule': ScheduleStartNodeConfig,
|
||||||
'input.form': FormNodeConfig,
|
'input.form': FormNodeConfig,
|
||||||
'input.approval': ApprovalNodeConfig,
|
'input.approval': ApprovalNodeConfig,
|
||||||
'input.upload': UploadNodeConfig,
|
'input.upload': UploadNodeConfig,
|
||||||
|
|
@ -32,6 +43,7 @@ export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
||||||
'ai.convertDocument': AiNodeConfig,
|
'ai.convertDocument': AiNodeConfig,
|
||||||
'ai.generateDocument': AiNodeConfig,
|
'ai.generateDocument': AiNodeConfig,
|
||||||
'ai.generateCode': AiNodeConfig,
|
'ai.generateCode': AiNodeConfig,
|
||||||
|
'file.create': FileCreateNodeConfig,
|
||||||
'email.checkEmail': EmailNodeConfig,
|
'email.checkEmail': EmailNodeConfig,
|
||||||
'email.searchEmail': EmailNodeConfig,
|
'email.searchEmail': EmailNodeConfig,
|
||||||
'email.draftEmail': EmailNodeConfig,
|
'email.draftEmail': EmailNodeConfig,
|
||||||
|
|
@ -41,4 +53,13 @@ export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
||||||
'sharepoint.listFiles': SharePointNodeConfig,
|
'sharepoint.listFiles': SharePointNodeConfig,
|
||||||
'sharepoint.downloadFile': SharePointNodeConfig,
|
'sharepoint.downloadFile': SharePointNodeConfig,
|
||||||
'sharepoint.copyFile': 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,
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export type { NodeConfigRendererProps, FormField } from '../shared/types';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/components/Automation2FlowEditor/nodes/form/index.ts
Normal file
1
src/components/Automation2FlowEditor/nodes/form/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { FormNodeConfig } from './FormNodeConfig';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { IfElseNodeConfig } from './IfElseNodeConfig';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/components/Automation2FlowEditor/nodes/loop/index.ts
Normal file
1
src/components/Automation2FlowEditor/nodes/loop/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { LoopNodeConfig } from './LoopNodeConfig';
|
||||||
|
|
@ -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 }));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
/**
|
||||||
|
* User-friendly schedule ↔ cron
|
||||||
|
* Standard: 5 Felder (minute hour dom month dow), DOW 0=So … 6=Sa
|
||||||
|
* Intervall Sekunden: 6 Felder (sec min hour dom month dow)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ScheduleMode = 'daily' | 'weekdays' | 'weekly' | 'calendar' | 'interval';
|
||||||
|
|
||||||
|
export type CalendarPeriod = 'monthly' | 'yearly';
|
||||||
|
|
||||||
|
/** sek, min, h, T (Tage), a (Jahre) — Cron nur näherungsweise für T/a */
|
||||||
|
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
|
||||||
|
|
||||||
|
export interface ScheduleSpec {
|
||||||
|
mode: ScheduleMode;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
/** 0–6, cron DOW; nur bei mode === 'weekly' */
|
||||||
|
weekdays: number[];
|
||||||
|
/** Monatlich: Tag 1–31; Jährlich: Tag im gewählten Monat */
|
||||||
|
monthDay: number;
|
||||||
|
/** 1–12, nur bei calendar + yearly */
|
||||||
|
monthIndex: number;
|
||||||
|
calendarPeriod: CalendarPeriod;
|
||||||
|
intervalValue: number;
|
||||||
|
intervalUnit: IntervalUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
|
||||||
|
|
||||||
|
/** Anzeige Mo–So (cronDow wie oben) */
|
||||||
|
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
|
||||||
|
{ cronDow: 1, label: 'Mo' },
|
||||||
|
{ cronDow: 2, label: 'Di' },
|
||||||
|
{ cronDow: 3, label: 'Mi' },
|
||||||
|
{ cronDow: 4, label: 'Do' },
|
||||||
|
{ cronDow: 5, label: 'Fr' },
|
||||||
|
{ cronDow: 6, label: 'Sa' },
|
||||||
|
{ cronDow: 0, label: 'So' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function defaultScheduleSpec(): ScheduleSpec {
|
||||||
|
return {
|
||||||
|
mode: 'daily',
|
||||||
|
hour: 8,
|
||||||
|
minute: 0,
|
||||||
|
weekdays: [1, 2, 3, 4, 5],
|
||||||
|
monthDay: 1,
|
||||||
|
monthIndex: 1,
|
||||||
|
calendarPeriod: 'monthly',
|
||||||
|
intervalValue: 15,
|
||||||
|
intervalUnit: 'minutes',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
|
||||||
|
export function buildCronFromSpec(spec: ScheduleSpec): string {
|
||||||
|
const m = clamp(Math.floor(spec.minute), 0, 59);
|
||||||
|
const h = clamp(Math.floor(spec.hour), 0, 23);
|
||||||
|
|
||||||
|
switch (spec.mode) {
|
||||||
|
case 'daily':
|
||||||
|
return `${m} ${h} * * *`;
|
||||||
|
case 'weekdays':
|
||||||
|
return `${m} ${h} * * 1-5`;
|
||||||
|
case 'weekly': {
|
||||||
|
const days = [...new Set(spec.weekdays)]
|
||||||
|
.filter((d) => d >= 0 && d <= 6)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const order = (x: number) => (x === 0 ? 7 : x);
|
||||||
|
return order(a) - order(b);
|
||||||
|
});
|
||||||
|
if (days.length === 0) return `${m} ${h} * * 1`;
|
||||||
|
return `${m} ${h} * * ${days.join(',')}`;
|
||||||
|
}
|
||||||
|
case 'calendar': {
|
||||||
|
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
|
||||||
|
if (spec.calendarPeriod === 'monthly') {
|
||||||
|
return `${m} ${h} ${dom} * *`;
|
||||||
|
}
|
||||||
|
const month = clamp(Math.floor(spec.monthIndex), 1, 12);
|
||||||
|
return `${m} ${h} ${dom} ${month} *`;
|
||||||
|
}
|
||||||
|
case 'interval': {
|
||||||
|
const v = Math.max(1, Math.floor(spec.intervalValue));
|
||||||
|
switch (spec.intervalUnit) {
|
||||||
|
case 'seconds': {
|
||||||
|
const s = clamp(v, 1, 59);
|
||||||
|
return `*/${s} * * * * *`;
|
||||||
|
}
|
||||||
|
case 'minutes': {
|
||||||
|
const mm = clamp(v, 1, 59);
|
||||||
|
return `*/${mm} * * * *`;
|
||||||
|
}
|
||||||
|
case 'hours': {
|
||||||
|
const hh = clamp(v, 1, 23);
|
||||||
|
return `0 */${hh} * * *`;
|
||||||
|
}
|
||||||
|
case 'days': {
|
||||||
|
if (v <= 1) return `0 0 * * *`;
|
||||||
|
const d = clamp(v, 2, 31);
|
||||||
|
return `0 0 */${d} * *`;
|
||||||
|
}
|
||||||
|
case 'years':
|
||||||
|
default:
|
||||||
|
// Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
|
||||||
|
return `0 0 1 1 *`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `${m} ${h} * * *`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
|
||||||
|
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
|
||||||
|
if (!cron || typeof cron !== 'string') return null;
|
||||||
|
const p = cron.trim().split(/\s+/);
|
||||||
|
|
||||||
|
if (p.length === 6) {
|
||||||
|
const [secS, minS, hourS, domS, monthS, dowS] = p;
|
||||||
|
if (
|
||||||
|
secS.startsWith('*/') &&
|
||||||
|
minS === '*' &&
|
||||||
|
hourS === '*' &&
|
||||||
|
domS === '*' &&
|
||||||
|
monthS === '*' &&
|
||||||
|
(dowS === '*' || dowS === '?')
|
||||||
|
) {
|
||||||
|
const iv = parseInt(secS.slice(2), 10);
|
||||||
|
if (!Number.isNaN(iv)) {
|
||||||
|
return {
|
||||||
|
...defaultScheduleSpec(),
|
||||||
|
mode: 'interval',
|
||||||
|
intervalValue: iv,
|
||||||
|
intervalUnit: 'seconds',
|
||||||
|
minute: 0,
|
||||||
|
hour: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.length < 5) return null;
|
||||||
|
const [minS, hourS, domS, monthS, dowS] = p;
|
||||||
|
const minute = parseInt(minS, 10);
|
||||||
|
const hour = parseInt(hourS, 10);
|
||||||
|
if (Number.isNaN(minute) || Number.isNaN(hour)) return null;
|
||||||
|
|
||||||
|
if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
|
||||||
|
const iv = parseInt(minS.slice(2), 10);
|
||||||
|
if (!Number.isNaN(iv)) {
|
||||||
|
return {
|
||||||
|
...defaultScheduleSpec(),
|
||||||
|
mode: 'interval',
|
||||||
|
intervalValue: iv,
|
||||||
|
intervalUnit: 'minutes',
|
||||||
|
minute: 0,
|
||||||
|
hour: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
|
||||||
|
const iv = parseInt(hourS.slice(2), 10);
|
||||||
|
if (!Number.isNaN(iv)) {
|
||||||
|
return {
|
||||||
|
...defaultScheduleSpec(),
|
||||||
|
mode: 'interval',
|
||||||
|
intervalValue: iv,
|
||||||
|
intervalUnit: 'hours',
|
||||||
|
minute: 0,
|
||||||
|
hour: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
|
||||||
|
const iv = parseInt(domS.slice(2), 10);
|
||||||
|
if (!Number.isNaN(iv)) {
|
||||||
|
return {
|
||||||
|
...defaultScheduleSpec(),
|
||||||
|
mode: 'interval',
|
||||||
|
intervalValue: iv,
|
||||||
|
intervalUnit: 'days',
|
||||||
|
minute: 0,
|
||||||
|
hour: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domS === '*' && dowS === '*') {
|
||||||
|
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domS === '*' && dowS === '1-5') {
|
||||||
|
return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
|
||||||
|
const parts = dowS.split(',').map((x) => parseInt(x.trim(), 10));
|
||||||
|
const days = parts.filter((x) => !Number.isNaN(x) && x >= 0 && x <= 7);
|
||||||
|
if (days.length > 0) {
|
||||||
|
const norm = days.map((d) => (d === 7 ? 0 : d));
|
||||||
|
return {
|
||||||
|
...defaultScheduleSpec(),
|
||||||
|
mode: 'weekly',
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
weekdays: norm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dom = parseInt(domS, 10);
|
||||||
|
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
|
||||||
|
|
||||||
|
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
|
||||||
|
return {
|
||||||
|
...defaultScheduleSpec(),
|
||||||
|
mode: 'calendar',
|
||||||
|
calendarPeriod: 'monthly',
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
monthDay: dom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isNaN(dom) &&
|
||||||
|
dom >= 1 &&
|
||||||
|
dom <= 31 &&
|
||||||
|
!Number.isNaN(month) &&
|
||||||
|
month >= 1 &&
|
||||||
|
month <= 12 &&
|
||||||
|
(dowS === '*' || dowS === '?')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...defaultScheduleSpec(),
|
||||||
|
mode: 'calendar',
|
||||||
|
calendarPeriod: 'yearly',
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
monthDay: dom,
|
||||||
|
monthIndex: month,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval'];
|
||||||
|
|
||||||
|
function normalizeIntervalUnit(u: unknown): IntervalUnit {
|
||||||
|
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
|
||||||
|
return 'minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest Spec aus Node-Parametern (schedule-Objekt bevorzugt, sonst Cron parsen) */
|
||||||
|
export function scheduleSpecFromParams(params: Record<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();
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
126
src/components/Automation2FlowEditor/nodes/shared/DataPicker.tsx
Normal file
126
src/components/Automation2FlowEditor/nodes/shared/DataPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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> = {
|
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||||
trigger: <FaPlay />,
|
trigger: <FaPlay />,
|
||||||
|
|
@ -11,8 +11,10 @@ export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||||
flow: <FaCodeBranch />,
|
flow: <FaCodeBranch />,
|
||||||
data: <FaDatabase />,
|
data: <FaDatabase />,
|
||||||
ai: <FaRobot />,
|
ai: <FaRobot />,
|
||||||
|
file: <FaFileAlt />,
|
||||||
email: <FaEnvelope />,
|
email: <FaEnvelope />,
|
||||||
sharepoint: <FaCloud />,
|
sharepoint: <FaCloud />,
|
||||||
|
clickup: <FaTasks />,
|
||||||
human: <FaUser />,
|
human: <FaUser />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -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 (1–4)', type: 'number', required: false },
|
||||||
|
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false },
|
||||||
|
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const standardTrigger: TriggerFormFieldRow[] = [
|
||||||
|
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
|
||||||
|
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
|
||||||
|
...(statusOpts.length > 0
|
||||||
|
? [{ name: PAYLOAD_STATUS, label: 'Status', type: 'clickup_status', statusOptions: statusOpts }]
|
||||||
|
: []),
|
||||||
|
{ name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' },
|
||||||
|
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
|
||||||
|
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const customInput: FormField[] = [];
|
||||||
|
const customTrigger: TriggerFormFieldRow[] = [];
|
||||||
|
const customRefs: Record<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 };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
91
src/components/Automation2FlowEditor/nodes/shared/dataRef.ts
Normal file
91
src/components/Automation2FlowEditor/nodes/shared/dataRef.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
* Converts between API graph format and canvas internal format.
|
* Converts between API graph format and canvas internal format.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NodeType } from '../../api/automation2Api';
|
import type {
|
||||||
import type { CanvasNode, CanvasConnection } from './FlowCanvas';
|
NodeType,
|
||||||
import type { Automation2Graph } from '../../api/automation2Api';
|
Automation2Graph,
|
||||||
|
Automation2GraphNode,
|
||||||
|
Automation2Connection,
|
||||||
|
} from '../../../../api/automation2Api';
|
||||||
|
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
||||||
|
|
||||||
export function fromApiGraph(
|
export function fromApiGraph(
|
||||||
graph: Automation2Graph,
|
graph: Automation2Graph,
|
||||||
|
|
@ -16,8 +20,13 @@ export function fromApiGraph(
|
||||||
nodeMap.set(nt.id, { inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1 });
|
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 };
|
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 {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: n.type,
|
type: n.type,
|
||||||
|
|
@ -26,13 +35,13 @@ export function fromApiGraph(
|
||||||
title: (n as { title?: string }).title ?? (typeof n.type === 'string' ? n.type : ''),
|
title: (n as { title?: string }).title ?? (typeof n.type === 'string' ? n.type : ''),
|
||||||
comment: (n as { comment?: string }).comment,
|
comment: (n as { comment?: string }).comment,
|
||||||
inputs: io.inputs,
|
inputs: io.inputs,
|
||||||
outputs: io.outputs,
|
outputs,
|
||||||
parameters: n.parameters ?? {},
|
parameters: n.parameters ?? {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const connId = (s: string, t: string, so: number, ti: number) => `c_${s}_${so}_${t}_${ti}`;
|
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 srcNode = nodes.find((n) => n.id === c.source);
|
||||||
const sourceOutput = c.sourceOutput ?? 0;
|
const sourceOutput = c.sourceOutput ?? 0;
|
||||||
const sourceHandle = srcNode ? srcNode.inputs + sourceOutput : 0;
|
const sourceHandle = srcNode ? srcNode.inputs + sourceOutput : 0;
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
28
src/components/Automation2FlowEditor/nodes/shared/types.ts
Normal file
28
src/components/Automation2FlowEditor/nodes/shared/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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.<name></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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { StartNodeConfig } from './StartNodeConfig';
|
||||||
|
export { FormStartNodeConfig } from './FormStartNodeConfig';
|
||||||
|
export { ScheduleStartNodeConfig } from './ScheduleStartNodeConfig';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { SwitchNodeConfig } from './SwitchNodeConfig';
|
||||||
|
|
@ -26,6 +26,8 @@ export interface SharepointBrowseTreeProps {
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
|
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
|
||||||
onSelectFolder?: (path: string) => void;
|
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) */
|
/** Currently selected path (for highlight) */
|
||||||
selectedPath?: string | null;
|
selectedPath?: string | null;
|
||||||
/** Optional: pre-seed root children (e.g. from initial load) */
|
/** Optional: pre-seed root children (e.g. from initial load) */
|
||||||
|
|
@ -86,6 +88,7 @@ function _FolderRow({
|
||||||
onToggle,
|
onToggle,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
onSelectFolder,
|
onSelectFolder,
|
||||||
|
foldersOnly,
|
||||||
}: {
|
}: {
|
||||||
entry: BrowseEntry;
|
entry: BrowseEntry;
|
||||||
selectedPath: string | null | undefined;
|
selectedPath: string | null | undefined;
|
||||||
|
|
@ -95,6 +98,7 @@ function _FolderRow({
|
||||||
onToggle: (path: string) => void;
|
onToggle: (path: string) => void;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
onSelectFolder?: (path: string) => void;
|
onSelectFolder?: (path: string) => void;
|
||||||
|
foldersOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isExpanded = expandedPaths.has(entry.path);
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
const isSelected = selectedPath === entry.path;
|
const isSelected = selectedPath === entry.path;
|
||||||
|
|
@ -159,16 +163,18 @@ function _FolderRow({
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
onSelectFolder={onSelectFolder}
|
onSelectFolder={onSelectFolder}
|
||||||
|
foldersOnly={foldersOnly}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{files.map((child) => (
|
{!foldersOnly &&
|
||||||
<_FileRow
|
files.map((child) => (
|
||||||
key={child.path}
|
<_FileRow
|
||||||
entry={child}
|
key={child.path}
|
||||||
selectedPath={selectedPath}
|
entry={child}
|
||||||
onSelect={onSelectFile}
|
selectedPath={selectedPath}
|
||||||
/>
|
onSelect={onSelectFile}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
{children.length === 0 && (
|
{children.length === 0 && (
|
||||||
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||||||
Leer
|
Leer
|
||||||
|
|
@ -189,6 +195,7 @@ export function SharepointBrowseTree({
|
||||||
onLoadChildren,
|
onLoadChildren,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
onSelectFolder,
|
onSelectFolder,
|
||||||
|
foldersOnly = false,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
initialChildren = [],
|
initialChildren = [],
|
||||||
}: SharepointBrowseTreeProps) {
|
}: SharepointBrowseTreeProps) {
|
||||||
|
|
@ -282,16 +289,18 @@ export function SharepointBrowseTree({
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
onSelectFolder={onSelectFolder}
|
onSelectFolder={onSelectFolder}
|
||||||
|
foldersOnly={foldersOnly}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{rootFiles.map((entry) => (
|
{!foldersOnly &&
|
||||||
<_FileRow
|
rootFiles.map((entry) => (
|
||||||
key={entry.path}
|
<_FileRow
|
||||||
entry={entry}
|
key={entry.path}
|
||||||
selectedPath={selectedPath}
|
entry={entry}
|
||||||
onSelect={onSelectFile}
|
selectedPath={selectedPath}
|
||||||
/>
|
onSelect={onSelectFile}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
{rootItems.length === 0 && !rootLoading && (
|
{rootItems.length === 0 && !rootLoading && (
|
||||||
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||||||
Keine Einträge
|
Keine Einträge
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,11 @@ export function useConnections() {
|
||||||
return;
|
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
|
// Clean up
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
|
|
@ -302,7 +306,11 @@ export function useConnections() {
|
||||||
// Refresh connections
|
// Refresh connections
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
resolve();
|
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
|
// Handle error
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
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
|
// Create Microsoft connection and open OAuth popup
|
||||||
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
|
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -600,6 +679,7 @@ export function useConnections() {
|
||||||
connectWithPopup,
|
connectWithPopup,
|
||||||
createGoogleConnectionAndAuth,
|
createGoogleConnectionAndAuth,
|
||||||
createMicrosoftConnectionAndAuth,
|
createMicrosoftConnectionAndAuth,
|
||||||
|
createClickupConnectionAndAuth,
|
||||||
isLoading,
|
isLoading,
|
||||||
loading: isLoading, // Alias for FormGenerator compatibility
|
loading: isLoading, // Alias for FormGenerator compatibility
|
||||||
isConnecting,
|
isConnecting,
|
||||||
|
|
@ -681,7 +761,11 @@ export function useOAuthConnect() {
|
||||||
return;
|
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
|
// Clean up - IMPORTANT: clear the checkClosed interval first
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
|
|
@ -691,7 +775,11 @@ export function useOAuthConnect() {
|
||||||
// Refresh connections
|
// Refresh connections
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
resolve();
|
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
|
// Handle error - also clear the checkClosed interval
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
|
|
|
||||||
|
|
@ -508,11 +508,8 @@ export function useFileOperations() {
|
||||||
// FormData is now correctly configured for backend
|
// FormData is now correctly configured for backend
|
||||||
|
|
||||||
|
|
||||||
const response = await api.post('/api/files/upload', formData, {
|
// Do NOT set Content-Type manually – axios sets multipart/form-data with boundary for FormData
|
||||||
headers: {
|
const response = await api.post('/api/files/upload', formData);
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const fileData = response.data;
|
const fileData = response.data;
|
||||||
|
|
||||||
// Check if the response indicates a duplicate file
|
// Check if the response indicates a duplicate file
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,34 @@
|
||||||
cursor: not-allowed;
|
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 */
|
/* Filter Section Styles */
|
||||||
.filterSection {
|
.filterSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -714,7 +742,9 @@
|
||||||
|
|
||||||
.primaryButton,
|
.primaryButton,
|
||||||
.secondaryButton,
|
.secondaryButton,
|
||||||
.dangerButton {
|
.dangerButton,
|
||||||
|
.googleButton,
|
||||||
|
.clickupButton {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useConnections, type Connection } from '../../hooks/useConnections';
|
import { useConnections, type Connection } from '../../hooks/useConnections';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
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 { getApiBaseUrl } from '../../../config/config';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
|
|
@ -29,6 +29,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
createGoogleConnectionAndAuth,
|
createGoogleConnectionAndAuth,
|
||||||
createMicrosoftConnectionAndAuth,
|
createMicrosoftConnectionAndAuth,
|
||||||
|
createClickupConnectionAndAuth,
|
||||||
connectWithPopup,
|
connectWithPopup,
|
||||||
refreshMicrosoftToken,
|
refreshMicrosoftToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
|
|
@ -100,7 +101,12 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
|
|
||||||
// Validate and set authority if present
|
// Validate and set authority if present
|
||||||
if (data.authority) {
|
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;
|
updateData.authority = data.authority;
|
||||||
} else {
|
} else {
|
||||||
// Remove invalid authority value
|
// 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
|
// Open Microsoft Admin Consent flow in a popup
|
||||||
const handleAdminConsent = () => {
|
const handleAdminConsent = () => {
|
||||||
setAdminConsentPending(true);
|
setAdminConsentPending(true);
|
||||||
|
|
@ -228,7 +244,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Verbindungen</h1>
|
<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>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -262,6 +278,14 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<FaMicrosoft /> Microsoft
|
<FaMicrosoft /> Microsoft
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.clickupButton}
|
||||||
|
onClick={handleCreateClickup}
|
||||||
|
disabled={isConnecting}
|
||||||
|
title="ClickUp-Konto verbinden"
|
||||||
|
>
|
||||||
|
<FaTasks /> ClickUp
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -278,10 +302,10 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
<FaPlug className={styles.emptyIcon} />
|
<FaPlug className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
|
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
|
||||||
<p className={styles.emptyDescription}>
|
<p className={styles.emptyDescription}>
|
||||||
Verbinden Sie Ihr Google- oder Microsoft-Konto, um loszulegen.
|
Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.
|
||||||
</p>
|
</p>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
className={styles.googleButton}
|
className={styles.googleButton}
|
||||||
onClick={handleCreateGoogle}
|
onClick={handleCreateGoogle}
|
||||||
|
|
@ -296,6 +320,13 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<FaMicrosoft /> Mit Microsoft verbinden
|
<FaMicrosoft /> Mit Microsoft verbinden
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.clickupButton}
|
||||||
|
onClick={handleCreateClickup}
|
||||||
|
disabled={isConnecting}
|
||||||
|
>
|
||||||
|
<FaTasks /> Mit ClickUp verbinden
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
/**
|
/**
|
||||||
* Automation2WorkflowsPage
|
* Automation2WorkflowsPage
|
||||||
* List of saved workflows with FormGeneratorTable.
|
* List of saved workflows with FormGeneratorTable.
|
||||||
* Shows: label, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
|
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
|
||||||
* Actions: Edit (navigate to editor), Delete, Execute.
|
* Filter: Alle | Aktiv | Inaktiv.
|
||||||
|
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
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 { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
fetchWorkflows,
|
fetchWorkflows,
|
||||||
deleteWorkflow,
|
deleteWorkflow,
|
||||||
executeGraph,
|
executeGraph,
|
||||||
|
updateWorkflow,
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
} from '../../../api/automation2Api';
|
} from '../../../api/automation2Api';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
|
@ -44,12 +46,16 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [executingId, setExecutingId] = useState<string | null>(null);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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);
|
setWorkflows(list);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Automation2] load workflows failed', e);
|
console.error('[Automation2] load workflows failed', e);
|
||||||
|
|
@ -57,7 +63,7 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [instanceId, request, showError]);
|
}, [instanceId, request, showError, activeFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|
@ -87,12 +93,41 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
[mandateId, instanceId, navigate]
|
[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(
|
const handleExecute = useCallback(
|
||||||
async (row: Automation2Workflow) => {
|
async (row: Automation2Workflow) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setExecutingId(row.id);
|
setExecutingId(row.id);
|
||||||
try {
|
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?.success) {
|
||||||
if (result?.paused) {
|
if (result?.paused) {
|
||||||
showSuccess('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.');
|
showSuccess('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.');
|
||||||
|
|
@ -114,6 +149,18 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
|
|
||||||
const columns: ColumnConfig[] = [
|
const columns: ColumnConfig[] = [
|
||||||
{ key: 'label', label: 'Workflow', type: 'string', width: 200, sortable: true },
|
{ 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',
|
key: 'isRunning',
|
||||||
label: 'Läuft',
|
label: 'Läuft',
|
||||||
|
|
@ -181,7 +228,19 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
Workflows verwalten, ausführen und bearbeiten
|
Workflows verwalten, ausführen und bearbeiten
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={() => load()}
|
onClick={() => load()}
|
||||||
|
|
@ -215,12 +274,29 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
customActions={[
|
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',
|
id: 'execute',
|
||||||
icon: <FaPlay />,
|
icon: <FaPlay />,
|
||||||
title: 'Ausführen',
|
title: 'Ausführen',
|
||||||
onClick: (row) => handleExecute(row),
|
onClick: (row) => handleExecute(row),
|
||||||
loading: (row) => executingId === row.id,
|
loading: (row) => executingId === row.id,
|
||||||
|
visible: (row) => hasManualTrigger(row),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onDelete={(row) => handleDelete(row.id)}
|
onDelete={(row) => handleDelete(row.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,121 @@
|
||||||
.container {
|
.pageLayout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
padding: 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 {
|
.container h2 {
|
||||||
|
|
@ -195,6 +310,7 @@
|
||||||
.formFields input[type='text'],
|
.formFields input[type='text'],
|
||||||
.formFields input[type='number'],
|
.formFields input[type='number'],
|
||||||
.formFields input[type='date'],
|
.formFields input[type='date'],
|
||||||
|
.formFields select,
|
||||||
.taskCard input[type='text'],
|
.taskCard input[type='text'],
|
||||||
.taskCard input[type='number'],
|
.taskCard input[type='number'],
|
||||||
.taskCard textarea {
|
.taskCard textarea {
|
||||||
|
|
@ -279,3 +395,72 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,29 @@
|
||||||
* Tasks only (no workflow grouping).
|
* Tasks only (no workflow grouping).
|
||||||
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
|
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
|
||||||
* Each task shows workflow, created, due, step, type, and action.
|
* 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 React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaUpload } from 'react-icons/fa';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import {
|
import {
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
completeTask,
|
completeTask,
|
||||||
|
fetchCompletedRuns,
|
||||||
|
fetchWorkflows,
|
||||||
|
executeGraph,
|
||||||
|
loadClickupListTasksForDropdown,
|
||||||
type Automation2Task,
|
type Automation2Task,
|
||||||
|
type Automation2Workflow,
|
||||||
|
type CompletedRun,
|
||||||
|
type ApiRequestFunction,
|
||||||
} from '../../../api/automation2Api';
|
} from '../../../api/automation2Api';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { Popup } from '../../../components/UiComponents/Popup';
|
import { Popup } from '../../../components/UiComponents/Popup';
|
||||||
|
import { getAcceptStringFromConfig } from '../../../components/Automation2FlowEditor';
|
||||||
|
import { useFileOperations } from '../../../hooks/useFiles';
|
||||||
import styles from './Automation2WorkflowsTasks.module.css';
|
import styles from './Automation2WorkflowsTasks.module.css';
|
||||||
|
|
||||||
const NODE_TYPE_LABELS: Record<string, string> = {
|
const NODE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
|
@ -49,20 +61,66 @@ function getNodeStepLabel(config: Record<string, unknown>): string {
|
||||||
return '';
|
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 = () => {
|
export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
const [tasks, setTasks] = useState<Automation2Task[]>([]);
|
const [tasks, setTasks] = useState<Automation2Task[]>([]);
|
||||||
|
const [completedRuns, setCompletedRuns] = useState<CompletedRun[]>([]);
|
||||||
|
const [startableWorkflows, setStartableWorkflows] = useState<Automation2Workflow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [completedExpanded, setCompletedExpanded] = useState(false);
|
const [completedExpanded, setCompletedExpanded] = useState(false);
|
||||||
|
const [outputExpanded, setOutputExpanded] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState<string | null>(null);
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
|
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const taskList = await fetchTasks(request, instanceId);
|
const [taskList, runs] = await Promise.all([
|
||||||
|
fetchTasks(request, instanceId),
|
||||||
|
fetchCompletedRuns(request, instanceId, 20),
|
||||||
|
]);
|
||||||
setTasks(taskList);
|
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) {
|
} catch (e) {
|
||||||
console.error('[Automation2] load failed', e);
|
console.error('[Automation2] load failed', e);
|
||||||
} finally {
|
} 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 openTasks = tasks.filter((t) => t.status === 'pending');
|
||||||
const completedTasks = tasks.filter((t) => t.status !== 'pending');
|
const completedTasks = tasks.filter((t) => t.status !== 'pending');
|
||||||
|
|
||||||
|
|
@ -109,8 +197,10 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.pageLayout}>
|
||||||
<h2>Tasks</h2>
|
<div className={styles.mainColumn}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
|
||||||
{/* Open tasks */}
|
{/* Open tasks */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
|
|
@ -126,6 +216,7 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
<TaskCard
|
<TaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
instanceId={instanceId ?? undefined}
|
||||||
onSubmit={(result) => handleComplete(task.id, result)}
|
onSubmit={(result) => handleComplete(task.id, result)}
|
||||||
submitting={submitting === task.id}
|
submitting={submitting === task.id}
|
||||||
/>
|
/>
|
||||||
|
|
@ -156,6 +247,7 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
<TaskCard
|
<TaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
instanceId={instanceId ?? undefined}
|
||||||
onSubmit={(result) => handleComplete(task.id, result)}
|
onSubmit={(result) => handleComplete(task.id, result)}
|
||||||
submitting={submitting === task.id}
|
submitting={submitting === task.id}
|
||||||
readOnly
|
readOnly
|
||||||
|
|
@ -165,40 +257,307 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
task: Automation2Task;
|
task: Automation2Task;
|
||||||
|
instanceId?: string;
|
||||||
onSubmit: (result: Record<string, unknown>) => void;
|
onSubmit: (result: Record<string, unknown>) => void;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
readOnly?: 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> = ({
|
const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
task,
|
task,
|
||||||
|
instanceId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { handleFileUpload } = useFileOperations();
|
||||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
const [formPopupOpen, setFormPopupOpen] = useState(false);
|
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 config = task.config ?? {};
|
||||||
const nodeType = task.nodeType;
|
const nodeType = task.nodeType;
|
||||||
const stepLabel = getNodeStepLabel(config);
|
const stepLabel = getNodeStepLabel(config);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUploadedFiles([]);
|
||||||
|
setUploadError(null);
|
||||||
|
}, [task.id]);
|
||||||
|
|
||||||
const renderInput = () => {
|
const renderInput = () => {
|
||||||
if (readOnly) return null;
|
if (readOnly) return null;
|
||||||
switch (nodeType) {
|
switch (nodeType) {
|
||||||
case 'input.form': {
|
case 'input.form': {
|
||||||
const fields =
|
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 requiredFields = fields.filter((f) => f.required);
|
||||||
const allRequiredFilled = requiredFields.every((f) => {
|
const allRequiredFilled = requiredFields.every((f) => {
|
||||||
const v = formData[f.name];
|
const v = formData[f.name];
|
||||||
if (f.type === 'boolean') return true;
|
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() !== '';
|
return v !== undefined && v !== null && String(v).trim() !== '';
|
||||||
});
|
});
|
||||||
const formContent = (
|
const formContent = (
|
||||||
|
|
@ -217,6 +576,30 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
setFormData((p) => ({ ...p, [f.name]: e.target.checked }))
|
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
|
<input
|
||||||
type={
|
type={
|
||||||
|
|
@ -251,7 +634,8 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSubmit(formData);
|
// Match node output shape used by refs (payload.*) and outputPreviewRegistry.input.form
|
||||||
|
onSubmit({ payload: formData });
|
||||||
setFormPopupOpen(false);
|
setFormPopupOpen(false);
|
||||||
}}
|
}}
|
||||||
disabled={submitting || !allRequiredFilled}
|
disabled={submitting || !allRequiredFilled}
|
||||||
|
|
@ -368,19 +752,123 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<div>
|
<div className={styles.uploadTaskBlock}>
|
||||||
<p>Upload-Komponente – noch nicht implementiert</p>
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={acceptStr || undefined}
|
||||||
|
multiple={allowMultiple}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSubmit({ uploaded: [] })}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={submitting}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'input.review':
|
case 'input.review':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -448,12 +936,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
{NODE_TYPE_LABELS[nodeType] ?? nodeType}
|
{NODE_TYPE_LABELS[nodeType] ?? nodeType}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.nodeId && (
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>Node</span>
|
|
||||||
<span className={styles.metaValueMono}>{task.nodeId}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{renderInput()}
|
{renderInput()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ interface DataSourcePanelProps {
|
||||||
const _AUTHORITY_ICONS: Record<string, string> = {
|
const _AUTHORITY_ICONS: Record<string, string> = {
|
||||||
msft: '\uD83D\uDFE6',
|
msft: '\uD83D\uDFE6',
|
||||||
google: '\uD83D\uDFE9',
|
google: '\uD83D\uDFE9',
|
||||||
|
clickup: '\uD83D\uDCCB',
|
||||||
'local:ftp': '\uD83D\uDD17',
|
'local:ftp': '\uD83D\uDD17',
|
||||||
'local:jira': '\uD83D\uDD27',
|
'local:jira': '\uD83D\uDD27',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,18 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
|
||||||
|
|
||||||
if (isText && file.fileSize < 500_000) {
|
if (isText && file.fileSize < 500_000) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.get(`/api/files/${file.id}/download`, { responseType: 'text' })
|
api.get(`/api/files/${file.id}/download`, { responseType: 'arraybuffer' })
|
||||||
.then(res => setContent(typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2)))
|
.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))
|
.catch(() => setContent(null))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
} else if (isImage) {
|
} else if (isImage) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue