fix: clean header

This commit is contained in:
Ida 2026-05-13 15:00:06 +02:00
parent 8860f49714
commit 4bf6677bc5
6 changed files with 189 additions and 581 deletions

View file

@ -256,27 +256,28 @@
background: var(--bg-primary, #fff);
}
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
/* Single toolbar row (all controls in one flex wrap). */
.canvasHeaderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
}
@media (max-width: 900px) {
.canvasHeaderRow {
grid-template-columns: 1fr;
}
.canvasHeaderToolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
box-sizing: border-box;
}
.canvasHeaderContext {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderToolbar :global(button),
.canvasHeaderToolbar label {
margin-top: 0;
}
/* Closed <select> width must not follow the longest option label. */
@ -284,17 +285,18 @@
flex: 0 0 auto;
width: 12.5rem;
max-width: 100%;
padding: 0.4rem 0.5rem;
min-height: 2rem;
font-size: 0.85rem;
padding: 0.31rem 0.45rem;
min-height: 30px;
box-sizing: border-box;
font-size: 0.8125rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
border-radius: var(--button-border-radius, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderTitleBlock {
flex: 1 1 8rem;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
@ -335,38 +337,31 @@
box-sizing: border-box;
}
.canvasHeaderActionPanel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto;
max-width: 100%;
.canvasHeaderIconBtn {
padding: 6px !important;
min-width: 30px !important;
min-height: 30px !important;
box-sizing: border-box !important;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderActionPanel button {
margin-top: 0;
.canvasHeaderSplitPair :global(.button + .button) {
margin-left: 0;
}
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */
.canvasHeaderRunButton {
min-width: 12.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
.canvasHeaderRunBlocked {
background: rgba(220, 53, 69, 0.1) !important;
border: 1px solid var(--danger-color, #dc3545) !important;
color: var(--danger-color, #dc3545) !important;
cursor: help !important;
box-shadow: none !important;
}
@media (max-width: 900px) {
.canvasHeaderActionPanel {
justify-content: flex-start;
}
.canvasHeaderRunBlocked:hover:not(:disabled) {
filter: brightness(0.97);
}
.canvasHeaderRunBlocked :global(.buttonIcon) {
opacity: 0.5;
}
.canvasHeaderVersionRow {
@ -1495,24 +1490,6 @@
cursor: pointer;
}
.canvasGearBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background: var(--bg-primary, #fff);
cursor: pointer;
font-size: 1rem;
}
.canvasGearBtn:hover {
background: var(--bg-hover, #f0f0f0);
}
.startsInput,
.startsSelect {
padding: 0.35rem 0.5rem;

View file

@ -2,7 +2,8 @@
* Automation2FlowEditor
*
* n8n-style flow builder with backend-driven node list.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
* Starts and invocations are driven by backend graph + defaults; canvas start
* node stays in sync on load via `syncCanvasStartNode`.
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@ -32,11 +33,10 @@ import {
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
@ -58,8 +58,7 @@ import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
@ -92,7 +91,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSourcesChanged,
}) => {
const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
@ -117,7 +115,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
@ -136,13 +133,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
@ -435,18 +425,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[]
);
const handleApplyWorkflowConfiguration = useCallback(
(next: WorkflowEntryPoint[]) => {
setInvocations(next);
setCanvasNodes((nodes) => {
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
setCanvasConnections(r.connections);
return r.nodes;
});
},
[canvasConnections, nodeTypes, language]
);
const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
@ -675,31 +653,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[request, instanceId, handleFromApiGraph]
);
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try {
await updateWorkflow(request, instanceId, workflowId, { label: newName });
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`${LOG} rename failed`, e);
showError(t('Workflow umbenennen fehlgeschlagen: {msg}', { msg }));
}
}, [request, instanceId, showError, t]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
}, [canvasConnections]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
@ -840,8 +796,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
workspacePanelOpen={leftPanelOpen}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
@ -868,13 +824,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@ -974,12 +925,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}

View file

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

View file

@ -1,19 +1,27 @@
/**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
* CanvasHeader - Workflow controls, version selector, and execute result.
*/
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import {
FaPlay,
FaSpinner,
FaCloudUploadAlt,
FaCloudDownloadAlt,
FaArchive,
FaBookmark,
FaCaretDown,
FaSave,
FaPlus,
FaChevronLeft,
FaChevronRight,
} from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
interface TargetInstanceOption {
id: string;
label: string;
}
import { Button } from '../../UiComponents/Button';
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
@ -22,8 +30,8 @@ interface CanvasHeaderProps {
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onWorkflowSettings?: () => void;
onToggleChat?: () => void;
onToggleWorkspacePanel?: () => void;
workspacePanelOpen?: boolean;
saving: boolean;
executing: boolean;
hasNodes: boolean;
@ -44,15 +52,10 @@ interface CanvasHeaderProps {
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean;
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
onAutoLayout?: () => void;
/** Sysadmin-only: when true, NodeConfigPanel renders the static
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -63,14 +66,18 @@ function _getStatusBadge(t: (key: string) => string): Record<string, { label: st
};
}
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
const _tb = 'secondary' as const;
const _ts = 'sm' as const;
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onWorkflowSettings,
onToggleChat,
onToggleWorkspacePanel,
workspacePanelOpen,
saving,
executing,
hasNodes,
@ -88,13 +95,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onSaveAsTemplate,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
verboseSchema,
onVerboseSchemaChange,
targetFeatureInstanceId,
onTargetInstanceChange,
targetInstanceOptions,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@ -109,34 +111,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
const _startNameEdit = useCallback(() => {
if (!currentWorkflowId || !onWorkflowRename) return;
setNameValue(currentWorkflow?.label || '');
setEditingName(true);
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const _commitNameEdit = useCallback(() => {
setEditingName(false);
const trimmed = nameValue.trim();
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
if (trimmed !== currentWorkflow?.label) {
onWorkflowRename(currentWorkflowId, trimmed);
}
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
useEffect(() => {
if (editingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [editingName]);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
@ -156,15 +130,77 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
[t]
);
const _titleHint =
onWorkflowRename && currentWorkflow
? `${currentWorkflow.label}${t('Klicken zum Umbenennen')}`
: currentWorkflow?.label;
const _panelOpen = workspacePanelOpen ?? false;
const _runAriaLabel = executing
? t('Ausführen…')
: executeBlockedReason
? t('Pflicht-Felder fehlen')
: t('Ausführen');
const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
return (
<div className={styles.canvasHeader}>
<div className={styles.canvasHeaderRow}>
<div className={styles.canvasHeaderContext}>
<div
className={styles.canvasHeaderToolbar}
role="toolbar"
aria-label={t('Workflow-Aktionen')}
>
{onToggleWorkspacePanel && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={_panelOpen ? FaChevronLeft : FaChevronRight}
className={styles.canvasHeaderIconBtn}
onClick={onToggleWorkspacePanel}
title={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
/>
)}
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaPlus}
className={`${styles.canvasHeaderIconBtn} ${onNewFromTemplate ? styles.canvasHeaderNewSplitMain : ''}`}
onClick={onNew}
title={t('Neuer leerer Workflow')}
aria-label={t('Neuer leerer Workflow')}
/>
{onNewFromTemplate && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaCaretDown}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Aus Vorlage…')}
aria-label={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
/>
)}
</div>
{newMenuOpen && onNewFromTemplate && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => {
onNewFromTemplate();
setNewMenuOpen(false);
}}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
</div>
)}
</div>
<select
className={styles.canvasHeaderWorkflowSelect}
value={currentWorkflowId ?? ''}
@ -182,142 +218,53 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</option>
))}
</select>
<div className={styles.canvasHeaderTitleBlock}>
{currentWorkflowId && currentWorkflow ? (
editingName ? (
<input
ref={nameInputRef}
className={styles.canvasHeaderTitle}
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={_commitNameEdit}
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
/>
) : (
<h4
className={styles.canvasHeaderTitle}
style={{ cursor: onWorkflowRename ? 'pointer' : 'default' }}
onClick={_startNameEdit}
title={_titleHint}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={`${styles.canvasHeaderTitle} ${styles.canvasHeaderTitleMuted}`}>
{t('Neuer Workflow')}
</h4>
)}
</div>
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title={t('Workflowkonfiguration Einstieg/Starts')}
aria-label={t('Workflow-Konfiguration')}
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
<select
className={styles.canvasHeaderWorkflowSelect}
value={targetFeatureInstanceId ?? ''}
onChange={(e) => onTargetInstanceChange(e.target.value)}
aria-label={t('Ziel-Instanz')}
title={t('Ziel-Instanz für Daten-Scope')}
style={{ maxWidth: 200, fontSize: '0.8rem' }}
>
<option value="">{t('Ziel-Instanz wählen…')}</option>
{targetInstanceOptions.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)}
</div>
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMain}`}
onClick={onNew}
>
{t('Neu')}
</button>
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
</div>
{newMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNew(); setNewMenuOpen(false); }}
role="menuitem"
>
{t('Leerer Workflow')}
</button>
{onNewFromTemplate && (
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
)}
</div>
)}
</div>
<button
<Button
type="button"
className={styles.retryButton}
onClick={onSave}
variant={_tb}
size={_ts}
icon={saving ? undefined : FaSave}
className={styles.canvasHeaderIconBtn}
loading={saving}
disabled={saving}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
>
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
{onAutoLayout && (
<button
type="button"
className={styles.retryButton}
onClick={onAutoLayout}
disabled={!hasNodes}
title={t('Knoten automatisch anordnen')}
>
<FaSitemap style={{ marginRight: '0.4rem' }} />
{t('Anordnen')}
</button>
)}
onClick={onSave}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
aria-label={t('Speichern')}
/>
<Button
type="button"
variant={_tb}
size={_ts}
icon={executing ? undefined : FaPlay}
loading={executing}
disabled={executing || !hasNodes}
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
aria-label={_runAriaLabel}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={_runTitle}
/>
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<button
<Button
type="button"
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
variant={_tb}
size={_ts}
icon={FaBookmark}
loading={templateSaving}
disabled={templateSaving}
onClick={() => setTemplateMenuOpen((p) => !p)}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
</button>
{t('Als Vorlage')}
</Button>
{templateMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => (
@ -325,7 +272,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
key={s}
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
onClick={() => {
onSaveAsTemplate(s);
setTemplateMenuOpen(false);
}}
role="menuitem"
>
{scopeLabels[s]}
@ -336,53 +286,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</div>
)}
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderRunButton}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
disabled={executing || !hasNodes}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={executeBlockedReason ?? undefined}
style={
executeBlockedReason
? {
background: 'rgba(220,53,69,0.10)',
borderColor: 'var(--danger-color, #dc3545)',
color: 'var(--danger-color, #dc3545)',
cursor: 'help',
}
: undefined
}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ flexShrink: 0 }} />
{t('Ausführen…')}
</>
) : executeBlockedReason ? (
<>
<FaPlay style={{ opacity: 0.5, flexShrink: 0 }} />
{t('Pflicht-Felder fehlen')}
</>
) : (
<>
<FaPlay style={{ flexShrink: 0 }} />
{t('Ausführen')}
</>
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
<FaDatabase style={{ marginRight: '0.4rem' }} />
{t('Workspace')}
</button>
)}
{_isSysAdmin && onVerboseSchemaChange && (
<label
className={styles.canvasHeaderSysadmin}
@ -515,8 +418,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
)
) : (executeResult as { paused?: boolean }).paused ? (
<>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
Task zu bearbeiten.
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den Task zu
bearbeiten.
</>
) : (
<> {executeResult.error ?? t('Unbekannter Fehler')}</>

View file

@ -1,123 +0,0 @@
/**
* Workflow configuration primary start kind drives the canvas start node.
*/
import React, { useState, useEffect } from 'react';
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
import {
getPrimaryStartKind,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
return [
{ value: 'manual', label: t('Manueller Trigger') },
{ value: 'form', label: t('Formular') },
{ value: 'schedule', label: t('Zeitplan') },
{ value: 'always_on', label: t('Immer aktiv') },
];
}
interface WorkflowConfigurationModalProps {
open: boolean;
onClose: () => void;
invocations: WorkflowEntryPoint[];
onApply: (next: WorkflowEntryPoint[]) => void;
}
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
function normalizeLoadedKind(k: string): string {
if (_validKinds.includes(k)) return k;
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
if (k === 'api') return 'manual';
return 'manual';
}
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
onClose,
invocations,
onApply,
}) => {
const { t } = useLanguage();
const kindOptions = _getKindOptions(t);
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
const [titleDe, setTitleDe] = useState('');
useEffect(() => {
if (!open) return;
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
setKind(k);
const entry = invocations[0];
const entryTitle = entry?.title;
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
else setTitleDe('');
}, [open, invocations]);
if (!open) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const label =
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
onApply(next);
onClose();
};
return (
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
<div className={styles.workflowModal}>
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
{t('Workflow-Konfiguration')}
</h3>
<p className={styles.workflowModalHint}>
{t(
'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
)}
</p>
<form onSubmit={handleSubmit}>
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
{t('Titel der Start Node')}
</label>
<input
id="wf-start-title"
className={styles.workflowModalInput}
value={titleDe}
onChange={(e) => setTitleDe(e.target.value)}
placeholder={t('z.B. Angebot anlegen')}
/>
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
{kindOptions.map((o) => (
<label key={o.value} className={styles.workflowModalRadio}>
<input
type="radio"
name="kind"
value={o.value}
checked={kind === o.value}
onChange={() => setKind(o.value)}
/>
{o.label}
</label>
))}
</div>
<div className={styles.workflowModalActions}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
{t('Abbrechen')}
</button>
<button type="submit" className={styles.workflowModalBtnPrimary}>
{t('Übernehmen')}
</button>
</div>
</form>
</div>
</div>
);
};

View file

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