feat(workflows): 'Clean up layout' auto-arrange button (dagre)
Adds a one-click tidy that re-lays the graph top-to-bottom with dagre (@dagrejs/dagre) and fits the view — handles the loop-back cycle by breaking it internally. en + de string.
This commit is contained in:
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [counter, setCounter] = useState(1);
|
||||
const rfRef = useRef<any>(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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PALETTE.map((type) => (
|
||||
<button
|
||||
key={type} type="button" onClick={() => addNode(type)}
|
||||
@@ -222,6 +246,12 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
+ {type}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button" onClick={cleanUp}
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" /> {t('workflows.editor.cleanUp', 'Clean up layout')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3" style={{ height: '70vh' }}>
|
||||
@@ -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
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
|
||||
Reference in New Issue
Block a user