Compare commits

...

24 commits

Author SHA1 Message Date
Ida
60ff00802c continued testing and improvement 2026-05-14 19:25:44 +02:00
Ida
b5084c028e fix: handover fix, if/else node extended comparison mode 2026-05-14 18:38:44 +02:00
Ida
587dad5cf9 feat: extract content node angepasst für mehr optionen 2026-05-14 13:06:31 +02:00
Ida
0fd05f638f feat: seperated accordion list component to be seperate and reusable ui component 2026-05-14 12:08:05 +02:00
Ida
aa61e00af6 fix: moved cron schedule calculator to utils for better reusability 2026-05-14 11:57:45 +02:00
Ida
7e2ffb42fe fix: schedule node to be more user friendly 2026-05-14 11:52:17 +02:00
Ida
dd26ea132d fix: formular trigger 2026-05-14 11:15:16 +02:00
Ida
50a3df5c18 feat: added trigger nodes such that they are not hidden anymore 2026-05-14 10:57:29 +02:00
Ida
e7f2272c30 fix: formular node aufgeräumt und files besser sortiert 2026-05-13 16:58:02 +02:00
Ida
ef9955257e fix: pfeile zeichnen nicht mehr verbugged 2026-05-13 16:46:10 +02:00
Ida
6890a38546 fix: arrow beginning und ending 2026-05-13 16:41:03 +02:00
Ida
590178b8f2 feat: ctrl c shortcut und pfeil zeichnen 2026-05-13 16:24:38 +02:00
Ida
e3c93dc220 fix: anordnen knopf wieder hinzugefügt mit verschachtelten rangpfaden 2026-05-13 16:16:41 +02:00
Ida
600e0c87dc fix: leerer select in node im config panel leading to white screen 2026-05-13 15:48:30 +02:00
Ida
9e36075f0e fix: resized canvas size and fixed delete comments 2026-05-13 15:42:00 +02:00
Ida
3a7a34a4f3 feat: added edit button bar und kommentar-funktion 2026-05-13 15:36:16 +02:00
Ida
c13489e232 fix: removed legacy code 2026-05-13 15:03:37 +02:00
Ida
4bf6677bc5 fix: clean header 2026-05-13 15:00:06 +02:00
Ida
8860f49714 fix: removed duplicate keepAlive Files for pages and views and instead implemented single source of truth for mounted pages, removed unused legacy core page manager 2026-05-13 14:25:20 +02:00
Ida
74dc7b85f8 continous work on grafischer editor, loop verbessert 2026-05-13 13:30:45 +02:00
Ida
66a7a6fa56 neue context nodes hinzugefügt, muss noch debuggt werden 2026-05-12 06:34:32 +02:00
Ida
294803e66c node handover standartisiert, kein hardcoden mehr, inhalt extraktion node verbessert, output ports vereinheitlicht mit user im blick 2026-05-12 06:34:32 +02:00
Ida
ae630201ba finished file tree folder selection in file create node 2026-05-12 06:34:30 +02:00
Ida
7fb96451a5 workign on folder location in file create node 2026-05-12 06:34:10 +02:00
74 changed files with 7058 additions and 5302 deletions

View file

@ -169,8 +169,8 @@ function App() {
<Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
{/* Automation2: legacy workflows URL → editor */}
<Route path="workflows" element={<Navigate to="../editor" replace />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */}

View file

@ -32,6 +32,10 @@ export interface PortField {
enumValues?: string[] | null;
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
recommended?: boolean;
/** Human label from portTypeCatalog (backend). Preferred over technical path in DataPicker. */
pickerLabel?: string | null;
/** Backend: segment for one list element (between List field and nested field). */
pickerItemLabel?: string | null;
}
export interface PortSchema {
@ -39,6 +43,20 @@ export interface PortSchema {
fields: PortField[];
}
/** One pickable binding — defined on ``outputPorts[n].dataPickOptions`` (authoritative list from gateway). */
export interface DataPickOption {
path: (string | number)[];
pickerLabel: string;
detail?: string;
recommended?: boolean;
iterable?: boolean;
/** For display and optional strict compatibility (e.g. str, Any). */
type?: string;
}
/** @deprecated Prefer ``outputPorts[].dataPickOptions``; kept for older payloads. */
export type OutputPickHint = DataPickOption;
export interface InputPortDef {
accepts: string[];
}
@ -53,6 +71,11 @@ export interface OutputPortDef {
schema: string | GraphDefinedSchemaRef;
dynamic?: boolean;
deriveFrom?: string;
/**
* When set, DataPicker uses **only** this list for that port (no portTypeCatalog expansion).
* Authoritative, like `parameters` for node configuration.
*/
dataPickOptions?: DataPickOption[];
}
export interface NodeType {
@ -76,7 +99,6 @@ export interface NodeType {
action?: string;
};
}
export interface NodeTypeCategory {
id: string;
label: Record<string, string> | string;
@ -94,10 +116,19 @@ export interface FormFieldType {
portType: string;
}
export interface ConditionOperatorDef {
id: string;
label: string;
labelKey?: string;
needsValue: boolean;
valueInput?: { kind: string; options?: string[] };
}
export interface NodeTypesResponse {
nodeTypes: NodeType[];
categories: NodeTypeCategory[];
portTypeCatalog?: Record<string, PortSchema>;
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
}
@ -288,15 +319,17 @@ export async function fetchNodeTypes(
const nodeTypes = data?.nodeTypes ?? [];
const categories = data?.categories ?? [];
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
const conditionOperatorCatalog = data?.conditionOperatorCatalog ?? undefined;
const systemVariables = data?.systemVariables ?? undefined;
const formFieldTypes = data?.formFieldTypes ?? undefined;
console.log(
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
`${conditionOperatorCatalog ? Object.keys(conditionOperatorCatalog).length : 0} conditionKinds, ` +
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` +
`${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes`
);
return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes };
return { nodeTypes, categories, portTypeCatalog, conditionOperatorCatalog, systemVariables, formFieldTypes };
}
export interface UpstreamPathEntry {
@ -306,6 +339,39 @@ export interface UpstreamPathEntry {
type: string;
label: string;
scopeOrigin: 'data' | 'loop' | 'system';
valueKind?: string;
}
export interface ConditionMetaResponse {
valueKind: string;
operators: ConditionOperatorDef[];
}
export interface ConditionMetaRequest {
graph: Automation2Graph;
nodeId?: string;
ref: { type: 'ref'; nodeId: string; path: (string | number)[] };
}
/**
* POST /api/workflows/{instanceId}/condition-meta operators for a DataRef (If/Else).
*/
export async function fetchConditionMeta(
request: ApiRequestFunction,
instanceId: string,
body: ConditionMetaRequest,
language = 'de'
): Promise<ConditionMetaResponse> {
const data = await request({
url: `/api/workflows/${instanceId}/condition-meta`,
method: 'post',
params: { language },
data: body,
});
return {
valueKind: String(data?.valueKind ?? 'unknown'),
operators: (data?.operators ?? []) as ConditionOperatorDef[],
};
}
/**
@ -325,6 +391,41 @@ export async function postUpstreamPaths(
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
}
/** Scope-aware data sources for the DataPicker — all loop-scope logic lives on the backend. */
export interface GraphDataSources {
/** Ancestor node IDs that are valid sources (loop body nodes excluded when on Done branch). */
availableSourceIds: string[];
/** Maps nodeId → output port index to use instead of 0 (e.g. loop node on Done branch → 1). */
portIndexOverrides: Record<string, number>;
/** IDs of flow.loop nodes whose body the current node is inside (show currentItem etc.). */
loopBodyContextIds: string[];
}
/**
* POST /api/workflows/{instanceId}/graph-data-sources
*
* Returns scope-aware source list so the DataPicker needs zero graph-traversal logic.
* The graph connections must use { source, target, sourceOutput?, targetInput? } format.
*/
export async function fetchGraphDataSources(
request: ApiRequestFunction,
instanceId: string,
nodeId: string,
nodes: Array<{ id: string; type?: string }>,
connections: Array<{ source: string; target: string; sourceOutput?: number; targetInput?: number }>,
): Promise<GraphDataSources> {
const data = await request({
url: `/api/workflows/${instanceId}/graph-data-sources`,
method: 'post',
data: { nodeId, graph: { nodes, connections } },
});
return {
availableSourceIds: data?.availableSourceIds ?? [],
portIndexOverrides: data?.portIndexOverrides ?? {},
loopBodyContextIds: data?.loopBodyContextIds ?? [],
};
}
/** GET saved workflow graph variant of upstream-paths (requires workflowId). */
export async function getUpstreamPathsSaved(
request: ApiRequestFunction,
@ -670,6 +771,23 @@ export async function completeTask(
});
}
/** Cancel a pending human task and stop its workflow run (Graphical Editor). */
export async function cancelPendingTaskStopRun(
request: ApiRequestFunction,
instanceId: string,
taskId: string
): Promise<{ success: boolean; runId?: string | null; taskId: string }> {
const data = await request({
url: `/api/workflows/${instanceId}/tasks/${taskId}/cancel`,
method: 'post',
});
return {
success: Boolean(data?.success),
runId: data?.runId,
taskId: data?.taskId ?? taskId,
};
}
// -------------------------------------------------------------------------
// Versions (AutoVersion Lifecycle)
// -------------------------------------------------------------------------

View file

@ -6,7 +6,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
export interface Automation2DataFlowContextValue {
currentNodeId: string;
@ -19,6 +19,8 @@ export interface Automation2DataFlowContextValue {
systemVariables: Record<string, SystemVariable>;
/** Canonical form field types from the API — maps UI type id to portType primitive. */
formFieldTypes: FormFieldType[];
/** Backend-driven condition operators per valueKind (flow.ifElse). */
conditionOperatorCatalog: Record<string, ConditionOperatorDef[]>;
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
getAvailableSourceIds: () => string[];
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
@ -44,6 +46,7 @@ interface Automation2DataFlowProviderProps {
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
instanceId?: string;
request?: ApiRequestFunction;
children: React.ReactNode;
@ -59,6 +62,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog = {},
systemVariables = {},
formFieldTypes = [],
conditionOperatorCatalog = {},
instanceId,
request,
children,
@ -120,6 +124,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog,
systemVariables,
formFieldTypes,
conditionOperatorCatalog,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
@ -127,7 +132,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
request,
parseGraphDefinedSchema,
};
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, instanceId, request]);
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
return (
<Automation2DataFlowContext.Provider value={value}>

View file

@ -246,6 +246,7 @@
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--canvas-bg, #fafafa);
}
@ -256,27 +257,133 @@
background: var(--bg-primary, #fff);
}
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
.canvasHeaderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
}
@media (max-width: 900px) {
.canvasHeaderRow {
grid-template-columns: 1fr;
}
}
.canvasHeaderContext {
.canvasHeaderToolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
width: 100%;
padding: 0;
border-radius: 8px;
border: none;
background: none;
box-sizing: border-box;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderToolbar :global(button),
.canvasHeaderToolbar label {
margin-top: 0;
}
.canvasHeaderEditRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
width: 100%;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e8e8e8);
}
.canvasHeaderEditRow :global(button) {
margin-top: 0;
}
.canvasHeaderGhostIconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-primary, #333);
cursor: pointer;
box-sizing: border-box;
}
.canvasHeaderGhostIconBtn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
}
.canvasHeaderGhostIconBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.canvasHeaderZoomCombo {
position: relative;
display: inline-flex;
align-items: stretch;
flex: 0 0 auto;
}
.canvasHeaderZoomInputWrap {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
min-width: 4.25rem;
padding-left: 0.35rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px 0 0 6px;
border-right: none;
background: var(--bg-primary, #fff);
box-sizing: border-box;
min-height: 30px;
}
.canvasHeaderZoomInputWrap:focus-within {
border-color: var(--primary-color, #007bff);
}
.canvasHeaderZoomInput {
flex: 1 1 auto;
width: 2.25rem;
min-width: 0;
flex: 1;
padding: 0.28rem 0.15rem 0.28rem 0;
font-size: 0.8125rem;
border: none;
background: transparent;
color: var(--text-primary, #333);
text-align: right;
box-sizing: border-box;
min-height: 28px;
}
.canvasHeaderZoomInput:focus {
outline: none;
}
.canvasHeaderZoomSuffix {
flex-shrink: 0;
padding-right: 0.35rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #666);
user-select: none;
}
.canvasHeaderZoomChevronBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-height: 30px;
padding: 0;
border: 1px solid var(--border-color, #ccc);
border-radius: 0 6px 6px 0;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
cursor: pointer;
box-sizing: border-box;
}
.canvasHeaderZoomChevronBtn:hover {
background: rgba(0, 0, 0, 0.06);
}
/* Closed <select> width must not follow the longest option label. */
@ -284,89 +391,41 @@
flex: 0 0 auto;
width: 12.5rem;
max-width: 100%;
padding: 0.4rem 0.5rem;
min-height: 2rem;
font-size: 0.85rem;
padding: 0.31rem 0.45rem;
min-height: 30px;
box-sizing: border-box;
font-size: 0.8125rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
border-radius: var(--button-border-radius, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderTitleBlock {
flex: 1 1 8rem;
min-width: 0;
display: flex;
align-items: center;
gap: 0.25rem;
.canvasHeaderIconBtn {
padding: 6px !important;
min-width: 30px !important;
min-height: 30px !important;
box-sizing: border-box !important;
}
.canvasHeaderTitle,
.canvasHeaderTitle input {
margin: 0;
min-width: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
.canvasHeaderSplitPair :global(.button + .button) {
margin-left: 0;
}
.canvasHeaderTitle {
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.canvasHeaderRunBlocked {
background: rgba(220, 53, 69, 0.1) !important;
border: 1px solid var(--danger-color, #dc3545) !important;
color: var(--danger-color, #dc3545) !important;
cursor: help !important;
box-shadow: none !important;
}
.canvasHeaderTitleMuted {
font-style: italic;
font-weight: 500;
opacity: 0.65;
color: var(--text-secondary, #666);
.canvasHeaderRunBlocked:hover:not(:disabled) {
filter: brightness(0.97);
}
.canvasHeaderTitle input {
width: 100%;
max-width: 100%;
padding: 0.25rem 0.4rem;
border: 1px solid var(--primary-color, #007bff);
border-radius: 4px;
outline: none;
background: var(--bg-primary, #fff);
box-sizing: border-box;
}
.canvasHeaderActionPanel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto;
max-width: 100%;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderActionPanel button {
margin-top: 0;
}
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */
.canvasHeaderRunButton {
min-width: 12.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
@media (max-width: 900px) {
.canvasHeaderActionPanel {
justify-content: flex-start;
}
.canvasHeaderRunBlocked :global(.buttonIcon) {
opacity: 0.5;
}
.canvasHeaderVersionRow {
@ -380,7 +439,7 @@
width: 100%;
}
.canvasHeaderVersionRow button {
.canvasHeaderVersionRow :global(.button) {
margin-top: 0;
}
@ -391,6 +450,57 @@
flex: 0 0 auto;
}
.canvasHeaderVersionBadge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
background: var(--canvasHeaderBadgeBg, transparent);
color: var(--canvasHeaderBadgeFg, inherit);
flex: 0 0 auto;
}
.canvasHeaderVersionAction {
font-size: 0.8rem !important;
padding: 0.25rem 0.6rem !important;
min-height: auto !important;
}
.canvasHeaderVersionSpinner {
font-size: 0.85rem;
}
.canvasHeaderExecuteBanner {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 6px;
font-size: 0.875rem;
}
.canvasHeaderExecuteBannerSuccess {
background: rgba(40, 167, 69, 0.15);
color: var(--success-color, #28a745);
}
.canvasHeaderExecuteBannerWarning {
background: rgba(255, 193, 7, 0.15);
color: var(--warning-color, #ffc107);
}
.canvasHeaderExecuteBannerPaused {
background: rgba(0, 123, 255, 0.15);
color: var(--primary-color, #007bff);
}
.canvasHeaderExecuteBannerError {
background: rgba(220, 53, 69, 0.15);
color: var(--danger-color, #dc3545);
}
.canvasHeaderSysadminInput {
margin: 0;
}
.canvasHeaderVersionSelect {
width: 11rem;
max-width: 100%;
@ -484,22 +594,183 @@
.canvasArea {
flex: 1;
padding: 2rem;
min-height: 400px;
overflow: hidden;
padding: 0;
min-height: 0;
overflow-x: visible;
overflow-y: hidden;
}
.canvasDropZone {
position: relative;
min-height: 100%;
height: 100%;
overflow: hidden;
/* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
overflow: visible;
border-radius: 8px;
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
background-repeat: repeat;
}
.canvasDropZoneConnectionTool {
cursor: crosshair;
}
.canvasStickyNote {
position: relative;
pointer-events: auto;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteResize {
position: absolute;
right: 1px;
bottom: 1px;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
border: none;
border-radius: 2px 0 6px 0;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.12) 45%,
rgba(0, 0, 0, 0.12) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.18) 58%,
rgba(0, 0, 0, 0.18) 64%,
transparent 64%
);
box-sizing: border-box;
}
.canvasStickyNoteResize:hover {
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.2) 45%,
rgba(0, 0, 0, 0.2) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.26) 58%,
rgba(0, 0, 0, 0.26) 64%,
transparent 64%
);
}
.canvasStickyNoteResize:focus-visible {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteSelected {
box-shadow:
0 0 0 2px var(--primary-color, #007bff),
0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteToolbar {
display: flex;
align-items: center;
gap: 0.35rem;
min-height: 1.5rem;
padding: 0.15rem 0.25rem 0.2rem;
background: rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
cursor: grab;
user-select: none;
}
.canvasStickyNoteToolbar:active {
cursor: grabbing;
}
.canvasStickyNoteGrip {
flex: 1;
font-size: 0.7rem;
letter-spacing: -0.12em;
color: var(--text-muted, #666);
opacity: 0.85;
padding: 0 0.15rem;
}
.canvasStickyNoteSwatches {
display: flex;
flex-wrap: wrap;
gap: 3px;
justify-content: flex-end;
}
.canvasStickyNoteSwatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.22);
padding: 0;
cursor: pointer;
flex-shrink: 0;
box-sizing: border-box;
}
.canvasStickyNoteSwatch:hover {
filter: brightness(0.96);
}
.canvasStickyNoteSwatchActive {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteBody {
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--text-primary, #333);
border: 1px solid transparent;
border-radius: 0;
white-space: pre-wrap;
word-break: break-word;
cursor: text;
outline: none;
}
.canvasStickyNoteBody:focus-visible {
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.35);
}
.canvasStickyNoteTextarea {
display: block;
width: 100%;
margin: 0;
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
font-family: inherit;
color: var(--text-primary, #333);
border-style: solid;
border-width: 1px;
border-radius: 0;
box-shadow: none;
resize: none;
box-sizing: border-box;
outline: none;
}
.canvasStickyNoteTextarea:focus {
border-color: var(--primary-color, #007bff);
box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.25);
}
.canvasContent {
position: absolute;
left: 0;
@ -695,6 +966,8 @@
.handleWrapper:has(.handleOutput) {
flex-direction: row;
/* Bottom handles: keep circle math aligned with wires even when a label grows row height. */
align-items: flex-end;
}
.handleWrapper:has(.handleInput) {
@ -726,6 +999,16 @@
cursor: copy;
}
/* Shell: stretches to full canvas-area height so inner `.nodeConfigPanel` can scroll. */
.nodeConfigPanelWrap {
flex-shrink: 0;
align-self: stretch;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* Node Config Panel
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
* pair acts as a safety net so long unbreakable strings (type names like
@ -735,17 +1018,20 @@
* a long label rather than escaping to the right.
*/
.nodeConfigPanel {
flex: 1;
min-height: 0;
padding: 1rem;
background: var(--bg-primary, #fff);
border-left: 1px solid var(--border-color, #e0e0e0);
width: 280px;
flex-shrink: 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
position: relative;
z-index: 10;
}
.nodeConfigPanel h4 {
@ -808,7 +1094,9 @@
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
(DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */
.nodeConfigPanel
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) {
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn):not(
[data-accordion-header]
):not([data-schedule-day]) {
margin-top: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
@ -901,6 +1189,12 @@
background: rgba(220, 53, 69, 0.1);
}
.formFieldOptionsBlock {
margin-top: 0.4rem;
padding-top: 0.45rem;
border-top: 1px solid var(--border-color, #e8e8e8);
}
/* Upload node config */
.uploadNodeConfig {
display: flex;
@ -1491,24 +1785,6 @@
cursor: pointer;
}
.canvasGearBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background: var(--bg-primary, #fff);
cursor: pointer;
font-size: 1rem;
}
.canvasGearBtn:hover {
background: var(--bg-hover, #f0f0f0);
}
.startsInput,
.startsSelect {
padding: 0.35rem 0.5rem;
@ -1771,6 +2047,39 @@
border-color: var(--primary-color, #007bff);
}
/* Curated picker: disclose technical / rare paths behind a single quiet control. */
.dataPickerCuratedToggle {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.38rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-secondary, #5c6370);
background: var(--bg-primary, #fff);
border: 1px dashed var(--border-color, #cfd4dc);
border-radius: 5px;
cursor: pointer;
text-align: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.dataPickerCuratedToggle:hover {
color: var(--text-primary, #333);
background: var(--bg-secondary, #f4f6f8);
border-color: var(--border-color, #b8c0cc);
}
.dataPickerCuratedDivider {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary, #8a9199);
margin: 0.75rem 0 0.35rem 0;
padding-left: 0.15rem;
}
/* Dynamic Value Field */
.dynamicValueField {
display: flex;

View file

@ -1,8 +1,8 @@
/**
* Automation2FlowEditor
*
* n8n-style flow builder with backend-driven node list.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
* n8n-style flow builder with backend-driven node list and categories.
* Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@ -32,18 +32,20 @@ import {
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import {
FlowCanvas,
type CanvasNode,
type CanvasConnection,
type CanvasStickyNote,
type FlowCanvasHandle,
type FlowCanvasViewportEditState,
} from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
@ -58,13 +60,23 @@ import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], runLabel);
const CANVAS_HISTORY_MAX = 50;
function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[]) {
return {
nodes: nodes.map((n) => ({
...n,
parameters: n.parameters ? { ...n.parameters } : {},
inputPorts: n.inputPorts?.map((p) => ({ ...p })),
outputPorts: n.outputPorts?.map((p) => ({ ...p })),
})),
connections: connections.map((c) => ({ ...c })),
};
}
interface Automation2FlowEditorProps {
instanceId: string;
@ -92,7 +104,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSourcesChanged,
}) => {
const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
@ -100,24 +111,37 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
Record<string, import('../../../api/workflowApi').ConditionOperatorDef[]>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const flowCanvasRef = useRef<FlowCanvasHandle>(null);
const canvasHistoryPastRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const canvasHistoryFutureRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const suppressCanvasHistoryRef = useRef(false);
const [canvasHistoryTick, setCanvasHistoryTick] = useState(0);
const [canvasViewportEdit, setCanvasViewportEdit] = useState<FlowCanvasViewportEditState>({
zoom: 1,
selectedNodeCount: 0,
connectionSelected: false,
stickyNoteSelected: false,
});
const [canvasConnectionToolActive, setCanvasConnectionToolActive] = useState(false);
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]);
const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
@ -136,13 +160,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
@ -196,7 +213,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
document.body.style.userSelect = 'none';
}, [leftPanelWidth, sidebarWidth]);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
const startNodeTypeIds = useMemo(
() => new Set(nodeTypes.filter((n) => n.category === 'start').map((n) => n.id)),
[nodeTypes]
);
const hasCanvasStartNode = useMemo(
() => canvasNodes.some((n) => startNodeTypeIds.has(n.type)),
[canvasNodes, startNodeTypeIds]
);
const missingStartNodeBlocking = useMemo(
() => canvasNodes.length > 0 && !hasCanvasStartNode,
[canvasNodes.length, hasCanvasStartNode]
);
const nodeOutputsPreview = useMemo(
() =>
@ -219,22 +247,73 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const pushCanvasHistoryPastFromCurrent = useCallback(() => {
if (suppressCanvasHistoryRef.current) return;
const snap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const past = canvasHistoryPastRef.current;
const last = past[past.length - 1];
if (last && JSON.stringify(last) === JSON.stringify(snap)) return;
past.push(snap);
if (past.length > CANVAS_HISTORY_MAX) past.shift();
canvasHistoryFutureRef.current = [];
setCanvasHistoryTick((x) => x + 1);
}, [canvasNodes, canvasConnections]);
const onCanvasHistoryCheckpoint = useCallback(() => {
pushCanvasHistoryPastFromCurrent();
}, [pushCanvasHistoryPastFromCurrent]);
const undoCanvasEdit = useCallback(() => {
const past = canvasHistoryPastRef.current;
if (past.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = past.pop()!;
canvasHistoryFutureRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const redoCanvasEdit = useCallback(() => {
const fut = canvasHistoryFutureRef.current;
if (fut.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = fut.pop()!;
canvasHistoryPastRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const canCanvasUndo = useMemo(() => canvasHistoryPastRef.current.length > 0, [canvasHistoryTick]);
const canCanvasRedo = useMemo(() => canvasHistoryFutureRef.current.length > 0, [canvasHistoryTick]);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
setInvocations(inv);
if (!graph?.nodes?.length) {
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
return;
(
graph: Automation2Graph | null | undefined,
wfInvocations: WorkflowEntryPoint[] | undefined,
opts?: { skipHistory?: boolean }
) => {
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
pushCanvasHistoryPastFromCurrent();
}
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
setInvocations(wfInvocations ?? []);
const g: Automation2Graph = graph ?? { nodes: [], connections: [] };
const { nodes, connections } = fromApiGraph(g, nodeTypes);
setCanvasNodes(nodes);
setCanvasConnections(connections);
},
[nodeTypes, language, t]
[nodeTypes, pushCanvasHistoryPastFromCurrent]
);
const handleFromApiGraph = useCallback(
@ -263,6 +342,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
});
return;
}
if (missingStartNodeBlocking) {
setExecuteResult({
success: false,
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
});
return;
}
setExecuting(true);
setExecuteResult(null);
try {
@ -280,7 +366,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
@ -296,19 +382,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
0,
);
const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => ({
success: true,
warning:
errorCount > 0
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
: undefined,
});
const _buildSaveResult = (): ExecuteGraphResponse => {
const parts: string[] = [];
if (errorCount > 0) {
parts.push(
t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
);
}
if (canvasNodes.length > 0 && !hasCanvasStartNode) {
parts.push(t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'));
}
return {
success: true,
warning: parts.length ? parts.join(' ') : undefined,
};
};
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
const updated = await updateWorkflow(request, instanceId, currentWorkflowId, {
graph,
invocations,
targetFeatureInstanceId,
});
setInvocations(updated.invocations ?? []);
setExecuteResult(_buildSaveResult());
} else {
const label = await promptInput(t('Workflow-Name:'), {
@ -327,7 +426,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
targetFeatureInstanceId,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setInvocations(created.invocations ?? []);
setWorkflows((prev) => [...prev, created]);
setExecuteResult(_buildSaveResult());
}
@ -336,7 +435,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
const handleLoad = useCallback(
async (workflowId: string) => {
@ -361,7 +460,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, []);
try {
const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items);
@ -385,7 +484,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (workflowId) handleLoad(workflowId);
else {
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, []);
}
},
[handleLoad, applyGraphWithSync, t]
@ -394,36 +493,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleNew = useCallback(() => {
setCurrentWorkflowId(null);
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, []);
}, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
setCanvasNodes((prev) => {
const nextNodes = 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);
const newCount = switchOutputCountFromCases(parameters.cases);
next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
}
return next;
})
);
});
return nextNodes;
});
}, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
setCanvasNodes((prev) => {
const nextNodes = 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);
const newCount = switchOutputCountFromCases(merged.cases);
next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
}
return next;
})
);
});
return nextNodes;
});
}, []);
const handleNodeUpdate = useCallback(
@ -435,18 +542,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[]
);
const handleApplyWorkflowConfiguration = useCallback(
(next: WorkflowEntryPoint[]) => {
setInvocations(next);
setCanvasNodes((nodes) => {
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
setCanvasConnections(r.connections);
return r.nodes;
});
},
[canvasConnections, nodeTypes, language]
);
const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
@ -461,6 +556,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
}
if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]);
@ -488,6 +584,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
loadWorkflows();
}, [loadWorkflows]);
useEffect(() => {
setCanvasStickyNotes([]);
}, [currentWorkflowId]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
@ -500,7 +600,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true,
});
}, [
loading,
nodeTypes.length,
@ -522,7 +624,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@ -675,31 +776,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[request, instanceId, handleFromApiGraph]
);
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try {
await updateWorkflow(request, instanceId, workflowId, { label: newName });
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`${LOG} rename failed`, e);
showError(t('Workflow umbenennen fehlgeschlagen: {msg}', { msg }));
}
}, [request, instanceId, showError, t]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
}, [canvasConnections]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
@ -741,7 +820,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle}
/>
);
@ -749,15 +827,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
selectedNode.type.startsWith(p)
);
[
'input.',
'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
const canvasHeaderEdit = useMemo(
() => ({
zoomPercent: Math.round(canvasViewportEdit.zoom * 100),
selectedNodeCount: canvasViewportEdit.selectedNodeCount,
connectionSelected: canvasViewportEdit.connectionSelected,
stickyNoteSelected: canvasViewportEdit.stickyNoteSelected,
connectionToolActive: canvasConnectionToolActive,
canUndo: canCanvasUndo,
canRedo: canCanvasRedo,
onZoomIn: () => flowCanvasRef.current?.zoomIn(),
onZoomOut: () => flowCanvasRef.current?.zoomOut(),
onZoomPercentCommit: (pct: number) => flowCanvasRef.current?.setZoomPercent(pct),
onFitWindow: () => flowCanvasRef.current?.fitWindow(),
onResetView: () => flowCanvasRef.current?.resetView(),
onUndo: undoCanvasEdit,
onRedo: redoCanvasEdit,
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
}),
[
canvasViewportEdit,
canvasConnectionToolActive,
canCanvasUndo,
canCanvasRedo,
undoCanvasEdit,
redoCanvasEdit,
]
);
return (
<div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div
data-suppress-flow-node-hotkeys=""
style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}
>
<div className={styles.rightTabBar}>
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button
@ -829,15 +953,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
workspacePanelOpen={leftPanelOpen}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeBlockedReason={
hasGraphErrors
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
: null
: missingStartNodeBlocking
? t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.')
: null
}
onExecuteBlockedClick={() => {
if (firstErrorNodeId) {
@ -857,17 +983,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
canvasEdit={canvasHeaderEdit}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<FlowCanvas
ref={flowCanvasRef}
nodes={canvasNodes}
connections={canvasConnections}
nodeTypes={nodeTypes}
@ -879,6 +1002,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors}
onViewportEditState={setCanvasViewportEdit}
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
onConnectionToolActiveChange={setCanvasConnectionToolActive}
stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes}
onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow' || !instanceId) return false;
const p = payload as { files?: Array<{ id: string }> } | undefined;
@ -897,6 +1025,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
/>
</div>
{configurableSelected && selectedNode && (
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
<Automation2DataFlowProvider
node={selectedNode}
nodes={canvasNodes}
@ -907,6 +1036,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes}
conditionOperatorCatalog={conditionOperatorCatalog}
instanceId={instanceId}
request={request}
>
@ -922,13 +1052,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
verboseSchema={verboseSchema}
/>
</Automation2DataFlowProvider>
</div>
)}
</div>
</div>
{/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
<div
data-suppress-flow-node-hotkeys=""
style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}
>
<div className={styles.rightTabBar}>
<button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
@ -961,12 +1095,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}

View file

@ -1,99 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.4 (T10): CanvasHeader Run-button gating logic.
// Verifies the AC-9 patch — Save always enabled (unless saving), Run blocked
// when executeBlockedReason is set + warning toast surfaced as amber banner.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/workflowApi';
vi.mock('../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
import { CanvasHeader } from './CanvasHeader';
const _workflows: Automation2Workflow[] = [];
function _renderHeader(overrides: Partial<React.ComponentProps<typeof CanvasHeader>> = {}) {
const props: React.ComponentProps<typeof CanvasHeader> = {
workflows: _workflows,
currentWorkflowId: null,
onWorkflowSelect: () => {},
onNew: () => {},
onSave: () => {},
onExecute: () => {},
saving: false,
executing: false,
hasNodes: true,
executeResult: null,
...overrides,
};
return render(<CanvasHeader {...props} />);
}
describe('CanvasHeader Run-button (T10)', () => {
it('runs `onExecute` when not blocked', async () => {
const onExecute = vi.fn();
_renderHeader({ onExecute });
await userEvent.click(screen.getByRole('button', { name: /Ausführen/i }));
expect(onExecute).toHaveBeenCalledTimes(1);
});
it('shows the "Pflicht-Felder fehlen" label and triggers `onExecuteBlockedClick` instead of `onExecute`', async () => {
const onExecute = vi.fn();
const onExecuteBlockedClick = vi.fn();
_renderHeader({
onExecute,
onExecuteBlockedClick,
executeBlockedReason: '2 Nodes mit Pflicht-Fehlern',
});
const btn = screen.getByRole('button', { name: /Pflicht-Felder fehlen/i });
expect(btn).toHaveAttribute('aria-disabled', 'true');
expect(btn).toHaveAttribute('title', '2 Nodes mit Pflicht-Fehlern');
await userEvent.click(btn);
expect(onExecute).not.toHaveBeenCalled();
expect(onExecuteBlockedClick).toHaveBeenCalledTimes(1);
});
it('disables the Run button while executing or when no nodes are present', () => {
const { rerender } = _renderHeader({ executing: true });
expect(screen.getByRole('button', { name: /Ausführen…/i })).toBeDisabled();
rerender(
<CanvasHeader
workflows={_workflows}
currentWorkflowId={null}
onWorkflowSelect={() => {}}
onNew={() => {}}
onSave={() => {}}
onExecute={() => {}}
saving={false}
executing={false}
hasNodes={false}
executeResult={null}
/>,
);
expect(screen.getByRole('button', { name: /Ausführen/i })).toBeDisabled();
});
});
describe('CanvasHeader executeResult banner (AC-9)', () => {
it('renders the warning text in amber when success+warning is present', () => {
const result: ExecuteGraphResponse = {
success: true,
warning: 'Gespeichert mit 3 Pflicht-Fehlern in 2 Nodes.',
};
_renderHeader({ executeResult: result });
expect(screen.getByText(/Gespeichert mit 3 Pflicht-Fehlern/i)).toBeInTheDocument();
});
it('renders the error text in red when success=false', () => {
const result: ExecuteGraphResponse = { success: false, error: 'Boom' };
_renderHeader({ executeResult: result });
expect(screen.getByText(/Boom/)).toBeInTheDocument();
});
});

View file

@ -1,18 +1,63 @@
/**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
* CanvasHeader - Workflow controls, version selector, and execute result.
*/
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import {
FaPlay,
FaSpinner,
FaCloudUploadAlt,
FaCloudDownloadAlt,
FaArchive,
FaBookmark,
FaCaretDown,
FaSave,
FaPlus,
FaChevronLeft,
FaChevronRight,
} from 'react-icons/fa';
import {
HiOutlineMagnifyingGlassMinus,
HiOutlineMagnifyingGlassPlus,
HiOutlineArrowUturnLeft,
HiOutlineArrowUturnRight,
HiOutlineTrash,
HiOutlineDocumentDuplicate,
HiOutlineArrowLongRight,
HiOutlineChatBubbleLeftEllipsis,
HiOutlineSquares2X2,
} from 'react-icons/hi2';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
import { Button } from '../../UiComponents/Button';
interface TargetInstanceOption {
id: string;
label: string;
const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
export interface CanvasHeaderCanvasEditProps {
zoomPercent: number;
selectedNodeCount: number;
connectionSelected: boolean;
stickyNoteSelected: boolean;
connectionToolActive: boolean;
canUndo: boolean;
canRedo: boolean;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomPercentCommit: (percent: number) => void;
onFitWindow: () => void;
onResetView: () => void;
onUndo: () => void;
onRedo: () => void;
onDeleteSelection: () => void;
onDuplicateNode: () => void;
onToggleConnectionTool: () => void;
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
onAddCanvasComment: () => void;
/** Verschachtelte Rasterpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
onArrangeNodes: () => void;
}
interface CanvasHeaderProps {
@ -22,14 +67,14 @@ interface CanvasHeaderProps {
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onWorkflowSettings?: () => void;
onToggleChat?: () => void;
onToggleWorkspacePanel?: () => void;
workspacePanelOpen?: boolean;
saving: boolean;
executing: boolean;
hasNodes: boolean;
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
* parent can navigate the user to the first offending node. */
/** When set, required-field graph errors block a normal run; message is the
* run button tooltip. Click still fires `onExecuteBlockedClick` to focus
* the first offending node. */
executeBlockedReason?: string | null;
onExecuteBlockedClick?: () => void;
executeResult: ExecuteGraphResponse | null;
@ -44,15 +89,11 @@ interface CanvasHeaderProps {
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean;
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
onAutoLayout?: () => void;
/** Sysadmin-only: when true, NodeConfigPanel renders the static
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
canvasEdit?: CanvasHeaderCanvasEditProps;
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -63,14 +104,18 @@ function _getStatusBadge(t: (key: string) => string): Record<string, { label: st
};
}
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
const _tb = 'secondary' as const;
const _ts = 'sm' as const;
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onWorkflowSettings,
onToggleChat,
onToggleWorkspacePanel,
workspacePanelOpen,
saving,
executing,
hasNodes,
@ -88,13 +133,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onSaveAsTemplate,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
verboseSchema,
onVerboseSchemaChange,
targetFeatureInstanceId,
onTargetInstanceChange,
targetInstanceOptions,
canvasEdit,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@ -109,38 +150,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
const _startNameEdit = useCallback(() => {
if (!currentWorkflowId || !onWorkflowRename) return;
setNameValue(currentWorkflow?.label || '');
setEditingName(true);
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const _commitNameEdit = useCallback(() => {
setEditingName(false);
const trimmed = nameValue.trim();
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
if (trimmed !== currentWorkflow?.label) {
onWorkflowRename(currentWorkflowId, trimmed);
}
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const zoomMenuRef = useRef<HTMLDivElement>(null);
const [zoomInputDraft, setZoomInputDraft] = useState('');
useEffect(() => {
if (editingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [editingName]);
const zp = canvasEdit?.zoomPercent;
if (zp !== undefined) setZoomInputDraft(String(zp));
}, [canvasEdit?.zoomPercent]);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
};
document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside);
@ -156,15 +179,106 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
[t]
);
const _titleHint =
onWorkflowRename && currentWorkflow
? `${currentWorkflow.label}${t('Klicken zum Umbenennen')}`
: currentWorkflow?.label;
const _panelOpen = workspacePanelOpen ?? false;
const _runAriaLabel = executing
? t('Ausführen…')
: executeBlockedReason
? t('Pflicht-Felder fehlen')
: t('Ausführen');
const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
const _executeBannerSegmentClass = !executeResult
? ''
: executeResult.success
? executeResult.warning
? styles.canvasHeaderExecuteBannerWarning
: styles.canvasHeaderExecuteBannerSuccess
: executeResult.paused
? styles.canvasHeaderExecuteBannerPaused
: styles.canvasHeaderExecuteBannerError;
const _commitZoomDraft = () => {
if (!canvasEdit) return;
const raw = zoomInputDraft.replace(/%/g, '').replace(',', '.').trim();
const n = parseFloat(raw);
if (!Number.isFinite(n)) {
setZoomInputDraft(String(canvasEdit.zoomPercent));
return;
}
canvasEdit.onZoomPercentCommit(Math.min(400, Math.max(25, Math.round(n))));
setZoomMenuOpen(false);
};
const _canDeleteSelection =
!!canvasEdit &&
(canvasEdit.selectedNodeCount > 0 ||
canvasEdit.connectionSelected ||
canvasEdit.stickyNoteSelected);
const _singleNodeOnly =
!!canvasEdit && canvasEdit.selectedNodeCount === 1 && !canvasEdit.connectionSelected;
return (
<div className={styles.canvasHeader}>
<div className={styles.canvasHeaderRow}>
<div className={styles.canvasHeaderContext}>
<div className={styles.canvasHeader} data-suppress-flow-node-hotkeys="">
<div
className={styles.canvasHeaderToolbar}
role="toolbar"
aria-label={t('Workflow-Aktionen')}
>
{onToggleWorkspacePanel && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={_panelOpen ? FaChevronLeft : FaChevronRight}
className={styles.canvasHeaderIconBtn}
onClick={onToggleWorkspacePanel}
title={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
/>
)}
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaPlus}
className={`${styles.canvasHeaderIconBtn} ${onNewFromTemplate ? styles.canvasHeaderNewSplitMain : ''}`}
onClick={onNew}
title={t('Neuer leerer Workflow')}
aria-label={t('Neuer leerer Workflow')}
/>
{onNewFromTemplate && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaCaretDown}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Aus Vorlage…')}
aria-label={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
/>
)}
</div>
{newMenuOpen && onNewFromTemplate && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => {
onNewFromTemplate();
setNewMenuOpen(false);
}}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
</div>
)}
</div>
<select
className={styles.canvasHeaderWorkflowSelect}
value={currentWorkflowId ?? ''}
@ -182,142 +296,53 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</option>
))}
</select>
<div className={styles.canvasHeaderTitleBlock}>
{currentWorkflowId && currentWorkflow ? (
editingName ? (
<input
ref={nameInputRef}
className={styles.canvasHeaderTitle}
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={_commitNameEdit}
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
/>
) : (
<h4
className={styles.canvasHeaderTitle}
style={{ cursor: onWorkflowRename ? 'pointer' : 'default' }}
onClick={_startNameEdit}
title={_titleHint}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={`${styles.canvasHeaderTitle} ${styles.canvasHeaderTitleMuted}`}>
{t('Neuer Workflow')}
</h4>
)}
</div>
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title={t('Workflowkonfiguration Einstieg/Starts')}
aria-label={t('Workflow-Konfiguration')}
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
<select
className={styles.canvasHeaderWorkflowSelect}
value={targetFeatureInstanceId ?? ''}
onChange={(e) => onTargetInstanceChange(e.target.value)}
aria-label={t('Ziel-Instanz')}
title={t('Ziel-Instanz für Daten-Scope')}
style={{ maxWidth: 200, fontSize: '0.8rem' }}
>
<option value="">{t('Ziel-Instanz wählen…')}</option>
{targetInstanceOptions.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)}
</div>
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMain}`}
onClick={onNew}
>
{t('Neu')}
</button>
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
</div>
{newMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNew(); setNewMenuOpen(false); }}
role="menuitem"
>
{t('Leerer Workflow')}
</button>
{onNewFromTemplate && (
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
)}
</div>
)}
</div>
<button
<Button
type="button"
className={styles.retryButton}
onClick={onSave}
variant={_tb}
size={_ts}
icon={saving ? undefined : FaSave}
className={styles.canvasHeaderIconBtn}
loading={saving}
disabled={saving}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
>
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
{onAutoLayout && (
<button
type="button"
className={styles.retryButton}
onClick={onAutoLayout}
disabled={!hasNodes}
title={t('Knoten automatisch anordnen')}
>
<FaSitemap style={{ marginRight: '0.4rem' }} />
{t('Anordnen')}
</button>
)}
onClick={onSave}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
aria-label={t('Speichern')}
/>
<Button
type="button"
variant={_tb}
size={_ts}
icon={executing ? undefined : FaPlay}
loading={executing}
disabled={executing || !hasNodes}
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
aria-label={_runAriaLabel}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={_runTitle}
/>
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<button
<Button
type="button"
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
variant={_tb}
size={_ts}
icon={FaBookmark}
loading={templateSaving}
disabled={templateSaving}
onClick={() => setTemplateMenuOpen((p) => !p)}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
</button>
{t('Als Vorlage')}
</Button>
{templateMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => (
@ -325,7 +350,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
key={s}
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
onClick={() => {
onSaveAsTemplate(s);
setTemplateMenuOpen(false);
}}
role="menuitem"
>
{scopeLabels[s]}
@ -336,53 +364,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</div>
)}
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderRunButton}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
disabled={executing || !hasNodes}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={executeBlockedReason ?? undefined}
style={
executeBlockedReason
? {
background: 'rgba(220,53,69,0.10)',
borderColor: 'var(--danger-color, #dc3545)',
color: 'var(--danger-color, #dc3545)',
cursor: 'help',
}
: undefined
}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ flexShrink: 0 }} />
{t('Ausführen…')}
</>
) : executeBlockedReason ? (
<>
<FaPlay style={{ opacity: 0.5, flexShrink: 0 }} />
{t('Pflicht-Felder fehlen')}
</>
) : (
<>
<FaPlay style={{ flexShrink: 0 }} />
{t('Ausführen')}
</>
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
<FaDatabase style={{ marginRight: '0.4rem' }} />
{t('Workspace')}
</button>
)}
{_isSysAdmin && onVerboseSchemaChange && (
<label
className={styles.canvasHeaderSysadmin}
@ -392,14 +373,173 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
type="checkbox"
checked={!!verboseSchema}
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
style={{ margin: 0 }}
className={styles.canvasHeaderSysadminInput}
/>
{t('Schema-Details')}
</label>
)}
</div>
</div>
{canvasEdit && (
<div
className={styles.canvasHeaderEditRow}
role="toolbar"
aria-label={t('Canvas bearbeiten')}
>
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
<div className={styles.canvasHeaderZoomInputWrap}>
<input
type="text"
inputMode="numeric"
className={styles.canvasHeaderZoomInput}
value={zoomInputDraft}
onChange={(e) => setZoomInputDraft(e.target.value)}
onBlur={_commitZoomDraft}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitZoomDraft();
}
}}
aria-label={t('Zoomstufe (Prozent)')}
title={t('Zoomstufe (Prozent)')}
/>
<span className={styles.canvasHeaderZoomSuffix} aria-hidden>
%
</span>
</div>
<button
type="button"
className={styles.canvasHeaderZoomChevronBtn}
onClick={() => setZoomMenuOpen((p) => !p)}
aria-label={t('Zoom-Voreinstellungen')}
aria-haspopup="menu"
aria-expanded={zoomMenuOpen}
title={t('Zoom-Voreinstellungen')}
>
<FaCaretDown aria-hidden />
</button>
{zoomMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onFitWindow();
setZoomMenuOpen(false);
}}
>
{t('Ansicht an Fenster anpassen')}
</button>
<button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onResetView();
setZoomMenuOpen(false);
}}
>
{t('Ansicht zurücksetzen')}
</button>
{ZOOM_PRESET_PERCENTS.map((pct) => (
<button
key={pct}
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onZoomPercentCommit(pct);
setZoomMenuOpen(false);
}}
>
{pct}%
</button>
))}
</div>
)}
</div>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onZoomIn}
title={t('Vergrößern')}
aria-label={t('Vergrößern')}
>
<HiOutlineMagnifyingGlassPlus size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onZoomOut}
title={t('Verkleinern')}
aria-label={t('Verkleinern')}
>
<HiOutlineMagnifyingGlassMinus size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canUndo}
onClick={canvasEdit.onUndo}
title={t('Rückgängig')}
aria-label={t('Rückgängig')}
>
<HiOutlineArrowUturnLeft size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canRedo}
onClick={canvasEdit.onRedo}
title={t('Wiederholen')}
aria-label={t('Wiederholen')}
>
<HiOutlineArrowUturnRight size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_canDeleteSelection}
onClick={canvasEdit.onDeleteSelection}
title={t('Auswahl löschen')}
aria-label={t('Auswahl löschen')}
>
<HiOutlineTrash size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_singleNodeOnly}
onClick={canvasEdit.onDuplicateNode}
title={t('Knoten duplizieren')}
aria-label={t('Knoten duplizieren')}
>
<HiOutlineDocumentDuplicate size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!hasNodes}
onClick={canvasEdit.onArrangeNodes}
title={t('Knoten im Raster anordnen')}
aria-label={t('Knoten im Raster anordnen')}
>
<HiOutlineSquares2X2 size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onAddCanvasComment}
title={t('Kommentar auf dem Canvas einfügen')}
aria-label={t('Kommentar auf dem Canvas einfügen')}
>
<HiOutlineChatBubbleLeftEllipsis size={18} strokeWidth={2} aria-hidden />
</button>
</div>
)}
{currentWorkflowId && versions && versions.length > 0 && (
<div className={styles.canvasHeaderVersionRow}>
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
@ -418,108 +558,94 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
))}
</select>
<span
style={{
padding: '2px 8px',
borderRadius: 10,
fontSize: '0.75rem',
fontWeight: 600,
background: badge.color + '22',
color: badge.color,
}}
className={styles.canvasHeaderVersionBadge}
style={
{
'--canvasHeaderBadgeBg': `${badge.color}22`,
'--canvasHeaderBadgeFg': badge.color,
} as React.CSSProperties
}
>
{badge.label}
</span>
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaCloudUploadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onPublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version veröffentlichen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudUploadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichen')}
</button>
</Button>
)}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaCloudDownloadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onUnpublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Veröffentlichung zurücknehmen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichung aufheben')}
</button>
</Button>
)}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaArchive}
className={styles.canvasHeaderVersionAction}
onClick={() => onArchiveVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version archivieren')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaArchive style={{ marginRight: 4 }} />
Archiv
</button>
{t('Archiv')}
</Button>
)}
{onCreateDraft && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaPlus}
className={styles.canvasHeaderVersionAction}
onClick={onCreateDraft}
disabled={versionLoading}
title={t('Neuen Entwurf erstellen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
+ Entwurf
</button>
{t('+ Entwurf')}
</Button>
)}
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
{versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
</div>
)}
{executeResult && (
<div
style={{
marginTop: '0.5rem',
padding: '0.5rem',
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? executeResult.warning
? 'rgba(255,193,7,0.15)'
: 'rgba(40,167,69,0.15)'
: (executeResult as { paused?: boolean }).paused
? 'rgba(0,123,255,0.15)'
: 'rgba(220,53,69,0.15)',
color: executeResult.success
? executeResult.warning
? 'var(--warning-color,#ffc107)'
: 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
>
{executeResult.success ? (
executeResult.warning ? (
<> {executeResult.warning}</>
<>{executeResult.warning}</>
) : (
<>{t('Ausführung abgeschlossen')}</>
)
) : (executeResult as { paused?: boolean }).paused ? (
) : executeResult.paused ? (
<>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
Task zu bearbeiten.
{t('Workflow pausiert. Öffne ')}
<strong>{t('Workflows/Tasks')}</strong>
{t(' in der Sidebar, um den Task zu bearbeiten.')}
</>
) : (
<> {executeResult.error ?? t('Unbekannter Fehler')}</>
<>{executeResult.error ?? t('Unbekannter Fehler')}</>
)}
</div>
)}

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,82 @@ import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { AccordionList } from '../../UiComponents/AccordionList';
import type { AccordionListItem } from '../../UiComponents/AccordionList';
const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
const CONTEXT_EXTRACT_CHUNK_SET = new Set<string>(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
export function workflowParamUiValue(stored: Record<string, unknown>, param: NodeTypeParameter): unknown {
const raw = stored[param.name];
if (param.required) {
return raw !== undefined && raw !== null ? raw : param.default;
}
return raw;
}
function effectiveSchemaParamString(name: string, currentParams: Record<string, unknown>, nt: NodeType): string {
const raw = currentParams[name];
const s = raw !== undefined && raw !== null ? String(raw) : '';
if (s !== '') return s;
const meta = nt.parameters?.find((p) => p.name === name);
const d = meta?.default;
return d !== undefined && d !== null ? String(d) : '';
}
function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
return (
<span style={{ fontWeight: 700, fontSize: 12 }}>
{param.required ? (
<span style={{ color: 'var(--danger-color, #dc3545)', marginRight: 3 }} title={t('Pflichtfeld')}>
*
</span>
) : null}
{param.name}
</span>
);
}
function verboseSchemaTypeBadge(
verboseSchema: boolean,
param: NodeTypeParameter,
t: (key: string) => string,
): React.ReactElement | null {
if (!verboseSchema || !param.type) return null;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 6,
flexWrap: 'wrap',
minWidth: 0,
}}
>
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
</div>
);
}
interface NodeConfigPanelProps {
node: CanvasNode | null;
@ -30,6 +106,35 @@ interface NodeConfigPanelProps {
verboseSchema?: boolean;
}
/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
* (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
* parameter unless the referenced parameter's effective value matches.
*/
export function parameterVisibleForFrontendOptions(
param: NodeTypeParameter,
params: Record<string, unknown>,
nodeType: NodeType,
): boolean {
const fo = param.frontendOptions;
if (!fo || typeof fo !== 'object') return true;
const dependsOnRaw = fo.dependsOn as unknown;
const showWhenRaw = fo.showWhen as unknown;
if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
return true;
}
const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
const rawSibling = params[dependsOnRaw];
const siblingValue =
rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
const fallback =
depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
const effective = siblingValue !== '' ? siblingValue : fallback;
const allowed: string[] = Array.isArray(showWhenRaw)
? showWhenRaw.map((x) => String(x))
: [String(showWhenRaw)];
return allowed.includes(effective);
}
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
nodeType,
language,
@ -62,7 +167,32 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
const updateParam = useCallback(
(key: string, value: unknown) => {
setParams((prev) => {
const next = { ...prev, [key]: value };
const next = { ...prev };
if (value === undefined) {
delete next[key];
} else {
next[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 patchParams = useCallback(
(patch: Record<string, unknown>) => {
setParams((prev) => {
const next = { ...prev, ...patch };
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
@ -115,6 +245,139 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
.join('\n');
}, [requiredErrors, nodeType, language]);
const extractContentAccordionItems = useMemo((): AccordionListItem<string>[] | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
const out: AccordionListItem<string>[] = [];
for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
const usePicker = _shouldUseRequiredPicker(param);
if (usePicker) {
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
),
});
continue;
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
if (param.name === 'outputMode') {
const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks';
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
{chunksNested ? (
<div style={{ marginTop: 8 }}>
<AccordionList<string>
key={`extract-chunks-${node.id}`}
defaultOpenId={null}
items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem<string> => {
const cp = byName.get(chunkName);
if (!cp) {
return { id: chunkName, title: chunkName, children: <></> };
}
const ft = cp.frontendType || 'text';
const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text;
return {
id: chunkName,
title: accordionExtractParamTitle(cp, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, cp, t)}
<ChunkRenderer
param={cp}
value={workflowParamUiValue(params, cp)}
onChange={(val: unknown) => updateParam(cp.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
};
})}
/>
</div>
) : null}
</div>
),
});
continue;
}
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
});
}
return out;
}, [
sortedParameters,
params,
nodeType,
language,
node?.id,
node?.type,
verboseSchema,
instanceId,
request,
patchParams,
updateParam,
t,
]);
if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.');
@ -219,78 +482,88 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div>
)}
{parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
{extractContentAccordionItems !== null ? (
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
) : (
parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={params[param.name] ?? param.default}
onChange={(val) => updateParam(param.name, val)}
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={param.name} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={params[param.name] ?? param.default}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
/>
</div>
);
})}
})
)}
</div>
);
};
@ -320,6 +593,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'featureInstance',
'sharepointFolder',
'sharepointFile',
'userFileFolder',
'clickupList',
'clickupTask',
'dataRef',

