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({ items, columns, locale, search, onSearchChange, filterKey, columnVisibilityStorageKey, navigation, initialSorting: requestedSorting, initialPagination, }: { items: readonly T[]; columns: readonly EntityColumn[]; 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(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(() => 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( () => initialPagination ?? { pageIndex: 0, pageSize: storedPageSize() } ); const pagination = navigation ? { ...localPagination, pageIndex: tableNavigationPage(navigation.values.page) } : localPagination; const setSorting: OnChangeFn = (update) => { const next = typeof update === "function" ? update(sorting) : update; if (navigation) { navigation.onChange(tableNavigationValue(next, initialSorting, 0)); } else { setLocalSorting(next); } }; const setPagination: OnChangeFn = (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[]>( () => 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, }; }