mirror of
https://github.com/johndoe6345789/AutoMetabuilder.git
synced 2026-04-24 13:54:59 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
13
frontend/autometabuilder/components/workflow/ErrorState.tsx
Normal file
13
frontend/autometabuilder/components/workflow/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
44
frontend/autometabuilder/components/workflow/NodeBody.tsx
Normal file
44
frontend/autometabuilder/components/workflow/NodeBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/autometabuilder/components/workflow/NodeHeader.tsx
Normal file
26
frontend/autometabuilder/components/workflow/NodeHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/autometabuilder/components/workflow/NodePorts.tsx
Normal file
32
frontend/autometabuilder/components/workflow/NodePorts.tsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
54
frontend/autometabuilder/hooks/useCanvasDragDrop.ts
Normal file
54
frontend/autometabuilder/hooks/useCanvasDragDrop.ts
Normal 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 };
|
||||
}
|
||||
36
frontend/autometabuilder/hooks/useCanvasEdges.ts
Normal file
36
frontend/autometabuilder/hooks/useCanvasEdges.ts
Normal 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 };
|
||||
}
|
||||
42
frontend/autometabuilder/hooks/useCanvasNodes.ts
Normal file
42
frontend/autometabuilder/hooks/useCanvasNodes.ts
Normal 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 };
|
||||
}
|
||||
19
frontend/autometabuilder/hooks/usePluginSearch.ts
Normal file
19
frontend/autometabuilder/hooks/usePluginSearch.ts
Normal 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 };
|
||||
}
|
||||
11
frontend/autometabuilder/hooks/useTabNavigation.ts
Normal file
11
frontend/autometabuilder/hooks/useTabNavigation.ts
Normal 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 };
|
||||
}
|
||||
43
frontend/autometabuilder/hooks/useWorkflowCanvas.ts
Normal file
43
frontend/autometabuilder/hooks/useWorkflowCanvas.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
frontend/autometabuilder/hooks/useWorkflowGraph.ts
Normal file
38
frontend/autometabuilder/hooks/useWorkflowGraph.ts
Normal 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 };
|
||||
}
|
||||
38
frontend/autometabuilder/hooks/useWorkflowPlugins.ts
Normal file
38
frontend/autometabuilder/hooks/useWorkflowPlugins.ts
Normal 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 };
|
||||
}
|
||||
7227
frontend/package-lock.json
generated
7227
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user