View file

@ -1,6 +1,6 @@
/**
* NodeSidebar - Sidebar with searchable, collapsible node list.
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
* Groups node types by category (start, input, flow, data, ai, email, sharepoint).
*/
import React, { useMemo } from 'react';
@ -21,7 +21,7 @@ interface NodeSidebarProps {
language: string;
expandedCategories: Set<string>;
onToggleCategory: (id: string) => void;
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
/** Hide palette categories (optional; e.g. feature flags) */
excludedCategories?: Set<string>;
style?: React.CSSProperties;
}

View file

@ -1,123 +0,0 @@
/**
* Workflow configuration primary start kind drives the canvas start node.
*/
import React, { useState, useEffect } from 'react';
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
import {
getPrimaryStartKind,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
return [
{ value: 'manual', label: t('Manueller Trigger') },
{ value: 'form', label: t('Formular') },
{ value: 'schedule', label: t('Zeitplan') },
{ value: 'always_on', label: t('Immer aktiv') },
];
}
interface WorkflowConfigurationModalProps {
open: boolean;
onClose: () => void;
invocations: WorkflowEntryPoint[];
onApply: (next: WorkflowEntryPoint[]) => void;
}
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
function normalizeLoadedKind(k: string): string {
if (_validKinds.includes(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 { t } = useLanguage();
const kindOptions = _getKindOptions(t);
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 entryTitle = entry?.title;
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
else setTitleDe('');
}, [open, invocations]);
if (!open) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const label =
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('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}>
{t('Workflow-Konfiguration')}
</h3>
<p className={styles.workflowModalHint}>
{t(
'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">
{t('Titel der Start Node')}
</label>
<input
id="wf-start-title"
className={styles.workflowModalInput}
value={titleDe}
onChange={(e) => setTitleDe(e.target.value)}
placeholder={t('z.B. Angebot anlegen')}
/>
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
{kindOptions.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}>
{t('Abbrechen')}
</button>
<button type="submit" className={styles.workflowModalBtnPrimary}>
{t('Übernehmen')}
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -1,11 +1,12 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader';
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
export * from './nodes/shared/utils';
export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils';

View file

@ -0,0 +1,104 @@
/**
* One text field per option the text the end user sees in the dropdown.
* Stored as { value, label } with the same string so payload and UI stay in sync.
*/
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import type { FormFieldOptionRow } from './formFieldOptionsUtils';
export interface FormFieldOptionsEditorProps {
options: FormFieldOptionRow[];
onChange: (next: FormFieldOptionRow[]) => void;
className?: string;
rowClassName?: string;
}
export const FormFieldOptionsEditor: React.FC<FormFieldOptionsEditorProps> = ({
options,
onChange,
className,
rowClassName,
}) => {
const { t } = useLanguage();
const rootClass = className ?? '';
const lineClass = rowClassName ?? '';
const setOptionText = (idx: number, text: string) => {
const next = options.map((o, i) =>
i === idx ? { value: text, label: text } : o,
);
onChange(next);
};
return (
<div className={rootClass}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary, #666)', marginBottom: 4 }}>
{t('Auswahloptionen')}
</div>
{options.map((opt, idx) => (
<div
key={idx}
className={lineClass}
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
<input
type="text"
placeholder={t('z.B. On hold')}
value={opt.label || opt.value}
onChange={(e) => setOptionText(idx, e.target.value)}
style={{
flex: '1 1 120px',
minWidth: 80,
padding: '4px 6px',
fontSize: '0.8rem',
borderRadius: 4,
border: '1px solid var(--border-color, #ddd)',
boxSizing: 'border-box',
}}
/>
<button
type="button"
title={t('Option entfernen')}
onClick={() => onChange(options.filter((_, i) => i !== idx))}
style={{
padding: '4px 8px',
border: 'none',
background: 'transparent',
color: 'var(--text-tertiary, #999)',
cursor: 'pointer',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
}}
>
<FaTimes />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...options, { value: '', label: '' }])}
style={{
marginTop: 2,
padding: '4px 10px',
fontSize: '0.75rem',
borderRadius: 4,
border: '1px dashed var(--border-color, #bbb)',
background: 'var(--bg-primary, #fff)',
color: 'var(--text-secondary, #555)',
cursor: 'pointer',
}}
>
+ {t('Option')}
</button>
</div>
);
};

View file

@ -8,6 +8,12 @@ import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -64,20 +70,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
</span>
<div className={styles.formFieldInputs}>
<input
placeholder={t('name')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder={t('label')}
placeholder={t('Bezeichnung')}
value={f.label ?? ''}
onChange={(e) => {
const label = e.target.value;
const next = [...fields];
next[i] = { ...next[i], label: e.target.value };
next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
updateParam('fields', next);
}}
/>
@ -88,7 +86,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required };
const type = e.target.value as FormField['type'];
const row: FormField = { ...f, type };
if (formFieldTypeHasConfigurableOptions(type)) {
row.options = normalizeFormFieldOptions(row.options);
}
next[i] = row;
updateParam('fields', next);
}}
style={{ width: 'auto', minWidth: 90 }}
@ -118,12 +121,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
<FaTimes />
</button>
</div>
{formFieldTypeHasConfigurableOptions(f.type) ? (
<FormFieldOptionsEditor
className={styles.formFieldOptionsBlock}
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => {
const next = [...fields];
next[i] = { ...next[i], options: opts };
updateParam('fields', next);
}}
/>
) : null}
</div>
))}
<button
type="button"
onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
updateParam('fields', [
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
type: 'text',
label: '',
required: false,
},
])
}
>
+ {t('Feld')}

