Compare commits
24 commits
main
...
feat/grafi
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ff00802c | |||
| b5084c028e | |||
| 587dad5cf9 | |||
| 0fd05f638f | |||
| aa61e00af6 | |||
| 7e2ffb42fe | |||
| dd26ea132d | |||
| 50a3df5c18 | |||
| e7f2272c30 | |||
| ef9955257e | |||
| 6890a38546 | |||
| 590178b8f2 | |||
| e3c93dc220 | |||
| 600e0c87dc | |||
| 9e36075f0e | |||
| 3a7a34a4f3 | |||
| c13489e232 | |||
| 4bf6677bc5 | |||
| 8860f49714 | |||
| 74dc7b85f8 | |||
| 66a7a6fa56 | |||
| 294803e66c | |||
| ae630201ba | |||
| 7fb96451a5 |
74 changed files with 7058 additions and 5302 deletions
|
|
@ -169,8 +169,8 @@ function App() {
|
||||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||||
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
||||||
|
|
||||||
{/* Automation2 Workflows & Tasks */}
|
{/* Automation2: legacy workflows URL → editor */}
|
||||||
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
<Route path="workflows" element={<Navigate to="../editor" replace />} />
|
||||||
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
||||||
|
|
||||||
{/* Teams Bot Feature Views */}
|
{/* Teams Bot Feature Views */}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ export interface PortField {
|
||||||
enumValues?: string[] | null;
|
enumValues?: string[] | null;
|
||||||
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
|
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
|
||||||
recommended?: boolean;
|
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 {
|
export interface PortSchema {
|
||||||
|
|
@ -39,6 +43,20 @@ export interface PortSchema {
|
||||||
fields: PortField[];
|
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 {
|
export interface InputPortDef {
|
||||||
accepts: string[];
|
accepts: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +71,11 @@ export interface OutputPortDef {
|
||||||
schema: string | GraphDefinedSchemaRef;
|
schema: string | GraphDefinedSchemaRef;
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
deriveFrom?: string;
|
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 {
|
export interface NodeType {
|
||||||
|
|
@ -76,7 +99,6 @@ export interface NodeType {
|
||||||
action?: string;
|
action?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeTypeCategory {
|
export interface NodeTypeCategory {
|
||||||
id: string;
|
id: string;
|
||||||
label: Record<string, string> | string;
|
label: Record<string, string> | string;
|
||||||
|
|
@ -94,10 +116,19 @@ export interface FormFieldType {
|
||||||
portType: string;
|
portType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConditionOperatorDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
labelKey?: string;
|
||||||
|
needsValue: boolean;
|
||||||
|
valueInput?: { kind: string; options?: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeTypesResponse {
|
export interface NodeTypesResponse {
|
||||||
nodeTypes: NodeType[];
|
nodeTypes: NodeType[];
|
||||||
categories: NodeTypeCategory[];
|
categories: NodeTypeCategory[];
|
||||||
portTypeCatalog?: Record<string, PortSchema>;
|
portTypeCatalog?: Record<string, PortSchema>;
|
||||||
|
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
|
||||||
systemVariables?: Record<string, SystemVariable>;
|
systemVariables?: Record<string, SystemVariable>;
|
||||||
formFieldTypes?: FormFieldType[];
|
formFieldTypes?: FormFieldType[];
|
||||||
}
|
}
|
||||||
|
|
@ -288,15 +319,17 @@ export async function fetchNodeTypes(
|
||||||
const nodeTypes = data?.nodeTypes ?? [];
|
const nodeTypes = data?.nodeTypes ?? [];
|
||||||
const categories = data?.categories ?? [];
|
const categories = data?.categories ?? [];
|
||||||
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
|
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
|
||||||
|
const conditionOperatorCatalog = data?.conditionOperatorCatalog ?? undefined;
|
||||||
const systemVariables = data?.systemVariables ?? undefined;
|
const systemVariables = data?.systemVariables ?? undefined;
|
||||||
const formFieldTypes = data?.formFieldTypes ?? undefined;
|
const formFieldTypes = data?.formFieldTypes ?? undefined;
|
||||||
console.log(
|
console.log(
|
||||||
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
|
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
|
||||||
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
|
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
|
||||||
|
`${conditionOperatorCatalog ? Object.keys(conditionOperatorCatalog).length : 0} conditionKinds, ` +
|
||||||
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` +
|
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` +
|
||||||
`${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes`
|
`${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes`
|
||||||
);
|
);
|
||||||
return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes };
|
return { nodeTypes, categories, portTypeCatalog, conditionOperatorCatalog, systemVariables, formFieldTypes };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpstreamPathEntry {
|
export interface UpstreamPathEntry {
|
||||||
|
|
@ -306,6 +339,39 @@ export interface UpstreamPathEntry {
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
scopeOrigin: 'data' | 'loop' | 'system';
|
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[] };
|
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). */
|
/** GET saved workflow graph variant of upstream-paths (requires workflowId). */
|
||||||
export async function getUpstreamPathsSaved(
|
export async function getUpstreamPathsSaved(
|
||||||
request: ApiRequestFunction,
|
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)
|
// Versions (AutoVersion Lifecycle)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
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 {
|
export interface Automation2DataFlowContextValue {
|
||||||
currentNodeId: string;
|
currentNodeId: string;
|
||||||
|
|
@ -19,6 +19,8 @@ export interface Automation2DataFlowContextValue {
|
||||||
systemVariables: Record<string, SystemVariable>;
|
systemVariables: Record<string, SystemVariable>;
|
||||||
/** Canonical form field types from the API — maps UI type id to portType primitive. */
|
/** Canonical form field types from the API — maps UI type id to portType primitive. */
|
||||||
formFieldTypes: FormFieldType[];
|
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;
|
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||||
getAvailableSourceIds: () => string[];
|
getAvailableSourceIds: () => string[];
|
||||||
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
|
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
|
||||||
|
|
@ -44,6 +46,7 @@ interface Automation2DataFlowProviderProps {
|
||||||
portTypeCatalog?: Record<string, PortSchema>;
|
portTypeCatalog?: Record<string, PortSchema>;
|
||||||
systemVariables?: Record<string, SystemVariable>;
|
systemVariables?: Record<string, SystemVariable>;
|
||||||
formFieldTypes?: FormFieldType[];
|
formFieldTypes?: FormFieldType[];
|
||||||
|
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
request?: ApiRequestFunction;
|
request?: ApiRequestFunction;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -59,6 +62,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
portTypeCatalog = {},
|
portTypeCatalog = {},
|
||||||
systemVariables = {},
|
systemVariables = {},
|
||||||
formFieldTypes = [],
|
formFieldTypes = [],
|
||||||
|
conditionOperatorCatalog = {},
|
||||||
instanceId,
|
instanceId,
|
||||||
request,
|
request,
|
||||||
children,
|
children,
|
||||||
|
|
@ -120,6 +124,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
portTypeCatalog,
|
portTypeCatalog,
|
||||||
systemVariables,
|
systemVariables,
|
||||||
formFieldTypes,
|
formFieldTypes,
|
||||||
|
conditionOperatorCatalog,
|
||||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||||
n.title ?? n.label ?? n.type ?? n.id,
|
n.title ?? n.label ?? n.type ?? n.id,
|
||||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||||
|
|
@ -127,7 +132,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
request,
|
request,
|
||||||
parseGraphDefinedSchema,
|
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 (
|
return (
|
||||||
<Automation2DataFlowContext.Provider value={value}>
|
<Automation2DataFlowContext.Provider value={value}>
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--canvas-bg, #fafafa);
|
background: var(--canvas-bg, #fafafa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,27 +257,133 @@
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
|
.canvasHeaderToolbar {
|
||||||
.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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
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;
|
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. */
|
/* Closed <select> width must not follow the longest option label. */
|
||||||
|
|
@ -284,89 +391,41 @@
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 12.5rem;
|
width: 12.5rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.31rem 0.45rem;
|
||||||
min-height: 2rem;
|
min-height: 30px;
|
||||||
font-size: 0.85rem;
|
box-sizing: border-box;
|
||||||
|
font-size: 0.8125rem;
|
||||||
border: 1px solid var(--border-color, #ccc);
|
border: 1px solid var(--border-color, #ccc);
|
||||||
border-radius: 6px;
|
border-radius: var(--button-border-radius, 6px);
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
color: var(--text-primary, #333);
|
color: var(--text-primary, #333);
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderTitleBlock {
|
.canvasHeaderIconBtn {
|
||||||
flex: 1 1 8rem;
|
padding: 6px !important;
|
||||||
min-width: 0;
|
min-width: 30px !important;
|
||||||
display: flex;
|
min-height: 30px !important;
|
||||||
align-items: center;
|
box-sizing: border-box !important;
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderTitle,
|
.canvasHeaderSplitPair :global(.button + .button) {
|
||||||
.canvasHeaderTitle input {
|
margin-left: 0;
|
||||||
margin: 0;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1a1a);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderTitle {
|
.canvasHeaderRunBlocked {
|
||||||
line-height: 1.2;
|
background: rgba(220, 53, 69, 0.1) !important;
|
||||||
overflow: hidden;
|
border: 1px solid var(--danger-color, #dc3545) !important;
|
||||||
text-overflow: ellipsis;
|
color: var(--danger-color, #dc3545) !important;
|
||||||
white-space: nowrap;
|
cursor: help !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderTitleMuted {
|
.canvasHeaderRunBlocked:hover:not(:disabled) {
|
||||||
font-style: italic;
|
filter: brightness(0.97);
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.65;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderTitle input {
|
.canvasHeaderRunBlocked :global(.buttonIcon) {
|
||||||
width: 100%;
|
opacity: 0.5;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderVersionRow {
|
.canvasHeaderVersionRow {
|
||||||
|
|
@ -380,7 +439,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderVersionRow button {
|
.canvasHeaderVersionRow :global(.button) {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,6 +450,57 @@
|
||||||
flex: 0 0 auto;
|
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 {
|
.canvasHeaderVersionSelect {
|
||||||
width: 11rem;
|
width: 11rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -484,22 +594,183 @@
|
||||||
|
|
||||||
.canvasArea {
|
.canvasArea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 0;
|
||||||
min-height: 400px;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow-x: visible;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasDropZone {
|
.canvasDropZone {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
/* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
|
||||||
|
overflow: visible;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
|
/* 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-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
|
||||||
background-repeat: repeat;
|
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 {
|
.canvasContent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -695,6 +966,8 @@
|
||||||
|
|
||||||
.handleWrapper:has(.handleOutput) {
|
.handleWrapper:has(.handleOutput) {
|
||||||
flex-direction: row;
|
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) {
|
.handleWrapper:has(.handleInput) {
|
||||||
|
|
@ -726,6 +999,16 @@
|
||||||
cursor: copy;
|
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
|
/* Node Config Panel
|
||||||
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
|
* 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
|
* 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.
|
* a long label rather than escaping to the right.
|
||||||
*/
|
*/
|
||||||
.nodeConfigPanel {
|
.nodeConfigPanel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
border-left: 1px solid var(--border-color, #e0e0e0);
|
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||||
width: 280px;
|
width: 280px;
|
||||||
flex-shrink: 0;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeConfigPanel h4 {
|
.nodeConfigPanel h4 {
|
||||||
|
|
@ -808,7 +1094,9 @@
|
||||||
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
|
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
|
||||||
(DataPicker-Dialog wird per createPortal an document.body gehangen — nicht hier). */
|
(DataPicker-Dialog wird per createPortal an document.body gehangen — nicht hier). */
|
||||||
.nodeConfigPanel
|
.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;
|
margin-top: 0.5rem;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
@ -901,6 +1189,12 @@
|
||||||
background: rgba(220, 53, 69, 0.1);
|
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 */
|
/* Upload node config */
|
||||||
.uploadNodeConfig {
|
.uploadNodeConfig {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1491,24 +1785,6 @@
|
||||||
cursor: pointer;
|
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,
|
.startsInput,
|
||||||
.startsSelect {
|
.startsSelect {
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
|
|
@ -1771,6 +2047,39 @@
|
||||||
border-color: var(--primary-color, #007bff);
|
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 */
|
/* Dynamic Value Field */
|
||||||
.dynamicValueField {
|
.dynamicValueField {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Automation2FlowEditor
|
* Automation2FlowEditor
|
||||||
*
|
*
|
||||||
* n8n-style flow builder with backend-driven node list.
|
* n8n-style flow builder with backend-driven node list and categories.
|
||||||
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
|
* 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';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
|
@ -32,18 +32,20 @@ import {
|
||||||
type AutoVersion,
|
type AutoVersion,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} 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 { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
|
||||||
import {
|
|
||||||
syncCanvasStartNode,
|
|
||||||
buildInvocationsForPrimaryKind,
|
|
||||||
} from '../nodes/runtime/workflowStartSync';
|
|
||||||
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||||
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
||||||
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
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 styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
|
||||||
import { useFeatureStore } from '../../../stores/featureStore';
|
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
const LOG = '[Automation2]';
|
||||||
|
|
||||||
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
|
const CANVAS_HISTORY_MAX = 50;
|
||||||
buildInvocationsForPrimaryKind('manual', [], runLabel);
|
|
||||||
|
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 {
|
interface Automation2FlowEditorProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
|
|
@ -92,7 +104,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onSourcesChanged,
|
onSourcesChanged,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { showError } = useToast();
|
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||||
|
|
@ -100,24 +111,37 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
||||||
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
||||||
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
|
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
|
||||||
|
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
|
||||||
|
Record<string, import('../../../api/workflowApi').ConditionOperatorDef[]>
|
||||||
|
>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
||||||
);
|
);
|
||||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
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 [executing, setExecuting] = useState(false);
|
||||||
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
||||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
|
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
|
||||||
_buildDefaultInvocations(t('Jetzt ausführen'))
|
|
||||||
);
|
|
||||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
|
||||||
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
||||||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||||
|
|
@ -136,13 +160,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [versionLoading, setVersionLoading] = useState(false);
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
|
|
||||||
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
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(() => {
|
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
||||||
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
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';
|
document.body.style.userSelect = 'none';
|
||||||
}, [leftPanelWidth, sidebarWidth]);
|
}, [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(
|
const nodeOutputsPreview = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -219,22 +247,73 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
|
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
|
||||||
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [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(
|
const applyGraphWithSync = useCallback(
|
||||||
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
(
|
||||||
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
graph: Automation2Graph | null | undefined,
|
||||||
setInvocations(inv);
|
wfInvocations: WorkflowEntryPoint[] | undefined,
|
||||||
if (!graph?.nodes?.length) {
|
opts?: { skipHistory?: boolean }
|
||||||
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
|
) => {
|
||||||
setCanvasNodes(synced.nodes);
|
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
|
||||||
setCanvasConnections(synced.connections);
|
pushCanvasHistoryPastFromCurrent();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
setInvocations(wfInvocations ?? []);
|
||||||
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
|
const g: Automation2Graph = graph ?? { nodes: [], connections: [] };
|
||||||
setCanvasNodes(synced.nodes);
|
const { nodes, connections } = fromApiGraph(g, nodeTypes);
|
||||||
setCanvasConnections(synced.connections);
|
setCanvasNodes(nodes);
|
||||||
|
setCanvasConnections(connections);
|
||||||
},
|
},
|
||||||
[nodeTypes, language, t]
|
[nodeTypes, pushCanvasHistoryPastFromCurrent]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFromApiGraph = useCallback(
|
const handleFromApiGraph = useCallback(
|
||||||
|
|
@ -263,6 +342,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (missingStartNodeBlocking) {
|
||||||
|
setExecuteResult({
|
||||||
|
success: false,
|
||||||
|
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -280,7 +366,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setExecuting(false);
|
setExecuting(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
|
@ -296,19 +382,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const errorNodeCount = Object.keys(nodeErrors).length;
|
const errorNodeCount = Object.keys(nodeErrors).length;
|
||||||
const _buildSaveResult = (): ExecuteGraphResponse => ({
|
const _buildSaveResult = (): ExecuteGraphResponse => {
|
||||||
success: true,
|
const parts: string[] = [];
|
||||||
warning:
|
if (errorCount > 0) {
|
||||||
errorCount > 0
|
parts.push(
|
||||||
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
|
t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
|
||||||
.replace('{n}', String(errorCount))
|
.replace('{n}', String(errorCount))
|
||||||
.replace('{m}', String(errorNodeCount))
|
.replace('{m}', String(errorNodeCount))
|
||||||
: undefined,
|
);
|
||||||
});
|
}
|
||||||
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (currentWorkflowId) {
|
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());
|
setExecuteResult(_buildSaveResult());
|
||||||
} else {
|
} else {
|
||||||
const label = await promptInput(t('Workflow-Name:'), {
|
const label = await promptInput(t('Workflow-Name:'), {
|
||||||
|
|
@ -327,7 +426,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
targetFeatureInstanceId,
|
targetFeatureInstanceId,
|
||||||
});
|
});
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
if (created.invocations?.length) setInvocations(created.invocations);
|
setInvocations(created.invocations ?? []);
|
||||||
setWorkflows((prev) => [...prev, created]);
|
setWorkflows((prev) => [...prev, created]);
|
||||||
setExecuteResult(_buildSaveResult());
|
setExecuteResult(_buildSaveResult());
|
||||||
}
|
}
|
||||||
|
|
@ -336,7 +435,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
|
|
@ -361,7 +460,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
|
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
|
||||||
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
|
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
try {
|
try {
|
||||||
const result = await fetchWorkflows(request, instanceId);
|
const result = await fetchWorkflows(request, instanceId);
|
||||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||||
|
|
@ -385,7 +484,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
if (workflowId) handleLoad(workflowId);
|
if (workflowId) handleLoad(workflowId);
|
||||||
else {
|
else {
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleLoad, applyGraphWithSync, t]
|
[handleLoad, applyGraphWithSync, t]
|
||||||
|
|
@ -394,36 +493,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const handleNew = useCallback(() => {
|
const handleNew = useCallback(() => {
|
||||||
setCurrentWorkflowId(null);
|
setCurrentWorkflowId(null);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
}, [applyGraphWithSync, t]);
|
}, [applyGraphWithSync, t]);
|
||||||
|
|
||||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||||
setCanvasNodes((prev) =>
|
setCanvasNodes((prev) => {
|
||||||
prev.map((n) => {
|
const nextNodes = prev.map((n) => {
|
||||||
if (n.id !== nodeId) return n;
|
if (n.id !== nodeId) return n;
|
||||||
const next = { ...n, parameters };
|
const next = { ...n, parameters };
|
||||||
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
||||||
const cases = (parameters.cases as unknown[]) ?? [];
|
const newCount = switchOutputCountFromCases(parameters.cases);
|
||||||
next.outputs = Math.max(1, cases.length);
|
next.outputs = newCount;
|
||||||
|
setCanvasConnections((conns) =>
|
||||||
|
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
);
|
return nextNodes;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
||||||
setCanvasNodes((prev) =>
|
setCanvasNodes((prev) => {
|
||||||
prev.map((n) => {
|
const nextNodes = prev.map((n) => {
|
||||||
if (n.id !== nodeId) return n;
|
if (n.id !== nodeId) return n;
|
||||||
const merged = { ...(n.parameters ?? {}), ...patch };
|
const merged = { ...(n.parameters ?? {}), ...patch };
|
||||||
const next = { ...n, parameters: merged };
|
const next = { ...n, parameters: merged };
|
||||||
if (n.type === 'flow.switch' && 'cases' in merged) {
|
if (n.type === 'flow.switch' && 'cases' in merged) {
|
||||||
const cases = (merged.cases as unknown[]) ?? [];
|
const newCount = switchOutputCountFromCases(merged.cases);
|
||||||
next.outputs = Math.max(1, cases.length);
|
next.outputs = newCount;
|
||||||
|
setCanvasConnections((conns) =>
|
||||||
|
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
);
|
return nextNodes;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNodeUpdate = useCallback(
|
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 () => {
|
const loadNodeTypes = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -461,6 +556,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
}
|
}
|
||||||
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
||||||
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
|
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
|
||||||
|
if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setNodeTypes([]);
|
setNodeTypes([]);
|
||||||
|
|
@ -488,6 +584,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
loadWorkflows();
|
loadWorkflows();
|
||||||
}, [loadWorkflows]);
|
}, [loadWorkflows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCanvasStickyNotes([]);
|
||||||
|
}, [currentWorkflowId]);
|
||||||
|
|
||||||
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
|
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 (loading || nodeTypes.length === 0) return;
|
||||||
if (currentWorkflowId || initialWorkflowId) return;
|
if (currentWorkflowId || initialWorkflowId) return;
|
||||||
if (canvasNodes.length > 0) return;
|
if (canvasNodes.length > 0) return;
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, [], {
|
||||||
|
skipHistory: true,
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
loading,
|
loading,
|
||||||
nodeTypes.length,
|
nodeTypes.length,
|
||||||
|
|
@ -522,7 +624,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
const handleDropNodeType = useCallback(
|
const handleDropNodeType = useCallback(
|
||||||
(nodeTypeId: string, x: number, y: number) => {
|
(nodeTypeId: string, x: number, y: number) => {
|
||||||
if (nodeTypeId.startsWith('trigger.')) return;
|
|
||||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||||
if (!nt) return;
|
if (!nt) return;
|
||||||
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
@ -675,31 +776,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
[request, instanceId, handleFromApiGraph]
|
[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]);
|
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
||||||
|
|
||||||
|
|
@ -741,7 +820,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
language={language}
|
language={language}
|
||||||
expandedCategories={expandedCategories}
|
expandedCategories={expandedCategories}
|
||||||
onToggleCategory={toggleCategory}
|
onToggleCategory={toggleCategory}
|
||||||
excludedCategories={sidebarExcludedCategories}
|
|
||||||
style={_sidebarStyle}
|
style={_sidebarStyle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -749,15 +827,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
const configurableSelected =
|
const configurableSelected =
|
||||||
selectedNode &&
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
||||||
{leftPanelOpen && (<>
|
{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}>
|
<div className={styles.rightTabBar}>
|
||||||
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
|
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -829,15 +953,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onNew={handleNew}
|
onNew={handleNew}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
|
||||||
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
|
workspacePanelOpen={leftPanelOpen}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
executing={executing}
|
executing={executing}
|
||||||
hasNodes={canvasNodes.length > 0}
|
hasNodes={canvasNodes.length > 0}
|
||||||
executeBlockedReason={
|
executeBlockedReason={
|
||||||
hasGraphErrors
|
hasGraphErrors
|
||||||
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
|
? 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={() => {
|
onExecuteBlockedClick={() => {
|
||||||
if (firstErrorNodeId) {
|
if (firstErrorNodeId) {
|
||||||
|
|
@ -857,17 +983,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onSaveAsTemplate={handleSaveAsTemplate}
|
onSaveAsTemplate={handleSaveAsTemplate}
|
||||||
templateSaving={templateSaving}
|
templateSaving={templateSaving}
|
||||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||||
onWorkflowRename={handleWorkflowRename}
|
|
||||||
onAutoLayout={handleAutoLayout}
|
|
||||||
verboseSchema={verboseSchema}
|
verboseSchema={verboseSchema}
|
||||||
onVerboseSchemaChange={setVerboseSchema}
|
onVerboseSchemaChange={setVerboseSchema}
|
||||||
targetFeatureInstanceId={targetFeatureInstanceId}
|
canvasEdit={canvasHeaderEdit}
|
||||||
onTargetInstanceChange={handleTargetInstanceChange}
|
|
||||||
targetInstanceOptions={targetInstanceOptions}
|
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
|
||||||
<FlowCanvas
|
<FlowCanvas
|
||||||
|
ref={flowCanvasRef}
|
||||||
nodes={canvasNodes}
|
nodes={canvasNodes}
|
||||||
connections={canvasConnections}
|
connections={canvasConnections}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
|
@ -879,6 +1002,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onSelectionChange={setSelectedNode}
|
onSelectionChange={setSelectedNode}
|
||||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||||
nodeErrors={nodeErrors}
|
nodeErrors={nodeErrors}
|
||||||
|
onViewportEditState={setCanvasViewportEdit}
|
||||||
|
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
|
||||||
|
onConnectionToolActiveChange={setCanvasConnectionToolActive}
|
||||||
|
stickyNotes={canvasStickyNotes}
|
||||||
|
onStickyNotesChange={setCanvasStickyNotes}
|
||||||
onExternalDrop={async (mime, payload) => {
|
onExternalDrop={async (mime, payload) => {
|
||||||
if (mime !== 'application/json+workflow' || !instanceId) return false;
|
if (mime !== 'application/json+workflow' || !instanceId) return false;
|
||||||
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
||||||
|
|
@ -897,6 +1025,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{configurableSelected && selectedNode && (
|
{configurableSelected && selectedNode && (
|
||||||
|
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
|
||||||
<Automation2DataFlowProvider
|
<Automation2DataFlowProvider
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
nodes={canvasNodes}
|
nodes={canvasNodes}
|
||||||
|
|
@ -907,6 +1036,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||||
systemVariables={systemVariables as Record<string, never>}
|
systemVariables={systemVariables as Record<string, never>}
|
||||||
formFieldTypes={formFieldTypes}
|
formFieldTypes={formFieldTypes}
|
||||||
|
conditionOperatorCatalog={conditionOperatorCatalog}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
request={request}
|
request={request}
|
||||||
>
|
>
|
||||||
|
|
@ -922,13 +1052,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
verboseSchema={verboseSchema}
|
verboseSchema={verboseSchema}
|
||||||
/>
|
/>
|
||||||
</Automation2DataFlowProvider>
|
</Automation2DataFlowProvider>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel: Nodes + Tracing tabs */}
|
{/* Right panel: Nodes + Tracing tabs */}
|
||||||
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
|
<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}>
|
<div className={styles.rightTabBar}>
|
||||||
<button
|
<button
|
||||||
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
|
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
|
||||||
|
|
@ -961,12 +1095,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PromptDialog />
|
<PromptDialog />
|
||||||
<WorkflowConfigurationModal
|
|
||||||
open={workflowSettingsOpen}
|
|
||||||
onClose={() => setWorkflowSettingsOpen(false)}
|
|
||||||
invocations={invocations}
|
|
||||||
onApply={handleApplyWorkflowConfiguration}
|
|
||||||
/>
|
|
||||||
<TemplatePicker
|
<TemplatePicker
|
||||||
open={templatePickerOpen}
|
open={templatePickerOpen}
|
||||||
onClose={() => setTemplatePickerOpen(false)}
|
onClose={() => setTemplatePickerOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
|
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 type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { getUserDataCache } from '../../../utils/userCache';
|
import { getUserDataCache } from '../../../utils/userCache';
|
||||||
|
import { Button } from '../../UiComponents/Button';
|
||||||
|
|
||||||
interface TargetInstanceOption {
|
const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
|
||||||
id: string;
|
|
||||||
label: string;
|
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 {
|
interface CanvasHeaderProps {
|
||||||
|
|
@ -22,14 +67,14 @@ interface CanvasHeaderProps {
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onExecute: () => void;
|
onExecute: () => void;
|
||||||
onWorkflowSettings?: () => void;
|
onToggleWorkspacePanel?: () => void;
|
||||||
onToggleChat?: () => void;
|
workspacePanelOpen?: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
executing: boolean;
|
executing: boolean;
|
||||||
hasNodes: boolean;
|
hasNodes: boolean;
|
||||||
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
|
/** When set, required-field graph errors block a normal run; message is the
|
||||||
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
|
* run button tooltip. Click still fires `onExecuteBlockedClick` to focus
|
||||||
* parent can navigate the user to the first offending node. */
|
* the first offending node. */
|
||||||
executeBlockedReason?: string | null;
|
executeBlockedReason?: string | null;
|
||||||
onExecuteBlockedClick?: () => void;
|
onExecuteBlockedClick?: () => void;
|
||||||
executeResult: ExecuteGraphResponse | null;
|
executeResult: ExecuteGraphResponse | null;
|
||||||
|
|
@ -44,15 +89,11 @@ interface CanvasHeaderProps {
|
||||||
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
||||||
templateSaving?: boolean;
|
templateSaving?: boolean;
|
||||||
onNewFromTemplate?: () => void;
|
onNewFromTemplate?: () => void;
|
||||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
|
||||||
onAutoLayout?: () => void;
|
|
||||||
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
||||||
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||||
verboseSchema?: boolean;
|
verboseSchema?: boolean;
|
||||||
onVerboseSchemaChange?: (next: boolean) => void;
|
onVerboseSchemaChange?: (next: boolean) => void;
|
||||||
targetFeatureInstanceId?: string | null;
|
canvasEdit?: CanvasHeaderCanvasEditProps;
|
||||||
onTargetInstanceChange?: (instanceId: string) => void;
|
|
||||||
targetInstanceOptions?: TargetInstanceOption[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
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,
|
currentWorkflowId,
|
||||||
onWorkflowSelect,
|
onWorkflowSelect,
|
||||||
onNew,
|
onNew,
|
||||||
onSave,
|
onSave,
|
||||||
onExecute,
|
onExecute,
|
||||||
onWorkflowSettings,
|
onToggleWorkspacePanel,
|
||||||
onToggleChat,
|
workspacePanelOpen,
|
||||||
saving,
|
saving,
|
||||||
executing,
|
executing,
|
||||||
hasNodes,
|
hasNodes,
|
||||||
|
|
@ -88,13 +133,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
onSaveAsTemplate,
|
onSaveAsTemplate,
|
||||||
templateSaving,
|
templateSaving,
|
||||||
onNewFromTemplate,
|
onNewFromTemplate,
|
||||||
onWorkflowRename,
|
|
||||||
onAutoLayout,
|
|
||||||
verboseSchema,
|
verboseSchema,
|
||||||
onVerboseSchemaChange,
|
onVerboseSchemaChange,
|
||||||
targetFeatureInstanceId,
|
canvasEdit,
|
||||||
onTargetInstanceChange,
|
|
||||||
targetInstanceOptions,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||||
|
|
@ -109,38 +150,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
const templateMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||||||
const [nameValue, setNameValue] = useState('');
|
const zoomMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const [zoomInputDraft, setZoomInputDraft] = useState('');
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingName && nameInputRef.current) {
|
const zp = canvasEdit?.zoomPercent;
|
||||||
nameInputRef.current.focus();
|
if (zp !== undefined) setZoomInputDraft(String(zp));
|
||||||
nameInputRef.current.select();
|
}, [canvasEdit?.zoomPercent]);
|
||||||
}
|
|
||||||
}, [editingName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _handleClickOutside = (e: MouseEvent) => {
|
const _handleClickOutside = (e: MouseEvent) => {
|
||||||
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
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 (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);
|
document.addEventListener('mousedown', _handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
||||||
|
|
@ -156,15 +179,106 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const _titleHint =
|
const _panelOpen = workspacePanelOpen ?? false;
|
||||||
onWorkflowRename && currentWorkflow
|
const _runAriaLabel = executing
|
||||||
? `${currentWorkflow.label} — ${t('Klicken zum Umbenennen')}`
|
? t('Ausführen…')
|
||||||
: currentWorkflow?.label;
|
: 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 (
|
return (
|
||||||
<div className={styles.canvasHeader}>
|
<div className={styles.canvasHeader} data-suppress-flow-node-hotkeys="">
|
||||||
<div className={styles.canvasHeaderRow}>
|
<div
|
||||||
<div className={styles.canvasHeaderContext}>
|
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
|
<select
|
||||||
className={styles.canvasHeaderWorkflowSelect}
|
className={styles.canvasHeaderWorkflowSelect}
|
||||||
value={currentWorkflowId ?? ''}
|
value={currentWorkflowId ?? ''}
|
||||||
|
|
@ -182,142 +296,53 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className={styles.canvasHeaderTitleBlock}>
|
<Button
|
||||||
{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
|
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
onClick={onSave}
|
size={_ts}
|
||||||
|
icon={saving ? undefined : FaSave}
|
||||||
|
className={styles.canvasHeaderIconBtn}
|
||||||
|
loading={saving}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
|
onClick={onSave}
|
||||||
>
|
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
|
||||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
aria-label={t('Speichern')}
|
||||||
</button>
|
/>
|
||||||
|
<Button
|
||||||
{onAutoLayout && (
|
type="button"
|
||||||
<button
|
variant={_tb}
|
||||||
type="button"
|
size={_ts}
|
||||||
className={styles.retryButton}
|
icon={executing ? undefined : FaPlay}
|
||||||
onClick={onAutoLayout}
|
loading={executing}
|
||||||
disabled={!hasNodes}
|
disabled={executing || !hasNodes}
|
||||||
title={t('Knoten automatisch anordnen')}
|
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
|
||||||
>
|
onClick={() => {
|
||||||
<FaSitemap style={{ marginRight: '0.4rem' }} />
|
if (executeBlockedReason) {
|
||||||
{t('Anordnen')}
|
onExecuteBlockedClick?.();
|
||||||
</button>
|
return;
|
||||||
)}
|
}
|
||||||
|
onExecute();
|
||||||
|
}}
|
||||||
|
aria-label={_runAriaLabel}
|
||||||
|
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
|
||||||
|
title={_runTitle}
|
||||||
|
/>
|
||||||
{currentWorkflowId && onSaveAsTemplate && (
|
{currentWorkflowId && onSaveAsTemplate && (
|
||||||
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
size={_ts}
|
||||||
|
icon={FaBookmark}
|
||||||
|
loading={templateSaving}
|
||||||
disabled={templateSaving}
|
disabled={templateSaving}
|
||||||
|
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||||
title={t('Als Vorlage speichern')}
|
title={t('Als Vorlage speichern')}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={templateMenuOpen}
|
aria-expanded={templateMenuOpen}
|
||||||
>
|
>
|
||||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
{t('Als Vorlage')}
|
||||||
</button>
|
</Button>
|
||||||
{templateMenuOpen && (
|
{templateMenuOpen && (
|
||||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||||
|
|
@ -325,7 +350,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.canvasHeaderMenuItem}
|
className={styles.canvasHeaderMenuItem}
|
||||||
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
|
onClick={() => {
|
||||||
|
onSaveAsTemplate(s);
|
||||||
|
setTemplateMenuOpen(false);
|
||||||
|
}}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
{scopeLabels[s]}
|
{scopeLabels[s]}
|
||||||
|
|
@ -336,53 +364,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
</div>
|
</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 && (
|
{_isSysAdmin && onVerboseSchemaChange && (
|
||||||
<label
|
<label
|
||||||
className={styles.canvasHeaderSysadmin}
|
className={styles.canvasHeaderSysadmin}
|
||||||
|
|
@ -392,14 +373,173 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!verboseSchema}
|
checked={!!verboseSchema}
|
||||||
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
|
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
|
||||||
style={{ margin: 0 }}
|
className={styles.canvasHeaderSysadminInput}
|
||||||
/>
|
/>
|
||||||
{t('Schema-Details')}
|
{t('Schema-Details')}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</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 && (
|
{currentWorkflowId && versions && versions.length > 0 && (
|
||||||
<div className={styles.canvasHeaderVersionRow}>
|
<div className={styles.canvasHeaderVersionRow}>
|
||||||
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
|
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
|
||||||
|
|
@ -418,108 +558,94 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
style={{
|
className={styles.canvasHeaderVersionBadge}
|
||||||
padding: '2px 8px',
|
style={
|
||||||
borderRadius: 10,
|
{
|
||||||
fontSize: '0.75rem',
|
'--canvasHeaderBadgeBg': `${badge.color}22`,
|
||||||
fontWeight: 600,
|
'--canvasHeaderBadgeFg': badge.color,
|
||||||
background: badge.color + '22',
|
} as React.CSSProperties
|
||||||
color: badge.color,
|
}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaCloudUploadAlt}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={() => onPublishVersion(currentVersion.id)}
|
onClick={() => onPublishVersion(currentVersion.id)}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Version veröffentlichen')}
|
title={t('Version veröffentlichen')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
<FaCloudUploadAlt style={{ marginRight: 4 }} />
|
|
||||||
{t('Veröffentlichen')}
|
{t('Veröffentlichen')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
|
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaCloudDownloadAlt}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={() => onUnpublishVersion(currentVersion.id)}
|
onClick={() => onUnpublishVersion(currentVersion.id)}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Veröffentlichung zurücknehmen')}
|
title={t('Veröffentlichung zurücknehmen')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
|
|
||||||
{t('Veröffentlichung aufheben')}
|
{t('Veröffentlichung aufheben')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
|
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaArchive}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={() => onArchiveVersion(currentVersion.id)}
|
onClick={() => onArchiveVersion(currentVersion.id)}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Version archivieren')}
|
title={t('Version archivieren')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
<FaArchive style={{ marginRight: 4 }} />
|
{t('Archiv')}
|
||||||
Archiv
|
</Button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{onCreateDraft && (
|
{onCreateDraft && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaPlus}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={onCreateDraft}
|
onClick={onCreateDraft}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Neuen Entwurf erstellen')}
|
title={t('Neuen Entwurf erstellen')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
+ Entwurf
|
{t('+ Entwurf')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
|
{versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{executeResult && (
|
{executeResult && (
|
||||||
<div
|
<div
|
||||||
style={{
|
className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{executeResult.success ? (
|
{executeResult.success ? (
|
||||||
executeResult.warning ? (
|
executeResult.warning ? (
|
||||||
<>⚠ {executeResult.warning}</>
|
<>{executeResult.warning}</>
|
||||||
) : (
|
) : (
|
||||||
<>{t('Ausführung abgeschlossen')}</>
|
<>{t('Ausführung abgeschlossen')}</>
|
||||||
)
|
)
|
||||||
) : (executeResult as { paused?: boolean }).paused ? (
|
) : executeResult.paused ? (
|
||||||
<>
|
<>
|
||||||
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
{t('Workflow pausiert. Öffne ')}
|
||||||
Task zu bearbeiten.
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,82 @@ import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
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 {
|
interface NodeConfigPanelProps {
|
||||||
node: CanvasNode | null;
|
node: CanvasNode | null;
|
||||||
|
|
@ -30,6 +106,35 @@ interface NodeConfigPanelProps {
|
||||||
verboseSchema?: boolean;
|
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,
|
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
nodeType,
|
nodeType,
|
||||||
language,
|
language,
|
||||||
|
|
@ -62,7 +167,32 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
const updateParam = useCallback(
|
const updateParam = useCallback(
|
||||||
(key: string, value: unknown) => {
|
(key: string, value: unknown) => {
|
||||||
setParams((prev) => {
|
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;
|
const id = nodeIdRef.current;
|
||||||
if (id) {
|
if (id) {
|
||||||
if (notifyParentTimeoutRef.current != null) {
|
if (notifyParentTimeoutRef.current != null) {
|
||||||
|
|
@ -115,6 +245,139 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}, [requiredErrors, nodeType, language]);
|
}, [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;
|
if (!node || !nodeType) return null;
|
||||||
|
|
||||||
const isTrigger = node.type.startsWith('trigger.');
|
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>
|
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parameters.map((param: NodeTypeParameter) => {
|
{extractContentAccordionItems !== null ? (
|
||||||
// Safety net: hidden params have no UI footprint at all — no row,
|
<AccordionList<string>
|
||||||
// no required-mark, no type-badge. Their value is system-set.
|
key={`${node.id}-extract-accordion`}
|
||||||
if (param.frontendType === 'hidden') return null;
|
defaultOpenId={null}
|
||||||
const useRequiredPicker = _shouldUseRequiredPicker(param);
|
items={extractContentAccordionItems}
|
||||||
if (useRequiredPicker) {
|
/>
|
||||||
|
) : (
|
||||||
|
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 (
|
return (
|
||||||
<div key={param.name} style={{ marginBottom: 8 }}>
|
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
|
||||||
<RequiredAttributePicker
|
<div
|
||||||
label={getLabel(param.description, language) || param.name}
|
style={{
|
||||||
expectedType={param.type}
|
display: 'flex',
|
||||||
value={params[param.name] ?? param.default}
|
alignItems: 'center',
|
||||||
onChange={(val) => updateParam(param.name, val)}
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -320,6 +593,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
||||||
'featureInstance',
|
'featureInstance',
|
||||||
'sharepointFolder',
|
'sharepointFolder',
|
||||||
'sharepointFile',
|
'sharepointFile',
|
||||||
|
'userFileFolder',
|
||||||
'clickupList',
|
'clickupList',
|
||||||
'clickupTask',
|
'clickupTask',
|
||||||
'dataRef',
|
'dataRef',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
* 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';
|
import React, { useMemo } from 'react';
|
||||||
|
|
@ -21,7 +21,7 @@ interface NodeSidebarProps {
|
||||||
language: string;
|
language: string;
|
||||||
expandedCategories: Set<string>;
|
expandedCategories: Set<string>;
|
||||||
onToggleCategory: (id: string) => void;
|
onToggleCategory: (id: string) => void;
|
||||||
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
|
/** Hide palette categories (optional; e.g. feature flags) */
|
||||||
excludedCategories?: Set<string>;
|
excludedCategories?: Set<string>;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
||||||
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
||||||
export { FlowCanvas } 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 } from './editor/FlowCanvas';
|
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
|
||||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||||
export { NodeSidebar } from './editor/NodeSidebar';
|
export { NodeSidebar } from './editor/NodeSidebar';
|
||||||
export { NodeListItem } from './editor/NodeListItem';
|
export { NodeListItem } from './editor/NodeListItem';
|
||||||
export { CanvasHeader } from './editor/CanvasHeader';
|
export { CanvasHeader } from './editor/CanvasHeader';
|
||||||
|
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
|
||||||
export * from './nodes/shared/utils';
|
export * from './nodes/shared/utils';
|
||||||
export * from './nodes/shared/constants';
|
export * from './nodes/shared/constants';
|
||||||
export * from './nodes/shared/graphUtils';
|
export * from './nodes/shared/graphUtils';
|
||||||
|
|
|
||||||
104
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal file
104
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,12 @@ import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
|
||||||
|
import {
|
||||||
|
deriveFormFieldPayloadKey,
|
||||||
|
formFieldTypeHasConfigurableOptions,
|
||||||
|
normalizeFormFieldOptions,
|
||||||
|
} from './formFieldOptionsUtils';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -64,20 +70,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.formFieldInputs}>
|
<div className={styles.formFieldInputs}>
|
||||||
<input
|
<input
|
||||||
placeholder={t('name')}
|
placeholder={t('Bezeichnung')}
|
||||||
value={f.name ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...fields];
|
|
||||||
next[i] = { ...next[i], name: e.target.value };
|
|
||||||
updateParam('fields', next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder={t('label')}
|
|
||||||
value={f.label ?? ''}
|
value={f.label ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
const label = e.target.value;
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
next[i] = { ...next[i], label: e.target.value };
|
next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
|
||||||
updateParam('fields', next);
|
updateParam('fields', next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -88,7 +86,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
|
||||||
value={f.type ?? 'text'}
|
value={f.type ?? 'text'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
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);
|
updateParam('fields', next);
|
||||||
}}
|
}}
|
||||||
style={{ width: 'auto', minWidth: 90 }}
|
style={{ width: 'auto', minWidth: 90 }}
|
||||||
|
|
@ -118,12 +121,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
|
||||||
<FaTimes />
|
<FaTimes />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
|
updateParam('fields', [
|
||||||
|
...fields,
|
||||||
|
{
|
||||||
|
name: deriveFormFieldPayloadKey('', fields.length),
|
||||||
|
type: 'text',
|
||||||
|
label: '',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
+ {t('Feld')}
|
+ {t('Feld')}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1 +1,8 @@
|
||||||
export { FormNodeConfig } from './FormNodeConfig';
|
export { FormNodeConfig } from './FormNodeConfig';
|
||||||
|
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
|
||||||
|
export type { FormFieldOptionRow } from './formFieldOptionsUtils';
|
||||||
|
export {
|
||||||
|
deriveFormFieldPayloadKey,
|
||||||
|
formFieldTypeHasConfigurableOptions,
|
||||||
|
normalizeFormFieldOptions,
|
||||||
|
} from './formFieldOptionsUtils';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,12 @@ import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
||||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
|
||||||
|
import {
|
||||||
|
deriveFormFieldPayloadKey,
|
||||||
|
formFieldTypeHasConfigurableOptions,
|
||||||
|
normalizeFormFieldOptions,
|
||||||
|
} from '../form/formFieldOptionsUtils';
|
||||||
|
|
||||||
export interface FieldRendererProps {
|
export interface FieldRendererProps {
|
||||||
param: NodeTypeParameter;
|
param: NodeTypeParameter;
|
||||||
|
|
@ -17,6 +23,10 @@ export interface FieldRendererProps {
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
request?: ApiRequestFunction;
|
request?: ApiRequestFunction;
|
||||||
nodeType?: string;
|
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>;
|
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||||
|
|
@ -26,6 +36,13 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { SchedulePlanner } from '../../../SchedulePlanner';
|
||||||
|
import {
|
||||||
|
buildCronFromSpec,
|
||||||
|
scheduleSpecFromParams,
|
||||||
|
scheduleSpecToPersistentJson,
|
||||||
|
type ScheduleSpec,
|
||||||
|
} from '../../../../utils/scheduleCron';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { toApiGraph } from '../shared/graphUtils';
|
import { toApiGraph } from '../shared/graphUtils';
|
||||||
|
|
@ -33,7 +50,11 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
|
||||||
import type { CanvasNode } from '../../editor/FlowCanvas';
|
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||||
import { DataRefRenderer } from './DataRefRenderer';
|
import { DataRefRenderer } from './DataRefRenderer';
|
||||||
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
|
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
|
||||||
|
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
|
||||||
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
||||||
|
import { UserFileFolderPicker } from './UserFileFolderPicker';
|
||||||
|
import { ConditionEditor } from './ConditionEditor';
|
||||||
|
import { CaseListEditor } from './CaseListEditor';
|
||||||
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
||||||
import { getApiBaseUrl } from '../../../../../config/config';
|
import { getApiBaseUrl } from '../../../../../config/config';
|
||||||
|
|
||||||
|
|
@ -98,29 +119,145 @@ const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) =>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
/** Backend may send `options: ["a","b"]` or `options: [{ value, label }, ...]` (e.g. context.extractContent). */
|
||||||
const options: string[] =
|
function _normalizedSelectOptions(raw: unknown): Array<{ value: string; label: string }> {
|
||||||
(param.frontendOptions?.options as string[]) || (param.options as 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 (
|
return (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
{showNameLine ? (
|
||||||
<select
|
<div
|
||||||
value={typeof value === 'string' ? value : ''}
|
id={titleId}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
style={{
|
||||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
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) => {
|
||||||
{options.map((opt) => (
|
const selected = current === opt.value;
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
return (
|
||||||
))}
|
<button
|
||||||
</select>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
const options: string[] =
|
const options = _normalizedSelectOptions(
|
||||||
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
|
param.frontendOptions?.options ?? param.options ?? []
|
||||||
|
);
|
||||||
const selected = Array.isArray(value) ? value : [];
|
const selected = Array.isArray(value) ? value : [];
|
||||||
const toggle = (opt: string) => {
|
const toggle = (opt: string) => {
|
||||||
const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt];
|
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>
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
|
<label key={opt.value} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} />
|
<input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} />
|
||||||
{opt}
|
{opt.label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const ctx = useAutomation2DataFlow();
|
const ctx = useAutomation2DataFlow();
|
||||||
|
|
@ -541,13 +647,35 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
? ctx.formFieldTypes
|
? ctx.formFieldTypes
|
||||||
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
|
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
|
||||||
const fields = Array.isArray(value) ? value : [];
|
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 removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
|
||||||
const updateField = (idx: number, field: string, val: unknown) => {
|
const updateField = (idx: number, field: string, val: unknown) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||||
onChange(next);
|
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 = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
|
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
|
||||||
fontSize: 12, boxSizing: 'border-box', background: '#fff',
|
fontSize: 12, boxSizing: 'border-box', background: '#fff',
|
||||||
|
|
@ -565,7 +693,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('Bezeichnung (Anzeigename)')}
|
placeholder={t('Bezeichnung (Anzeigename)')}
|
||||||
value={String(f.label ?? '')}
|
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 }}
|
style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
<button
|
<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 }}
|
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
|
||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 2: Name + Typ + Pflicht */}
|
{/* Row 2: Typ + Pflicht */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '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>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</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) => (
|
{fieldTypeOptions.map((ft) => (
|
||||||
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||||
))}
|
))}
|
||||||
|
|
@ -601,6 +719,14 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
Pflicht
|
Pflicht
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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' && (
|
{String(f.type) === 'group' && (
|
||||||
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
|
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
|
||||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
|
<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' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('Name')}
|
placeholder={t('Bezeichnung')}
|
||||||
value={String(sub.name ?? '')}
|
value={String(sub.label ?? sub.name ?? '')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
const label = e.target.value;
|
||||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
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);
|
updateField(i, 'fields', nextFields);
|
||||||
}}
|
}}
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
|
@ -621,8 +752,13 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
<select
|
<select
|
||||||
value={String(sub.type ?? 'text')}
|
value={String(sub.type ?? 'text')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
const typeId = e.target.value;
|
||||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
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);
|
updateField(i, 'fields', nextFields);
|
||||||
}}
|
}}
|
||||||
style={{ ...selectStyle, flex: 1 }}
|
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 }}
|
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
|
||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
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);
|
updateField(i, 'fields', nextFields);
|
||||||
}}
|
}}
|
||||||
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
|
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 CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, onPatchParams }) => {
|
||||||
const { t } = useLanguage();
|
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 (
|
return (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
|
||||||
<input
|
<SchedulePlanner value={spec} onChange={handlePlanner} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
const ConditionBuilder = ConditionEditor;
|
||||||
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 MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -913,10 +1059,12 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
hidden: HiddenInput,
|
hidden: HiddenInput,
|
||||||
dataRef: DataRefRenderer,
|
dataRef: DataRefRenderer,
|
||||||
contextBuilder: ContextBuilderRenderer,
|
contextBuilder: ContextBuilderRenderer,
|
||||||
|
contextAssignments: ContextAssignmentsEditor,
|
||||||
userConnection: ConnectionPicker,
|
userConnection: ConnectionPicker,
|
||||||
featureInstance: FeatureInstancePicker,
|
featureInstance: FeatureInstancePicker,
|
||||||
sharepointFolder: SharepointPathPicker,
|
sharepointFolder: SharepointPathPicker,
|
||||||
sharepointFile: SharepointPathPicker,
|
sharepointFile: SharepointPathPicker,
|
||||||
|
userFileFolder: UserFileFolderPicker,
|
||||||
clickupList: FolderPicker,
|
clickupList: FolderPicker,
|
||||||
clickupTask: FolderPicker,
|
clickupTask: FolderPicker,
|
||||||
caseList: CaseListEditor,
|
caseList: CaseListEditor,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { IfElseNodeConfig } from './IfElseNodeConfig';
|
export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor';
|
||||||
|
export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
/** 0–6, cron DOW; nur bei mode === 'weekly' */
|
|
||||||
weekdays: number[];
|
|
||||||
/** Monatlich: Tag 1–31; Jährlich: Tag im gewählten Monat */
|
|
||||||
monthDay: number;
|
|
||||||
/** 1–12, nur bei calendar + yearly */
|
|
||||||
monthIndex: number;
|
|
||||||
calendarPeriod: CalendarPeriod;
|
|
||||||
intervalValue: number;
|
|
||||||
intervalUnit: IntervalUnit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
|
|
||||||
|
|
||||||
/** Anzeige Mo–So (cronDow wie oben) */
|
|
||||||
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
|
|
||||||
{ cronDow: 1, label: 'Mo' },
|
|
||||||
{ cronDow: 2, label: 'Di' },
|
|
||||||
{ cronDow: 3, label: 'Mi' },
|
|
||||||
{ cronDow: 4, label: 'Do' },
|
|
||||||
{ cronDow: 5, label: 'Fr' },
|
|
||||||
{ cronDow: 6, label: 'Sa' },
|
|
||||||
{ cronDow: 0, label: 'So' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function defaultScheduleSpec(): ScheduleSpec {
|
|
||||||
return {
|
|
||||||
mode: 'daily',
|
|
||||||
hour: 8,
|
|
||||||
minute: 0,
|
|
||||||
weekdays: [1, 2, 3, 4, 5],
|
|
||||||
monthDay: 1,
|
|
||||||
monthIndex: 1,
|
|
||||||
calendarPeriod: 'monthly',
|
|
||||||
intervalValue: 15,
|
|
||||||
intervalUnit: 'minutes',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(n: number, min: number, max: number): number {
|
|
||||||
return Math.min(max, Math.max(min, n));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
|
|
||||||
export function buildCronFromSpec(spec: ScheduleSpec): string {
|
|
||||||
const m = clamp(Math.floor(spec.minute), 0, 59);
|
|
||||||
const h = clamp(Math.floor(spec.hour), 0, 23);
|
|
||||||
|
|
||||||
switch (spec.mode) {
|
|
||||||
case 'daily':
|
|
||||||
return `${m} ${h} * * *`;
|
|
||||||
case 'weekdays':
|
|
||||||
return `${m} ${h} * * 1-5`;
|
|
||||||
case 'weekly': {
|
|
||||||
const days = [...new Set(spec.weekdays)]
|
|
||||||
.filter((d) => d >= 0 && d <= 6)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const order = (x: number) => (x === 0 ? 7 : x);
|
|
||||||
return order(a) - order(b);
|
|
||||||
});
|
|
||||||
if (days.length === 0) return `${m} ${h} * * 1`;
|
|
||||||
return `${m} ${h} * * ${days.join(',')}`;
|
|
||||||
}
|
|
||||||
case 'calendar': {
|
|
||||||
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
|
|
||||||
if (spec.calendarPeriod === 'monthly') {
|
|
||||||
return `${m} ${h} ${dom} * *`;
|
|
||||||
}
|
|
||||||
const month = clamp(Math.floor(spec.monthIndex), 1, 12);
|
|
||||||
return `${m} ${h} ${dom} ${month} *`;
|
|
||||||
}
|
|
||||||
case 'interval': {
|
|
||||||
const v = Math.max(1, Math.floor(spec.intervalValue));
|
|
||||||
switch (spec.intervalUnit) {
|
|
||||||
case 'seconds': {
|
|
||||||
const s = clamp(v, 1, 59);
|
|
||||||
return `*/${s} * * * * *`;
|
|
||||||
}
|
|
||||||
case 'minutes': {
|
|
||||||
const mm = clamp(v, 1, 59);
|
|
||||||
return `*/${mm} * * * *`;
|
|
||||||
}
|
|
||||||
case 'hours': {
|
|
||||||
const hh = clamp(v, 1, 23);
|
|
||||||
return `0 */${hh} * * *`;
|
|
||||||
}
|
|
||||||
case 'days': {
|
|
||||||
if (v <= 1) return `0 0 * * *`;
|
|
||||||
const d = clamp(v, 2, 31);
|
|
||||||
return `0 0 */${d} * *`;
|
|
||||||
}
|
|
||||||
case 'years':
|
|
||||||
default:
|
|
||||||
// Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
|
|
||||||
return `0 0 1 1 *`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return `${m} ${h} * * *`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
|
|
||||||
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
|
|
||||||
if (!cron || typeof cron !== 'string') return null;
|
|
||||||
const p = cron.trim().split(/\s+/);
|
|
||||||
|
|
||||||
if (p.length === 6) {
|
|
||||||
const [secS, minS, hourS, domS, monthS, dowS] = p;
|
|
||||||
if (
|
|
||||||
secS.startsWith('*/') &&
|
|
||||||
minS === '*' &&
|
|
||||||
hourS === '*' &&
|
|
||||||
domS === '*' &&
|
|
||||||
monthS === '*' &&
|
|
||||||
(dowS === '*' || dowS === '?')
|
|
||||||
) {
|
|
||||||
const iv = parseInt(secS.slice(2), 10);
|
|
||||||
if (!Number.isNaN(iv)) {
|
|
||||||
return {
|
|
||||||
...defaultScheduleSpec(),
|
|
||||||
mode: 'interval',
|
|
||||||
intervalValue: iv,
|
|
||||||
intervalUnit: 'seconds',
|
|
||||||
minute: 0,
|
|
||||||
hour: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.length < 5) return null;
|
|
||||||
const [minS, hourS, domS, monthS, dowS] = p;
|
|
||||||
const minute = parseInt(minS, 10);
|
|
||||||
const hour = parseInt(hourS, 10);
|
|
||||||
if (Number.isNaN(minute) || Number.isNaN(hour)) return null;
|
|
||||||
|
|
||||||
if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
|
|
||||||
const iv = parseInt(minS.slice(2), 10);
|
|
||||||
if (!Number.isNaN(iv)) {
|
|
||||||
return {
|
|
||||||
...defaultScheduleSpec(),
|
|
||||||
mode: 'interval',
|
|
||||||
intervalValue: iv,
|
|
||||||
intervalUnit: 'minutes',
|
|
||||||
minute: 0,
|
|
||||||
hour: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
|
|
||||||
const iv = parseInt(hourS.slice(2), 10);
|
|
||||||
if (!Number.isNaN(iv)) {
|
|
||||||
return {
|
|
||||||
...defaultScheduleSpec(),
|
|
||||||
mode: 'interval',
|
|
||||||
intervalValue: iv,
|
|
||||||
intervalUnit: 'hours',
|
|
||||||
minute: 0,
|
|
||||||
hour: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
|
|
||||||
const iv = parseInt(domS.slice(2), 10);
|
|
||||||
if (!Number.isNaN(iv)) {
|
|
||||||
return {
|
|
||||||
...defaultScheduleSpec(),
|
|
||||||
mode: 'interval',
|
|
||||||
intervalValue: iv,
|
|
||||||
intervalUnit: 'days',
|
|
||||||
minute: 0,
|
|
||||||
hour: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domS === '*' && dowS === '*') {
|
|
||||||
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domS === '*' && dowS === '1-5') {
|
|
||||||
return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
|
|
||||||
const parts = dowS.split(',').map((x) => parseInt(x.trim(), 10));
|
|
||||||
const days = parts.filter((x) => !Number.isNaN(x) && x >= 0 && x <= 7);
|
|
||||||
if (days.length > 0) {
|
|
||||||
const norm = days.map((d) => (d === 7 ? 0 : d));
|
|
||||||
return {
|
|
||||||
...defaultScheduleSpec(),
|
|
||||||
mode: 'weekly',
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
weekdays: norm,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dom = parseInt(domS, 10);
|
|
||||||
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
|
|
||||||
|
|
||||||
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
|
|
||||||
return {
|
|
||||||
...defaultScheduleSpec(),
|
|
||||||
mode: 'calendar',
|
|
||||||
calendarPeriod: 'monthly',
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
monthDay: dom,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!Number.isNaN(dom) &&
|
|
||||||
dom >= 1 &&
|
|
||||||
dom <= 31 &&
|
|
||||||
!Number.isNaN(month) &&
|
|
||||||
month >= 1 &&
|
|
||||||
month <= 12 &&
|
|
||||||
(dowS === '*' || dowS === '?')
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...defaultScheduleSpec(),
|
|
||||||
mode: 'calendar',
|
|
||||||
calendarPeriod: 'yearly',
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
monthDay: dom,
|
|
||||||
monthIndex: month,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval'];
|
|
||||||
|
|
||||||
function normalizeIntervalUnit(u: unknown): IntervalUnit {
|
|
||||||
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
|
|
||||||
return 'minutes';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Liest Spec aus Node-Parametern (schedule-Objekt bevorzugt, sonst Cron parsen) */
|
|
||||||
export function scheduleSpecFromParams(params: Record<string, unknown>): ScheduleSpec {
|
|
||||||
const raw = params.schedule;
|
|
||||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
||||||
const o = raw as Record<string, unknown>;
|
|
||||||
let mode = o.mode as string;
|
|
||||||
if (mode === 'monthly') {
|
|
||||||
mode = 'calendar';
|
|
||||||
}
|
|
||||||
if (VALID_MODES.includes(mode as ScheduleMode)) {
|
|
||||||
const base = defaultScheduleSpec();
|
|
||||||
let calendarPeriod: CalendarPeriod = base.calendarPeriod;
|
|
||||||
if (mode === 'calendar') {
|
|
||||||
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mode: mode as ScheduleMode,
|
|
||||||
hour: clamp(Number(o.hour) || base.hour, 0, 23),
|
|
||||||
minute: clamp(Number(o.minute) || base.minute, 0, 59),
|
|
||||||
weekdays: Array.isArray(o.weekdays)
|
|
||||||
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
|
|
||||||
: base.weekdays,
|
|
||||||
monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31),
|
|
||||||
monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12),
|
|
||||||
calendarPeriod,
|
|
||||||
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
|
|
||||||
intervalUnit: normalizeIntervalUnit(o.intervalUnit),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cron = typeof params.cron === 'string' ? params.cron : '';
|
|
||||||
return parseCronToSpec(cron) ?? defaultScheduleSpec();
|
|
||||||
}
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
||||||
|
|
@ -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',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* Automation2 Flow Editor - Schema-based Data Picker.
|
* 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.
|
* Resolves Transit chains to show the real upstream schema.
|
||||||
* Includes a System Variables section.
|
* 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 { createPortal } from 'react-dom';
|
||||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
|
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi';
|
import type { DataPickOption, GraphDataSources, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
|
||||||
import { findLoopAncestorIds } from './scopeHelpers';
|
import { fetchGraphDataSources } from '../../../../api/workflowApi';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
@ -39,14 +40,28 @@ interface PickablePath {
|
||||||
typeMismatch?: boolean;
|
typeMismatch?: boolean;
|
||||||
/** Surfaced at the top of the list as the most common / recommended pick. */
|
/** Surfaced at the top of the list as the most common / recommended pick. */
|
||||||
recommended?: boolean;
|
recommended?: boolean;
|
||||||
|
/** Tooltip (Katalog oder Backend-Hinweistext). */
|
||||||
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _LIST_INNER_RE = /^List\[(.+)\]$/;
|
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(
|
function _buildPathsFromSchema(
|
||||||
schema: PortSchema | undefined,
|
schema: PortSchema | undefined,
|
||||||
catalog: Record<string, PortSchema>,
|
catalog: Record<string, PortSchema>,
|
||||||
basePath: (string | number)[] = [],
|
basePath: (string | number)[] = [],
|
||||||
|
baseSegments: string[] = [],
|
||||||
depth = 0,
|
depth = 0,
|
||||||
): PickablePath[] {
|
): PickablePath[] {
|
||||||
if (!schema || !schema.fields || depth > 8) return [];
|
if (!schema || !schema.fields || depth > 8) return [];
|
||||||
|
|
@ -64,21 +79,43 @@ function _buildPathsFromSchema(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of schema.fields) {
|
for (const field of schema.fields) {
|
||||||
|
const segHuman = _fieldSegHuman(field);
|
||||||
const fieldPath = [...basePath, field.name];
|
const fieldPath = [...basePath, field.name];
|
||||||
const label = fieldPath.map(String).join(' → ');
|
const label =
|
||||||
result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false });
|
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 m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
|
||||||
const inner = m?.[1]?.trim();
|
const inner = m?.[1]?.trim();
|
||||||
if (inner && catalog[inner]) {
|
if (inner && catalog[inner]) {
|
||||||
// Generic List drill-down: use '*' wildcard so the engine maps each item.
|
const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : '';
|
||||||
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
|
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({
|
||||||
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
|
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
|
||||||
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
|
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
|
||||||
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
|
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
|
||||||
|
|
@ -162,6 +199,18 @@ function _buildPathsFromPreview(
|
||||||
return [{ path: [...basePath], label: pathLabel }];
|
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(
|
function _resolveSchemaForNode(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
|
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
|
// other hook) below it would change the hook count when the picker toggles
|
||||||
// open/closed and crash the whole tree (white screen).
|
// open/closed and crash the whole tree (white screen).
|
||||||
const connectionsRaw = ctx?.connections ?? [];
|
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(
|
const connections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
connectionsRaw.map((c) => ({
|
connectionsRaw.map((c) => ({
|
||||||
source: c.sourceId,
|
source: c.sourceId,
|
||||||
target: c.targetId,
|
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;
|
// Fetch scope data from the backend when the picker opens — zero topology logic in JS.
|
||||||
if (!cid) return [] as string[];
|
const [scopeData, setScopeData] = useState<GraphDataSources | null>(null);
|
||||||
return findLoopAncestorIds(nodes, connections, cid);
|
const scopeFetchKey = useRef<string>('');
|
||||||
}, [ctx?.currentNodeId, nodes, connections]);
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
|
|
@ -321,18 +393,18 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dataPickerBody}>
|
<div className={styles.dataPickerBody}>
|
||||||
{/* System Variables Section */}
|
{/* System Variables Section */}
|
||||||
{loopAncestorIds.length > 0 && (
|
{loopBodyContextIds.length > 0 && (
|
||||||
<div className={styles.dataPickerNodeSection}>
|
<div className={styles.dataPickerNodeSection}>
|
||||||
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
|
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
|
||||||
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
|
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dataPickerTree}>
|
<div className={styles.dataPickerTree}>
|
||||||
{loopAncestorIds.map((loopId) => {
|
{loopBodyContextIds.map((loopId) => {
|
||||||
const loopNode = nodes.find((n) => n.id === loopId);
|
const loopNode = nodes.find((n) => n.id === loopId);
|
||||||
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
|
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
|
||||||
const loopSchema = catalog.LoopItem;
|
const loopSchema = catalog.LoopItem;
|
||||||
const loopPaths = loopSchema
|
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: ['currentItem'], label: 'currentItem', type: 'Any' },
|
||||||
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' },
|
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' },
|
||||||
|
|
@ -407,7 +479,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
|
|
||||||
{/* Node outputs */}
|
{/* Node outputs */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const filteredIds = availableSourceIds.filter((nodeId) => {
|
const filteredIds = effectiveSourceIds.filter((nodeId) => {
|
||||||
const node = nodes.find((n) => n.id === nodeId);
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
return node?.type !== 'trigger.manual';
|
return node?.type !== 'trigger.manual';
|
||||||
});
|
});
|
||||||
|
|
@ -423,24 +495,35 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
|
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
|
||||||
const isExpanded = expandedNodes.has(nodeId);
|
const isExpanded = expandedNodes.has(nodeId);
|
||||||
|
|
||||||
const resolvedSchema = _resolveSchemaForNode(
|
// Use the port index the backend says to use (e.g. 1 for loop on Done branch)
|
||||||
nodeId,
|
const portIdx = portIndexOverrides[nodeId] ?? 0;
|
||||||
nodes,
|
const portDef = nodeTypeDef?.outputPorts?.[portIdx];
|
||||||
nodeTypes,
|
const backendPick =
|
||||||
connections,
|
portDef?.dataPickOptions &&
|
||||||
catalog,
|
Array.isArray(portDef.dataPickOptions) &&
|
||||||
new Set(),
|
portDef.dataPickOptions.length > 0;
|
||||||
formTypeToPort,
|
|
||||||
);
|
let schemaPaths: PickablePath[];
|
||||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
|
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(
|
const annotated = _markIterableCandidates(
|
||||||
schemaPaths.length > 0
|
schemaPaths.length > 0
|
||||||
? schemaPaths
|
? schemaPaths
|
||||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
|
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
|
||||||
expectedParamType,
|
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) => ({
|
const markedPaths = annotated.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
typeMismatch:
|
typeMismatch:
|
||||||
|
|
@ -450,7 +533,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
!p.iterable &&
|
!p.iterable &&
|
||||||
isCompatible(p.type!, expectedParamType!) === 'mismatch',
|
isCompatible(p.type!, expectedParamType!) === 'mismatch',
|
||||||
}));
|
}));
|
||||||
const paths = [
|
const orderedPaths = [
|
||||||
...markedPaths.filter((p) => p.recommended),
|
...markedPaths.filter((p) => p.recommended),
|
||||||
...markedPaths.filter((p) => !p.recommended),
|
...markedPaths.filter((p) => !p.recommended),
|
||||||
];
|
];
|
||||||
|
|
@ -472,56 +555,55 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className={styles.dataPickerTree}>
|
<div className={styles.dataPickerTree}>
|
||||||
{paths.length === 0 && (
|
{orderedPaths.length === 0 && (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
|
||||||
{t('(keine Felder verfügbar)')}
|
{t('(keine Felder verfügbar)')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{paths.map((p, i) => {
|
{orderedPaths.map((p, i) => (
|
||||||
return (
|
<div
|
||||||
<div
|
key={`${p.path.join('.')}-${i}`}
|
||||||
key={`${p.path.join('.')}-${i}`}
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
|
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
|
||||||
style={{ flex: 1 }}
|
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
|
||||||
onClick={() => handlePick(nodeId, p.path, p.type)}
|
title={t('Pro Element der Liste iterieren (Loop)')}
|
||||||
>
|
>
|
||||||
{p.label}
|
{t('iterieren')}
|
||||||
{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>
|
</button>
|
||||||
{p.iterable && (
|
)}
|
||||||
<button
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -6,7 +6,7 @@ import React from 'react';
|
||||||
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa';
|
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa';
|
||||||
|
|
||||||
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||||
trigger: <FaPlay />,
|
start: <FaPlay />,
|
||||||
input: <FaUser />,
|
input: <FaUser />,
|
||||||
flow: <FaCodeBranch />,
|
flow: <FaCodeBranch />,
|
||||||
data: <FaDatabase />,
|
data: <FaDatabase />,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const HIDDEN_NODE_IDS = new Set<string>();
|
||||||
|
|
||||||
/** Default category display order */
|
/** Default category display order */
|
||||||
export const CATEGORY_ORDER = [
|
export const CATEGORY_ORDER = [
|
||||||
'trigger',
|
'start',
|
||||||
'input',
|
'input',
|
||||||
'flow',
|
'flow',
|
||||||
'data',
|
'data',
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,39 @@ import type {
|
||||||
} from '../../../../api/workflowApi';
|
} from '../../../../api/workflowApi';
|
||||||
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
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(
|
export function fromApiGraph(
|
||||||
graph: Automation2Graph,
|
graph: Automation2Graph,
|
||||||
nodeTypes: NodeType[]
|
nodeTypes: NodeType[]
|
||||||
|
|
@ -26,7 +59,7 @@ export function fromApiGraph(
|
||||||
let outputs = io.outputs;
|
let outputs = io.outputs;
|
||||||
if (n.type === 'flow.switch') {
|
if (n.type === 'flow.switch') {
|
||||||
const cases = (n.parameters?.cases as unknown[]) ?? [];
|
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);
|
const nt = nodeTypes.find((t) => t.id === n.type);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -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']]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
|
|
@ -8,6 +8,12 @@ import type { FormField } from '../shared/types';
|
||||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
|
||||||
|
import {
|
||||||
|
deriveFormFieldPayloadKey,
|
||||||
|
formFieldTypeHasConfigurableOptions,
|
||||||
|
normalizeFormFieldOptions,
|
||||||
|
} from '../form/formFieldOptionsUtils';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
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 name = String(o.name ?? `field${i + 1}`);
|
||||||
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
||||||
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
|
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 };
|
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}>
|
<div className={styles.startNodeDoc}>
|
||||||
<p className={styles.startNodeDocIntro}>
|
<p className={styles.startNodeDocIntro}>
|
||||||
<strong>{t('Formular-Felder')}</strong>{' '}
|
<strong>{t('Formular-Felder')}</strong>{' '}
|
||||||
{t('werden beim Start ausgefüllt und liegen unter')}{' '}
|
{t('werden beim Start ausgefüllt. Der Payload-Schlüssel wird aus der Beschriftung abgeleitet.')}
|
||||||
<code>payload.<name></code> {t('in der Start-Ausgabe.')}
|
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.formFieldsList}>
|
<div className={styles.formFieldsList}>
|
||||||
{fields.map((f, idx) => (
|
{fields.map((f, idx) => (
|
||||||
<div key={idx} className={styles.formFieldRow}>
|
<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
|
<input
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
placeholder={t('Beschriftung')}
|
placeholder={t('Beschriftung')}
|
||||||
value={f.label ?? ''}
|
value={f.label ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
const label = e.target.value;
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
next[idx] = { ...f, label: e.target.value };
|
next[idx] = { ...f, label, name: deriveFormFieldPayloadKey(label, idx) };
|
||||||
setFields(next);
|
setFields(next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -74,7 +72,12 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
value={f.type ?? 'text'}
|
value={f.type ?? 'text'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
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);
|
setFields(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -89,13 +92,32 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.startsAddBtn}
|
className={styles.startsAddBtn}
|
||||||
onClick={() =>
|
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')}
|
{t('+ Feld')}
|
||||||
|
|
|
||||||
|
|
@ -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 React, { useCallback, useMemo } from 'react';
|
||||||
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
import { flushSync } from 'react-dom';
|
||||||
import type { NodeConfigRendererProps } from '../shared/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
|
import { SchedulePlanner } from '../../../SchedulePlanner';
|
||||||
import {
|
import {
|
||||||
type ScheduleSpec,
|
|
||||||
type ScheduleMode,
|
|
||||||
type IntervalUnit,
|
|
||||||
type CalendarPeriod,
|
|
||||||
buildCronFromSpec,
|
buildCronFromSpec,
|
||||||
scheduleSpecFromParams,
|
scheduleSpecFromParams,
|
||||||
WEEKDAYS_MO_SO,
|
scheduleSpecToPersistentJson,
|
||||||
} from '../runtime/scheduleCron';
|
type ScheduleSpec,
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
} from '../../../../utils/scheduleCron';
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
const { t } = useLanguage();
|
const spec = useMemo(
|
||||||
const modeOptions = _getModeOptions(t);
|
() => scheduleSpecFromParams(params as Record<string, unknown>),
|
||||||
const intervalUnits = _getIntervalUnits(t);
|
[params.cron, params.schedule]
|
||||||
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
|
);
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const specModeRef = useRef(spec.mode);
|
|
||||||
specModeRef.current = spec.mode;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const derived = scheduleSpecFromParams(params as Record<string, unknown>);
|
|
||||||
console.log('[ScheduleStartNode] useEffect params → setSpec', {
|
|
||||||
paramsCron: params.cron,
|
|
||||||
paramsSchedule: params.schedule,
|
|
||||||
derivedMode: derived.mode,
|
|
||||||
previousSpecMode: specModeRef.current,
|
|
||||||
});
|
|
||||||
setSpec(derived);
|
|
||||||
}, [params.cron, params.schedule]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[ScheduleStartNode] spec.mode changed (UI sollte passen)', {
|
|
||||||
specMode: spec.mode,
|
|
||||||
cssBlockBase: styles.scheduleModeBlock,
|
|
||||||
cssBlockActive: styles.scheduleModeBlockActive,
|
|
||||||
});
|
|
||||||
}, [spec.mode]);
|
|
||||||
|
|
||||||
const push = useCallback(
|
const push = useCallback(
|
||||||
(next: ScheduleSpec) => {
|
(next: ScheduleSpec) => {
|
||||||
setSpec(next);
|
const sched = scheduleSpecToPersistentJson(next);
|
||||||
commitSpec(next, updateParam);
|
const cron = buildCronFromSpec(next);
|
||||||
|
flushSync(() => {
|
||||||
|
updateParam('schedule', sched);
|
||||||
|
});
|
||||||
|
updateParam('cron', cron);
|
||||||
},
|
},
|
||||||
[updateParam]
|
[updateParam]
|
||||||
);
|
);
|
||||||
|
return <SchedulePlanner value={spec} onChange={push} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { SwitchNodeConfig } from './SwitchNodeConfig';
|
export { CaseListEditor as SwitchNodeConfig } from '../frontendTypeRenderers/CaseListEditor';
|
||||||
|
export type { SwitchCase } from '../frontendTypeRenderers/CaseListEditor';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -520,6 +520,22 @@
|
||||||
line-height: 1.5;
|
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 */
|
/* Compact mode */
|
||||||
.compactMode .sectionHeader {
|
.compactMode .sectionHeader {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
|
||||||
function _flatten<T>(
|
function _flatten<T>(
|
||||||
nodes: TreeNode<T>[],
|
nodes: TreeNode<T>[],
|
||||||
expandedIds: Set<string>,
|
expandedIds: Set<string>,
|
||||||
|
confirmedEmptyFolderIds: Set<string>,
|
||||||
): FlatEntry<T>[] {
|
): FlatEntry<T>[] {
|
||||||
const childMap = _buildChildMap(nodes);
|
const childMap = _buildChildMap(nodes);
|
||||||
const result: FlatEntry<T>[] = [];
|
const result: FlatEntry<T>[] = [];
|
||||||
|
|
@ -72,8 +73,22 @@ function _flatten<T>(
|
||||||
const children = childMap.get(parentKey);
|
const children = childMap.get(parentKey);
|
||||||
if (!children) return;
|
if (!children) return;
|
||||||
for (const node of children) {
|
for (const node of children) {
|
||||||
const nodeChildren = childMap.get(node.id);
|
const loadedChildren = childMap.get(node.id) ?? [];
|
||||||
const hasChildren = (nodeChildren && nodeChildren.length > 0) || node.type === 'folder';
|
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 });
|
result.push({ node, depth, hasChildren });
|
||||||
if (hasChildren && expandedIds.has(node.id)) {
|
if (hasChildren && expandedIds.has(node.id)) {
|
||||||
_walk(node.id, depth + 1);
|
_walk(node.id, depth + 1);
|
||||||
|
|
@ -135,6 +150,8 @@ interface TreeNodeRowProps<T = any> {
|
||||||
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
|
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
|
||||||
onDragLeave: (e: React.DragEvent) => void;
|
onDragLeave: (e: React.DragEvent) => void;
|
||||||
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
|
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
|
||||||
|
hideRowActionButtons?: boolean;
|
||||||
|
dragDropEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
|
|
@ -163,6 +180,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
onDrop,
|
onDrop,
|
||||||
|
hideRowActionButtons = false,
|
||||||
|
dragDropEnabled = true,
|
||||||
}: TreeNodeRowProps<T>) {
|
}: TreeNodeRowProps<T>) {
|
||||||
const { node, depth, hasChildren } = entry;
|
const { node, depth, hasChildren } = entry;
|
||||||
const renameRef = useRef<HTMLInputElement>(null);
|
const renameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -196,11 +215,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
const _handleDoubleClick = useCallback(
|
const _handleDoubleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (hideRowActionButtons) return;
|
||||||
if (ownership === 'own' && provider.canRename?.(node)) {
|
if (ownership === 'own' && provider.canRename?.(node)) {
|
||||||
onStartRename(node.id);
|
onStartRename(node.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ownership, provider, node, onStartRename],
|
[hideRowActionButtons, ownership, provider, node, onStartRename],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _handleRowClick = useCallback(
|
const _handleRowClick = useCallback(
|
||||||
|
|
@ -242,11 +262,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
className={rowClasses}
|
className={rowClasses}
|
||||||
onClick={_handleRowClick}
|
onClick={_handleRowClick}
|
||||||
onDoubleClick={_handleDoubleClick}
|
onDoubleClick={_handleDoubleClick}
|
||||||
draggable
|
draggable={dragDropEnabled}
|
||||||
onDragStart={(e) => onDragStart(e, node)}
|
onDragStart={dragDropEnabled ? (e) => onDragStart(e, node) : undefined}
|
||||||
onDragOver={(e) => onDragOver(e, node)}
|
onDragOver={dragDropEnabled ? (e) => onDragOver(e, node) : undefined}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={dragDropEnabled ? onDragLeave : undefined}
|
||||||
onDrop={(e) => onDrop(e, node)}
|
onDrop={dragDropEnabled ? (e) => onDrop(e, node) : undefined}
|
||||||
data-node-id={node.id}
|
data-node-id={node.id}
|
||||||
title={node.name}
|
title={node.name}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
|
|
@ -256,17 +276,19 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
>
|
>
|
||||||
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
|
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
|
||||||
|
|
||||||
<input
|
{!hideRowActionButtons && (
|
||||||
type="checkbox"
|
<input
|
||||||
className={styles.nodeCheckbox}
|
type="checkbox"
|
||||||
checked={isSelected}
|
className={styles.nodeCheckbox}
|
||||||
onChange={() => {}}
|
checked={isSelected}
|
||||||
onClick={(e) => {
|
onChange={() => {}}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onToggleSelect(node.id, e as unknown as React.MouseEvent);
|
e.stopPropagation();
|
||||||
}}
|
onToggleSelect(node.id, e as unknown as React.MouseEvent);
|
||||||
tabIndex={-1}
|
}}
|
||||||
/>
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<span
|
<span
|
||||||
|
|
@ -303,91 +325,104 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.nodeSizeGroup}>
|
{!hideRowActionButtons && (
|
||||||
<span className={styles.nodeSize}>
|
<span className={styles.nodeSize}>
|
||||||
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.nodeActionsHover}>
|
{!hideRowActionButtons && (
|
||||||
{canRename && (
|
<>
|
||||||
<button
|
<div className={styles.nodeActionsHover}>
|
||||||
className={styles.emojiBtn}
|
{canRename && (
|
||||||
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }}
|
<button
|
||||||
title="Umbenennen"
|
className={styles.emojiBtn}
|
||||||
tabIndex={-1}
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
{'\u270F\uFE0F'}
|
onStartRename(node.id);
|
||||||
</button>
|
}}
|
||||||
)}
|
title="Umbenennen"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{'\u270F\uFE0F'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{node.type !== 'folder' && (
|
{node.type !== 'folder' && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
onClick={(e) => { e.stopPropagation(); onDownload(node); }}
|
onClick={(e) => {
|
||||||
title="Datei herunterladen"
|
e.stopPropagation();
|
||||||
tabIndex={-1}
|
onDownload(node);
|
||||||
>
|
}}
|
||||||
{'\u{1F4E5}'}
|
title="Datei herunterladen"
|
||||||
</button>
|
tabIndex={-1}
|
||||||
)}
|
>
|
||||||
|
{'\u{1F4E5}'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }}
|
onClick={(e) => {
|
||||||
title="Loeschen"
|
e.stopPropagation();
|
||||||
tabIndex={-1}
|
onDelete(node.id);
|
||||||
>
|
}}
|
||||||
{'\u{1F5D1}\uFE0F'}
|
title="Loeschen"
|
||||||
</button>
|
tabIndex={-1}
|
||||||
)}
|
>
|
||||||
</div>
|
{'\u{1F5D1}\uFE0F'}
|
||||||
</div>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.nodeActionsPersistent}>
|
<div className={styles.nodeActionsPersistent}>
|
||||||
{onSendToChat && (
|
{onSendToChat && (
|
||||||
<button
|
<button
|
||||||
className={styles.emojiBtn}
|
className={styles.emojiBtn}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSendToChat(node);
|
onSendToChat(node);
|
||||||
}}
|
}}
|
||||||
title="In Chat senden"
|
title="In Chat senden"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{'\u{1F4AC}'}
|
{'\u{1F4AC}'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{node.scope !== undefined && (
|
{node.scope !== undefined && (
|
||||||
<button
|
<button
|
||||||
className={`${styles.emojiBtn} ${canPatchScope ? '' : styles.emojiBtnReadonly}`}
|
className={`${styles.emojiBtn} ${canPatchScope ? '' : styles.emojiBtnReadonly}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (canPatchScope) onCycleScope(node);
|
if (canPatchScope) onCycleScope(node);
|
||||||
}}
|
}}
|
||||||
title={`Scope: ${node.scope}`}
|
title={`Scope: ${node.scope}`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
|
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{node.neutralize !== undefined && (
|
{node.neutralize !== undefined && (
|
||||||
<button
|
<button
|
||||||
className={`${styles.emojiBtn} ${canPatchNeutralize ? '' : styles.emojiBtnReadonly}`}
|
className={`${styles.emojiBtn} ${canPatchNeutralize ? '' : styles.emojiBtnReadonly}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (canPatchNeutralize) onToggleNeutralize(node);
|
if (canPatchNeutralize) onToggleNeutralize(node);
|
||||||
}}
|
}}
|
||||||
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ opacity: node.neutralize ? 1 : 0.35 }}
|
style={{ opacity: node.neutralize ? 1 : 0.35 }}
|
||||||
>
|
>
|
||||||
{_NEUTRALIZE_EMOJI}
|
{_NEUTRALIZE_EMOJI}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement;
|
}) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement;
|
||||||
|
|
@ -407,6 +442,10 @@ export function FormGeneratorTree<T = any>({
|
||||||
onSendToChat,
|
onSendToChat,
|
||||||
allowCreateFolder = true,
|
allowCreateFolder = true,
|
||||||
className,
|
className,
|
||||||
|
embedMaxHeight,
|
||||||
|
hideRowActionButtons = false,
|
||||||
|
hideSectionHeader = false,
|
||||||
|
enableDragDrop,
|
||||||
}: FormGeneratorTreeProps<T>) {
|
}: FormGeneratorTreeProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
|
@ -421,12 +460,15 @@ export function FormGeneratorTree<T = any>({
|
||||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
|
||||||
const [filterText, setFilterText] = useState('');
|
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 lastSelectedIdRef = useRef<string | null>(null);
|
||||||
const treeContentRef = useRef<HTMLDivElement>(null);
|
const treeContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const _loadRoot = useCallback(async () => {
|
const _loadRoot = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
setConfirmedEmptyFolderIds(new Set());
|
||||||
const rootNodes = await provider.loadChildren(null, ownership);
|
const rootNodes = await provider.loadChildren(null, ownership);
|
||||||
setNodes(rootNodes);
|
setNodes(rootNodes);
|
||||||
if (defaultCollapsed && rootNodes.length === 0) {
|
if (defaultCollapsed && rootNodes.length === 0) {
|
||||||
|
|
@ -441,7 +483,10 @@ export function FormGeneratorTree<T = any>({
|
||||||
_loadRoot();
|
_loadRoot();
|
||||||
}, [_loadRoot]);
|
}, [_loadRoot]);
|
||||||
|
|
||||||
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]);
|
const flatEntriesRaw = useMemo(
|
||||||
|
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
|
||||||
|
[nodes, expandedIds, confirmedEmptyFolderIds],
|
||||||
|
);
|
||||||
|
|
||||||
const flatEntries = useMemo(() => {
|
const flatEntries = useMemo(() => {
|
||||||
const term = filterText.trim().toLowerCase();
|
const term = filterText.trim().toLowerCase();
|
||||||
|
|
@ -490,6 +535,13 @@ export function FormGeneratorTree<T = any>({
|
||||||
const childNodes = await provider.loadChildren(id, ownership);
|
const childNodes = await provider.loadChildren(id, ownership);
|
||||||
if (childNodes.length > 0) {
|
if (childNodes.length > 0) {
|
||||||
setNodes((prev) => [...prev, ...childNodes]);
|
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(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -607,6 +659,11 @@ export function FormGeneratorTree<T = any>({
|
||||||
const newNode = await provider.createChild(parentId, trimmed);
|
const newNode = await provider.createChild(parentId, trimmed);
|
||||||
setNodes((prev) => [...prev, newNode]);
|
setNodes((prev) => [...prev, newNode]);
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
|
setConfirmedEmptyFolderIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(parentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setExpandedIds((prev) => new Set(prev).add(parentId));
|
setExpandedIds((prev) => new Set(prev).add(parentId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -795,6 +852,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
}
|
}
|
||||||
case 'F2': {
|
case 'F2': {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (hideRowActionButtons) break;
|
||||||
const node = nodes.find((n) => n.id === focusedId);
|
const node = nodes.find((n) => n.id === focusedId);
|
||||||
if (node && ownership === 'own' && provider.canRename?.(node)) {
|
if (node && ownership === 'own' && provider.canRename?.(node)) {
|
||||||
_handleStartRename(focusedId);
|
_handleStartRename(focusedId);
|
||||||
|
|
@ -803,6 +861,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
}
|
}
|
||||||
case 'Delete': {
|
case 'Delete': {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (hideRowActionButtons) break;
|
||||||
const node = nodes.find((n) => n.id === focusedId);
|
const node = nodes.find((n) => n.id === focusedId);
|
||||||
if (node && ownership === 'own' && provider.canDelete?.(node)) {
|
if (node && ownership === 'own' && provider.canDelete?.(node)) {
|
||||||
_handleDelete(focusedId);
|
_handleDelete(focusedId);
|
||||||
|
|
@ -822,6 +881,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
_handleToggleSelect,
|
_handleToggleSelect,
|
||||||
_handleStartRename,
|
_handleStartRename,
|
||||||
_handleDelete,
|
_handleDelete,
|
||||||
|
hideRowActionButtons,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -837,6 +897,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
);
|
);
|
||||||
}, [provider, ownership]);
|
}, [provider, ownership]);
|
||||||
|
|
||||||
|
const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons;
|
||||||
|
|
||||||
const _filteredIdsForAction = useCallback(
|
const _filteredIdsForAction = useCallback(
|
||||||
(action: TreeBatchAction): string[] => {
|
(action: TreeBatchAction): string[] => {
|
||||||
const ids = [...selectedIds];
|
const ids = [...selectedIds];
|
||||||
|
|
@ -861,14 +923,22 @@ export function FormGeneratorTree<T = any>({
|
||||||
const wrapperClasses = [
|
const wrapperClasses = [
|
||||||
styles.formGeneratorTree,
|
styles.formGeneratorTree,
|
||||||
compact && styles.compactMode,
|
compact && styles.compactMode,
|
||||||
|
embedMaxHeight != null && styles.embeddedPicker,
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={wrapperClasses}>
|
<div
|
||||||
{title && (
|
className={wrapperClasses}
|
||||||
|
style={
|
||||||
|
embedMaxHeight != null
|
||||||
|
? { height: embedMaxHeight, maxHeight: embedMaxHeight, flexShrink: 0 }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{title && !hideSectionHeader && (
|
||||||
<div
|
<div
|
||||||
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
|
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
|
||||||
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
|
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
|
||||||
|
|
@ -934,7 +1004,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedIds.size > 0 && batchActions.length > 0 && (
|
{selectedIds.size > 0 && batchActions.length > 0 && !hideRowActionButtons && (
|
||||||
<div className={styles.batchToolbar}>
|
<div className={styles.batchToolbar}>
|
||||||
<span className={styles.batchCount}>{selectedIds.size} selected</span>
|
<span className={styles.batchCount}>{selectedIds.size} selected</span>
|
||||||
{batchActions.map((action: TreeBatchAction) => {
|
{batchActions.map((action: TreeBatchAction) => {
|
||||||
|
|
@ -1011,6 +1081,8 @@ export function FormGeneratorTree<T = any>({
|
||||||
onDragOver={_handleDragOver}
|
onDragOver={_handleDragOver}
|
||||||
onDragLeave={_handleDragLeave}
|
onDragLeave={_handleDragLeave}
|
||||||
onDrop={_handleDrop}
|
onDrop={_handleDrop}
|
||||||
|
hideRowActionButtons={hideRowActionButtons}
|
||||||
|
dragDropEnabled={dragDropEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ interface FileData {
|
||||||
sysCreatedBy?: string;
|
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 {
|
return {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
|
|
@ -34,6 +36,8 @@ function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
|
||||||
neutralize: folder.neutralize,
|
neutralize: folder.neutralize,
|
||||||
contextOrphan: folder.contextOrphan,
|
contextOrphan: folder.contextOrphan,
|
||||||
icon: <FaFolder />,
|
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 ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
|
||||||
const typeMap = new Map<string, 'folder' | 'file'>();
|
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 foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
|
||||||
const allFolders: FolderData[] = foldersRes.data ?? [];
|
const allFolders: FolderData[] = foldersRes.data ?? [];
|
||||||
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
|
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 {
|
if (includeFiles) {
|
||||||
const filters: Record<string, any> = {};
|
try {
|
||||||
if (parentId) {
|
const filters: Record<string, any> = {};
|
||||||
filters.folderId = parentId;
|
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);
|
_trackTypes(nodes);
|
||||||
|
|
@ -137,7 +144,9 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
|
|
||||||
async createChild(parentId, name) {
|
async createChild(parentId, name) {
|
||||||
const res = await api.post('/api/files/folders', { name, parentId });
|
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) {
|
async renameNode(id, newName) {
|
||||||
|
|
@ -159,7 +168,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
|
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 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ export interface TreeNode<T = any> {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
data?: T;
|
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 {
|
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. */
|
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
|
||||||
allowCreateFolder?: boolean;
|
allowCreateFolder?: boolean;
|
||||||
className?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
145
src/components/SchedulePlanner/SchedulePlanner.module.css
Normal file
145
src/components/SchedulePlanner/SchedulePlanner.module.css
Normal 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;
|
||||||
|
}
|
||||||
526
src/components/SchedulePlanner/SchedulePlanner.tsx
Normal file
526
src/components/SchedulePlanner/SchedulePlanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/components/SchedulePlanner/index.ts
Normal file
1
src/components/SchedulePlanner/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { SchedulePlanner, type SchedulePlannerProps, type PlannerModeId } from './SchedulePlanner';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
98
src/components/UiComponents/AccordionList/AccordionList.tsx
Normal file
98
src/components/UiComponents/AccordionList/AccordionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/components/UiComponents/AccordionList/index.ts
Normal file
2
src/components/UiComponents/AccordionList/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { AccordionList } from './AccordionList';
|
||||||
|
export type { AccordionListProps, AccordionListItem } from './AccordionList';
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ButtonWithIconProps } from './ButtonTypes';
|
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';
|
as?: 'button' | 'a';
|
||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
|
||||||
export * from './AutoScroll';
|
export * from './AutoScroll';
|
||||||
export * from './Tabs';
|
export * from './Tabs';
|
||||||
export type { TabsProps, Tab } from './Tabs';
|
export type { TabsProps, Tab } from './Tabs';
|
||||||
|
export * from './AccordionList';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
export * from './VoiceLanguageSelect';
|
export * from './VoiceLanguageSelect';
|
||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
45
src/config/keepAliveRoutes.tsx
Normal file
45
src/config/keepAliveRoutes.tsx
Normal 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));
|
||||||
|
}
|
||||||
|
|
@ -131,7 +131,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
'feature.graphicalEditor': <FaProjectDiagram />,
|
'feature.graphicalEditor': <FaProjectDiagram />,
|
||||||
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
|
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
|
||||||
'page.feature.graphicalEditor.workflows': <FaProjectDiagram />,
|
|
||||||
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
|
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
|
||||||
'page.feature.chatbot.conversations': <FaComments />,
|
'page.feature.chatbot.conversations': <FaComments />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { GenericPageData } from '../../../pageInterface';
|
|
||||||
|
|
||||||
export const realEstatePages: GenericPageData[] = [];
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +1,108 @@
|
||||||
/**
|
/**
|
||||||
* MainLayout
|
* MainLayout
|
||||||
*
|
*
|
||||||
* Hauptlayout der Anwendung mit Sidebar und Content-Bereich.
|
* Hauptlayout der Anwendung mit Sidebar und Content-Bereich.
|
||||||
* Enthält den FeatureProvider für das Multi-Tenant-System.
|
* 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 { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||||
import { UserSection } from '../components/Navigation/UserSection';
|
import { UserSection } from '../components/Navigation/UserSection';
|
||||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
|
||||||
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
|
||||||
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
import { isKeepAliveScoped } from '../types/keepAlive.types';
|
||||||
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
|
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
|
||||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/;
|
display: isVisible ? 'flex' : 'none',
|
||||||
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
flexDirection: 'column',
|
||||||
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
|
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)
|
// INNER LAYOUT (mit Zugriff auf Store)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const MainLayoutInner: React.FC = () => {
|
const MainLayoutInner: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
|
const hideOutletShell = hideFeatureOutlet(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;
|
|
||||||
|
|
||||||
// Features laden beim Mount
|
// Features laden beim Mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized && !loading) {
|
if (!initialized && !loading) {
|
||||||
|
|
@ -60,7 +124,7 @@ const MainLayoutInner: React.FC = () => {
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.mainLayout}>
|
<div className={styles.mainLayout}>
|
||||||
{isMobileSidebarOpen && (
|
{isMobileSidebarOpen && (
|
||||||
|
|
@ -74,35 +138,25 @@ const MainLayoutInner: React.FC = () => {
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
|
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
|
||||||
<div className={styles.logoContainer}>
|
<div className={styles.logoContainer}>
|
||||||
<img
|
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
|
||||||
src="/logos/poweron-logo.png"
|
|
||||||
alt="PowerOn"
|
|
||||||
className={styles.logoImage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className={styles.navigation}>
|
<nav className={styles.navigation}>
|
||||||
{loading && (
|
{loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
|
||||||
<div className={styles.loadingNav}>
|
|
||||||
{t('Lade Navigation…')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={styles.errorNav}>
|
<div className={styles.errorNav}>
|
||||||
{t('Fehler')}: {error}
|
{t('Fehler')}: {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{initialized && !loading && (
|
{initialized && !loading && <MandateNavigation />}
|
||||||
<MandateNavigation />
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User-Bereich am unteren Rand */}
|
{/* User-Bereich am unteren Rand */}
|
||||||
<UserSection />
|
<UserSection />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
<div className={styles.mobileTopBar}>
|
<div className={styles.mobileTopBar}>
|
||||||
|
|
@ -113,17 +167,12 @@ const MainLayoutInner: React.FC = () => {
|
||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
|
||||||
src="/logos/poweron-logo.png"
|
|
||||||
alt="PowerOn"
|
|
||||||
className={styles.mobileLogo}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
{KEEP_ALIVE_ROUTES.map((routeEntry) => (
|
||||||
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
<RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} />
|
||||||
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
|
))}
|
||||||
<AdminLanguagesKeepAlive isVisible={isLanguagesKeepAliveVisible} />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.outletShell}
|
className={styles.outletShell}
|
||||||
|
|
|
||||||
|
|
@ -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<{
|
const _ProducedFilesSection: React.FC<{
|
||||||
steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>;
|
steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>;
|
||||||
unassignedFiles?: 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 }> = [];
|
const allFiles: Array<{ id: string; fileName?: string }> = [];
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
for (const f of step.outputFiles ?? []) {
|
for (const f of step.outputFiles ?? []) {
|
||||||
|
if (_isHiddenWorkflowArtifactFile(f)) continue;
|
||||||
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
|
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const f of unassignedFiles ?? []) {
|
for (const f of unassignedFiles ?? []) {
|
||||||
|
if (_isHiddenWorkflowArtifactFile(f)) continue;
|
||||||
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
|
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
|
||||||
}
|
}
|
||||||
if (!allFiles.length) return null;
|
if (!allFiles.length) return null;
|
||||||
|
|
@ -1312,8 +1321,8 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
|
||||||
{steps.map((step) => {
|
{steps.map((step) => {
|
||||||
const inputData = _stripFileRefKeys(step.inputSnapshot ?? {});
|
const inputData = _stripFileRefKeys(step.inputSnapshot ?? {});
|
||||||
const outputData = _stripFileRefKeys(step.output ?? {});
|
const outputData = _stripFileRefKeys(step.output ?? {});
|
||||||
const inputFiles = step.inputFiles ?? [];
|
const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
|
||||||
const outputFiles = step.outputFiles ?? [];
|
const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
|
||||||
const hasInput = inputData !== undefined || inputFiles.length > 0;
|
const hasInput = inputData !== undefined || inputFiles.length > 0;
|
||||||
const hasOutput = outputData !== undefined || outputFiles.length > 0;
|
const hasOutput = outputData !== undefined || outputFiles.length > 0;
|
||||||
return (
|
return (
|
||||||
|
|
@ -1374,12 +1383,16 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
|
||||||
<_FileLinkList files={unassignedFiles} />
|
<_FileLinkList files={visibleUnassigned} />
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { hideFeatureOutlet } from '../config/keepAliveRoutes';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
// Trustee Views
|
// Trustee Views
|
||||||
|
|
@ -28,7 +30,6 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
|
||||||
|
|
||||||
// GraphicalEditor Views
|
// GraphicalEditor Views
|
||||||
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
|
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
|
||||||
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
|
|
||||||
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
|
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
|
||||||
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
|
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
|
||||||
// Workspace Views
|
// Workspace Views
|
||||||
|
|
@ -148,7 +149,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
},
|
},
|
||||||
graphicalEditor: {
|
graphicalEditor: {
|
||||||
editor: GraphicalEditorPage,
|
editor: GraphicalEditorPage,
|
||||||
workflows: GraphicalEditorWorkflowsPage,
|
|
||||||
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
|
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
|
||||||
templates: GraphicalEditorTemplatesPage,
|
templates: GraphicalEditorTemplatesPage,
|
||||||
},
|
},
|
||||||
|
|
@ -192,6 +192,7 @@ interface FeatureViewPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
|
const location = useLocation();
|
||||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
|
|
||||||
// Berechtigungs-Check
|
// Berechtigungs-Check
|
||||||
|
|
@ -226,20 +227,10 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
if (!canView && view !== 'not-found') {
|
if (!canView && view !== 'not-found') {
|
||||||
return <AccessDenied />;
|
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.
|
// Feature outlet is hidden for paths configured in KEEP_ALIVE_ROUTES (rendered in MainLayout).
|
||||||
if (featureCode === 'commcoach' && view === 'session') {
|
// Add new persistent workspace URLs there if needed.
|
||||||
return null;
|
if (hideFeatureOutlet(location.pathname)) {
|
||||||
}
|
|
||||||
|
|
||||||
// GraphicalEditor editor is rendered persistently by GraphicalEditorKeepAlive at MainLayout level.
|
|
||||||
if (featureCode === 'graphicalEditor' && view === 'editor') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -276,6 +276,46 @@
|
||||||
background: var(--bg-primary, #fff);
|
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 {
|
.taskCard:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -396,6 +436,13 @@
|
||||||
cursor: not-allowed;
|
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 */
|
/* Upload task */
|
||||||
.uploadTaskBlock {
|
.uploadTaskBlock {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -7,26 +7,30 @@
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import {
|
import {
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
|
cancelPendingTaskStopRun,
|
||||||
completeTask,
|
completeTask,
|
||||||
fetchCompletedRuns,
|
fetchCompletedRuns,
|
||||||
fetchWorkflows,
|
fetchWorkflows,
|
||||||
executeGraph,
|
executeGraph,
|
||||||
loadClickupListTasksForDropdown,
|
|
||||||
type Automation2Task,
|
type Automation2Task,
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
type CompletedRun,
|
type CompletedRun,
|
||||||
type ApiRequestFunction,
|
|
||||||
} from '../../../api/workflowApi';
|
} from '../../../api/workflowApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { Popup } from '../../../components/UiComponents/Popup';
|
import { Popup } from '../../../components/UiComponents/Popup';
|
||||||
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
|
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
|
||||||
import { useFileOperations } from '../../../hooks/useFiles';
|
import { useFileOperations } from '../../../hooks/useFiles';
|
||||||
import styles from './Automation2WorkflowsTasks.module.css';
|
import styles from './Automation2WorkflowsTasks.module.css';
|
||||||
|
import {
|
||||||
|
WorkflowRuntimeFormFields,
|
||||||
|
useWorkflowRuntimeFormRequiredOk,
|
||||||
|
type WorkflowRuntimeFormFieldRow,
|
||||||
|
} from '../../../components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -75,17 +79,38 @@ function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary entry for execute — matches GraphicalEditorWorkflowsPage.handleExecute
|
* Primary entry for execute — align with first start node in graph order (backend-driven),
|
||||||
* (manual first, then form or api).
|
* then fall back to manual / form / api on invocations list.
|
||||||
*/
|
*/
|
||||||
function getPrimaryEntryPoint(wf: Automation2Workflow) {
|
function getPrimaryEntryPoint(wf: Automation2Workflow) {
|
||||||
const invs = wf.invocations || [];
|
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 (
|
return (
|
||||||
invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
|
invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
|
||||||
invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api'))
|
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 {
|
function primaryKindLabel(kind: string): string {
|
||||||
if (kind === 'form') return 'Formular';
|
if (kind === 'form') return 'Formular';
|
||||||
if (kind === 'manual') return 'Manuell';
|
if (kind === 'manual') return 'Manuell';
|
||||||
|
|
@ -105,7 +130,11 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
|
||||||
const [completedExpanded, setCompletedExpanded] = useState(false);
|
const [completedExpanded, setCompletedExpanded] = useState(false);
|
||||||
const [outputExpanded, setOutputExpanded] = useState(true);
|
const [outputExpanded, setOutputExpanded] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState<string | null>(null);
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
|
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
|
||||||
const [executingWorkflowId, setExecutingWorkflowId] = 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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
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(
|
const handleStartWorkflow = useCallback(
|
||||||
async (wf: Automation2Workflow) => {
|
async (wf: Automation2Workflow) => {
|
||||||
if (!instanceId || !wf.graph) return;
|
if (!instanceId || !wf.graph) return;
|
||||||
const primary = getPrimaryEntryPoint(wf);
|
const primary = getPrimaryEntryPoint(wf);
|
||||||
|
if (primary?.kind === 'form') {
|
||||||
|
setFormStartFields(getTriggerFormFieldsForWorkflow(wf));
|
||||||
|
setStartFormData({});
|
||||||
|
setFormStartWorkflow(wf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setExecutingWorkflowId(wf.id);
|
setExecutingWorkflowId(wf.id);
|
||||||
try {
|
try {
|
||||||
const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
|
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]
|
[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 openTasks = tasks.filter((task) => task.status === 'pending');
|
||||||
const completedTasks = 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}
|
instanceId={instanceId ?? undefined}
|
||||||
onSubmit={(result) => handleComplete(task.id, result)}
|
onSubmit={(result) => handleComplete(task.id, result)}
|
||||||
submitting={submitting === task.id}
|
submitting={submitting === task.id}
|
||||||
|
showDismiss
|
||||||
|
onDismiss={() => handleDismissOpenTask(task.id)}
|
||||||
|
dismissing={dismissingTaskId === task.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -337,6 +438,41 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -406,99 +542,10 @@ interface TaskCardProps {
|
||||||
onSubmit: (result: Record<string, unknown>) => void;
|
onSubmit: (result: Record<string, unknown>) => void;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
/** Open-task card: show top-right control to cancel run and remove from list. */
|
||||||
|
showDismiss?: boolean;
|
||||||
/** Check if file matches accept string (e.g. ".pdf,image/*"). */
|
onDismiss?: () => void;
|
||||||
function relationshipTaskIdFromFormValue(v: unknown): string {
|
dismissing?: boolean;
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskCard: React.FC<TaskCardProps> = ({
|
const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
|
|
@ -507,6 +554,9 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
showDismiss = false,
|
||||||
|
onDismiss,
|
||||||
|
dismissing = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
|
@ -521,6 +571,12 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
const nodeType = task.nodeType;
|
const nodeType = task.nodeType;
|
||||||
const stepLabel = getNodeStepLabel(config);
|
const stepLabel = getNodeStepLabel(config);
|
||||||
|
|
||||||
|
const inputFormFields: WorkflowRuntimeFormFieldRow[] =
|
||||||
|
nodeType === 'input.form'
|
||||||
|
? ((config.fields as WorkflowRuntimeFormFieldRow[]) ?? [])
|
||||||
|
: [];
|
||||||
|
const inputFormRequiredOk = useWorkflowRuntimeFormRequiredOk(inputFormFields, formData);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
@ -530,82 +586,13 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
if (readOnly) return null;
|
if (readOnly) return null;
|
||||||
switch (nodeType) {
|
switch (nodeType) {
|
||||||
case 'input.form': {
|
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 = (
|
const formContent = (
|
||||||
<div className={styles.formFields}>
|
<WorkflowRuntimeFormFields
|
||||||
{fields.map((f) => (
|
fields={inputFormFields}
|
||||||
<div key={f.name}>
|
formData={formData}
|
||||||
<label>
|
setFormData={setFormData}
|
||||||
{f.label || f.name}
|
formFieldsClassName={styles.formFields}
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -630,7 +617,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
onSubmit({ payload: formData });
|
onSubmit({ payload: formData });
|
||||||
setFormPopupOpen(false);
|
setFormPopupOpen(false);
|
||||||
}}
|
}}
|
||||||
disabled={submitting || !allRequiredFilled}
|
disabled={submitting || !inputFormRequiredOk}
|
||||||
className={styles.popupSubmitButton}
|
className={styles.popupSubmitButton}
|
||||||
>
|
>
|
||||||
{submitting ? t('wird gesendet') : t('absenden')}
|
{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 (
|
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.taskMeta}>
|
||||||
<div className={styles.taskMetaRow}>
|
<div className={styles.taskMetaRow}>
|
||||||
<span className={styles.metaLabel}>{t('Workflow')}</span>
|
<span className={styles.metaLabel}>{t('Workflow')}</span>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,45 +1,2 @@
|
||||||
// Copyright (c) 2025 Patrick Motsch
|
// Vitest / jsdom setup (minimal).
|
||||||
// All rights reserved.
|
|
||||||
//
|
|
||||||
// Vitest global setup: jest-dom matchers + jsdom polyfills required by some
|
|
||||||
// of our components (ResizeObserver, matchMedia, scrollIntoView).
|
|
||||||
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
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 {};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
34
src/types/keepAlive.types.ts
Normal file
34
src/types/keepAlive.types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -259,7 +259,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
icon: 'sitemap',
|
icon: 'sitemap',
|
||||||
views: [
|
views: [
|
||||||
{ code: 'editor', label: 'Editor', path: 'editor' },
|
{ code: 'editor', label: 'Editor', path: 'editor' },
|
||||||
{ code: 'workflows', label: 'Workflows', path: 'workflows' },
|
|
||||||
{ code: 'templates', label: 'Vorlagen', path: 'templates' },
|
{ code: 'templates', label: 'Vorlagen', path: 'templates' },
|
||||||
{ code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
|
{ code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
|
||||||
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
||||||
|
|
|
||||||
531
src/utils/scheduleCron.ts
Normal file
531
src/utils/scheduleCron.ts
Normal 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;
|
||||||
|
/** 0–6 cron DOW; für weeks / weekly / weekdays */
|
||||||
|
weekdays: number[];
|
||||||
|
/** Tag des Monats 1–31 (Planner months: 1–28 empfohlen) */
|
||||||
|
monthDay: number;
|
||||||
|
/** 1–12, 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 Mo–So (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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue