Files
homarr/packages/widgets/src/common/use-persisted-table-layout.ts
T

234 lines
8.2 KiB
TypeScript

"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 };
};