View file

@ -0,0 +1,40 @@
/**
* Helpers for optional select/multiselect rows on workflow form field definitions.
*/
export type FormFieldOptionRow = { value: string; label: string };
/** Field types where the author defines explicit { value, label } choices. */
export function formFieldTypeHasConfigurableOptions(typeId: string | undefined): boolean {
if (!typeId) return false;
return typeId === 'select' || typeId === 'enum';
}
export function normalizeFormFieldOptions(raw: unknown): FormFieldOptionRow[] {
if (!Array.isArray(raw)) return [];
return raw.map((o, i) => {
if (o && typeof o === 'object' && !Array.isArray(o)) {
const r = o as Record<string, unknown>;
const value = String(r.value ?? r.id ?? '');
const label = String(r.label ?? r.value ?? r.id ?? `Option ${i + 1}`);
return { value, label };
}
const s = String(o ?? '');
return { value: s, label: s };
});
}
/**
* Stable key for `payload.*` / data refs. From the visible label; empty label `field_<index>`.
*/
export function deriveFormFieldPayloadKey(label: string, index: number): string {
const trimmed = label.trim();
if (!trimmed) return `field_${index + 1}`;
const deaccent = trimmed.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
let s = deaccent
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
if (!s) return `field_${index + 1}`;
return s;
}

View file

@ -1 +1,8 @@
export { FormNodeConfig } from './FormNodeConfig';
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
export type { FormFieldOptionRow } from './formFieldOptionsUtils';
export {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';

View file

@ -0,0 +1,274 @@
/**
* Backend-driven case list for flow.switch (depends on value dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
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 };
}
function operatorsFromCatalog(
catalog: Record<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
function sanitizeCases(cases: SwitchCase[], operators: ConditionOperatorDef[]): SwitchCase[] {
if (!operators.length) return cases;
return cases.map((c) => {
const op = operators.find((o) => o.id === c.operator) ?? operators[0];
return {
operator: op.id,
value: op.needsValue ? c.value ?? '' : undefined,
};
});
}
function CaseValueInput({
caseItem,
opDef,
valueKind,
onChange,
t,
}: {
caseItem: SwitchCase;
opDef: ConditionOperatorDef | undefined;
valueKind: string;
onChange: (v: string | number) => void;
t: (key: string) => string;
}) {
const valueInput = opDef?.valueInput;
const val = caseItem.value;
if (
valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime'
) {
return (
<select
value={String(val ?? '')}
onChange={(e) => onChange(e.target.value)}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
}
return (
<input
type={
valueInput?.kind === 'number' || valueKind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(val ?? '')}
onChange={(e) =>
onChange(
valueInput?.kind === 'number' || valueKind === 'number'
? parseFloat(e.target.value) || 0
: e.target.value
)
}
placeholder={valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert')}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
);
}
export const CaseListEditor: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value')
: 'value';
const valueParam = allParams?.[dependsOn];
const ref: DataRef | null = isRef(valueParam) ? valueParam : null;
const rawCases = Array.isArray(value) ? value : [];
const cases: SwitchCase[] = rawCases.map(normalizeCase);
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
const [valueKind, setValueKind] = React.useState('unknown');
const [loading, setLoading] = React.useState(false);
const catalog = dataFlow?.conditionOperatorCatalog;
React.useEffect(() => {
if (!ref) {
const ops = operatorsFromCatalog(catalog, 'unknown');
setOperators(ops);
setValueKind('unknown');
return;
}
let cancelled = false;
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
if (cancelled) return;
setValueKind(vk);
setOperators(ops);
if (cases.length > 0) {
const next = sanitizeCases(cases, ops);
if (JSON.stringify(next) !== JSON.stringify(cases)) {
onChange(next);
}
}
};
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => applyMeta(meta.valueKind, meta.operators))
.catch(() => applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown')))
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown'));
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const setCases = (next: SwitchCase[]) => onChange(next);
const addCase = () => {
const opDef = operators[0];
setCases([
...cases,
{
operator: opDef?.id ?? 'eq',
value: opDef?.needsValue ? (valueKind === 'number' ? 0 : '') : undefined,
},
]);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst einen Wert im Data Picker wählen')}
</p>
</div>
);
}
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
{t('Lade Operatoren…')}
</div>
)}
{cases.map((c, i) => {
const opDef = operators.find((o) => o.id === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<select
value={c.operator}
onChange={(e) => {
const op = operators.find((o) => o.id === e.target.value);
const next = [...cases];
next[i] = {
operator: e.target.value,
value: op?.needsValue ? cases[i]?.value ?? '' : undefined,
};
setCases(next);
}}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
{needsValue && (
<CaseValueInput
caseItem={c}
opDef={opDef}
valueKind={valueKind}
t={t}
onChange={(v) => {
const next = [...cases];
next[i] = { ...next[i], value: v };
setCases(next);
}}
/>
)}
<button
type="button"
onClick={() => setCases(cases.filter((_, j) => j !== i))}
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
>
×
</button>
</div>
);
})}
<button
type="button"
onClick={addCase}
disabled={loading || operators.length === 0}
style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}
>
{t('Fall hinzufügen')}
</button>
</div>
);
};

View file

@ -0,0 +1,223 @@
/**
* Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition {
type: 'condition';
operator: string;
value?: string | number;
/** Legacy — ignored when Item is set */
ref?: DataRef | null;
}
function parseCondition(v: unknown): StructuredCondition {
if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') {
const c = v as StructuredCondition;
return { type: 'condition', operator: c.operator ?? 'eq', value: c.value };
}
return { type: 'condition', operator: 'eq', value: '' };
}
function operatorsFromCatalog(
catalog: Record<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
export const ConditionEditor: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'Item')
: 'Item';
const itemRef = allParams?.[dependsOn];
const ref: DataRef | null = isRef(itemRef) ? itemRef : null;
const cond = parseCondition(value);
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
const [valueKind, setValueKind] = React.useState('unknown');
const [loading, setLoading] = React.useState(false);
const catalog = dataFlow?.conditionOperatorCatalog;
React.useEffect(() => {
if (!ref) {
setOperators([]);
setValueKind('unknown');
return;
}
let cancelled = false;
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
if (cancelled) return;
setValueKind(vk);
setOperators(ops);
const valid = ops.some((o) => o.id === cond.operator);
if (!valid && ops.length > 0) {
const first = ops[0];
onChange({
type: 'condition',
operator: first.id,
value: first.needsValue ? cond.value ?? '' : undefined,
});
}
};
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => {
applyMeta(meta.valueKind, meta.operators);
})
.catch(() => {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset operators when Item ref changes
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const currentOp = operators.find((o) => o.id === cond.operator) ?? operators[0];
const needsValue = currentOp?.needsValue ?? true;
const valueInput = currentOp?.valueInput;
const setCondition = (next: StructuredCondition) => {
onChange(next);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst ein Item im Data Picker wählen')}
</p>
</div>
);
}
const handleOperatorChange = (opId: string) => {
const opDef = operators.find((o) => o.id === opId);
setCondition({
type: 'condition',
operator: opId,
value: opDef?.needsValue ? cond.value ?? '' : undefined,
});
};
const handleValueChange = (v: string | number) => {
const kind = valueInput?.kind;
const parsed =
kind === 'number' || valueKind === 'number' ? parseFloat(String(v)) || 0 : String(v);
setCondition({ type: 'condition', operator: cond.operator, value: parsed });
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<ConditionRow>
<label>{t('Vergleich')}</label>
<select
value={cond.operator}
onChange={(e) => handleOperatorChange(e.target.value)}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</ConditionRow>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>{t('Lade Operatoren…')}</div>
)}
{needsValue && (
<ConditionRow>
<label>{t('Wert')}</label>
{valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime' ? (
<select
value={String(cond.value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
type={
valueInput?.kind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(cond.value ?? '')}
onChange={(e) =>
handleValueChange(
valueInput?.kind === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
)
}
placeholder={
valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert eingeben')
}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
</ConditionRow>
)}
</div>
);
};
const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6, fontSize: 12 }}>
{children}
</div>
);

View file

@ -0,0 +1,372 @@
/**
* One place to configure context.setContext rows: target key, then either
* upstream picker, a fixed literal, or a human task.
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
type ValueSource = 'pickUpstream' | 'literal' | 'humanTask';
export interface ContextAssignmentRow {
contextKey: string;
valueSource: ValueSource;
/** Single resolved ref (server resolves { type: ref } to a value). */
upstreamRef?: DataRef | SystemVarRef | null;
/** Optional dotted path under the picked value, or under the wire payload (expert). */
sourcePath?: string;
literal?: string;
taskTitle?: string;
taskDescription?: string;
mode?: 'set' | 'setIfEmpty' | 'append' | 'increment';
valueType?: string;
}
function defaultRow(): ContextAssignmentRow {
return {
contextKey: '',
valueSource: 'literal',
literal: '',
mode: 'set',
valueType: 'str',
};
}
function legacyEntryToRow(
e: Record<string, unknown>,
globalPick: unknown,
): ContextAssignmentRow {
const am = String(e.assignmentMode || 'direct');
let valueSource: ValueSource = 'literal';
if (am === 'fromUpstream') valueSource = 'pickUpstream';
else if (am === 'humanTask') valueSource = 'humanTask';
const sourcePathStr = typeof e.sourcePath === 'string' ? e.sourcePath : '';
let upstream: DataRef | SystemVarRef | undefined;
if (isRef(e.upstreamRef) || isSystemVar(e.upstreamRef)) {
upstream = e.upstreamRef as DataRef | SystemVarRef;
} else if (
am === 'fromUpstream' &&
!sourcePathStr.trim() &&
(isRef(globalPick) || isSystemVar(globalPick))
) {
upstream = globalPick as DataRef | SystemVarRef;
}
return {
contextKey: typeof e.contextKey === 'string' ? e.contextKey : typeof e.key === 'string' ? e.key : '',
valueSource,
upstreamRef: upstream,
sourcePath: sourcePathStr,
literal: e.literal != null ? String(e.literal) : e.value != null ? String(e.value) : '',
taskTitle: typeof e.taskTitle === 'string' ? e.taskTitle : '',
taskDescription: typeof e.taskDescription === 'string' ? e.taskDescription : '',
mode: (e.mode as ContextAssignmentRow['mode']) || 'set',
valueType: typeof e.valueType === 'string' ? e.valueType : typeof e.type === 'string' ? e.type : 'str',
};
}
function normalizeRows(raw: unknown, allParams?: Record<string, unknown>): ContextAssignmentRow[] {
if (Array.isArray(raw) && raw.length > 0) {
return raw.map((r) => {
if (!r || typeof r !== 'object') return defaultRow();
const o = r as Record<string, unknown>;
let valueSource = o.valueSource as ValueSource | undefined;
if (!valueSource && o.assignmentMode === 'fromUpstream') valueSource = 'pickUpstream';
else if (!valueSource && o.assignmentMode === 'humanTask') valueSource = 'humanTask';
else if (!valueSource) valueSource = 'literal';
return {
contextKey: typeof o.contextKey === 'string' ? o.contextKey : typeof o.key === 'string' ? o.key : '',
valueSource,
upstreamRef: (isRef(o.upstreamRef) || isSystemVar(o.upstreamRef) ? o.upstreamRef : undefined) as
| DataRef
| SystemVarRef
| undefined,
sourcePath: typeof o.sourcePath === 'string' ? o.sourcePath : '',
literal: o.literal != null ? String(o.literal) : o.value != null ? String(o.value) : '',
taskTitle: typeof o.taskTitle === 'string' ? o.taskTitle : '',
taskDescription: typeof o.taskDescription === 'string' ? o.taskDescription : '',
mode: (o.mode as ContextAssignmentRow['mode']) || 'set',
valueType: typeof o.valueType === 'string' ? o.valueType : typeof o.type === 'string' ? o.type : 'str',
};
});
}
const g = allParams;
if (g && Array.isArray(g.entries) && g.entries.length > 0) {
const globalPick = g.upstreamPick;
return (g.entries as Record<string, unknown>[]).map((e) => legacyEntryToRow(e, globalPick));
}
if (g) {
const tk = String(g.targetKey || '').trim();
const globalPick = g.upstreamPick;
if (
tk &&
globalPick !== undefined &&
globalPick !== null &&
!(typeof globalPick === 'string' && !globalPick.trim()) &&
!(typeof globalPick === 'object' && globalPick !== null && Object.keys(globalPick).length === 0)
) {
const ups =
isRef(globalPick) || isSystemVar(globalPick) ? (globalPick as DataRef | SystemVarRef) : undefined;
return [
{
contextKey: tk,
valueSource: 'pickUpstream' as const,
upstreamRef: ups,
sourcePath: '',
literal: '',
taskTitle: '',
taskDescription: '',
mode: 'set',
valueType: 'str',
},
];
}
}
return [defaultRow()];
}
const MODES: Array<{ id: NonNullable<ContextAssignmentRow['mode']>; labelDe: string }> = [
{ id: 'set', labelDe: 'setzen' },
{ id: 'setIfEmpty', labelDe: 'setzen wenn leer' },
{ id: 'append', labelDe: 'anhängen' },
{ id: 'increment', labelDe: 'addieren' },
];
const TYPES = ['str', 'int', 'float', 'bool', 'object', 'list'] as const;
const ROW_BOX: React.CSSProperties = {
border: '1px solid #ddd',
borderRadius: 6,
padding: 8,
marginBottom: 8,
background: '#fafafa',
};
const CHIP_STYLE: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginTop: 4,
};
const REMOVE_BTN: React.CSSProperties = {
padding: '0 6px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
marginLeft: 'auto',
};
export const ContextAssignmentsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const rows = normalizeRows(value, allParams);
const [pickerRow, setPickerRow] = React.useState<number | null>(null);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const setRows = (next: ContextAssignmentRow[]) => {
onChange(next.length ? next : [defaultRow()]);
};
const setRow = (idx: number, patch: Partial<ContextAssignmentRow>) => {
const next = [...rows];
next[idx] = { ...next[idx], ...patch };
setRows(next);
};
const addRow = () => setRows([...rows, defaultRow()]);
const removeRow = (idx: number) => {
if (rows.length <= 1) {
onChange([defaultRow()]);
return;
}
setRows(rows.filter((_, i) => i !== idx));
};
const labelForRef = (ref: DataRef | SystemVarRef): string => {
if (isSystemVar(ref)) {
return t('System') + `: ${ref.variable}`;
}
const nodeLabel =
dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
) ?? ref.nodeId;
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
return pathStr ? `${nodeLabel}${pathStr}` : nodeLabel;
};
const onPickRef = (idx: number, picked: DataRef | SystemVarRef) => {
if (!isRef(picked) && !isSystemVar(picked)) return;
setRow(idx, { upstreamRef: picked });
setPickerRow(null);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{rows.map((row, idx) => (
<div key={idx} style={ROW_BOX}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="text"
placeholder={t('Ziel-Schlüssel im Kontext')}
value={row.contextKey}
onChange={(e) => setRow(idx, { contextKey: e.target.value })}
style={{ flex: '2 1 140px', minWidth: 120, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<select
value={row.valueSource}
onChange={(e) => {
const vs = e.target.value as ValueSource;
const patch: Partial<ContextAssignmentRow> = { valueSource: vs };
if (vs === 'literal') patch.upstreamRef = undefined;
if (vs === 'pickUpstream') patch.literal = '';
setRow(idx, patch);
}}
style={{ flex: '1 1 160px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="pickUpstream">{t('Wert aus Daten-Picker')}</option>
<option value="literal">{t('Fester Wert')}</option>
<option value="humanTask">{t('Benutzer setzt Wert (Task)')}</option>
</select>
<select
value={row.mode || 'set'}
onChange={(e) => setRow(idx, { mode: e.target.value as ContextAssignmentRow['mode'] })}
style={{ flex: '1 1 120px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{MODES.map((m) => (
<option key={m.id} value={m.id}>
{m.labelDe}
</option>
))}
</select>
<select
value={row.valueType || 'str'}
onChange={(e) => setRow(idx, { valueType: e.target.value })}
style={{ flex: '0 1 90px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{tp}
</option>
))}
</select>
<button type="button" style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }} onClick={() => removeRow(idx)}>
×
</button>
</div>
{row.valueSource === 'pickUpstream' && (
<div>
{row.upstreamRef && (isRef(row.upstreamRef) || isSystemVar(row.upstreamRef)) && (
<div style={CHIP_STYLE}>
<span style={{ flex: 1, color: '#2d6a2d' }}>{labelForRef(row.upstreamRef)}</span>
<button type="button" style={REMOVE_BTN} onClick={() => setRow(idx, { upstreamRef: undefined })} title={t('Entfernen')}>
×
</button>
</div>
)}
<button
type="button"
onClick={() => setPickerRow(idx)}
disabled={!hasSources}
style={{
marginTop: 4,
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #1c5fb5',
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{hasSources ? t('Datenquelle wählen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
<input
type="text"
placeholder={t('Optional: Zusatz-Pfad (z. B. payload.status)')}
value={row.sourcePath || ''}
onChange={(e) => setRow(idx, { sourcePath: e.target.value })}
style={{ width: '100%', marginTop: 6, padding: '4px 6px', borderRadius: 4, border: '1px dashed #aaa', fontSize: 11 }}
/>
</div>
)}
{row.valueSource === 'literal' && (
<input
type="text"
placeholder={t('Wert (oder JSON für object/list)')}
value={row.literal ?? ''}
onChange={(e) => setRow(idx, { literal: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
{row.valueSource === 'humanTask' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
type="text"
placeholder={t('Titel der Aufgabe (optional)')}
value={row.taskTitle || ''}
onChange={(e) => setRow(idx, { taskTitle: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<textarea
placeholder={t('Beschreibung für den Bearbeiter (optional)')}
value={row.taskDescription || ''}
onChange={(e) => setRow(idx, { taskDescription: e.target.value })}
rows={2}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
/>
</div>
)}
</div>
))}
<button type="button" onClick={addRow} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>
{t('Zuweisung hinzufügen')}
</button>
{dataFlow && pickerRow != null && (
<DataPicker
open
onClose={() => setPickerRow(null)}
onPick={(picked) => onPickRef(pickerRow, picked)}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType="Any"
/>
)}
</div>
);
};

View file

@ -0,0 +1,267 @@
/**
* userFileFolder FormGeneratorTree embedded: combobox-style trigger + expandable tree.
*/
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { FaFolderPlus } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { usePrompt } from '../../../../hooks/usePrompt';
import { getFolderTree, createFolder } from '../../../../api/fileApi';
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
import type { FieldRendererProps } from './index';
export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => {
const { t } = useLanguage();
const { prompt, PromptDialog } = usePrompt();
const [panelOpen, setPanelOpen] = useState(false);
/** Remount embedded tree after create/rename elsewhere */
const [treeRefreshKey, setTreeRefreshKey] = useState(0);
const [creating, setCreating] = useState(false);
/** Display name for saved folderId (resolved from API when graph loads). */
const [pickedName, setPickedName] = useState<string | null>(null);
const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
const strVal = typeof value === 'string' ? value : '';
const rootSelected = strVal === '';
useEffect(() => {
if (!strVal) {
setPickedName(null);
return;
}
if (!request) return;
let cancelled = false;
getFolderTree(request, 'me')
.then((folders) => {
if (cancelled) return;
const f = folders.find((x) => x.id === strVal);
setPickedName(f?.name ?? null);
})
.catch(() => {
if (!cancelled) setPickedName(null);
});
return () => {
cancelled = true;
};
}, [strVal, request]);
const handleNodeClick = useCallback(
(node: TreeNode) => {
if (node.type === 'folder') {
setPickedName(node.name);
onChange(node.id);
setPanelOpen(false);
}
},
[onChange],
);
const clearFolder = useCallback(() => {
onChange('');
setPickedName(null);
}, [onChange]);
const triggerLabel = strVal ? (pickedName ?? '…') : t('Wähle einen Zielordner');
const handleCreateFolder = useCallback(async () => {
if (!request || creating) return;
const parentHint = strVal && pickedName ? ` („${pickedName}“)` : strVal ? '' : ' (Stamm)';
const entered = await prompt(`Ordnername${parentHint}:`, {
title: 'Neuer Ordner',
placeholder: 'Ordnername',
confirmLabel: t('Anlegen'),
});
const trimmed = entered?.trim();
if (!trimmed) return;
setCreating(true);
try {
const parentId = strVal || null;
const folder = await createFolder(request, trimmed, parentId);
setPickedName(folder.name);
onChange(folder.id);
setTreeRefreshKey((k) => k + 1);
} catch {
// stay silent in minimal UI; devtools / global handler may log
} finally {
setCreating(false);
}
}, [request, creating, strVal, pickedName, prompt, onChange, t]);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{!request && (
<div style={{ fontSize: 11, color: '#888' }}>{t('Ordnerliste nicht verfügbar (keine API-Anbindung).')}</div>
)}
{request && (
<>
<div
style={{
display: 'flex',
width: '100%',
alignItems: 'stretch',
borderRadius: 6,
border: '1px solid var(--color-border, #cbd5e1)',
background: 'var(--table-header-bg, #f1f5f9)',
overflow: 'hidden',
marginBottom: panelOpen ? 6 : 0,
}}
>
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 12,
textAlign: 'left',
color: 'var(--color-text, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{triggerLabel}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Zielordner entfernen (Stamm — Meine Dateien)')}
aria-label={t('Zielordner entfernen')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearFolder();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid var(--color-border, #cbd5e1)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--color-text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && (
<div
style={{
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--color-bg, #fff)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'stretch',
borderBottom: '1px solid var(--color-border, #e2e8f0)',
background: 'var(--table-header-bg, #f8fafc)',
}}
>
<div
role="button"
tabIndex={0}
onClick={() => {
clearFolder();
setPanelOpen(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearFolder();
setPanelOpen(false);
}
}}
style={{
flex: 1,
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
minHeight: 36,
background: rootSelected ? 'rgba(37, 99, 235, 0.12)' : 'transparent',
}}
>
{t('Stamm — Meine Dateien')}
</div>
<button
type="button"
aria-label={t('Neuen Ordner erstellen')}
title={
creating
? t('Wird angelegt…')
: strVal
? `Unterordner von: ${pickedName ?? '…'}`
: 'Unter dem Stamm (oberste Ebene)'
}
disabled={creating}
onClick={(e) => {
e.stopPropagation();
void handleCreateFolder();
}}
style={{
flexShrink: 0,
width: 40,
minHeight: 36,
alignSelf: 'stretch',
border: 'none',
borderLeft: '1px solid var(--color-border, #e2e8f0)',
background: 'transparent',
cursor: creating ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--primary-color, #2563eb)',
opacity: creating ? 0.5 : 1,
}}
>
<FaFolderPlus size={14} aria-hidden />
</button>
</div>
<FormGeneratorTree
key={`user-folder-tree-${treeRefreshKey}`}
provider={provider}
ownership="own"
compact
allowCreateFolder={false}
showFilter={false}
emptyMessage={t('Noch keine Ordner')}
onNodeClick={handleNodeClick}
embedMaxHeight={240}
hideRowActionButtons
hideSectionHeader
enableDragDrop
/>
</div>
)}
<PromptDialog />
</>
)}
</div>
);
};

View file

@ -8,6 +8,12 @@ import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from '../form/formFieldOptionsUtils';
export interface FieldRendererProps {
param: NodeTypeParameter;
@ -17,6 +23,10 @@ export interface FieldRendererProps {
instanceId?: string;
request?: ApiRequestFunction;
nodeType?: string;
/** Atomically merge several parameter keys (e.g. cron + schedule). */
onPatchParams?: (patch: Record<string, unknown>) => void;
/** Hide the prominent ``param.name`` line (e.g. Accordion header already shows it). */
hideAccordionTitle?: boolean;
}
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
@ -26,6 +36,13 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
// ---------------------------------------------------------------------------
import React from 'react';
import { SchedulePlanner } from '../../../SchedulePlanner';
import {
buildCronFromSpec,
scheduleSpecFromParams,
scheduleSpecToPersistentJson,
type ScheduleSpec,
} from '../../../../utils/scheduleCron';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils';
@ -33,7 +50,11 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { ConditionEditor } from './ConditionEditor';
import { CaseListEditor } from './CaseListEditor';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config';
@ -98,29 +119,145 @@ const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) =>
</div>
);
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] =
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
/** Backend may send `options: ["a","b"]` or `options: [{ value, label }, ...]` (e.g. context.extractContent). */
function _normalizedSelectOptions(raw: unknown): Array<{ value: string; label: string }> {
if (!Array.isArray(raw)) return [];
const out: Array<{ value: string; label: string }> = [];
for (const item of raw) {
if (typeof item === 'string') {
out.push({ value: item, label: item });
continue;
}
if (item && typeof item === 'object' && 'value' in item) {
const rec = item as { value?: unknown; label?: unknown };
if (typeof rec.value === 'string') {
const label = typeof rec.label === 'string' && rec.label.length > 0 ? rec.label : rec.value;
out.push({ value: rec.value, label });
}
}
}
return out;
}
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange, hideAccordionTitle }) => {
const { t } = useLanguage();
const options = _normalizedSelectOptions(
param.frontendOptions?.options ?? param.options ?? []
);
const allowClear = !param.required;
const current = value === undefined || value === null || value === '' ? '' : String(value);
const groupId = `select-segment-${param.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
const titleId = `${groupId}-title`;
const descId = `${groupId}-desc`;
const showNameLine = !hideAccordionTitle;
const labelledBy = showNameLine
? param.description
? `${titleId} ${descId}`
: titleId
: param.description
? descId
: undefined;
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
{showNameLine ? (
<div
id={titleId}
style={{
display: 'block',
fontSize: 12,
fontWeight: 700,
marginBottom: param.description ? 4 : 6,
color: 'var(--text-primary, #212529)',
letterSpacing: '0.01em',
}}
>
{param.name}
</div>
) : null}
{param.description ? (
<div
id={descId}
style={{
display: 'block',
fontSize: 12,
fontWeight: 400,
lineHeight: 1.35,
marginBottom: 6,
color: 'var(--text-secondary, #555)',
}}
>
{param.description}
</div>
) : null}
<div
role="radiogroup"
aria-labelledby={labelledBy ?? undefined}
aria-label={!labelledBy ? param.name : undefined}
aria-required={param.required ? true : undefined}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
alignItems: 'stretch',
}}
>
<option value=""></option>
{options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
{options.map((opt) => {
const selected = current === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
title={
allowClear && selected
? t('Erneut klicken, um die Auswahl aufzuheben')
: undefined
}
onClick={() => {
if (allowClear && selected) {
onChange(undefined);
} else {
onChange(opt.value);
}
}}
style={{
flex: '1 1 auto',
minWidth: 'min(100%, 72px)',
maxWidth: '100%',
textAlign: 'center',
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.25,
borderRadius: 6,
border: selected
? '2px solid var(--primary-color, #0d6efd)'
: '1px solid var(--border-color, #ccc)',
background: selected
? 'var(--primary-soft-bg, rgba(13, 110, 253, 0.12))'
: 'var(--panel-subtle-bg, #f8f9fa)',
color: selected
? 'var(--primary-color, #0a58ca)'
: 'var(--text-primary, #212529)',
fontWeight: selected ? 600 : 400,
cursor: 'pointer',
boxShadow: selected ? 'inset 0 0 0 1px rgba(13, 110, 253, 0.15)' : 'none',
transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
}}
>
{opt.label}
</button>
);
})}
</div>
</div>
);
};
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] =
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
const options = _normalizedSelectOptions(
param.frontendOptions?.options ?? param.options ?? []
);
const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) => {
const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt];
@ -131,9 +268,9 @@ const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{options.map((opt) => (
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} />
{opt}
<label key={opt.value} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt.label}
</label>
))}
</div>
@ -503,37 +640,6 @@ const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onCh
);
};
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cases = Array.isArray(value) ? value : [];
const addCase = () => onChange([...cases, { operator: 'eq', value: '' }]);
const removeCase = (idx: number) => onChange(cases.filter((_: unknown, i: number) => i !== idx));
const updateCase = (idx: number, field: string, val: unknown) => {
const next = [...cases];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{cases.map((c: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<select value={String(c.operator || 'eq')} onChange={(e) => updateCase(i, 'operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="contains">{t('enthält')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
</select>
<input type="text" value={String(c.value ?? '')} onChange={(e) => updateCase(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<button onClick={() => removeCase(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
<button onClick={addCase} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Fall hinzufügen')}</button>
</div>
);
};
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
@ -541,13 +647,35 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = Array.isArray(value) ? value : [];
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]);
const addField = () => {
const idx = fields.length;
onChange([
...fields,
{ name: deriveFormFieldPayloadKey('', idx), type: 'text', label: '', required: false },
]);
};
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
const updateField = (idx: number, field: string, val: unknown) => {
const next = [...fields];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
const setFieldLabel = (idx: number, label: string) => {
const next = [...fields];
const row = { ...(next[idx] as Record<string, unknown>), label, name: deriveFormFieldPayloadKey(label, idx) };
next[idx] = row;
onChange(next);
};
const setTopFieldType = (idx: number, typeId: string) => {
const next = [...fields];
const cur = { ...(next[idx] as Record<string, unknown>) };
cur.type = typeId;
if (formFieldTypeHasConfigurableOptions(typeId)) {
cur.options = normalizeFormFieldOptions(cur.options);
}
next[idx] = cur;
onChange(next);
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff',
@ -565,7 +693,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
type="text"
placeholder={t('Bezeichnung (Anzeigename)')}
value={String(f.label ?? '')}
onChange={(e) => updateField(i, 'label', e.target.value)}
onChange={(e) => setFieldLabel(i, e.target.value)}
style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
/>
<button
@ -575,21 +703,11 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
>×</button>
</div>
{/* Row 2: Name + Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
<input
type="text"
placeholder="z.B. customerName"
value={String(f.name ?? '')}
onChange={(e) => updateField(i, 'name', e.target.value)}
style={inputStyle}
/>
</div>
{/* Row 2: Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div>
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={selectStyle}>
<select value={String(f.type ?? 'text')} onChange={(e) => setTopFieldType(i, e.target.value)} style={selectStyle}>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
@ -601,6 +719,14 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
Pflicht
</label>
</div>
{formFieldTypeHasConfigurableOptions(String(f.type)) ? (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => updateField(i, 'options', opts)}
/>
</div>
) : null}
{String(f.type) === 'group' && (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
@ -609,11 +735,16 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="text"
placeholder={t('Name')}
value={String(sub.name ?? '')}
placeholder={t('Bezeichnung')}
value={String(sub.label ?? sub.name ?? '')}
onChange={(e) => {
const label = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, name: e.target.value };
nextFields[j] = {
...sub,
label,
name: deriveFormFieldPayloadKey(label, j),
};
updateField(i, 'fields', nextFields);
}}
style={{ ...inputStyle, flex: 1 }}
@ -621,8 +752,13 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<select
value={String(sub.type ?? 'text')}
onChange={(e) => {
const typeId = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, type: e.target.value };
const subRow = { ...(nextFields[j] as Record<string, unknown>), type: typeId };
if (formFieldTypeHasConfigurableOptions(typeId)) {
subRow.options = normalizeFormFieldOptions(subRow.options);
}
nextFields[j] = subRow;
updateField(i, 'fields', nextFields);
}}
style={{ ...selectStyle, flex: 1 }}
@ -640,12 +776,31 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
>×</button>
</div>
{formFieldTypeHasConfigurableOptions(String(sub.type)) ? (
<div style={{ marginTop: 6 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(sub.options)}
onChange={(opts) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, options: opts };
updateField(i, 'fields', nextFields);
}}
/>
</div>
) : null}
</div>
))}
<button
type="button"
onClick={() => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
const j = nextFields.length;
nextFields.push({
name: deriveFormFieldPayloadKey('', j),
type: 'text',
label: '',
required: false,
});
updateField(i, 'fields', nextFields);
}}
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
@ -692,47 +847,38 @@ const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
);
};
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, onPatchParams }) => {
const spec = React.useMemo(
() =>
scheduleSpecFromParams({
...(allParams ?? {}),
cron:
(typeof value === 'string' && value
? value
: typeof allParams?.cron === 'string'
? allParams.cron
: '') as string,
} as Record<string, unknown>),
[allParams, value]
);
const handlePlanner = React.useCallback(
(next: ScheduleSpec) => {
const cron = buildCronFromSpec(next);
const schedule = scheduleSpecToPersistentJson(next);
if (onPatchParams) onPatchParams({ cron, schedule });
else onChange(cron);
},
[onChange, onPatchParams]
);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={t('0 9 * * *')}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
/>
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
<SchedulePlanner value={spec} onChange={handlePlanner} />
</div>
);
};
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val });
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
<option value="contains">{t('enthält')}</option>
<option value="empty">{t('ist leer')}</option>
<option value="not_empty">{t('ist nicht leer')}</option>
<option value="is_true">{t('ist wahr')}</option>
<option value="is_false">{t('ist falsch')}</option>
</select>
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);
};
const ConditionBuilder = ConditionEditor;
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
@ -913,10 +1059,12 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
hidden: HiddenInput,
dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer,
contextAssignments: ContextAssignmentsEditor,
userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker,
clickupList: FolderPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,

View file

@ -1,154 +0,0 @@
/**
* 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 '../shared/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';
import { useLanguage } from '../../../../providers/language/LanguageContext';
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 { t } = useLanguage();
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>{t('Datenquelle')}</label>
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('Formularfeld 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>{t('Wert')}</label>
{mimeTypeOptions.length > 0 ? (
<select
value={String(value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
>
<option value="">{t('MIME-Typ 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
? t('z.B. application/pdf')
: t('z.B. ch')
}
/>
)}
</div>
)}
</div>
);
};

View file

@ -1 +1,2 @@
export { IfElseNodeConfig } from './IfElseNodeConfig';
export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor';
export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor';

View file

@ -1,296 +0,0 @@
/**
* User-friendly schedule cron
* Standard: 5 Felder (minute hour dom month dow), DOW 0=So 6=Sa
* Intervall Sekunden: 6 Felder (sec min hour dom month dow)
*/
export type ScheduleMode = 'daily' | 'weekdays' | 'weekly' | 'calendar' | 'interval';
export type CalendarPeriod = 'monthly' | 'yearly';
/** sek, min, h, T (Tage), a (Jahre) — Cron nur näherungsweise für T/a */
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
export interface ScheduleSpec {
mode: ScheduleMode;
hour: number;
minute: number;
/** 06, cron DOW; nur bei mode === 'weekly' */
weekdays: number[];
/** Monatlich: Tag 131; Jährlich: Tag im gewählten Monat */
monthDay: number;
/** 112, nur bei calendar + yearly */
monthIndex: number;
calendarPeriod: CalendarPeriod;
intervalValue: number;
intervalUnit: IntervalUnit;
}
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
/** Anzeige MoSo (cronDow wie oben) */
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
{ cronDow: 1, label: 'Mo' },
{ cronDow: 2, label: 'Di' },
{ cronDow: 3, label: 'Mi' },
{ cronDow: 4, label: 'Do' },
{ cronDow: 5, label: 'Fr' },
{ cronDow: 6, label: 'Sa' },
{ cronDow: 0, label: 'So' },
];
export function defaultScheduleSpec(): ScheduleSpec {
return {
mode: 'daily',
hour: 8,
minute: 0,
weekdays: [1, 2, 3, 4, 5],
monthDay: 1,
monthIndex: 1,
calendarPeriod: 'monthly',
intervalValue: 15,
intervalUnit: 'minutes',
};
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
export function buildCronFromSpec(spec: ScheduleSpec): string {
const m = clamp(Math.floor(spec.minute), 0, 59);
const h = clamp(Math.floor(spec.hour), 0, 23);
switch (spec.mode) {
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
case 'weekly': {
const days = [...new Set(spec.weekdays)]
.filter((d) => d >= 0 && d <= 6)
.sort((a, b) => {
const order = (x: number) => (x === 0 ? 7 : x);
return order(a) - order(b);
});
if (days.length === 0) return `${m} ${h} * * 1`;
return `${m} ${h} * * ${days.join(',')}`;
}
case 'calendar': {
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
if (spec.calendarPeriod === 'monthly') {
return `${m} ${h} ${dom} * *`;
}
const month = clamp(Math.floor(spec.monthIndex), 1, 12);
return `${m} ${h} ${dom} ${month} *`;
}
case 'interval': {
const v = Math.max(1, Math.floor(spec.intervalValue));
switch (spec.intervalUnit) {
case 'seconds': {
const s = clamp(v, 1, 59);
return `*/${s} * * * * *`;
}
case 'minutes': {
const mm = clamp(v, 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(v, 1, 23);
return `0 */${hh} * * *`;
}
case 'days': {
if (v <= 1) return `0 0 * * *`;
const d = clamp(v, 2, 31);
return `0 0 */${d} * *`;
}
case 'years':
default:
// Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
return `0 0 1 1 *`;
}
}
default:
return `${m} ${h} * * *`;
}
}
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
if (!cron || typeof cron !== 'string') return null;
const p = cron.trim().split(/\s+/);
if (p.length === 6) {
const [secS, minS, hourS, domS, monthS, dowS] = p;
if (
secS.startsWith('*/') &&
minS === '*' &&
hourS === '*' &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(secS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'seconds',
minute: 0,
hour: 0,
};
}
}
return null;
}
if (p.length < 5) return null;
const [minS, hourS, domS, monthS, dowS] = p;
const minute = parseInt(minS, 10);
const hour = parseInt(hourS, 10);
if (Number.isNaN(minute) || Number.isNaN(hour)) return null;
if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
const iv = parseInt(minS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'minutes',
minute: 0,
hour: 0,
};
}
}
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'hours',
minute: 0,
hour: 0,
};
}
}
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'days',
minute: 0,
hour: 0,
};
}
}
if (domS === '*' && dowS === '*') {
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
}
if (domS === '*' && dowS === '1-5') {
return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute };
}
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
const parts = dowS.split(',').map((x) => parseInt(x.trim(), 10));
const days = parts.filter((x) => !Number.isNaN(x) && x >= 0 && x <= 7);
if (days.length > 0) {
const norm = days.map((d) => (d === 7 ? 0 : d));
return {
...defaultScheduleSpec(),
mode: 'weekly',
hour,
minute,
weekdays: norm,
};
}
}
const dom = parseInt(domS, 10);
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'monthly',
hour,
minute,
monthDay: dom,
};
}
if (
!Number.isNaN(dom) &&
dom >= 1 &&
dom <= 31 &&
!Number.isNaN(month) &&
month >= 1 &&
month <= 12 &&
(dowS === '*' || dowS === '?')
) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'yearly',
hour,
minute,
monthDay: dom,
monthIndex: month,
};
}
return null;
}
const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval'];
function normalizeIntervalUnit(u: unknown): IntervalUnit {
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
return 'minutes';
}
/** Liest Spec aus Node-Parametern (schedule-Objekt bevorzugt, sonst Cron parsen) */
export function scheduleSpecFromParams(params: Record<string, unknown>): ScheduleSpec {
const raw = params.schedule;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const o = raw as Record<string, unknown>;
let mode = o.mode as string;
if (mode === 'monthly') {
mode = 'calendar';
}
if (VALID_MODES.includes(mode as ScheduleMode)) {
const base = defaultScheduleSpec();
let calendarPeriod: CalendarPeriod = base.calendarPeriod;
if (mode === 'calendar') {
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
}
return {
mode: mode as ScheduleMode,
hour: clamp(Number(o.hour) || base.hour, 0, 23),
minute: clamp(Number(o.minute) || base.minute, 0, 59),
weekdays: Array.isArray(o.weekdays)
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
: base.weekdays,
monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31),
monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12),
calendarPeriod,
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
intervalUnit: normalizeIntervalUnit(o.intervalUnit),
};
}
}
const cron = typeof params.cron === 'string' ? params.cron : '';
return parseCronToSpec(cron) ?? defaultScheduleSpec();
}

View file

@ -1,222 +0,0 @@
/**
* 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/workflowApi';
import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
import { getLabel } from '../shared/utils';
export const CANVAS_START_NODE_ID = 'start';
/** Primary entry is always the first invocation (gear configures index 0). */
export function getPrimaryEntry(invocations: WorkflowEntryPoint[] | undefined): WorkflowEntryPoint | undefined {
return invocations?.[0];
}
/** Kind of the primary entry (drives canvas node type) */
export function getPrimaryStartKind(invocations: WorkflowEntryPoint[] | undefined): string {
return getPrimaryEntry(invocations)?.kind ?? 'manual';
}
function entryTitle(entry: WorkflowEntryPoint | undefined, language: string): string {
if (!entry?.title) return '';
const t = entry.title;
if (typeof t === 'string') return t.trim();
const s = t[language] || t.de || t.en || Object.values(t)[0];
return (s != null ? String(s) : '').trim();
}
export function mapKindToNodeType(kind: string): string {
if (kind === 'form') return 'trigger.form';
if (kind === 'schedule') return 'trigger.schedule';
// Immer aktiv: zunächst Standard-Start; Listener (E-Mail, Webhook, …) folgt separat
if (kind === 'always_on') return 'trigger.manual';
return 'trigger.manual';
}
function categoryForKind(kind: string): 'on_demand' | 'always_on' {
if (kind === 'manual' || kind === 'form') return 'on_demand';
return 'always_on';
}
function titleForStartNode(
kind: string,
invocations: WorkflowEntryPoint[],
nodeTypes: NodeType[],
language: string
): string {
const custom = entryTitle(getPrimaryEntry(invocations), language);
if (custom) return custom;
const nt = nodeTypes.find((n) => n.id === mapKindToNodeType(kind));
if (nt) return getLabel(nt.label, language);
return 'Start';
}
/** Rewire connections when replacing node ids */
function rewireConnections(
connections: CanvasConnection[],
fromId: string,
toId: string
): CanvasConnection[] {
if (fromId === toId) return connections;
return connections.map((c) => ({
...c,
sourceId: c.sourceId === fromId ? toId : c.sourceId,
targetId: c.targetId === fromId ? toId : c.targetId,
}));
}
/** Deep-rewrite ref.nodeId in parameters (e.g. flow.ifElse condition.ref) */
function rewireRefInParams(params: unknown, fromIds: Set<string>, toId: string): unknown {
if (params == null) return params;
if (typeof params === 'object' && params !== null && 'type' in params && 'nodeId' in params) {
const obj = params as { type?: string; nodeId?: string; path?: unknown };
if (obj.type === 'ref' && typeof obj.nodeId === 'string' && fromIds.has(obj.nodeId)) {
return { ...obj, nodeId: toId };
}
}
if (Array.isArray(params)) {
return params.map((item) => rewireRefInParams(item, fromIds, toId));
}
if (typeof params === 'object' && params !== null) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) {
out[k] = rewireRefInParams(v, fromIds, toId);
}
return out;
}
return params;
}
/** Rewrite refs in all nodes' parameters when trigger id changes */
function rewireRefsInNodes(
nodes: CanvasNode[],
fromIds: Set<string>,
toId: string
): CanvasNode[] {
if (fromIds.size === 0) return nodes;
return nodes.map((n) => {
const p = n.parameters;
if (!p || typeof p !== 'object') return n;
const next = rewireRefInParams(p, fromIds, toId);
if (next === p) return n;
return { ...n, parameters: next as Record<string, unknown> };
});
}
/** Remove duplicate trigger nodes; keep first, merge connections onto it */
function dedupeTriggers(
nodes: CanvasNode[],
connections: CanvasConnection[]
): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
const triggers = nodes.filter((n) => n.type.startsWith('trigger.'));
if (triggers.length <= 1) return { nodes, connections };
const keep = triggers[0];
const removeIds = new Set(triggers.slice(1).map((n) => n.id));
let nextConn = connections;
for (const rid of removeIds) {
nextConn = rewireConnections(nextConn, rid, keep.id);
}
const newNodes = nodes.filter((n) => !removeIds.has(n.id));
return { nodes: newNodes, connections: nextConn };
}
/** Normalize canonical id `start` and update type/labels from primary kind */
export function syncCanvasStartNode(
nodes: CanvasNode[],
connections: CanvasConnection[],
invocations: WorkflowEntryPoint[],
nodeTypes: NodeType[],
language: string
): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
const kind = getPrimaryStartKind(invocations);
const targetType = mapKindToNodeType(kind);
const title = titleForStartNode(kind, invocations, nodeTypes, language);
const nt = nodeTypes.find((n) => n.id === targetType);
const inputs = nt?.inputs ?? 0;
const outputs = nt?.outputs ?? 1;
const triggerIdsBeforeDedupe = new Set(nodes.filter((n) => n.type.startsWith('trigger.')).map((n) => n.id));
let { nodes: ns, connections: cs } = dedupeTriggers(nodes, connections);
let startIdx = ns.findIndex((n) => n.type.startsWith('trigger.'));
if (startIdx === -1) {
const newNode: CanvasNode = {
id: CANVAS_START_NODE_ID,
type: targetType,
x: 100,
y: 120,
title,
label: title,
inputs,
outputs,
color: nt?.meta?.color as string | undefined,
parameters: {},
};
ns = rewireRefsInNodes([newNode, ...ns], triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
return { nodes: ns, connections: cs };
}
const current = ns[startIdx];
const oldId = current.id;
let nextConn = cs;
if (oldId !== CANVAS_START_NODE_ID) {
nextConn = rewireConnections(nextConn, oldId, CANVAS_START_NODE_ID);
}
ns = rewireRefsInNodes(ns, triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
const updated: CanvasNode = {
...current,
id: CANVAS_START_NODE_ID,
type: targetType,
title,
label: title,
inputs,
outputs,
color: nt?.meta?.color as string | undefined,
parameters:
targetType === current.type ? current.parameters ?? {} : preserveParametersForTypeSwitch(current, targetType),
};
const nextNodes = [...ns];
nextNodes[startIdx] = updated;
return { nodes: nextNodes, connections: nextConn };
}
function preserveParametersForTypeSwitch(node: CanvasNode, newType: string): Record<string, unknown> {
const p = node.parameters ?? {};
if (newType === 'trigger.form' && p.formFields) return { formFields: p.formFields };
if (newType === 'trigger.schedule' && (p.cron || p.schedule)) {
const out: Record<string, unknown> = {};
if (p.cron != null) out.cron = p.cron;
if (p.schedule != null) out.schedule = p.schedule;
return out;
}
return {};
}
/** Build invocations: replace primary (index 0), keep further entries (e.g. listener config later). */
export function buildInvocationsForPrimaryKind(
kind: string,
existing: WorkflowEntryPoint[] | undefined,
titleDe: string
): WorkflowEntryPoint[] {
const list = existing ?? [];
const primaryId =
list[0]?.id ??
(typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `ep-${Date.now()}`);
const category = categoryForKind(kind);
const primary: WorkflowEntryPoint = {
id: primaryId,
kind,
category,
enabled: true,
title: { de: titleDe, en: titleDe, fr: titleDe },
description: {},
config: {},
};
const rest = list.slice(1).filter((x) => x.id !== primaryId);
return [primary, ...rest];
}

View file

@ -1,194 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.2 / A1.3
// T7: DataPicker strict-type filtering (only compatible candidates rendered).
// T8: DataPicker generic object drill-down via wildcard '*' segment when the
// schema declares List[X] of a known X.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
import { DataPicker } from './DataPicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
_field('meta', 'str'),
],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.filter((n) => n.id !== opts.consumerNodeId).map((n) => n.id),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRef | SystemVarRef) => void }) {
const upstream = _node('up', 'sharepoint.readDocs');
const consumer = _node('cons', 'ai.summarize');
_setContext({
consumerNodeId: 'cons',
nodes: [upstream, consumer],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [_nodeType('sharepoint.readDocs', 'DocumentList'), _nodeType('ai.summarize', 'AiResult')],
});
return render(
<DataPicker
open
onClose={() => {}}
onPick={props?.onPick ?? (() => {})}
availableSourceIds={['up']}
nodes={[upstream]}
nodeOutputsPreview={{}}
getNodeLabel={(n) => n.title ?? n.id}
expectedParamType={props?.expectedParamType}
/>,
);
}
// ---------------------------------------------------------------------------
// T8: Wildcard drill-down
// ---------------------------------------------------------------------------
describe('DataPicker — generic-object drill-down (T8)', () => {
it('renders the wildcard "documents → * → name" path when drilling into List[UdmDocument]', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument();
expect(screen.getByText(/documents → \* → mimeType/)).toBeInTheDocument();
});
it('lists the wholeOutput, top-level fields, and drilled fields together', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
// Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error).
expect(screen.getAllByText(/documents → \*/).length).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// T7: Strict type filter
// ---------------------------------------------------------------------------
describe('DataPicker — strict type filtering (T7)', () => {
it('hides hard-mismatch fields when expectedParamType is set + strict toggle is on (default)', async () => {
_renderPicker({ expectedParamType: 'str' });
expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked();
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is a hard mismatch → must be hidden.
expect(screen.queryByText('documents')).not.toBeInTheDocument();
// meta (str) is exact match → kept.
expect(screen.getByText('meta')).toBeInTheDocument();
// count (int) is "coerce" against str → kept (coerce is allowed in strict mode).
expect(screen.getByText('count')).toBeInTheDocument();
// Drilled wildcard candidates of type str (name, mimeType) remain.
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument();
});
it('shows all fields after the user disables the strict toggle', async () => {
_renderPicker({ expectedParamType: 'str' });
await userEvent.click(screen.getByLabelText(/Nur kompatible/i));
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
});
it('shows the iterieren-button on List[X] candidates that match expectedParamType=X (T6)', async () => {
_renderPicker({ expectedParamType: 'UdmDocument' });
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is the only candidate with expectedParamType=UdmDocument
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('iterieren')).toBeInTheDocument();
});
it('emits a wildcard ref when the user clicks "iterieren"', async () => {
const onPick = vi.fn();
_renderPicker({ expectedParamType: 'UdmDocument', onPick });
await userEvent.click(screen.getByText(/^up$/));
await userEvent.click(screen.getByText('iterieren'));
expect(onPick).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ref',
nodeId: 'up',
path: ['documents', '*'],
expectedType: 'UdmDocument',
}),
);
});
});

View file

@ -1,16 +1,17 @@
/**
* Automation2 Flow Editor - Schema-based Data Picker.
* Builds pickable paths from portTypeCatalog + node outputPorts.
* Builds pickable paths from portTypeCatalog + node outputPorts, or from
* outputPorts[n].dataPickOptions when the backend defines an explicit list (authoritative).
* Resolves Transit chains to show the real upstream schema.
* Includes a System Variables section.
*/
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi';
import { findLoopAncestorIds } from './scopeHelpers';
import type { DataPickOption, GraphDataSources, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import { fetchGraphDataSources } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -39,14 +40,28 @@ interface PickablePath {
typeMismatch?: boolean;
/** Surfaced at the top of the list as the most common / recommended pick. */
recommended?: boolean;
/** Tooltip (Katalog oder Backend-Hinweistext). */
detail?: string;
}
const _LIST_INNER_RE = /^List\[(.+)\]$/;
function _fieldSegHuman(field: PortField): string {
const picker = field.pickerLabel;
if (typeof picker === 'string' && picker.trim()) return picker.trim();
return field.name;
}
function _detailFromField(description: unknown): string | undefined {
if (typeof description === 'string' && description.trim()) return description.trim();
return undefined;
}
function _buildPathsFromSchema(
schema: PortSchema | undefined,
catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [],
baseSegments: string[] = [],
depth = 0,
): PickablePath[] {
if (!schema || !schema.fields || depth > 8) return [];
@ -64,21 +79,43 @@ function _buildPathsFromSchema(
}
for (const field of schema.fields) {
const segHuman = _fieldSegHuman(field);
const fieldPath = [...basePath, field.name];
const label = fieldPath.map(String).join(' → ');
result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false });
const label =
baseSegments.length > 0
? `${baseSegments.join(' ')} ${segHuman}`
: segHuman;
const detail = _detailFromField(field.description);
result.push({
path: fieldPath,
label,
type: field.type,
recommended: field.recommended ?? false,
detail,
});
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
const inner = m?.[1]?.trim();
if (inner && catalog[inner]) {
// Generic List drill-down: use '*' wildcard so the engine maps each item.
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : '';
const itemBridge = pil || '*';
const nextSegments = [...baseSegments, segHuman, itemBridge];
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], nextSegments, depth + 1));
}
}
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
result.push({
path: [...basePath, '_success'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Erfolgskennzeichen` : '_success',
type: 'bool',
});
result.push({
path: [...basePath, '_error'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Fehlermeldung` : '_error',
type: 'str',
});
return result;
}
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
@ -162,6 +199,18 @@ function _buildPathsFromPreview(
return [{ path: [...basePath], label: pathLabel }];
}
/** Gateway ``outputPorts[n].dataPickOptions`` — authoritative; no client-side catalog merge. */
function _pathsFromDataPickOptions(options: DataPickOption[]): PickablePath[] {
return options.map((o) => ({
path: [...o.path],
label: o.pickerLabel,
type: o.type,
recommended: Boolean(o.recommended),
iterable: Boolean(o.iterable),
detail: typeof o.detail === 'string' ? o.detail.trim() : undefined,
}));
}
function _resolveSchemaForNode(
nodeId: string,
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
@ -227,20 +276,43 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
// other hook) below it would change the hook count when the picker toggles
// open/closed and crash the whole tree (white screen).
const connectionsRaw = ctx?.connections ?? [];
const nodesRaw = ctx?.nodes ?? [];
// sourceHandle is a flat handle index (inputs first, then outputs).
// The backend expects sourceOutput as an output-port index (0-based after inputs).
const nodeInputsById = useMemo(
() => new Map(nodesRaw.map((n) => [n.id, n.inputs ?? 0])),
[nodesRaw],
);
const connections = useMemo(
() =>
connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle,
sourceOutput: c.sourceHandle - (nodeInputsById.get(c.sourceId) ?? 0),
targetInput: c.targetHandle,
})),
[connectionsRaw],
[connectionsRaw, nodeInputsById],
);
const loopAncestorIds = useMemo(() => {
const cid = ctx?.currentNodeId;
if (!cid) return [] as string[];
return findLoopAncestorIds(nodes, connections, cid);
}, [ctx?.currentNodeId, nodes, connections]);
// Fetch scope data from the backend when the picker opens — zero topology logic in JS.
const [scopeData, setScopeData] = useState<GraphDataSources | null>(null);
const scopeFetchKey = useRef<string>('');
useEffect(() => {
if (!open || !ctx?.instanceId || !ctx?.request || !ctx?.currentNodeId) return;
const key = `${ctx.instanceId}:${ctx.currentNodeId}:${connections.length}:${(ctx.nodes ?? []).length}`;
if (scopeFetchKey.current === key) return; // already fetched for this state
scopeFetchKey.current = key;
const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type }));
fetchGraphDataSources(ctx.request, ctx.instanceId, ctx.currentNodeId, nodeShapes, connections)
.then(setScopeData)
.catch(() => setScopeData(null));
}, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]);
// Derived: effective source ids and loop context — use backend result when available,
// fall back to the prop (e.g. in tests or offline).
const effectiveSourceIds = scopeData?.availableSourceIds ?? availableSourceIds;
const portIndexOverrides = scopeData?.portIndexOverrides ?? {};
const loopBodyContextIds = scopeData?.loopBodyContextIds ?? [];
if (!open) return null;
@ -321,18 +393,18 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</div>
<div className={styles.dataPickerBody}>
{/* System Variables Section */}
{loopAncestorIds.length > 0 && (
{loopBodyContextIds.length > 0 && (
<div className={styles.dataPickerNodeSection}>
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
</div>
<div className={styles.dataPickerTree}>
{loopAncestorIds.map((loopId) => {
{loopBodyContextIds.map((loopId) => {
const loopNode = nodes.find((n) => n.id === loopId);
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
const loopSchema = catalog.LoopItem;
const loopPaths = loopSchema
? _buildPathsFromSchema(loopSchema, catalog, [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
? _buildPathsFromSchema(loopSchema, catalog, [], [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
: [
{ path: ['currentItem'], label: 'currentItem', type: 'Any' },
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' },
@ -407,7 +479,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
{/* Node outputs */}
{(() => {
const filteredIds = availableSourceIds.filter((nodeId) => {
const filteredIds = effectiveSourceIds.filter((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
return node?.type !== 'trigger.manual';
});
@ -423,24 +495,35 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId);
const resolvedSchema = _resolveSchemaForNode(
nodeId,
nodes,
nodeTypes,
connections,
catalog,
new Set(),
formTypeToPort,
);
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
// Use the port index the backend says to use (e.g. 1 for loop on Done branch)
const portIdx = portIndexOverrides[nodeId] ?? 0;
const portDef = nodeTypeDef?.outputPorts?.[portIdx];
const backendPick =
portDef?.dataPickOptions &&
Array.isArray(portDef.dataPickOptions) &&
portDef.dataPickOptions.length > 0;
let schemaPaths: PickablePath[];
if (backendPick) {
schemaPaths = _pathsFromDataPickOptions(portDef!.dataPickOptions!);
} else {
const resolvedSchema = _resolveSchemaForNode(
nodeId,
nodes,
nodeTypes,
connections,
catalog,
new Set(),
formTypeToPort,
);
schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
}
const annotated = _markIterableCandidates(
schemaPaths.length > 0
? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType,
);
// Always show all paths; mark mismatches as a visual warning instead of hiding them.
// Recommended entries bubble to the top.
const markedPaths = annotated.map((p) => ({
...p,
typeMismatch:
@ -450,7 +533,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
!p.iterable &&
isCompatible(p.type!, expectedParamType!) === 'mismatch',
}));
const paths = [
const orderedPaths = [
...markedPaths.filter((p) => p.recommended),
...markedPaths.filter((p) => !p.recommended),
];
@ -472,56 +555,55 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{paths.length === 0 && (
{orderedPaths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
{t('(keine Felder verfügbar)')}
</div>
)}
{paths.map((p, i) => {
return (
<div
key={`${p.path.join('.')}-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
{orderedPaths.map((p, i) => (
<div
key={`${p.path.join('.')}-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<button
type="button"
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
style={{ flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)}
title={p.detail || p.label}
>
{p.label}
{p.recommended && (
<span className={styles.dataPickerRecommendedPill}>
{t('Empfohlen')}
</span>
)}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{p.typeMismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
{p.iterable && (
<button
type="button"
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
style={{ flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)}
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
title={t('Pro Element der Liste iterieren (Loop)')}
>
{p.label}
{p.recommended && (
<span className={styles.dataPickerRecommendedPill}>
{t('Empfohlen')}
</span>
)}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{p.typeMismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
{t('iterieren')}
</button>
{p.iterable && (
<button
type="button"
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
title={t('Pro Element der Liste iterieren (Loop)')}
>
{t('iterieren')}
</button>
)}
</div>
);
})}
)}
</div>
))}
</div>
)}
</div>

View file

@ -1,243 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.1: Component-level tests for RequiredAttributePicker.
// Validates the 0/1/N rendering logic that orchestrates DataPicker selection
// + the iterierens-suggestion (T5, T6).
//
// We mock the two consumed contexts (LanguageContext + Automation2DataFlow)
// and the DataPicker child so we can assert on the picker UI in isolation.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
// ---------------------------------------------------------------------------
// Module mocks — must be registered before importing the SUT
// ---------------------------------------------------------------------------
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
vi.mock('./DataPicker', () => ({
DataPicker: (props: {
open: boolean;
onClose: () => void;
onPick: (ref: DataRef | SystemVarRef) => void;
}) => {
if (!props.open) return null;
return (
<div data-testid="mock-data-picker">
<button
type="button"
onClick={() => {
props.onPick({ type: 'ref', nodeId: 'picked', path: [], expectedType: 'DocumentList' });
props.onClose();
}}
>
mock-pick
</button>
<button type="button" onClick={props.onClose}>
mock-close
</button>
</div>
);
},
}));
// SUT imported AFTER mocks (so mocks are applied)
import { RequiredAttributePicker } from './RequiredAttributePicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [_field('documents', 'List[UdmDocument]'), _field('count', 'int')],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [_field('name', 'str'), _field('mimeType', 'str')],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.map((n) => n.id).filter((id) => id !== opts.consumerNodeId),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RequiredAttributePicker — 0/1/N rendering (T5/T6)', () => {
it('shows red "no source" pill when no upstream candidate matches (0-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('cons', 'ai.summarizeDocument')],
connections: [],
nodeTypes: [_nodeType('ai.summarizeDocument', 'AiResult')],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(
screen.getByText(/Keine typkompatible Quelle vorhanden/i),
).toBeInTheDocument();
});
it('shows auto-bind suggestion when exactly one candidate matches (1-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/Vorschlag übernehmen/i)).toBeInTheDocument();
});
it('shows iterieren-suggestion when upstream is List[X] and required is X (T6)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Single document"
expectedType="UdmDocument"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/iterieren/i)).toBeInTheDocument();
});
it('renders bound chip + "Andere wählen" when value is already a DataRef', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
expect(screen.getByText('up')).toBeInTheDocument();
const clearButton = screen.getByTitle(/Bindung entfernen/i);
await userEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith(null);
});
it('opens DataPicker via "Andere wählen" and forwards the picked ref to onChange', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
const otherButton = screen.getByText(/Andere wählen…/i);
await userEvent.click(otherButton);
expect(screen.getByTestId('mock-data-picker')).toBeInTheDocument();
await userEvent.click(screen.getByText('mock-pick'));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ref', nodeId: 'picked', expectedType: 'DocumentList' }),
);
});
});

View file

@ -6,7 +6,7 @@ import React from 'react';
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa';
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
trigger: <FaPlay />,
start: <FaPlay />,
input: <FaUser />,
flow: <FaCodeBranch />,
data: <FaDatabase />,

View file

@ -8,7 +8,7 @@ export const HIDDEN_NODE_IDS = new Set<string>();
/** Default category display order */
export const CATEGORY_ORDER = [
'trigger',
'start',
'input',
'flow',
'data',

View file

@ -12,6 +12,39 @@ import type {
} from '../../../../api/workflowApi';
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
/** Switch: one output per case plus a default (``Sonst``) port. */
export function switchOutputCountFromCases(cases: unknown): number {
const n = Array.isArray(cases) ? cases.length : 0;
return Math.max(1, n + 1);
}
/** Drop edges from switch output ports that no longer exist after case removal. */
export function trimConnectionsForSwitchOutputs(
connections: CanvasConnection[],
nodeId: string,
nodeInputs: number,
outputCount: number
): CanvasConnection[] {
return connections.filter((c) => {
if (c.sourceId !== nodeId) return true;
const outIdx = c.sourceHandle - nodeInputs;
return outIdx >= 0 && outIdx < outputCount;
});
}
export function switchOutputLabel(
node: CanvasNode,
outputIndex: number,
translate: (key: string) => string
): string | undefined {
if (node.type !== 'flow.switch') return undefined;
const cases = (node.parameters?.cases as unknown[]) ?? [];
const caseCount = Array.isArray(cases) ? cases.length : 0;
if (outputIndex < caseCount) return `${translate('Fall')} ${outputIndex + 1}`;
if (outputIndex === caseCount) return translate('Sonst');
return undefined;
}
export function fromApiGraph(
graph: Automation2Graph,
nodeTypes: NodeType[]
@ -26,7 +59,7 @@ export function fromApiGraph(
let outputs = io.outputs;
if (n.type === 'flow.switch') {
const cases = (n.parameters?.cases as unknown[]) ?? [];
outputs = Math.max(1, cases.length);
outputs = switchOutputCountFromCases(cases);
}
const nt = nodeTypes.find((t) => t.id === n.type);
return {

View file

@ -1,318 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1 / FE-Tests
// T5/T6 (RequiredAttributePicker): 0/1/N candidate logic + iterierens-suggestion
// T7 (DataPicker): strict type filtering
// T8 (DataPicker): generic-object drill-down via wildcard segment '*'
//
// We test the pure helpers in paramValidation.ts directly. The component
// pickers are thin shells over these helpers, so covering the helpers covers
// the deterministic core of the binding affordance.
import { describe, expect, it } from 'vitest';
import {
findGraphErrors,
findRequiredErrors,
findSourceCandidates,
isParamBound,
strictlyCompatible,
type SourceCandidate,
} from './paramValidation';
import { createRef, createSystemVar, createValue } from './dataRef';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
function _schema(name: string, fields: PortField[]): PortSchema {
return { name, fields };
}
const _docListSchema: PortSchema = _schema('DocumentList', [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
]);
const _udmDocumentSchema: PortSchema = _schema('UdmDocument', [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
]);
const _aiResultSchema: PortSchema = _schema('AiResult', [
_field('text', 'str'),
_field('tokensUsed', 'int'),
]);
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
AiResult: _aiResultSchema,
};
function _makeNode(id: string, type: string, parameters: Record<string, unknown> = {}): CanvasNode {
return {
id,
type,
title: `${id} (${type})`,
x: 0,
y: 0,
inputs: 1,
outputs: 1,
parameters,
};
}
function _makeConnection(id: string, sourceId: string, targetId: string): CanvasConnection {
return {
id,
sourceId,
sourceHandle: 0,
targetId,
targetHandle: 0,
};
}
function _makeNodeType(
id: string,
outputSchema: string,
parameters: NodeType['parameters'] = [],
): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters,
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// isParamBound
// ---------------------------------------------------------------------------
describe('isParamBound', () => {
it('returns false for null/undefined/empty string', () => {
expect(isParamBound(null)).toBe(false);
expect(isParamBound(undefined)).toBe(false);
expect(isParamBound('')).toBe(false);
});
it('returns true for non-empty string/number/boolean', () => {
expect(isParamBound('hello')).toBe(true);
expect(isParamBound(0)).toBe(true);
expect(isParamBound(false)).toBe(true);
});
it('returns true for a valid DataRef and false for one without nodeId', () => {
expect(isParamBound(createRef('node-1', ['x']))).toBe(true);
expect(isParamBound({ type: 'ref', nodeId: '', path: [] })).toBe(false);
});
it('returns true for a SystemVarRef with a variable name', () => {
expect(isParamBound(createSystemVar('user.id'))).toBe(true);
expect(isParamBound({ type: 'system', variable: '' })).toBe(false);
});
it('treats {type:"value", value:""} as unbound but {value:0} as bound', () => {
expect(isParamBound(createValue(''))).toBe(false);
expect(isParamBound(createValue(0))).toBe(true);
expect(isParamBound(createValue([]))).toBe(false);
expect(isParamBound(createValue(['a']))).toBe(true);
});
it('counts non-empty arrays/objects as bound', () => {
expect(isParamBound([])).toBe(false);
expect(isParamBound([1])).toBe(true);
expect(isParamBound({})).toBe(false);
expect(isParamBound({ k: 1 })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// findRequiredErrors / findGraphErrors
// ---------------------------------------------------------------------------
describe('findRequiredErrors', () => {
it('returns empty when all required params are bound', () => {
const node = _makeNode('n1', 'ai.process', {
aiPrompt: 'hello',
documentList: createRef('upstream', ['documents']),
});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
expect(findRequiredErrors(node, nodeType)).toEqual([]);
});
it('flags every unbound required param with its name + type', () => {
const node = _makeNode('n1', 'ai.process', {});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(2);
expect(errs.map((e) => e.paramName)).toEqual(['aiPrompt', 'documentList']);
});
it('returns empty list when nodeType is unknown', () => {
const node = _makeNode('n1', 'ghost.node');
expect(findRequiredErrors(node, undefined)).toEqual([]);
});
it('skips required params with frontendType="hidden" (UI safety net)', () => {
// Hidden params have no UI surface, so reporting them as
// "Pflichtfeld ohne Quelle" would create a phantom error the user can
// not resolve. They are auto-set by adapters / system defaults.
const node = _makeNode('n1', 'trustee.extractFromFiles', {});
const nodeType = _makeNodeType('trustee.extractFromFiles', 'AiResult', [
{ name: 'prompt', type: 'str', required: true },
{ name: 'systemContext', type: 'str', required: true, frontendType: 'hidden' },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(1);
expect(errs[0]!.paramName).toBe('prompt');
});
});
describe('findGraphErrors', () => {
it('aggregates per-node errors and omits clean nodes', () => {
const cleanNodeType = _makeNodeType('clean.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
]);
const dirtyNodeType = _makeNodeType('dirty.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
{ name: 'p2', type: 'str', required: true },
]);
const nodes: CanvasNode[] = [
_makeNode('clean', 'clean.node', { p1: 'value' }),
_makeNode('dirty', 'dirty.node', { p1: 'set' }),
];
const result = findGraphErrors(nodes, [cleanNodeType, dirtyNodeType]);
expect(Object.keys(result)).toEqual(['dirty']);
expect(result['dirty']!.map((e) => e.paramName)).toEqual(['p2']);
});
});
// ---------------------------------------------------------------------------
// findSourceCandidates — T5/T6/T7/T8 core
// ---------------------------------------------------------------------------
describe('findSourceCandidates', () => {
function _makeFixture() {
const upstreamType = _makeNodeType('sharepoint.readDocs', 'DocumentList');
const consumerType = _makeNodeType('ai.summarize', 'AiResult', [
{ name: 'documentList', type: 'DocumentList', required: true },
]);
const upstream = _makeNode('up', 'sharepoint.readDocs');
const consumer = _makeNode('cons', 'ai.summarize');
const conns = [_makeConnection('c1', 'up', 'cons')];
return { nodes: [upstream, consumer], connections: conns, nodeTypes: [upstreamType, consumerType] };
}
it('returns the whole-output candidate first (path=[]) for the upstream', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'DocumentList',
...f,
portTypeCatalog: _portCatalog,
});
const wholeOutput = candidates.find((c) => c.nodeId === 'up' && c.path.length === 0);
expect(wholeOutput).toBeDefined();
expect(wholeOutput!.type).toBe('DocumentList');
expect(wholeOutput!.compat).toBe('ok');
});
it('drills into List[X] elements via wildcard "*" segment (T8 generic drill-down)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'str',
...f,
portTypeCatalog: _portCatalog,
});
const wildcardCandidate = candidates.find(
(c) =>
c.nodeId === 'up' &&
c.path[0] === 'documents' &&
c.path[1] === '*' &&
c.path[2] === 'name',
);
expect(wildcardCandidate).toBeDefined();
expect(wildcardCandidate!.type).toBe('str');
expect(wildcardCandidate!.compat).toBe('ok');
});
it('marks List[X] → X as iterable (T6 "iterieren"-Vorschlag)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'UdmDocument',
...f,
portTypeCatalog: _portCatalog,
});
const iterable = candidates.find(
(c) => c.nodeId === 'up' && c.path.length === 1 && c.path[0] === 'documents' && c.iterable,
);
expect(iterable).toBeDefined();
expect(iterable!.type).toBe('List[UdmDocument]');
});
it('returns no candidates when no upstream is connected (T5: 0-case)', () => {
const f = _makeFixture();
const isolated = _makeNode('iso', 'ai.summarize');
const candidates = findSourceCandidates({
consumerNodeId: 'iso',
expectedType: 'DocumentList',
nodes: [...f.nodes, isolated],
connections: f.connections,
nodeTypes: f.nodeTypes,
portTypeCatalog: _portCatalog,
});
expect(candidates).toEqual([]);
});
it('returns plain candidates (compat="ok") when expectedType is omitted', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
...f,
portTypeCatalog: _portCatalog,
});
expect(candidates.length).toBeGreaterThan(0);
expect(candidates.every((c) => c.compat === 'ok')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// strictlyCompatible — T7 strict type filter
// ---------------------------------------------------------------------------
describe('strictlyCompatible', () => {
it('keeps only ok / coerce / iterable candidates and drops mismatch', () => {
const all: SourceCandidate[] = [
{ nodeId: 'a', path: [], type: 'DocumentList', compat: 'ok' },
{ nodeId: 'a', path: ['documents'], type: 'List[UdmDocument]', compat: 'mismatch', iterable: true },
{ nodeId: 'a', path: ['count'], type: 'int', compat: 'coerce' },
{ nodeId: 'a', path: ['junk'], type: 'object', compat: 'mismatch' },
];
const out = strictlyCompatible(all);
expect(out.map((c) => c.path)).toEqual([[], ['documents'], ['count']]);
});
});

View file

@ -1,55 +0,0 @@
/**
* Lexical scope for DataPicker: ancestor node ids reachable backward on the graph.
*/
export interface GraphEdgeLike {
source: string;
target: string;
}
export interface GraphNodeLike {
id: string;
type?: string;
}
/** All node ids that can reach targetNodeId via incoming edges (excluding target). */
export function computeAncestorNodeIds(
_nodes: GraphNodeLike[],
connections: GraphEdgeLike[],
targetNodeId: string
): Set<string> {
const preds = new Map<string, Set<string>>();
for (const c of connections) {
const src = c.source;
const tgt = c.target;
if (!src || !tgt) continue;
if (!preds.has(tgt)) preds.set(tgt, new Set());
preds.get(tgt)!.add(src);
}
const seen = new Set<string>();
const stack = [targetNodeId];
while (stack.length) {
const cur = stack.pop()!;
const ps = preds.get(cur);
if (!ps) continue;
for (const p of ps) {
if (!seen.has(p)) {
seen.add(p);
stack.push(p);
}
}
}
seen.delete(targetNodeId);
return seen;
}
/** Node ids of flow.loop ancestors (subset of ancestors). */
export function findLoopAncestorIds(
nodes: GraphNodeLike[],
connections: GraphEdgeLike[],
targetNodeId: string
): string[] {
const anc = computeAncestorNodeIds(nodes, connections, targetNodeId);
const byId = new Map(nodes.map((n) => [n.id, n]));
return [...anc].filter((id) => byId.get(id)?.type === 'flow.loop');
}

View file

@ -8,6 +8,12 @@ import type { FormField } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from '../form/formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -21,7 +27,9 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
return { name, label, type } as FormField;
const required = Boolean(o.required);
const options = formFieldTypeHasConfigurableOptions(type) ? normalizeFormFieldOptions(o.options) : undefined;
return { name, label, type, required, ...(options !== undefined ? { options } : {}) } as FormField;
}
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
});
@ -43,29 +51,19 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}>
<strong>{t('Formular-Felder')}</strong>{' '}
{t('werden beim Start ausgefüllt und liegen unter')}{' '}
<code>payload.&lt;name&gt;</code> {t('in der Start-Ausgabe.')}
{t('werden beim Start ausgefüllt. Der Payload-Schlüssel wird aus der Beschriftung abgeleitet.')}
</p>
<div className={styles.formFieldsList}>
{fields.map((f, idx) => (
<div key={idx} className={styles.formFieldRow}>
<input
className={styles.startsInput}
placeholder={t('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={t('Beschriftung')}
value={f.label ?? ''}
onChange={(e) => {
const label = e.target.value;
const next = [...fields];
next[idx] = { ...f, label: e.target.value };
next[idx] = { ...f, label, name: deriveFormFieldPayloadKey(label, idx) };
setFields(next);
}}
/>
@ -74,7 +72,12 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] };
const type = e.target.value as FormField['type'];
const row: FormField = { ...f, type };
if (formFieldTypeHasConfigurableOptions(type)) {
row.options = normalizeFormFieldOptions(row.options);
}
next[idx] = row;
setFields(next);
}}
>
@ -89,13 +92,32 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
>
</button>
{formFieldTypeHasConfigurableOptions(f.type) ? (
<div className={styles.formFieldOptionsBlock}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => {
const next = [...fields];
next[idx] = { ...next[idx], options: opts };
setFields(next);
}}
/>
</div>
) : null}
</div>
))}
<button
type="button"
className={styles.startsAddBtn}
onClick={() =>
setFields([...fields, { name: `field${fields.length + 1}`, label: t('Neues Feld'), type: 'text' }])
setFields([
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
label: '',
type: 'text',
},
])
}
>
{t('+ Feld')}

View file

@ -1,438 +1,33 @@
/**
* Start node (Zeitplan) Karten-UI mit Konfiguration unter der gewählten Option.
* Start node (Zeitplan) Accordion planner; gespeichert werden cron + schedule.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
import React, { useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import type { NodeConfigRendererProps } from '../shared/types';
import { SchedulePlanner } from '../../../SchedulePlanner';
import {
type ScheduleSpec,
type ScheduleMode,
type IntervalUnit,
type CalendarPeriod,
buildCronFromSpec,
scheduleSpecFromParams,
WEEKDAYS_MO_SO,
} from '../runtime/scheduleCron';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
return [
{ value: 'daily', title: t('Täglich'), subtitle: t('Jeden Tag zur gleichen Zeit') },
{ value: 'weekdays', title: t('Werktage'), subtitle: t('Montag bis Freitag') },
{ value: 'weekly', title: t('Bestimmte Tage'), subtitle: t('Wochentage auswählen') },
{ value: 'calendar', title: t('Ein anderer Zeitraum'), subtitle: t('Monatlich oder jährlich') },
{ value: 'interval', title: t('Intervall'), subtitle: t('In regelmäßigen Abständen') },
];
}
function _monthNames(t: (k: string) => string): string[] {
return [
t('Januar'), t('Februar'), t('März'), t('April'),
t('Mai'), t('Juni'), t('Juli'), t('August'),
t('September'), t('Oktober'), t('November'), t('Dezember'),
];
}
function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] {
return [
{ value: 'seconds', label: t('Sekunde'), title: t('Sekunden') },
{ value: 'minutes', label: t('Minute'), title: t('Minuten') },
{ value: 'hours', label: t('Stunde'), title: t('Stunden') },
{ value: 'days', label: t('Tag'), title: t('Tage') },
{ value: 'years', label: t('Jahr'), title: t('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;
scheduleSpecToPersistentJson,
type ScheduleSpec,
} from '../../../../utils/scheduleCron';
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const modeOptions = _getModeOptions(t);
const intervalUnits = _getIntervalUnits(t);
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 spec = useMemo(
() => scheduleSpecFromParams(params as Record<string, unknown>),
[params.cron, params.schedule]
);
const push = useCallback(
(next: ScheduleSpec) => {
setSpec(next);
commitSpec(next, updateParam);
const sched = scheduleSpecToPersistentJson(next);
const cron = buildCronFromSpec(next);
flushSync(() => {
updateParam('schedule', sched);
});
updateParam('cron', cron);
},
[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: { value: ScheduleMode; title: string; subtitle: string }
) => {
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}>
{t(
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
)}
</p>
<LayoutGroup>
<div className={styles.scheduleModeStack}>
{modeOptions.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}>{t('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}>{t('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}>{t('Wochentage')}</span>
<div className={styles.scheduleWeekdayToggles}>
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
<button
key={cronDow}
type="button"
className={
spec.weekdays.includes(cronDow) ? styles.scheduleDayOn : styles.scheduleDayOff
}
onClick={() => toggleWeekday(cronDow)}
>
{cronDow === 1 ? t('Mo') : cronDow === 2 ? t('Di') : cronDow === 3 ? t('Mi') : cronDow === 4 ? t('Do') : cronDow === 5 ? t('Fr') : cronDow === 6 ? t('Sa') : t('So')}
</button>
))}
</div>
</div>
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('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')}
>
{t('Monatlich')}
</button>
<button
type="button"
className={
spec.calendarPeriod === 'yearly'
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
: styles.scheduleSubModeBtn
}
onClick={() => setCalendarPeriod('yearly')}
>
{t('Jährlich')}
</button>
</div>
{spec.calendarPeriod === 'monthly' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('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}>{t('Monat')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthIndex}
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
>
{_monthNames(t).map((name, i) => (
<option key={i + 1} value={i + 1}>
{name}
</option>
))}
</select>
</label>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>{t('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}>{t('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}>{t('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={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
>
{intervalUnits.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>
);
return <SchedulePlanner value={spec} onChange={push} />;
};

View file

@ -1,250 +0,0 @@
/**
* 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 '../shared/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';
import { useLanguage } from '../../../../providers/language/LanguageContext';
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 { t } = useLanguage();
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="">{t('MIME-Typ 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="">{t('Wählen')}</option>
<option value="true">{t('Ja (true)')}</option>
<option value="false">{t('Nein (false)')}</option>
</select>
);
}
return (
<input
type="text"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
placeholder={isMimeTypeRef ? t('z.B. application/pdf') : t('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>{t('Datenquelle')}</label>
<RefSourceSelect
value={ref}
onChange={handleRefChange}
placeholder={t('Feld zum Vergleich wählen')}
/>
</div>
{!ref && (
<div className={styles.ifElseConditionRow}>
<label>{t('Fester Wert (ohne Referenz)')}</label>
<input
type="text"
value={String(staticValue ?? '')}
onChange={(e) => handleStaticValueChange(e.target.value)}
placeholder={t('z. B. CH oder 42')}
/>
</div>
)}
<div className={styles.ifElseConditionRow}>
<label>{t('Fälle / Reihenfolge / Ausgabe')}</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}>
{t('+ Fall')}
</button>
</div>
</div>
</div>
);
};

View file

@ -1 +1,2 @@
export { SwitchNodeConfig } from './SwitchNodeConfig';
export { CaseListEditor as SwitchNodeConfig } from '../frontendTypeRenderers/CaseListEditor';
export type { SwitchCase } from '../frontendTypeRenderers/CaseListEditor';

View file

@ -0,0 +1,236 @@
/**
* Runtime form fields shared by Human Task input.form and workflow list trigger.form start.
* Field rows match task.config.fields / graph node parameters.formFields shape from the backend.
*/
import React, { useEffect, useState } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import { loadClickupListTasksForDropdown, type ApiRequestFunction } from '../../../api/workflowApi';
import { normalizeFormFieldOptions } from '../nodes/form';
import { useLanguage } from '../../../providers/language/LanguageContext';
export type WorkflowRuntimeFormFieldRow = {
name: string;
type: string;
label: string;
required?: boolean;
options?: unknown;
clickupConnectionId?: string;
clickupListId?: string;
clickupStatusOptions?: Array<{ value: string; label: string }>;
};
export 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 { t } = useLanguage();
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(t('Aufgaben konnten nicht geladen werden.'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [request, connectionId, listId, t]);
const sel = relationshipTaskIdFromFormValue(value);
if (!connectionId.trim() || !listId.trim()) {
return (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
{t('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)' }}>{t('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="">{t('Aufgabe wählen')}</option>
{tasks.map((taskRow) => (
<option key={taskRow.id} value={taskRow.id}>
{taskRow.name}
</option>
))}
</select>
)}
</>
);
}
export function useWorkflowRuntimeFormRequiredOk(
fields: WorkflowRuntimeFormFieldRow[],
formData: Record<string, unknown>
): boolean {
const requiredFields = fields.filter((f) => f.required);
return requiredFields.every((f) => {
const v = formData[f.name];
if (f.type === 'boolean') return true;
if (f.type === 'clickup_tasks') {
return relationshipTaskIdFromFormValue(v) !== '';
}
if (f.type === 'clickup_status') {
return v !== undefined && v !== null && String(v).trim() !== '';
}
if (
(f.type === 'select' || f.type === 'enum') &&
normalizeFormFieldOptions(f.options).some((o) => String(o.value).trim() !== '')
) {
return v !== undefined && v !== null && String(v).trim() !== '';
}
return v !== undefined && v !== null && String(v).trim() !== '';
});
}
export interface WorkflowRuntimeFormFieldsProps {
fields: WorkflowRuntimeFormFieldRow[];
formData: Record<string, unknown>;
setFormData: React.Dispatch<React.SetStateAction<Record<string, unknown>>>;
formFieldsClassName: string;
}
/**
* Renders the same controls as TaskCard input.form (no Popup parent wraps if needed).
*/
export const WorkflowRuntimeFormFields: React.FC<WorkflowRuntimeFormFieldsProps> = ({
fields,
formData,
setFormData,
formFieldsClassName,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const renderFormControl = (field: WorkflowRuntimeFormFieldRow): React.ReactNode => {
const selectChoices = normalizeFormFieldOptions(field.options).filter((o) => String(o.value).trim() !== '');
if (field.type === 'boolean') {
return (
<input
type="checkbox"
checked={(formData[field.name] as boolean) ?? false}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.checked }))}
/>
);
}
if (field.type === 'clickup_tasks' && request) {
return (
<InputFormClickupTaskField
connectionId={field.clickupConnectionId ?? ''}
listId={field.clickupListId ?? ''}
value={formData[field.name]}
onChange={(v) => setFormData((p) => ({ ...p, [field.name]: v }))}
request={request}
/>
);
}
if (
field.type === 'clickup_status' &&
Array.isArray(field.clickupStatusOptions) &&
field.clickupStatusOptions.length > 0
) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
>
<option value="">{t('Status wählen')}</option>
{field.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
>
<option value="">{t('Bitte wählen')}</option>
{selectChoices.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select>
);
}
return (
<input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
/>
);
};
return (
<div className={formFieldsClassName}>
{fields.map((f) => (
<div key={f.name}>
<label>
{f.label || f.name}
{f.required && ' *'}
</label>
{renderFormControl(f)}
</div>
))}
</div>
);
};

View file

@ -520,6 +520,22 @@
line-height: 1.5;
}
/* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */
.embeddedPicker {
display: flex;
flex-direction: column;
flex: none !important;
min-height: 0;
overflow: hidden;
/* height + maxHeight set inline (embedMaxHeight) */
}
.embeddedPicker .treeWrapper {
flex: 1 1 0;
min-height: 0;
max-height: none;
}
/* Compact mode */
.compactMode .sectionHeader {
padding: 6px 8px;

View file

@ -64,6 +64,7 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
function _flatten<T>(
nodes: TreeNode<T>[],
expandedIds: Set<string>,
confirmedEmptyFolderIds: Set<string>,
): FlatEntry<T>[] {
const childMap = _buildChildMap(nodes);
const result: FlatEntry<T>[] = [];
@ -72,8 +73,22 @@ function _flatten<T>(
const children = childMap.get(parentKey);
if (!children) return;
for (const node of children) {
const nodeChildren = childMap.get(node.id);
const hasChildren = (nodeChildren && nodeChildren.length > 0) || node.type === 'folder';
const loadedChildren = childMap.get(node.id) ?? [];
const hasLoadedKids = loadedChildren.length > 0;
let hasChildren = false;
if (node.type !== 'folder') {
hasChildren = hasLoadedKids;
} else if (hasLoadedKids) {
hasChildren = true;
} else if (confirmedEmptyFolderIds.has(node.id)) {
hasChildren = false;
} else if (node.hasSubfoldersInApiTree === false && node.mayHaveLazyFileChildren === false) {
hasChildren = false;
} else {
hasChildren = true;
}
result.push({ node, depth, hasChildren });
if (hasChildren && expandedIds.has(node.id)) {
_walk(node.id, depth + 1);
@ -135,6 +150,8 @@ interface TreeNodeRowProps<T = any> {
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
hideRowActionButtons?: boolean;
dragDropEnabled?: boolean;
}
const TreeNodeRow = React.memo(function TreeNodeRow<T>({
@ -163,6 +180,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
onDragOver,
onDragLeave,
onDrop,
hideRowActionButtons = false,
dragDropEnabled = true,
}: TreeNodeRowProps<T>) {
const { node, depth, hasChildren } = entry;
const renameRef = useRef<HTMLInputElement>(null);
@ -196,11 +215,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
const _handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (hideRowActionButtons) return;
if (ownership === 'own' && provider.canRename?.(node)) {
onStartRename(node.id);
}
},
[ownership, provider, node, onStartRename],
[hideRowActionButtons, ownership, provider, node, onStartRename],
);
const _handleRowClick = useCallback(
@ -242,11 +262,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
className={rowClasses}
onClick={_handleRowClick}
onDoubleClick={_handleDoubleClick}
draggable
onDragStart={(e) => onDragStart(e, node)}
onDragOver={(e) => onDragOver(e, node)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node)}
draggable={dragDropEnabled}
onDragStart={dragDropEnabled ? (e) => onDragStart(e, node) : undefined}
onDragOver={dragDropEnabled ? (e) => onDragOver(e, node) : undefined}
onDragLeave={dragDropEnabled ? onDragLeave : undefined}
onDrop={dragDropEnabled ? (e) => onDrop(e, node) : undefined}
data-node-id={node.id}
title={node.name}
role="treeitem"
@ -256,17 +276,19 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
>
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
<input
type="checkbox"
className={styles.nodeCheckbox}
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect(node.id, e as unknown as React.MouseEvent);
}}
tabIndex={-1}
/>
{!hideRowActionButtons && (
<input
type="checkbox"
className={styles.nodeCheckbox}
checked={isSelected}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect(node.id, e as unknown as React.MouseEvent);
}}
tabIndex={-1}
/>
)}
{hasChildren ? (
<span
@ -303,91 +325,104 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</span>
)}
<div className={styles.nodeSizeGroup}>
{!hideRowActionButtons && (
<span className={styles.nodeSize}>
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
</span>
)}
<div className={styles.nodeActionsHover}>
{canRename && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }}
title="Umbenennen"
tabIndex={-1}
>
{'\u270F\uFE0F'}
</button>
)}
{!hideRowActionButtons && (
<>
<div className={styles.nodeActionsHover}>
{canRename && (
<button
className={styles.emojiBtn}
onClick={(e) => {
e.stopPropagation();
onStartRename(node.id);
}}
title="Umbenennen"
tabIndex={-1}
>
{'\u270F\uFE0F'}
</button>
)}
{node.type !== 'folder' && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
title="Datei herunterladen"
tabIndex={-1}
>
{'\u{1F4E5}'}
</button>
)}
{node.type !== 'folder' && (
<button
className={styles.emojiBtn}
onClick={(e) => {
e.stopPropagation();
onDownload(node);
}}
title="Datei herunterladen"
tabIndex={-1}
>
{'\u{1F4E5}'}
</button>
)}
{canDelete && (
<button
className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
title="Loeschen"
tabIndex={-1}
>
{'\u{1F5D1}\uFE0F'}
</button>
)}
</div>
</div>
{canDelete && (
<button
className={styles.emojiBtn}
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
title="Loeschen"
tabIndex={-1}
>
{'\u{1F5D1}\uFE0F'}
</button>
)}
</div>
<div className={styles.nodeActionsPersistent}>
{onSendToChat && (
<button
className={styles.emojiBtn}
onClick={(e) => {
e.stopPropagation();
onSendToChat(node);
}}
title="In Chat senden"
tabIndex={-1}
>
{'\u{1F4AC}'}
</button>
)}
<div className={styles.nodeActionsPersistent}>
{onSendToChat && (
<button
className={styles.emojiBtn}
onClick={(e) => {
e.stopPropagation();
onSendToChat(node);
}}
title="In Chat senden"
tabIndex={-1}
>
{'\u{1F4AC}'}
</button>
)}
{node.scope !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchScope ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchScope) onCycleScope(node);
}}
title={`Scope: ${node.scope}`}
tabIndex={-1}
>
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
</button>
)}
{node.scope !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchScope ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchScope) onCycleScope(node);
}}
title={`Scope: ${node.scope}`}
tabIndex={-1}
>
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
</button>
)}
{node.neutralize !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchNeutralize ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchNeutralize) onToggleNeutralize(node);
}}
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
tabIndex={-1}
style={{ opacity: node.neutralize ? 1 : 0.35 }}
>
{_NEUTRALIZE_EMOJI}
</button>
)}
</div>
{node.neutralize !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchNeutralize ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchNeutralize) onToggleNeutralize(node);
}}
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
tabIndex={-1}
style={{ opacity: node.neutralize ? 1 : 0.35 }}
>
{_NEUTRALIZE_EMOJI}
</button>
)}
</div>
</>
)}
</div>
);
}) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement;
@ -407,6 +442,10 @@ export function FormGeneratorTree<T = any>({
onSendToChat,
allowCreateFolder = true,
className,
embedMaxHeight,
hideRowActionButtons = false,
hideSectionHeader = false,
enableDragDrop,
}: FormGeneratorTreeProps<T>) {
const { t } = useLanguage();
const { confirm } = useConfirm();
@ -421,12 +460,15 @@ export function FormGeneratorTree<T = any>({
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
const [filterText, setFilterText] = useState('');
/** Folders we expanded and confirmed have no visible children → hide chevron like a real leaf */
const [confirmedEmptyFolderIds, setConfirmedEmptyFolderIds] = useState(() => new Set<string>());
const lastSelectedIdRef = useRef<string | null>(null);
const treeContentRef = useRef<HTMLDivElement>(null);
const _loadRoot = useCallback(async () => {
setLoading(true);
try {
setConfirmedEmptyFolderIds(new Set());
const rootNodes = await provider.loadChildren(null, ownership);
setNodes(rootNodes);
if (defaultCollapsed && rootNodes.length === 0) {
@ -441,7 +483,10 @@ export function FormGeneratorTree<T = any>({
_loadRoot();
}, [_loadRoot]);
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
const flatEntriesRaw = useMemo(
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
[nodes, expandedIds, confirmedEmptyFolderIds],
);
const flatEntries = useMemo(() => {
const term = filterText.trim().toLowerCase();
@ -490,6 +535,13 @@ export function FormGeneratorTree<T = any>({
const childNodes = await provider.loadChildren(id, ownership);
if (childNodes.length > 0) {
setNodes((prev) => [...prev, ...childNodes]);
setConfirmedEmptyFolderIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
} else if (node.type === 'folder') {
setConfirmedEmptyFolderIds((prev) => new Set(prev).add(id));
}
}
setTimeout(() => {
@ -607,6 +659,11 @@ export function FormGeneratorTree<T = any>({
const newNode = await provider.createChild(parentId, trimmed);
setNodes((prev) => [...prev, newNode]);
if (parentId) {
setConfirmedEmptyFolderIds((prev) => {
const next = new Set(prev);
next.delete(parentId);
return next;
});
setExpandedIds((prev) => new Set(prev).add(parentId));
}
} catch {
@ -795,6 +852,7 @@ export function FormGeneratorTree<T = any>({
}
case 'F2': {
e.preventDefault();
if (hideRowActionButtons) break;
const node = nodes.find((n) => n.id === focusedId);
if (node && ownership === 'own' && provider.canRename?.(node)) {
_handleStartRename(focusedId);
@ -803,6 +861,7 @@ export function FormGeneratorTree<T = any>({
}
case 'Delete': {
e.preventDefault();
if (hideRowActionButtons) break;
const node = nodes.find((n) => n.id === focusedId);
if (node && ownership === 'own' && provider.canDelete?.(node)) {
_handleDelete(focusedId);
@ -822,6 +881,7 @@ export function FormGeneratorTree<T = any>({
_handleToggleSelect,
_handleStartRename,
_handleDelete,
hideRowActionButtons,
],
);
@ -837,6 +897,8 @@ export function FormGeneratorTree<T = any>({
);
}, [provider, ownership]);
const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons;
const _filteredIdsForAction = useCallback(
(action: TreeBatchAction): string[] => {
const ids = [...selectedIds];
@ -861,14 +923,22 @@ export function FormGeneratorTree<T = any>({
const wrapperClasses = [
styles.formGeneratorTree,
compact && styles.compactMode,
embedMaxHeight != null && styles.embeddedPicker,
className,
]
.filter(Boolean)
.join(' ');
return (
<div className={wrapperClasses}>
{title && (
<div
className={wrapperClasses}
style={
embedMaxHeight != null
? { height: embedMaxHeight, maxHeight: embedMaxHeight, flexShrink: 0 }
: undefined
}
>
{title && !hideSectionHeader && (
<div
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
@ -934,7 +1004,7 @@ export function FormGeneratorTree<T = any>({
</div>
)}
{selectedIds.size > 0 && batchActions.length > 0 && (
{selectedIds.size > 0 && batchActions.length > 0 && !hideRowActionButtons && (
<div className={styles.batchToolbar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span>
{batchActions.map((action: TreeBatchAction) => {
@ -1011,6 +1081,8 @@ export function FormGeneratorTree<T = any>({
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
hideRowActionButtons={hideRowActionButtons}
dragDropEnabled={dragDropEnabled}
/>
))
)}

View file

@ -23,7 +23,9 @@ interface FileData {
sysCreatedBy?: string;
}
function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
function _mapFolderToNode(folder: FolderData, ownership: Ownership, allFolders: FolderData[], includeFilesInTree: boolean): TreeNode {
const hasSubfoldersInApiTree = allFolders.some((f) => (f.parentId ?? null) === folder.id);
const mayHaveLazyFileChildren = includeFilesInTree && !hasSubfoldersInApiTree;
return {
id: folder.id,
name: folder.name,
@ -34,6 +36,8 @@ function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
neutralize: folder.neutralize,
contextOrphan: folder.contextOrphan,
icon: <FaFolder />,
hasSubfoldersInApiTree,
mayHaveLazyFileChildren,
};
}
@ -52,7 +56,8 @@ function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode {
};
}
export function createFolderFileProvider(): TreeNodeProvider {
export function createFolderFileProvider(options: { includeFiles?: boolean } = {}): TreeNodeProvider {
const includeFiles = options.includeFiles !== false;
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
const typeMap = new Map<string, 'folder' | 'file'>();
@ -76,32 +81,34 @@ export function createFolderFileProvider(): TreeNodeProvider {
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
const allFolders: FolderData[] = foldersRes.data ?? [];
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership)));
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles)));
try {
const filters: Record<string, any> = {};
if (parentId) {
filters.folderId = parentId;
if (includeFiles) {
try {
const filters: Record<string, any> = {};
if (parentId) {
filters.folderId = parentId;
}
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', {
params: { pagination: paginationParam },
});
const data = filesRes.data;
let rawFiles: FileData[] = [];
if (data && typeof data === 'object' && 'items' in data) {
rawFiles = Array.isArray(data.items) ? data.items : [];
} else if (Array.isArray(data)) {
rawFiles = data;
}
let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
} catch {
// file list may fail for shared trees; folders still render
}
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', {
params: { pagination: paginationParam },
});
const data = filesRes.data;
let rawFiles: FileData[] = [];
if (data && typeof data === 'object' && 'items' in data) {
rawFiles = Array.isArray(data.items) ? data.items : [];
} else if (Array.isArray(data)) {
rawFiles = data;
}
let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
} catch {
// file list may fail for shared trees; folders still render
}
_trackTypes(nodes);
@ -137,7 +144,9 @@ export function createFolderFileProvider(): TreeNodeProvider {
async createChild(parentId, name) {
const res = await api.post('/api/files/folders', { name, parentId });
return _mapFolderToNode(res.data, 'own');
const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
typeMap.set(node.id, 'folder');
return node;
},
async renameNode(id, newName) {
@ -159,7 +168,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
return api.post(`/api/files/folders/${id}/move`, { targetParentId });
return api.post(`/api/files/folders/${id}/move`, { parentId: targetParentId });
}),
);
},

View file

@ -16,6 +16,16 @@ export interface TreeNode<T = any> {
isLoading?: boolean;
sizeBytes?: number;
data?: T;
/**
* From bulk `/folders/tree` response: another folder references this folder as parent.
* When false AND no lazy-file mode, omit expand affordance immediately.
*/
hasSubfoldersInApiTree?: boolean;
/**
* Folder tree mixes in files lazily (`includeFiles` in FolderFileProvider). When true but
* no subfolders in API snapshot, expand may still reveal files keep chevron until loaded.
*/
mayHaveLazyFileChildren?: boolean;
}
export interface TreeBatchAction {
@ -63,4 +73,15 @@ export interface FormGeneratorTreeProps<T = any> {
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
allowCreateFolder?: boolean;
className?: string;
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
embedMaxHeight?: number;
/**
* Hides checkbox, size column, per-row emoji actions, and batch toolbar saves space in pickers.
* Drag-drop defaults off when hidden; pass `enableDragDrop` to keep moving folders inside the mini tree.
*/
hideRowActionButtons?: boolean;
/** When true, folders remain draggable despite `hideRowActionButtons`. */
enableDragDrop?: boolean;
/** Hides the titled section header (count, refresh, new folder) — for compact embedded pickers. */
hideSectionHeader?: boolean;
}

View file

@ -0,0 +1,145 @@
/**
* Schedule planner layout + Felder; Akkordeon: UiComponents/AccordionList.
*/
.wrap {
max-width: 560px;
font-size: 14px;
color: var(--text-primary, #1a1a1a);
}
.intro {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary, #666);
}
.fields {
display: grid;
gap: 10px;
padding-top: 8px;
}
.field {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fieldStart {
align-items: flex-start;
}
.fieldLabel {
font-size: 13px;
color: var(--text-secondary, #666);
width: 120px;
flex-shrink: 0;
}
.fieldLabelTop {
padding-top: 4px;
}
.unit {
font-size: 13px;
color: var(--text-secondary, #666);
}
.timeRow {
display: flex;
align-items: center;
gap: 6px;
}
.timeSep {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary, #666);
}
.daysRow {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.dayBtn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.15));
background: var(--bg-secondary, rgba(0, 0, 0, 0.05));
color: var(--text-secondary, #555);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.dayBtn:hover {
border-color: var(--text-secondary, #888);
}
.dayBtnSel {
background: var(--text-primary, #1a1a1a);
color: var(--bg-primary, #fff);
border-color: var(--text-primary, #1a1a1a);
}
.numberInput {
width: 72px;
font-size: 14px;
padding: 6px 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
}
.select {
font-size: 14px;
padding: 6px 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
min-width: 4.5rem;
}
.cronInput {
font-family: ui-monospace, monospace;
font-size: 13px;
width: 100%;
max-width: 320px;
padding: 8px 10px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
}
.cronHint {
font-size: 11px;
color: var(--text-tertiary, #888);
margin-top: 8px;
line-height: 1.7;
max-width: 320px;
}
.warn {
font-size: 11px;
color: var(--warning-color, #b45309);
margin-top: 8px;
line-height: 1.5;
}
.disabled {
opacity: 0.55;
pointer-events: none;
}

View file

@ -0,0 +1,526 @@
/**
* Accordion schedule planner updates ScheduleSpec; parent derives cron via buildCronFromSpec.
*/
import React, { useCallback, useMemo, useState } from 'react';
import {
MINUTE_SELECT_OPTIONS,
WEEKDAYS_MO_SO,
type ScheduleSpec,
} from '../../utils/scheduleCron';
import { useLanguage } from '../../providers/language/LanguageContext';
import { AccordionList } from '../UiComponents';
import styles from './SchedulePlanner.module.css';
export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom';
const PLANNER_MODE_IDS: PlannerModeId[] = [
'minutes',
'hours',
'days',
'weeks',
'months',
'custom',
];
function plannerModeFromSpec(spec: ScheduleSpec): PlannerModeId {
const m = spec.mode;
if (m === 'interval') {
if (spec.intervalUnit === 'hours') return 'hours';
if (spec.intervalUnit === 'days') return 'days';
if (spec.intervalUnit === 'minutes') return 'minutes';
return 'custom';
}
if (m === 'minutes') return 'minutes';
if (m === 'hours') return 'hours';
if (m === 'days' || m === 'daily') return 'days';
if (m === 'weeks' || m === 'weekly' || m === 'weekdays') return 'weeks';
if (m === 'months' || (m === 'calendar' && spec.calendarPeriod === 'monthly')) return 'months';
return 'custom';
}
function withPlannerMode(spec: ScheduleSpec, id: PlannerModeId): ScheduleSpec {
const base = { ...spec };
switch (id) {
case 'minutes':
return {
...base,
mode: 'minutes',
intervalValue: Math.max(1, base.intervalValue || 15),
};
case 'hours':
return {
...base,
mode: 'hours',
intervalValue: Math.max(1, base.intervalValue || 1),
};
case 'days':
return {
...base,
mode: 'days',
intervalValue: Math.max(1, base.intervalValue || 1),
};
case 'weeks':
return {
...base,
mode: 'weeks',
weeksInterval: Math.max(1, base.weeksInterval || 1),
weekdays:
base.weekdays.length > 0 ? [...base.weekdays] : [1, 2, 3, 4, 5],
};
case 'months':
return {
...base,
mode: 'months',
intervalValue: Math.max(1, base.intervalValue || 1),
monthDay: Math.min(28, Math.max(1, base.monthDay || 1)),
};
case 'custom':
return {
...base,
mode: 'custom',
customCron: (base.customCron || '0 9 * * *').trim(),
};
default:
return base;
}
}
export interface SchedulePlannerProps {
value: ScheduleSpec;
onChange: (next: ScheduleSpec) => void;
className?: string;
disabled?: boolean;
}
export const SchedulePlanner: React.FC<SchedulePlannerProps> = ({
value,
onChange,
className = '',
disabled = false,
}) => {
const { t } = useLanguage();
const activeMode = useMemo(() => plannerModeFromSpec(value), [value]);
const [openId, setOpenId] = useState<PlannerModeId | null>(null);
const modeMeta = useMemo(
() =>
({
minutes: { label: t('Alle X Minuten') },
hours: { label: t('Alle X Stunden') },
days: { label: t('Täglich / alle X Tage') },
weeks: { label: t('Wöchentlich') },
months: { label: t('Monatlich') },
custom: { label: t('Custom (Cron)') },
}) as const,
[t]
);
const push = useCallback(
(next: ScheduleSpec) => {
if (!disabled) onChange(next);
},
[disabled, onChange]
);
const handleOpenChange = (next: PlannerModeId | null) => {
setOpenId(next);
if (next != null) {
push(withPlannerMode(value, next));
}
};
const hourOptions = useMemo(
() => Array.from({ length: 24 }, (_, i) => i),
[]
);
const renderBody = (id: PlannerModeId) => {
const v = plannerModeFromSpec(value) === id ? value : withPlannerMode(value, id);
switch (id) {
case 'minutes':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={59}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'minutes',
intervalValue: Math.min(59, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Minuten')}</span>
</div>
</div>
);
case 'hours':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={23}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'hours',
intervalValue: Math.min(23, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Stunden')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Bei Minute')}</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'hours',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
);
case 'days':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={31}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'days',
intervalValue: Math.min(31, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Tage')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'days',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'days',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
<p className={styles.cronHint}>
{t(
'Hinweis: „Alle N Tage“ entspricht in Cron einem Schritt im Tag-des-Monats-Feld, nicht zwingend jedem Kalendertag.'
)}
</p>
</div>
);
case 'weeks':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={52}
className={styles.numberInput}
value={v.weeksInterval ?? 1}
onChange={(e) =>
push({
...v,
mode: 'weeks',
weeksInterval: Math.min(52, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Wochen')}</span>
</div>
{(v.weeksInterval ?? 1) > 1 && (
<p className={styles.warn}>
{t(
'Mehr als jede Woche: der erzeugte Cron entspricht vorläufig wöchentlich (ein Wochen-Intervall > 1 ist im Standard-Cron nicht exakt abbildbar).'
)}
</p>
)}
<div className={`${styles.field} ${styles.fieldStart}`}>
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
{t('Wochentage')}
</span>
<div className={styles.daysRow}>
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
<button
key={cronDow}
type="button"
data-schedule-day=""
className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`}
onClick={() => {
const next = { ...v, mode: 'weeks' as const };
const set = new Set(next.weekdays);
if (set.has(cronDow)) set.delete(cronDow);
else set.add(cronDow);
let wd = [...set];
if (wd.length === 0) wd = [cronDow];
wd.sort((a, b) => {
const o = (x: number) => (x === 0 ? 7 : x);
return o(a) - o(b);
});
push({ ...next, weekdays: wd });
}}
>
{cronDow === 1
? t('Mo')
: cronDow === 2
? t('Di')
: cronDow === 3
? t('Mi')
: cronDow === 4
? t('Do')
: cronDow === 5
? t('Fr')
: cronDow === 6
? t('Sa')
: t('So')}
</button>
))}
</div>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'weeks',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'weeks',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
</div>
);
case 'months':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={12}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'months',
intervalValue: Math.min(12, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Monate')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Tag des Monats')}</span>
<input
type="number"
min={1}
max={28}
className={styles.numberInput}
value={Math.min(28, v.monthDay)}
onChange={(e) =>
push({
...v,
mode: 'months',
monthDay: Math.min(28, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'months',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'months',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
</div>
);
case 'custom':
return (
<div className={styles.fields}>
<div className={`${styles.field} ${styles.fieldStart}`}>
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
{t('Ausdruck')}
</span>
<div>
<input
type="text"
className={styles.cronInput}
value={v.customCron}
onChange={(e) =>
push({
...v,
mode: 'custom',
customCron: e.target.value,
})
}
spellCheck={false}
/>
<div className={styles.cronHint}>
{t('Cron')}: {t('Minute')} · {t('Stunde')} · {t('Tag')} · {t('Monat')} ·{' '}
{t('Wochentag')}
{' · '}[{t('Sekunde')} {t('optional')}]
<br />
{t('z.B.')}{' '}
<code>0 9 * * 3,5</code>
</div>
</div>
</div>
</div>
);
default:
return null;
}
};
return (
<div className={`${styles.wrap} ${className} ${disabled ? styles.disabled : ''}`}>
<p className={styles.intro}>
{t(
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
)}
</p>
<AccordionList<PlannerModeId>
items={PLANNER_MODE_IDS.map((mid) => ({
id: mid,
title: modeMeta[mid].label,
children: renderBody(mid),
}))}
showSelectionIndicator
selectedId={activeMode}
openId={openId}
onOpenChange={handleOpenChange}
disabled={disabled}
/>
</div>
);
};

View file

@ -0,0 +1 @@
export { SchedulePlanner, type SchedulePlannerProps, type PlannerModeId } from './SchedulePlanner';

View file

@ -0,0 +1,114 @@
/** Single-expand accordion — grid 0fr/1fr height animation. */
.list {
margin: 0;
padding: 0;
}
.item {
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
border-radius: var(--radius-md, 8px);
margin-bottom: 6px;
overflow: hidden;
background: var(--bg-primary, #fff);
}
.itemSelected {
border-color: var(--border-color, rgba(0, 0, 0, 0.14));
}
.header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
margin: 0;
border: none;
cursor: pointer;
user-select: none;
text-align: left;
background: transparent;
color: inherit;
font: inherit;
outline: none;
appearance: none;
}
.dotRail {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
flex-shrink: 0;
}
.selectionDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: transparent;
box-shadow: inset 0 0 0 1px var(--border-color, rgba(0, 0, 0, 0.22));
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.selectionDotOn {
background: var(--text-primary, #1a1a1a);
box-shadow: none;
}
.title {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
}
.chevron {
flex-shrink: 0;
margin-left: auto;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--text-tertiary, #888);
transition: transform 0.2s ease;
transform-origin: 50% 40%;
}
.itemExpanded .chevron {
transform: rotate(180deg);
}
.bodyCollapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.36s cubic-bezier(0.33, 1, 0.68, 1);
pointer-events: none;
}
.bodyCollapseOpen {
grid-template-rows: 1fr;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.bodyCollapse {
transition: none;
}
}
.bodyInner {
min-height: 0;
overflow: hidden;
}
.bodyPad {
padding: 0 12px 12px;
}
.nonInteractive {
pointer-events: none;
}

View file

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import styles from './AccordionList.module.css';
export interface AccordionListItem<T extends string = string> {
id: T;
title: React.ReactNode;
/** Always mounted while the list is mounted — enables smooth open/close. */
children: React.ReactNode;
}
export interface AccordionListProps<T extends string = string> {
items: AccordionListItem<T>[];
/** Optional left rail: dot marks the “selected” row (e.g. persisted value). */
showSelectionIndicator?: boolean;
selectedId?: T | null;
/** Controlled: currently open panel id, or `null` if all closed. */
openId?: T | null;
/** Uncontrolled initial open panel. Ignored when `openId` is passed. */
defaultOpenId?: T | null;
onOpenChange?: (openId: T | null) => void;
className?: string;
disabled?: boolean;
}
/**
* Single-expand accordion: one section open at a time; grid-row animation on real content height.
*/
export function AccordionList<T extends string = string>({
items,
showSelectionIndicator = false,
selectedId = null,
openId: openIdProp,
defaultOpenId = null,
onOpenChange,
className = '',
disabled = false,
}: AccordionListProps<T>): React.ReactElement {
const isControlled = openIdProp !== undefined;
const [openIdInternal, setOpenIdInternal] = useState<T | null>(defaultOpenId ?? null);
const openId = isControlled ? (openIdProp as T | null) : openIdInternal;
const setOpen = (next: T | null) => {
if (!isControlled) setOpenIdInternal(next);
onOpenChange?.(next);
};
const onToggle = (id: T) => {
if (disabled) return;
const next = openId === id ? null : id;
setOpen(next);
};
return (
<div
role="list"
className={`${styles.list} ${className} ${disabled ? styles.nonInteractive : ''}`}
>
{items.map((item) => {
const expanded = openId === item.id;
const isSelected = selectedId != null && selectedId === item.id;
return (
<div
key={item.id}
role="listitem"
className={`${styles.item} ${expanded ? styles.itemExpanded : ''} ${isSelected ? styles.itemSelected : ''}`}
>
<button
type="button"
className={styles.header}
aria-expanded={expanded}
disabled={disabled}
data-accordion-header=""
onClick={() => onToggle(item.id)}
>
{showSelectionIndicator ? (
<span className={styles.dotRail} aria-hidden>
<span
className={`${styles.selectionDot} ${isSelected ? styles.selectionDotOn : ''}`}
/>
</span>
) : null}
<span className={styles.title}>{item.title}</span>
<span className={styles.chevron} aria-hidden />
</button>
<div
className={`${styles.bodyCollapse} ${expanded ? styles.bodyCollapseOpen : ''}`}
aria-hidden={!expanded}
>
<div className={styles.bodyInner}>
<div className={styles.bodyPad}>{item.children}</div>
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,2 @@
export { AccordionList } from './AccordionList';
export type { AccordionListProps, AccordionListItem } from './AccordionList';

View file

@ -1,7 +1,12 @@
import React from 'react';
import { ButtonWithIconProps } from './ButtonTypes';
interface ButtonProps extends ButtonWithIconProps {
type ButtonDomAccessibilityProps = Pick<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'title' | 'aria-label' | 'aria-busy' | 'aria-disabled' | 'aria-expanded' | 'aria-haspopup'
>;
interface ButtonProps extends ButtonWithIconProps, ButtonDomAccessibilityProps {
as?: 'button' | 'a';
href?: string;
}

View file

@ -19,6 +19,7 @@ export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';
export * from './AccordionList';
export * from './Toast';
export * from './VoiceLanguageSelect';
export * from './Modal';

View file

@ -0,0 +1,45 @@
import type { KeepAliveEntry } from '../types/keepAlive.types';
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
import { CommcoachSessionView } from '../pages/views/commcoach';
import { GraphicalEditorPage } from '../pages/views/graphicalEditor/GraphicalEditorPage';
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
{
id: 'workspace-dashboard',
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
scopeRegex: /\/mandates\/([^/]+)\/workspace\/([^/]+)/,
requireMandateForMount: false,
render: ({ instanceId, scopeKey }) => (
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
),
},
{
id: 'commcoach-session',
pathRegex: /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/,
scopeRegex: /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/,
shellOverflowHidden: false,
render: ({ scopeKey }) => <CommcoachSessionView key={scopeKey} />,
},
{
id: 'graphical-editor',
pathRegex: /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/,
scopeRegex: /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/,
render: ({ mandateId, instanceId, scopeKey }) => (
<GraphicalEditorPage
key={scopeKey}
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
),
},
{
id: 'admin-languages',
pathRegex: /\/admin\/languages(?:$|\/)/,
render: () => <AdminLanguagesPage />,
},
];
export function hideFeatureOutlet(pathname: string): boolean {
return KEEP_ALIVE_ROUTES.some((e) => e.pathRegex.test(pathname));
}

View file

@ -131,7 +131,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.chatworkflow': <FaPlay />,
'feature.graphicalEditor': <FaProjectDiagram />,
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />,

View file

@ -1,473 +0,0 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { allPageData, SidebarItem, SidebarSubmenuItemData } from './data';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText, GenericPageData } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions';
import { FaHome, FaHatWizard, FaBriefcase, FaBuilding, FaProjectDiagram } from 'react-icons/fa';
import { RiFolderSettingsFill } from 'react-icons/ri';
// Configuration for parent groups that don't have a page definition
// Maps parentPath (can be nested like "start.real-estate") to icon and default order
const parentGroupConfig: Record<string, {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
defaultOrder?: number;
}> = {
'start': {
icon: FaHome,
defaultOrder: 1
},
'workflows': {
icon: FaProjectDiagram,
defaultOrder: 2
},
'trustee': {
icon: FaBriefcase,
defaultOrder: 3
},
'basedata': {
icon: RiFolderSettingsFill,
defaultOrder: 4
},
'admin': {
icon: FaHatWizard,
defaultOrder: 5
},
'start.realestate': {
icon: FaBuilding,
defaultOrder: 2
}
};
interface SidebarContextType {
sidebarItems: SidebarItem[];
loading: boolean;
error: string | null;
refreshSidebar: () => Promise<void>;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export const useSidebar = () => {
const context = useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
};
interface SidebarProviderProps {
children: React.ReactNode;
}
export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) => {
const [sidebarItems, setSidebarItems] = useState<SidebarItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get translation function from language context
const { t } = useLanguage();
const { canView, preloadUiPermissions } = usePermissions();
// Helper type for navigation tree nodes
interface NavigationNode {
id: string;
pathSegment: string;
fullPath: string; // Full dot-notation path (e.g., "start.real-estate")
name: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
order: number;
page?: GenericPageData; // If this node represents an actual page
children: Map<string, NavigationNode>; // Keyed by path segment
pages: GenericPageData[]; // Direct child pages
}
// Helper function to resolve node name
const resolveNodeName = (pathSegment: string, _fullPath: string, page?: GenericPageData): string => {
if (page) {
return resolveLanguageText(page.name, t);
}
return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
};
// Helper function to resolve node icon
const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType<React.SVGProps<SVGSVGElement>> | undefined => {
if (page?.icon) {
return page.icon;
}
// Check parentGroupConfig for nested paths first (e.g., "start.real-estate")
if (parentGroupConfig[fullPath]?.icon) {
return parentGroupConfig[fullPath].icon;
}
// Check parentGroupConfig for top-level segments (e.g., "start")
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) {
return parentGroupConfig[pathSegment].icon;
}
return undefined;
};
// Helper function to resolve node order
const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => {
if (page?.order !== undefined) {
return page.order;
}
// Check parentGroupConfig for top-level segments
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) {
return parentGroupConfig[pathSegment].defaultOrder!;
}
// Use minimum order of child pages
if (childPages.length > 0) {
const childOrders = childPages.map(p => p.order ?? 0);
return Math.min(...childOrders);
}
return 0;
};
// Build navigation tree from page data
const buildNavigationTree = (): Map<string, NavigationNode> => {
const rootNodes = new Map<string, NavigationNode>();
// Process all pages with parent paths
const pagesWithParents = allPageData.filter(
page => page.parentPath && !page.hide && page.showInSidebar !== false
);
for (const page of pagesWithParents) {
if (!page.parentPath) continue;
// Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"])
const pathSegments = page.parentPath.split('.');
// Build path to root, creating nodes as needed
let currentMap = rootNodes;
let currentFullPath = '';
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i];
currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment;
// Get or create node for this segment
if (!currentMap.has(segment)) {
// Check if there's a page for this path segment
const segmentPage = allPageData.find(
p => p.path === currentFullPath && !p.hide
);
const node: NavigationNode = {
id: segmentPage?.id || currentFullPath,
pathSegment: segment,
fullPath: currentFullPath,
name: '', // Will be resolved later
icon: undefined, // Will be resolved later
order: 0, // Will be resolved later
page: segmentPage,
children: new Map(),
pages: []
};
currentMap.set(segment, node);
}
const node = currentMap.get(segment)!;
// If this is the last segment, add the page as a child page
if (i === pathSegments.length - 1) {
node.pages.push(page);
}
// Move to next level
currentMap = node.children;
}
}
// Resolve names, icons, and orders for all nodes
const resolveNode = (node: NavigationNode): void => {
// Resolve children first (bottom-up)
for (const childNode of node.children.values()) {
resolveNode(childNode);
}
// Resolve this node
node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page);
node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page);
// Collect all child pages (from direct pages and nested children)
const allChildPages = [...node.pages];
for (const childNode of node.children.values()) {
if (childNode.page) {
allChildPages.push(childNode.page);
}
allChildPages.push(...childNode.pages);
}
node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages);
};
// Resolve all root nodes
for (const node of rootNodes.values()) {
resolveNode(node);
}
return rootNodes;
};
// Convert navigation tree node to sidebar submenu item (recursive)
const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise<SidebarSubmenuItemData | null> => {
// Filter child pages by RBAC and privilegeChecker
const accessiblePages: GenericPageData[] = [];
for (const page of node.pages) {
try {
const hasRBACAccess = await canView('UI', page.path);
if (!hasRBACAccess) continue;
if (page.privilegeChecker) {
try {
const hasPrivilege = await page.privilegeChecker();
if (!hasPrivilege) continue;
} catch (error) {
console.error(`Error checking privilegeChecker for page ${page.path}:`, error);
continue;
}
}
accessiblePages.push(page);
} catch (error) {
console.error(`Error checking RBAC access for page ${page.path}:`, error);
}
}
// Process child nodes recursively (increment depth)
const accessibleChildren: SidebarSubmenuItemData[] = [];
for (const childNode of node.children.values()) {
const childItem = await nodeToSubmenuItem(childNode, depth + 1);
if (childItem) {
accessibleChildren.push(childItem);
}
}
// Combine pages and child nodes, assigning depth
const allChildren: SidebarSubmenuItemData[] = [
...accessiblePages.map(page => ({
id: page.id,
name: resolveLanguageText(page.name, t),
link: `/${page.path}`,
icon: page.icon,
depth: depth + 1 // Child pages are one level deeper
})),
...accessibleChildren
];
// If no accessible children, don't create this node
if (allChildren.length === 0) {
return null;
}
// If this node has a page itself, it shouldn't be a navigation node
// But according to requirements: if it has subpages, it is NOT a page itself
// So we create a navigation node without a link
return {
id: node.id,
name: node.name,
link: undefined, // Navigation node - not a clickable page
icon: node.icon,
submenu: allChildren.length > 0 ? allChildren : undefined,
depth: depth // Current depth level
};
};
// Convert navigation tree to sidebar items
const treeToSidebarItems = async (tree: Map<string, NavigationNode>): Promise<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Process each root node (depth 0 for top-level items)
for (const node of tree.values()) {
const submenuItem = await nodeToSubmenuItem(node, 0);
if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) {
items.push({
id: node.id,
name: node.name,
link: undefined, // Navigation node - not a clickable page
icon: node.icon,
moduleEnabled: true,
order: node.order,
submenu: submenuItem.submenu,
depth: 0 // Top-level items have depth 0
});
}
}
return items;
};
// Get sidebar items from page data
const getSidebarItems = async (): Promise<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Build navigation tree
const navigationTree = buildNavigationTree();
// Convert tree to sidebar items
const treeItems = await treeToSidebarItems(navigationTree);
items.push(...treeItems);
// Get main pages (no parent path)
const mainPages = allPageData
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0));
// Process each main page
for (const pageData of mainPages) {
// Check RBAC permissions
try {
const hasRBACAccess = await canView('UI', pageData.path);
if (!hasRBACAccess) {
continue;
}
// Check client-side privilegeChecker if provided
if (pageData.privilegeChecker) {
try {
const hasPrivilege = await pageData.privilegeChecker();
if (!hasPrivilege) {
continue;
}
} catch (error) {
console.error(`Error checking privilegeChecker for ${pageData.path}:`, error);
continue;
}
}
} catch (error) {
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
continue;
}
// Check if this page has subpages (legacy support)
if (pageData.hasSubpages) {
// Find all subpages for this parent
const allSubpages = allPageData.filter(p =>
p.parentPath === pageData.path &&
!p.hide &&
p.showInSidebar !== false
);
// Filter subpages by RBAC access
const accessibleSubpages: GenericPageData[] = [];
for (const subpage of allSubpages) {
try {
const hasSubpageRBACAccess = await canView('UI', subpage.path);
if (!hasSubpageRBACAccess) {
continue;
}
if (subpage.privilegeChecker) {
try {
const hasPrivilege = await subpage.privilegeChecker();
if (!hasPrivilege) {
continue;
}
} catch (error) {
console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error);
continue;
}
}
accessibleSubpages.push(subpage);
} catch (error) {
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
}
}
if (accessibleSubpages.length > 0) {
// Create item with submenu (no link since it has subpages)
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: undefined, // No link - has subpages, so it's a navigation node
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0, // Top-level items have depth 0
submenu: accessibleSubpages.map(subpage => ({
id: subpage.id,
name: resolveLanguageText(subpage.name, t),
link: `/${subpage.path}`,
icon: subpage.icon,
depth: 1 // First level of submenu
}))
});
} else {
// No accessible subpages, show as regular item
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0 // Top-level items have depth 0
});
}
} else {
// Regular items without subpages
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0 // Top-level items have depth 0
});
}
}
// Sort all items by order
const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0));
return sortedItems;
};
// Refresh sidebar items
const refreshSidebar = async () => {
console.log('🔄 SidebarProvider: Refreshing sidebar items...');
setLoading(true);
setError(null);
try {
// Preload all UI permissions in a single API call
// This caches all permissions before iterating through pages
await preloadUiPermissions();
const items = await getSidebarItems();
console.log('✅ SidebarProvider: Setting sidebar items:', {
count: items.length,
items: items.map(item => ({ id: item.id, link: item.link, name: item.name }))
});
setSidebarItems(items);
} catch (err) {
console.error('❌ SidebarProvider: Error refreshing sidebar:', err);
setError(err instanceof Error ? err.message : t('Seitenleiste konnte nicht geladen werden'));
} finally {
setLoading(false);
}
};
// Load sidebar items on mount and when language changes
useEffect(() => {
refreshSidebar();
}, [t]);
const contextValue: SidebarContextType = {
sidebarItems,
loading,
error,
refreshSidebar
};
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
};
export default SidebarProvider;

