teamsbot
This commit is contained in:
parent
fc2cce8732
commit
e09ed758ff
35 changed files with 5522 additions and 137 deletions
1626
package-lock.json
generated
1626
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -11,7 +11,11 @@
|
||||||
"build:prod": "tsc -b && vite build --mode prod",
|
"build:prod": "tsc -b && vite build --mode prod",
|
||||||
"build:int": "tsc -b && vite build --mode int",
|
"build:int": "tsc -b && vite build --mode int",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
|
|
@ -47,18 +51,24 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
"@types/proj4": "^2.5.6",
|
"@types/proj4": "^2.5.6",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.35.1",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-html": "^3.2.2"
|
"vite-plugin-html": "^3.2.2",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,11 +169,63 @@ export interface MfaChallengeEvent {
|
||||||
|
|
||||||
// SSE Event Types
|
// SSE Event Types
|
||||||
export interface TeamsbotSSEEvent {
|
export interface TeamsbotSSEEvent {
|
||||||
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed';
|
type:
|
||||||
|
| 'transcript'
|
||||||
|
| 'botResponse'
|
||||||
|
| 'analysis'
|
||||||
|
| 'suggestedResponse'
|
||||||
|
| 'statusChange'
|
||||||
|
| 'error'
|
||||||
|
| 'ping'
|
||||||
|
| 'sessionState'
|
||||||
|
| 'ttsDeliveryStatus'
|
||||||
|
| 'mfaChallenge'
|
||||||
|
| 'mfaResolved'
|
||||||
|
| 'chatSendFailed'
|
||||||
|
| 'directorPrompt'
|
||||||
|
| 'agentRun'
|
||||||
|
| 'botConnectionState';
|
||||||
data: any;
|
data: any;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Director Prompts (private operator instructions during a live meeting)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
export type DirectorPromptMode = 'oneShot' | 'persistent';
|
||||||
|
export type DirectorPromptStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'consumed';
|
||||||
|
|
||||||
|
export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000;
|
||||||
|
export const DIRECTOR_PROMPT_FILE_LIMIT = 10;
|
||||||
|
|
||||||
|
export interface DirectorPrompt {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
instanceId: string;
|
||||||
|
operatorUserId: string;
|
||||||
|
text: string;
|
||||||
|
mode: DirectorPromptMode;
|
||||||
|
fileIds: string[];
|
||||||
|
status: DirectorPromptStatus;
|
||||||
|
statusMessage?: string;
|
||||||
|
createdAt: string;
|
||||||
|
consumedAt?: string;
|
||||||
|
agentRunId?: string;
|
||||||
|
responseText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectorPromptCreateRequest {
|
||||||
|
text: string;
|
||||||
|
mode: DirectorPromptMode;
|
||||||
|
fileIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API FUNCTIONS
|
// API FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -289,6 +341,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new system bot account. The password is encrypted server-side
|
||||||
|
* before storage; the API never returns the password back. SysAdmin only.
|
||||||
|
*/
|
||||||
|
export async function createSystemBot(
|
||||||
|
instanceId: string,
|
||||||
|
payload: { email: string; password: string; name?: string },
|
||||||
|
): Promise<{ bot: SystemBot }> {
|
||||||
|
const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a system bot account. SysAdmin only.
|
||||||
|
*/
|
||||||
|
export async function deleteSystemBot(
|
||||||
|
instanceId: string,
|
||||||
|
botId: string,
|
||||||
|
): Promise<{ deleted: boolean }> {
|
||||||
|
const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
|
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
|
||||||
*/
|
*/
|
||||||
|
|
@ -452,3 +527,50 @@ export async function submitMfaCode(
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Director Prompts
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a private director prompt to the running bot. Triggers the full
|
||||||
|
* agent path (web, mail, RAG, etc.) and delivers the answer into the meeting.
|
||||||
|
*/
|
||||||
|
export async function submitDirectorPrompt(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
body: DirectorPromptCreateRequest,
|
||||||
|
): Promise<{ prompt: DirectorPrompt }> {
|
||||||
|
const response = await api.post(
|
||||||
|
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List director prompts for a session (operator's own prompts only).
|
||||||
|
*/
|
||||||
|
export async function listDirectorPrompts(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<{ prompts: DirectorPrompt[] }> {
|
||||||
|
const response = await api.get(
|
||||||
|
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a (typically persistent) director prompt.
|
||||||
|
*/
|
||||||
|
export async function deleteDirectorPrompt(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
promptId: string,
|
||||||
|
): Promise<{ deleted: boolean; promptId: string }> {
|
||||||
|
const response = await api.delete(
|
||||||
|
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface PortField {
|
||||||
/** Plain string or per-language map from the API catalog. */
|
/** Plain string or per-language map from the API catalog. */
|
||||||
description: string | Record<string, string>;
|
description: string | Record<string, string>;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
|
enumValues?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PortSchema {
|
export interface PortSchema {
|
||||||
|
|
@ -40,8 +41,14 @@ export interface InputPortDef {
|
||||||
accepts: string[];
|
accepts: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Graph-defined output schema (e.g. form fields from node parameters). */
|
||||||
|
export interface GraphDefinedSchemaRef {
|
||||||
|
kind: 'fromGraph';
|
||||||
|
parameter: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutputPortDef {
|
export interface OutputPortDef {
|
||||||
schema: string;
|
schema: string | GraphDefinedSchemaRef;
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
deriveFrom?: string;
|
deriveFrom?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +97,7 @@ export interface Automation2GraphNode {
|
||||||
type: string;
|
type: string;
|
||||||
parameters?: Record<string, unknown>;
|
parameters?: Record<string, unknown>;
|
||||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||||
outputPorts?: Array<{ name: string; schema: string }>;
|
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Automation2Connection {
|
export interface Automation2Connection {
|
||||||
|
|
@ -109,6 +116,10 @@ export interface ExecuteGraphResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
nodeOutputs?: Record<string, unknown>;
|
nodeOutputs?: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** Soft, non-blocking message displayed alongside a successful response.
|
||||||
|
* Used e.g. by the Save flow to surface "Gespeichert mit X Pflicht-Fehlern"
|
||||||
|
* without flipping `success` to `false`. */
|
||||||
|
warning?: string;
|
||||||
stopped?: boolean;
|
stopped?: boolean;
|
||||||
failedNode?: string;
|
failedNode?: string;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
|
@ -264,8 +275,55 @@ export async function fetchNodeTypes(
|
||||||
});
|
});
|
||||||
const nodeTypes = data?.nodeTypes ?? [];
|
const nodeTypes = data?.nodeTypes ?? [];
|
||||||
const categories = data?.categories ?? [];
|
const categories = data?.categories ?? [];
|
||||||
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
|
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
|
||||||
return { nodeTypes, categories };
|
const systemVariables = data?.systemVariables ?? undefined;
|
||||||
|
console.log(
|
||||||
|
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
|
||||||
|
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
|
||||||
|
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars`
|
||||||
|
);
|
||||||
|
return { nodeTypes, categories, portTypeCatalog, systemVariables };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpstreamPathEntry {
|
||||||
|
producerNodeId: string;
|
||||||
|
producerLabel?: string;
|
||||||
|
path: (string | number)[];
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
scopeOrigin: 'data' | 'loop' | 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/workflows/{instanceId}/upstream-paths — pickable upstream paths for DataPicker / AI.
|
||||||
|
*/
|
||||||
|
export async function postUpstreamPaths(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
graph: Automation2Graph,
|
||||||
|
nodeId: string
|
||||||
|
): Promise<{ paths: UpstreamPathEntry[] }> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/workflows/${instanceId}/upstream-paths`,
|
||||||
|
method: 'post',
|
||||||
|
data: { graph, nodeId },
|
||||||
|
});
|
||||||
|
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET saved workflow graph variant of upstream-paths (requires workflowId). */
|
||||||
|
export async function getUpstreamPathsSaved(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
workflowId: string,
|
||||||
|
nodeId: string
|
||||||
|
): Promise<{ paths: UpstreamPathEntry[] }> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/workflows/${instanceId}/upstream-paths/${encodeURIComponent(nodeId)}`,
|
||||||
|
method: 'get',
|
||||||
|
params: { workflowId },
|
||||||
|
});
|
||||||
|
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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 { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
import type { ApiRequestFunction, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||||
|
|
||||||
export interface Automation2DataFlowContextValue {
|
export interface Automation2DataFlowContextValue {
|
||||||
currentNodeId: string;
|
currentNodeId: string;
|
||||||
|
|
@ -19,6 +19,11 @@ export interface Automation2DataFlowContextValue {
|
||||||
systemVariables: Record<string, SystemVariable>;
|
systemVariables: Record<string, SystemVariable>;
|
||||||
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). */
|
||||||
|
instanceId?: string;
|
||||||
|
request?: ApiRequestFunction;
|
||||||
|
/** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */
|
||||||
|
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
|
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
|
||||||
|
|
@ -36,6 +41,8 @@ interface Automation2DataFlowProviderProps {
|
||||||
language: string;
|
language: string;
|
||||||
portTypeCatalog?: Record<string, PortSchema>;
|
portTypeCatalog?: Record<string, PortSchema>;
|
||||||
systemVariables?: Record<string, SystemVariable>;
|
systemVariables?: Record<string, SystemVariable>;
|
||||||
|
instanceId?: string;
|
||||||
|
request?: ApiRequestFunction;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,10 +55,52 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
language,
|
language,
|
||||||
portTypeCatalog = {},
|
portTypeCatalog = {},
|
||||||
systemVariables = {},
|
systemVariables = {},
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
|
||||||
|
const raw = node.parameters?.[parameterKey];
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
const fields: PortField[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item !== 'object' || item === null) continue;
|
||||||
|
const rec = item as Record<string, unknown>;
|
||||||
|
if (typeof rec.name !== 'string') continue;
|
||||||
|
const lab = rec.label;
|
||||||
|
const desc =
|
||||||
|
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
|
||||||
|
const ftype = typeof rec.type === 'string' ? rec.type : 'str';
|
||||||
|
if (ftype === 'group' && Array.isArray(rec.fields)) {
|
||||||
|
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||||
|
if (!sub || typeof sub.name !== 'string') continue;
|
||||||
|
const sl = sub.label;
|
||||||
|
const sdesc =
|
||||||
|
typeof sl === 'string'
|
||||||
|
? sl
|
||||||
|
: typeof sl === 'object' && sl !== null
|
||||||
|
? String((sl as Record<string, string>).de ?? '')
|
||||||
|
: '';
|
||||||
|
fields.push({
|
||||||
|
name: `${rec.name}.${sub.name}`,
|
||||||
|
type: typeof sub.type === 'string' ? sub.type : 'str',
|
||||||
|
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
|
||||||
|
required: Boolean(sub.required),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fields.push({
|
||||||
|
name: rec.name,
|
||||||
|
type: ftype,
|
||||||
|
description: (desc && desc.trim()) || rec.name,
|
||||||
|
required: Boolean(rec.required),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fields.length ? { name: 'FormPayload_dynamic', fields } : null;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
currentNodeId: node.id,
|
currentNodeId: node.id,
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -64,8 +113,11 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
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),
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
parseGraphDefinedSchema,
|
||||||
};
|
};
|
||||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
|
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, instanceId, request]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Automation2DataFlowContext.Provider value={value}>
|
<Automation2DataFlowContext.Provider value={value}>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ import {
|
||||||
buildInvocationsForPrimaryKind,
|
buildInvocationsForPrimaryKind,
|
||||||
} from '../nodes/runtime/workflowStartSync';
|
} 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 { getLabel as getParamLabel } from '../nodes/shared/utils';
|
||||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import { EditorChatPanel } from './EditorChatPanel';
|
import { EditorChatPanel } from './EditorChatPanel';
|
||||||
|
|
@ -180,6 +182,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
|
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the
|
||||||
|
// canvas error badges and the Run-button gate. Graph-level: Save stays
|
||||||
|
// unconditional (Schicht-4 invariant: WIP must always be persistable).
|
||||||
|
const nodeErrors = useMemo(
|
||||||
|
() =>
|
||||||
|
findGraphErrors(
|
||||||
|
canvasNodes,
|
||||||
|
nodeTypes,
|
||||||
|
(p) => getParamLabel(p.description, language) || p.name,
|
||||||
|
),
|
||||||
|
[canvasNodes, nodeTypes, language]
|
||||||
|
);
|
||||||
|
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
|
||||||
|
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
|
||||||
|
|
||||||
const applyGraphWithSync = useCallback(
|
const applyGraphWithSync = useCallback(
|
||||||
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
||||||
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
||||||
|
|
@ -211,6 +228,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
|
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen.
|
||||||
|
if (Object.keys(nodeErrors).length > 0) {
|
||||||
|
const firstId = Object.keys(nodeErrors)[0];
|
||||||
|
const firstNode = canvasNodes.find((n) => n.id === firstId);
|
||||||
|
if (firstNode) setSelectedNode(firstNode);
|
||||||
|
setExecuteResult({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') +
|
||||||
|
(firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -228,7 +258,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setExecuting(false);
|
setExecuting(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
|
@ -236,11 +266,28 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
|
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt,
|
||||||
|
// aber wir berichten die Anzahl in einem nicht-blockierenden Warning,
|
||||||
|
// damit der User die WIP-Lücken nicht stillschweigend persistiert.
|
||||||
|
const errorCount = Object.values(nodeErrors).reduce(
|
||||||
|
(acc, list) => acc + list.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const errorNodeCount = Object.keys(nodeErrors).length;
|
||||||
|
const _buildSaveResult = (): ExecuteGraphResponse => ({
|
||||||
|
success: true,
|
||||||
|
warning:
|
||||||
|
errorCount > 0
|
||||||
|
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
|
||||||
|
.replace('{n}', String(errorCount))
|
||||||
|
.replace('{m}', String(errorNodeCount))
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (currentWorkflowId) {
|
if (currentWorkflowId) {
|
||||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult(_buildSaveResult());
|
||||||
} else {
|
} else {
|
||||||
const label = await promptInput(t('Workflow-Name:'), {
|
const label = await promptInput(t('Workflow-Name:'), {
|
||||||
title: t('Workflow speichern'),
|
title: t('Workflow speichern'),
|
||||||
|
|
@ -259,14 +306,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
if (created.invocations?.length) setInvocations(created.invocations);
|
if (created.invocations?.length) setInvocations(created.invocations);
|
||||||
setWorkflows((prev) => [...prev, created]);
|
setWorkflows((prev) => [...prev, created]);
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult(_buildSaveResult());
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]);
|
||||||
|
|
||||||
const handleLoad = useCallback(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
|
|
@ -749,6 +796,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
saving={saving}
|
saving={saving}
|
||||||
executing={executing}
|
executing={executing}
|
||||||
hasNodes={canvasNodes.length > 0}
|
hasNodes={canvasNodes.length > 0}
|
||||||
|
executeBlockedReason={
|
||||||
|
hasGraphErrors
|
||||||
|
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onExecuteBlockedClick={() => {
|
||||||
|
if (firstErrorNodeId) {
|
||||||
|
const n = canvasNodes.find((x) => x.id === firstErrorNodeId);
|
||||||
|
if (n) setSelectedNode(n);
|
||||||
|
}
|
||||||
|
}}
|
||||||
executeResult={executeResult}
|
executeResult={executeResult}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
currentVersionId={currentVersionId}
|
currentVersionId={currentVersionId}
|
||||||
|
|
@ -777,6 +835,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
getCategoryIcon={getCategoryIcon}
|
getCategoryIcon={getCategoryIcon}
|
||||||
onSelectionChange={setSelectedNode}
|
onSelectionChange={setSelectedNode}
|
||||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||||
|
nodeErrors={nodeErrors}
|
||||||
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;
|
||||||
|
|
@ -804,6 +863,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
language={language}
|
language={language}
|
||||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||||
systemVariables={systemVariables as Record<string, never>}
|
systemVariables={systemVariables as Record<string, never>}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
>
|
>
|
||||||
<NodeConfigPanel
|
<NodeConfigPanel
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
|
|
|
||||||
99
src/components/FlowEditor/editor/CanvasHeader.test.tsx
Normal file
99
src/components/FlowEditor/editor/CanvasHeader.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,6 +21,11 @@ interface CanvasHeaderProps {
|
||||||
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
|
||||||
|
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
|
||||||
|
* parent can navigate the user to the first offending node. */
|
||||||
|
executeBlockedReason?: string | null;
|
||||||
|
onExecuteBlockedClick?: () => void;
|
||||||
executeResult: ExecuteGraphResponse | null;
|
executeResult: ExecuteGraphResponse | null;
|
||||||
versions?: AutoVersion[];
|
versions?: AutoVersion[];
|
||||||
currentVersionId?: string | null;
|
currentVersionId?: string | null;
|
||||||
|
|
@ -56,6 +61,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
saving,
|
saving,
|
||||||
executing,
|
executing,
|
||||||
hasNodes,
|
hasNodes,
|
||||||
|
executeBlockedReason,
|
||||||
|
onExecuteBlockedClick,
|
||||||
executeResult,
|
executeResult,
|
||||||
versions,
|
versions,
|
||||||
currentVersionId,
|
currentVersionId,
|
||||||
|
|
@ -213,7 +220,11 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
className={styles.retryButton}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={saving || !hasNodes}
|
// Phase-4 Schicht-4: Save niemals blockieren — work-in-progress muss
|
||||||
|
// jederzeit persistierbar sein. Nur während des Save-Requests selbst
|
||||||
|
// sperren wir den Button, um Doppelklicks zu verhindern.
|
||||||
|
disabled={saving}
|
||||||
|
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
|
||||||
>
|
>
|
||||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -277,14 +288,37 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
className={styles.retryButton}
|
||||||
onClick={onExecute}
|
onClick={() => {
|
||||||
|
if (executeBlockedReason) {
|
||||||
|
onExecuteBlockedClick?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onExecute();
|
||||||
|
}}
|
||||||
disabled={executing || !hasNodes}
|
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 ? (
|
{executing ? (
|
||||||
<>
|
<>
|
||||||
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
||||||
{t('Ausführen…')}
|
{t('Ausführen…')}
|
||||||
</>
|
</>
|
||||||
|
) : executeBlockedReason ? (
|
||||||
|
<>
|
||||||
|
<FaPlay style={{ marginRight: '0.5rem', opacity: 0.5 }} />
|
||||||
|
{t('Pflicht-Felder fehlen')}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FaPlay style={{ marginRight: '0.5rem' }} />
|
<FaPlay style={{ marginRight: '0.5rem' }} />
|
||||||
|
|
@ -392,19 +426,27 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
background: executeResult.success
|
background: executeResult.success
|
||||||
? 'rgba(40,167,69,0.15)'
|
? executeResult.warning
|
||||||
|
? 'rgba(255,193,7,0.15)'
|
||||||
|
: 'rgba(40,167,69,0.15)'
|
||||||
: (executeResult as { paused?: boolean }).paused
|
: (executeResult as { paused?: boolean }).paused
|
||||||
? 'rgba(0,123,255,0.15)'
|
? 'rgba(0,123,255,0.15)'
|
||||||
: 'rgba(220,53,69,0.15)',
|
: 'rgba(220,53,69,0.15)',
|
||||||
color: executeResult.success
|
color: executeResult.success
|
||||||
? 'var(--success-color,#28a745)'
|
? executeResult.warning
|
||||||
|
? 'var(--warning-color,#ffc107)'
|
||||||
|
: 'var(--success-color,#28a745)'
|
||||||
: (executeResult as { paused?: boolean }).paused
|
: (executeResult as { paused?: boolean }).paused
|
||||||
? 'var(--primary-color,#007bff)'
|
? 'var(--primary-color,#007bff)'
|
||||||
: 'var(--danger-color,#dc3545)',
|
: 'var(--danger-color,#dc3545)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{executeResult.success ? (
|
{executeResult.success ? (
|
||||||
<>{t('Ausführung abgeschlossen')}</>
|
executeResult.warning ? (
|
||||||
|
<>⚠ {executeResult.warning}</>
|
||||||
|
) : (
|
||||||
|
<>{t('Ausführung abgeschlossen')}</>
|
||||||
|
)
|
||||||
) : (executeResult as { paused?: boolean }).paused ? (
|
) : (executeResult as { paused?: boolean }).paused ? (
|
||||||
<>
|
<>
|
||||||
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { NodeType } from '../../../api/workflowApi';
|
import type { GraphDefinedSchemaRef, NodeType } 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';
|
||||||
|
|
@ -23,7 +23,7 @@ export interface CanvasNode {
|
||||||
outputs: number;
|
outputs: number;
|
||||||
parameters?: Record<string, unknown>;
|
parameters?: Record<string, unknown>;
|
||||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||||
outputPorts?: Array<{ name: string; schema: string }>;
|
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasConnection {
|
export interface CanvasConnection {
|
||||||
|
|
@ -108,6 +108,12 @@ export function computeAutoLayout(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string {
|
||||||
|
if (typeof schema === 'string') return schema;
|
||||||
|
if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
||||||
function _checkConnectionCompatibility(
|
function _checkConnectionCompatibility(
|
||||||
sourceNode: CanvasNode,
|
sourceNode: CanvasNode,
|
||||||
|
|
@ -124,11 +130,12 @@ function _checkConnectionCompatibility(
|
||||||
const tgtPort = tgtType.inputPorts[targetInputIdx];
|
const tgtPort = tgtType.inputPorts[targetInputIdx];
|
||||||
if (!srcPort || !tgtPort) return 'ok';
|
if (!srcPort || !tgtPort) return 'ok';
|
||||||
|
|
||||||
const srcSchema = srcPort.schema;
|
const srcSchema = _outputSchemaName(srcPort.schema as string | GraphDefinedSchemaRef);
|
||||||
const accepts = tgtPort.accepts;
|
const accepts = tgtPort.accepts;
|
||||||
if (!accepts || accepts.length === 0) return 'ok';
|
if (!accepts || accepts.length === 0) return 'ok';
|
||||||
if (accepts.includes('Transit')) return 'ok';
|
if (accepts.includes('Transit')) return 'ok';
|
||||||
if (accepts.includes(srcSchema)) return 'ok';
|
if (srcSchema && accepts.includes(srcSchema)) return 'ok';
|
||||||
|
if (srcSchema?.startsWith('FormPayload') && accepts.includes('FormPayload')) return 'ok';
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +150,9 @@ interface FlowCanvasProps {
|
||||||
getCategoryIcon: (category: string) => React.ReactNode;
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||||
highlightedNodeIds?: Record<string, string>;
|
highlightedNodeIds?: Record<string, string>;
|
||||||
|
/** Phase-4: per-node "required-but-unbound" param errors. The canvas renders
|
||||||
|
* a red error badge in the top-right of each node whose id is a key. */
|
||||||
|
nodeErrors?: Record<string, Array<{ paramName: string; paramLabel: string }>>;
|
||||||
/** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt
|
/** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt
|
||||||
* (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
|
* (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
|
||||||
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
|
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
|
||||||
|
|
@ -167,6 +177,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
getCategoryIcon,
|
getCategoryIcon,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
highlightedNodeIds,
|
highlightedNodeIds,
|
||||||
|
nodeErrors,
|
||||||
onExternalDrop,
|
onExternalDrop,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -790,6 +801,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
|
for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
|
||||||
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
|
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
|
||||||
|
|
||||||
|
const wireSourceNode =
|
||||||
|
connectingFrom && !selectedConnectionId ? nodes.find((n) => n.id === connectingFrom.nodeId) : null;
|
||||||
|
|
||||||
const isSelected = selectedNodeIds.has(node.id);
|
const isSelected = selectedNodeIds.has(node.id);
|
||||||
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||||
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||||||
|
|
@ -834,15 +848,54 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
|
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{nodeErrors?.[node.id]?.length ? (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
title={
|
||||||
|
t('Pflicht-Felder ohne Quelle: ') +
|
||||||
|
nodeErrors[node.id].map((e) => e.paramLabel).join(', ')
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
minWidth: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '0 6px',
|
||||||
|
background: 'var(--danger-color, #dc3545)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
|
||||||
|
zIndex: 5,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nodeErrors[node.id].length}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{handles.map(({ index, isOutput }) => {
|
{handles.map(({ index, isOutput }) => {
|
||||||
const pos = getHandlePosition(node, index);
|
const pos = getHandlePosition(node, index);
|
||||||
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
||||||
const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
|
const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
|
||||||
const isCurrentTargetOfSelection =
|
const isCurrentTargetOfSelection =
|
||||||
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
|
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
|
||||||
|
let wireTargetOk = true;
|
||||||
|
if (!isOutput && connectingFrom && !selectedConnectionId && wireSourceNode) {
|
||||||
|
const sourceOutputIdx =
|
||||||
|
connectingFrom.handleIndex >= wireSourceNode.inputs
|
||||||
|
? connectingFrom.handleIndex - wireSourceNode.inputs
|
||||||
|
: 0;
|
||||||
|
wireTargetOk =
|
||||||
|
_checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
|
||||||
|
}
|
||||||
const canConnect =
|
const canConnect =
|
||||||
isOutput ||
|
isOutput ||
|
||||||
(!used && connectingFrom) ||
|
(!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) ||
|
||||||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
||||||
const nt = nodeTypeMap[node.type];
|
const nt = nodeTypeMap[node.type];
|
||||||
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;
|
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import type { CanvasNode } from './FlowCanvas';
|
import type { CanvasNode } from './FlowCanvas';
|
||||||
import type { NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
|
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
|
||||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||||
|
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||||
|
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||||
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
@ -76,11 +78,27 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
||||||
|
|
||||||
|
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
|
||||||
|
// nicht nach unten scrollen muss, um zu sehen was fehlt.
|
||||||
|
const sortedParameters: NodeTypeParameter[] = useMemo(() => {
|
||||||
|
const all = nodeType?.parameters ?? [];
|
||||||
|
const required = all.filter((p) => p.required);
|
||||||
|
const optional = all.filter((p) => !p.required);
|
||||||
|
return [...required, ...optional];
|
||||||
|
}, [nodeType?.parameters]);
|
||||||
|
|
||||||
|
// Pre-compute which required params are unbound on this node so we can
|
||||||
|
// surface a panel-level summary banner.
|
||||||
|
const requiredErrors = useMemo(() => {
|
||||||
|
if (!node || !nodeType) return [];
|
||||||
|
return findRequiredErrors(node, nodeType, (p) => getLabel(p.description, language) || p.name);
|
||||||
|
}, [node, nodeType, language]);
|
||||||
|
|
||||||
if (!node || !nodeType) return null;
|
if (!node || !nodeType) return null;
|
||||||
|
|
||||||
const isTrigger = node.type.startsWith('trigger.');
|
const isTrigger = node.type.startsWith('trigger.');
|
||||||
const showNameField = onNodeUpdate && !isTrigger;
|
const showNameField = onNodeUpdate && !isTrigger;
|
||||||
const parameters = nodeType.parameters || [];
|
const parameters = sortedParameters;
|
||||||
|
|
||||||
const inputPortDefs = nodeType.inputPorts ?? {};
|
const inputPortDefs = nodeType.inputPorts ?? {};
|
||||||
const outputPortDefs = nodeType.outputPorts ?? {};
|
const outputPortDefs = nodeType.outputPorts ?? {};
|
||||||
|
|
@ -112,9 +130,18 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{hasPortInfo && (
|
{hasPortInfo && (
|
||||||
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.75rem' }}>
|
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
|
||||||
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #666)', fontWeight: 600, padding: '0.25rem 0' }}>
|
<summary
|
||||||
{t('Datenfluss (Eingabe / Ausgabe)')}
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-secondary, #888)',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '0.15rem 0',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
|
||||||
|
>
|
||||||
|
{t('Schema (Typ-Referenz)')}
|
||||||
</summary>
|
</summary>
|
||||||
{inputPortEntries.length > 0 && (
|
{inputPortEntries.length > 0 && (
|
||||||
<div style={{ marginTop: '0.4rem' }}>
|
<div style={{ marginTop: '0.4rem' }}>
|
||||||
|
|
@ -142,7 +169,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
<_PortFieldList
|
<_PortFieldList
|
||||||
key={`out-${idx}`}
|
key={`out-${idx}`}
|
||||||
portIndex={Number(idx)}
|
portIndex={Number(idx)}
|
||||||
schemaNames={def?.schema ? [def.schema] : []}
|
schemaNames={_schemaNamesFromOutputPort(def)}
|
||||||
catalog={portTypeCatalog}
|
catalog={portTypeCatalog}
|
||||||
emptyLabel={t('keine Felder')}
|
emptyLabel={t('keine Felder')}
|
||||||
language={language}
|
language={language}
|
||||||
|
|
@ -152,26 +179,123 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
)}
|
)}
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
{requiredErrors.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: 'rgba(220,53,69,0.10)',
|
||||||
|
borderLeft: '3px solid var(--danger-color, #dc3545)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--danger-color, #dc3545)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Pflicht-Felder ohne Quelle:')}{' '}
|
||||||
|
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{parameters.map((param: NodeTypeParameter) => {
|
{parameters.map((param: NodeTypeParameter) => {
|
||||||
|
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={params[param.name] ?? param.default}
|
||||||
|
onChange={(val) => updateParam(param.name, val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const frontendType = param.frontendType || 'text';
|
const frontendType = param.frontendType || 'text';
|
||||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||||
return (
|
return (
|
||||||
<Renderer
|
<div key={param.name} style={{ marginBottom: 4 }}>
|
||||||
key={param.name}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||||
param={param}
|
{param.required && (
|
||||||
value={params[param.name] ?? param.default}
|
<span
|
||||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
title={t('Pflichtfeld')}
|
||||||
allParams={params}
|
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700 }}
|
||||||
instanceId={instanceId}
|
>
|
||||||
request={request}
|
*
|
||||||
nodeType={node.type}
|
</span>
|
||||||
/>
|
)}
|
||||||
|
{param.type && (
|
||||||
|
<span
|
||||||
|
title={t('Parameter-Typ')}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#555',
|
||||||
|
background: '#eee',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Heuristic: required params with a Schicht-1 catalog type (non-primitive
|
||||||
|
* ref/record/list) get the typed RequiredAttributePicker; primitive scalars
|
||||||
|
* fall through to the legacy frontend-type renderer (text/number/select etc.)
|
||||||
|
* unless they have no frontendType at all and a non-trivial type. */
|
||||||
|
function _shouldUseRequiredPicker(param: NodeTypeParameter): boolean {
|
||||||
|
if (!param.required) return false;
|
||||||
|
if (!param.type) return false;
|
||||||
|
// Always defer to specialized FE renderers when explicitly chosen.
|
||||||
|
if (param.frontendType && _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS.has(param.frontendType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Catalog ref/record/list types are best handled by RequiredAttributePicker.
|
||||||
|
if (/^(List\[|Dict\[)/.test(param.type)) return true;
|
||||||
|
if (/^[A-Z]/.test(param.type)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
||||||
|
'userConnection',
|
||||||
|
'sharepointFolder',
|
||||||
|
'sharepointFile',
|
||||||
|
'clickupList',
|
||||||
|
'clickupTask',
|
||||||
|
'dataRef',
|
||||||
|
'caseList',
|
||||||
|
'fieldBuilder',
|
||||||
|
'keyValueRows',
|
||||||
|
'cron',
|
||||||
|
'condition',
|
||||||
|
'mappingTable',
|
||||||
|
'filterExpression',
|
||||||
|
'attachmentBuilder',
|
||||||
|
'json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
||||||
|
if (!def?.schema) return [];
|
||||||
|
if (typeof def.schema === 'string') return [def.schema];
|
||||||
|
if (typeof def.schema === 'object' && def.schema.kind === 'fromGraph') return ['FormPayload', 'FormPayload_dynamic'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
interface _PortFieldListProps {
|
interface _PortFieldListProps {
|
||||||
portIndex: number;
|
portIndex: number;
|
||||||
schemaNames: string[];
|
schemaNames: string[];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* DataRefRenderer — Pick-not-Push attribute binding using the existing
|
||||||
|
* hierarchical DataPicker.
|
||||||
|
*
|
||||||
|
* For required typed parameters (e.g. ``documentList: DocumentList``) where
|
||||||
|
* the user must explicitly bind to an upstream node's typed output. Replaces
|
||||||
|
* the legacy ``frontendType: "hidden"`` so the binding becomes visible and
|
||||||
|
* editable directly in the node config panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { DataPicker } from '../shared/DataPicker';
|
||||||
|
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
|
||||||
|
import type { FieldRendererProps } from './index';
|
||||||
|
|
||||||
|
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const dataFlow = useAutomation2DataFlow();
|
||||||
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const currentRef = isRef(value) ? (value as DataRef) : null;
|
||||||
|
const isMissing = param.required && !currentRef;
|
||||||
|
|
||||||
|
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
|
||||||
|
const hasSources = sourceIds.some((id) => {
|
||||||
|
const n = dataFlow?.nodes.find((x) => x.id === id);
|
||||||
|
return n?.type !== 'trigger.manual';
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentNodeLabel = currentRef
|
||||||
|
? dataFlow?.getNodeLabel(
|
||||||
|
dataFlow.nodes.find((n) => n.id === currentRef.nodeId) ?? { id: currentRef.nodeId },
|
||||||
|
) ?? currentRef.nodeId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const onPick = (picked: DataRef | SystemVarRef) => {
|
||||||
|
onChange(picked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 2, fontWeight: 600 }}>
|
||||||
|
{param.description || param.name}
|
||||||
|
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
|
||||||
|
{param.type && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#666',
|
||||||
|
background: '#eef',
|
||||||
|
padding: '0 4px',
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
title={t('Erwarteter Typ')}
|
||||||
|
>
|
||||||
|
{param.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{currentRef && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: '#eaf6e8',
|
||||||
|
border: '1px solid #5cb85c',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
title={t('Aktive DataRef-Bindung')}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#3c763d', fontWeight: 700 }}>{'\u2190'}</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', color: '#3c763d', flex: 1 }}>
|
||||||
|
{currentNodeLabel}
|
||||||
|
{currentRef.path.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: '#999' }}>{' \u2192 '}</span>
|
||||||
|
{currentRef.path.map((p) => String(p)).join('.')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{currentRef.expectedType && (
|
||||||
|
<span style={{ fontSize: 10, color: '#666', fontFamily: 'monospace' }}>
|
||||||
|
{currentRef.expectedType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(undefined)}
|
||||||
|
title={t('Bindung entfernen')}
|
||||||
|
style={{
|
||||||
|
padding: '0 6px',
|
||||||
|
border: '1px solid #5cb85c',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: '#fff',
|
||||||
|
color: '#3c763d',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMissing && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: '#fdecea',
|
||||||
|
border: '1px solid #d9534f',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#a94442',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Pflicht-Bindung fehlt — Quelle aus Upstream-Node wählen.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
disabled={!hasSources}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${isMissing ? '#d9534f' : currentRef ? '#5cb85c' : '#1c5fb5'}`,
|
||||||
|
background: hasSources ? '#fff' : '#f5f5f5',
|
||||||
|
color: hasSources ? '#1c5fb5' : '#999',
|
||||||
|
cursor: hasSources ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!hasSources
|
||||||
|
? t('Keine vorherigen Nodes verfügbar')
|
||||||
|
: currentRef
|
||||||
|
? t('Bindung ändern …')
|
||||||
|
: t('Quelle wählen …')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dataFlow && (
|
||||||
|
<DataPicker
|
||||||
|
open={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onPick={onPick}
|
||||||
|
availableSourceIds={sourceIds}
|
||||||
|
nodes={dataFlow.nodes}
|
||||||
|
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
|
||||||
|
getNodeLabel={dataFlow.getNodeLabel}
|
||||||
|
expectedParamType={param.type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -26,6 +26,11 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { toApiGraph } from '../shared/graphUtils';
|
||||||
|
import { postUpstreamPaths } from '../../../../api/workflowApi';
|
||||||
|
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||||
|
import { DataRefRenderer } from './DataRefRenderer';
|
||||||
|
|
||||||
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
|
@ -152,8 +157,11 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
|
||||||
|
|
||||||
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
|
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const dataFlow = useAutomation2DataFlow();
|
||||||
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
||||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||||
|
const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]);
|
||||||
|
const autoSingleRef = React.useRef(false);
|
||||||
const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
|
const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!instanceId || !request) return;
|
if (!instanceId || !request) return;
|
||||||
|
|
@ -170,26 +178,100 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
|
||||||
setLoadError(err instanceof Error ? err.message : String(err));
|
setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
});
|
});
|
||||||
}, [instanceId, request, authority]);
|
}, [instanceId, request, authority]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!instanceId || !request || !dataFlow?.currentNodeId) {
|
||||||
|
setUpstreamBindOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
|
||||||
|
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
|
||||||
|
.then(({ paths }) => {
|
||||||
|
const opts = paths
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.path.length > 0
|
||||||
|
&& (String(p.path[p.path.length - 1]) === 'id' || p.path.join('.').includes('connection')),
|
||||||
|
)
|
||||||
|
.map((p, i) => ({
|
||||||
|
key: `${p.producerNodeId}:${p.path.join('.')}:${i}`,
|
||||||
|
label: `${p.producerLabel ?? p.producerNodeId} → ${p.label}`,
|
||||||
|
ref: {
|
||||||
|
type: 'ref',
|
||||||
|
nodeId: p.producerNodeId,
|
||||||
|
path: p.path,
|
||||||
|
expectedType: p.type,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setUpstreamBindOptions(opts);
|
||||||
|
})
|
||||||
|
.catch(() => setUpstreamBindOptions([]));
|
||||||
|
}, [instanceId, request, dataFlow?.currentNodeId, dataFlow?.nodes, dataFlow?.connections]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (connections.length !== 1 || autoSingleRef.current) return;
|
||||||
|
if (value !== '' && value !== undefined && value !== null) return;
|
||||||
|
autoSingleRef.current = true;
|
||||||
|
onChange(connections[0].id);
|
||||||
|
}, [connections, value, onChange]);
|
||||||
|
|
||||||
|
const strVal = typeof value === 'string' ? value : '';
|
||||||
|
const isRef = typeof value === 'object' && value !== null && (value as { type?: string }).type === 'ref';
|
||||||
|
const selectedUpstreamKey =
|
||||||
|
isRef
|
||||||
|
? upstreamBindOptions.find((o) => {
|
||||||
|
const r = o.ref as { nodeId?: string; path?: unknown[] };
|
||||||
|
const v = value as { nodeId?: string; path?: unknown[] };
|
||||||
|
return r.nodeId === v.nodeId && JSON.stringify(r.path ?? []) === JSON.stringify(v.path ?? []);
|
||||||
|
})?.key ?? ''
|
||||||
|
: '';
|
||||||
|
|
||||||
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: 2 }}>{param.description || param.name}</label>
|
||||||
<select
|
{connections.length === 0 && !loadError && (
|
||||||
value={typeof value === 'string' ? value : ''}
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
|
||||||
>
|
|
||||||
<option value="">{t('Verbindung wählen')}</option>
|
|
||||||
{connections.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!loadError && connections.length === 0 && (
|
|
||||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
|
|
||||||
{authority
|
{authority
|
||||||
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
|
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
|
||||||
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
|
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{connections.length === 1 && (
|
||||||
|
<div style={{ fontSize: 12, marginBottom: 4, color: '#444' }}>
|
||||||
|
{connections[0].label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{connections.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={strVal}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<option value="">{t('Verbindung wählen')}</option>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{upstreamBindOptions.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>{t('Oder aus vorherigem Node (DataRef)')}</div>
|
||||||
|
<select
|
||||||
|
value={selectedUpstreamKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
const opt = upstreamBindOptions.find((o) => o.key === e.target.value);
|
||||||
|
if (opt) onChange(opt.ref);
|
||||||
|
else if (!e.target.value) onChange('');
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<option value="">{t('—')}</option>
|
||||||
|
{upstreamBindOptions.map((o) => (
|
||||||
|
<option key={o.key} value={o.key}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<div style={{ fontSize: 11, color: '#c00', marginTop: 2 }}>{t('Verbindungen konnten nicht geladen werden')}</div>
|
<div style={{ fontSize: 11, color: '#c00', marginTop: 2 }}>{t('Verbindungen konnten nicht geladen werden')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -470,12 +552,65 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
<option value="checkbox">{t('Kontrollkästchen')}</option>
|
<option value="checkbox">{t('Kontrollkästchen')}</option>
|
||||||
<option value="select">{t('Auswahl')}</option>
|
<option value="select">{t('Auswahl')}</option>
|
||||||
<option value="textarea">{t('Mehrzeilig')}</option>
|
<option value="textarea">{t('Mehrzeilig')}</option>
|
||||||
|
<option value="group">{t('Gruppe')}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||||
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
|
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
|
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
|
||||||
</label>
|
</label>
|
||||||
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||||
|
{String(f.type) === 'group' && (
|
||||||
|
<div style={{ width: '100%', marginTop: 6, marginLeft: 8, borderLeft: '2px solid #ddd', paddingLeft: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>{t('Unterfelder')}</div>
|
||||||
|
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => (
|
||||||
|
<div key={j} style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('Name')}
|
||||||
|
value={String(sub.name ?? '')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
||||||
|
nextFields[j] = { ...sub, name: e.target.value };
|
||||||
|
updateField(i, 'fields', nextFields);
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={String(sub.type ?? 'text')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
||||||
|
nextFields[j] = { ...sub, type: e.target.value };
|
||||||
|
updateField(i, 'fields', nextFields);
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<option value="text">{t('Text')}</option>
|
||||||
|
<option value="number">{t('Zahl')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
|
||||||
|
updateField(i, 'fields', nextFields);
|
||||||
|
}}
|
||||||
|
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
|
||||||
|
updateField(i, 'fields', nextFields);
|
||||||
|
}}
|
||||||
|
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{t('Unterfeld hinzufügen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
|
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
|
||||||
|
|
@ -618,6 +753,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
json: JsonEditor,
|
json: JsonEditor,
|
||||||
file: TextInput,
|
file: TextInput,
|
||||||
hidden: HiddenInput,
|
hidden: HiddenInput,
|
||||||
|
dataRef: DataRefRenderer,
|
||||||
userConnection: ConnectionPicker,
|
userConnection: ConnectionPicker,
|
||||||
sharepointFolder: SharepointPathPicker,
|
sharepointFolder: SharepointPathPicker,
|
||||||
sharepointFile: SharepointPathPicker,
|
sharepointFile: SharepointPathPicker,
|
||||||
|
|
@ -630,6 +766,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
condition: ConditionBuilder,
|
condition: ConditionBuilder,
|
||||||
mappingTable: MappingTableEditor,
|
mappingTable: MappingTableEditor,
|
||||||
filterExpression: FilterExpressionEditor,
|
filterExpression: FilterExpressionEditor,
|
||||||
|
attachmentBuilder: JsonEditor,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FRONTEND_TYPE_RENDERERS;
|
export default FRONTEND_TYPE_RENDERERS;
|
||||||
|
|
|
||||||
194
src/components/FlowEditor/nodes/shared/DataPicker.test.tsx
Normal file
194
src/components/FlowEditor/nodes/shared/DataPicker.test.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
// 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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
* Includes a System Variables section.
|
* Includes a System Variables section.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef';
|
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
|
import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi';
|
||||||
|
import { findLoopAncestorIds } from './scopeHelpers';
|
||||||
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';
|
||||||
|
|
@ -18,33 +19,103 @@ interface DataPickerProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (ref: DataRef | SystemVarRef) => void;
|
onPick: (ref: DataRef | SystemVarRef) => void;
|
||||||
availableSourceIds: string[];
|
availableSourceIds: string[];
|
||||||
nodes: Array<{ id: string; title?: string; type?: string }>;
|
nodes: Array<{ id: string; title?: string; type?: string; parameters?: Record<string, unknown> }>;
|
||||||
nodeOutputsPreview: Record<string, unknown>;
|
nodeOutputsPreview: Record<string, unknown>;
|
||||||
getNodeLabel: (node: { id: string; title?: string }) => string;
|
getNodeLabel: (node: { id: string; title?: string }) => string;
|
||||||
|
/** When set, the picker can hide incompatible candidates (strict toggle) and
|
||||||
|
* surfaces "Iterieren als Loop" affordances for List[X]→X candidates. */
|
||||||
|
expectedParamType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickablePath {
|
interface PickablePath {
|
||||||
path: (string | number)[];
|
path: (string | number)[];
|
||||||
label: string;
|
label: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
/** True iff this path produces `List[X]` and the consumer expects `X` —
|
||||||
|
* picking with iterate=true appends the wildcard segment. */
|
||||||
|
iterable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _LIST_INNER_RE = /^List\[(.+)\]$/;
|
||||||
|
|
||||||
function _buildPathsFromSchema(
|
function _buildPathsFromSchema(
|
||||||
schema: PortSchema | undefined,
|
schema: PortSchema | undefined,
|
||||||
|
catalog: Record<string, PortSchema>,
|
||||||
basePath: (string | number)[] = [],
|
basePath: (string | number)[] = [],
|
||||||
|
depth = 0,
|
||||||
): PickablePath[] {
|
): PickablePath[] {
|
||||||
if (!schema || !schema.fields) return [];
|
if (!schema || !schema.fields || depth > 8) return [];
|
||||||
const result: PickablePath[] = [];
|
const result: PickablePath[] = [];
|
||||||
for (const field of schema.fields) {
|
for (const field of schema.fields) {
|
||||||
const fieldPath = [...basePath, field.name];
|
const fieldPath = [...basePath, field.name];
|
||||||
const label = fieldPath.map(String).join(' → ');
|
const label = fieldPath.map(String).join(' → ');
|
||||||
result.push({ path: fieldPath, label, type: field.type });
|
result.push({ path: fieldPath, label, type: field.type });
|
||||||
|
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
|
||||||
|
const inner = m?.[1]?.trim();
|
||||||
|
if (inner && catalog[inner]) {
|
||||||
|
// Generic List drill-down: use '*' wildcard so the engine maps each item.
|
||||||
|
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
|
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
|
||||||
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
|
||||||
|
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
|
||||||
|
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
|
||||||
|
if (!expectedParamType) return paths;
|
||||||
|
return paths.map((p) => {
|
||||||
|
if (!p.type) return p;
|
||||||
|
const m = p.type.match(_LIST_INNER_RE);
|
||||||
|
if (m && m[1].trim() === expectedParamType) return { ...p, iterable: true };
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _deriveFormPortSchemaFromParams(
|
||||||
|
node: { parameters?: Record<string, unknown> },
|
||||||
|
paramKey: string,
|
||||||
|
): PortSchema | undefined {
|
||||||
|
const raw = node.parameters?.[paramKey];
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const fields: Array<{ name: string; type: string; description: string | Record<string, string>; required: boolean }> = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item !== 'object' || item === null) continue;
|
||||||
|
const rec = item as Record<string, unknown>;
|
||||||
|
if (typeof rec.name !== 'string') continue;
|
||||||
|
const lab = rec.label;
|
||||||
|
let description: string | Record<string, string> = rec.name;
|
||||||
|
if (typeof lab === 'string') description = lab;
|
||||||
|
else if (lab && typeof lab === 'object') description = lab as Record<string, string>;
|
||||||
|
const ftype = typeof rec.type === 'string' ? rec.type : 'str';
|
||||||
|
if (ftype === 'group' && Array.isArray(rec.fields)) {
|
||||||
|
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||||
|
if (!sub || typeof sub.name !== 'string') continue;
|
||||||
|
const sl = sub.label;
|
||||||
|
let sdesc: string | Record<string, string> = `${rec.name}.${sub.name}`;
|
||||||
|
if (typeof sl === 'string') sdesc = sl;
|
||||||
|
else if (sl && typeof sl === 'object') sdesc = sl as Record<string, string>;
|
||||||
|
fields.push({
|
||||||
|
name: `${rec.name}.${sub.name}`,
|
||||||
|
type: typeof sub.type === 'string' ? sub.type : 'str',
|
||||||
|
description: sdesc,
|
||||||
|
required: Boolean(sub.required),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fields.push({
|
||||||
|
name: rec.name,
|
||||||
|
type: ftype,
|
||||||
|
description,
|
||||||
|
required: Boolean(rec.required),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fields.length ? { name: 'FormPayload_dynamic', fields } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function _buildPathsFromPreview(
|
function _buildPathsFromPreview(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
basePath: (string | number)[] = [],
|
basePath: (string | number)[] = [],
|
||||||
|
|
@ -74,7 +145,7 @@ function _buildPathsFromPreview(
|
||||||
|
|
||||||
function _resolveSchemaForNode(
|
function _resolveSchemaForNode(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
nodes: Array<{ id: string; type?: string }>,
|
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
|
||||||
nodeTypes: NodeType[],
|
nodeTypes: NodeType[],
|
||||||
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
||||||
catalog: Record<string, PortSchema>,
|
catalog: Record<string, PortSchema>,
|
||||||
|
|
@ -88,11 +159,23 @@ function _resolveSchemaForNode(
|
||||||
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
||||||
if (!typeDef?.outputPorts) return undefined;
|
if (!typeDef?.outputPorts) return undefined;
|
||||||
|
|
||||||
const port0 = typeDef.outputPorts[0];
|
const port0 = typeDef.outputPorts[0] as {
|
||||||
|
schema?: string | GraphDefinedSchemaRef;
|
||||||
|
dynamic?: boolean;
|
||||||
|
deriveFrom?: string;
|
||||||
|
};
|
||||||
if (!port0) return undefined;
|
if (!port0) return undefined;
|
||||||
|
|
||||||
if (port0.schema !== 'Transit') {
|
const schemaSpec = port0.schema;
|
||||||
return catalog[port0.schema];
|
if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') {
|
||||||
|
const paramKey = schemaSpec.parameter ?? 'fields';
|
||||||
|
return _deriveFormPortSchemaFromParams(node, paramKey);
|
||||||
|
}
|
||||||
|
if (port0.dynamic && port0.deriveFrom) {
|
||||||
|
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom);
|
||||||
|
}
|
||||||
|
if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') {
|
||||||
|
return catalog[schemaSpec];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transit: follow the incoming connection to find the real producer
|
// Transit: follow the incoming connection to find the real producer
|
||||||
|
|
@ -108,23 +191,42 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
nodes,
|
nodes,
|
||||||
nodeOutputsPreview,
|
nodeOutputsPreview,
|
||||||
getNodeLabel,
|
getNodeLabel,
|
||||||
|
expectedParamType,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||||
const [showSystem, setShowSystem] = useState(false);
|
const [showSystem, setShowSystem] = useState(false);
|
||||||
|
// Default: when the consumer declares an expected type, show only compatible
|
||||||
|
// candidates ("strict" mode). User can override per-session via the toggle.
|
||||||
|
const [strictFilter, setStrictFilter] = useState<boolean>(Boolean(expectedParamType));
|
||||||
const ctx = useAutomation2DataFlow();
|
const ctx = useAutomation2DataFlow();
|
||||||
|
|
||||||
|
// NOTE: All hooks must be called unconditionally on every render to satisfy
|
||||||
|
// the Rules of Hooks. The `if (!open) return null;` early-return therefore
|
||||||
|
// has to live BELOW every hook in this component. Adding a useMemo (or any
|
||||||
|
// other hook) below it would change the hook count when the picker toggles
|
||||||
|
// open/closed and crash the whole tree (white screen).
|
||||||
|
const connectionsRaw = ctx?.connections ?? [];
|
||||||
|
const connections = useMemo(
|
||||||
|
() =>
|
||||||
|
connectionsRaw.map((c) => ({
|
||||||
|
source: c.sourceId,
|
||||||
|
target: c.targetId,
|
||||||
|
sourceOutput: c.sourceHandle,
|
||||||
|
})),
|
||||||
|
[connectionsRaw],
|
||||||
|
);
|
||||||
|
const loopAncestorIds = useMemo(() => {
|
||||||
|
const cid = ctx?.currentNodeId;
|
||||||
|
if (!cid) return [] as string[];
|
||||||
|
return findLoopAncestorIds(nodes, connections, cid);
|
||||||
|
}, [ctx?.currentNodeId, nodes, connections]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const catalog = ctx?.portTypeCatalog ?? {};
|
const catalog = ctx?.portTypeCatalog ?? {};
|
||||||
const systemVars = ctx?.systemVariables ?? {};
|
const systemVars = ctx?.systemVariables ?? {};
|
||||||
const nodeTypes = ctx?.nodeTypes ?? [];
|
const nodeTypes = ctx?.nodeTypes ?? [];
|
||||||
const connectionsRaw = ctx?.connections ?? [];
|
|
||||||
const connections = connectionsRaw.map((c) => ({
|
|
||||||
source: c.sourceId,
|
|
||||||
target: c.targetId,
|
|
||||||
sourceOutput: c.sourceHandle,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const toggleExpand = (nodeId: string) => {
|
const toggleExpand = (nodeId: string) => {
|
||||||
setExpandedNodes((prev) => {
|
setExpandedNodes((prev) => {
|
||||||
|
|
@ -135,8 +237,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePick = (nodeId: string, path: (string | number)[]) => {
|
const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => {
|
||||||
onPick(createRef(nodeId, path));
|
onPick(createRef(nodeId, path, expectedType));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Loop-Vorschlag: for List[X]→X candidates, append the '*' wildcard so the
|
||||||
|
* engine maps the consumer over each element (executionEngine wildcard). */
|
||||||
|
const handlePickIterate = (nodeId: string, path: (string | number)[], expectedType?: string) => {
|
||||||
|
onPick(createRef(nodeId, [...path, '*'], expectedType));
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -149,13 +258,92 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
<div className={styles.dataPickerOverlay} onClick={onClose}>
|
<div className={styles.dataPickerOverlay} onClick={onClose}>
|
||||||
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
|
||||||
<div className={styles.dataPickerHeader}>
|
<div className={styles.dataPickerHeader}>
|
||||||
<h4 className={styles.dataPickerTitle}>{t('Datenquelle wählen')}</h4>
|
<h4 className={styles.dataPickerTitle}>
|
||||||
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
|
{t('Datenquelle wählen')}
|
||||||
×
|
{expectedParamType && (
|
||||||
</button>
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#555',
|
||||||
|
background: '#eee',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
title={t('Erwarteter Typ')}
|
||||||
|
>
|
||||||
|
{expectedParamType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{expectedParamType && (
|
||||||
|
<label style={{ fontSize: 11, color: '#666', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={strictFilter}
|
||||||
|
onChange={(e) => setStrictFilter(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('Nur kompatible')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dataPickerBody}>
|
<div className={styles.dataPickerBody}>
|
||||||
{/* System Variables Section */}
|
{/* System Variables Section */}
|
||||||
|
{loopAncestorIds.length > 0 && (
|
||||||
|
<div className={styles.dataPickerNodeSection}>
|
||||||
|
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
|
||||||
|
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.dataPickerTree}>
|
||||||
|
{loopAncestorIds.map((loopId) => {
|
||||||
|
const loopNode = nodes.find((n) => n.id === loopId);
|
||||||
|
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
|
||||||
|
const loopSchema = catalog.LoopItem;
|
||||||
|
const loopPaths = loopSchema
|
||||||
|
? _buildPathsFromSchema(loopSchema, catalog, [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
|
||||||
|
: [
|
||||||
|
{ path: ['currentItem'], label: 'currentItem', type: 'Any' },
|
||||||
|
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' },
|
||||||
|
{ path: ['count'], label: 'count', type: 'int' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div key={loopId} style={{ marginBottom: 6 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>{loopLabel}</div>
|
||||||
|
{loopPaths.map((p, i) => {
|
||||||
|
const compat = expectedParamType && p.type
|
||||||
|
? isCompatible(p.type, expectedParamType)
|
||||||
|
: 'ok';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${loopId}-${p.path.join('.')}-${i}`}
|
||||||
|
type="button"
|
||||||
|
className={styles.dataPickerLeaf}
|
||||||
|
style={{ opacity: compat === 'mismatch' ? 0.45 : 1 }}
|
||||||
|
onClick={() => handlePick(loopId, p.path, p.type)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
{p.type && (
|
||||||
|
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||||
|
({p.type})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{Object.keys(systemVars).length > 0 && (
|
{Object.keys(systemVars).length > 0 && (
|
||||||
<div className={styles.dataPickerNodeSection}>
|
<div className={styles.dataPickerNodeSection}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -199,12 +387,26 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
const isExpanded = expandedNodes.has(nodeId);
|
const isExpanded = expandedNodes.has(nodeId);
|
||||||
|
|
||||||
const resolvedSchema = _resolveSchemaForNode(
|
const resolvedSchema = _resolveSchemaForNode(
|
||||||
nodeId, nodes, nodeTypes, connections, catalog,
|
nodeId,
|
||||||
|
nodes,
|
||||||
|
nodeTypes,
|
||||||
|
connections,
|
||||||
|
catalog,
|
||||||
);
|
);
|
||||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
|
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
|
||||||
const paths = schemaPaths.length > 0
|
const annotated = _markIterableCandidates(
|
||||||
? schemaPaths
|
schemaPaths.length > 0
|
||||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
|
? schemaPaths
|
||||||
|
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
|
||||||
|
expectedParamType,
|
||||||
|
);
|
||||||
|
const paths = strictFilter && expectedParamType
|
||||||
|
? annotated.filter((p) => {
|
||||||
|
if (p.iterable) return true;
|
||||||
|
if (!p.type) return false;
|
||||||
|
return isCompatible(p.type, expectedParamType) !== 'mismatch';
|
||||||
|
})
|
||||||
|
: annotated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
||||||
|
|
@ -223,21 +425,52 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className={styles.dataPickerTree}>
|
<div className={styles.dataPickerTree}>
|
||||||
{paths.map((p, i) => (
|
{paths.length === 0 && (
|
||||||
<button
|
<div style={{ fontSize: 11, color: '#999', padding: '4px 8px' }}>
|
||||||
key={`${p.path.join('.')}-${i}`}
|
{t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')}
|
||||||
type="button"
|
</div>
|
||||||
className={styles.dataPickerLeaf}
|
)}
|
||||||
onClick={() => handlePick(nodeId, p.path)}
|
{paths.map((p, i) => {
|
||||||
>
|
const compat =
|
||||||
{p.label}
|
expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok';
|
||||||
{p.type && (
|
return (
|
||||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
<div
|
||||||
({p.type})
|
key={`${p.path.join('.')}-${i}`}
|
||||||
</span>
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
)}
|
>
|
||||||
</button>
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
className={styles.dataPickerLeaf}
|
||||||
|
style={{ opacity: compat === 'mismatch' && !p.iterable ? 0.45 : 1, flex: 1 }}
|
||||||
|
onClick={() => handlePick(nodeId, p.path, p.type)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
{p.type && (
|
||||||
|
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||||
|
({p.type})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{p.iterable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.dataPickerLeaf}
|
||||||
|
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: 'rgba(0,123,255,0.10)',
|
||||||
|
color: 'var(--primary-color, #007bff)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={t('Pro Element der Liste iterieren (Loop)')}
|
||||||
|
>
|
||||||
|
{t('iterieren')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
// 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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
/**
|
||||||
|
* RequiredAttributePicker — Phase-4 Schicht-4 binding affordance for
|
||||||
|
* required parameters of a Schicht-3 Adapter (Editor-Node).
|
||||||
|
*
|
||||||
|
* 0/1/N logic, applied on the set of typed source candidates:
|
||||||
|
* - 0 candidates → red pill: "Keine typkompatible Quelle vorhanden"
|
||||||
|
* (user must add an upstream node first)
|
||||||
|
* - 1 candidate → auto-bound chip with a "Andere wählen…" override button
|
||||||
|
* (still shown explicitly so the user sees what was chosen)
|
||||||
|
* - N candidates → "Quelle wählen…" button that opens the DataPicker
|
||||||
|
* pre-filtered to the expected type
|
||||||
|
*
|
||||||
|
* The picker also surfaces a "Iterieren als Loop" hint when the expected type
|
||||||
|
* is `X` and an upstream candidate is `List[X]` — see paramValidation.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { DataPicker } from './DataPicker';
|
||||||
|
import { createRef, formatRefLabel, isRef, type DataRef, type SystemVarRef } from './dataRef';
|
||||||
|
import { findSourceCandidates, strictlyCompatible, type SourceCandidate } from './paramValidation';
|
||||||
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
export interface RequiredAttributePickerProps {
|
||||||
|
/** Display label for the parameter (already localized). */
|
||||||
|
label: string;
|
||||||
|
/** Type expected by the bound action argument (e.g. "DocumentList", "str"). */
|
||||||
|
expectedType?: string;
|
||||||
|
/** Current bound value (DataRef, SystemVarRef, or unset). */
|
||||||
|
value: unknown;
|
||||||
|
/** Persist a new binding (or `null` to clear). */
|
||||||
|
onChange: (next: DataRef | SystemVarRef | null) => void;
|
||||||
|
/** Optional description shown beneath the picker. */
|
||||||
|
description?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequiredAttributePicker: React.FC<RequiredAttributePickerProps> = ({
|
||||||
|
label,
|
||||||
|
expectedType,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
description,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const ctx = useAutomation2DataFlow();
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const consumerNodeId = ctx?.currentNodeId ?? '';
|
||||||
|
const nodes = ctx?.nodes ?? [];
|
||||||
|
const connections = ctx?.connections ?? [];
|
||||||
|
const nodeTypes = ctx?.nodeTypes ?? [];
|
||||||
|
const catalog = ctx?.portTypeCatalog ?? {};
|
||||||
|
|
||||||
|
const allCandidates: SourceCandidate[] = useMemo(() => {
|
||||||
|
if (!consumerNodeId) return [];
|
||||||
|
return findSourceCandidates({
|
||||||
|
consumerNodeId,
|
||||||
|
expectedType,
|
||||||
|
nodes,
|
||||||
|
connections: connections.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
sourceId: c.sourceId,
|
||||||
|
sourceHandle: c.sourceHandle,
|
||||||
|
targetId: c.targetId,
|
||||||
|
targetHandle: c.targetHandle,
|
||||||
|
})),
|
||||||
|
nodeTypes,
|
||||||
|
portTypeCatalog: catalog,
|
||||||
|
});
|
||||||
|
}, [consumerNodeId, expectedType, nodes, connections, nodeTypes, catalog]);
|
||||||
|
|
||||||
|
const compatibleCandidates = useMemo(() => strictlyCompatible(allCandidates), [allCandidates]);
|
||||||
|
|
||||||
|
const isBoundRef = isRef(value);
|
||||||
|
const boundLabel = isBoundRef ? formatRefLabel(value as DataRef, nodes) : null;
|
||||||
|
|
||||||
|
// 0/1/N
|
||||||
|
const candidateCount = compatibleCandidates.length;
|
||||||
|
const single = candidateCount === 1 ? compatibleCandidates[0] : null;
|
||||||
|
|
||||||
|
const handleAutoBind = () => {
|
||||||
|
if (!single) return;
|
||||||
|
const ref = createRef(single.nodeId, single.iterable && expectedType ? [...single.path, '*'] : single.path, expectedType);
|
||||||
|
onChange(ref);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePicked = (picked: DataRef | SystemVarRef) => {
|
||||||
|
onChange(picked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.requiredAttributePicker} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{label}
|
||||||
|
<span style={{ color: 'var(--danger-color, #dc3545)', marginLeft: 2 }}>*</span>
|
||||||
|
</label>
|
||||||
|
{expectedType && (
|
||||||
|
<span
|
||||||
|
title={t('Erwarteter Typ')}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#555',
|
||||||
|
background: '#eee',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expectedType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBoundRef ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'rgba(40,167,69,0.15)',
|
||||||
|
color: 'var(--success-color, #28a745)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{boundLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||||
|
>
|
||||||
|
{t('Andere wählen…')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||||
|
title={t('Bindung entfernen')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : candidateCount === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'rgba(220,53,69,0.12)',
|
||||||
|
color: 'var(--danger-color, #dc3545)',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⚠</span>
|
||||||
|
<span>
|
||||||
|
{t('Keine typkompatible Quelle vorhanden — füge zuerst einen Knoten ein, der ')}
|
||||||
|
<code style={{ fontFamily: 'monospace' }}>{expectedType ?? '?'}</code>
|
||||||
|
{t(' liefert.')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : single ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={handleAutoBind}
|
||||||
|
style={{ fontSize: 11, padding: '3px 10px' }}
|
||||||
|
title={t('Einzige passende Quelle übernehmen')}
|
||||||
|
>
|
||||||
|
{t('Vorschlag übernehmen:')}{' '}
|
||||||
|
<strong>
|
||||||
|
{nodes.find((n) => n.id === single.nodeId)?.title ?? single.nodeId}
|
||||||
|
{single.path.length > 0 ? ' → ' + single.path.map(String).join(' → ') : ''}
|
||||||
|
{single.iterable ? ' [' + t('iterieren') + ']' : ''}
|
||||||
|
</strong>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
style={{ fontSize: 11, padding: '3px 10px' }}
|
||||||
|
>
|
||||||
|
{t('Andere…')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
style={{ fontSize: 11, padding: '3px 10px' }}
|
||||||
|
>
|
||||||
|
{t('Quelle wählen…')} <span style={{ opacity: 0.6 }}>({candidateCount})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{description && <div style={{ fontSize: 11, color: '#888' }}>{description}</div>}
|
||||||
|
|
||||||
|
{pickerOpen && (
|
||||||
|
<DataPicker
|
||||||
|
open={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onPick={(picked) => {
|
||||||
|
handlePicked(picked);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
|
availableSourceIds={ctx?.getAvailableSourceIds() ?? []}
|
||||||
|
nodes={nodes}
|
||||||
|
nodeOutputsPreview={ctx?.nodeOutputsPreview ?? {}}
|
||||||
|
getNodeLabel={(n) =>
|
||||||
|
ctx?.getNodeLabel(n as { id: string; title?: string; label?: string; type?: string }) ?? n.id
|
||||||
|
}
|
||||||
|
expectedParamType={expectedType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,8 @@ export interface DataRef {
|
||||||
type: 'ref';
|
type: 'ref';
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
path: (string | number)[];
|
path: (string | number)[];
|
||||||
|
/** Optional declared type at bind time (for UI / validation hints) */
|
||||||
|
expectedType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Explicit static value wrapper */
|
/** Explicit static value wrapper */
|
||||||
|
|
@ -63,8 +65,18 @@ export function createSystemVar(variable: string): SystemVarRef {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a reference object */
|
/** Create a reference object */
|
||||||
export function createRef(nodeId: string, path: (string | number)[] = []): DataRef {
|
export function createRef(nodeId: string, path: (string | number)[] = [], expectedType?: string): DataRef {
|
||||||
return { type: 'ref', nodeId, path };
|
return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structural type compatibility (best-effort; same as gateway soft rules). */
|
||||||
|
export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' {
|
||||||
|
if (!expectedType || !producedType) return 'ok';
|
||||||
|
if (producedType === expectedType) return 'ok';
|
||||||
|
if (expectedType === 'Any' || producedType === 'Any') return 'ok';
|
||||||
|
if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce';
|
||||||
|
if (expectedType === 'int' && producedType === 'str') return 'coerce';
|
||||||
|
return 'mismatch';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a value wrapper */
|
/** Create a value wrapper */
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
Automation2Graph,
|
Automation2Graph,
|
||||||
Automation2GraphNode,
|
Automation2GraphNode,
|
||||||
Automation2Connection,
|
Automation2Connection,
|
||||||
|
GraphDefinedSchemaRef,
|
||||||
} from '../../../../api/workflowApi';
|
} from '../../../../api/workflowApi';
|
||||||
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
||||||
|
|
||||||
|
|
@ -42,7 +43,10 @@ export function fromApiGraph(
|
||||||
? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts }))
|
? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts }))
|
||||||
: undefined,
|
: undefined,
|
||||||
outputPorts: nt?.outputPorts
|
outputPorts: nt?.outputPorts
|
||||||
? Object.entries(nt.outputPorts).map(([, v]) => ({ name: '', schema: (v as { schema?: string }).schema ?? '' }))
|
? Object.entries(nt.outputPorts).map(([, v]) => ({
|
||||||
|
name: '',
|
||||||
|
schema: (v as { schema?: string | GraphDefinedSchemaRef }).schema ?? '',
|
||||||
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ export function buildNodeOutputPreview(
|
||||||
return { _transit: true, _meta: {}, data: {} };
|
return { _transit: true, _meta: {}, data: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof port0.schema !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
return _buildSchemaPreview(port0.schema);
|
return _buildSchemaPreview(port0.schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
304
src/components/FlowEditor/nodes/shared/paramValidation.test.ts
Normal file
304
src/components/FlowEditor/nodes/shared/paramValidation.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
// 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
208
src/components/FlowEditor/nodes/shared/paramValidation.ts
Normal file
208
src/components/FlowEditor/nodes/shared/paramValidation.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
/**
|
||||||
|
* Phase-4 Schicht-4 (Instanz-Bindings) — Validation utilities.
|
||||||
|
*
|
||||||
|
* Single source of truth for two questions every UI surface needs to answer:
|
||||||
|
* 1. "Is this required parameter on this node bound to anything?"
|
||||||
|
* 2. "Which upstream nodes are type-compatible sources for this parameter?"
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - RequiredAttributePicker (renders 0/1/N affordance based on candidate count)
|
||||||
|
* - NodeConfigPanel (orders required params first, surfaces missing-source pill)
|
||||||
|
* - FlowCanvas (red error badge per node when any required param is unbound)
|
||||||
|
* - CanvasHeader (Run button disabled when any node has unbound required params)
|
||||||
|
*
|
||||||
|
* The required check is deliberately conservative: a param counts as "bound"
|
||||||
|
* if it has any non-empty value, a non-empty static value-wrapper, a ref, or a
|
||||||
|
* system-var ref. Empty string / null / undefined / { type: 'value', value: '' }
|
||||||
|
* all count as unbound.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||||
|
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, OutputPortDef, PortSchema } from '../../../../api/workflowApi';
|
||||||
|
import { isCompatible, isRef, isSystemVar, isValue } from './dataRef';
|
||||||
|
import { getAvailableSources } from './dataFlowGraph';
|
||||||
|
|
||||||
|
const _LIST_INNER_RE = /^List\[(.+)\]$/;
|
||||||
|
|
||||||
|
/** A candidate path on an upstream node that could satisfy a parameter binding. */
|
||||||
|
export interface SourceCandidate {
|
||||||
|
nodeId: string;
|
||||||
|
/** JSON path on the node output, e.g. ['documents', 0, 'name']. */
|
||||||
|
path: (string | number)[];
|
||||||
|
/** Type as declared by the schema field at this path (best-effort). */
|
||||||
|
type?: string;
|
||||||
|
/** Compatibility verdict against the requested type. */
|
||||||
|
compat: 'ok' | 'coerce' | 'mismatch';
|
||||||
|
/** True iff the candidate is a List that, by element-iteration ('*'), would
|
||||||
|
* satisfy the requested scalar type — the "iterieren als Loop-Vorschlag". */
|
||||||
|
iterable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decide whether a parameter value counts as "bound" for required-check purposes. */
|
||||||
|
export function isParamBound(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
if (typeof value === 'string') return value.length > 0;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return true;
|
||||||
|
if (isRef(value)) return Boolean(value.nodeId);
|
||||||
|
if (isSystemVar(value)) return Boolean(value.variable);
|
||||||
|
if (isValue(value)) {
|
||||||
|
const inner = value.value;
|
||||||
|
if (inner === null || inner === undefined) return false;
|
||||||
|
if (typeof inner === 'string') return inner.length > 0;
|
||||||
|
if (Array.isArray(inner)) return inner.length > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.length > 0;
|
||||||
|
if (typeof value === 'object') return Object.keys(value as object).length > 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A "required" param on a node that has no value and no incoming binding. */
|
||||||
|
export interface RequiredParamError {
|
||||||
|
paramName: string;
|
||||||
|
paramLabel: string;
|
||||||
|
paramType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk a node's parameter spec + values and flag every required-but-unbound. */
|
||||||
|
export function findRequiredErrors(
|
||||||
|
node: CanvasNode,
|
||||||
|
nodeType: NodeType | undefined,
|
||||||
|
resolveLabel: (param: NodeTypeParameter) => string = (p) => p.name,
|
||||||
|
): RequiredParamError[] {
|
||||||
|
if (!nodeType) return [];
|
||||||
|
const errors: RequiredParamError[] = [];
|
||||||
|
const values = node.parameters ?? {};
|
||||||
|
for (const param of nodeType.parameters ?? []) {
|
||||||
|
if (!param.required) continue;
|
||||||
|
if (isParamBound(values[param.name])) continue;
|
||||||
|
errors.push({ paramName: param.name, paramLabel: resolveLabel(param), paramType: param.type });
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of nodeId → required errors. Empty entries are omitted. */
|
||||||
|
export function findGraphErrors(
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
nodeTypes: NodeType[],
|
||||||
|
resolveLabel?: (param: NodeTypeParameter) => string,
|
||||||
|
): Record<string, RequiredParamError[]> {
|
||||||
|
const byId: Record<string, RequiredParamError[]> = {};
|
||||||
|
const byTypeId = new Map(nodeTypes.map((nt) => [nt.id, nt]));
|
||||||
|
for (const n of nodes) {
|
||||||
|
const errs = findRequiredErrors(n, byTypeId.get(n.type), resolveLabel);
|
||||||
|
if (errs.length) byId[n.id] = errs;
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the schema produced by an output port (Transit follows incoming connection). */
|
||||||
|
function _resolveOutputSchemaName(
|
||||||
|
nodeId: string,
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
connections: CanvasConnection[],
|
||||||
|
nodeTypes: NodeType[],
|
||||||
|
visited: Set<string> = new Set(),
|
||||||
|
): { schemaName?: string; node?: CanvasNode; portDef?: OutputPortDef } {
|
||||||
|
if (visited.has(nodeId)) return {};
|
||||||
|
visited.add(nodeId);
|
||||||
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return {};
|
||||||
|
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
||||||
|
const port0 = typeDef?.outputPorts?.[0] as OutputPortDef | undefined;
|
||||||
|
if (!port0) return { node };
|
||||||
|
const spec = port0.schema as string | GraphDefinedSchemaRef | undefined;
|
||||||
|
if (typeof spec === 'object' && spec !== null && spec.kind === 'fromGraph') {
|
||||||
|
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
|
||||||
|
}
|
||||||
|
if (port0.dynamic) {
|
||||||
|
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
|
||||||
|
}
|
||||||
|
if (typeof spec === 'string' && spec !== 'Transit') {
|
||||||
|
return { schemaName: spec, node, portDef: port0 };
|
||||||
|
}
|
||||||
|
// Transit: follow upstream
|
||||||
|
const incoming = connections.find((c) => c.targetId === nodeId);
|
||||||
|
if (!incoming) return { node };
|
||||||
|
return _resolveOutputSchemaName(incoming.sourceId, nodes, connections, nodeTypes, visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build candidate paths from a schema, recursing into List-element schemas one level deep. */
|
||||||
|
function _candidatesFromSchema(
|
||||||
|
schema: PortSchema | undefined,
|
||||||
|
catalog: Record<string, PortSchema>,
|
||||||
|
basePath: (string | number)[] = [],
|
||||||
|
depth = 0,
|
||||||
|
): Array<{ path: (string | number)[]; type?: string }> {
|
||||||
|
if (!schema || !schema.fields || depth > 6) return [];
|
||||||
|
const out: Array<{ path: (string | number)[]; type?: string }> = [];
|
||||||
|
for (const field of schema.fields) {
|
||||||
|
const fieldPath = [...basePath, field.name];
|
||||||
|
out.push({ path: fieldPath, type: field.type });
|
||||||
|
const inner = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE)?.[1]?.trim() : undefined;
|
||||||
|
if (inner && catalog[inner]) {
|
||||||
|
out.push(..._candidatesFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute every typed source candidate that could satisfy `expectedType`
|
||||||
|
* for the given consumer node. Includes ranked compatibility per candidate
|
||||||
|
* and a `iterable` flag for List-X→X "iterate as Loop" suggestions.
|
||||||
|
*
|
||||||
|
* If `expectedType` is omitted, returns all candidates (all marked 'ok').
|
||||||
|
*/
|
||||||
|
export function findSourceCandidates(args: {
|
||||||
|
consumerNodeId: string;
|
||||||
|
expectedType?: string;
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
nodeTypes: NodeType[];
|
||||||
|
portTypeCatalog: Record<string, PortSchema>;
|
||||||
|
}): SourceCandidate[] {
|
||||||
|
const { consumerNodeId, expectedType, nodes, connections, nodeTypes, portTypeCatalog } = args;
|
||||||
|
const sourceIds = getAvailableSources(consumerNodeId, nodes, connections).filter((id) => {
|
||||||
|
const n = nodes.find((x) => x.id === id);
|
||||||
|
return n?.type !== 'trigger.manual';
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: SourceCandidate[] = [];
|
||||||
|
for (const nid of sourceIds) {
|
||||||
|
const { schemaName } = _resolveOutputSchemaName(nid, nodes, connections, nodeTypes);
|
||||||
|
const schema = schemaName ? portTypeCatalog[schemaName] : undefined;
|
||||||
|
const wholeType = schemaName ?? undefined;
|
||||||
|
results.push({
|
||||||
|
nodeId: nid,
|
||||||
|
path: [],
|
||||||
|
type: wholeType,
|
||||||
|
compat: expectedType && wholeType ? isCompatible(wholeType, expectedType) : 'ok',
|
||||||
|
iterable: _isIterableMatch(wholeType, expectedType),
|
||||||
|
});
|
||||||
|
for (const cand of _candidatesFromSchema(schema, portTypeCatalog)) {
|
||||||
|
const compat = expectedType && cand.type ? isCompatible(cand.type, expectedType) : 'ok';
|
||||||
|
results.push({
|
||||||
|
nodeId: nid,
|
||||||
|
path: cand.path,
|
||||||
|
type: cand.type,
|
||||||
|
compat,
|
||||||
|
iterable: _isIterableMatch(cand.type, expectedType),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff `producedType` is `List[X]` and `expectedType` equals `X`. */
|
||||||
|
function _isIterableMatch(producedType?: string, expectedType?: string): boolean {
|
||||||
|
if (!producedType || !expectedType) return false;
|
||||||
|
const m = producedType.match(_LIST_INNER_RE);
|
||||||
|
if (!m) return false;
|
||||||
|
return m[1].trim() === expectedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter candidates to only those that satisfy `expectedType` (strict mode). */
|
||||||
|
export function strictlyCompatible(candidates: SourceCandidate[]): SourceCandidate[] {
|
||||||
|
return candidates.filter((c) => c.compat === 'ok' || c.compat === 'coerce' || c.iterable === true);
|
||||||
|
}
|
||||||
55
src/components/FlowEditor/nodes/shared/scopeHelpers.ts
Normal file
55
src/components/FlowEditor/nodes/shared/scopeHelpers.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
|
@ -72,11 +72,11 @@ export interface ReportDateRangeSelectorConfig {
|
||||||
* stored selection is available. Default: `'ytd'`.
|
* stored selection is available. Default: `'ytd'`.
|
||||||
*/
|
*/
|
||||||
defaultPresetKind?:
|
defaultPresetKind?:
|
||||||
| 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
| 'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
|
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
|
||||||
/** Whitelist of preset kinds offered to the user. */
|
/** Whitelist of preset kinds offered to the user. */
|
||||||
enabledPresets?: Array<
|
enabledPresets?: Array<
|
||||||
'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
|
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
|
||||||
| 'lastN' | 'nextN' | 'custom'
|
| 'lastN' | 'nextN' | 'custom'
|
||||||
>;
|
>;
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints
|
||||||
// for ``<input type="date">`` ``min``/``max`` attributes so the browser
|
// for ``<input type="date">`` ``min``/``max`` attributes so the browser
|
||||||
// refuses invalid years instead of us silently falling back to the default
|
// refuses invalid years instead of us silently falling back to the default
|
||||||
// preset afterwards.
|
// preset afterwards.
|
||||||
export function clampIsoDate(iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined {
|
export function clampIsoDate(_iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined {
|
||||||
const today = toIsoDate(todayDate());
|
const today = toIsoDate(todayDate());
|
||||||
let lo: string | undefined = cfg.minDate;
|
let lo: string | undefined = cfg.minDate;
|
||||||
let hi: string | undefined = cfg.maxDate;
|
let hi: string | undefined = cfg.maxDate;
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,366 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- Session Layout (UDB Sidebar + Main) ------------------------------- */
|
||||||
|
|
||||||
|
.sessionLayout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.2s, min-width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebarCollapsed {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbToggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbToggle:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sessionLayout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.udbSidebar {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
.udbSidebarCollapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Director Prompt Panel --------------------------------------------- */
|
||||||
|
|
||||||
|
.directorPanel {
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: outline-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorPanelDragOver {
|
||||||
|
outline: 2px dashed var(--primary-color, #F25843);
|
||||||
|
outline-offset: -4px;
|
||||||
|
background: var(--primary-dark-bg, rgba(242, 88, 67, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.botStatusDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.botStatusDotLive {
|
||||||
|
background: #15803d;
|
||||||
|
box-shadow: 0 0 0 2px rgba(21, 128, 61, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.botStatusDotIdle {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.18);
|
||||||
|
animation: directorPulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes directorPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorAttachBtn {
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorAttachBtn:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorAttachBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorHint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
background: var(--surface-alt, #fafafa);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px dashed var(--border-color, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--surface-alt, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorHeaderLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorBadge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorTextarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 70px;
|
||||||
|
max-height: 200px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorTextarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: var(--surface-alt, #f0f4f8);
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorChipName {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorChipRemove {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorChipRemove:hover {
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorMeta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorSubmit {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorSubmit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorModeToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorModeButton {
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorModeButtonActive {
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorHistory {
|
||||||
|
border-top: 1px dashed var(--border-color, #e0e0e0);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorHistoryItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid var(--border-color, #eee);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-alt, #fafafa);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorHistoryHead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorHistoryText {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorStatus {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorStatusQueued { background: #e6efff; color: #1d4ed8; }
|
||||||
|
.directorStatusRunning { background: #fff7e0; color: #b45309; }
|
||||||
|
.directorStatusSucceeded { background: #e6f7ec; color: #15803d; }
|
||||||
|
.directorStatusFailed { background: #fde2e1; color: #b91c1c; }
|
||||||
|
.directorStatusConsumed { background: #eee; color: #555; }
|
||||||
|
|
||||||
|
.directorRemoveBtn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directorRemoveBtn:hover {
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
.sessionViewHeader {
|
.sessionViewHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -579,6 +939,35 @@
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.settingsTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsTab {
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsTab:hover {
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsTabActive {
|
||||||
|
color: var(--primary-color, #4A90D9);
|
||||||
|
border-bottom-color: var(--primary-color, #4A90D9);
|
||||||
|
}
|
||||||
|
|
||||||
.settingsCard {
|
.settingsCard {
|
||||||
background: var(--surface-color, #fff);
|
background: var(--surface-color, #fff);
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,23 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
import type { TeamsbotSession, TeamsbotTranscript, TeamsbotBotResponse, TeamsbotSSEEvent, ScreenshotInfo } from '../../../api/teamsbotApi';
|
import type {
|
||||||
|
TeamsbotSession,
|
||||||
|
TeamsbotTranscript,
|
||||||
|
TeamsbotBotResponse,
|
||||||
|
TeamsbotSSEEvent,
|
||||||
|
ScreenshotInfo,
|
||||||
|
DirectorPrompt,
|
||||||
|
DirectorPromptMode,
|
||||||
|
} from '../../../api/teamsbotApi';
|
||||||
|
import {
|
||||||
|
DIRECTOR_PROMPT_TEXT_LIMIT,
|
||||||
|
DIRECTOR_PROMPT_FILE_LIMIT,
|
||||||
|
} from '../../../api/teamsbotApi';
|
||||||
import { getUserDataCache } from '../../../utils/userCache';
|
import { getUserDataCache } from '../../../utils/userCache';
|
||||||
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
|
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||||
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
import styles from './Teamsbot.module.css';
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
@ -41,6 +56,32 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}>>([]);
|
}>>([]);
|
||||||
|
|
||||||
|
// Director Prompt panel state
|
||||||
|
const [directorPrompts, setDirectorPrompts] = useState<DirectorPrompt[]>([]);
|
||||||
|
const [directorText, setDirectorText] = useState('');
|
||||||
|
const [directorMode, setDirectorMode] = useState<DirectorPromptMode>('oneShot');
|
||||||
|
const [directorFiles, setDirectorFiles] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
const [directorSubmitting, setDirectorSubmitting] = useState(false);
|
||||||
|
const [directorError, setDirectorError] = useState<string | null>(null);
|
||||||
|
const [directorDragOver, setDirectorDragOver] = useState(false);
|
||||||
|
const [directorUploading, setDirectorUploading] = useState(false);
|
||||||
|
const directorDragCounterRef = useRef(0);
|
||||||
|
const directorFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Bot WebSocket connection state (separate from session.status: the session
|
||||||
|
// can be 'active' before the bot has actually opened its WebSocket back to
|
||||||
|
// the gateway. Director prompts can only be processed once botConnected=true.)
|
||||||
|
const [botConnected, setBotConnected] = useState(false);
|
||||||
|
|
||||||
|
// UDB Sidebar state
|
||||||
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||||
|
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||||
|
const _udbContext: UdbContext | null = instanceId
|
||||||
|
? { instanceId, featureInstanceId: instanceId }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const fileCtx = useFileContext();
|
||||||
|
|
||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
|
@ -98,14 +139,38 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
_loadSession();
|
_loadSession();
|
||||||
}, [_loadSession]);
|
}, [_loadSession]);
|
||||||
|
|
||||||
// SSE Live Stream - connect once per session, don't re-create on status changes
|
// Load director prompt history when session changes
|
||||||
const sseSessionRef = useRef<string | null>(null);
|
|
||||||
const sessionStatus = session?.status;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !sessionId || !sessionStatus) return;
|
if (!instanceId || !sessionId) return;
|
||||||
if (!['active', 'joining', 'pending'].includes(sessionStatus)) return;
|
let cancelled = false;
|
||||||
|
teamsbotApi
|
||||||
|
.listDirectorPrompts(instanceId, sessionId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setDirectorPrompts(res.prompts || []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setDirectorPrompts([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [instanceId, sessionId]);
|
||||||
|
|
||||||
|
// SSE Live Stream - connect once per session, don't re-create on status changes.
|
||||||
|
// We deliberately depend ONLY on (instanceId, sessionId), not on session.status,
|
||||||
|
// so transient status transitions (pending -> joining -> active) don't tear down
|
||||||
|
// and rebuild the EventSource (which used to flicker botConnected and spawn
|
||||||
|
// multiple parallel /stream connections to the gateway).
|
||||||
|
const sseSessionRef = useRef<string | null>(null);
|
||||||
|
const sessionStatusRef = useRef<string | undefined>(session?.status);
|
||||||
|
sessionStatusRef.current = session?.status;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId || !sessionId) return;
|
||||||
// Avoid reconnecting if already streaming this session
|
// Avoid reconnecting if already streaming this session
|
||||||
if (sseSessionRef.current === sessionId && eventSourceRef.current) return;
|
if (sseSessionRef.current === sessionId && eventSourceRef.current) return;
|
||||||
|
// Don't open a stream for sessions that are known to already be terminal.
|
||||||
|
const initialStatus = sessionStatusRef.current;
|
||||||
|
if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return;
|
||||||
|
|
||||||
eventSourceRef.current?.close();
|
eventSourceRef.current?.close();
|
||||||
sseSessionRef.current = sessionId;
|
sseSessionRef.current = sessionId;
|
||||||
|
|
@ -200,6 +265,34 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'botConnectionState': {
|
||||||
|
const data = sseEvent.data || {};
|
||||||
|
setBotConnected(Boolean(data.connected));
|
||||||
|
_dlog('BOT-WS', data.connected ? 'connected' : 'disconnected');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'directorPrompt': {
|
||||||
|
const prompt = sseEvent.data as DirectorPrompt | undefined;
|
||||||
|
if (!prompt || !prompt.id) break;
|
||||||
|
setDirectorPrompts((prev) => {
|
||||||
|
const idx = prev.findIndex((p) => p.id === prompt.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = { ...updated[idx], ...prompt };
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return [prompt, ...prev];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'agentRun': {
|
||||||
|
const data = sseEvent.data || {};
|
||||||
|
_dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'error': {
|
case 'error': {
|
||||||
const errData = sseEvent.data || {};
|
const errData = sseEvent.data || {};
|
||||||
const errMsg = errData.message || t('Unbekannter Fehler');
|
const errMsg = errData.message || t('Unbekannter Fehler');
|
||||||
|
|
@ -229,8 +322,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
sseSessionRef.current = null;
|
sseSessionRef.current = null;
|
||||||
setIsLive(false);
|
setIsLive(false);
|
||||||
|
setBotConnected(false);
|
||||||
};
|
};
|
||||||
}, [instanceId, sessionId, sessionStatus, _dlog, t]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [instanceId, sessionId]);
|
||||||
|
|
||||||
// Polling fallback: refresh session data every 5s when SSE is not connected
|
// Polling fallback: refresh session data every 5s when SSE is not connected
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
@ -274,6 +369,193 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _addDirectorFile = useCallback((fileId: string, fileName?: string) => {
|
||||||
|
setDirectorFiles((prev) => {
|
||||||
|
if (prev.some((f) => f.id === fileId)) return prev;
|
||||||
|
if (prev.length >= DIRECTOR_PROMPT_FILE_LIMIT) {
|
||||||
|
setDirectorError(
|
||||||
|
t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }),
|
||||||
|
);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
setDirectorError(null);
|
||||||
|
return [...prev, { id: fileId, name: fileName || fileId }];
|
||||||
|
});
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const _handleUdbFileSelect = _addDirectorFile;
|
||||||
|
|
||||||
|
const _removeDirectorFile = (fileId: string) => {
|
||||||
|
setDirectorFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const _uploadAndAttachDirectorFile = useCallback(async (file: File) => {
|
||||||
|
if (!fileCtx?.handleFileUpload) return;
|
||||||
|
setDirectorUploading(true);
|
||||||
|
setDirectorError(null);
|
||||||
|
try {
|
||||||
|
const result = await fileCtx.handleFileUpload(file);
|
||||||
|
if (result?.success) {
|
||||||
|
const data: any = (result.fileData as any)?.file || result.fileData;
|
||||||
|
const id = data?.id || (result.fileData as any)?.id;
|
||||||
|
if (id) {
|
||||||
|
_addDirectorFile(id, data?.fileName || file.name);
|
||||||
|
} else {
|
||||||
|
setDirectorError(t('Upload erfolgreich, aber keine Datei-ID erhalten.'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDirectorError(result?.error || t('Upload fehlgeschlagen.'));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setDirectorError(err?.message || t('Upload fehlgeschlagen.'));
|
||||||
|
} finally {
|
||||||
|
setDirectorUploading(false);
|
||||||
|
}
|
||||||
|
}, [fileCtx, _addDirectorFile, t]);
|
||||||
|
|
||||||
|
const _onDirectorDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
if (
|
||||||
|
e.dataTransfer.types.includes('Files') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-id') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-ids') ||
|
||||||
|
e.dataTransfer.types.includes('application/tree-items')
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
directorDragCounterRef.current += 1;
|
||||||
|
setDirectorDragOver(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _onDirectorDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (
|
||||||
|
e.dataTransfer.types.includes('Files') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-id') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-ids') ||
|
||||||
|
e.dataTransfer.types.includes('application/tree-items')
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _onDirectorDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
directorDragCounterRef.current = Math.max(0, directorDragCounterRef.current - 1);
|
||||||
|
if (directorDragCounterRef.current === 0) setDirectorDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _onDirectorDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
directorDragCounterRef.current = 0;
|
||||||
|
setDirectorDragOver(false);
|
||||||
|
|
||||||
|
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
|
||||||
|
if (fileIdsJson) {
|
||||||
|
try {
|
||||||
|
const ids: string[] = JSON.parse(fileIdsJson);
|
||||||
|
ids.forEach((id) => _addDirectorFile(id));
|
||||||
|
} catch { /* ignore malformed */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleFileId = e.dataTransfer.getData('application/file-id');
|
||||||
|
if (singleFileId) {
|
||||||
|
const label = e.dataTransfer.getData('text/plain');
|
||||||
|
_addDirectorFile(singleFileId, label || undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
|
if (treeItemsJson) {
|
||||||
|
try {
|
||||||
|
const items: Array<{ id: string; type: 'file' | 'folder'; name: string }> = JSON.parse(treeItemsJson);
|
||||||
|
items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name));
|
||||||
|
} catch { /* ignore malformed */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
for (const file of Array.from(e.dataTransfer.files)) {
|
||||||
|
if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) {
|
||||||
|
setDirectorError(
|
||||||
|
t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await _uploadAndAttachDirectorFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [_addDirectorFile, _uploadAndAttachDirectorFile, directorFiles.length, t]);
|
||||||
|
|
||||||
|
const _onDirectorFileInput = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files || e.target.files.length === 0) return;
|
||||||
|
for (const file of Array.from(e.target.files)) {
|
||||||
|
if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) break;
|
||||||
|
await _uploadAndAttachDirectorFile(file);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}, [_uploadAndAttachDirectorFile, directorFiles.length]);
|
||||||
|
|
||||||
|
const _submitDirectorPrompt = async () => {
|
||||||
|
if (!instanceId || !sessionId) return;
|
||||||
|
const trimmed = directorText.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setDirectorError(t('Bitte gib eine Anweisung ein.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed.length > DIRECTOR_PROMPT_TEXT_LIMIT) {
|
||||||
|
setDirectorError(
|
||||||
|
t('Text zu lang (max. {n} Zeichen).', { n: String(DIRECTOR_PROMPT_TEXT_LIMIT) }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDirectorSubmitting(true);
|
||||||
|
setDirectorError(null);
|
||||||
|
try {
|
||||||
|
const res = await teamsbotApi.submitDirectorPrompt(instanceId, sessionId, {
|
||||||
|
text: trimmed,
|
||||||
|
mode: directorMode,
|
||||||
|
fileIds: directorFiles.map((f) => f.id),
|
||||||
|
});
|
||||||
|
if (res.prompt) {
|
||||||
|
setDirectorPrompts((prev) => {
|
||||||
|
const idx = prev.findIndex((p) => p.id === res.prompt.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = res.prompt;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [res.prompt, ...prev];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDirectorText('');
|
||||||
|
setDirectorFiles([]);
|
||||||
|
} catch (err: any) {
|
||||||
|
setDirectorError(err?.response?.data?.detail || err?.message || t('Senden fehlgeschlagen.'));
|
||||||
|
} finally {
|
||||||
|
setDirectorSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _removeDirectorPrompt = async (promptId: string) => {
|
||||||
|
if (!instanceId || !sessionId) return;
|
||||||
|
try {
|
||||||
|
await teamsbotApi.deleteDirectorPrompt(instanceId, sessionId, promptId);
|
||||||
|
setDirectorPrompts((prev) => prev.filter((p) => p.id !== promptId));
|
||||||
|
} catch (err: any) {
|
||||||
|
setDirectorError(err?.message || t('Entfernen fehlgeschlagen.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activePersistentCount = useMemo(
|
||||||
|
() => directorPrompts.filter((p) => p.mode === 'persistent' && p.status !== 'consumed').length,
|
||||||
|
[directorPrompts],
|
||||||
|
);
|
||||||
|
|
||||||
const _getSpeakerColor = (speaker: string) => {
|
const _getSpeakerColor = (speaker: string) => {
|
||||||
const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9'];
|
const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9'];
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
|
|
@ -341,6 +623,227 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
|
|
||||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
|
|
||||||
|
{/* Layout: UDB Sidebar + Main */}
|
||||||
|
<div className={styles.sessionLayout}>
|
||||||
|
{/* UDB Sidebar (Files / Sources) */}
|
||||||
|
{_udbContext && (
|
||||||
|
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||||
|
<button
|
||||||
|
className={styles.udbToggle}
|
||||||
|
onClick={() => setUdbCollapsed((v) => !v)}
|
||||||
|
title={udbCollapsed ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
|
||||||
|
>
|
||||||
|
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||||
|
</button>
|
||||||
|
{!udbCollapsed && (
|
||||||
|
<UnifiedDataBar
|
||||||
|
context={_udbContext}
|
||||||
|
activeTab={udbTab}
|
||||||
|
onTabChange={setUdbTab}
|
||||||
|
hideTabs={['chats']}
|
||||||
|
onFileSelect={_handleUdbFileSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main column */}
|
||||||
|
<div className={styles.sessionMain}>
|
||||||
|
|
||||||
|
{/* Director Prompt Panel (private operator instructions) */}
|
||||||
|
{['active', 'joining', 'pending'].includes(session.status) && (
|
||||||
|
<div
|
||||||
|
className={`${styles.directorPanel} ${directorDragOver ? styles.directorPanelDragOver : ''}`}
|
||||||
|
onDragEnter={_onDirectorDragEnter}
|
||||||
|
onDragOver={_onDirectorDragOver}
|
||||||
|
onDragLeave={_onDirectorDragLeave}
|
||||||
|
onDrop={_onDirectorDrop}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const sStatus = session?.status;
|
||||||
|
const isSessionLaunching = !!sStatus && ['pending', 'joining'].includes(sStatus);
|
||||||
|
const isSessionActive = sStatus === 'active';
|
||||||
|
// Bot has joined the meeting (session active) but the WebSocket back
|
||||||
|
// to the gateway is missing -> usually means the browser-bot service
|
||||||
|
// can't reach this gateway (e.g. localhost gateway + remote bot, or
|
||||||
|
// bot behind firewall). Audio + transcripts won't flow.
|
||||||
|
const isBotUnreachable = isSessionActive && !botConnected;
|
||||||
|
const statusLabel = botConnected
|
||||||
|
? t('Bot live')
|
||||||
|
: isBotUnreachable
|
||||||
|
? t('Bot ist im Meeting, aber nicht mit dem Gateway verbunden')
|
||||||
|
: isSessionLaunching
|
||||||
|
? t('Bot startet ...')
|
||||||
|
: t('Keine aktive Session');
|
||||||
|
const statusTitle = botConnected
|
||||||
|
? t('Bot ist live im Meeting verbunden und liefert Transkripte')
|
||||||
|
: isBotUnreachable
|
||||||
|
? t('Der Browser-Bot hat den WebSocket nicht zurueck zum Gateway geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL und APP_API_URL: bei lokalem Gateway muss der Bot ebenfalls lokal laufen oder das Gateway ueber einen Tunnel erreichbar sein.')
|
||||||
|
: isSessionLaunching
|
||||||
|
? t('Bot tritt dem Meeting bei und oeffnet die WebSocket-Verbindung ...')
|
||||||
|
: t('Es laeuft keine aktive Bot-Session');
|
||||||
|
return (
|
||||||
|
<div className={styles.directorHeader}>
|
||||||
|
<div className={styles.directorHeaderLeft}>
|
||||||
|
<h4 className={styles.directorTitle}>{t('Regieanweisungen')}</h4>
|
||||||
|
<span
|
||||||
|
className={`${styles.botStatusDot} ${botConnected ? styles.botStatusDotLive : styles.botStatusDotIdle}`}
|
||||||
|
title={statusTitle}
|
||||||
|
/>
|
||||||
|
<span className={styles.directorMeta} title={statusTitle}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{activePersistentCount > 0 && (
|
||||||
|
<span className={styles.directorBadge} title={t('Aktive persistente Anweisungen')}>
|
||||||
|
{activePersistentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.directorMeta}>
|
||||||
|
{t('Privat - nur fuer den Bot sichtbar')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className={styles.directorBody}>
|
||||||
|
<textarea
|
||||||
|
className={styles.directorTextarea}
|
||||||
|
placeholder={t('Anweisung an den Bot (z. B. recherchiere ... und gib eine Empfehlung) ...')}
|
||||||
|
value={directorText}
|
||||||
|
maxLength={DIRECTOR_PROMPT_TEXT_LIMIT}
|
||||||
|
onChange={(e) => setDirectorText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void _submitDirectorPrompt();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{directorFiles.length > 0 && (
|
||||||
|
<div className={styles.directorChips}>
|
||||||
|
{directorFiles.map((f) => (
|
||||||
|
<span key={f.id} className={styles.directorChip} title={f.name}>
|
||||||
|
<span className={styles.directorChipName}>{f.name}</span>
|
||||||
|
<button
|
||||||
|
className={styles.directorChipRemove}
|
||||||
|
onClick={() => _removeDirectorFile(f.id)}
|
||||||
|
title={t('Entfernen')}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.directorActions}>
|
||||||
|
<div className={styles.directorRow}>
|
||||||
|
<span className={styles.directorModeToggle}>
|
||||||
|
<button
|
||||||
|
className={`${styles.directorModeButton} ${directorMode === 'oneShot' ? styles.directorModeButtonActive : ''}`}
|
||||||
|
onClick={() => setDirectorMode('oneShot')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('Einmalig')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.directorModeButton} ${directorMode === 'persistent' ? styles.directorModeButtonActive : ''}`}
|
||||||
|
onClick={() => setDirectorMode('persistent')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('Persistent')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={directorFileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={_onDirectorFileInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.directorAttachBtn}
|
||||||
|
onClick={() => directorFileInputRef.current?.click()}
|
||||||
|
disabled={directorUploading || directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT}
|
||||||
|
title={t('Dateien anhaengen')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{directorUploading ? t('Lade hoch ...') : t('Datei anhaengen')}
|
||||||
|
</button>
|
||||||
|
<span className={styles.directorMeta}>
|
||||||
|
{directorText.length}/{DIRECTOR_PROMPT_TEXT_LIMIT} {t('Zeichen')} -
|
||||||
|
{' '}{directorFiles.length}/{DIRECTOR_PROMPT_FILE_LIMIT} {t('Dateien')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.directorSubmit}
|
||||||
|
onClick={_submitDirectorPrompt}
|
||||||
|
disabled={directorSubmitting || !directorText.trim() || !botConnected}
|
||||||
|
type="button"
|
||||||
|
title={botConnected ? t('Strg+Enter: Senden') : t('Bot ist noch nicht live verbunden')}
|
||||||
|
>
|
||||||
|
{directorSubmitting ? t('Senden ...') : t('An Bot senden')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!botConnected && (
|
||||||
|
<div className={styles.directorHint}>
|
||||||
|
{session?.status === 'active'
|
||||||
|
? t('Der Bot ist im Meeting, hat aber den WebSocket-Kanal zum Gateway nicht geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL/APP_API_URL und ob der Browser-Bot-Service das Gateway erreichen kann.')
|
||||||
|
: t('Der Bot muss erst dem Meeting beitreten und sich verbinden, bevor Regieanweisungen ausgefuehrt werden koennen.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{directorError && (
|
||||||
|
<div className={styles.errorBanner} style={{ margin: 0 }}>{directorError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{directorPrompts.length > 0 && (
|
||||||
|
<div className={styles.directorHistory}>
|
||||||
|
{directorPrompts.slice(0, 8).map((p) => (
|
||||||
|
<div key={p.id} className={styles.directorHistoryItem}>
|
||||||
|
<div className={styles.directorHistoryHead}>
|
||||||
|
<span>
|
||||||
|
{_formatTime(p.createdAt)} - {p.mode === 'persistent' ? t('Persistent') : t('Einmalig')}
|
||||||
|
{p.fileIds && p.fileIds.length > 0 && ` - ${p.fileIds.length} ${t('Dateien')}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||||
|
<span className={`${styles.directorStatus} ${styles[`directorStatus${p.status.charAt(0).toUpperCase() + p.status.slice(1)}`] || ''}`}>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
{p.mode === 'persistent' && p.status !== 'consumed' && (
|
||||||
|
<button
|
||||||
|
className={styles.directorRemoveBtn}
|
||||||
|
onClick={() => _removeDirectorPrompt(p.id)}
|
||||||
|
title={t('Persistente Anweisung entfernen')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.directorHistoryText}>{p.text}</div>
|
||||||
|
{p.responseText && (
|
||||||
|
<div className={styles.directorHistoryText} style={{ opacity: 0.85 }}>
|
||||||
|
<em>{t('Antwort')}:</em> {p.responseText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.statusMessage && p.status === 'failed' && (
|
||||||
|
<div className={styles.directorHistoryText} style={{ color: '#b91c1c' }}>
|
||||||
|
{p.statusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content: Transcript + Responses */}
|
{/* Main Content: Transcript + Responses */}
|
||||||
<div className={styles.sessionContent}>
|
<div className={styles.sessionContent}>
|
||||||
{/* Left: Transcript */}
|
{/* Left: Transcript */}
|
||||||
|
|
@ -501,6 +1004,9 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>{/* /sessionMain */}
|
||||||
|
</div>{/* /sessionLayout */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
|
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, SystemBot } from '../../../api/teamsbotApi';
|
||||||
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
||||||
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
import { FaPlay, FaSpinner, FaTrash } from 'react-icons/fa';
|
||||||
import styles from './Teamsbot.module.css';
|
import styles from './Teamsbot.module.css';
|
||||||
|
import { getUserDataCache } from '../../../utils/userCache';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
type SettingsTabId = 'general' | 'systemBots';
|
||||||
|
|
||||||
/** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
|
/** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
|
||||||
function _formatVoiceName(voice: VoiceOption): string {
|
function _formatVoiceName(voice: VoiceOption): string {
|
||||||
const parts = voice.name.split('-');
|
const parts = voice.name.split('-');
|
||||||
|
|
@ -148,12 +151,44 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cachedUser = getUserDataCache();
|
||||||
|
const _isSysAdmin = cachedUser?.isSysAdmin === true;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTabId>('general');
|
||||||
|
|
||||||
if (loading) return <div className={styles.loading}>{t('Konfiguration laden')}</div>;
|
if (loading) return <div className={styles.loading}>{t('Konfiguration laden')}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settingsContainer}>
|
<div className={styles.settingsContainer}>
|
||||||
|
{/* Tab navigation */}
|
||||||
|
<div className={styles.settingsTabs} role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'general'}
|
||||||
|
className={`${styles.settingsTab} ${activeTab === 'general' ? styles.settingsTabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('general')}
|
||||||
|
>
|
||||||
|
{t('Bot-Einstellungen')}
|
||||||
|
</button>
|
||||||
|
{_isSysAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'systemBots'}
|
||||||
|
className={`${styles.settingsTab} ${activeTab === 'systemBots' ? styles.settingsTabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('systemBots')}
|
||||||
|
>
|
||||||
|
{t('System-Bots')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'systemBots' && _isSysAdmin ? (
|
||||||
|
<_SystemBotsPanel instanceId={instanceId} />
|
||||||
|
) : (
|
||||||
<div className={styles.settingsCard}>
|
<div className={styles.settingsCard}>
|
||||||
<h3 className={styles.cardTitle}>Bot-Einstellungen</h3>
|
<h3 className={styles.cardTitle}>{t('Bot-Einstellungen')}</h3>
|
||||||
|
|
||||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
|
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
|
||||||
|
|
@ -375,6 +410,266 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// System-Bots Tab (SysAdmin only)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface _SystemBotsPanelProps {
|
||||||
|
instanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-Bots panel: list, create and delete the mandate's authenticated
|
||||||
|
* Teams bot accounts (email + encrypted password). Used as the SYSTEM_BOT
|
||||||
|
* join mode in the Dashboard. Server stores the password encrypted and
|
||||||
|
* never returns it; this panel only ever holds the password in memory
|
||||||
|
* during the create form.
|
||||||
|
*/
|
||||||
|
const _SystemBotsPanel: React.FC<_SystemBotsPanelProps> = ({ instanceId }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const [bots, setBots] = useState<SystemBot[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formEmail, setFormEmail] = useState('');
|
||||||
|
const [formPassword, setFormPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
// Delete state (id of the bot currently being deleted, if any)
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _load = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await teamsbotApi.listSystemBots(instanceId);
|
||||||
|
setBots(result.bots || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || t('Fehler beim Laden'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_load();
|
||||||
|
}, [_load]);
|
||||||
|
|
||||||
|
const _resetForm = () => {
|
||||||
|
setFormName('');
|
||||||
|
setFormEmail('');
|
||||||
|
setFormPassword('');
|
||||||
|
setShowPassword(false);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _flashSuccess = (msg: string) => {
|
||||||
|
setSuccessMsg(msg);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formEmail.trim() || !formPassword) {
|
||||||
|
setError(t('Email und Passwort sind erforderlich'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await teamsbotApi.createSystemBot(instanceId, {
|
||||||
|
email: formEmail.trim(),
|
||||||
|
password: formPassword,
|
||||||
|
name: formName.trim() || undefined,
|
||||||
|
});
|
||||||
|
_resetForm();
|
||||||
|
await _load();
|
||||||
|
_flashSuccess(t('System-Bot gespeichert'));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.detail || err?.message || t('Fehler beim Speichern'));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleDelete = async (bot: SystemBot) => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
t('System-Bot wirklich loeschen?') + `\n\n${bot.name} <${bot.email}>`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
setDeletingId(bot.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await teamsbotApi.deleteSystemBot(instanceId, bot.id);
|
||||||
|
setBots(prev => prev.filter(b => b.id !== bot.id));
|
||||||
|
_flashSuccess(t('System-Bot geloescht'));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.detail || err?.message || t('Fehler beim Loeschen'));
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.settingsCard}>
|
||||||
|
<h3 className={styles.cardTitle}>{t('System-Bots')}</h3>
|
||||||
|
<p className={styles.cardDescription}>
|
||||||
|
{t('Authentifizierte Teams-Konten, mit denen der Bot Meetings beitreten kann. Passwoerter werden verschluesselt gespeichert und nie zurueckgegeben.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||||
|
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>{t('Laden')}...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{bots.length === 0 ? (
|
||||||
|
<div className={styles.emptyState || ''} style={{ padding: '1rem 0', color: 'var(--text-secondary, #666)', fontSize: '0.9rem' }}>
|
||||||
|
{t('Noch kein System-Bot konfiguriert. Anonyme Joins werden verwendet, bis ein Konto angelegt ist.')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className={styles.systemBotsTable || ''} style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||||
|
<th style={{ padding: '0.5rem 0.5rem 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>{t('Name')}</th>
|
||||||
|
<th style={{ padding: '0.5rem', fontSize: '0.85rem', fontWeight: 600 }}>{t('Email')}</th>
|
||||||
|
<th style={{ padding: '0.5rem', fontSize: '0.85rem', fontWeight: 600 }}>{t('Status')}</th>
|
||||||
|
<th style={{ padding: '0.5rem 0 0.5rem 0.5rem', fontSize: '0.85rem', fontWeight: 600, textAlign: 'right' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bots.map(bot => (
|
||||||
|
<tr key={bot.id} style={{ borderBottom: '1px solid var(--border-color, #f0f0f0)' }}>
|
||||||
|
<td style={{ padding: '0.6rem 0.5rem 0.6rem 0', fontSize: '0.9rem' }}>{bot.name}</td>
|
||||||
|
<td style={{ padding: '0.6rem 0.5rem', fontSize: '0.9rem', fontFamily: 'monospace' }}>{bot.email}</td>
|
||||||
|
<td style={{ padding: '0.6rem 0.5rem', fontSize: '0.85rem' }}>
|
||||||
|
{bot.isActive ? (
|
||||||
|
<span style={{ color: 'var(--success-color, #2D8E5C)' }}>{t('Aktiv')}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-secondary, #999)' }}>{t('Inaktiv')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.6rem 0 0.6rem 0.5rem', textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onClick={() => _handleDelete(bot)}
|
||||||
|
disabled={deletingId === bot.id}
|
||||||
|
title={t('Loeschen')}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
{deletingId === bot.id ? <FaSpinner className={styles.spinner} /> : <FaTrash />}
|
||||||
|
{t('Loeschen')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showForm ? (
|
||||||
|
<div className={styles.settingsActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.saveButton}
|
||||||
|
onClick={() => { setShowForm(true); setError(null); }}
|
||||||
|
>
|
||||||
|
{t('Neuen System-Bot anlegen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={_handleCreate} className={styles.settingsSection}>
|
||||||
|
<h4 className={styles.sectionTitle}>{t('Neuer System-Bot')}</h4>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>{t('Anzeigename')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
placeholder={t('z.B. Nyla Larsson')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>
|
||||||
|
{t('Optional. Falls leer, wird der Email-Localpart verwendet.')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>{t('Email')} *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={styles.input}
|
||||||
|
value={formEmail}
|
||||||
|
onChange={(e) => setFormEmail(e.target.value)}
|
||||||
|
placeholder="bot@example.onmicrosoft.com"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>{t('Passwort')} *</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className={styles.input}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={formPassword}
|
||||||
|
onChange={(e) => setFormPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.viewButton}
|
||||||
|
onClick={() => setShowPassword(s => !s)}
|
||||||
|
>
|
||||||
|
{showPassword ? t('Verbergen') : t('Anzeigen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className={styles.hint}>
|
||||||
|
{t('Wird serverseitig verschluesselt gespeichert und nie zurueckgegeben.')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingsActions} style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.saveButton}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{creating ? <FaSpinner className={styles.spinner} /> : null}
|
||||||
|
{creating ? t('Speichern') : t('System-Bot speichern')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.viewButton}
|
||||||
|
onClick={_resetForm}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const [clearingCache, setClearingCache] = useState(false);
|
const [clearingCache, setClearingCache] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importPeriod, setImportPeriod] = useState<PeriodValue | null>(null);
|
const [importPeriod, setImportPeriod] = useState<PeriodValue | null>(null);
|
||||||
|
const [wipingData, setWipingData] = useState(false);
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -438,7 +439,9 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const lastSyncAt = importStatus?.lastSyncAt as number | null | undefined;
|
const lastSyncAt = importStatus?.lastSyncAt as number | null | undefined;
|
||||||
const winFrom = importStatus?.lastSyncDateFrom as string | undefined;
|
const winFrom = importStatus?.lastSyncDateFrom as string | undefined;
|
||||||
const winTo = importStatus?.lastSyncDateTo as string | undefined;
|
const winTo = importStatus?.lastSyncDateTo as string | undefined;
|
||||||
const counts = (importStatus?.lastSyncCounts || {}) as Record<string, number>;
|
const counts = (importStatus?.lastSyncCounts || {}) as Record<string, any>;
|
||||||
|
const oldestBooking = counts.oldestBookingDate as string | null | undefined;
|
||||||
|
const newestBooking = counts.newestBookingDate as string | null | undefined;
|
||||||
const timeWindow = winFrom && winTo
|
const timeWindow = winFrom && winTo
|
||||||
? t('{from} bis {to}', { from: winFrom, to: winTo })
|
? t('{from} bis {to}', { from: winFrom, to: winTo })
|
||||||
: winFrom
|
: winFrom
|
||||||
|
|
@ -446,6 +449,13 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
: winTo
|
: winTo
|
||||||
? t('bis {to}', { to: winTo })
|
? t('bis {to}', { to: winTo })
|
||||||
: null;
|
: null;
|
||||||
|
const dataWindow = oldestBooking && newestBooking
|
||||||
|
? t('{from} bis {to}', { from: oldestBooking, to: newestBooking })
|
||||||
|
: oldestBooking
|
||||||
|
? t('ab {from}', { from: oldestBooking })
|
||||||
|
: newestBooking
|
||||||
|
? t('bis {to}', { to: newestBooking })
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
|
|
@ -461,9 +471,18 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<strong>{t('Letzter Import:')}</strong> {new Date(lastSyncAt * 1000).toLocaleString()}
|
<strong>{t('Letzter Import:')}</strong> {new Date(lastSyncAt * 1000).toLocaleString()}
|
||||||
{timeWindow && (
|
{timeWindow && (
|
||||||
<> {' '}· <strong>{t('Zeitfenster:')}</strong> {timeWindow}</>
|
<> {' '}· <strong>{t('Angefragtes Zeitfenster:')}</strong> {timeWindow}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{dataWindow && (
|
||||||
|
<div style={{ marginTop: '0.2rem' }}>
|
||||||
|
<strong>{t('Tatsächlich erhaltene Buchungen:')}</strong>{' '}
|
||||||
|
{dataWindow}
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontStyle: 'italic' }}>
|
||||||
|
({t('älteste/neuste Buchung im Import — zur Vollständigkeitsprüfung')})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ marginTop: '0.2rem' }}>
|
<div style={{ marginTop: '0.2rem' }}>
|
||||||
{t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', {
|
{t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', {
|
||||||
konten: String(counts.accounts ?? 0),
|
konten: String(counts.accounts ?? 0),
|
||||||
|
|
@ -547,12 +566,13 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
disabled={clearingCache}
|
disabled={clearingCache}
|
||||||
|
title={t('Leert nur den Antwort-Cache des KI-Agenten (~5 Min). Synchronisierte Daten bleiben unverändert.')}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setClearingCache(true);
|
setClearingCache(true);
|
||||||
try {
|
try {
|
||||||
const result = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
const result = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
||||||
showSuccess(t('Cache geleert'), t('{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.', { n: String(result?.cleared ?? 0) }));
|
showSuccess(t('KI-Antwort-Cache geleert'), t('{n} gecachte KI-Antworten entfernt. Die nächste KI-Abfrage berechnet frische Antworten aus den synchronisierten Tabellen. Diese Aktion löscht KEINE importierten Buchungen.', { n: String(result?.cleared ?? 0) }));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -560,7 +580,41 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{clearingCache ? t('Leere…') : t('KI-Cache leeren')}
|
{clearingCache ? t('Leere…') : t('KI-Antwort-Cache leeren')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
disabled={wipingData}
|
||||||
|
title={t('Löscht alle importierten Buchungen, Konten, Kontakte und Salden für diese Instanz. Die Verbindungseinstellungen bleiben erhalten.')}
|
||||||
|
style={{ color: 'var(--error-color, #dc2626)' }}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
const ok = await confirm(
|
||||||
|
t('Wirklich alle importierten Buchhaltungsdaten dieser Instanz aus der lokalen Datenbank löschen? Die Verbindungseinstellungen bleiben erhalten. Sie können danach jederzeit erneut importieren.'),
|
||||||
|
{
|
||||||
|
title: t('Importierte Daten löschen'),
|
||||||
|
confirmLabel: t('Löschen'),
|
||||||
|
variant: 'danger',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setWipingData(true);
|
||||||
|
try {
|
||||||
|
const result = await request({ url: `/api/trustee/${instanceId}/accounting/wipe-imported-data`, method: 'post' });
|
||||||
|
showSuccess(
|
||||||
|
t('Daten gelöscht'),
|
||||||
|
t('{n} Datensätze entfernt. Sie können nun einen frischen Import starten.', { n: String(result?.totalRemoved ?? 0) }),
|
||||||
|
);
|
||||||
|
_loadImportStatus();
|
||||||
|
void loadData();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Daten konnten nicht gelöscht werden.'));
|
||||||
|
} finally {
|
||||||
|
setWipingData(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wipingData ? t('Lösche…') : t('Importierte Daten löschen')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ interface TrusteeGraphNode {
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Matches automation2 ``buildConnectionMap`` (``sourceOutput`` / ``targetInput``). */
|
||||||
interface TrusteeGraphConnection {
|
interface TrusteeGraphConnection {
|
||||||
source: string;
|
source: string;
|
||||||
sourcePort: number;
|
sourceOutput: number;
|
||||||
target: string;
|
target: string;
|
||||||
targetPort: number;
|
targetInput: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrusteeGraph {
|
export interface TrusteeGraph {
|
||||||
|
|
@ -68,7 +69,7 @@ export function _buildScanUploadGraph(
|
||||||
_method: 'trustee',
|
_method: 'trustee',
|
||||||
_action: 'processDocuments',
|
_action: 'processDocuments',
|
||||||
parameters: {
|
parameters: {
|
||||||
documentList: [],
|
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||||
featureInstanceId: trusteeInstanceId,
|
featureInstanceId: trusteeInstanceId,
|
||||||
},
|
},
|
||||||
position: { x: 500, y: 0 },
|
position: { x: 500, y: 0 },
|
||||||
|
|
@ -80,7 +81,7 @@ export function _buildScanUploadGraph(
|
||||||
_method: 'trustee',
|
_method: 'trustee',
|
||||||
_action: 'syncToAccounting',
|
_action: 'syncToAccounting',
|
||||||
parameters: {
|
parameters: {
|
||||||
documentList: [],
|
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||||
featureInstanceId: trusteeInstanceId,
|
featureInstanceId: trusteeInstanceId,
|
||||||
},
|
},
|
||||||
position: { x: 750, y: 0 },
|
position: { x: 750, y: 0 },
|
||||||
|
|
@ -88,9 +89,9 @@ export function _buildScanUploadGraph(
|
||||||
];
|
];
|
||||||
|
|
||||||
const connections: TrusteeGraphConnection[] = [
|
const connections: TrusteeGraphConnection[] = [
|
||||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
{ source: 'trigger-manual', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
return { nodes, connections };
|
return { nodes, connections };
|
||||||
|
|
@ -137,7 +138,7 @@ export function _buildExpenseImportGraph(
|
||||||
_method: 'trustee',
|
_method: 'trustee',
|
||||||
_action: 'processDocuments',
|
_action: 'processDocuments',
|
||||||
parameters: {
|
parameters: {
|
||||||
documentList: [],
|
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||||
featureInstanceId: trusteeInstanceId,
|
featureInstanceId: trusteeInstanceId,
|
||||||
},
|
},
|
||||||
position: { x: 500, y: 0 },
|
position: { x: 500, y: 0 },
|
||||||
|
|
@ -149,7 +150,7 @@ export function _buildExpenseImportGraph(
|
||||||
_method: 'trustee',
|
_method: 'trustee',
|
||||||
_action: 'syncToAccounting',
|
_action: 'syncToAccounting',
|
||||||
parameters: {
|
parameters: {
|
||||||
documentList: [],
|
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||||
featureInstanceId: trusteeInstanceId,
|
featureInstanceId: trusteeInstanceId,
|
||||||
},
|
},
|
||||||
position: { x: 750, y: 0 },
|
position: { x: 750, y: 0 },
|
||||||
|
|
@ -157,9 +158,9 @@ export function _buildExpenseImportGraph(
|
||||||
];
|
];
|
||||||
|
|
||||||
const connections: TrusteeGraphConnection[] = [
|
const connections: TrusteeGraphConnection[] = [
|
||||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
{ source: 'trigger-manual', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
return { nodes, connections };
|
return { nodes, connections };
|
||||||
|
|
@ -210,7 +211,7 @@ export function _buildScheduledExpenseImportGraph(
|
||||||
_method: 'trustee',
|
_method: 'trustee',
|
||||||
_action: 'processDocuments',
|
_action: 'processDocuments',
|
||||||
parameters: {
|
parameters: {
|
||||||
documentList: [],
|
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||||
featureInstanceId: trusteeInstanceId,
|
featureInstanceId: trusteeInstanceId,
|
||||||
},
|
},
|
||||||
position: { x: 500, y: 0 },
|
position: { x: 500, y: 0 },
|
||||||
|
|
@ -222,7 +223,7 @@ export function _buildScheduledExpenseImportGraph(
|
||||||
_method: 'trustee',
|
_method: 'trustee',
|
||||||
_action: 'syncToAccounting',
|
_action: 'syncToAccounting',
|
||||||
parameters: {
|
parameters: {
|
||||||
documentList: [],
|
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||||
featureInstanceId: trusteeInstanceId,
|
featureInstanceId: trusteeInstanceId,
|
||||||
},
|
},
|
||||||
position: { x: 750, y: 0 },
|
position: { x: 750, y: 0 },
|
||||||
|
|
@ -230,9 +231,9 @@ export function _buildScheduledExpenseImportGraph(
|
||||||
];
|
];
|
||||||
|
|
||||||
const connections: TrusteeGraphConnection[] = [
|
const connections: TrusteeGraphConnection[] = [
|
||||||
{ source: 'trigger-schedule', sourcePort: 0, target: 'extract', targetPort: 0 },
|
{ source: 'trigger-schedule', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
return { nodes, connections };
|
return { nodes, connections };
|
||||||
|
|
|
||||||
45
src/test/setup.ts
Normal file
45
src/test/setup.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright (c) 2025 Patrick Motsch
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Vitest global setup: jest-dom matchers + jsdom polyfills required by some
|
||||||
|
// of our components (ResizeObserver, matchMedia, scrollIntoView).
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
23
src/test/smoke.test.ts
Normal file
23
src/test/smoke.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,5 +28,10 @@
|
||||||
"src/**/*.tsx", // Include all .tsx files
|
"src/**/*.tsx", // Include all .tsx files
|
||||||
"src/**/*.d.ts", // Include all declaration files
|
"src/**/*.d.ts", // Include all declaration files
|
||||||
"src/global.d.ts"
|
"src/global.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/test/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.test.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
tsconfig.test.json
Normal file
15
tsconfig.test.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["vitest/globals", "@testing-library/jest-dom", "node"],
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/test/**/*.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
26
vitest.config.ts
Normal file
26
vitest.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright (c) 2025 Patrick Motsch
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Vitest config for frontend unit + component tests.
|
||||||
|
// Lives next to vite.config.ts; reuses the @vitejs/plugin-react setup.
|
||||||
|
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
css: true,
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
exclude: ['node_modules', 'dist', '.git'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**', 'src/main.tsx'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue