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:int": "tsc -b && vite build --mode int",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.12.0",
|
||||
|
|
@ -47,18 +51,24 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@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/proj4": "^2.5.6",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"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
|
||||
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;
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
@ -289,6 +341,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System
|
|||
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.
|
||||
*/
|
||||
|
|
@ -452,3 +527,50 @@ export async function submitMfaCode(
|
|||
});
|
||||
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. */
|
||||
description: string | Record<string, string>;
|
||||
required: boolean;
|
||||
enumValues?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PortSchema {
|
||||
|
|
@ -40,8 +41,14 @@ export interface InputPortDef {
|
|||
accepts: string[];
|
||||
}
|
||||
|
||||
/** Graph-defined output schema (e.g. form fields from node parameters). */
|
||||
export interface GraphDefinedSchemaRef {
|
||||
kind: 'fromGraph';
|
||||
parameter: string;
|
||||
}
|
||||
|
||||
export interface OutputPortDef {
|
||||
schema: string;
|
||||
schema: string | GraphDefinedSchemaRef;
|
||||
dynamic?: boolean;
|
||||
deriveFrom?: string;
|
||||
}
|
||||
|
|
@ -90,7 +97,7 @@ export interface Automation2GraphNode {
|
|||
type: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||
outputPorts?: Array<{ name: string; schema: string }>;
|
||||
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
|
||||
}
|
||||
|
||||
export interface Automation2Connection {
|
||||
|
|
@ -109,6 +116,10 @@ export interface ExecuteGraphResponse {
|
|||
success: boolean;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
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;
|
||||
failedNode?: string;
|
||||
paused?: boolean;
|
||||
|
|
@ -264,8 +275,55 @@ export async function fetchNodeTypes(
|
|||
});
|
||||
const nodeTypes = data?.nodeTypes ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
|
||||
return { nodeTypes, categories };
|
||||
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
|
||||
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 type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||
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 {
|
||||
currentNodeId: string;
|
||||
|
|
@ -19,6 +19,11 @@ export interface Automation2DataFlowContextValue {
|
|||
systemVariables: Record<string, SystemVariable>;
|
||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => 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);
|
||||
|
|
@ -36,6 +41,8 @@ interface Automation2DataFlowProviderProps {
|
|||
language: string;
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -48,10 +55,52 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
language,
|
||||
portTypeCatalog = {},
|
||||
systemVariables = {},
|
||||
instanceId,
|
||||
request,
|
||||
children,
|
||||
}) => {
|
||||
const value = useMemo((): Automation2DataFlowContextValue | 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 {
|
||||
currentNodeId: node.id,
|
||||
nodes,
|
||||
|
|
@ -64,8 +113,11 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||
n.title ?? n.label ?? n.type ?? n.id,
|
||||
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 (
|
||||
<Automation2DataFlowContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ import {
|
|||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
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 { usePrompt } from '../../../hooks/usePrompt';
|
||||
import { EditorChatPanel } from './EditorChatPanel';
|
||||
|
|
@ -180,6 +182,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
[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(
|
||||
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
||||
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.') });
|
||||
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);
|
||||
setExecuteResult(null);
|
||||
try {
|
||||
|
|
@ -228,7 +258,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
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.') });
|
||||
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);
|
||||
try {
|
||||
if (currentWorkflowId) {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
setExecuteResult(_buildSaveResult());
|
||||
} else {
|
||||
const label = await promptInput(t('Workflow-Name:'), {
|
||||
title: t('Workflow speichern'),
|
||||
|
|
@ -259,14 +306,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setCurrentWorkflowId(created.id);
|
||||
if (created.invocations?.length) setInvocations(created.invocations);
|
||||
setWorkflows((prev) => [...prev, created]);
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
setExecuteResult(_buildSaveResult());
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (workflowId: string) => {
|
||||
|
|
@ -749,6 +796,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
saving={saving}
|
||||
executing={executing}
|
||||
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}
|
||||
versions={versions}
|
||||
currentVersionId={currentVersionId}
|
||||
|
|
@ -777,6 +835,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
getCategoryIcon={getCategoryIcon}
|
||||
onSelectionChange={setSelectedNode}
|
||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||
nodeErrors={nodeErrors}
|
||||
onExternalDrop={async (mime, payload) => {
|
||||
if (mime !== 'application/json+workflow' || !instanceId) return false;
|
||||
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
||||
|
|
@ -804,6 +863,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
language={language}
|
||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||
systemVariables={systemVariables as Record<string, never>}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
>
|
||||
<NodeConfigPanel
|
||||
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;
|
||||
executing: boolean;
|
||||
hasNodes: boolean;
|
||||
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
|
||||
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
|
||||
* parent can navigate the user to the first offending node. */
|
||||
executeBlockedReason?: string | null;
|
||||
onExecuteBlockedClick?: () => void;
|
||||
executeResult: ExecuteGraphResponse | null;
|
||||
versions?: AutoVersion[];
|
||||
currentVersionId?: string | null;
|
||||
|
|
@ -56,6 +61,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
saving,
|
||||
executing,
|
||||
hasNodes,
|
||||
executeBlockedReason,
|
||||
onExecuteBlockedClick,
|
||||
executeResult,
|
||||
versions,
|
||||
currentVersionId,
|
||||
|
|
@ -213,7 +220,11 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
type="button"
|
||||
className={styles.retryButton}
|
||||
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')}
|
||||
</button>
|
||||
|
|
@ -277,14 +288,37 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onExecute}
|
||||
onClick={() => {
|
||||
if (executeBlockedReason) {
|
||||
onExecuteBlockedClick?.();
|
||||
return;
|
||||
}
|
||||
onExecute();
|
||||
}}
|
||||
disabled={executing || !hasNodes}
|
||||
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
|
||||
title={executeBlockedReason ?? undefined}
|
||||
style={
|
||||
executeBlockedReason
|
||||
? {
|
||||
background: 'rgba(220,53,69,0.10)',
|
||||
borderColor: 'var(--danger-color, #dc3545)',
|
||||
color: 'var(--danger-color, #dc3545)',
|
||||
cursor: 'help',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
||||
{t('Ausführen…')}
|
||||
</>
|
||||
) : executeBlockedReason ? (
|
||||
<>
|
||||
<FaPlay style={{ marginRight: '0.5rem', opacity: 0.5 }} />
|
||||
{t('Pflicht-Felder fehlen')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaPlay style={{ marginRight: '0.5rem' }} />
|
||||
|
|
@ -392,19 +426,27 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
borderRadius: 6,
|
||||
fontSize: '0.875rem',
|
||||
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
|
||||
? 'rgba(0,123,255,0.15)'
|
||||
: 'rgba(220,53,69,0.15)',
|
||||
color: executeResult.success
|
||||
? 'var(--success-color,#28a745)'
|
||||
? executeResult.warning
|
||||
? 'var(--warning-color,#ffc107)'
|
||||
: 'var(--success-color,#28a745)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'var(--primary-color,#007bff)'
|
||||
: 'var(--danger-color,#dc3545)',
|
||||
}}
|
||||
>
|
||||
{executeResult.success ? (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
executeResult.warning ? (
|
||||
<>⚠ {executeResult.warning}</>
|
||||
) : (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
)
|
||||
) : (executeResult as { paused?: boolean }).paused ? (
|
||||
<>
|
||||
⏸ 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 type { NodeType } from '../../../api/workflowApi';
|
||||
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
@ -23,7 +23,7 @@ export interface CanvasNode {
|
|||
outputs: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||
outputPorts?: Array<{ name: string; schema: string }>;
|
||||
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
|
||||
}
|
||||
|
||||
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' */
|
||||
function _checkConnectionCompatibility(
|
||||
sourceNode: CanvasNode,
|
||||
|
|
@ -124,11 +130,12 @@ function _checkConnectionCompatibility(
|
|||
const tgtPort = tgtType.inputPorts[targetInputIdx];
|
||||
if (!srcPort || !tgtPort) return 'ok';
|
||||
|
||||
const srcSchema = srcPort.schema;
|
||||
const srcSchema = _outputSchemaName(srcPort.schema as string | GraphDefinedSchemaRef);
|
||||
const accepts = tgtPort.accepts;
|
||||
if (!accepts || accepts.length === 0) 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';
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +150,9 @@ interface FlowCanvasProps {
|
|||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||
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
|
||||
* (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
|
||||
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
|
||||
|
|
@ -167,6 +177,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
|||
getCategoryIcon,
|
||||
onSelectionChange,
|
||||
highlightedNodeIds,
|
||||
nodeErrors,
|
||||
onExternalDrop,
|
||||
}) => {
|
||||
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.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 isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||
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')}
|
||||
/>
|
||||
)}
|
||||
{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 }) => {
|
||||
const pos = getHandlePosition(node, index);
|
||||
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
||||
const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
|
||||
const isCurrentTargetOfSelection =
|
||||
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 =
|
||||
isOutput ||
|
||||
(!used && connectingFrom) ||
|
||||
(!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) ||
|
||||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
|
||||
const nt = nodeTypeMap[node.type];
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
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 { getLabel } from '../nodes/shared/utils';
|
||||
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 styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
|
|
@ -76,11 +78,27 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
const dataFlow = useAutomation2DataFlow();
|
||||
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;
|
||||
|
||||
const isTrigger = node.type.startsWith('trigger.');
|
||||
const showNameField = onNodeUpdate && !isTrigger;
|
||||
const parameters = nodeType.parameters || [];
|
||||
const parameters = sortedParameters;
|
||||
|
||||
const inputPortDefs = nodeType.inputPorts ?? {};
|
||||
const outputPortDefs = nodeType.outputPorts ?? {};
|
||||
|
|
@ -112,9 +130,18 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
</p>
|
||||
)}
|
||||
{hasPortInfo && (
|
||||
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.75rem' }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #666)', fontWeight: 600, padding: '0.25rem 0' }}>
|
||||
{t('Datenfluss (Eingabe / Ausgabe)')}
|
||||
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
|
||||
<summary
|
||||
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>
|
||||
{inputPortEntries.length > 0 && (
|
||||
<div style={{ marginTop: '0.4rem' }}>
|
||||
|
|
@ -142,7 +169,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
<_PortFieldList
|
||||
key={`out-${idx}`}
|
||||
portIndex={Number(idx)}
|
||||
schemaNames={def?.schema ? [def.schema] : []}
|
||||
schemaNames={_schemaNamesFromOutputPort(def)}
|
||||
catalog={portTypeCatalog}
|
||||
emptyLabel={t('keine Felder')}
|
||||
language={language}
|
||||
|
|
@ -152,26 +179,123 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
)}
|
||||
</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) => {
|
||||
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 Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
return (
|
||||
<Renderer
|
||||
key={param.name}
|
||||
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 key={param.name} style={{ marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
{param.required && (
|
||||
<span
|
||||
title={t('Pflichtfeld')}
|
||||
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700 }}
|
||||
>
|
||||
*
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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 {
|
||||
portIndex: number;
|
||||
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 { 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 }) => (
|
||||
<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 { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
||||
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;
|
||||
React.useEffect(() => {
|
||||
if (!instanceId || !request) return;
|
||||
|
|
@ -170,26 +178,100 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
|
|||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, [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 (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<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 }}>
|
||||
{connections.length === 0 && !loadError && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
|
||||
{authority
|
||||
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
|
||||
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
|
||||
</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 && (
|
||||
<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="select">{t('Auswahl')}</option>
|
||||
<option value="textarea">{t('Mehrzeilig')}</option>
|
||||
<option value="group">{t('Gruppe')}</option>
|
||||
</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' }} />
|
||||
<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')}
|
||||
</label>
|
||||
<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>
|
||||
))}
|
||||
<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,
|
||||
file: TextInput,
|
||||
hidden: HiddenInput,
|
||||
dataRef: DataRefRenderer,
|
||||
userConnection: ConnectionPicker,
|
||||
sharepointFolder: SharepointPathPicker,
|
||||
sharepointFile: SharepointPathPicker,
|
||||
|
|
@ -630,6 +766,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
|||
condition: ConditionBuilder,
|
||||
mappingTable: MappingTableEditor,
|
||||
filterExpression: FilterExpressionEditor,
|
||||
attachmentBuilder: JsonEditor,
|
||||
};
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
|
||||
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 { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
|
@ -18,33 +19,103 @@ interface DataPickerProps {
|
|||
onClose: () => void;
|
||||
onPick: (ref: DataRef | SystemVarRef) => void;
|
||||
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>;
|
||||
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 {
|
||||
path: (string | number)[];
|
||||
label: 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(
|
||||
schema: PortSchema | undefined,
|
||||
catalog: Record<string, PortSchema>,
|
||||
basePath: (string | number)[] = [],
|
||||
depth = 0,
|
||||
): PickablePath[] {
|
||||
if (!schema || !schema.fields) return [];
|
||||
if (!schema || !schema.fields || depth > 8) return [];
|
||||
const result: PickablePath[] = [];
|
||||
for (const field of schema.fields) {
|
||||
const fieldPath = [...basePath, field.name];
|
||||
const label = fieldPath.map(String).join(' → ');
|
||||
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, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
|
||||
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
|
||||
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
|
||||
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(
|
||||
obj: unknown,
|
||||
basePath: (string | number)[] = [],
|
||||
|
|
@ -74,7 +145,7 @@ function _buildPathsFromPreview(
|
|||
|
||||
function _resolveSchemaForNode(
|
||||
nodeId: string,
|
||||
nodes: Array<{ id: string; type?: string }>,
|
||||
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
|
||||
nodeTypes: NodeType[],
|
||||
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
||||
catalog: Record<string, PortSchema>,
|
||||
|
|
@ -88,11 +159,23 @@ function _resolveSchemaForNode(
|
|||
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
||||
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.schema !== 'Transit') {
|
||||
return catalog[port0.schema];
|
||||
const schemaSpec = 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
|
||||
|
|
@ -108,23 +191,42 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
nodes,
|
||||
nodeOutputsPreview,
|
||||
getNodeLabel,
|
||||
expectedParamType,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
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();
|
||||
|
||||
// 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;
|
||||
|
||||
const catalog = ctx?.portTypeCatalog ?? {};
|
||||
const systemVars = ctx?.systemVariables ?? {};
|
||||
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) => {
|
||||
setExpandedNodes((prev) => {
|
||||
|
|
@ -135,8 +237,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
});
|
||||
};
|
||||
|
||||
const handlePick = (nodeId: string, path: (string | number)[]) => {
|
||||
onPick(createRef(nodeId, path));
|
||||
const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => {
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
@ -149,13 +258,92 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
<div className={styles.dataPickerOverlay} onClick={onClose}>
|
||||
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.dataPickerHeader}>
|
||||
<h4 className={styles.dataPickerTitle}>{t('Datenquelle wählen')}</h4>
|
||||
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
|
||||
×
|
||||
</button>
|
||||
<h4 className={styles.dataPickerTitle}>
|
||||
{t('Datenquelle wählen')}
|
||||
{expectedParamType && (
|
||||
<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 className={styles.dataPickerBody}>
|
||||
{/* 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 && (
|
||||
<div className={styles.dataPickerNodeSection}>
|
||||
<button
|
||||
|
|
@ -199,12 +387,26 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
const isExpanded = expandedNodes.has(nodeId);
|
||||
|
||||
const resolvedSchema = _resolveSchemaForNode(
|
||||
nodeId, nodes, nodeTypes, connections, catalog,
|
||||
nodeId,
|
||||
nodes,
|
||||
nodeTypes,
|
||||
connections,
|
||||
catalog,
|
||||
);
|
||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
|
||||
const paths = schemaPaths.length > 0
|
||||
? schemaPaths
|
||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
|
||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
|
||||
const annotated = _markIterableCandidates(
|
||||
schemaPaths.length > 0
|
||||
? 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 (
|
||||
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
||||
|
|
@ -223,21 +425,52 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
</button>
|
||||
{isExpanded && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
{paths.map((p, i) => (
|
||||
<button
|
||||
key={`${p.path.join('.')}-${i}`}
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
onClick={() => handlePick(nodeId, p.path)}
|
||||
>
|
||||
{p.label}
|
||||
{p.type && (
|
||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||
({p.type})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{paths.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: '#999', padding: '4px 8px' }}>
|
||||
{t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')}
|
||||
</div>
|
||||
)}
|
||||
{paths.map((p, i) => {
|
||||
const compat =
|
||||
expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok';
|
||||
return (
|
||||
<div
|
||||
key={`${p.path.join('.')}-${i}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
nodeId: string;
|
||||
path: (string | number)[];
|
||||
/** Optional declared type at bind time (for UI / validation hints) */
|
||||
expectedType?: string;
|
||||
}
|
||||
|
||||
/** Explicit static value wrapper */
|
||||
|
|
@ -63,8 +65,18 @@ export function createSystemVar(variable: string): SystemVarRef {
|
|||
}
|
||||
|
||||
/** Create a reference object */
|
||||
export function createRef(nodeId: string, path: (string | number)[] = []): DataRef {
|
||||
return { type: 'ref', nodeId, path };
|
||||
export function createRef(nodeId: string, path: (string | number)[] = [], expectedType?: string): DataRef {
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
Automation2Graph,
|
||||
Automation2GraphNode,
|
||||
Automation2Connection,
|
||||
GraphDefinedSchemaRef,
|
||||
} from '../../../../api/workflowApi';
|
||||
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 }))
|
||||
: undefined,
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ export function buildNodeOutputPreview(
|
|||
return { _transit: true, _meta: {}, data: {} };
|
||||
}
|
||||
|
||||
if (typeof port0.schema !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
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'`.
|
||||
*/
|
||||
defaultPresetKind?:
|
||||
| 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
|
||||
/** Whitelist of preset kinds offered to the user. */
|
||||
enabledPresets?: Array<
|
||||
'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
|
||||
| '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
|
||||
// refuses invalid years instead of us silently falling back to the default
|
||||
// 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());
|
||||
let lo: string | undefined = cfg.minDate;
|
||||
let hi: string | undefined = cfg.maxDate;
|
||||
|
|
|
|||
|
|
@ -416,6 +416,366 @@
|
|||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -579,6 +939,35 @@
|
|||
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 {
|
||||
background: var(--surface-color, #fff);
|
||||
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 { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
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 { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
@ -41,6 +56,32 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
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 eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
|
|
@ -98,14 +139,38 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
_loadSession();
|
||||
}, [_loadSession]);
|
||||
|
||||
// SSE Live Stream - connect once per session, don't re-create on status changes
|
||||
const sseSessionRef = useRef<string | null>(null);
|
||||
const sessionStatus = session?.status;
|
||||
// Load director prompt history when session changes
|
||||
useEffect(() => {
|
||||
if (!instanceId || !sessionId || !sessionStatus) return;
|
||||
if (!['active', 'joining', 'pending'].includes(sessionStatus)) return;
|
||||
if (!instanceId || !sessionId) 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
|
||||
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();
|
||||
sseSessionRef.current = sessionId;
|
||||
|
|
@ -200,6 +265,34 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
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': {
|
||||
const errData = sseEvent.data || {};
|
||||
const errMsg = errData.message || t('Unbekannter Fehler');
|
||||
|
|
@ -229,8 +322,10 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
eventSourceRef.current = null;
|
||||
sseSessionRef.current = null;
|
||||
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
|
||||
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 colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9'];
|
||||
let hash = 0;
|
||||
|
|
@ -341,6 +623,227 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
|
||||
{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 */}
|
||||
<div className={styles.sessionContent}>
|
||||
{/* Left: Transcript */}
|
||||
|
|
@ -501,6 +1004,9 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>{/* /sessionMain */}
|
||||
</div>{/* /sessionLayout */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
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 { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||
import { FaPlay, FaSpinner, FaTrash } from 'react-icons/fa';
|
||||
import styles from './Teamsbot.module.css';
|
||||
import { getUserDataCache } from '../../../utils/userCache';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
type SettingsTabId = 'general' | 'systemBots';
|
||||
|
||||
/** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
|
||||
function _formatVoiceName(voice: VoiceOption): string {
|
||||
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>;
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<h3 className={styles.cardTitle}>Bot-Einstellungen</h3>
|
||||
<h3 className={styles.cardTitle}>{t('Bot-Einstellungen')}</h3>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
|
||||
|
|
@ -375,6 +410,266 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
</button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importPeriod, setImportPeriod] = useState<PeriodValue | null>(null);
|
||||
const [wipingData, setWipingData] = useState(false);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -438,7 +439,9 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const lastSyncAt = importStatus?.lastSyncAt as number | null | undefined;
|
||||
const winFrom = importStatus?.lastSyncDateFrom 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
|
||||
? t('{from} bis {to}', { from: winFrom, to: winTo })
|
||||
: winFrom
|
||||
|
|
@ -446,6 +449,13 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
: winTo
|
||||
? t('bis {to}', { to: winTo })
|
||||
: 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 (
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
|
|
@ -461,9 +471,18 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<div>
|
||||
<strong>{t('Letzter Import:')}</strong> {new Date(lastSyncAt * 1000).toLocaleString()}
|
||||
{timeWindow && (
|
||||
<> {' '}· <strong>{t('Zeitfenster:')}</strong> {timeWindow}</>
|
||||
<> {' '}· <strong>{t('Angefragtes Zeitfenster:')}</strong> {timeWindow}</>
|
||||
)}
|
||||
</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' }}>
|
||||
{t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', {
|
||||
konten: String(counts.accounts ?? 0),
|
||||
|
|
@ -547,12 +566,13 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<button
|
||||
className={styles.secondaryButton}
|
||||
disabled={clearingCache}
|
||||
title={t('Leert nur den Antwort-Cache des KI-Agenten (~5 Min). Synchronisierte Daten bleiben unverändert.')}
|
||||
onClick={async () => {
|
||||
if (!instanceId) return;
|
||||
setClearingCache(true);
|
||||
try {
|
||||
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) {
|
||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
||||
} 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
|
||||
className={styles.secondaryButton}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@ interface TrusteeGraphNode {
|
|||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** Matches automation2 ``buildConnectionMap`` (``sourceOutput`` / ``targetInput``). */
|
||||
interface TrusteeGraphConnection {
|
||||
source: string;
|
||||
sourcePort: number;
|
||||
sourceOutput: number;
|
||||
target: string;
|
||||
targetPort: number;
|
||||
targetInput: number;
|
||||
}
|
||||
|
||||
export interface TrusteeGraph {
|
||||
|
|
@ -68,7 +69,7 @@ export function _buildScanUploadGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
|
|
@ -80,7 +81,7 @@ export function _buildScanUploadGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
|
|
@ -88,9 +89,9 @@ export function _buildScanUploadGraph(
|
|||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
{ source: 'trigger-manual', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
|
|
@ -137,7 +138,7 @@ export function _buildExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
|
|
@ -149,7 +150,7 @@ export function _buildExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
|
|
@ -157,9 +158,9 @@ export function _buildExpenseImportGraph(
|
|||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
{ source: 'trigger-manual', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
|
|
@ -210,7 +211,7 @@ export function _buildScheduledExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'extract', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
|
|
@ -222,7 +223,7 @@ export function _buildScheduledExpenseImportGraph(
|
|||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
documentList: { type: 'ref', nodeId: 'process', path: ['documents'] },
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
|
|
@ -230,9 +231,9 @@ export function _buildScheduledExpenseImportGraph(
|
|||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-schedule', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
{ source: 'trigger-schedule', sourceOutput: 0, target: 'extract', targetInput: 0 },
|
||||
{ source: 'extract', sourceOutput: 0, target: 'process', targetInput: 0 },
|
||||
{ source: 'process', sourceOutput: 0, target: 'sync', targetInput: 0 },
|
||||
];
|
||||
|
||||
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/**/*.d.ts", // Include all declaration files
|
||||
"src/global.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/test/**"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"files": [],
|
||||
"references": [
|
||||
{ "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