Phase 9: Implement atomic hooks-based workflow canvas components

- Created custom hooks: useWorkflowGraph, useWorkflowPlugins, usePluginSearch, useTabNavigation
- Decomposed canvas logic into: useCanvasNodes, useCanvasEdges, useCanvasDragDrop
- Built atomic node components: NodeHeader, NodeBody, NodePorts
- Created canvas UI components: CanvasInfoPanel, CanvasHintPanel
- Split builder into: LoadingState, ErrorState, WorkflowBuilderHeader, WorkflowBuilderTabs, WorkflowBuilderContent
- Added React Flow for n8n-style visual canvas with drag-and-drop
- All components now under 100 LOC following PROMPT.md guidelines

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-10 12:25:36 +00:00
parent d7f6d40efa
commit 6e9ff896e7
26 changed files with 2238 additions and 5880 deletions

View File

@@ -0,0 +1,15 @@
import { Paper, Typography } from "@mui/material";
type CanvasHintPanelProps = {
t: (key: string, fallback?: string) => string;
};
export default function CanvasHintPanel({ t }: CanvasHintPanelProps) {
return (
<Paper sx={{ p: 1, backgroundColor: "var(--color-panel-bg)" }}>
<Typography variant="caption" color="text.secondary">
{t("ui.workflow.canvas.hint", "Drag nodes from palette to add")}
</Typography>
</Paper>
);
}

View File

@@ -0,0 +1,19 @@
import { Paper, Typography } from "@mui/material";
type CanvasInfoPanelProps = {
nodeCount: number;
edgeCount: number;
t: (key: string, fallback?: string) => string;
};
export default function CanvasInfoPanel({ nodeCount, edgeCount, t }: CanvasInfoPanelProps) {
return (
<Paper sx={{ p: 1, backgroundColor: "var(--color-panel-bg)" }}>
<Typography variant="caption" color="text.secondary">
{t("ui.workflow.canvas.info", "Nodes: {nodes}, Edges: {edges}")
.replace("{nodes}", String(nodeCount))
.replace("{edges}", String(edgeCount))}
</Typography>
</Paper>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { DragEvent } from "react";
import { Box, Chip, Paper, Stack, Tooltip, Typography } from "@mui/material";
import { WorkflowPluginDefinition } from "../../lib/types";
type DraggablePaletteCardProps = {
id: string;
plugin: WorkflowPluginDefinition;
t: (key: string, fallback?: string) => string;
};
export default function DraggablePaletteCard({ id, plugin, t }: DraggablePaletteCardProps) {
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
event.dataTransfer.setData("application/reactflow", id);
event.dataTransfer.effectAllowed = "move";
};
const label = plugin.label ? t(plugin.label, id) : id;
const description = plugin.description ? t(plugin.description, "") : "";
const tags = plugin.tags || [];
const category = plugin.category || "general";
return (
<Tooltip title={description || label} arrow placement="right">
<Paper
draggable
onDragStart={onDragStart}
sx={{
p: 1.5,
cursor: "grab",
transition: "all 0.2s",
"&:hover": {
boxShadow: 4,
backgroundColor: "var(--color-hover-bg)",
},
"&:active": {
cursor: "grabbing",
},
}}
>
<Stack spacing={0.5}>
<Stack direction="row" alignItems="center" spacing={1}>
<Chip
label={category}
size="small"
sx={{
fontSize: "0.65rem",
height: 18,
backgroundColor: "var(--color-accent)",
}}
/>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{label}
</Typography>
</Stack>
{description && (
<Typography
variant="caption"
color="text.secondary"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{description}
</Typography>
)}
{tags.length > 0 && (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{tags.slice(0, 3).map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
variant="outlined"
sx={{
fontSize: "0.6rem",
height: 16,
}}
/>
))}
{tags.length > 3 && (
<Typography variant="caption" color="text.secondary">
+{tags.length - 3}
</Typography>
)}
</Box>
)}
</Stack>
</Paper>
</Tooltip>
);
}

View File

@@ -0,0 +1,13 @@
import { Paper, Typography } from "@mui/material";
type ErrorStateProps = {
message: string;
};
export default function ErrorState({ message }: ErrorStateProps) {
return (
<Paper sx={{ p: 3, backgroundColor: "var(--color-panel-bg)" }}>
<Typography color="error">{message}</Typography>
</Paper>
);
}

View File

@@ -0,0 +1,15 @@
import { Paper, Typography } from "@mui/material";
type LoadingStateProps = {
message: string;
};
export default function LoadingState({ message }: LoadingStateProps) {
return (
<Paper sx={{ p: 3, backgroundColor: "var(--color-panel-bg)" }}>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Paper>
);
}

View File

@@ -0,0 +1,44 @@
import { Stack, Typography } from "@mui/material";
type NodeBodyProps = {
label: string;
category?: string;
inputCount: number;
outputCount: number;
t: (key: string, fallback?: string) => string;
};
export default function NodeBody({ label, category, inputCount, outputCount, t }: NodeBodyProps) {
return (
<Stack spacing={0.5}>
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: "var(--color-text-strong)",
}}
>
{label}
</Typography>
{category && (
<Typography variant="caption" color="text.secondary">
{t(`ui.workflow.category.${category}`, category)}
</Typography>
)}
<Stack direction="row" spacing={1}>
{inputCount > 0 && (
<Typography variant="caption" color="text.secondary">
{t("ui.workflow.node.inputs", "In: {count}").replace("{count}", String(inputCount))}
</Typography>
)}
{outputCount > 0 && (
<Typography variant="caption" color="text.secondary">
{t("ui.workflow.node.outputs", "Out: {count}").replace("{count}", String(outputCount))}
</Typography>
)}
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,26 @@
import { Chip, IconButton, Stack } from "@mui/material";
import SettingsIcon from "@mui/icons-material/Settings";
type NodeHeaderProps = {
type: string;
onSettings?: () => void;
};
export default function NodeHeader({ type, onSettings }: NodeHeaderProps) {
return (
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Chip
label={type}
size="small"
sx={{
fontSize: "0.7rem",
height: 20,
backgroundColor: "var(--color-accent)",
}}
/>
<IconButton size="small" sx={{ p: 0.5 }} onClick={onSettings}>
<SettingsIcon fontSize="small" />
</IconButton>
</Stack>
);
}

View File

@@ -0,0 +1,32 @@
import { Handle, Position } from "reactflow";
import { WorkflowPluginPort } from "../../lib/types";
type NodePortsProps = {
ports: WorkflowPluginPort[];
type: "input" | "output";
};
export default function NodePorts({ ports, type }: NodePortsProps) {
const isInput = type === "input";
const position = isInput ? Position.Left : Position.Right;
const handleType = isInput ? "target" : "source";
return (
<>
{ports.map((port, index) => (
<Handle
key={`${type}-${port.key}`}
type={handleType}
position={position}
id={port.key}
style={{
top: `${((index + 1) * 100) / (ports.length + 1)}%`,
width: 10,
height: 10,
backgroundColor: port.required ? "#f44336" : isInput ? "#4caf50" : "#2196f3",
}}
/>
))}
</>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { Paper } from "@mui/material";
import { useWorkflowGraph } from "../../hooks/useWorkflowGraph";
import { useWorkflowPlugins } from "../../hooks/useWorkflowPlugins";
import { useTabNavigation } from "../../hooks/useTabNavigation";
import LoadingState from "./LoadingState";
import ErrorState from "./ErrorState";
import WorkflowBuilderHeader from "./WorkflowBuilderHeader";
import WorkflowBuilderTabs from "./WorkflowBuilderTabs";
import WorkflowBuilderContent from "./WorkflowBuilderContent";
type VisualWorkflowBuilderProps = {
t: (key: string, fallback?: string) => string;
};
export default function VisualWorkflowBuilder({ t }: VisualWorkflowBuilderProps) {
const { graph, loading: graphLoading, error: graphError } = useWorkflowGraph();
const { plugins, loading: pluginsLoading, error: pluginsError } = useWorkflowPlugins();
const { selectedTab, handleTabChange } = useTabNavigation(0);
const error = graphError || pluginsError;
const loading = graphLoading || pluginsLoading;
if (error) {
return <ErrorState message={error} />;
}
if (loading || !graph) {
return <LoadingState message={t("ui.workflow.canvas.loading", "Loading workflow canvas...")} />;
}
return (
<Paper sx={{ p: 3, backgroundColor: "var(--color-panel-bg)" }}>
<WorkflowBuilderHeader t={t} />
<WorkflowBuilderTabs value={selectedTab} onChange={handleTabChange} t={t} />
<WorkflowBuilderContent selectedTab={selectedTab} graph={graph} plugins={plugins} t={t} />
</Paper>
);
}

View File

@@ -0,0 +1,31 @@
import { Box, Stack } from "@mui/material";
import { WorkflowGraph, WorkflowPluginMap } from "../../lib/types";
import WorkflowCanvas from "./WorkflowCanvas";
import WorkflowInspector from "./WorkflowInspector";
import WorkflowPalette from "./WorkflowPalette";
type WorkflowBuilderContentProps = {
selectedTab: number;
graph: WorkflowGraph;
plugins: WorkflowPluginMap;
t: (key: string, fallback?: string) => string;
};
export default function WorkflowBuilderContent({
selectedTab,
graph,
plugins,
t,
}: WorkflowBuilderContentProps) {
return (
<Stack direction="row" spacing={2}>
<Box sx={{ flex: 1 }}>
{selectedTab === 0 && <WorkflowCanvas graph={graph} plugins={plugins} t={t} />}
{selectedTab === 1 && <WorkflowInspector t={t} />}
</Box>
<Box sx={{ width: 300 }}>
<WorkflowPalette t={t} />
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,18 @@
import { Typography } from "@mui/material";
type WorkflowBuilderHeaderProps = {
t: (key: string, fallback?: string) => string;
};
export default function WorkflowBuilderHeader({ t }: WorkflowBuilderHeaderProps) {
return (
<>
<Typography variant="h6" gutterBottom>
{t("ui.workflow.canvas.title", "Visual Workflow Builder")}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mb: 2 }}>
{t("ui.workflow.canvas.subtitle", "Design workflows with an n8n-style visual canvas")}
</Typography>
</>
);
}

View File

@@ -0,0 +1,16 @@
import { Tab, Tabs } from "@mui/material";
type WorkflowBuilderTabsProps = {
value: number;
onChange: (event: unknown, value: number) => void;
t: (key: string, fallback?: string) => string;
};
export default function WorkflowBuilderTabs({ value, onChange, t }: WorkflowBuilderTabsProps) {
return (
<Tabs value={value} onChange={onChange} sx={{ mb: 2 }}>
<Tab label={t("ui.workflow.canvas.tab.canvas", "Canvas")} />
<Tab label={t("ui.workflow.canvas.tab.inspector", "Inspector")} />
</Tabs>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import ReactFlow, {
Background,
BackgroundVariant,
Controls,
MiniMap,
Panel,
ReactFlowProvider,
} from "reactflow";
import "reactflow/dist/style.css";
import { Box } from "@mui/material";
import { WorkflowGraph, WorkflowPluginMap, Node, Edge } from "../../lib/types";
import WorkflowNode from "./WorkflowNode";
import CanvasInfoPanel from "./CanvasInfoPanel";
import CanvasHintPanel from "./CanvasHintPanel";
import { useWorkflowCanvas } from "../../hooks/useWorkflowCanvas";
const nodeTypes = {
workflow: WorkflowNode,
};
type WorkflowCanvasProps = {
graph: WorkflowGraph;
plugins: WorkflowPluginMap;
onNodesChange?: (nodes: Node[]) => void;
onEdgesChange?: (edges: Edge[]) => void;
t: (key: string, fallback?: string) => string;
};
function WorkflowCanvasInner(props: WorkflowCanvasProps) {
const { graph, plugins, onNodesChange, onEdgesChange, t } = props;
const {
nodes,
edges,
onNodesChangeInternal,
onEdgesChangeInternal,
onConnect,
onDragOver,
onDrop,
reactFlowWrapper,
} = useWorkflowCanvas(graph, plugins, t, onNodesChange, onEdgesChange);
return (
<Box
ref={reactFlowWrapper}
sx={{ width: "100%", height: "600px", position: "relative" }}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeInternal}
onEdgesChange={onEdgesChangeInternal}
onConnect={onConnect}
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-left"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls />
<MiniMap
nodeStrokeWidth={3}
zoomable
pannable
style={{
backgroundColor: "var(--color-panel-bg)",
border: "1px solid var(--color-border-muted)",
}}
/>
<Panel position="top-left">
<CanvasInfoPanel nodeCount={nodes.length} edgeCount={edges.length} t={t} />
</Panel>
<Panel position="top-right">
<CanvasHintPanel t={t} />
</Panel>
</ReactFlow>
</Box>
);
}
export default function WorkflowCanvas(props: WorkflowCanvasProps) {
return (
<ReactFlowProvider>
<WorkflowCanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { Paper, Stack, Typography } from "@mui/material";
type WorkflowInspectorProps = {
t: (key: string, fallback?: string) => string;
};
export default function WorkflowInspector({ t }: WorkflowInspectorProps) {
return (
<Paper sx={{ p: 2, minHeight: 400, backgroundColor: "var(--color-panel-alt)" }}>
<Typography variant="subtitle1" gutterBottom>
{t("ui.workflow.inspector.title", "Inspector")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t(
"ui.workflow.inspector.empty",
"Select a node or edge to view and edit properties"
)}
</Typography>
<Stack spacing={2} sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
{t("ui.workflow.inspector.hint", "Node properties will appear here when selected")}
</Typography>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { memo } from "react";
import { NodeProps } from "reactflow";
import { Paper, Stack } from "@mui/material";
import { WorkflowPluginDefinition } from "../../lib/types";
import NodeHeader from "./NodeHeader";
import NodeBody from "./NodeBody";
import NodePorts from "./NodePorts";
export type WorkflowNodeData = {
label: string;
type: string;
inputs: Record<string, unknown>;
outputs: Record<string, unknown>;
plugin: WorkflowPluginDefinition;
t: (key: string, fallback?: string) => string;
};
function WorkflowNode({ data, selected }: NodeProps<WorkflowNodeData>) {
const { label, type, plugin, t } = data;
const inputPorts = plugin.inputs || [];
const outputPorts = plugin.outputs || [];
return (
<Paper
elevation={selected ? 8 : 2}
sx={{
minWidth: 200,
minHeight: 80,
border: selected ? "2px solid #1976d2" : "1px solid var(--color-border-muted)",
backgroundColor: "var(--color-panel-bg)",
transition: "all 0.2s",
"&:hover": {
boxShadow: 4,
},
}}
>
<NodePorts ports={inputPorts} type="input" />
<Stack spacing={1} sx={{ p: 1.5 }}>
<NodeHeader type={type} />
<NodeBody
label={label}
category={plugin.category}
inputCount={inputPorts.length}
outputCount={outputPorts.length}
t={t}
/>
</Stack>
<NodePorts ports={outputPorts} type="output" />
</Paper>
);
}
export default memo(WorkflowNode);

View File

@@ -1,47 +1,26 @@
import { useEffect, useMemo, useState } from "react";
import { Divider, Paper, Stack, TextField, Typography } from "@mui/material";
import { fetchWorkflowPlugins } from "../../lib/api";
import { WorkflowPluginMap } from "../../lib/types";
import WorkflowPaletteCard from "./WorkflowPaletteCard";
import { useWorkflowPlugins } from "../../hooks/useWorkflowPlugins";
import { usePluginSearch } from "../../hooks/usePluginSearch";
import DraggablePaletteCard from "./DraggablePaletteCard";
type WorkflowPaletteProps = {
t: (key: string, fallback?: string) => string;
};
export default function WorkflowPalette({ t }: WorkflowPaletteProps) {
const [plugins, setPlugins] = useState<WorkflowPluginMap>({});
const [search, setSearch] = useState("");
const [error, setError] = useState("");
const { plugins, loading, error } = useWorkflowPlugins();
const { search, setSearch, filteredPlugins } = usePluginSearch(plugins, t);
useEffect(() => {
let alive = true;
void fetchWorkflowPlugins()
.then((data) => {
if (alive) {
setPlugins(data);
}
})
.catch((err) => {
if (alive) {
setError(String(err));
}
});
return () => {
alive = false;
};
}, []);
const entries = useMemo(() => {
const query = search.trim().toLowerCase();
return Object.entries(plugins).filter(([id, plugin]) => {
const label = plugin.label ? t(plugin.label, id) : id;
return !query || `${id} ${label}`.toLowerCase().includes(query);
});
}, [plugins, search, t]);
const isLoading = entries.length === 0 && !search && !error;
const hasQuery = search.trim().length > 0;
if (error) {
return (
<Paper sx={{ p: 2, backgroundColor: "var(--color-panel-alt)" }}>
<Typography color="error">{error}</Typography>
</Paper>
);
}
return (
<Paper sx={{ p: 2, backgroundColor: "var(--color-panel-alt)" }}>
<Typography variant="subtitle1" gutterBottom>
@@ -55,20 +34,18 @@ export default function WorkflowPalette({ t }: WorkflowPaletteProps) {
fullWidth
sx={{ mb: 2 }}
/>
{error ? (
<Typography color="error">{error}</Typography>
) : isLoading ? (
{loading ? (
<Typography variant="caption" color="text.secondary">
{t("ui.workflow.palette.loading", "Loading node library…")}
</Typography>
) : entries.length === 0 && hasQuery ? (
) : filteredPlugins.length === 0 && hasQuery ? (
<Typography variant="caption" color="text.secondary">
{`${t("ui.workflow.palette.empty", "No matching nodes.")} "${search.trim()}"`}
</Typography>
) : (
<Stack spacing={1} divider={<Divider sx={{ borderColor: "var(--color-divider)" }} />}>
{entries.map(([id, plugin]) => (
<WorkflowPaletteCard key={id} id={id} plugin={plugin} t={t} />
{filteredPlugins.map(([id, plugin]) => (
<DraggablePaletteCard key={id} id={id} plugin={plugin} t={t} />
))}
</Stack>
)}

View File

@@ -0,0 +1,54 @@
import { useCallback, DragEvent, useRef } from "react";
import { Node, useReactFlow } from "reactflow";
import { WorkflowPluginMap } from "../lib/types";
export function useCanvasDragDrop(
plugins: WorkflowPluginMap,
t: (key: string, fallback?: string) => string,
onNodeAdd: (node: Node) => void
) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { project } = useReactFlow();
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type || !reactFlowWrapper.current) {
return;
}
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
const position = project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
});
const plugin = plugins[type] || {};
const newNode: Node = {
id: `node-${Date.now()}`,
type: "workflow",
position,
data: {
label: plugin.label ? t(plugin.label, type) : type,
type,
inputs: {},
outputs: {},
plugin,
t,
},
};
onNodeAdd(newNode);
},
[plugins, project, t, onNodeAdd]
);
return { onDragOver, onDrop, reactFlowWrapper };
}

View File

@@ -0,0 +1,36 @@
import { useCallback, useEffect } from "react";
import { Edge, Connection, addEdge, useEdgesState } from "reactflow";
import { WorkflowGraph } from "../lib/types";
export function useCanvasEdges(
graph: WorkflowGraph,
onEdgesChange?: (edges: Edge[]) => void
) {
const [edges, setEdges, onEdgesChangeInternal] = useEdgesState([]);
useEffect(() => {
const flowEdges: Edge[] = graph.edges.map((edge, index) => ({
id: `e-${edge.from}-${edge.to}-${edge.var}-${index}`,
source: edge.from,
target: edge.to,
label: edge.var,
type: "smoothstep",
animated: false,
}));
setEdges(flowEdges);
}, [graph, setEdges]);
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) => addEdge({ ...connection, type: "smoothstep" }, eds));
},
[setEdges]
);
useEffect(() => {
if (onEdgesChange) onEdgesChange(edges);
}, [edges, onEdgesChange]);
return { edges, onConnect, onEdgesChangeInternal };
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { Node, useNodesState } from "reactflow";
import { WorkflowGraph, WorkflowPluginMap } from "../lib/types";
export function useCanvasNodes(
graph: WorkflowGraph,
plugins: WorkflowPluginMap,
t: (key: string, fallback?: string) => string,
onNodesChange?: (nodes: Node[]) => void
) {
const [nodes, setNodes, onNodesChangeInternal] = useNodesState([]);
useEffect(() => {
const flowNodes: Node[] = graph.nodes.map((node, index) => {
const plugin = plugins[node.type] || {};
const x = (index % 5) * 250;
const y = Math.floor(index / 5) * 150;
return {
id: node.id,
type: "workflow",
position: { x, y },
data: {
label: plugin.label ? t(plugin.label, node.type) : node.type,
type: node.type,
inputs: node.inputs || {},
outputs: node.outputs || {},
plugin,
t,
},
};
});
setNodes(flowNodes);
}, [graph, plugins, t, setNodes]);
useEffect(() => {
if (onNodesChange) onNodesChange(nodes);
}, [nodes, onNodesChange]);
return { nodes, setNodes, onNodesChangeInternal };
}

View File

@@ -0,0 +1,19 @@
import { useMemo, useState } from "react";
import { WorkflowPluginMap } from "../lib/types";
export function usePluginSearch(plugins: WorkflowPluginMap, t: (key: string, fallback?: string) => string) {
const [search, setSearch] = useState("");
const filteredPlugins = useMemo(() => {
const query = search.trim().toLowerCase();
return Object.entries(plugins).filter(([id, plugin]) => {
const label = plugin.label ? t(plugin.label, id) : id;
const category = plugin.category || "";
const tags = (plugin.tags || []).join(" ");
const searchText = `${id} ${label} ${category} ${tags}`.toLowerCase();
return !query || searchText.includes(query);
});
}, [plugins, search, t]);
return { search, setSearch, filteredPlugins };
}

View File

@@ -0,0 +1,11 @@
import { useState, useCallback } from "react";
export function useTabNavigation(initialTab = 0) {
const [selectedTab, setSelectedTab] = useState(initialTab);
const handleTabChange = useCallback((_: unknown, newValue: number) => {
setSelectedTab(newValue);
}, []);
return { selectedTab, handleTabChange };
}

View File

@@ -0,0 +1,43 @@
import { useCallback } from "react";
import { Node, Edge } from "reactflow";
import { WorkflowGraph, WorkflowPluginMap } from "../../lib/types";
import { useCanvasNodes } from "./useCanvasNodes";
import { useCanvasEdges } from "./useCanvasEdges";
import { useCanvasDragDrop } from "./useCanvasDragDrop";
export function useWorkflowCanvas(
graph: WorkflowGraph,
plugins: WorkflowPluginMap,
t: (key: string, fallback?: string) => string,
onNodesChange?: (nodes: Node[]) => void,
onEdgesChange?: (edges: Edge[]) => void
) {
const { nodes, setNodes, onNodesChangeInternal } = useCanvasNodes(
graph,
plugins,
t,
onNodesChange
);
const { edges, onConnect, onEdgesChangeInternal } = useCanvasEdges(graph, onEdgesChange);
const handleNodeAdd = useCallback(
(node: Node) => {
setNodes((nds) => nds.concat(node));
},
[setNodes]
);
const { onDragOver, onDrop, reactFlowWrapper } = useCanvasDragDrop(plugins, t, handleNodeAdd);
return {
nodes,
edges,
onNodesChangeInternal,
onEdgesChangeInternal,
onConnect,
onDragOver,
onDrop,
reactFlowWrapper,
};
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import { fetchWorkflowGraph } from "../lib/api";
import { WorkflowGraph } from "../lib/types";
export function useWorkflowGraph() {
const [graph, setGraph] = useState<WorkflowGraph | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>("");
useEffect(() => {
let alive = true;
setLoading(true);
fetchWorkflowGraph()
.then((data) => {
if (alive) {
setGraph(data);
setError("");
}
})
.catch((err) => {
if (alive) {
setError(String(err));
}
})
.finally(() => {
if (alive) {
setLoading(false);
}
});
return () => {
alive = false;
};
}, []);
return { graph, loading, error };
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import { fetchWorkflowPlugins } from "../lib/api";
import { WorkflowPluginMap } from "../lib/types";
export function useWorkflowPlugins() {
const [plugins, setPlugins] = useState<WorkflowPluginMap>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>("");
useEffect(() => {
let alive = true;
setLoading(true);
fetchWorkflowPlugins()
.then((data) => {
if (alive) {
setPlugins(data);
setError("");
}
})
.catch((err) => {
if (alive) {
setError(String(err));
}
})
.finally(() => {
if (alive) {
setLoading(false);
}
});
return () => {
alive = false;
};
}, []);
return { plugins, loading, error };
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,10 @@
"dependencies": {
"next": "16.1.1",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"reactflow": "^11.11.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@emotion/react": "^11.12.0",
"@emotion/styled": "^11.12.0",
"@mui/icons-material": "^5.14.4",
@@ -30,8 +28,12 @@
"@storybook/addon-interactions": "^8.0.0",
"@storybook/react": "^8.0.0",
"@storybook/react-webpack5": "^8.0.0",
"@types/node": "^20",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"sass": "^1.95.0"
"sass": "^1.95.0",
"typescript": "^5.9.3"
}
}