feat(widgets): persist Beszel and Docker table layouts (#6490)
This commit is contained in:
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 };
|
||||
};
|
||||
Reference in new issue
Block a user