refactor credential list to ag grid

This commit is contained in:
2025-10-16 03:17:12 -06:00
parent e9e7dd0cf8
commit 9946c7ea2e

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Box, Box,
Button, Button,
@@ -6,12 +6,6 @@ import {
Menu, Menu,
MenuItem, MenuItem,
Paper, Paper,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableSortLabel,
Typography, Typography,
CircularProgress CircularProgress
} from "@mui/material"; } from "@mui/material";
@@ -21,31 +15,31 @@ import RefreshIcon from "@mui/icons-material/Refresh";
import LockIcon from "@mui/icons-material/Lock"; import LockIcon from "@mui/icons-material/Lock";
import WifiIcon from "@mui/icons-material/Wifi"; import WifiIcon from "@mui/icons-material/Wifi";
import ComputerIcon from "@mui/icons-material/Computer"; import ComputerIcon from "@mui/icons-material/Computer";
import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
import CredentialEditor from "./Credential_Editor.jsx"; import CredentialEditor from "./Credential_Editor.jsx";
import { ConfirmDeleteDialog } from "../Dialogs.jsx"; import { ConfirmDeleteDialog } from "../Dialogs.jsx";
const tablePaperSx = { m: 2, p: 0, bgcolor: "#1e1e1e", borderRadius: 2 }; ModuleRegistry.registerModules([AllCommunityModule]);
const tableSx = {
minWidth: 840,
"& th, & td": {
color: "#ddd",
borderColor: "#2a2a2a",
fontSize: 13,
py: 0.9
},
"& th .MuiTableSortLabel-root": { color: "#ddd" },
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
};
const columns = [ const myTheme = themeQuartz.withParams({
{ id: "name", label: "Name" }, accentColor: "#FFA6FF",
{ id: "credential_type", label: "Credential Type" }, backgroundColor: "#1f2836",
{ id: "connection_type", label: "Connection" }, browserColorScheme: "dark",
{ id: "site_name", label: "Site" }, chromeBackgroundColor: {
{ id: "username", label: "Username" }, ref: "foregroundColor",
{ id: "updated_at", label: "Updated" }, mix: 0.07,
{ id: "actions", label: "" } 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';
function formatTs(ts) { function formatTs(ts) {
if (!ts) return "-"; if (!ts) return "-";
@@ -69,8 +63,6 @@ function connectionIcon(connection) {
export default function CredentialList({ isAdmin = false }) { export default function CredentialList({ isAdmin = false }) {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("name");
const [order, setOrder] = useState("asc");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [menuAnchor, setMenuAnchor] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null);
@@ -80,18 +72,126 @@ export default function CredentialList({ isAdmin = false }) {
const [editingCredential, setEditingCredential] = useState(null); const [editingCredential, setEditingCredential] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false);
const gridApiRef = useRef(null);
const sortedRows = useMemo(() => { const openMenu = useCallback((event, row) => {
const sorted = [...rows]; setMenuAnchor(event.currentTarget);
sorted.sort((a, b) => { setMenuRow(row);
const aVal = (a?.[orderBy] ?? "").toString().toLowerCase(); }, []);
const bVal = (b?.[orderBy] ?? "").toString().toLowerCase();
if (aVal < bVal) return order === "asc" ? -1 : 1; const closeMenu = useCallback(() => {
if (aVal > bVal) return order === "asc" ? 1 : -1; setMenuAnchor(null);
return 0; setMenuRow(null);
}); }, []);
return sorted;
}, [rows, order, orderBy]); const connectionCellRenderer = useCallback((params) => {
const row = params.data || {};
const label = titleCase(row.connection_type);
return (
<Box sx={{ display: "flex", alignItems: "center", fontFamily: gridFontFamily }}>
{connectionIcon(row.connection_type)}
<Box component="span" sx={{ color: "#f5f7fa" }}>
{label}
</Box>
</Box>
);
}, []);
const actionCellRenderer = useCallback(
(params) => {
const row = params.data;
if (!row) return null;
const handleClick = (event) => {
event.preventDefault();
event.stopPropagation();
openMenu(event, row);
};
return (
<IconButton size="small" onClick={handleClick} sx={{ color: "#7db7ff" }}>
<MoreVertIcon fontSize="small" />
</IconButton>
);
},
[openMenu]
);
const columnDefs = useMemo(
() => [
{
headerName: "Name",
field: "name",
sort: "asc",
cellRenderer: (params) => params.value || "-"
},
{
headerName: "Credential Type",
field: "credential_type",
valueGetter: (params) => titleCase(params.data?.credential_type)
},
{
headerName: "Connection",
field: "connection_type",
cellRenderer: connectionCellRenderer
},
{
headerName: "Site",
field: "site_name",
cellRenderer: (params) => params.value || "-"
},
{
headerName: "Username",
field: "username",
cellRenderer: (params) => params.value || "-"
},
{
headerName: "Updated",
field: "updated_at",
valueGetter: (params) =>
formatTs(params.data?.updated_at || params.data?.created_at)
},
{
headerName: "",
field: "__actions__",
minWidth: 70,
maxWidth: 80,
sortable: false,
filter: false,
resizable: false,
suppressMenu: true,
cellRenderer: actionCellRenderer,
pinned: "right"
}
],
[actionCellRenderer, connectionCellRenderer]
);
const defaultColDef = useMemo(
() => ({
sortable: true,
filter: "agTextColumnFilter",
resizable: true,
flex: 1,
minWidth: 140,
cellStyle: {
display: "flex",
alignItems: "center",
color: "#f5f7fa",
fontFamily: gridFontFamily,
fontSize: "13px"
},
headerClass: "credential-grid-header"
}),
[]
);
const getRowId = useCallback(
(params) =>
params.data?.id ||
params.data?.name ||
params.data?.username ||
String(params.rowIndex ?? ""),
[]
);
const fetchCredentials = useCallback(async () => { const fetchCredentials = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -115,25 +215,6 @@ export default function CredentialList({ isAdmin = false }) {
fetchCredentials(); fetchCredentials();
}, [fetchCredentials]); }, [fetchCredentials]);
const handleSort = (columnId) => () => {
if (orderBy === columnId) {
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setOrderBy(columnId);
setOrder("asc");
}
};
const openMenu = (event, row) => {
setMenuAnchor(event.currentTarget);
setMenuRow(row);
};
const closeMenu = () => {
setMenuAnchor(null);
setMenuRow(null);
};
const handleCreate = () => { const handleCreate = () => {
setEditorMode("create"); setEditorMode("create");
setEditingCredential(null); setEditingCredential(null);
@@ -176,6 +257,22 @@ export default function CredentialList({ isAdmin = false }) {
await fetchCredentials(); await fetchCredentials();
}; };
const handleGridReady = useCallback((params) => {
gridApiRef.current = params.api;
}, []);
useEffect(() => {
const api = gridApiRef.current;
if (!api) return;
if (loading) {
api.showLoadingOverlay();
} else if (!rows.length) {
api.showNoRowsOverlay();
} else {
api.hideOverlay();
}
}, [loading, rows]);
if (!isAdmin) { if (!isAdmin) {
return ( return (
<Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}> <Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}>
@@ -191,8 +288,29 @@ export default function CredentialList({ isAdmin = false }) {
return ( return (
<> <>
<Paper sx={tablePaperSx} elevation={3}> <Paper
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, borderBottom: "1px solid #2a2a2a" }}> sx={{
m: 2,
p: 0,
bgcolor: "#1e1e1e",
borderRadius: 2,
fontFamily: gridFontFamily,
color: "#f5f7fa",
display: "flex",
flexDirection: "column",
minHeight: 420
}}
elevation={3}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box> <Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}> <Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Credentials Credentials
@@ -224,66 +342,60 @@ export default function CredentialList({ isAdmin = false }) {
</Box> </Box>
</Box> </Box>
{loading && ( {loading && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#7db7ff", px: 2, py: 1.5 }}> <Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
color: "#7db7ff",
px: 2,
py: 1.5,
borderBottom: "1px solid #2a2a2a"
}}
>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} /> <CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading credentials</Typography> <Typography variant="body2">Loading credentials</Typography>
</Box> </Box>
)} )}
{error && ( {error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}> <Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
<Typography variant="body2">{error}</Typography> <Typography variant="body2">{error}</Typography>
</Box> </Box>
)} )}
<Table size="small" sx={tableSx}> <Box sx={{ flexGrow: 1, minHeight: 320, p: 0 }}>
<TableHead> <Box
<TableRow> className={themeClassName}
{columns.map((col) => ( sx={{
<TableCell key={col.id} align={col.id === "actions" ? "right" : "left"}> width: "100%",
{col.id === "actions" ? null : ( height: "100%",
<TableSortLabel "& .ag-root-wrapper": {
active={orderBy === col.id} borderRadius: "0 0 16px 16px",
direction={orderBy === col.id ? order : "asc"} borderColor: "#2a2a2a"
onClick={handleSort(col.id)} },
> "& .ag-header": {
{col.label} borderBottom: "1px solid #2a2a2a"
</TableSortLabel> },
)} "& .ag-row": {
</TableCell> borderColor: "#2a2a2a"
))} }
</TableRow> }}
</TableHead> style={{ fontFamily: gridFontFamily, color: "#f5f7fa" }}
<TableBody> >
{!sortedRows.length && !loading ? ( <AgGridReact
<TableRow> rowData={rows}
<TableCell colSpan={columns.length} sx={{ color: "#888", textAlign: "center", py: 4 }}> columnDefs={columnDefs}
No credentials have been created yet. defaultColDef={defaultColDef}
</TableCell> animateRows
</TableRow> rowHeight={46}
) : ( headerHeight={44}
sortedRows.map((row) => ( getRowId={getRowId}
<TableRow key={row.id} hover> overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No credentials have been created yet.</span>"
<TableCell>{row.name || "-"}</TableCell> onGridReady={handleGridReady}
<TableCell>{titleCase(row.credential_type)}</TableCell> suppressCellFocus
<TableCell> />
<Box sx={{ display: "flex", alignItems: "center" }}> </Box>
{connectionIcon(row.connection_type)} </Box>
{titleCase(row.connection_type)}
</Box>
</TableCell>
<TableCell>{row.site_name || "-"}</TableCell>
<TableCell>{row.username || "-"}</TableCell>
<TableCell>{formatTs(row.updated_at || row.created_at)}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={(e) => openMenu(e, row)} sx={{ color: "#7db7ff" }}>
<MoreVertIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Paper> </Paper>
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu} elevation={2} PaperProps={{ sx: { bgcolor: "#1f1f1f", color: "#f5f5f5" } }}> <Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu} elevation={2} PaperProps={{ sx: { bgcolor: "#1f1f1f", color: "#f5f5f5" } }}>