fix: clean header
This commit is contained in:
parent
8860f49714
commit
4bf6677bc5
6 changed files with 189 additions and 581 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
// Copyright (c) 2025 Patrick Motsch
|
||||
// All rights reserved.
|
||||
//
|
||||
// Plan #2 — Track A1.4 (T10): CanvasHeader Run-button gating logic.
|
||||
// Verifies the AC-9 patch — Save always enabled (unless saving), Run blocked
|
||||
// when executeBlockedReason is set + warning toast surfaced as amber banner.
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/workflowApi';
|
||||
|
||||
vi.mock('../../../providers/language/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
import { CanvasHeader } from './CanvasHeader';
|
||||
|
||||
const _workflows: Automation2Workflow[] = [];
|
||||
|
||||
function _renderHeader(overrides: Partial<React.ComponentProps<typeof CanvasHeader>> = {}) {
|
||||
const props: React.ComponentProps<typeof CanvasHeader> = {
|
||||
workflows: _workflows,
|
||||
currentWorkflowId: null,
|
||||
onWorkflowSelect: () => {},
|
||||
onNew: () => {},
|
||||
onSave: () => {},
|
||||
onExecute: () => {},
|
||||
saving: false,
|
||||
executing: false,
|
||||
hasNodes: true,
|
||||
executeResult: null,
|
||||
...overrides,
|
||||
};
|
||||
return render(<CanvasHeader {...props} />);
|
||||
}
|
||||
|
||||
describe('CanvasHeader Run-button (T10)', () => {
|
||||
it('runs `onExecute` when not blocked', async () => {
|
||||
const onExecute = vi.fn();
|
||||
_renderHeader({ onExecute });
|
||||
await userEvent.click(screen.getByRole('button', { name: /Ausführen/i }));
|
||||
expect(onExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the "Pflicht-Felder fehlen" label and triggers `onExecuteBlockedClick` instead of `onExecute`', async () => {
|
||||
const onExecute = vi.fn();
|
||||
const onExecuteBlockedClick = vi.fn();
|
||||
_renderHeader({
|
||||
onExecute,
|
||||
onExecuteBlockedClick,
|
||||
executeBlockedReason: '2 Nodes mit Pflicht-Fehlern',
|
||||
});
|
||||
const btn = screen.getByRole('button', { name: /Pflicht-Felder fehlen/i });
|
||||
expect(btn).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(btn).toHaveAttribute('title', '2 Nodes mit Pflicht-Fehlern');
|
||||
await userEvent.click(btn);
|
||||
expect(onExecute).not.toHaveBeenCalled();
|
||||
expect(onExecuteBlockedClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables the Run button while executing or when no nodes are present', () => {
|
||||
const { rerender } = _renderHeader({ executing: true });
|
||||
expect(screen.getByRole('button', { name: /Ausführen…/i })).toBeDisabled();
|
||||
rerender(
|
||||
<CanvasHeader
|
||||
workflows={_workflows}
|
||||
currentWorkflowId={null}
|
||||
onWorkflowSelect={() => {}}
|
||||
onNew={() => {}}
|
||||
onSave={() => {}}
|
||||
onExecute={() => {}}
|
||||
saving={false}
|
||||
executing={false}
|
||||
hasNodes={false}
|
||||
executeResult={null}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Ausführen/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CanvasHeader executeResult banner (AC-9)', () => {
|
||||
it('renders the warning text in amber when success+warning is present', () => {
|
||||
const result: ExecuteGraphResponse = {
|
||||
success: true,
|
||||
warning: 'Gespeichert mit 3 Pflicht-Fehlern in 2 Nodes.',
|
||||
};
|
||||
_renderHeader({ executeResult: result });
|
||||
expect(screen.getByText(/Gespeichert mit 3 Pflicht-Fehlern/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the error text in red when success=false', () => {
|
||||
const result: ExecuteGraphResponse = { success: false, error: 'Boom' };
|
||||
_renderHeader({ executeResult: result });
|
||||
expect(screen.getByText(/Boom/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,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')}</>
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
/**
|
||||
* Workflow configuration — primary start kind drives the canvas start node.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
|
||||
import {
|
||||
getPrimaryStartKind,
|
||||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
|
||||
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'manual', label: t('Manueller Trigger') },
|
||||
{ value: 'form', label: t('Formular') },
|
||||
{ value: 'schedule', label: t('Zeitplan') },
|
||||
{ value: 'always_on', label: t('Immer aktiv') },
|
||||
];
|
||||
}
|
||||
|
||||
interface WorkflowConfigurationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
invocations: WorkflowEntryPoint[];
|
||||
onApply: (next: WorkflowEntryPoint[]) => void;
|
||||
}
|
||||
|
||||
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
|
||||
|
||||
function normalizeLoadedKind(k: string): string {
|
||||
if (_validKinds.includes(k)) return k;
|
||||
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
|
||||
if (k === 'api') return 'manual';
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
|
||||
onClose,
|
||||
invocations,
|
||||
onApply,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const kindOptions = _getKindOptions(t);
|
||||
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
|
||||
const [titleDe, setTitleDe] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
|
||||
setKind(k);
|
||||
const entry = invocations[0];
|
||||
const entryTitle = entry?.title;
|
||||
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
|
||||
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
|
||||
else setTitleDe('');
|
||||
}, [open, invocations]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const label =
|
||||
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
|
||||
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
|
||||
onApply(next);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
|
||||
<div className={styles.workflowModal}>
|
||||
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
|
||||
{t('Workflow-Konfiguration')}
|
||||
</h3>
|
||||
<p className={styles.workflowModalHint}>
|
||||
{t(
|
||||
'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
|
||||
)}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
|
||||
{t('Titel der Start Node')}
|
||||
</label>
|
||||
<input
|
||||
id="wf-start-title"
|
||||
className={styles.workflowModalInput}
|
||||
value={titleDe}
|
||||
onChange={(e) => setTitleDe(e.target.value)}
|
||||
placeholder={t('z.B. Angebot anlegen')}
|
||||
/>
|
||||
|
||||
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
|
||||
{kindOptions.map((o) => (
|
||||
<label key={o.value} className={styles.workflowModalRadio}>
|
||||
<input
|
||||
type="radio"
|
||||
name="kind"
|
||||
value={o.value}
|
||||
checked={kind === o.value}
|
||||
onChange={() => setKind(o.value)}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.workflowModalActions}>
|
||||
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button type="submit" className={styles.workflowModalBtnPrimary}>
|
||||
{t('Übernehmen')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue