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);
|
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 {
|
.canvasHeaderRow {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.canvasHeaderToolbar {
|
||||||
.canvasHeaderRow {
|
display: flex;
|
||||||
grid-template-columns: 1fr;
|
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 {
|
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
|
||||||
display: flex;
|
.canvasHeaderToolbar :global(button),
|
||||||
align-items: center;
|
.canvasHeaderToolbar label {
|
||||||
gap: 0.5rem;
|
margin-top: 0;
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Closed <select> width must not follow the longest option label. */
|
/* Closed <select> width must not follow the longest option label. */
|
||||||
|
|
@ -284,17 +285,18 @@
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 12.5rem;
|
width: 12.5rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.31rem 0.45rem;
|
||||||
min-height: 2rem;
|
min-height: 30px;
|
||||||
font-size: 0.85rem;
|
box-sizing: border-box;
|
||||||
|
font-size: 0.8125rem;
|
||||||
border: 1px solid var(--border-color, #ccc);
|
border: 1px solid var(--border-color, #ccc);
|
||||||
border-radius: 6px;
|
border-radius: var(--button-border-radius, 6px);
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
color: var(--text-primary, #333);
|
color: var(--text-primary, #333);
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderTitleBlock {
|
.canvasHeaderTitleBlock {
|
||||||
flex: 1 1 8rem;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -335,38 +337,31 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderActionPanel {
|
.canvasHeaderIconBtn {
|
||||||
display: flex;
|
padding: 6px !important;
|
||||||
flex-wrap: wrap;
|
min-width: 30px !important;
|
||||||
align-items: center;
|
min-height: 30px !important;
|
||||||
justify-content: flex-end;
|
box-sizing: border-box !important;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
|
.canvasHeaderSplitPair :global(.button + .button) {
|
||||||
.canvasHeaderActionPanel button {
|
margin-left: 0;
|
||||||
margin-top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */
|
.canvasHeaderRunBlocked {
|
||||||
.canvasHeaderRunButton {
|
background: rgba(220, 53, 69, 0.1) !important;
|
||||||
min-width: 12.5rem;
|
border: 1px solid var(--danger-color, #dc3545) !important;
|
||||||
display: inline-flex;
|
color: var(--danger-color, #dc3545) !important;
|
||||||
align-items: center;
|
cursor: help !important;
|
||||||
justify-content: center;
|
box-shadow: none !important;
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.canvasHeaderRunBlocked:hover:not(:disabled) {
|
||||||
.canvasHeaderActionPanel {
|
filter: brightness(0.97);
|
||||||
justify-content: flex-start;
|
}
|
||||||
}
|
|
||||||
|
.canvasHeaderRunBlocked :global(.buttonIcon) {
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderVersionRow {
|
.canvasHeaderVersionRow {
|
||||||
|
|
@ -1495,24 +1490,6 @@
|
||||||
cursor: pointer;
|
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,
|
.startsInput,
|
||||||
.startsSelect {
|
.startsSelect {
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
* Automation2FlowEditor
|
* Automation2FlowEditor
|
||||||
*
|
*
|
||||||
* n8n-style flow builder with backend-driven node list.
|
* 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';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
|
@ -32,11 +33,10 @@ import {
|
||||||
type AutoVersion,
|
type AutoVersion,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} 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 { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
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 styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
|
||||||
import { useFeatureStore } from '../../../stores/featureStore';
|
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
const LOG = '[Automation2]';
|
||||||
|
|
||||||
|
|
@ -92,7 +91,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onSourcesChanged,
|
onSourcesChanged,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { showError } = useToast();
|
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||||
|
|
@ -117,7 +115,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
|
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
|
||||||
_buildDefaultInvocations(t('Jetzt ausführen'))
|
_buildDefaultInvocations(t('Jetzt ausführen'))
|
||||||
);
|
);
|
||||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
|
||||||
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
||||||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||||
|
|
@ -136,13 +133,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [versionLoading, setVersionLoading] = useState(false);
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
|
|
||||||
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
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(() => {
|
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
||||||
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
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 () => {
|
const loadNodeTypes = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -675,31 +653,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
[request, instanceId, handleFromApiGraph]
|
[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]);
|
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
||||||
|
|
||||||
|
|
@ -840,8 +796,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onNew={handleNew}
|
onNew={handleNew}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
|
||||||
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
|
workspacePanelOpen={leftPanelOpen}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
executing={executing}
|
executing={executing}
|
||||||
hasNodes={canvasNodes.length > 0}
|
hasNodes={canvasNodes.length > 0}
|
||||||
|
|
@ -868,13 +824,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onSaveAsTemplate={handleSaveAsTemplate}
|
onSaveAsTemplate={handleSaveAsTemplate}
|
||||||
templateSaving={templateSaving}
|
templateSaving={templateSaving}
|
||||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||||
onWorkflowRename={handleWorkflowRename}
|
|
||||||
onAutoLayout={handleAutoLayout}
|
|
||||||
verboseSchema={verboseSchema}
|
verboseSchema={verboseSchema}
|
||||||
onVerboseSchemaChange={setVerboseSchema}
|
onVerboseSchemaChange={setVerboseSchema}
|
||||||
targetFeatureInstanceId={targetFeatureInstanceId}
|
|
||||||
onTargetInstanceChange={handleTargetInstanceChange}
|
|
||||||
targetInstanceOptions={targetInstanceOptions}
|
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|
@ -974,12 +925,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PromptDialog />
|
<PromptDialog />
|
||||||
<WorkflowConfigurationModal
|
|
||||||
open={workflowSettingsOpen}
|
|
||||||
onClose={() => setWorkflowSettingsOpen(false)}
|
|
||||||
invocations={invocations}
|
|
||||||
onApply={handleApplyWorkflowConfiguration}
|
|
||||||
/>
|
|
||||||
<TemplatePicker
|
<TemplatePicker
|
||||||
open={templatePickerOpen}
|
open={templatePickerOpen}
|
||||||
onClose={() => setTemplatePickerOpen(false)}
|
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 React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
|
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 type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { getUserDataCache } from '../../../utils/userCache';
|
import { getUserDataCache } from '../../../utils/userCache';
|
||||||
|
import { Button } from '../../UiComponents/Button';
|
||||||
interface TargetInstanceOption {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CanvasHeaderProps {
|
interface CanvasHeaderProps {
|
||||||
workflows: Automation2Workflow[];
|
workflows: Automation2Workflow[];
|
||||||
|
|
@ -22,8 +30,8 @@ interface CanvasHeaderProps {
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onExecute: () => void;
|
onExecute: () => void;
|
||||||
onWorkflowSettings?: () => void;
|
onToggleWorkspacePanel?: () => void;
|
||||||
onToggleChat?: () => void;
|
workspacePanelOpen?: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
executing: boolean;
|
executing: boolean;
|
||||||
hasNodes: boolean;
|
hasNodes: boolean;
|
||||||
|
|
@ -44,15 +52,10 @@ interface CanvasHeaderProps {
|
||||||
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
||||||
templateSaving?: boolean;
|
templateSaving?: boolean;
|
||||||
onNewFromTemplate?: () => void;
|
onNewFromTemplate?: () => void;
|
||||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
|
||||||
onAutoLayout?: () => void;
|
|
||||||
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
||||||
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||||
verboseSchema?: boolean;
|
verboseSchema?: boolean;
|
||||||
onVerboseSchemaChange?: (next: boolean) => void;
|
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 }> {
|
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,
|
currentWorkflowId,
|
||||||
onWorkflowSelect,
|
onWorkflowSelect,
|
||||||
onNew,
|
onNew,
|
||||||
onSave,
|
onSave,
|
||||||
onExecute,
|
onExecute,
|
||||||
onWorkflowSettings,
|
onToggleWorkspacePanel,
|
||||||
onToggleChat,
|
workspacePanelOpen,
|
||||||
saving,
|
saving,
|
||||||
executing,
|
executing,
|
||||||
hasNodes,
|
hasNodes,
|
||||||
|
|
@ -88,13 +95,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
onSaveAsTemplate,
|
onSaveAsTemplate,
|
||||||
templateSaving,
|
templateSaving,
|
||||||
onNewFromTemplate,
|
onNewFromTemplate,
|
||||||
onWorkflowRename,
|
|
||||||
onAutoLayout,
|
|
||||||
verboseSchema,
|
verboseSchema,
|
||||||
onVerboseSchemaChange,
|
onVerboseSchemaChange,
|
||||||
targetFeatureInstanceId,
|
|
||||||
onTargetInstanceChange,
|
|
||||||
targetInstanceOptions,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||||
|
|
@ -109,34 +111,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const _handleClickOutside = (e: MouseEvent) => {
|
const _handleClickOutside = (e: MouseEvent) => {
|
||||||
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
||||||
|
|
@ -156,15 +130,77 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const _titleHint =
|
const _panelOpen = workspacePanelOpen ?? false;
|
||||||
onWorkflowRename && currentWorkflow
|
const _runAriaLabel = executing
|
||||||
? `${currentWorkflow.label} — ${t('Klicken zum Umbenennen')}`
|
? t('Ausführen…')
|
||||||
: currentWorkflow?.label;
|
: executeBlockedReason
|
||||||
|
? t('Pflicht-Felder fehlen')
|
||||||
|
: t('Ausführen');
|
||||||
|
const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.canvasHeader}>
|
<div className={styles.canvasHeader}>
|
||||||
<div className={styles.canvasHeaderRow}>
|
<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
|
<select
|
||||||
className={styles.canvasHeaderWorkflowSelect}
|
className={styles.canvasHeaderWorkflowSelect}
|
||||||
value={currentWorkflowId ?? ''}
|
value={currentWorkflowId ?? ''}
|
||||||
|
|
@ -182,142 +218,53 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className={styles.canvasHeaderTitleBlock}>
|
<Button
|
||||||
{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
|
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
onClick={onSave}
|
size={_ts}
|
||||||
|
icon={saving ? undefined : FaSave}
|
||||||
|
className={styles.canvasHeaderIconBtn}
|
||||||
|
loading={saving}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
|
onClick={onSave}
|
||||||
>
|
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
|
||||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
aria-label={t('Speichern')}
|
||||||
</button>
|
/>
|
||||||
|
<Button
|
||||||
{onAutoLayout && (
|
type="button"
|
||||||
<button
|
variant={_tb}
|
||||||
type="button"
|
size={_ts}
|
||||||
className={styles.retryButton}
|
icon={executing ? undefined : FaPlay}
|
||||||
onClick={onAutoLayout}
|
loading={executing}
|
||||||
disabled={!hasNodes}
|
disabled={executing || !hasNodes}
|
||||||
title={t('Knoten automatisch anordnen')}
|
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
|
||||||
>
|
onClick={() => {
|
||||||
<FaSitemap style={{ marginRight: '0.4rem' }} />
|
if (executeBlockedReason) {
|
||||||
{t('Anordnen')}
|
onExecuteBlockedClick?.();
|
||||||
</button>
|
return;
|
||||||
)}
|
}
|
||||||
|
onExecute();
|
||||||
|
}}
|
||||||
|
aria-label={_runAriaLabel}
|
||||||
|
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
|
||||||
|
title={_runTitle}
|
||||||
|
/>
|
||||||
{currentWorkflowId && onSaveAsTemplate && (
|
{currentWorkflowId && onSaveAsTemplate && (
|
||||||
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
size={_ts}
|
||||||
|
icon={FaBookmark}
|
||||||
|
loading={templateSaving}
|
||||||
disabled={templateSaving}
|
disabled={templateSaving}
|
||||||
|
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||||
title={t('Als Vorlage speichern')}
|
title={t('Als Vorlage speichern')}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={templateMenuOpen}
|
aria-expanded={templateMenuOpen}
|
||||||
>
|
>
|
||||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
{t('Als Vorlage')}
|
||||||
</button>
|
</Button>
|
||||||
{templateMenuOpen && (
|
{templateMenuOpen && (
|
||||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||||
|
|
@ -325,7 +272,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.canvasHeaderMenuItem}
|
className={styles.canvasHeaderMenuItem}
|
||||||
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
|
onClick={() => {
|
||||||
|
onSaveAsTemplate(s);
|
||||||
|
setTemplateMenuOpen(false);
|
||||||
|
}}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
{scopeLabels[s]}
|
{scopeLabels[s]}
|
||||||
|
|
@ -336,53 +286,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
</div>
|
</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 && (
|
{_isSysAdmin && onVerboseSchemaChange && (
|
||||||
<label
|
<label
|
||||||
className={styles.canvasHeaderSysadmin}
|
className={styles.canvasHeaderSysadmin}
|
||||||
|
|
@ -515,8 +418,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
)
|
)
|
||||||
) : (executeResult as { paused?: boolean }).paused ? (
|
) : (executeResult as { paused?: boolean }).paused ? (
|
||||||
<>
|
<>
|
||||||
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den Task zu
|
||||||
Task zu bearbeiten.
|
bearbeiten.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>✗ {executeResult.error ?? t('Unbekannter Fehler')}</>
|
<>✗ {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 React from 'react';
|
||||||
import { ButtonWithIconProps } from './ButtonTypes';
|
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';
|
as?: 'button' | 'a';
|
||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue