From 400ebf55e43db533e0a9ef24268c819bb8d59571 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Wed, 12 Mar 2025 21:07:42 +0300 Subject: [PATCH] R: Improve data-table internals --- .../src/components/data-table/data-table.tsx | 83 ++---------- .../data-table/pagination-tools.tsx | 2 +- .../components/data-table/sorting-icon.tsx | 25 ++-- .../src/components/data-table/table-body.tsx | 77 ++++++----- .../components/data-table/table-header.tsx | 20 +-- .../components/data-table/use-data-table.ts | 125 ++++++++++++++++++ 6 files changed, 197 insertions(+), 135 deletions(-) create mode 100644 rsconcept/frontend/src/components/data-table/use-data-table.ts diff --git a/rsconcept/frontend/src/components/data-table/data-table.tsx b/rsconcept/frontend/src/components/data-table/data-table.tsx index 5b1d6b99..a17b0051 100644 --- a/rsconcept/frontend/src/components/data-table/data-table.tsx +++ b/rsconcept/frontend/src/components/data-table/data-table.tsx @@ -1,20 +1,13 @@ 'use client'; 'use no memo'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { type ColumnSort, createColumnHelper, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - type PaginationState, type RowData, type RowSelectionState, - type SortingState, type TableOptions, - type Updater, - useReactTable, type VisibilityState } from '@tanstack/react-table'; @@ -25,6 +18,7 @@ import { PaginationTools } from './pagination-tools'; import { TableBody } from './table-body'; import { TableFooter } from './table-footer'; import { TableHeader } from './table-header'; +import { useDataTable } from './use-data-table'; export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState }; @@ -125,62 +119,16 @@ export function DataTable({ onRowDoubleClicked, noDataComponent, - enableRowSelection, - rowSelection, - - enableHiding, - columnVisibility, - - enableSorting, - initialSorting, - - enablePagination, - paginationPerPage = 10, paginationOptions = [10, 20, 30, 40, 50], - onChangePaginationOption, ...restProps }: DataTableProps) { - const [sorting, setSorting] = useState(initialSorting ? [initialSorting] : []); const [lastSelected, setLastSelected] = useState(null); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: paginationPerPage - }); + const table = useDataTable({ ...restProps }); - const handleChangePagination = useCallback( - (updater: Updater) => { - setPagination(prev => { - const resolvedValue = typeof updater === 'function' ? updater(prev) : updater; - if (onChangePaginationOption && prev.pageSize !== resolvedValue.pageSize) { - onChangePaginationOption(resolvedValue.pageSize); - } - return resolvedValue; - }); - }, - [onChangePaginationOption] - ); - - const tableImpl = useReactTable({ - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: enableSorting ? getSortedRowModel() : undefined, - getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined, - - state: { - pagination: pagination, - sorting: sorting, - rowSelection: rowSelection ?? {}, - columnVisibility: columnVisibility ?? {} - }, - enableHiding: enableHiding, - onPaginationChange: enablePagination ? handleChangePagination : undefined, - onSortingChange: enableSorting ? setSorting : undefined, - enableMultiRowSelection: enableRowSelection, - ...restProps - }); - - const isEmpty = tableImpl.getRowModel().rows.length === 0; + const isPaginationEnabled = typeof table.getCanNextPage === 'function'; + const isEmpty = table.getRowModel().rows.length === 0; const fixedSize = useMemo(() => { if (!rows) { @@ -194,47 +142,40 @@ export function DataTable({ }, [rows, dense, noHeader, contentHeight]); const columnSizeVars = useMemo(() => { - const headers = tableImpl.getFlatHeaders(); + const headers = table.getFlatHeaders(); const colSizes: Record = {}; for (const header of headers) { colSizes[`--header-${header.id}-size`] = header.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); } return colSizes; - }, [tableImpl]); + }, [table]); return (
{!noHeader ? ( - setLastSelected(null)} - /> + setLastSelected(null)} /> ) : null} - {!noFooter ? : null} + {!noFooter ? : null}
- {enablePagination && !isEmpty ? ( + {isPaginationEnabled && !isEmpty ? ( ) : null} diff --git a/rsconcept/frontend/src/components/data-table/pagination-tools.tsx b/rsconcept/frontend/src/components/data-table/pagination-tools.tsx index 4fe93cdd..0d792c3b 100644 --- a/rsconcept/frontend/src/components/data-table/pagination-tools.tsx +++ b/rsconcept/frontend/src/components/data-table/pagination-tools.tsx @@ -1,5 +1,5 @@ -'use client'; 'use no memo'; +'use client'; import { useCallback } from 'react'; import { type Table } from '@tanstack/react-table'; diff --git a/rsconcept/frontend/src/components/data-table/sorting-icon.tsx b/rsconcept/frontend/src/components/data-table/sorting-icon.tsx index 4503b582..4cea117b 100644 --- a/rsconcept/frontend/src/components/data-table/sorting-icon.tsx +++ b/rsconcept/frontend/src/components/data-table/sorting-icon.tsx @@ -1,20 +1,15 @@ -'use no memo'; - -import { type Column } from '@tanstack/react-table'; - import { IconSortAsc, IconSortDesc } from '../icons'; -interface SortingIconProps { - column: Column; +interface SortingIconProps { + sortDirection?: 'asc' | 'desc' | false; } -export function SortingIcon({ column }: SortingIconProps) { - return ( - <> - {{ - desc: , - asc: - }[column.getIsSorted() as string] ?? } - - ); +export function SortingIcon({ sortDirection }: SortingIconProps) { + if (sortDirection === 'asc') { + return ; + } + if (sortDirection === 'desc') { + return ; + } + return ; } diff --git a/rsconcept/frontend/src/components/data-table/table-body.tsx b/rsconcept/frontend/src/components/data-table/table-body.tsx index 8277919f..9e2968df 100644 --- a/rsconcept/frontend/src/components/data-table/table-body.tsx +++ b/rsconcept/frontend/src/components/data-table/table-body.tsx @@ -1,5 +1,6 @@ 'use no memo'; +import { useCallback } from 'react'; import { type Cell, flexRender, type Row, type Table } from '@tanstack/react-table'; import clsx from 'clsx'; @@ -10,7 +11,6 @@ interface TableBodyProps { table: Table; dense?: boolean; noHeader?: boolean; - enableRowSelection?: boolean; conditionalRowStyles?: IConditionalStyle[]; lastSelected: string | null; @@ -24,44 +24,49 @@ export function TableBody({ table, dense, noHeader, - enableRowSelection, conditionalRowStyles, lastSelected, onChangeLastSelected, onRowClicked, onRowDoubleClicked }: TableBodyProps) { - function handleRowClicked(target: Row, event: React.MouseEvent) { - onRowClicked?.(target.original, event); - if (enableRowSelection && target.getCanSelect()) { - if (event.shiftKey && !!lastSelected && lastSelected !== target.id) { - const { rows, rowsById } = table.getRowModel(); - const lastIndex = rowsById[lastSelected].index; - const currentIndex = target.index; - const toggleRows = rows.slice( - lastIndex > currentIndex ? currentIndex : lastIndex + 1, - lastIndex > currentIndex ? lastIndex : currentIndex + 1 - ); - const newSelection: Record = {}; - toggleRows.forEach(row => { - newSelection[row.id] = !target.getIsSelected(); - }); - table.setRowSelection(prev => ({ ...prev, ...newSelection })); - onChangeLastSelected(null); - } else { - onChangeLastSelected(target.id); - target.toggleSelected(!target.getIsSelected()); + const handleRowClicked = useCallback( + (target: Row, event: React.MouseEvent) => { + onRowClicked?.(target.original, event); + if (target.getCanSelect()) { + if (event.shiftKey && !!lastSelected && lastSelected !== target.id) { + const { rows, rowsById } = table.getRowModel(); + const lastIndex = rowsById[lastSelected].index; + const currentIndex = target.index; + const toggleRows = rows.slice( + lastIndex > currentIndex ? currentIndex : lastIndex + 1, + lastIndex > currentIndex ? lastIndex : currentIndex + 1 + ); + const newSelection: Record = {}; + toggleRows.forEach(row => { + newSelection[row.id] = !target.getIsSelected(); + }); + table.setRowSelection(prev => ({ ...prev, ...newSelection })); + onChangeLastSelected(null); + } else { + onChangeLastSelected(target.id); + target.toggleSelected(!target.getIsSelected()); + } } - } - } + }, + [table, lastSelected, onChangeLastSelected, onRowClicked] + ); - function getRowStyles(row: Row) { - return { - ...conditionalRowStyles! - .filter(item => item.when(row.original)) - .reduce((prev, item) => ({ ...prev, ...item.style }), {}) - }; - } + const getRowStyles = useCallback( + (row: Row) => { + return { + ...conditionalRowStyles! + .filter(item => item.when(row.original)) + .reduce((prev, item) => ({ ...prev, ...item.style }), {}) + }; + }, + [conditionalRowStyles] + ); return ( @@ -72,11 +77,15 @@ export function TableBody({ 'cc-scroll-row', 'clr-hover cc-animate-color', !noHeader && 'scroll-mt-[calc(2px+2rem)]', - row.getIsSelected() ? 'clr-selected' : 'odd:bg-prim-200 even:bg-prim-100' + table.options.enableRowSelection && row.getIsSelected() + ? 'clr-selected' + : 'odd:bg-prim-200 even:bg-prim-100' )} style={{ ...(conditionalRowStyles ? getRowStyles(row) : []) }} + onClick={event => handleRowClicked(row, event)} + onDoubleClick={event => onRowDoubleClicked?.(row.original, event)} > - {enableRowSelection ? ( + {table.options.enableRowSelection ? ( @@ -91,8 +100,6 @@ export function TableBody({ paddingTop: dense ? '0.25rem' : '0.5rem', width: noHeader && index === 0 ? `calc(var(--col-${cell.column.id}-size) * 1px)` : 'auto' }} - onClick={event => handleRowClicked(row, event)} - onDoubleClick={event => onRowDoubleClicked?.(row.original, event)} > {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/rsconcept/frontend/src/components/data-table/table-header.tsx b/rsconcept/frontend/src/components/data-table/table-header.tsx index 5e85a90b..0a757687 100644 --- a/rsconcept/frontend/src/components/data-table/table-header.tsx +++ b/rsconcept/frontend/src/components/data-table/table-header.tsx @@ -8,18 +8,10 @@ import { SortingIcon } from './sorting-icon'; interface TableHeaderProps { table: Table; headPosition?: string; - enableRowSelection?: boolean; - enableSorting?: boolean; resetLastSelected: () => void; } -export function TableHeader({ - table, - headPosition, - enableRowSelection, - enableSorting, - resetLastSelected -}: TableHeaderProps) { +export function TableHeader({ table, headPosition, resetLastSelected }: TableHeaderProps) { return ( ({ > {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( - {enableRowSelection ? ( + {table.options.enableRowSelection ? ( @@ -43,14 +35,16 @@ export function TableHeader({ className='cc-table-header group' style={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, - cursor: enableSorting && header.column.getCanSort() ? 'pointer' : 'auto' + cursor: table.options.enableSorting && header.column.getCanSort() ? 'pointer' : 'auto' }} - onClick={enableSorting ? header.column.getToggleSortingHandler() : undefined} + onClick={table.options.enableSorting ? header.column.getToggleSortingHandler() : undefined} > {!header.isPlaceholder ? ( {flexRender(header.column.columnDef.header, header.getContext())} - {enableSorting && header.column.getCanSort() ? : null} + {table.options.enableSorting && header.column.getCanSort() ? ( + + ) : null} ) : null} diff --git a/rsconcept/frontend/src/components/data-table/use-data-table.ts b/rsconcept/frontend/src/components/data-table/use-data-table.ts new file mode 100644 index 00000000..de3570a4 --- /dev/null +++ b/rsconcept/frontend/src/components/data-table/use-data-table.ts @@ -0,0 +1,125 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { + type ColumnSort, + createColumnHelper, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type RowData, + type RowSelectionState, + type SortingState, + type TableOptions, + type Updater, + useReactTable, + type VisibilityState +} from '@tanstack/react-table'; + +export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState }; + +/** Style to conditionally apply to rows. */ +export interface IConditionalStyle { + /** Callback to determine if the style should be applied. */ + when: (rowData: TData) => boolean; + + /** Style to apply. */ + style: React.CSSProperties; +} + +export interface UseDataTableProps + extends Pick, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> { + /** Enable row selection. */ + enableRowSelection?: boolean; + + /** Current row selection. */ + rowSelection?: RowSelectionState; + + /** Enable hiding of columns. */ + enableHiding?: boolean; + + /** Current column visibility. */ + columnVisibility?: VisibilityState; + + /** Enable pagination. */ + enablePagination?: boolean; + + /** Number of rows per page. */ + paginationPerPage?: number; + + /** Callback to be called when the pagination option is changed. */ + onChangePaginationOption?: (newValue: number) => void; + + /** Enable sorting. */ + enableSorting?: boolean; + + /** Initial sorting. */ + initialSorting?: ColumnSort; +} + +/** + * Dta representation as a table. + * + * @param headPosition - Top position of sticky header (0 if no other sticky elements are present). + * No sticky header if omitted + */ +export function useDataTable({ + enableRowSelection, + rowSelection, + + enableHiding, + columnVisibility, + + enableSorting, + initialSorting, + + enablePagination, + paginationPerPage = 10, + onChangePaginationOption, + + ...restProps +}: UseDataTableProps) { + const [sorting, setSorting] = useState(initialSorting ? [initialSorting] : []); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: paginationPerPage + }); + + const handleChangePagination = useCallback( + (updater: Updater) => { + setPagination(prev => { + const resolvedValue = typeof updater === 'function' ? updater(prev) : updater; + if (onChangePaginationOption && prev.pageSize !== resolvedValue.pageSize) { + onChangePaginationOption(resolvedValue.pageSize); + } + return resolvedValue; + }); + }, + [onChangePaginationOption] + ); + + const table = useReactTable({ + state: { + pagination: pagination, + sorting: sorting, + rowSelection: rowSelection, + columnVisibility: columnVisibility + }, + + getCoreRowModel: getCoreRowModel(), + + enableSorting: enableSorting, + getSortedRowModel: enableSorting ? getSortedRowModel() : undefined, + onSortingChange: enableSorting ? setSorting : undefined, + + getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined, + onPaginationChange: enablePagination ? handleChangePagination : undefined, + + enableHiding: enableHiding, + enableMultiRowSelection: enableRowSelection, + enableRowSelection: enableRowSelection, + ...restProps + }); + return table; +}