View file

@ -1,27 +0,0 @@
/**
* PageManager data: allPageData and SidebarItem type.
*/
import type React from 'react';
export { allPageData } from './pages';
export interface SidebarItem {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
moduleEnabled?: boolean;
order?: number;
submenu?: SidebarSubmenuItemData[];
depth?: number;
}
export interface SidebarSubmenuItemData {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
depth?: number;
submenu?: SidebarSubmenuItemData[];
}

View file

@ -1,15 +0,0 @@
/**
* Central page registry: all PageData for PageManager/Sidebar.
*/
import type { GenericPageData } from '../../pageInterface';
import { trusteePositionDocumentsPageData } from './trustee/position-documents';
import { realEstatePages } from './realestate';
export { realEstatePages } from './realestate';
export { trusteePositionDocumentsPageData } from './trustee/position-documents';
export const allPageData: GenericPageData[] = [
trusteePositionDocumentsPageData,
...realEstatePages,
];

View file

@ -1,3 +0,0 @@
import { GenericPageData } from '../../../pageInterface';
export const realEstatePages: GenericPageData[] = [];

View file

@ -1,241 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaLink, FaPlus } from 'react-icons/fa';
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
return {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for position-documents data
const createPositionDocumentsHook = () => {
return () => {
const {
positionDocuments,
loading,
error,
refetch,
removeOptimistically,
attributes,
permissions,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteePositionDocuments();
const {
handlePositionDocumentDelete,
handlePositionDocumentCreate,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError
} = useTrusteePositionDocumentOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandlePositionDocumentCreate = useCallback(async (formData: any) => {
return await handlePositionDocumentCreate(formData);
}, [handlePositionDocumentCreate]);
const handleDeleteSingle = useCallback(async (positionDocument: any) => {
const success = await handlePositionDocumentDelete(positionDocument.id);
if (success) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedPositionDocuments: any[]) => {
const positionDocumentIds = selectedPositionDocuments.map(pd => pd.id);
const results = await Promise.all(
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
return {
data: positionDocuments,
loading,
error,
refetch,
removeOptimistically,
handleDelete: handlePositionDocumentDelete,
handleDeleteMultiple,
handlePositionDocumentCreate: wrappedHandlePositionDocumentCreate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError,
attributes,
permissions,
columns: generatedColumns,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteePositionDocumentsPageData: GenericPageData = {
id: 'administration-trustee-position-documents',
path: 'administration/trustee/position-documents',
name: 'trustee.positionDocuments.title',
description: 'trustee.positionDocuments.description',
// Parent page
parentPath: 'administration/trustee',
// Visual
icon: FaLink,
title: 'trustee.positionDocuments.title',
subtitle: 'trustee.positionDocuments.subtitle',
// Header buttons
headerButtons: [
{
id: 'new-position-document',
label: 'trustee.positionDocuments.new',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.positionDocuments.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'trustee.organisation',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Organisation is required';
}
return null;
}
},
{
key: 'contractId',
label: 'trustee.positionDocuments.field.contractId',
type: 'enum',
required: true,
optionsReference: 'trustee.contract',
dependsOn: 'organisationId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Contract is required';
}
return null;
}
},
{
key: 'positionId',
label: 'trustee.positionDocuments.field.positionId',
type: 'enum',
required: true,
optionsReference: 'trustee.position',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Position is required';
}
return null;
}
},
{
key: 'documentId',
label: 'trustee.positionDocuments.field.documentId',
type: 'enum',
required: true,
optionsReference: 'trustee.document',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Document is required';
}
return null;
}
}
],
popupTitle: 'trustee.positionDocuments.modal.create.title',
popupSize: 'medium',
createOperationName: 'handlePositionDocumentCreate',
successMessage: 'trustee.positionDocuments.create.success',
errorMessage: 'trustee.positionDocuments.create.error'
}
}
],
// Content sections
content: [
{
id: 'position-documents-table',
type: 'table',
tableConfig: {
hookFactory: createPositionDocumentsHook,
actionButtons: [
{
type: 'delete',
title: 'trustee.positionDocuments.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingPositionDocuments',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
return { disabled: !hasDelete, message: 'No permission to delete links' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'position-documents-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Position-Documents activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Position-Documents loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Position-Documents unloaded');
}
};

View file

@ -1,44 +0,0 @@
/**
* PageManager page interface and helpers.
* Used by PageData definitions and SidebarProvider.
*/
import type React from 'react';
export interface GenericPageData {
id: string;
path: string;
name: string;
description?: string;
parentPath?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
title?: string;
subtitle?: string;
headerButtons?: Array<Record<string, unknown>>;
content?: Array<Record<string, unknown>>;
moduleEnabled?: boolean;
order?: number;
hide?: boolean;
showInSidebar?: boolean;
showInSidebarIf?: boolean;
hasSubpages?: boolean;
privilegeChecker?: () => Promise<boolean> | boolean;
persistent?: boolean;
preload?: boolean;
preserveState?: boolean;
onActivate?: () => void | Promise<void>;
onLoad?: () => void | Promise<void>;
onUnload?: () => void | Promise<void>;
}
type TranslationFunction = (key: string, params?: Record<string, string | number | boolean | null | undefined>) => string;
/**
* Resolve display text from a page name (i18n key) via the translation function.
*/
export function resolveLanguageText(
name: string,
t: TranslationFunction
): string {
return t(name);
}

View file

@ -1,44 +1,108 @@
/**
* MainLayout
*
*
* Hauptlayout der Anwendung mit Sidebar und Content-Bereich.
* Enthält den FeatureProvider für das Multi-Tenant-System.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
import { isKeepAliveScoped } from '../types/keepAlive.types';
import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/;
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
});
const RoutedKeepAliveUnscoped: React.FC<{ entry: KeepAliveUnscopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
return (
<div style={keepAliveShellStyle(isVisible, true)}>
{entry.render()}
</div>
);
};
const RoutedKeepAliveScoped: React.FC<{ entry: KeepAliveScopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
const {
scopeRegex,
requireMandateForMount = true,
shellOverflowHidden = true,
render,
} = entry;
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = pathname.match(scopeRegex);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
const scopeReady = requireMandateForMount
? !!(mandateId && instanceId)
: !!instanceId;
if (!scopeReady) {
return null;
}
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div style={keepAliveShellStyle(isVisible, shellOverflowHidden)}>
{render({ mandateId, instanceId, scopeKey })}
</div>
);
};
const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string }> = ({
entry,
pathname,
}) => {
if (!isKeepAliveScoped(entry)) {
return <RoutedKeepAliveUnscoped entry={entry} pathname={pathname} />;
}
return <RoutedKeepAliveScoped entry={entry} pathname={pathname} />;
};
// =============================================================================
// INNER LAYOUT (mit Zugriff auf Store)
// =============================================================================
const MainLayoutInner: React.FC = () => {
const { t } = useLanguage();
const { t } = useLanguage();
const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
const isGEEditorKeepAliveVisible = _GE_EDITOR_ROUTE_RE.test(location.pathname);
const isLanguagesKeepAliveVisible = _ADMIN_LANGUAGES_RE.test(location.pathname);
const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible || isGEEditorKeepAliveVisible || isLanguagesKeepAliveVisible;
const hideOutletShell = hideFeatureOutlet(location.pathname);
// Features laden beim Mount
useEffect(() => {
if (!initialized && !loading) {
@ -60,7 +124,7 @@ const MainLayoutInner: React.FC = () => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div className={styles.mainLayout}>
{isMobileSidebarOpen && (
@ -74,35 +138,25 @@ const MainLayoutInner: React.FC = () => {
{/* Sidebar */}
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
<div className={styles.logoContainer}>
<img
src="/logos/poweron-logo.png"
alt="PowerOn"
className={styles.logoImage}
/>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
</div>
<nav className={styles.navigation}>
{loading && (
<div className={styles.loadingNav}>
{t('Lade Navigation…')}
</div>
)}
{loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
{error && (
<div className={styles.errorNav}>
{t('Fehler')}: {error}
</div>
)}
{initialized && !loading && (
<MandateNavigation />
)}
{initialized && !loading && <MandateNavigation />}
</nav>
{/* User-Bereich am unteren Rand */}
<UserSection />
</aside>
{/* Content */}
<main className={styles.content}>
<div className={styles.mobileTopBar}>
@ -113,17 +167,12 @@ const MainLayoutInner: React.FC = () => {
>
</button>
<img
src="/logos/poweron-logo.png"
alt="PowerOn"
className={styles.mobileLogo}
/>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
</div>
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
<AdminLanguagesKeepAlive isVisible={isLanguagesKeepAliveVisible} />
{KEEP_ALIVE_ROUTES.map((routeEntry) => (
<RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} />
))}
<div
className={styles.outletShell}

View file

@ -1177,6 +1177,13 @@ const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }>
);
};
const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient';
/** Hide persisted transient extract JSON from user-facing Workspace file lists */
function _isHiddenWorkflowArtifactFile(f: { fileName?: string }): boolean {
return (f.fileName ?? '').toLowerCase().includes(_INTERNAL_EXTRACT_FILENAME_SUBSTR);
}
const _ProducedFilesSection: React.FC<{
steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>;
unassignedFiles?: Array<{ id: string; fileName?: string }>;
@ -1186,10 +1193,12 @@ const _ProducedFilesSection: React.FC<{
const allFiles: Array<{ id: string; fileName?: string }> = [];
for (const step of steps) {
for (const f of step.outputFiles ?? []) {
if (_isHiddenWorkflowArtifactFile(f)) continue;
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
}
}
for (const f of unassignedFiles ?? []) {
if (_isHiddenWorkflowArtifactFile(f)) continue;
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
}
if (!allFiles.length) return null;
@ -1312,8 +1321,8 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
{steps.map((step) => {
const inputData = _stripFileRefKeys(step.inputSnapshot ?? {});
const outputData = _stripFileRefKeys(step.output ?? {});
const inputFiles = step.inputFiles ?? [];
const outputFiles = step.outputFiles ?? [];
const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
const hasInput = inputData !== undefined || inputFiles.length > 0;
const hasOutput = outputData !== undefined || outputFiles.length > 0;
return (
@ -1374,12 +1383,16 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
})}
</div>
)}
{unassignedFiles && unassignedFiles.length > 0 && (
{(() => {
const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
if (!visibleUnassigned.length) return null;
return (
<>
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
<_FileLinkList files={unassignedFiles} />
<_FileLinkList files={visibleUnassigned} />
</>
)}
);
})()}
</div>
);
};

