feat: replace workflow list with tree view

This commit is contained in:
2025-08-09 23:55:02 -06:00
parent 9d90a7223b
commit ccb07a2ac6
4 changed files with 354 additions and 197 deletions

View File

@@ -12,6 +12,7 @@
"@emotion/styled": "11.14.0", "@emotion/styled": "11.14.0",
"@mui/icons-material": "7.0.2", "@mui/icons-material": "7.0.2",
"@mui/material": "7.0.2", "@mui/material": "7.0.2",
"@mui/x-tree-view": "8.10.0",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"react": "19.1.0", "react": "19.1.0",

View File

@@ -132,6 +132,39 @@ export function RenameWorkflowDialog({ open, value, onChange, onCancel, onSave }
); );
} }
export function RenameFolderDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Folder Name</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Folder Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": { borderColor: "#444" },
"&:hover fieldset": { borderColor: "#666" }
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
export function DeleteDeviceDialog({ open, onCancel, onConfirm }) { export function DeleteDeviceDialog({ open, onCancel, onConfirm }) {
return ( return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}> <Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>

View File

@@ -1,169 +1,263 @@
import React, { useState, useMemo, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { import {
Paper, Paper,
Box, Box,
Typography, Typography,
Button, Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
IconButton, IconButton,
Menu, Menu,
MenuItem MenuItem
} from "@mui/material"; } from "@mui/material";
import { PlayCircle as PlayCircleIcon, MoreVert as MoreVertIcon } from "@mui/icons-material"; import {
import { RenameWorkflowDialog } from "./Dialogs"; PlayCircle as PlayCircleIcon,
MoreVert as MoreVertIcon,
Folder as FolderIcon,
Description as DescriptionIcon,
CreateNewFolder as CreateNewFolderIcon
} from "@mui/icons-material";
import {
SimpleTreeView,
TreeItem,
useTreeViewApiRef,
useTreeViewDragAndDrop
} from "@mui/x-tree-view";
import { RenameWorkflowDialog, RenameFolderDialog } from "./Dialogs";
function formatDateTime(dateString) { function buildTree(workflows) {
if (!dateString) return ""; const map = {};
const date = new Date(dateString); const root = [];
if (isNaN(date)) return ""; (workflows || []).forEach((w) => {
const day = String(date.getDate()).padStart(2, "0"); const parts = (w.rel_path || "").split("/");
const month = String(date.getMonth() + 1).padStart(2, "0"); let children = root;
const year = date.getFullYear(); let parentPath = "";
let hours = date.getHours(); parts.forEach((part, idx) => {
const minutes = String(date.getMinutes()).padStart(2, "0"); const path = parentPath ? `${parentPath}/${part}` : part;
const ampm = hours >= 12 ? "PM" : "AM"; const isFile = idx === parts.length - 1;
hours = hours % 12 || 12; let node = children.find((n) => n.id === path);
return `${day}/${month}/${year} ${hours}:${minutes}${ampm}`; if (!node) {
node = {
id: path,
label: isFile
? w.tab_name && w.tab_name.trim().length > 0
? w.tab_name.trim()
: w.file_name
: part,
path,
isFolder: !isFile,
fileName: w.file_name,
workflow: isFile ? w : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
return { root, map };
} }
export default function WorkflowList({ onOpenWorkflow }) { export default function WorkflowList({ onOpenWorkflow }) {
const [rows, setRows] = useState([]); const [tree, setTree] = useState([]);
const [orderBy, setOrderBy] = useState("name"); const [nodeMap, setNodeMap] = useState({});
const [order, setOrder] = useState("asc");
const [menuAnchor, setMenuAnchor] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null);
const [selected, setSelected] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [renameOpen, setRenameOpen] = useState(false);
const [renameFolderOpen, setRenameFolderOpen] = useState(false);
const apiRef = useTreeViewApiRef();
const loadRows = useCallback(async () => { useTreeViewDragAndDrop(apiRef, {
onItemDrop: async (params) => {
const source = nodeMap[params.dragItemId];
const target = nodeMap[params.dropTargetId];
if (source && target && !source.isFolder && target.isFolder) {
const newPath = `${target.path}/${source.fileName}`;
try {
await fetch("/api/storage/move_workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: source.path, new_path: newPath })
});
loadTree();
} catch (err) {
console.error("Failed to move workflow:", err);
}
}
}
});
const loadTree = useCallback(async () => {
try { try {
const resp = await fetch("/api/storage/load_workflows"); const resp = await fetch("/api/storage/load_workflows");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json(); const data = await resp.json();
const mapped = (data.workflows || []).map((w) => ({ const { root, map } = buildTree(data.workflows || []);
...w, setTree(root);
name: w.tab_name && w.tab_name.trim() ? w.tab_name.trim() : w.file_name, setNodeMap(map);
description: "",
category: "",
lastEdited: w.last_edited,
lastEditedEpoch: w.last_edited_epoch
}));
setRows(mapped);
} catch (err) { } catch (err) {
console.error("Failed to load workflows:", err); console.error("Failed to load workflows:", err);
setRows([]); setTree([]);
setNodeMap({});
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
loadRows(); loadTree();
}, [loadRows]); }, [loadTree]);
const handleSort = (col) => { const openMenu = (e, node) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else {
setOrderBy(col);
setOrder("asc");
}
};
const sorted = useMemo(() => {
const dir = order === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
if (orderBy === "lastEdited" || orderBy === "lastEditedEpoch") {
const A = Number(a.lastEditedEpoch || 0);
const B = Number(b.lastEditedEpoch || 0);
return (A - B) * dir;
}
const A = a[orderBy] || "";
const B = b[orderBy] || "";
return String(A).localeCompare(String(B)) * dir;
});
}, [rows, orderBy, order]);
const handleNewWorkflow = () => {
if (onOpenWorkflow) {
onOpenWorkflow();
}
};
const handleRowClick = (workflow) => {
if (onOpenWorkflow) {
onOpenWorkflow(workflow);
}
};
const openMenu = (e, row) => {
e.stopPropagation(); e.stopPropagation();
setMenuAnchor(e.currentTarget); setMenuAnchor(e.currentTarget);
setSelected(row); setSelectedNode(node);
}; };
const closeMenu = () => setMenuAnchor(null); const closeMenu = () => setMenuAnchor(null);
const startRename = () => { const handleRename = () => {
closeMenu(); closeMenu();
if (selected) { if (!selectedNode) return;
const initial = selected.tab_name && selected.tab_name.trim().length > 0 setRenameValue(selectedNode.label);
? selected.tab_name.trim() if (selectedNode.isFolder) setRenameFolderOpen(true);
: selected.file_name.replace(/\.json$/i, ""); else setRenameOpen(true);
setRenameValue(initial); };
setRenameOpen(true);
const handleDeleteWorkflow = async () => {
closeMenu();
if (!selectedNode) return;
try {
await fetch("/api/storage/delete_workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selectedNode.path })
});
loadTree();
} catch (err) {
console.error("Failed to delete workflow:", err);
} }
}; };
const handleRenameSave = async () => { const handleCreateFolder = () => {
if (!selected) return; closeMenu();
setRenameValue("");
setRenameFolderOpen(true);
};
const saveRenameWorkflow = async () => {
if (!selectedNode) return;
try { try {
await fetch("/api/storage/rename_workflow", { await fetch("/api/storage/rename_workflow", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selected.rel_path, new_name: renameValue }) body: JSON.stringify({ path: selectedNode.path, new_name: renameValue })
}); });
await loadRows(); loadTree();
} catch (err) { } catch (err) {
console.error("Failed to rename workflow:", err); console.error("Failed to rename workflow:", err);
} }
setRenameOpen(false); setRenameOpen(false);
setSelected(null);
}; };
const renderNameCell = (r) => { const saveRenameFolder = async () => {
const hasPrefix = r.breadcrumb_prefix && r.breadcrumb_prefix.length > 0; try {
const primary = r.tab_name && r.tab_name.trim().length > 0 ? r.tab_name.trim() : r.file_name; if (selectedNode && selectedNode.isFolder) {
return ( await fetch("/api/storage/rename_folder", {
<Box component="span"> method: "POST",
{hasPrefix && ( headers: { "Content-Type": "application/json" },
<Typography body: JSON.stringify({ path: selectedNode.path, new_name: renameValue })
component="span" });
sx={{ color: "#6b6b6b", mr: 0.5 }} } else {
> const basePath = selectedNode ? selectedNode.path : "";
{r.breadcrumb_prefix} {">"}{" "} const newPath = basePath ? `${basePath}/${renameValue}` : renameValue;
</Typography> await fetch("/api/storage/create_folder", {
)} method: "POST",
<Typography component="span" sx={{ color: "#e6edf3" }}> headers: { "Content-Type": "application/json" },
{primary} body: JSON.stringify({ path: newPath })
</Typography> });
</Box> }
); loadTree();
} catch (err) {
console.error("Folder operation failed:", err);
}
setRenameFolderOpen(false);
}; };
const handleNodeSelect = (event, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder && onOpenWorkflow) {
onOpenWorkflow(node.workflow);
}
};
const renderItems = (nodes) =>
nodes.map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{n.isFolder ? (
<FolderIcon sx={{ mr: 1, color: "#ffd54f" }} />
) : (
<DescriptionIcon sx={{ mr: 1, color: "#90caf9" }} />
)}
<Typography sx={{ flexGrow: 1, color: "#e6edf3" }}>{n.label}</Typography>
<IconButton
size="small"
onClick={(e) => openMenu(e, n)}
sx={{ color: "#ccc" }}
>
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
}
>
{n.children && n.children.length > 0 ? renderItems(n.children) : null}
</TreeItem>
));
return ( return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}> <Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box sx={{ p: 2, pb: 1, display: "flex", justifyContent: "space-between", alignItems: "center" }}> <Box
sx={{
p: 2,
pb: 1,
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Box> <Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}> <Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
Workflows Workflows
</Typography> </Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}> <Typography variant="body2" sx={{ color: "#aaa" }}>
List of available workflows. Manage workflow folders and files.
</Typography> </Typography>
</Box> </Box>
<Box>
<Button
startIcon={<CreateNewFolderIcon />}
sx={{
mr: 1,
color: "#58a6ff",
borderColor: "#58a6ff",
textTransform: "none",
border: "1px solid #58a6ff",
backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" }
}}
onClick={() => {
setSelectedNode(null);
setRenameValue("");
setRenameFolderOpen(true);
}}
>
New Folder
</Button>
<Button <Button
startIcon={<PlayCircleIcon />} startIcon={<PlayCircleIcon />}
sx={{ sx={{
@@ -174,100 +268,50 @@ export default function WorkflowList({ onOpenWorkflow }) {
backgroundColor: "#1e1e1e", backgroundColor: "#1e1e1e",
"&:hover": { backgroundColor: "#1b1b1b" } "&:hover": { backgroundColor: "#1b1b1b" }
}} }}
onClick={handleNewWorkflow} onClick={() => onOpenWorkflow && onOpenWorkflow()}
> >
New Workflow New Workflow
</Button> </Button>
</Box> </Box>
<Table size="small" sx={{ minWidth: 680 }}> </Box>
<TableHead> <Box sx={{ p: 2 }}>
<TableRow> <SimpleTreeView
<TableCell sortDirection={orderBy === "name" ? order : false}> sx={{ color: "#e6edf3" }}
<TableSortLabel onNodeSelect={handleNodeSelect}
active={orderBy === "name"} apiRef={apiRef}
direction={orderBy === "name" ? order : "asc"}
onClick={() => handleSort("name")}
> >
Name {renderItems(tree)}
</TableSortLabel> </SimpleTreeView>
</TableCell> </Box>
<TableCell sortDirection={orderBy === "description" ? order : false}>
<TableSortLabel
active={orderBy === "description"}
direction={orderBy === "description" ? order : "asc"}
onClick={() => handleSort("description")}
>
Description
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "category" ? order : false}>
<TableSortLabel
active={orderBy === "category"}
direction={orderBy === "category" ? order : "asc"}
onClick={() => handleSort("category")}
>
Category
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "lastEdited" ? order : false}>
<TableSortLabel
active={orderBy === "lastEdited"}
direction={orderBy === "lastEdited" ? order : "asc"}
onClick={() => handleSort("lastEdited")}
>
Last Edited
</TableSortLabel>
</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{sorted.map((r, i) => (
<TableRow
key={i}
hover
sx={{ cursor: "pointer" }}
onClick={() => handleRowClick(r)}
>
<TableCell>{renderNameCell(r)}</TableCell>
<TableCell>{r.description}</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell>{formatDateTime(r.lastEdited)}</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={(e) => openMenu(e, r)}
sx={{ color: "#ccc" }}
>
<MoreVertIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={5} sx={{ color: "#888" }}>
No workflows found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Menu <Menu
anchorEl={menuAnchor} anchorEl={menuAnchor}
open={Boolean(menuAnchor)} open={Boolean(menuAnchor)}
onClose={closeMenu} onClose={closeMenu}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
> >
<MenuItem onClick={startRename}>Rename</MenuItem> {selectedNode?.isFolder && (
<MenuItem onClick={handleCreateFolder}>New Folder</MenuItem>
)}
<MenuItem onClick={handleRename}>Rename</MenuItem>
{!selectedNode?.isFolder && (
<MenuItem onClick={handleDeleteWorkflow}>Delete</MenuItem>
)}
</Menu> </Menu>
<RenameWorkflowDialog <RenameWorkflowDialog
open={renameOpen} open={renameOpen}
value={renameValue} value={renameValue}
onChange={setRenameValue} onChange={setRenameValue}
onCancel={() => setRenameOpen(false)} onCancel={() => setRenameOpen(false)}
onSave={handleRenameSave} onSave={saveRenameWorkflow}
/>
<RenameFolderDialog
open={renameFolderOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameFolderOpen(false)}
onSave={saveRenameFolder}
/> />
</Paper> </Paper>
); );
} }

