feat(widgets): persist Beszel and Docker table layouts (#6490)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-01 23:20:09 +02:00
1 parent f4449f9581
commit 27ca058088
19 files changed
+1171 -484

No files matched your search

@@ -0,0 +1,52 @@
.homarr-data-table,
.homarr-data-table .mantine-datatable-table,
.homarr-data-table .mantine-datatable-table thead,
.homarr-data-table .mantine-datatable-table tbody,
.homarr-data-table .mantine-datatable-table tfoot,
.homarr-data-table th,
.homarr-data-table td {
background-color: transparent !important;
}
.homarr-data-table .mantine-datatable-table tr {
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.homarr-data-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
opacity: 0;
transition: opacity 150ms ease-out;
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon {
opacity: 1;
}
.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor {
cursor: default;
}
@@ -0,0 +1,57 @@
"use client";
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
type HomarrDataTableProps<T> = DataTableProps<T> & {
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
};
export function HomarrDataTable<T>({
isEditMode,
cellPadding,
rowCursor = "auto",
className,
defaultColumnProps,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
className,
]
.filter(Boolean)
.join(" ");
return (
<DataTable<T>
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
loaderBackgroundBlur={2}
textSelectionDisabled
height="100%"
{...props}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
...defaultColumnProps,
noWrap: defaultColumnProps?.noWrap ?? true,
draggable: !isEditMode,
resizable: !isEditMode,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
/>
);
}
@@ -0,0 +1,83 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { Root } from "react-dom/client";
import type { DataTableColumn } from "mantine-datatable";
const tableState = vi.hoisted(() => ({
columnsOrder: ["name", "state"],
columnsWidth: [] as Record<string, string | number>[],
}));
vi.mock("mantine-datatable", () => ({
useDataTableColumns: ({ columns }: { columns: DataTableColumn<unknown>[] }) => ({
effectiveColumns: columns,
columnsOrder: tableState.columnsOrder,
columnsWidth: tableState.columnsWidth,
setColumnsOrder: vi.fn(),
setMultipleColumnWidths: vi.fn(),
}),
}));
import { usePersistedTableLayout } from "./use-persisted-table-layout";
const columns: DataTableColumn<{ name: string; state: string }>[] = [{ accessor: "name" }, { accessor: "state" }];
const columnAccessors = ["name", "state"] as const;
const LayoutHarness = ({ itemId, onLayoutChange }: { itemId: string; onLayoutChange: (layout: unknown) => void }) => {
usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: "",
columnWidths: "",
itemId,
storeKeyPrefix: "test-table",
onLayoutChange,
});
return null;
};
describe("usePersistedTableLayout", () => {
let container: HTMLDivElement;
let root: Root;
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
});
beforeEach(() => {
vi.useFakeTimers();
tableState.columnsOrder = ["name", "state"];
tableState.columnsWidth = [];
container = document.createElement("div");
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
vi.useRealTimers();
});
test("flushes a pending layout through the callback for its originating item", async () => {
const firstItemLayoutChange = vi.fn();
const secondItemLayoutChange = vi.fn();
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange }));
});
tableState.columnsOrder = ["state", "name"];
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange }));
});
expect(firstItemLayoutChange).not.toHaveBeenCalled();
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "second", onLayoutChange: secondItemLayoutChange }));
});
expect(firstItemLayoutChange).toHaveBeenCalledWith({ columnOrder: JSON.stringify(["state", "name"]) });
expect(secondItemLayoutChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,233 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { DataTableColumn } from "mantine-datatable";
import { useDataTableColumns } from "mantine-datatable";
interface TableLayoutOptions extends Record<string, unknown> {
columnOrder?: string;
columnWidths?: string;
}
interface PendingTableLayout {
layout: TableLayoutOptions;
onLayoutChange: (layout: TableLayoutOptions) => void;
storeKey: string;
}
interface UseTableLayoutPersistenceProps {
boardId: string | undefined;
hasChangeAccess: boolean;
itemId: string | undefined;
saveItemOptions: (input: { boardId: string; itemId: string; newOptions: TableLayoutOptions }) => void;
setOptions: (input: { newOptions: TableLayoutOptions }) => void;
}
interface UsePersistedTableLayoutProps<T> {
columns: DataTableColumn<T>[];
columnAccessors: readonly string[];
columnOrder: string;
columnWidths: string;
itemId: string | undefined;
storeKeyPrefix: string;
onLayoutChange: (layout: TableLayoutOptions) => void;
}
const layoutSaveDelay = 350;
export const useTableLayoutPersistence = ({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
}: UseTableLayoutPersistenceProps) =>
useCallback(
(newOptions: TableLayoutOptions) => {
setOptions({ newOptions });
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
},
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
);
const toPixelWidth = (width: string | number): number | undefined => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width !== "string" || !width.endsWith("px")) return undefined;
const parsedWidth = Number.parseInt(width, 10);
return Number.isFinite(parsedWidth) && parsedWidth > 0 ? parsedWidth : undefined;
};
const getColumnWidthMap = (
columnsWidth: Record<string, string | number>[],
visibleAccessorSet: ReadonlySet<string>,
): Record<string, number> => {
const widths: Record<string, number> = {};
for (const entry of columnsWidth) {
for (const [accessor, width] of Object.entries(entry)) {
if (!visibleAccessorSet.has(accessor)) continue;
const pixelWidth = toPixelWidth(width);
if (pixelWidth !== undefined) widths[accessor] = pixelWidth;
}
}
return widths;
};
export const parseColumnOrder = (value: string, columnAccessors: readonly string[]): string[] => {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) return [];
const allowed = new Set(columnAccessors);
return parsed.filter(
(accessor, index): accessor is string =>
typeof accessor === "string" && allowed.has(accessor) && parsed.indexOf(accessor) === index,
);
} catch {
return [];
}
};
export const parseColumnWidths = (value: string, columnAccessors: readonly string[]): Record<string, number> => {
if (!value) return {};
try {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
const allowed = new Set(columnAccessors);
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, number] =>
allowed.has(entry[0]) && typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] > 0,
),
);
} catch {
return {};
}
};
export const usePersistedTableLayout = <T>({
columns,
columnAccessors,
columnOrder,
columnWidths,
itemId,
storeKeyPrefix,
onLayoutChange,
}: UsePersistedTableLayoutProps<T>) => {
const savedOrder = useMemo(() => parseColumnOrder(columnOrder, columnAccessors), [columnAccessors, columnOrder]);
const savedWidths = useMemo(() => parseColumnWidths(columnWidths, columnAccessors), [columnAccessors, columnWidths]);
const visibleAccessors = useMemo(() => columns.map(({ accessor }) => String(accessor)), [columns]);
const visibleAccessorSet = useMemo(() => new Set(visibleAccessors), [visibleAccessors]);
const restoredOrder = useMemo(
() => [
...savedOrder.filter((accessor) => visibleAccessorSet.has(accessor)),
...visibleAccessors.filter((accessor) => !savedOrder.includes(accessor)),
],
[savedOrder, visibleAccessors, visibleAccessorSet],
);
const storeKey = `${storeKeyPrefix}-${itemId ?? "preview"}-${[...visibleAccessors].toSorted().join(",")}`;
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<T>({
key: storeKey,
columns,
});
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
const previousOrder = useRef(columnsOrder);
const previousWidths = useRef(columnsWidth);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
const orderMatches =
columnsOrder.length === restoredOrder.length &&
columnsOrder.every((accessor, index) => accessor === restoredOrder[index]);
const currentWidths = getColumnWidthMap(columnsWidth, visibleAccessorSet);
const visibleWidths = Object.entries(savedWidths).filter(([accessor]) => visibleAccessorSet.has(accessor));
const widthsMatch = visibleWidths.every(([accessor, width]) => currentWidths[accessor] === width);
if (orderMatches && widthsMatch) {
previousOrder.current = columnsOrder;
previousWidths.current = columnsWidth;
hydrated.current = true;
return;
}
if (!orderMatches) setColumnsOrder(restoredOrder);
if (!widthsMatch) {
setMultipleColumnWidths(visibleWidths.map(([accessor, width]) => ({ accessor, width })));
}
}, [
columnsOrder,
columnsWidth,
restoredOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
storeKey,
visibleAccessorSet,
]);
const pendingLayout = useRef<PendingTableLayout | undefined>(undefined);
const saveTimeout = useRef<number | undefined>(undefined);
const flushPendingLayout = useCallback(() => {
const pending = pendingLayout.current;
pendingLayout.current = undefined;
window.clearTimeout(saveTimeout.current);
saveTimeout.current = undefined;
if (pending) pending.onLayoutChange(pending.layout);
}, []);
useEffect(() => {
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
}, [flushPendingLayout, storeKey]);
useEffect(() => () => flushPendingLayout(), [flushPendingLayout]);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(previousOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(previousWidths.current);
previousOrder.current = columnsOrder;
previousWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
const newLayout: TableLayoutOptions = {};
if (orderChanged) {
const fullOrder = [...savedOrder, ...columnAccessors.filter((accessor) => !savedOrder.includes(accessor))];
const reorderedVisibleColumns = [...columnsOrder];
const mergedOrder = fullOrder.map((accessor) =>
visibleAccessorSet.has(accessor) ? (reorderedVisibleColumns.shift() ?? accessor) : accessor,
);
newLayout.columnOrder = JSON.stringify(mergedOrder);
}
if (widthsChanged) {
const widthMap: Record<string, number> = { ...savedWidths };
Object.assign(widthMap, getColumnWidthMap(columnsWidth, visibleAccessorSet));
newLayout.columnWidths = JSON.stringify(widthMap);
}
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
pendingLayout.current = pendingLayout.current
? { ...pendingLayout.current, layout: { ...pendingLayout.current.layout, ...newLayout } }
: { layout: newLayout, onLayoutChange, storeKey };
window.clearTimeout(saveTimeout.current);
saveTimeout.current = window.setTimeout(flushPendingLayout, layoutSaveDelay);
}, [
columnAccessors,
columnsOrder,
columnsWidth,
flushPendingLayout,
onLayoutChange,
savedOrder,
savedWidths,
storeKey,
visibleAccessorSet,
]);
return { effectiveColumns, storeKey };
};