254 lines
8.2 KiB
TypeScript
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,
|
|
};
|
|
}
|