metis-desk/packages/rdx-ui/src/hooks/use-entity-table.ts
2026-10-25 01:19:17 +02:00

254 lines
8.2 KiB
TypeScript

import type {
ColumnDef,
OnChangeFn,
PaginationState,
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import type { EntityColumn } from "../components/entity-list";
import {
readColumnVisibility,
writeColumnVisibility,
} from "../lib/entity-column-visibility-storage";
import { ENTITY_PAGE_SIZES, entityListRange } from "../lib/entity-list";
import type { EntityTableNavigation } from "../lib/entity-table-navigation";
import {
tableNavigationPage,
tableNavigationSorting,
tableNavigationValue,
} from "../lib/entity-table-navigation";
import { createTextMatcher } from "../lib/text-search";
const ENTITY_PAGE_SIZE_STORAGE_KEY = "entity-page-size";
function storedPageSize(): number {
if (typeof window === "undefined") {
return ENTITY_PAGE_SIZES[0];
}
try {
const value = Number(window.localStorage.getItem(ENTITY_PAGE_SIZE_STORAGE_KEY));
return (ENTITY_PAGE_SIZES as readonly number[]).includes(value) ? value : ENTITY_PAGE_SIZES[0];
} catch {
return ENTITY_PAGE_SIZES[0];
}
}
export function useEntityTable<T extends { id: string }>({
items,
columns,
locale,
search,
onSearchChange,
filterKey,
columnVisibilityStorageKey,
navigation,
initialSorting: requestedSorting,
initialPagination,
}: {
items: readonly T[];
columns: readonly EntityColumn<T>[];
locale: string;
search: string | undefined;
onSearchChange: ((value: string) => void) | undefined;
filterKey: string;
columnVisibilityStorageKey?: string | undefined;
navigation?: EntityTableNavigation | undefined;
initialSorting?: SortingState;
initialPagination?: PaginationState;
}) {
const [localSearch, setLocalSearch] = useState("");
const currentSearch = search ?? localSearch;
const sortableColumns = columns.filter((column) => column.id !== "actions" && column.value);
const defaultColumn =
sortableColumns.find((column) => column.defaultSort) ??
sortableColumns.find((column) => column.id === "name") ??
sortableColumns[0];
const initialSorting: SortingState =
requestedSorting ??
(defaultColumn ? [{ id: defaultColumn.id, desc: defaultColumn.defaultSort === "desc" }] : []);
const [localSorting, setLocalSorting] = useState<SortingState>(initialSorting);
const sorting = navigation
? tableNavigationSorting(
navigation.values,
columns.map((column) => ({
id: column.id,
sortable: column.id !== "actions" && Boolean(column.value),
})),
initialSorting
)
: localSorting;
const [visibility, setVisibility] = useState<VisibilityState>(() =>
readColumnVisibility(columnVisibilityStorageKey)
);
const [previousStorageKey, setPreviousStorageKey] = useState(columnVisibilityStorageKey);
if (previousStorageKey !== columnVisibilityStorageKey) {
setPreviousStorageKey(columnVisibilityStorageKey);
setVisibility(readColumnVisibility(columnVisibilityStorageKey));
}
useEffect(() => {
if (previousStorageKey === columnVisibilityStorageKey) {
writeColumnVisibility(columnVisibilityStorageKey, visibility);
}
}, [columnVisibilityStorageKey, previousStorageKey, visibility]);
const [localPagination, setLocalPagination] = useState<PaginationState>(
() => initialPagination ?? { pageIndex: 0, pageSize: storedPageSize() }
);
const pagination = navigation
? { ...localPagination, pageIndex: tableNavigationPage(navigation.values.page) }
: localPagination;
const setSorting: OnChangeFn<SortingState> = (update) => {
const next = typeof update === "function" ? update(sorting) : update;
if (navigation) {
navigation.onChange(tableNavigationValue(next, initialSorting, 0));
} else {
setLocalSorting(next);
}
};
const setPagination: OnChangeFn<PaginationState> = (update) => {
const next = typeof update === "function" ? update(pagination) : update;
if (!navigation) {
setLocalPagination(next);
return;
}
if (next.pageSize !== localPagination.pageSize) {
// Con control externo solo el tamaño es preferencia local; el índice pertenece al consumidor.
setLocalPagination((previous) => ({ ...previous, pageSize: next.pageSize }));
}
navigation.onChange(tableNavigationValue(sorting, initialSorting, next.pageIndex));
};
useEffect(() => {
try {
window.localStorage.setItem(ENTITY_PAGE_SIZE_STORAGE_KEY, String(pagination.pageSize));
} catch {
// La paginación sigue funcionando aunque el almacenamiento no esté disponible.
}
}, [pagination.pageSize]);
const resetKey = `${currentSearch}\0${filterKey}`;
const [previousKey, setPreviousKey] = useState(resetKey);
if (previousKey !== resetKey) {
setPreviousKey(resetKey);
if (!navigation) {
setLocalPagination((previous) => ({ ...previous, pageIndex: 0 }));
}
}
const nameCollator = useMemo(
() => new Intl.Collator(locale, { sensitivity: "base", numeric: true }),
[locale]
);
const definitions = useMemo<ColumnDef<T>[]>(
() =>
columns.map((column) => ({
id: column.id,
header: column.label,
accessorFn: column.value ?? (() => ""),
sortingFn:
column.id === "name"
? (first, second, columnId) =>
nameCollator.compare(
String(first.getValue(columnId) ?? ""),
String(second.getValue(columnId) ?? "")
)
: "auto",
enableSorting: column.id !== "actions" && !!column.value,
enableGlobalFilter: column.id !== "actions" && !!column.value,
enableHiding: column.id !== "actions" && column.hideable !== false,
})),
[columns, nameCollator]
);
const matchesSearch = createTextMatcher(currentSearch);
const table = useReactTable({
data: [...items],
columns: definitions,
initialState: { sorting: initialSorting },
enableSortingRemoval: false,
getRowId: (item) => item.id,
state: {
sorting,
columnVisibility: Object.fromEntries(
columns.map((column) => [
column.id,
column.hideable === false || column.id === "actions"
? true
: (visibility[column.id] ?? column.defaultVisible ?? true),
])
),
columnPinning: {
right: columns.filter((column) => column.id === "actions").map((column) => column.id),
},
pagination,
globalFilter: onSearchChange ? "" : currentSearch.trim(),
},
onSortingChange: setSorting,
onColumnVisibilityChange: setVisibility,
onPaginationChange: setPagination,
autoResetPageIndex: false,
globalFilterFn: (row, columnId) => matchesSearch(row.getValue(columnId)),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
const total = table.getFilteredRowModel().rows.length;
const range = entityListRange(total, pagination.pageIndex, pagination.pageSize);
// Una actualización puede reducir el conjunto sin cambiar los filtros.
if (!navigation && pagination.pageIndex !== range.page) {
setLocalPagination({ ...pagination, pageIndex: range.page });
}
const {
sort: canonicalSort,
direction: canonicalDirection,
page: canonicalPage,
} = tableNavigationValue(sorting, initialSorting, range.page);
// Sincroniza únicamente la frontera externa (URL), una vez válidos datos y definiciones.
useLayoutEffect(() => {
if (!navigation?.ready) {
return;
}
const { values } = navigation;
if (
values.sort !== (canonicalSort ?? null) ||
values.direction !== (canonicalDirection ?? null) ||
values.page !== (canonicalPage > 1 ? String(canonicalPage) : null)
) {
navigation.onChange({
sort: canonicalSort,
direction: canonicalDirection,
page: canonicalPage,
});
}
}, [navigation, canonicalSort, canonicalDirection, canonicalPage]);
return {
table,
total,
range,
pagination,
setPagination,
sorting,
setSorting,
currentSearch,
setLocalSearch,
};
}