View File

@@ -12,6 +12,7 @@ from flask_cors import CORS
import time import time
import os # To Read Production ReactJS Server Folder import os # To Read Production ReactJS Server Folder
import json # For reading workflow JSON files import json # For reading workflow JSON files
import shutil # For moving workflow files and folders
from typing import List, Dict from typing import List, Dict
# Borealis Python API Endpoints # Borealis Python API Endpoints
@@ -85,6 +86,84 @@ def ocr_endpoint():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# New storage management endpoints
@app.route("/api/storage/move_workflow", methods=["POST"])
def move_workflow():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
new_rel = (data.get("new_path") or "").strip()
workflows_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "Workflows")
)
old_abs = os.path.abspath(os.path.join(workflows_root, rel_path))
new_abs = os.path.abspath(os.path.join(workflows_root, new_rel))
if not old_abs.startswith(workflows_root) or not os.path.isfile(old_abs):
return jsonify({"error": "Workflow not found"}), 404
if not new_abs.startswith(workflows_root):
return jsonify({"error": "Invalid destination"}), 400
os.makedirs(os.path.dirname(new_abs), exist_ok=True)
try:
shutil.move(old_abs, new_abs)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/storage/delete_workflow", methods=["POST"])
def delete_workflow():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
workflows_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "Workflows")
)
abs_path = os.path.abspath(os.path.join(workflows_root, rel_path))
if not abs_path.startswith(workflows_root) or not os.path.isfile(abs_path):
return jsonify({"error": "Workflow not found"}), 404
try:
os.remove(abs_path)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/storage/create_folder", methods=["POST"])
def create_folder():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
workflows_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "Workflows")
)
abs_path = os.path.abspath(os.path.join(workflows_root, rel_path))
if not abs_path.startswith(workflows_root):
return jsonify({"error": "Invalid path"}), 400
try:
os.makedirs(abs_path, exist_ok=True)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/storage/rename_folder", methods=["POST"])
def rename_folder():
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip()
new_name = (data.get("new_name") or "").strip()
workflows_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "Workflows")
)
old_abs = os.path.abspath(os.path.join(workflows_root, rel_path))
if not old_abs.startswith(workflows_root) or not os.path.isdir(old_abs):
return jsonify({"error": "Folder not found"}), 404
if not new_name:
return jsonify({"error": "Invalid new_name"}), 400
new_abs = os.path.join(os.path.dirname(old_abs), new_name)
try:
os.rename(old_abs, new_abs)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
# --------------------------------------------- # ---------------------------------------------
# Borealis Storage API Endpoints # Borealis Storage API Endpoints
# --------------------------------------------- # ---------------------------------------------