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",
|
"name": "picpeak-frontend",
|
||||||
"version": "3.69.0-beta.0",
|
"version": "3.69.0-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@fullcalendar/core": "^6.1.20",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/interaction": "^6.1.20",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
@@ -549,6 +550,21 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@epic-web/invariant": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
"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"
|
"i18n:ci": "i18next-cli extract --ci --dry-run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@fullcalendar/core": "^6.1.20",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/interaction": "^6.1.20",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
|
|||||||
@@ -229,6 +229,7 @@
|
|||||||
"editor": {
|
"editor": {
|
||||||
"namePlaceholder": "Workflow-Name",
|
"namePlaceholder": "Workflow-Name",
|
||||||
"when": "Wenn",
|
"when": "Wenn",
|
||||||
|
"cleanUp": "Layout aufräumen",
|
||||||
"saved": "Workflow gespeichert",
|
"saved": "Workflow gespeichert",
|
||||||
"saveFailed": "Speichern fehlgeschlagen",
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
"badJson": "Konfiguration ist kein gültiges JSON",
|
"badJson": "Konfiguration ist kein gültiges JSON",
|
||||||
|
|||||||
@@ -229,6 +229,7 @@
|
|||||||
"editor": {
|
"editor": {
|
||||||
"namePlaceholder": "Workflow name",
|
"namePlaceholder": "Workflow name",
|
||||||
"when": "When",
|
"when": "When",
|
||||||
|
"cleanUp": "Clean up layout",
|
||||||
"saved": "Workflow saved",
|
"saved": "Workflow saved",
|
||||||
"saveFailed": "Could not save",
|
"saveFailed": "Could not save",
|
||||||
"badJson": "Config is not valid JSON",
|
"badJson": "Config is not valid JSON",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* version; in-flight runs keep theirs). The graph maps 1:1 onto
|
* version; in-flight runs keep theirs). The graph maps 1:1 onto
|
||||||
* workflow_nodes/workflow_edges. Honours the admin light/dark theme.
|
* 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 { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
Handle, Position, type Connection, type Node, type Edge,
|
Handle, Position, type Connection, type Node, type Edge,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
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 { Button, Loading } from '../../../components/common';
|
||||||
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
|
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
|
||||||
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
|
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
|
||||||
@@ -90,6 +91,23 @@ function WfNode({ data }: { data: any }) {
|
|||||||
|
|
||||||
const nodeTypes = { wf: WfNode };
|
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 = () => {
|
export const WorkflowEditorPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -111,6 +129,12 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [counter, setCounter] = useState(1);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!workflow) return;
|
if (!workflow) return;
|
||||||
@@ -213,7 +237,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{PALETTE.map((type) => (
|
{PALETTE.map((type) => (
|
||||||
<button
|
<button
|
||||||
key={type} type="button" onClick={() => addNode(type)}
|
key={type} type="button" onClick={() => addNode(type)}
|
||||||
@@ -222,6 +246,12 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
+ {type}
|
+ {type}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="flex gap-3" style={{ height: '70vh' }}>
|
<div className="flex gap-3" style={{ height: '70vh' }}>
|
||||||
@@ -230,7 +260,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
colorMode={isDark ? 'dark' : 'light'}
|
colorMode={isDark ? 'dark' : 'light'}
|
||||||
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
|
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
|
||||||
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
|
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
|
||||||
onNodeClick={(_, n) => setSelectedId(n.id)} fitView
|
onNodeClick={(_, n) => setSelectedId(n.id)} onInit={(inst) => { rfRef.current = inst; }} fitView
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls />
|
<Controls />
|
||||||
|
|||||||
Reference in New Issue
Block a user