View file

@ -6,6 +6,8 @@
*/
import React from 'react';
import { useLocation } from 'react-router-dom';
import { hideFeatureOutlet } from '../config/keepAliveRoutes';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
// Trustee Views
@ -28,7 +30,6 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
// GraphicalEditor Views
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
// Workspace Views
@ -148,7 +149,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
},
graphicalEditor: {
editor: GraphicalEditorPage,
workflows: GraphicalEditorWorkflowsPage,
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
templates: GraphicalEditorTemplatesPage,
},
@ -192,6 +192,7 @@ interface FeatureViewPageProps {
}
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const location = useLocation();
const { instance, featureCode, isValid } = useCurrentInstance();
// Berechtigungs-Check
@ -226,20 +227,10 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
if (!canView && view !== 'not-found') {
return <AccessDenied />;
}
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
return null;
}
// CommCoach session is rendered persistently by CommcoachKeepAlive at MainLayout level.
if (featureCode === 'commcoach' && view === 'session') {
return null;
}
// GraphicalEditor editor is rendered persistently by GraphicalEditorKeepAlive at MainLayout level.
if (featureCode === 'graphicalEditor' && view === 'editor') {
// Feature outlet is hidden for paths configured in KEEP_ALIVE_ROUTES (rendered in MainLayout).
// Add new persistent workspace URLs there if needed.
if (hideFeatureOutlet(location.pathname)) {
return null;
}

View file

@ -1,35 +0,0 @@
/**
* AdminLanguagesKeepAlive
*
* Keeps the AdminLanguagesPage mounted across route changes so that
* long-running AI translation progress, table state, and selections
* survive when the user navigates away and returns.
*/
import React from 'react';
import { AdminLanguagesPage } from './AdminLanguagesPage';
interface AdminLanguagesKeepAliveProps {
isVisible: boolean;
}
export const AdminLanguagesKeepAlive: React.FC<AdminLanguagesKeepAliveProps> = ({ isVisible }) => {
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<AdminLanguagesPage />
</div>
);
};
export default AdminLanguagesKeepAlive;

