This commit is contained in:
ValueOn AG 2026-04-25 01:13:13 +02:00
parent fc2cce8732
commit e09ed758ff
35 changed files with 5522 additions and 137 deletions

1626
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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;
}

View file

@ -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[] };
}
/**

View file

@ -6,7 +6,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { 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}>

View file

@ -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}

View 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();
});
});

View file

@ -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

View file

@ -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;

View file

@ -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[];

View file

@ -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>
);
};

View file

@ -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;

View 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',
}),
);
});
});

View file

@ -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>

View file

@ -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' }),
);
});
});

View file

@ -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>
);
};

View file

@ -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 */

View file

@ -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,
};
});

View file

@ -69,6 +69,10 @@ export function buildNodeOutputPreview(
return { _transit: true, _meta: {}, data: {} };
}
if (typeof port0.schema !== 'string') {
return {};
}
return _buildSchemaPreview(port0.schema);
}

View 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']]);
});
});

View 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-XX "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);
}

View 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');
}

View file

@ -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'
>;

View file

@ -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;

View file

@ -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);

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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 && (
<> {' '}&middot; <strong>{t('Zeitfenster:')}</strong> {timeWindow}</>
<> {' '}&middot; <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}

View file

@ -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
View 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
View 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);
});
});

View file

@ -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/**"
]
}

View file

@ -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
View 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
View 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'],
},
},
});