From dd7dce19474bdb07ad235a764f56c5f397240c09 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 2 Nov 2025 00:50:45 -0600 Subject: [PATCH] Update Assembly List Design --- .../src/Assemblies/Assembly_List.jsx | 1262 +++++++---------- 1 file changed, 551 insertions(+), 711 deletions(-) diff --git a/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx b/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx index 5f5fc1dd..95457e39 100644 --- a/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx +++ b/Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx @@ -1,777 +1,617 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { Paper, Box, Typography, Menu, MenuItem, Button } from "@mui/material"; -import { Folder as FolderIcon, Description as DescriptionIcon, Polyline as WorkflowsIcon, Code as ScriptIcon, MenuBook as BookIcon } from "@mui/icons-material"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { - SimpleTreeView, - TreeItem, - useTreeViewApiRef -} from "@mui/x-tree-view"; -import { - RenameWorkflowDialog, - RenameFolderDialog, - NewWorkflowDialog, - ConfirmDeleteDialog -} from "../Dialogs"; + Paper, + Box, + Typography, + Button, + Menu, + MenuItem, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + CircularProgress, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import CachedIcon from "@mui/icons-material/Cached"; +import PolylineIcon from "@mui/icons-material/Polyline"; +import CodeIcon from "@mui/icons-material/Code"; +import MenuBookIcon from "@mui/icons-material/MenuBook"; +import { AgGridReact } from "ag-grid-react"; +import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; +import { ConfirmDeleteDialog, NewWorkflowDialog } from "../Dialogs"; -// Generic Island wrapper with large icon, stacked title/description, and actions on the right -const Island = ({ title, description, icon, actions, children, sx }) => ( - - - - {icon ? ( - - {icon} - - ) : null} - - - {title} - - {description ? ( - - {description} - - ) : null} - - - {actions ? ( - - {actions} - - ) : null} - - {children} - -); +ModuleRegistry.registerModules([AllCommunityModule]); -// ---------------- Workflows Island ----------------- -const sortTree = (node) => { - if (!node || !Array.isArray(node.children)) return; - node.children.sort((a, b) => { - const aFolder = Boolean(a.isFolder); - const bFolder = Boolean(b.isFolder); - if (aFolder !== bFolder) return aFolder ? -1 : 1; - return String(a.label || "").localeCompare(String(b.label || ""), undefined, { - sensitivity: "base" - }); - }); - node.children.forEach(sortTree); +const myTheme = themeQuartz.withParams({ + accentColor: "#FFA6FF", + backgroundColor: "#1f2836", + browserColorScheme: "dark", + chromeBackgroundColor: { + ref: "foregroundColor", + mix: 0.07, + onto: "backgroundColor", + }, + fontFamily: { + googleFont: "IBM Plex Sans", + }, + foregroundColor: "#FFF", + headerFontSize: 14, +}); + +const themeClassName = myTheme.themeName || "ag-theme-quartz"; +const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif'; +const iconFontFamily = '"Quartz Regular"'; +const BOREALIS_BLUE = "#58a6ff"; +const PAGE_SIZE = 25; + +const TYPE_METADATA = { + workflows: { + label: "Workflow", + Icon: PolylineIcon, + }, + scripts: { + label: "Script", + Icon: CodeIcon, + }, + ansible: { + label: "Playbook", + Icon: MenuBookIcon, + }, }; -function buildWorkflowTree(workflows, folders) { - const map = {}; - const rootNode = { id: "root", label: "Workflows", path: "", isFolder: true, children: [] }; - map[rootNode.id] = rootNode; - (folders || []).forEach((f) => { - const parts = (f || "").split("/"); - let children = rootNode.children; - let parentPath = ""; - parts.forEach((part) => { - const path = parentPath ? `${parentPath}/${part}` : part; - let node = children.find((n) => n.id === path); - if (!node) { - node = { id: path, label: part, path, isFolder: true, children: [] }; - children.push(node); - map[path] = node; - } - children = node.children; - parentPath = path; - }); - }); - (workflows || []).forEach((w) => { - const parts = (w.rel_path || "").split("/"); - let children = rootNode.children; - let parentPath = ""; - parts.forEach((part, idx) => { - const path = parentPath ? `${parentPath}/${part}` : part; - const isFile = idx === parts.length - 1; - let node = children.find((n) => n.id === path); - if (!node) { - node = { - id: path, - label: isFile ? ((w.tab_name && 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; - } - }); - }); - sortTree(rootNode); - return { root: [rootNode], map }; -} +const TypeCellRenderer = React.memo(function TypeCellRenderer(props) { + const typeKey = props?.data?.typeKey; + const meta = typeKey ? TYPE_METADATA[typeKey] : null; + if (!meta) { + return null; + } + const { Icon, label } = meta; + return ( + + + + {label} + + + ); +}); -function WorkflowsIsland({ onOpenWorkflow }) { - const [tree, setTree] = useState([]); - const [nodeMap, setNodeMap] = useState({}); - const [contextMenu, setContextMenu] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); - const [renameValue, setRenameValue] = useState(""); - const [renameOpen, setRenameOpen] = useState(false); - const [renameFolderOpen, setRenameFolderOpen] = useState(false); - const [folderDialogMode, setFolderDialogMode] = useState("rename"); - const [newWorkflowOpen, setNewWorkflowOpen] = useState(false); - const [newWorkflowName, setNewWorkflowName] = useState(""); - const [deleteOpen, setDeleteOpen] = useState(false); - const apiRef = useTreeViewApiRef(); - const [dragNode, setDragNode] = useState(null); - - const handleDrop = async (target) => { - if (!dragNode || !target.isFolder) return; - if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) { - setDragNode(null); - return; - } - const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName; - try { - await fetch("/api/assembly/move", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island: 'workflows', kind: 'file', path: dragNode.path, new_path: newPath }) - }); - loadTree(); - } catch (err) { - console.error("Failed to move workflow:", err); - } - setDragNode(null); +const normalizeRow = (island, item) => { + const relPath = String(item?.rel_path || "").replace(/\\/g, "/"); + const fileName = String(item?.file_name || relPath.split("/").pop() || ""); + const folder = relPath ? relPath.split("/").slice(0, -1).join("/") : ""; + const idSeed = relPath || fileName || `${Date.now()}_${Math.random().toString(36).slice(2)}`; + const name = + island === "workflows" + ? item?.tab_name || fileName.replace(/\.[^.]+$/, "") || fileName || "Workflow" + : item?.name || fileName.replace(/\.[^.]+$/, "") || fileName || "Assembly"; + const category = + island === "workflows" + ? folder || "Workflows" + : item?.category || ""; + const description = + island === "workflows" ? "" : item?.description || ""; + return { + id: `${island}:${idSeed}`, + typeKey: island, + name, + category, + description, + relPath, + fileName, + folder, + raw: item || {}, }; +}; - const loadTree = useCallback(async () => { +export default function AssemblyList({ onOpenWorkflow, onOpenScript }) { + const gridRef = useRef(null); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const [newMenuAnchor, setNewMenuAnchor] = useState(null); + const [scriptDialog, setScriptDialog] = useState({ open: false, island: null }); + const [scriptName, setScriptName] = useState(""); + const [workflowDialogOpen, setWorkflowDialogOpen] = useState(false); + const [workflowName, setWorkflowName] = useState(""); + + const [contextMenu, setContextMenu] = useState(null); + const [activeRow, setActiveRow] = useState(null); + + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const fetchAssemblies = useCallback(async () => { + setLoading(true); + setError(""); try { - const resp = await fetch(`/api/assembly/list?island=workflows`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - const { root, map } = buildWorkflowTree(data.items || [], data.folders || []); - setTree(root); - setNodeMap(map); + const islands = ["workflows", "scripts", "ansible"]; + const results = await Promise.all( + islands.map(async (island) => { + const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`); + if (!resp.ok) { + const problem = await resp.text(); + throw new Error(problem || `Failed to load ${island} assemblies (HTTP ${resp.status})`); + } + const data = await resp.json(); + const items = Array.isArray(data?.items) ? data.items : []; + return items.map((item) => normalizeRow(island, item)); + }), + ); + setRows(results.flat()); } catch (err) { - console.error("Failed to load workflows:", err); - setTree([]); - setNodeMap({}); + console.error("Failed to load assemblies:", err); + setRows([]); + setError(err?.message || "Failed to load assemblies"); + } finally { + setLoading(false); } }, []); - useEffect(() => { loadTree(); }, [loadTree]); + useEffect(() => { + fetchAssemblies(); + }, [fetchAssemblies]); - const handleContextMenu = (e, node) => { - e.preventDefault(); - setSelectedNode(node); - setContextMenu( - contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null - ); - }; - - const handleRename = () => { - setContextMenu(null); - if (!selectedNode) return; - setRenameValue(selectedNode.label); - if (selectedNode.isFolder) { - setFolderDialogMode("rename"); - setRenameFolderOpen(true); - } else setRenameOpen(true); - }; - - const handleEdit = () => { - setContextMenu(null); - if (selectedNode && !selectedNode.isFolder && onOpenWorkflow) { - onOpenWorkflow(selectedNode.workflow); - } - }; - - const handleDelete = () => { - setContextMenu(null); - if (!selectedNode) return; - setDeleteOpen(true); - }; - - const handleNewFolder = () => { - if (!selectedNode) return; - setContextMenu(null); - setFolderDialogMode("create"); - setRenameValue(""); - setRenameFolderOpen(true); - }; - - const handleNewWorkflow = () => { - if (!selectedNode) return; - setContextMenu(null); - setNewWorkflowName(""); - setNewWorkflowOpen(true); - }; - - const saveRenameWorkflow = async () => { - if (!selectedNode) return; - try { - await fetch("/api/assembly/rename", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island: 'workflows', kind: 'file', path: selectedNode.path, new_name: renameValue }) - }); - loadTree(); - } catch (err) { - console.error("Failed to rename workflow:", err); - } - setRenameOpen(false); - }; - - const saveRenameFolder = async () => { - try { - if (folderDialogMode === "rename" && selectedNode) { - await fetch("/api/assembly/rename", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path, new_name: renameValue }) - }); - } else { - const basePath = selectedNode ? selectedNode.path : ""; - const newPath = basePath ? `${basePath}/${renameValue}` : renameValue; - await fetch("/api/assembly/create", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island: 'workflows', kind: 'folder', path: newPath }) - }); - } - 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 confirmDelete = async () => { - if (!selectedNode) return; - try { - if (selectedNode.isFolder) { - await fetch("/api/assembly/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island: 'workflows', kind: 'folder', path: selectedNode.path }) - }); - } else { - await fetch("/api/assembly/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island: 'workflows', kind: 'file', path: selectedNode.path }) - }); - } - loadTree(); - } catch (err) { - console.error("Failed to delete:", err); - } - setDeleteOpen(false); - }; - - const renderItems = (nodes) => - (nodes || []).map((n) => ( - !n.isFolder && setDragNode(n)} - onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }} - onDrop={(e) => { e.preventDefault(); handleDrop(n); }} - onContextMenu={(e) => handleContextMenu(e, n)} - > - {n.isFolder ? ( - - ) : ( - - )} - {n.label} - - } - > - {n.children && n.children.length > 0 ? renderItems(n.children) : null} - - )); - - const rootChildIds = tree[0]?.children?.map((c) => c.id) || []; - - return ( - } - actions={ - - } - > - { if (dragNode) e.preventDefault(); }} - onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }} - > - - {renderItems(tree)} - - - setContextMenu(null)} - anchorReference="anchorPosition" - anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} - PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} - > - {selectedNode?.isFolder && ( - <> - New Workflow - New Subfolder - {selectedNode.id !== "root" && (Rename)} - {selectedNode.id !== "root" && (Delete)} - - )} - {!selectedNode?.isFolder && ( - <> - Edit - Rename - Delete - - )} - - setRenameOpen(false)} onSave={saveRenameWorkflow} /> - setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} /> - setNewWorkflowOpen(false)} onCreate={() => { setNewWorkflowOpen(false); onOpenWorkflow && onOpenWorkflow(null, selectedNode?.path || "", newWorkflowName); }} /> - setDeleteOpen(false)} onConfirm={confirmDelete} /> - - ); -} - -// ---------------- Generic Scripts-like Islands (used for Scripts and Ansible) ----------------- -function buildFileTree(rootLabel, items, folders) { - // Some backends (e.g. /api/scripts) return paths relative to - // the Assemblies root, which prefixes items with a top-level - // folder like "Scripts". Others (e.g. /api/ansible) already - // return paths relative to their specific root. Normalize by - // stripping a matching top-level segment so the UI shows - // "Scripts/<...>" rather than "Scripts/Scripts/<...>". - const normalize = (p) => { - const candidates = [ - String(rootLabel || "").trim(), - String(rootLabel || "").replace(/\s+/g, "_") - ].filter(Boolean); - const parts = String(p || "").replace(/\\/g, "/").split("/").filter(Boolean); - if (parts.length && candidates.includes(parts[0])) parts.shift(); - return parts; - }; - - const map = {}; - const rootNode = { id: "root", label: rootLabel, path: "", isFolder: true, children: [] }; - map[rootNode.id] = rootNode; - - (folders || []).forEach((f) => { - const parts = normalize(f); - let children = rootNode.children; - let parentPath = ""; - parts.forEach((part) => { - const path = parentPath ? `${parentPath}/${part}` : part; - let node = children.find((n) => n.id === path); - if (!node) { - node = { id: path, label: part, path, isFolder: true, children: [] }; - children.push(node); - map[path] = node; - } - children = node.children; - parentPath = path; - }); - }); - - (items || []).forEach((s) => { - const parts = normalize(s?.rel_path); - let children = rootNode.children; - let parentPath = ""; - parts.forEach((part, idx) => { - const path = parentPath ? `${parentPath}/${part}` : part; - const isFile = idx === parts.length - 1; - let node = children.find((n) => n.id === path); - if (!node) { - node = { - id: path, - label: isFile ? (s.name || s.display_name || s.file_name || part) : part, - path, - isFolder: !isFile, - fileName: s.file_name, - meta: isFile ? s : null, - children: [] + const openRow = useCallback( + (row) => { + if (!row) return; + if (row.typeKey === "workflows") { + const payload = { + ...row.raw, + rel_path: row.relPath, + file_name: row.fileName, }; - children.push(node); - map[path] = node; + if (!payload.name) payload.name = row.name; + if (!payload.tab_name) payload.tab_name = row.name; + if (onOpenWorkflow) { + onOpenWorkflow(payload); + } + return; } - if (!isFile) { - children = node.children; - parentPath = path; + const mode = row.typeKey === "ansible" ? "ansible" : "scripts"; + if (onOpenScript) { + onOpenScript(row.relPath, mode, null); } - }); - }); - sortTree(rootNode); - return { root: [rootNode], map }; -} + }, + [onOpenWorkflow, onOpenScript], + ); -function ScriptsLikeIsland({ - title, - description, - rootLabel, - baseApi, // e.g. '/api/scripts' or '/api/ansible' - newItemLabel = "New Script", - onEdit // (rel_path) => void -}) { - const [tree, setTree] = useState([]); - const [nodeMap, setNodeMap] = useState({}); - const [contextMenu, setContextMenu] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); - const [renameValue, setRenameValue] = useState(""); - const [renameOpen, setRenameOpen] = useState(false); - const [renameFolderOpen, setRenameFolderOpen] = useState(false); - const [folderDialogMode, setFolderDialogMode] = useState("rename"); - const [newItemOpen, setNewItemOpen] = useState(false); - const [newItemName, setNewItemName] = useState(""); - const [deleteOpen, setDeleteOpen] = useState(false); - const apiRef = useTreeViewApiRef(); - const [dragNode, setDragNode] = useState(null); + const handleRowDoubleClicked = useCallback( + (event) => { + openRow(event?.data); + }, + [openRow], + ); - const island = React.useMemo(() => { - const b = String(baseApi || '').toLowerCase(); - return b.endsWith('/api/ansible') ? 'ansible' : 'scripts'; - }, [baseApi]); - - const loadTree = useCallback(async () => { - try { - const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - const { root, map } = buildFileTree(rootLabel, data.items || [], data.folders || []); - setTree(root); - setNodeMap(map); - } catch (err) { - console.error(`Failed to load ${title}:`, err); - setTree([]); - setNodeMap({}); - } - }, [island, title, rootLabel]); - - useEffect(() => { loadTree(); }, [loadTree]); - - const handleContextMenu = (e, node) => { - e.preventDefault(); - setSelectedNode(node); + const handleCellContextMenu = useCallback((params) => { + params.event?.preventDefault(); + setActiveRow(params?.data || null); setContextMenu( - contextMenu === null ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } : null + params?.event + ? { + mouseX: params.event.clientX + 2, + mouseY: params.event.clientY - 6, + } + : null, ); + }, []); + + const closeContextMenu = () => setContextMenu(null); + + const startRename = () => { + if (!activeRow) return; + setRenameValue(activeRow.name || activeRow.fileName || ""); + setRenameDialogOpen(true); + closeContextMenu(); }; - const handleDrop = async (target) => { - if (!dragNode || !target.isFolder) return; - if (dragNode.path === target.path || target.path.startsWith(`${dragNode.path}/`)) { - setDragNode(null); + const startDelete = () => { + if (!activeRow) return; + setDeleteDialogOpen(true); + closeContextMenu(); + }; + + const handleRenameSave = async () => { + const target = activeRow; + const trimmed = renameValue.trim(); + if (!target || !trimmed) { + setRenameDialogOpen(false); return; } - const newPath = target.path ? `${target.path}/${dragNode.fileName}` : dragNode.fileName; try { - await fetch(`/api/assembly/move`, { + const payload = { + island: target.typeKey, + kind: "file", + path: target.relPath, + new_name: trimmed, + }; + if (target.typeKey !== "workflows" && target.raw?.type) { + payload.type = target.raw.type; + } + const resp = await fetch(`/api/assembly/rename`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: 'file', path: dragNode.path, new_path: newPath }) + body: JSON.stringify(payload), }); - loadTree(); + const data = await resp.json(); + if (!resp.ok) { + throw new Error(data?.error || `HTTP ${resp.status}`); + } + setRenameDialogOpen(false); + await fetchAssemblies(); } catch (err) { - console.error("Failed to move:", err); - } - setDragNode(null); - }; - - const handleNodeSelect = async (_e, itemId) => { - const node = nodeMap[itemId]; - if (node && !node.isFolder) { - setContextMenu(null); - onEdit && onEdit(node.path); + console.error("Failed to rename assembly:", err); + setRenameDialogOpen(false); } }; - const saveRenameFile = async () => { + const handleDeleteConfirm = async () => { + const target = activeRow; + if (!target) { + setDeleteDialogOpen(false); + return; + } try { - const payload = { island, kind: 'file', path: selectedNode.path, new_name: renameValue }; - // preserve extension for scripts when no extension provided - if (selectedNode?.meta?.type) payload.type = selectedNode.meta.type; - const res = await fetch(`/api/assembly/rename`, { + const resp = await fetch(`/api/assembly/delete`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) + body: JSON.stringify({ + island: target.typeKey, + kind: "file", + path: target.relPath, + }), }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`); - setRenameOpen(false); - loadTree(); - } catch (err) { - console.error("Failed to rename file:", err); - setRenameOpen(false); - } - }; - - const saveRenameFolder = async () => { - try { - if (folderDialogMode === "rename" && selectedNode) { - await fetch(`/api/assembly/rename`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path, new_name: renameValue }) - }); - } else { - const basePath = selectedNode ? selectedNode.path : ""; - const newPath = basePath ? `${basePath}/${renameValue}` : renameValue; - await fetch(`/api/assembly/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: 'folder', path: newPath }) - }); + const data = await resp.json(); + if (!resp.ok) { + throw new Error(data?.error || `HTTP ${resp.status}`); } - setRenameFolderOpen(false); - loadTree(); + setDeleteDialogOpen(false); + await fetchAssemblies(); } catch (err) { - console.error("Folder operation failed:", err); - setRenameFolderOpen(false); + console.error("Failed to delete assembly:", err); + setDeleteDialogOpen(false); } }; - const confirmDelete = async () => { - if (!selectedNode) return; - try { - if (selectedNode.isFolder) { - await fetch(`/api/assembly/delete`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: 'folder', path: selectedNode.path }) - }); - } else { - await fetch(`/api/assembly/delete`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: 'file', path: selectedNode.path }) - }); - } - setDeleteOpen(false); - loadTree(); - } catch (err) { - console.error("Failed to delete:", err); - setDeleteOpen(false); + const columnDefs = useMemo( + () => [ + { + field: "assemblyType", + headerName: "Assembly Type", + valueGetter: (params) => TYPE_METADATA[params?.data?.typeKey]?.label || "", + cellRenderer: TypeCellRenderer, + width: 200, + minWidth: 180, + flex: 0, + sortable: true, + filter: "agTextColumnFilter", + }, + { + field: "category", + headerName: "Category", + valueGetter: (params) => params?.data?.category || "", + }, + { + field: "name", + headerName: "Name", + valueGetter: (params) => params?.data?.name || "", + }, + { + field: "description", + headerName: "Description", + valueGetter: (params) => params?.data?.description || "", + }, + ], + [], + ); + + const defaultColDef = useMemo( + () => ({ + sortable: true, + filter: "agTextColumnFilter", + floatingFilter: true, + resizable: true, + flex: 1, + minWidth: 180, + }), + [], + ); + + const gridWrapperClass = themeClassName; + + const handleRefresh = () => fetchAssemblies(); + + const handleNewAssemblyOption = (island) => { + setNewMenuAnchor(null); + if (island === "workflows") { + setWorkflowName(""); + setWorkflowDialogOpen(true); + return; } + setScriptName(""); + setScriptDialog({ open: true, island }); }; - const createNewItem = () => { - const trimmedName = (newItemName || '').trim(); - const folder = selectedNode?.isFolder - ? selectedNode.path - : (selectedNode?.path?.split("/").slice(0, -1).join("/") || ""); + const handleCreateScript = () => { + const trimmed = scriptName.trim(); + if (!trimmed || !scriptDialog.island) { + return; + } + const isAnsible = scriptDialog.island === "ansible"; const context = { - folder, - suggestedFileName: trimmedName, - defaultType: island === 'ansible' ? 'ansible' : 'powershell', - type: island === 'ansible' ? 'ansible' : 'powershell', - category: island === 'ansible' ? 'application' : 'script' + folder: "", + suggestedFileName: trimmed, + name: trimmed, + defaultType: isAnsible ? "ansible" : "powershell", + type: isAnsible ? "ansible" : "powershell", + category: isAnsible ? "application" : "script", }; - setNewItemOpen(false); - setNewItemName(""); - onEdit && onEdit(null, context); + if (onOpenScript) { + onOpenScript(null, isAnsible ? "ansible" : "scripts", context); + } + setScriptDialog({ open: false, island: null }); + setScriptName(""); }; - const renderItems = (nodes) => - (nodes || []).map((n) => ( - !n.isFolder && setDragNode(n)} - onDragOver={(e) => { if (dragNode && n.isFolder) e.preventDefault(); }} - onDrop={(e) => { e.preventDefault(); handleDrop(n); }} - onContextMenu={(e) => handleContextMenu(e, n)} - onDoubleClick={() => { if (!n.isFolder) onEdit && onEdit(n.path); }} - > - {n.isFolder ? ( - - ) : ( - - )} - {n.label} - - } - > - {n.children && n.children.length > 0 ? renderItems(n.children) : null} - - )); - - const rootChildIds = tree[0]?.children?.map((c) => c.id) || []; + const handleCreateWorkflow = () => { + const trimmed = workflowName.trim(); + if (!trimmed) { + return; + } + setWorkflowDialogOpen(false); + if (onOpenWorkflow) { + onOpenWorkflow(null, "", trimmed); + } + setWorkflowName(""); + }; return ( - : } - actions={ - - } + - { if (dragNode) e.preventDefault(); }} - onDrop={(e) => { e.preventDefault(); handleDrop({ path: "", isFolder: true }); }} - > - - {renderItems(tree)} - + + + Assemblies + + + Collections of scripts, workflows, and playbooks used to automate tasks across devices. + + + + + + + {error ? ( + + {error} + + ) : null} + + + setNewMenuAnchor(null)} + PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} + > + handleNewAssemblyOption("scripts")}>Script + handleNewAssemblyOption("workflows")}>Workflow + handleNewAssemblyOption("ansible")}>Ansible Playbook + + + + + params?.data?.id || params?.data?.relPath || params?.data?.fileName || String(params?.rowIndex ?? "")} + theme={myTheme} + style={{ + width: "100%", + height: "100%", + fontFamily: gridFontFamily, + "--ag-icon-font-family": iconFontFamily, + }} + /> + {loading ? ( + + + + ) : null} + + + setContextMenu(null)} + onClose={closeContextMenu} anchorReference="anchorPosition" anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} > - {selectedNode?.isFolder && ( - <> - { setContextMenu(null); setNewItemOpen(true); }}>{newItemLabel} - { setContextMenu(null); setFolderDialogMode("create"); setRenameValue(""); setRenameFolderOpen(true); }}>New Subfolder - {selectedNode.id !== "root" && ( { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename)} - {selectedNode.id !== "root" && ( { setContextMenu(null); setDeleteOpen(true); }}>Delete)} - - )} - {!selectedNode?.isFolder && ( - <> - { setContextMenu(null); onEdit && onEdit(selectedNode.path); }}>Edit - { setContextMenu(null); setRenameValue(selectedNode.label); setRenameOpen(true); }}>Rename - { setContextMenu(null); setDeleteOpen(true); }}>Delete - - )} + { + closeContextMenu(); + openRow(activeRow); + }} + > + Open + + Rename + + Delete + - {/* Simple inline dialogs using shared components */} - setRenameFolderOpen(false)} onSave={saveRenameFolder} title={folderDialogMode === "rename" ? "Rename Folder" : "New Folder"} confirmText={folderDialogMode === "rename" ? "Save" : "Create"} /> - {/* File rename */} -
} sx={{ display: renameOpen ? 'block' : 'none' }}> -
- - Rename - setRenameValue(e.target.value)} style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} /> - - - - - -
- -
} sx={{ display: newItemOpen ? 'block' : 'none' }}> -
- - {newItemLabel} - setNewItemName(e.target.value)} placeholder="Name" style={{ width: '100%', padding: 8, background: '#2a2a2a', color: '#ccc', border: '1px solid #444', borderRadius: 4 }} /> - - - - - -
- - setDeleteOpen(false)} onConfirm={confirmDelete} /> - - ); -} -export default function AssemblyList({ onOpenWorkflow, onOpenScript }) { - return ( - - - Assemblies - Collections of various types of components used to perform various automations upon targeted devices. - - - - {/* Left: Workflows */} - + setRenameDialogOpen(false)}> + Rename Assembly + + setRenameValue(event.target.value)} + sx={{ + mt: 1, + "& .MuiOutlinedInput-root": { + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" }, + }, + }} + /> + + + + + + - {/* Middle: Scripts */} - onOpenScript && onOpenScript(rel, 'scripts', ctx)} - /> + setDeleteDialogOpen(false)} + onConfirm={handleDeleteConfirm} + /> - {/* Right: Ansible Playbooks */} - onOpenScript && onOpenScript(rel, 'ansible', ctx)} - /> - - + { + setScriptDialog({ open: false, island: null }); + setScriptName(""); + }} + > + {scriptDialog.island === "ansible" ? "New Ansible Playbook" : "New Script"} + + setScriptName(event.target.value)} + sx={{ + mt: 1, + "& .MuiOutlinedInput-root": { + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" }, + }, + }} + /> + + + + + + + + { + setWorkflowDialogOpen(false); + setWorkflowName(""); + }} + onCreate={handleCreateWorkflow} + /> ); }