View file

@ -1,55 +0,0 @@
/**
* CommcoachKeepAlive
*
* Keeps the CommCoach session page mounted across route changes.
* The voice session must persist when the user navigates to other tabs.
* Only the "session" tab is kept alive; modules/dashboard can unmount freely.
*
* Persistence is scoped per `(mandateId, instanceId)` switching to a
* different mandate or instance unmounts the previous view.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { CommcoachSessionView } from './CommcoachSessionView';
const _COMMCOACH_SESSION_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/;
interface CommcoachKeepAliveProps {
isVisible: boolean;
}
export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_COMMCOACH_SESSION_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
if (!mandateId || !instanceId) return null;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
}}
>
<CommcoachSessionView key={scopeKey} />
</div>
);
};
export default CommcoachKeepAlive;

View file

@ -276,6 +276,46 @@
background: var(--bg-primary, #fff);
}
.taskCardDismissable {
position: relative;
padding-top: 0.85rem;
padding-right: 2.25rem;
}
.dismissOpenTaskBtn {
position: absolute;
top: 0.35rem;
right: 0.35rem;
width: 1.85rem;
height: 1.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-secondary, #888);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.dismissOpenTaskBtn:hover:not(:disabled) {
color: var(--danger-color, #c82333);
background: rgba(220, 53, 69, 0.08);
}
.dismissOpenTaskBtn:focus-visible {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 2px;
}
.dismissOpenTaskBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.taskCard:last-child {
margin-bottom: 0;
}
@ -396,6 +436,13 @@
cursor: not-allowed;
}
/* Override broad .taskCard button[type='button'] primary styling for dismiss control */
.taskCard button.dismissOpenTaskBtn {
background: transparent;
color: var(--text-secondary, #888);
padding: 0;
}
/* Upload task */
.uploadTaskBlock {
display: flex;

View file

@ -1,96 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Persistence is per (mandateId, instanceId): switching to a different mandate
// or instance must remount the editor page so its internal state (loaded
// workflow, currentWorkflowId, …) is reset and saves go to the right tenant.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { MemoryRouter, useNavigate } from 'react-router-dom';
const _mountCount = { value: 0 };
vi.mock('./GraphicalEditorPage', () => ({
GraphicalEditorPage: ({ persistentMandateId, persistentInstanceId }: { persistentMandateId?: string; persistentInstanceId?: string }) => {
React.useEffect(() => {
_mountCount.value += 1;
}, []);
return <div data-testid="ge-page">{persistentMandateId}::{persistentInstanceId}</div>;
},
}));
import { GraphicalEditorKeepAlive } from './GraphicalEditorKeepAlive';
let _navigateTo: ((path: string) => void) | null = null;
const _NavCapture: React.FC = () => {
_navigateTo = useNavigate();
return null;
};
function _renderHarness(initialPath: string) {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<_NavCapture />
<GraphicalEditorKeepAlive isVisible />
</MemoryRouter>,
);
}
function _navigate(path: string) {
act(() => {
_navigateTo?.(path);
});
}
describe('GraphicalEditorKeepAlive — persistence per (mandate, instance)', () => {
it('remounts the page when the mandate changes', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA');
_navigate('/mandates/mB/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(2);
expect(screen.getByTestId('ge-page').textContent).toBe('mB::iA');
});
it('remounts the page when the instance changes', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
_navigate('/mandates/mA/graphicalEditor/iZ/editor');
expect(_mountCount.value).toBe(2);
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iZ');
});
it('does NOT remount when the route stays on the same (mandate, instance)', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
_navigate('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
});
it('keeps the cached page mounted (no remount) when the user navigates AWAY and BACK to the same scope', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
// Away to a non-editor route: the regex match fails, refs keep their
// previous values — the cached page must not remount.
_navigate('/admin/languages');
expect(_mountCount.value).toBe(1);
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA');
// Back to the same (mandate, instance) — still no remount.
_navigate('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
});
});

View file

