diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 414aaecb..2287d0c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "picpeak-frontend", "version": "3.69.0-beta.0", "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/interaction": "^6.1.20", @@ -549,6 +550,21 @@ "node": ">=18" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index da03cbb7..d6cdac71 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "i18n:ci": "i18next-cli extract --ci --dry-run" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/interaction": "^6.1.20", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3d3f315b..05c92f2d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -229,6 +229,7 @@ "editor": { "namePlaceholder": "Workflow-Name", "when": "Wenn", + "cleanUp": "Layout aufräumen", "saved": "Workflow gespeichert", "saveFailed": "Speichern fehlgeschlagen", "badJson": "Konfiguration ist kein gültiges JSON", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 006884e8..357dcd0c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -229,6 +229,7 @@ "editor": { "namePlaceholder": "Workflow name", "when": "When", + "cleanUp": "Clean up layout", "saved": "Workflow saved", "saveFailed": "Could not save", "badJson": "Config is not valid JSON", diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx index 2a7236a8..95f87d9f 100644 --- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -7,7 +7,7 @@ * version; in-flight runs keep theirs). The graph maps 1:1 onto * workflow_nodes/workflow_edges. Honours the admin light/dark theme. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -17,7 +17,8 @@ import { Handle, Position, type Connection, type Node, type Edge, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { ArrowLeft, Save, Trash2 } from 'lucide-react'; +import dagre from '@dagrejs/dagre'; +import { ArrowLeft, Save, Trash2, Wand2 } from 'lucide-react'; import { Button, Loading } from '../../../components/common'; import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext'; import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service'; @@ -90,6 +91,23 @@ function WfNode({ data }: { data: any }) { const nodeTypes = { wf: WfNode }; +// Tidy the graph into a top-to-bottom tree with dagre (handles the loop-back +// cycle by breaking it internally). +function layoutGraph(nodes: Node[], edges: Edge[]): Node[] { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: 'TB', nodesep: 70, ranksep: 80 }); + const W = 170; + const H = 70; + nodes.forEach((n) => g.setNode(n.id, { width: W, height: H })); + edges.forEach((e) => g.setEdge(e.source, e.target)); + dagre.layout(g); + return nodes.map((n) => { + const p = g.node(n.id); + return p ? { ...n, position: { x: p.x - W / 2, y: p.y - H / 2 } } : n; + }); +} + export const WorkflowEditorPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -111,6 +129,12 @@ export const WorkflowEditorPage: React.FC = () => { const [enabled, setEnabled] = useState(false); const [selectedId, setSelectedId] = useState(null); const [counter, setCounter] = useState(1); + const rfRef = useRef(null); + + const cleanUp = useCallback(() => { + setNodes((nds) => layoutGraph(nds, edges)); + setTimeout(() => rfRef.current?.fitView?.({ padding: 0.2, duration: 300 }), 60); + }, [edges, setNodes]); useEffect(() => { if (!workflow) return; @@ -213,7 +237,7 @@ export const WorkflowEditorPage: React.FC = () => { -
+
{PALETTE.map((type) => ( ))} +
@@ -230,7 +260,7 @@ export const WorkflowEditorPage: React.FC = () => { colorMode={isDark ? 'dark' : 'light'} nodes={nodes} edges={edges} nodeTypes={nodeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} - onNodeClick={(_, n) => setSelectedId(n.id)} fitView + onNodeClick={(_, n) => setSelectedId(n.id)} onInit={(inst) => { rfRef.current = inst; }} fitView >