Improve device grid theming and filters

This commit is contained in:
2025-10-16 01:43:38 -06:00
parent 1e016e9584
commit 42eb3b6f8c
4 changed files with 165 additions and 51 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "11.14.0", "@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0", "@emotion/styled": "11.14.0",
"@fontsource/ibm-plex-sans": "5.0.17",
"@mui/icons-material": "7.0.2", "@mui/icons-material": "7.0.2",
"@mui/material": "7.0.2", "@mui/material": "7.0.2",
"@mui/x-date-pickers": "8.11.3", "@mui/x-date-pickers": "8.11.3",

View File

@@ -1,5 +1,11 @@
/* ///////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Borealis.css /* ///////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Borealis.css
body {
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
background-color: #0b0f19;
color: #f5f7fa;
}
/* ======================================= */ /* ======================================= */
/* FLOW EDITOR */ /* FLOW EDITOR */
/* ======================================= */ /* ======================================= */

View File

@@ -43,6 +43,8 @@ const myTheme = themeQuartz.withParams({
}); });
const themeClassName = myTheme.themeName || "ag-theme-quartz"; const themeClassName = myTheme.themeName || "ag-theme-quartz";
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
const iconFontFamily = '"Quartz Regular"';
function formatLastSeen(tsSec, offlineAfter = 300) { function formatLastSeen(tsSec, offlineAfter = 300) {
if (!tsSec) return "unknown"; if (!tsSec) return "unknown";
@@ -188,7 +190,103 @@ export default function DeviceList({
const gridRef = useRef(null); const gridRef = useRef(null);
// Per-column filters // Per-column filters
const [filters, setFilters] = useState({}); const [filtersState, setFiltersState] = useState({});
const sanitizeFilterModel = useCallback((raw) => {
if (!raw || typeof raw !== "object") return {};
const sanitized = {};
Object.entries(raw).forEach(([key, value]) => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) {
sanitized[key] = {
filterType: "text",
type: "contains",
filter: trimmed,
};
}
return;
}
if (!value || typeof value !== "object") return;
const clone = JSON.parse(JSON.stringify(value));
if (!clone.filterType) clone.filterType = "text";
if (clone.filterType === "text") {
if (typeof clone.filter === "string") {
clone.filter = clone.filter.trim();
}
if (Array.isArray(clone.conditions)) {
clone.conditions = clone.conditions
.map((condition) => {
if (!condition || typeof condition !== "object") return null;
const condClone = { ...condition };
if (typeof condClone.filter === "string") {
condClone.filter = condClone.filter.trim();
}
if (
!condClone.filter &&
!["blank", "notBlank"].includes(condClone.type ?? "")
) {
return null;
}
return condClone;
})
.filter(Boolean);
if (!clone.conditions.length) {
delete clone.conditions;
}
}
if (
!clone.filter &&
!clone.conditions &&
!["blank", "notBlank"].includes(clone.type ?? "")
) {
return;
}
}
sanitized[key] = clone;
});
return sanitized;
}, []);
const filterModelsEqual = useCallback(
(a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {}),
[]
);
const replaceFilters = useCallback(
(raw) => {
const sanitized =
raw && typeof raw === "object" ? sanitizeFilterModel(raw) : {};
setFiltersState((prev) =>
filterModelsEqual(prev, sanitized) ? prev : sanitized
);
},
[filterModelsEqual, sanitizeFilterModel]
);
const mergeFilters = useCallback(
(raw) => {
if (!raw || typeof raw !== "object") return;
const sanitized = sanitizeFilterModel(raw);
if (!Object.keys(sanitized).length) return;
setFiltersState((prev) => {
const base = prev || {};
const next = { ...base };
let changed = false;
Object.entries(sanitized).forEach(([key, value]) => {
if (!value) return;
if (!next[key] || !filterModelsEqual(next[key], value)) {
next[key] = value;
changed = true;
}
});
return changed ? next : base;
});
},
[filterModelsEqual, sanitizeFilterModel]
);
const filters = filtersState;
const [sites, setSites] = useState([]); // sites list for assignment const [sites, setSites] = useState([]); // sites list for assignment
const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [assignDialogOpen, setAssignDialogOpen] = useState(false);
@@ -472,7 +570,7 @@ export default function DeviceList({
if (json) { if (json) {
const obj = JSON.parse(json); const obj = JSON.parse(json);
if (obj && typeof obj === 'object') { if (obj && typeof obj === 'object') {
setFilters((prev) => ({ ...prev, ...obj })); mergeFilters(obj);
// Optionally ensure Site column exists when site filter is present // Optionally ensure Site column exists when site filter is present
if (obj.site) { if (obj.site) {
setColumns((prev) => { setColumns((prev) => {
@@ -505,16 +603,16 @@ export default function DeviceList({
next.splice(insertAt, 0, { id: 'site', label: COL_LABELS.site }); next.splice(insertAt, 0, { id: 'site', label: COL_LABELS.site });
return next; return next;
}); });
setFilters((f) => ({ ...f, site })); mergeFilters({ site });
localStorage.removeItem('device_list_initial_site_filter'); localStorage.removeItem('device_list_initial_site_filter');
} }
} catch {} } catch {}
}, [COL_LABELS.site]); }, [COL_LABELS.site, mergeFilters]);
const applyView = useCallback((view) => { const applyView = useCallback((view) => {
if (!view || view.id === "default") { if (!view || view.id === "default") {
setColumns(defaultColumns); setColumns(defaultColumns);
setFilters({}); replaceFilters({});
return; return;
} }
try { try {
@@ -525,12 +623,14 @@ export default function DeviceList({
.filter((id) => COL_LABELS[id]) .filter((id) => COL_LABELS[id])
.map((id) => ({ id, label: COL_LABELS[id] })); .map((id) => ({ id, label: COL_LABELS[id] }));
setColumns(mapped.length ? mapped : defaultColumns); setColumns(mapped.length ? mapped : defaultColumns);
setFilters(view.filters && typeof view.filters === "object" ? view.filters : {}); replaceFilters(
view.filters && typeof view.filters === "object" ? view.filters : {}
);
} catch { } catch {
setColumns(defaultColumns); setColumns(defaultColumns);
setFilters({}); replaceFilters({});
} }
}, [COL_LABELS, defaultColumns]); }, [COL_LABELS, defaultColumns, replaceFilters]);
const statusColor = useCallback( const statusColor = useCallback(
(s) => (s === "Online" ? "#00d18c" : "#ff4f4f"), (s) => (s === "Online" ? "#00d18c" : "#ff4f4f"),
@@ -551,20 +651,10 @@ export default function DeviceList({
return created || ""; return created || "";
}, []); }, []);
const filterModel = useMemo(() => { const filterModel = useMemo(
const model = {}; () => JSON.parse(JSON.stringify(filters || {})),
Object.entries(filters).forEach(([key, value]) => { [filters]
const trimmed = (value || "").trim(); );
if (trimmed) {
model[key] = {
filterType: "text",
type: "contains",
filter: trimmed,
};
}
});
return model;
}, [filters]);
useEffect(() => { useEffect(() => {
if (gridRef.current?.api) { if (gridRef.current?.api) {
@@ -572,28 +662,13 @@ export default function DeviceList({
} }
}, [filterModel]); }, [filterModel]);
const handleFilterChanged = useCallback((event) => { const handleFilterChanged = useCallback(
const model = event.api.getFilterModel() || {}; (event) => {
setFilters((prev) => { const model = event.api.getFilterModel() || {};
const next = {}; replaceFilters(model);
Object.entries(model).forEach(([key, cfg]) => { },
const filterValue = [replaceFilters]
cfg && typeof cfg.filter === "string" ? cfg.filter : ""; );
if (filterValue) {
next[key] = filterValue;
}
});
const prevEntries = Object.entries(prev);
const nextEntries = Object.entries(next);
if (
prevEntries.length === nextEntries.length &&
prevEntries.every(([k, v]) => next[k] === v)
) {
return prev;
}
return next;
});
}, []);
const handleSelectionChanged = useCallback(() => { const handleSelectionChanged = useCallback(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
@@ -699,21 +774,27 @@ export default function DeviceList({
<Box <Box
component="span" component="span"
sx={{ sx={{
display: "inline-block", display: "inline-flex",
px: 1.2, alignItems: "center",
py: 0.25, justifyContent: "center",
minWidth: 70,
px: 1.5,
py: 0.35,
borderRadius: 999, borderRadius: 999,
bgcolor: statusColor(status), bgcolor: statusColor(status),
color: "#fff", color: "#fff",
fontWeight: 600, fontWeight: 600,
fontSize: "12px", fontSize: "13px",
lineHeight: 1,
fontFamily: gridFontFamily,
textTransform: "capitalize",
}} }}
> >
{status} {status}
</Box> </Box>
); );
}, },
[statusColor] [statusColor, gridFontFamily]
); );
const actionCellRenderer = useCallback( const actionCellRenderer = useCallback(
@@ -938,7 +1019,16 @@ export default function DeviceList({
); );
return ( return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}> <Paper
sx={{
m: 2,
p: 0,
bgcolor: "#1e1e1e",
fontFamily: gridFontFamily,
color: "#f5f7fa",
}}
elevation={2}
>
{/* Header area with title on left and controls on right */} {/* Header area with title on left and controls on right */}
<Box sx={{ p: 2, pb: 1, display: "flex", flexDirection: 'column', gap: 1 }}> <Box sx={{ p: 2, pb: 1, display: "flex", flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -1083,9 +1173,18 @@ export default function DeviceList({
width: "100%", width: "100%",
height: 600, height: 600,
minHeight: 400, minHeight: 400,
fontFamily: gridFontFamily,
"--ag-font-family": gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
"& .ag-root-wrapper": { "& .ag-root-wrapper": {
borderRadius: 1, borderRadius: 1,
}, },
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
fontFamily: gridFontFamily,
},
"& .ag-icon": {
fontFamily: iconFontFamily,
},
}} }}
> >
<AgGridReact <AgGridReact
@@ -1103,7 +1202,12 @@ export default function DeviceList({
onGridReady={handleGridReady} onGridReady={handleGridReady}
getRowId={getRowId} getRowId={getRowId}
theme={myTheme} theme={myTheme}
style={{ width: "100%", height: "100%" }} style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
}}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -5,6 +5,9 @@ import ReactDOM from 'react-dom/client';
// Global Styles // Global Styles
import "normalize.css/normalize.css"; import "normalize.css/normalize.css";
import "@fontsource/ibm-plex-sans/400.css";
import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css";
import './Borealis.css'; // Global Theming for All of Borealis import './Borealis.css'; // Global Theming for All of Borealis
import App from './App.jsx'; import App from './App.jsx';