@ -1,70 +0,0 @@
/**
* GraphicalEditorKeepAlive
*
* Keeps the GraphicalEditorPage mounted across route changes so the canvas
* state, SSE connections, and editor context survive navigation to ANY page
* (other features, admin, settings, etc.).
*
* Persistence is scoped per `(mandateId, instanceId)`: when the user switches
* to a DIFFERENT mandate or instance via the navigator, the previous editor
* mount is discarded and a fresh page is mounted. Otherwise stale state from
* mandate A leaks into mandate B and saves end up hitting the wrong tenant
* (HTTP 404 / "not found").
*
* Implementation: feeds the cached `(mandate, instance)` tuple into both
* `props` and `key`. React reuses the mount as long as the tuple stays
* identical and unmounts/remounts on change.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { GraphicalEditorPage } from './GraphicalEditorPage';
const _GE_EDITOR_ROUTE_RE = /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/;
interface GraphicalEditorKeepAliveProps {
isVisible: boolean;
}
export const GraphicalEditorKeepAlive: React.FC<GraphicalEditorKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const hasEverMountedRef = useRef(false);
const match = location.pathname.match(_GE_EDITOR_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
hasEverMountedRef.current = true;
}
if (!hasEverMountedRef.current) return null;
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<GraphicalEditorPage
key={scopeKey}
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
</div>
);
};
export default GraphicalEditorKeepAlive;

View file

@ -1,441 +0,0 @@
/**
* GraphicalEditorWorkflowsPage
* List of saved workflows with FormGeneratorTable.
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
* Filter: Alle | Aktiv | Inaktiv.
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchWorkflows,
deleteWorkflow,
executeGraph,
updateWorkflow,
importWorkflowFromFile,
exportWorkflowToFile,
isWorkflowFileContent,
workflowFileNameFor,
WORKFLOW_FILE_EXTENSION,
type Automation2Workflow,
type WorkflowFileEnvelope,
} from '../../../api/workflowApi';
import { fetchAttributes } from '../../../api/attributesApi';
import type { AttributeDefinition } from '../../../api/attributesApi';
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
function formatTs(ts?: number): string {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
const { time } = formatUnixTimestamp(sec, undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return time;
}
export const GraphicalEditorWorkflowsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { mandateId } = useParams<{ mandateId: string }>();
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [importing, setImporting] = useState(false);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
useEffect(() => {
fetchAttributes(request, 'Automation2WorkflowView')
.then(setBackendAttributes)
.catch((err) => {
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
});
}, [request]);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
setLoading(true);
try {
const active = activeFilter === 'active' ? true : activeFilter === 'inactive' ? false : undefined;
const result = await fetchWorkflows(request, instanceId, { active, pagination: paginationParams });
if (result && typeof result === 'object' && 'items' in result && !Array.isArray(result)) {
setWorkflows((result as any).items);
setPaginationMeta((result as any).pagination);
} else {
setWorkflows(result as Automation2Workflow[]);
setPaginationMeta(null);
}
} catch (e) {
console.error('[graphicalEditor] load workflows failed', e);
showError(t('Fehler beim Laden der Workflows'));
} finally {
setLoading(false);
}
}, [instanceId, request, showError, activeFilter, t]);
useEffect(() => {
load();
}, [load]);
const handleDelete = useCallback(
async (workflowId: string): Promise<boolean> => {
if (!instanceId) return false;
try {
await deleteWorkflow(request, instanceId, workflowId);
showSuccess(t('Workflow gelöscht'));
await load();
return true;
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
return false;
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleEdit = useCallback(
(row: Automation2Workflow) => {
if (!mandateId || !instanceId) return;
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
},
[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 ? t('Workflow aktiviert') : t('Workflow deaktiviert'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') }));
} finally {
setTogglingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleRename = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const newLabel = await promptInput(t('Neuer Name:'), {
title: t('Workflow umbenennen'),
defaultValue: row.label,
placeholder: t('Workflow-Name'),
});
if (!newLabel || newLabel.trim() === row.label) return;
try {
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
showSuccess(t('Workflow umbenannt'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
}
},
[instanceId, request, promptInput, showSuccess, showError, load, t]
);
const handleExecute = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
setExecutingId(row.id);
try {
const invs = row.invocations || [];
const primary =
invs.find((i) => i.enabled && i.kind === 'manual') ||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
const result = await executeGraph(request, instanceId, row.graph!, row.id, {
...(primary ? { entryPointId: primary.id } : {}),
});
if (result?.success) {
if (result?.paused) {
showSuccess(t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.'));
} else {
showSuccess(t('Workflow ausgeführt'));
}
await load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
} finally {
setExecutingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleExport = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
try {
const result = await exportWorkflowToFile(request, instanceId, row.id, false);
const fileName = result.fileName || workflowFileNameFor(row.label);
const blob = new Blob([JSON.stringify(result.envelope, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('Workflow als Datei exportiert'));
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') }));
}
},
[instanceId, request, showSuccess, showError, t],
);
const handleImportFileSelected = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !instanceId) return;
setImporting(true);
try {
const text = await file.text();
let envelope: WorkflowFileEnvelope;
try {
envelope = JSON.parse(text) as WorkflowFileEnvelope;
} catch {
showError(t('Datei ist kein gültiges JSON'));
return;
}
if (!isWorkflowFileContent(envelope)) {
showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION }));
return;
}
const result = await importWorkflowFromFile(request, instanceId, { envelope });
const warnings = result?.warnings ?? [];
if (warnings.length > 0) {
showSuccess(
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }),
);
} else {
showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.'));
}
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') }));
} finally {
setImporting(false);
}
},
[instanceId, request, showSuccess, showError, load, t],
);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
{ key: 'active', width: 80, sortable: true, filterable: true },
{ key: 'isRunning', width: 80, sortable: true, filterable: true },
{
key: 'stuckAtNodeLabel',
label: t('steht bei'),
width: 160,
sortable: false,
filterable: false,
formatter: (value: string, row: Automation2Workflow) =>
row.isRunning && (value || row.stuckAtNodeId)
? value || row.stuckAtNodeId || '—'
: '—',
},
{
key: 'sysCreatedAt',
label: t('Erstellt'),
width: 140,
sortable: true,
filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'lastStartedAt',
label: t('zuletzt gestartet'),
width: 160,
sortable: true,
filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
width: 80,
sortable: true,
filterable: true,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
const hookData = {
refetch: load,
handleDelete: (id: string) => handleDelete(id),
pagination: paginationMeta,
};
if (!instanceId) {
return (
<div className={styles.adminPage}>
<p>{t('Keine Feature-Instanz gefunden')}</p>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>
{t('Workflows verwalten, ausführen und bearbeiten')}
</p>
</div>
<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' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
</button>
))}
</div>
<button
className={styles.secondaryButton}
onClick={() => importFileInputRef.current?.click()}
disabled={importing || loading}
title={t('Workflow aus Datei importieren ({ext})', { ext: WORKFLOW_FILE_EXTENSION })}
>
<FaFileImport /> {importing ? t('Importiere...') : t('Importieren')}
</button>
<input
ref={importFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportFileSelected}
/>
<button
className={styles.secondaryButton}
onClick={() => load()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<Automation2Workflow>
data={workflows}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
apiEndpoint={`/api/workflows/${instanceId}/workflows`}
actionButtons={[
{
type: 'edit',
title: t('bearbeiten'),
onAction: handleEdit,
},
{
type: 'delete',
title: t('löschen'),
},
]}
customActions={[
{
id: 'rename',
icon: <FaPen />,
title: t('umbenennen'),
onClick: (row) => handleRename(row),
},
{
id: 'activate',
icon: <FaCheck />,
title: t('aktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active === false,
},
{
id: 'deactivate',
icon: <FaBan />,
title: t('deaktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active !== false,
},
{
id: 'execute',
icon: <FaPlay />,
title: t('ausführen'),
onClick: (row) => handleExecute(row),
loading: (row) => executingId === row.id,
visible: (row) => hasManualTrigger(row),
},
{
id: 'export',
icon: <FaFileExport />,
title: t('Als Datei exportieren'),
onClick: (row) => handleExport(row),
},
]}
onDelete={(row) => handleDelete(row.id)}
hookData={hookData}
emptyMessage={t('Keine Workflows gefunden. Erstelle einen.')}
/>
</div>
<PromptDialog />
</div>
);
};

View file

@ -7,26 +7,30 @@
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaUpload } from 'react-icons/fa';
import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaTimes, FaUpload } from 'react-icons/fa';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchTasks,
cancelPendingTaskStopRun,
completeTask,
fetchCompletedRuns,
fetchWorkflows,
executeGraph,
loadClickupListTasksForDropdown,
type Automation2Task,
type Automation2Workflow,
type CompletedRun,
type ApiRequestFunction,
} from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext';
import { Popup } from '../../../components/UiComponents/Popup';
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
import { useFileOperations } from '../../../hooks/useFiles';
import styles from './Automation2WorkflowsTasks.module.css';
import {
WorkflowRuntimeFormFields,
useWorkflowRuntimeFormRequiredOk,
type WorkflowRuntimeFormFieldRow,
} from '../../../components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -75,17 +79,38 @@ function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
}
/**
* Primary entry for execute matches GraphicalEditorWorkflowsPage.handleExecute
* (manual first, then form or api).
* Primary entry for execute align with first start node in graph order (backend-driven),
* then fall back to manual / form / api on invocations list.
*/
function getPrimaryEntryPoint(wf: Automation2Workflow) {
const invs = wf.invocations || [];
const nodes = wf.graph?.nodes ?? [];
for (const n of nodes) {
const nodeType = n.type;
if (typeof nodeType === 'string' && nodeType.startsWith('trigger.')) {
const inv = invs.find((i) => i.enabled !== false && i.id === n.id);
if (inv) return inv;
}
}
return (
invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api'))
);
}
/** Form field rows from graph trigger.form for workflow list (parameters.formFields). */
function getTriggerFormFieldsForWorkflow(wf: Automation2Workflow): WorkflowRuntimeFormFieldRow[] {
const primary = getPrimaryEntryPoint(wf);
if (!primary || primary.kind !== 'form') return [];
const nodes = wf.graph?.nodes ?? [];
let node = nodes.find((n) => n.id === primary.id && n.type === 'trigger.form');
if (!node) node = nodes.find((n) => n.type === 'trigger.form');
if (!node) return [];
const raw = (node.parameters as Record<string, unknown> | undefined)?.formFields;
if (!Array.isArray(raw)) return [];
return raw as WorkflowRuntimeFormFieldRow[];
}
function primaryKindLabel(kind: string): string {
if (kind === 'form') return 'Formular';
if (kind === 'manual') return 'Manuell';
@ -105,7 +130,11 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
const [completedExpanded, setCompletedExpanded] = useState(false);
const [outputExpanded, setOutputExpanded] = useState(true);
const [submitting, setSubmitting] = useState<string | null>(null);
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [formStartWorkflow, setFormStartWorkflow] = useState<Automation2Workflow | null>(null);
const [formStartFields, setFormStartFields] = useState<WorkflowRuntimeFormFieldRow[]>([]);
const [startFormData, setStartFormData] = useState<Record<string, unknown>>({});
const load = useCallback(async () => {
if (!instanceId) return;
@ -157,10 +186,37 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
}
};
const handleDismissOpenTask = async (taskId: string) => {
if (!instanceId) return;
setDismissingTaskId(taskId);
try {
const res = await cancelPendingTaskStopRun(request, instanceId, taskId);
if (res.success) {
showSuccess(t('Ausführung abgebrochen'));
await load();
} else {
showError(t('Abbrechen fehlgeschlagen'));
}
} catch (e: unknown) {
const msg =
(e as { message?: string })?.message ?? t('Abbrechen fehlgeschlagen');
showError(msg);
console.error('[graphicalEditor] cancel task failed', e);
} finally {
setDismissingTaskId(null);
}
};
const handleStartWorkflow = useCallback(
async (wf: Automation2Workflow) => {
if (!instanceId || !wf.graph) return;
const primary = getPrimaryEntryPoint(wf);
if (primary?.kind === 'form') {
setFormStartFields(getTriggerFormFieldsForWorkflow(wf));
setStartFormData({});
setFormStartWorkflow(wf);
return;
}
setExecutingWorkflowId(wf.id);
try {
const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
@ -187,6 +243,48 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
[instanceId, request, showSuccess, showError, load, t]
);
const formStartRequiredOk = useWorkflowRuntimeFormRequiredOk(formStartFields, startFormData);
const handleFormStartSubmit = useCallback(async () => {
if (!instanceId || !formStartWorkflow?.graph) return;
const wf = formStartWorkflow;
const primary = getPrimaryEntryPoint(wf);
const payload = { ...startFormData };
setExecutingWorkflowId(wf.id);
try {
const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
...(primary ? { entryPointId: primary.id } : {}),
payload,
});
if (result?.success) {
if (result?.paused) {
showSuccess(t('Workflow gestartet und bei Human Task pausiert.'));
} else {
showSuccess(t('Workflow gestartet'));
}
await load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: unknown) {
const msg =
(e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen');
showError(msg);
} finally {
setExecutingWorkflowId(null);
setFormStartWorkflow(null);
}
}, [
instanceId,
formStartWorkflow,
startFormData,
request,
showSuccess,
showError,
load,
t,
]);
const openTasks = tasks.filter((task) => task.status === 'pending');
const completedTasks = tasks.filter((task) => task.status !== 'pending');
@ -228,6 +326,9 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
instanceId={instanceId ?? undefined}
onSubmit={(result) => handleComplete(task.id, result)}
submitting={submitting === task.id}
showDismiss
onDismiss={() => handleDismissOpenTask(task.id)}
dismissing={dismissingTaskId === task.id}
/>
))}
</div>
@ -337,6 +438,41 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
)}
</div>
</aside>
<Popup
isOpen={formStartWorkflow != null}
title={t('Formular ausfüllen')}
onClose={() => setFormStartWorkflow(null)}
closable={
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
closeOnEscape={
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
size="medium"
footerContent={
<button
type="button"
onClick={() => void handleFormStartSubmit()}
disabled={
!formStartRequiredOk ||
(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
className={styles.popupSubmitButton}
>
{formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id
? t('wird gesendet')
: t('absenden')}
</button>
}
>
<WorkflowRuntimeFormFields
fields={formStartFields}
formData={startFormData}
setFormData={setStartFormData}
formFieldsClassName={styles.formFields}
/>
</Popup>
</div>
);
};
@ -406,99 +542,10 @@ interface TaskCardProps {
onSubmit: (result: Record<string, unknown>) => void;
submitting: boolean;
readOnly?: boolean;
}
/** Check if file matches accept string (e.g. ".pdf,image/*"). */
function relationshipTaskIdFromFormValue(v: unknown): string {
if (v && typeof v === 'object' && !Array.isArray(v) && 'add' in v) {
const a = (v as { add?: unknown[] }).add;
if (Array.isArray(a) && a[0] != null && String(a[0]).trim()) return String(a[0]);
}
return '';
}
function InputFormClickupTaskField({
connectionId,
listId,
value,
onChange,
request,
}: {
connectionId: string;
listId: string;
value: unknown;
onChange: (v: unknown) => void;
request: ApiRequestFunction;
}) {
const { t } = useLanguage();
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(t('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)' }}>
{t('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)' }}>{t('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="">{t('Aufgabe wählen')}</option>
{tasks.map((taskRow) => (
<option key={taskRow.id} value={taskRow.id}>
{taskRow.name}
</option>
))}
</select>
)}
</>
);
/** Open-task card: show top-right control to cancel run and remove from list. */
showDismiss?: boolean;
onDismiss?: () => void;
dismissing?: boolean;
}
const TaskCard: React.FC<TaskCardProps> = ({
@ -507,6 +554,9 @@ const TaskCard: React.FC<TaskCardProps> = ({
onSubmit,
submitting,
readOnly = false,
showDismiss = false,
onDismiss,
dismissing = false,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
@ -521,6 +571,12 @@ const TaskCard: React.FC<TaskCardProps> = ({
const nodeType = task.nodeType;
const stepLabel = getNodeStepLabel(config);
const inputFormFields: WorkflowRuntimeFormFieldRow[] =
nodeType === 'input.form'
? ((config.fields as WorkflowRuntimeFormFieldRow[]) ?? [])
: [];
const inputFormRequiredOk = useWorkflowRuntimeFormRequiredOk(inputFormFields, formData);
useEffect(() => {
setUploadedFiles([]);
setUploadError(null);
@ -530,82 +586,13 @@ const TaskCard: React.FC<TaskCardProps> = ({
if (readOnly) return null;
switch (nodeType) {
case 'input.form': {
const fields =
(config.fields as Array<{
name: string;
type: string;
label: string;
required?: boolean;
clickupConnectionId?: string;
clickupListId?: string;
clickupStatusOptions?: Array<{ value: string; label: string }>;
}>) ?? [];
const requiredFields = fields.filter((f) => f.required);
const allRequiredFilled = requiredFields.every((f) => {
const v = formData[f.name];
if (f.type === 'boolean') return true;
if (f.type === 'clickup_tasks') {
return relationshipTaskIdFromFormValue(v) !== '';
}
if (f.type === 'clickup_status') {
return v !== undefined && v !== null && String(v).trim() !== '';
}
return v !== undefined && v !== null && String(v).trim() !== '';
});
const formContent = (
<div className={styles.formFields}>
{fields.map((f) => (
<div key={f.name}>
<label>
{f.label || f.name}
{f.required && ' *'}
</label>
{f.type === 'boolean' ? (
<input
type="checkbox"
checked={(formData[f.name] as boolean) ?? false}
onChange={(e) =>
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="">{t('Status wählen')}</option>
{f.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={
f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'
}
value={(formData[f.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
}
/>
)}
</div>
))}
</div>
<WorkflowRuntimeFormFields
fields={inputFormFields}
formData={formData}
setFormData={setFormData}
formFieldsClassName={styles.formFields}
/>
);
return (
<>
@ -630,7 +617,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
onSubmit({ payload: formData });
setFormPopupOpen(false);
}}
disabled={submitting || !allRequiredFilled}
disabled={submitting || !inputFormRequiredOk}
className={styles.popupSubmitButton}
>
{submitting ? t('wird gesendet') : t('absenden')}
@ -897,8 +884,27 @@ const TaskCard: React.FC<TaskCardProps> = ({
}
};
const cardClass = showDismiss
? `${styles.taskCard} ${styles.taskCardDismissable}`
: styles.taskCard;
return (
<div className={styles.taskCard}>
<div className={cardClass}>
{showDismiss && onDismiss ? (
<button
type="button"
className={styles.dismissOpenTaskBtn}
title={t('Task entfernen und Ausführung abbrechen')}
aria-label={t('Task entfernen und Ausführung abbrechen')}
disabled={submitting || dismissing}
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
{dismissing ? <FaSpinner className={styles.spinner} /> : <FaTimes />}
</button>
) : null}
<div className={styles.taskMeta}>
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>{t('Workflow')}</span>

View file

@ -1,57 +0,0 @@
/**
* WorkspaceKeepAlive
*
* Renders the WorkspacePage permanently at the MainLayout level so it
* survives route changes. Visibility is toggled via CSS `display`
* instead of mount / unmount, preserving messages, SSE connections,
* files, and all other workspace state.
*
* Persistence is scoped per `(mandateId, instanceId)` switching to a
* different mandate or instance via the navigator unmounts the previous
* page and mounts a fresh one (otherwise stale state from tenant A
* leaks into tenant B).
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { WorkspacePage } from './WorkspacePage';
const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/;
interface WorkspaceKeepAliveProps {
isVisible: boolean;
}
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
if (!instanceId) return null;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
</div>
);
};

View file

@ -1,45 +1,2 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Vitest global setup: jest-dom matchers + jsdom polyfills required by some
// of our components (ResizeObserver, matchMedia, scrollIntoView).
// Vitest / jsdom setup (minimal).
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
class _ResizeObserverPolyfill {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
if (!('ResizeObserver' in globalThis)) {
(globalThis as unknown as { ResizeObserver: typeof _ResizeObserverPolyfill }).ResizeObserver =
_ResizeObserverPolyfill;
}
if (!('matchMedia' in window)) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
if (!('scrollIntoView' in HTMLElement.prototype)) {
(HTMLElement.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView =
function (): void {};
}

View file

@ -1,23 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Smoke test that validates the Vitest + jsdom setup is wired correctly.
// If this fails the rest of the suite is meaningless.
import { describe, expect, it } from 'vitest';
describe('vitest smoke', () => {
it('runs in jsdom and has window/document', () => {
expect(typeof window).toBe('object');
expect(typeof document).toBe('object');
});
it('has jest-dom matchers via globals setup', () => {
const div = document.createElement('div');
div.textContent = 'hello';
document.body.appendChild(div);
expect(div).toBeInTheDocument();
expect(div).toHaveTextContent('hello');
document.body.removeChild(div);
});
});

View file

@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
export interface KeepAliveRenderContext {
mandateId: string;
instanceId: string;
scopeKey: string;
}
/** Mandate-scoped persistent routes: cache (mandateId, instanceId) from the URL while hidden. */
export interface KeepAliveScopedEntry {
id: string;
pathRegex: RegExp;
scopeRegex: RegExp;
/**
* If false, mount once instanceId is known (Workspace). If true, both ids required (Commcoach, Graphical Editor).
*/
requireMandateForMount?: boolean;
/** Commcoach shell omits overflow:hidden; other routes use hidden. */
shellOverflowHidden?: boolean;
render: (ctx: KeepAliveRenderContext) => ReactNode;
}
/** Routes kept alive without mandate/instance scope (e.g. admin). */
export interface KeepAliveUnscopedEntry {
id: string;
pathRegex: RegExp;
render: () => ReactNode;
}
export type KeepAliveEntry = KeepAliveScopedEntry | KeepAliveUnscopedEntry;
export function isKeepAliveScoped(entry: KeepAliveEntry): entry is KeepAliveScopedEntry {
return 'scopeRegex' in entry;
}

View file

@ -259,7 +259,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
icon: 'sitemap',
views: [
{ code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'workflows', label: 'Workflows', path: 'workflows' },
{ code: 'templates', label: 'Vorlagen', path: 'templates' },
{ code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },

531
src/utils/scheduleCron.ts Normal file
View file

@ -0,0 +1,531 @@
/**
* 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)
*/
/** Primary planner modes (+ legacy aliases still read/written). */
export type ScheduleMode =
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| 'custom'
| 'daily'
| 'weekdays'
| 'weekly'
| 'calendar'
| 'interval';
export type CalendarPeriod = 'monthly' | 'yearly';
/** sek, min, h, T (Tage), a (Jahre) — legacy interval mode */
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
export interface ScheduleSpec {
mode: ScheduleMode;
hour: number;
minute: number;
/** 06 cron DOW; für weeks / weekly / weekdays */
weekdays: number[];
/** Tag des Monats 131 (Planner months: 128 empfohlen) */
monthDay: number;
/** 112, nur bei calendar + yearly (Legacy) */
monthIndex: number;
calendarPeriod: CalendarPeriod;
intervalValue: number;
intervalUnit: IntervalUnit;
/** mode === 'custom': Roh-Cron (5 oder 6 Felder) */
customCron: string;
/** mode === 'weeks': alle W Wochen (Phase 1: Cron entspricht W===1; W>1 nur in schedule persistiert) */
weeksInterval: number;
}
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
/** Anzeige MoSo (cron DOW) */
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' },
];
/** Minuten-Optionen für Dropdowns (wie Prototyp + feinere Schritte bis 59) */
export const MINUTE_SELECT_OPTIONS: readonly number[] = (() => {
const base = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
const set = new Set(base);
for (let i = 0; i < 60; i++) if (!set.has(i)) set.add(i);
return [...set].sort((a, b) => a - b);
})();
export function defaultScheduleSpec(): ScheduleSpec {
return {
mode: 'days',
hour: 9,
minute: 0,
weekdays: [1, 2, 3, 4, 5],
monthDay: 1,
monthIndex: 1,
calendarPeriod: 'monthly',
intervalValue: 1,
intervalUnit: 'minutes',
customCron: '0 9 * * 1-5',
weeksInterval: 1,
};
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Normalisiert Legacy-Modi für Planner-UI (ohne Cron neu zu bauen). */
export function normalizeSpecForPlanner(spec: ScheduleSpec): ScheduleSpec {
const s = { ...spec };
switch (s.mode) {
case 'daily':
return { ...s, mode: 'days', intervalValue: 1 };
case 'weekdays':
return {
...s,
mode: 'weeks',
weeksInterval: 1,
weekdays: [1, 2, 3, 4, 5],
};
case 'weekly':
return { ...s, mode: 'weeks', weeksInterval: s.weeksInterval || 1 };
case 'calendar':
if (s.calendarPeriod === 'yearly') {
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'calendar' }),
};
}
return {
...s,
mode: 'months',
intervalValue: 1,
monthDay: clamp(s.monthDay, 1, 28),
};
case 'interval': {
const u = s.intervalUnit;
if (u === 'minutes')
return { ...s, mode: 'minutes', intervalValue: Math.max(1, s.intervalValue) };
if (u === 'hours')
return {
...s,
mode: 'hours',
intervalValue: Math.max(1, s.intervalValue),
minute: 0,
};
if (u === 'days')
return {
...s,
mode: 'days',
intervalValue: Math.max(1, s.intervalValue),
hour: 0,
minute: 0,
};
if (u === 'seconds')
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
}
default:
return s;
}
}
/** 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 'minutes': {
const mm = clamp(Math.floor(spec.intervalValue), 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(Math.floor(spec.intervalValue), 1, 23);
return `${m} */${hh} * * *`;
}
case 'days': {
const d = Math.max(1, Math.floor(spec.intervalValue));
if (d <= 1) return `${m} ${h} * * *`;
const step = clamp(d, 2, 31);
return `${m} ${h} */${step} * *`;
}
case 'weeks':
case 'weekly': {
const days = [...new Set(spec.weekdays)]
.filter((x) => x >= 0 && x <= 6)
.sort((a, b) => {
const order = (x: number) => (x === 0 ? 7 : x);
return order(a) - order(b);
});
// Phase 1: weeksInterval > 1 nicht als Cron abbildbar — gleicher weekly-Ausdruck
if (days.length === 0) return `${m} ${h} * * 1`;
return `${m} ${h} * * ${days.join(',')}`;
}
case 'months': {
const dom = clamp(Math.floor(spec.monthDay), 1, 28);
const monIv = Math.max(1, Math.floor(spec.intervalValue));
if (monIv <= 1) return `${m} ${h} ${dom} * *`;
const step = clamp(monIv, 2, 12);
return `${m} ${h} ${dom} */${step} *`;
}
case 'custom': {
const c = (spec.customCron || '').trim();
if (!c) return '0 9 * * *';
return c;
}
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
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 sec = clamp(v, 1, 59);
return `*/${sec} * * * * *`;
}
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 `${m} ${h} * * *`;
const d = clamp(v, 2, 31);
return `${m} ${h} */${d} * *`;
}
case 'years':
default:
return `0 0 1 1 *`;
}
}
default:
return `${m} ${h} * * *`;
}
}
/** Validiert 5- oder 6-Feld-Cron (Whitespace-getrennt). */
export function isValidCronFieldCount(cron: string): boolean {
const n = cron.trim().split(/\s+/).length;
return n === 5 || n === 6;
}
/** 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 trimmed = cron.trim();
const p = trimmed.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,
};
}
}
const base = { ...defaultScheduleSpec(), mode: 'custom' as const, customCron: trimmed };
return base;
}
if (p.length < 5) return null;
let [minS, hourS, domS, monthS, dowS] = p;
if (minS.startsWith('*/') && hourS === '*' && domS === '*') {
const iv = parseInt(minS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'minutes',
intervalValue: iv,
minute: 0,
hour: 0,
};
}
}
const minNum = parseInt(minS, 10);
const hourNum = parseInt(hourS, 10);
if (
!minS.startsWith('*/') &&
!Number.isNaN(minNum) &&
hourS.startsWith('*/') &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'hours',
intervalValue: iv,
minute: minNum,
hour: 0,
};
}
}
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'hours',
intervalValue: iv,
minute: 0,
hour: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
domS.startsWith('*/') &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'days',
intervalValue: iv,
hour: hourNum,
minute: minNum,
};
}
}
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'days',
intervalValue: iv,
hour: 0,
minute: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
!domS.startsWith('*/') &&
monthS.startsWith('*/') &&
(dowS === '*' || dowS === '?')
) {
const dom = parseInt(domS, 10);
const iv = parseInt(monthS.slice(2), 10);
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && !Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'months',
intervalValue: iv,
monthDay: dom,
hour: hourNum,
minute: minNum,
};
}
}
const minute = minNum;
const hour = hourNum;
if (Number.isNaN(minute) || Number.isNaN(hour)) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
if (domS === '*' && dowS === '*') {
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
}
if (domS === '*' && dowS === '1-5') {
return {
...defaultScheduleSpec(),
mode: 'weekdays',
hour,
minute,
weekdays: [1, 2, 3, 4, 5],
};
}
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,
weeksInterval: 1,
};
}
}
const dom = parseInt(domS, 10);
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
if (
(domS.includes(',') || domS.includes('-') || domS.includes('/')) &&
!domS.startsWith('*/')
) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
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 { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
const VALID_MODES: ScheduleMode[] = [
'minutes',
'hours',
'days',
'weeks',
'months',
'custom',
'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';
}
const spec: ScheduleSpec = {
mode: mode as ScheduleMode,
hour: Number.isFinite(Number(o.hour)) ? clamp(Number(o.hour), 0, 23) : base.hour,
minute: Number.isFinite(Number(o.minute)) ? clamp(Number(o.minute), 0, 59) : base.minute,
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),
customCron: typeof o.customCron === 'string' ? o.customCron : base.customCron,
weeksInterval: Math.max(1, Number(o.weeksInterval) || base.weeksInterval),
};
return normalizeSpecForPlanner(spec);
}
}
const cron = typeof params.cron === 'string' ? params.cron : '';
const parsed = parseCronToSpec(cron);
return normalizeSpecForPlanner(parsed ?? defaultScheduleSpec());
}
/** Serialisiert Spec für parameters.schedule (persistiert). */
export function scheduleSpecToPersistentJson(spec: ScheduleSpec): Record<string, unknown> {
return {
mode: spec.mode,
hour: spec.hour,
minute: spec.minute,
weekdays: spec.weekdays,
monthDay: spec.monthDay,
monthIndex: spec.monthIndex,
calendarPeriod: spec.calendarPeriod,
intervalValue: spec.intervalValue,
intervalUnit: spec.intervalUnit,
customCron: spec.customCron,
weeksInterval: spec.weeksInterval,
};
}