Componentized the UI by isolating elements into individual JSX modules.

This commit is contained in:
Nicole Rappe 2025-04-14 19:48:15 -06:00
parent 00a2c83186
commit e1a359169e
4 changed files with 1209 additions and 1131 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,214 @@
// Flow_Editor.jsx
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactFlow, {
Background,
addEdge,
applyNodeChanges,
applyEdgeChanges,
useReactFlow
} from "reactflow";
import { Menu, MenuItem } from "@mui/material";
import {
Polyline as PolylineIcon,
DeleteForever as DeleteForeverIcon
} from "@mui/icons-material";
import "reactflow/dist/style.css";
import "./Borealis.css";
/**
* Single flow editor component.
*
* Props:
* - nodes
* - edges
* - setNodes
* - setEdges
* - nodeTypes
* - categorizedNodes (used to find node meta info on drop)
*/
export default function FlowEditor({
nodes,
edges,
setNodes,
setEdges,
nodeTypes,
categorizedNodes
}) {
const wrapperRef = useRef(null);
const { project } = useReactFlow();
const [contextMenu, setContextMenu] = useState(null);
const onDrop = useCallback(
(event) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
const bounds = wrapperRef.current.getBoundingClientRect();
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
const id = "node-" + Date.now();
// Find node definition in the categorizedNodes
const nodeMeta = Object.values(categorizedNodes)
.flat()
.find((n) => n.type === type);
const newNode = {
id: id,
type: type,
position: position,
data: {
label: nodeMeta?.label || type,
content: nodeMeta?.content
}
};
setNodes((nds) => [...nds, newNode]);
},
[project, setNodes, categorizedNodes]
);
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onConnect = useCallback(
(params) =>
setEdges((eds) =>
addEdge(
{
...params,
type: "smoothstep",
animated: true,
style: {
strokeDasharray: "6 3",
stroke: "#58a6ff"
}
},
eds
)
),
[setEdges]
);
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[setNodes]
);
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[setEdges]
);
const handleRightClick = (e, node) => {
e.preventDefault();
setContextMenu({
mouseX: e.clientX + 2,
mouseY: e.clientY - 6,
nodeId: node.id
});
};
const handleDisconnect = () => {
if (contextMenu?.nodeId) {
setEdges((eds) =>
eds.filter(
(e) =>
e.source !== contextMenu.nodeId &&
e.target !== contextMenu.nodeId
)
);
}
setContextMenu(null);
};
const handleRemoveNode = () => {
if (contextMenu?.nodeId) {
setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
setEdges((eds) =>
eds.filter(
(e) =>
e.source !== contextMenu.nodeId &&
e.target !== contextMenu.nodeId
)
);
}
setContextMenu(null);
};
useEffect(() => {
const nodeCountEl = document.getElementById("nodeCount");
if (nodeCountEl) {
nodeCountEl.innerText = nodes.length;
}
}, [nodes]);
return (
<div className="flow-editor-container" ref={wrapperRef}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeContextMenu={handleRightClick}
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
edgeOptions={{
type: "smoothstep",
animated: true,
style: {
strokeDasharray: "6 3",
stroke: "#58a6ff"
}
}}
proOptions={{ hideAttribution: true }}
>
<Background
variant="lines"
gap={65}
size={1}
color="rgba(255, 255, 255, 0.2)"
/>
</ReactFlow>
{/* Right-click node menu */}
<Menu
open={Boolean(contextMenu)}
onClose={() => setContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={
contextMenu
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
PaperProps={{
sx: {
bgcolor: "#1e1e1e",
color: "#fff",
fontSize: "13px"
}
}}
>
<MenuItem onClick={handleDisconnect}>
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Disconnect All Edges
</MenuItem>
<MenuItem onClick={handleRemoveNode}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Remove Node
</MenuItem>
</Menu>
</div>
);
}

View File

@ -0,0 +1,98 @@
// Flow_Tabs.jsx
import React from "react";
import { Box, Tabs, Tab } from "@mui/material";
import { Add as AddIcon } from "@mui/icons-material";
/**
* Renders the tab bar (including the "add tab" button).
*
* Props:
* - tabs (array of {id, tab_name, nodes, edges})
* - activeTabId (string)
* - onTabChange(newActiveTabId: string)
* - onAddTab()
* - onTabRightClick(evt: MouseEvent, tabId: string)
*/
export default function FlowTabs({
tabs,
activeTabId,
onTabChange,
onAddTab,
onTabRightClick
}) {
// Determine the currently active tab index
const activeIndex = (() => {
const idx = tabs.findIndex((t) => t.id === activeTabId);
return idx >= 0 ? idx : 0;
})();
// Handle tab clicks
const handleChange = (event, newValue) => {
if (newValue === "__addtab__") {
// The "plus" tab
onAddTab();
} else {
// normal tab index
const newTab = tabs[newValue];
if (newTab) {
onTabChange(newTab.id);
}
}
};
return (
<Box
sx={{
display: "flex",
alignItems: "center",
backgroundColor: "#232323",
borderBottom: "1px solid #333",
height: "36px"
}}
>
<Tabs
value={activeIndex}
onChange={handleChange}
variant="scrollable"
scrollButtons="auto"
textColor="inherit"
TabIndicatorProps={{
style: { backgroundColor: "#58a6ff" }
}}
sx={{
minHeight: "36px",
height: "36px",
flexGrow: 1
}}
>
{tabs.map((tab, index) => (
<Tab
key={tab.id}
label={tab.tab_name}
value={index}
onContextMenu={(evt) => onTabRightClick(evt, tab.id)}
sx={{
minHeight: "36px",
height: "36px",
textTransform: "none",
backgroundColor: tab.id === activeTabId ? "#2C2C2C" : "transparent",
color: "#58a6ff"
}}
/>
))}
{/* The "plus" tab has a special value */}
<Tab
icon={<AddIcon />}
value="__addtab__"
sx={{
minHeight: "36px",
height: "36px",
color: "#58a6ff",
textTransform: "none"
}}
/>
</Tabs>
</Box>
);
}

View File

@ -0,0 +1,265 @@
// Node_Sidebar.jsx
import React from "react";
import {
Accordion,
AccordionSummary,
AccordionDetails,
Button,
Divider,
Tooltip,
Typography
} from "@mui/material";
import {
ExpandMore as ExpandMoreIcon,
Save as SaveIcon,
FileOpen as FileOpenIcon,
DeleteForever as DeleteForeverIcon,
DragIndicator as DragIndicatorIcon,
Polyline as PolylineIcon
} from "@mui/icons-material";
/**
* Left sidebar for managing workflows and node categories.
*
* Props:
* - categorizedNodes (object of arrays, e.g. { "Category": [{...}, ...], ... })
* - handleExportFlow() => void
* - handleImportFlow() => void
* - handleOpenCloseAllDialog() => void
* - fileInputRef (ref to hidden file input)
* - onFileInputChange(event) => void
*/
export default function NodeSidebar({
categorizedNodes,
handleExportFlow,
handleImportFlow,
handleOpenCloseAllDialog,
fileInputRef,
onFileInputChange
}) {
return (
<div
style={{
width: 320,
backgroundColor: "#121212",
borderRight: "1px solid #333",
overflowY: "auto"
}}
>
<Accordion
defaultExpanded
square
disableGutters
sx={{
"&:before": { display: "none" },
margin: 0,
border: 0
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
backgroundColor: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": {
margin: 0
}
}}
>
<Typography
align="left"
sx={{
fontSize: "0.9rem",
color: "#0475c2"
}}
>
<b>Workflows</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<Button
fullWidth
startIcon={<SaveIcon />}
sx={{
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "flex-start",
pl: 2,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
}}
onClick={handleExportFlow}
>
Export Current Flow
</Button>
<Button
fullWidth
startIcon={<FileOpenIcon />}
sx={{
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "flex-start",
pl: 2,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
}}
onClick={handleImportFlow}
>
Import Flow
</Button>
<Button
fullWidth
startIcon={<DeleteForeverIcon />}
sx={{
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "flex-start",
pl: 2,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
}}
onClick={handleOpenCloseAllDialog}
>
Close All Flows
</Button>
</AccordionDetails>
</Accordion>
<Accordion
defaultExpanded
square
disableGutters
sx={{
"&:before": { display: "none" },
margin: 0,
border: 0
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
backgroundColor: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": {
margin: 0
}
}}
>
<Typography
align="left"
sx={{
fontSize: "0.9rem",
color: "#0475c2"
}}
>
<b>Nodes</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
{Object.entries(categorizedNodes).map(([category, items]) => (
<div key={category} style={{ marginBottom: 0, backgroundColor: "#232323" }}>
<Divider
sx={{
bgcolor: "transparent",
px: 2,
py: 0.75,
display: "flex",
justifyContent: "center",
borderColor: "#333"
}}
variant="fullWidth"
>
<Typography
variant="caption"
sx={{
color: "#888",
fontSize: "0.75rem"
}}
>
{category}
</Typography>
</Divider>
{items.map((nodeDef) => (
<Tooltip
key={`${category}-${nodeDef.type}`}
title={
<span
style={{
whiteSpace: "pre-line",
wordWrap: "break-word",
maxWidth: 220
}}
>
{nodeDef.description || "Drag & Drop into Editor"}
</span>
}
placement="right"
arrow
>
<Button
fullWidth
sx={{
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "space-between",
pl: 2,
pr: 1,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
}}
draggable
onDragStart={(event) => {
event.dataTransfer.setData("application/reactflow", nodeDef.type);
event.dataTransfer.effectAllowed = "move";
}}
startIcon={
<DragIndicatorIcon
sx={{
color: "#666",
fontSize: 18
}}
/>
}
>
<span style={{ flexGrow: 1, textAlign: "left" }}>
{nodeDef.label}
</span>
<PolylineIcon
sx={{
color: "#58a6ff",
fontSize: 18,
ml: 1
}}
/>
</Button>
</Tooltip>
))}
</div>
))}
</AccordionDetails>
</Accordion>
{/* Hidden file input fallback for older browsers */}
<input
type="file"
accept=".json,application/json"
style={{ display: "none" }}
ref={fileInputRef}
onChange={onFileInputChange}
/>
</div>
);
}