Enable workflow import/export and rename

This commit is contained in:
2025-08-09 22:20:59 -06:00
parent 8eb676fd9f
commit 3b6f8bfbac
5 changed files with 281 additions and 35 deletions

View File

@@ -175,6 +175,86 @@ export default function App() {
setRenameDialogOpen(false);
};
const handleExportFlow = useCallback(() => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab) return;
const payload = {
tab_name: tab.tab_name,
nodes: tab.nodes,
edges: tab.edges
};
const fileName = `${tab.tab_name || "workflow"}.json`;
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}, [tabs, activeTabId]);
const handleImportFlow = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.value = null;
fileInputRef.current.click();
}
}, []);
const onFileInputChange = useCallback(
(e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
const newId = "flow_" + Date.now();
setTabs((prev) => [
...prev,
{
id: newId,
tab_name:
data.tab_name || data.name || file.name.replace(/\.json$/i, ""),
nodes: data.nodes || [],
edges: data.edges || []
}
]);
setActiveTabId(newId);
setCurrentPage("workflow-editor");
} catch (err) {
console.error("Failed to import workflow:", err);
}
};
reader.readAsText(file);
e.target.value = "";
},
[setTabs]
);
const handleSaveFlow = useCallback(async () => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab) return;
const name = window.prompt("Enter workflow name", tab.tab_name || "workflow");
if (!name) return;
const payload = {
name,
workflow: {
tab_name: tab.tab_name,
nodes: tab.nodes,
edges: tab.edges
}
};
try {
await fetch("/api/storage/save_workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} catch (err) {
console.error("Failed to save workflow:", err);
}
}, [tabs, activeTabId]);
const renderMainContent = () => {
switch (currentPage) {
case "devices":
@@ -230,11 +310,12 @@ export default function App() {
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
handleExportFlow={() => {}}
handleImportFlow={() => {}}
handleOpenCloseAllDialog={() => {}}
handleExportFlow={handleExportFlow}
handleImportFlow={handleImportFlow}
handleSaveFlow={handleSaveFlow}
handleOpenCloseAllDialog={() => setConfirmCloseOpen(true)}
fileInputRef={fileInputRef}
onFileInputChange={() => {}}
onFileInputChange={onFileInputChange}
/>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs

View File

@@ -95,6 +95,43 @@ export function RenameTabDialog({ open, value, onChange, onCancel, onSave }) {
);
}
export function RenameWorkflowDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Rename Workflow</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Workflow 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 }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>

View File

@@ -12,6 +12,7 @@ import {
} from "@mui/material";
import {
ExpandMore as ExpandMoreIcon,
SaveAlt as SaveAltIcon,
Save as SaveIcon,
FileOpen as FileOpenIcon,
DeleteForever as DeleteForeverIcon,
@@ -25,6 +26,7 @@ export default function NodeSidebar({
categorizedNodes,
handleExportFlow,
handleImportFlow,
handleSaveFlow,
handleOpenCloseAllDialog,
fileInputRef,
onFileInputChange
@@ -72,10 +74,15 @@ export default function NodeSidebar({
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<Tooltip title="Export Current Tab to a JSON File" placement="right" arrow>
<Button fullWidth startIcon={<SaveIcon />} onClick={handleExportFlow} sx={buttonStyle}>
<Button fullWidth startIcon={<SaveAltIcon />} onClick={handleExportFlow} sx={buttonStyle}>
Export Current Flow
</Button>
</Tooltip>
<Tooltip title="Save Current Flow to Workflows Folder" placement="right" arrow>
<Button fullWidth startIcon={<SaveIcon />} onClick={handleSaveFlow} sx={buttonStyle}>
Save Workflow
</Button>
</Tooltip>
<Tooltip title="Import JSON File into New Flow Tab" placement="right" arrow>
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
Import Flow

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import {
Paper,
Box,
@@ -9,9 +9,13 @@ import {
TableCell,
TableHead,
TableRow,
TableSortLabel
TableSortLabel,
IconButton,
Menu,
MenuItem
} from "@mui/material";
import { PlayCircle as PlayCircleIcon } from "@mui/icons-material";
import { PlayCircle as PlayCircleIcon, MoreVert as MoreVertIcon } from "@mui/icons-material";
import { RenameWorkflowDialog } from "./Dialogs";
function formatDateTime(dateString) {
if (!dateString) return "";
@@ -31,34 +35,34 @@ export default function WorkflowList({ onOpenWorkflow }) {
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("name");
const [order, setOrder] = useState("asc");
const [menuAnchor, setMenuAnchor] = useState(null);
const [selected, setSelected] = useState(null);
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState("");
const loadRows = useCallback(async () => {
try {
const resp = await fetch("/api/storage/load_workflows");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const mapped = (data.workflows || []).map((w) => ({
...w,
name: w.tab_name && w.tab_name.trim() ? w.tab_name.trim() : w.file_name,
description: "",
category: "",
lastEdited: w.last_edited,
lastEditedEpoch: w.last_edited_epoch
}));
setRows(mapped);
} catch (err) {
console.error("Failed to load workflows:", err);
setRows([]);
}
}, []);
useEffect(() => {
let alive = true;
(async () => {
try {
const resp = await fetch("/api/storage/load_workflows");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!alive) return;
const mapped = (data.workflows || []).map(w => ({
...w,
name: w.tab_name && w.tab_name.trim() ? w.tab_name.trim() : w.file_name,
description: "",
category: "",
lastEdited: w.last_edited,
lastEditedEpoch: w.last_edited_epoch
}));
setRows(mapped);
} catch (err) {
console.error("Failed to load workflows:", err);
setRows([]);
}
})();
return () => {
alive = false;
};
}, []);
loadRows();
}, [loadRows]);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
@@ -94,6 +98,41 @@ export default function WorkflowList({ onOpenWorkflow }) {
}
};
const openMenu = (e, row) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget);
setSelected(row);
};
const closeMenu = () => setMenuAnchor(null);
const startRename = () => {
closeMenu();
if (selected) {
const initial = selected.tab_name && selected.tab_name.trim().length > 0
? selected.tab_name.trim()
: selected.file_name.replace(/\.json$/i, "");
setRenameValue(initial);
setRenameOpen(true);
}
};
const handleRenameSave = async () => {
if (!selected) return;
try {
await fetch("/api/storage/rename_workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selected.rel_path, new_name: renameValue })
});
await loadRows();
} catch (err) {
console.error("Failed to rename workflow:", err);
}
setRenameOpen(false);
setSelected(null);
};
const renderNameCell = (r) => {
const hasPrefix = r.breadcrumb_prefix && r.breadcrumb_prefix.length > 0;
const primary = r.tab_name && r.tab_name.trim().length > 0 ? r.tab_name.trim() : r.file_name;
@@ -179,6 +218,7 @@ export default function WorkflowList({ onOpenWorkflow }) {
Last Edited
</TableSortLabel>
</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
@@ -193,17 +233,41 @@ export default function WorkflowList({ onOpenWorkflow }) {
<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={4} sx={{ color: "#888" }}>
<TableCell colSpan={5} sx={{ color: "#888" }}>
No workflows found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={closeMenu}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
<MenuItem onClick={startRename}>Rename</MenuItem>
</Menu>
<RenameWorkflowDialog
open={renameOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameOpen(false)}
onSave={handleRenameSave}
/>
</Paper>
);
}