diff --git a/docs/api/features/column-pinning.md b/docs/api/features/pinning.md similarity index 63% rename from docs/api/features/column-pinning.md rename to docs/api/features/pinning.md index 2b0c3ac769..59b58bf7f3 100644 --- a/docs/api/features/column-pinning.md +++ b/docs/api/features/pinning.md @@ -1,6 +1,6 @@ --- -title: Column Pinning -id: column-pinning +title: Pinning +id: pinning --- ## Can-Pin @@ -8,23 +8,37 @@ id: column-pinning The ability for a column to be **pinned** is determined by the following: - `options.enablePinning` is not set to `false` +- `options.enableColumnPinning` is not set to `false` - `columnDefinition.enablePinning` is not set to `false` +The ability for a row to be **pinned** is determined by the following: + +- `options.enableRowPinning` resolves to `true` +- `options.enablePinning` is not set to `false` + ## State -Column pinning state is stored on the table using the following shape: +Pinning state is stored on the table using the following shape: ```tsx export type ColumnPinningPosition = false | 'left' | 'right' +export type RowPinningPosition = false | 'top' | 'bottom' export type ColumnPinningState = { left?: string[] right?: string[] } +export type RowPinningState = { + top?: boolean + bottom?: boolean +} export type ColumnPinningTableState = { columnPinning: ColumnPinningState } +export type RowPinningRowState = { + rowPinning: RowPinningState +} ``` ## Table Options @@ -37,6 +51,30 @@ enablePinning?: boolean Enables/disables all pinning for the table. +### `enableColumnPinning` + +```tsx +enableColumnPinning?: boolean +``` + +Enables/disables column pinning for all columns in the table. + +### `enableRowPinning` + +```tsx +enableRowPinning?: boolean | ((row: Row) => boolean) +``` + +Enables/disables row pinning for all rows in the table. + +### `keepPinnedRows` + +```tsx +keepPinnedRows?: boolean +``` + +When `false`, pinned rows will not be visible if they are filtered or paginated out of the table. When `true`, pinned rows will always be visible regardless of filtering or pagination. Defaults to `true`. + ### `onColumnPinningChange` ```tsx @@ -45,6 +83,14 @@ onColumnPinningChange?: OnChangeFn If provided, this function will be called with an `updaterFn` when `state.columnPinning` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. +### `onRowPinningChange` + +```tsx +onRowPinningChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.rowPinning` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + ## Column Def Options ### `enablePinning` @@ -65,6 +111,14 @@ setColumnPinning: (updater: Updater) => void Sets or updates the `state.columnPinning` state. +### `setRowPinning` + +```tsx +setRowPinning: (updater: Updater) => void +``` + +Sets or updates the `state.rowPinning` state. + ### `resetColumnPinning` ```tsx @@ -73,6 +127,14 @@ resetColumnPinning: (defaultState?: boolean) => void Resets the **columnPinning** state to `initialState.columnPinning`, or `true` can be passed to force a default blank state reset to `{ left: [], right: [], }`. +### `resetRowPinning` + +```tsx +resetRowPinning: (defaultState?: boolean) => void +``` + +Resets the **rowPinning** state to `initialState.rowPinning`, or `true` can be passed to force a default blank state reset to `{}`. + ### `getIsSomeColumnsPinned` ```tsx @@ -83,6 +145,14 @@ Returns whether or not any columns are pinned. Optionally specify to only check _Note: Does not account for column visibility_ +### `getIsSomeRowsPinned` + +```tsx +getIsSomeRowsPinned: (position?: RowPinningPosition) => boolean +``` + +Returns whether or not any rows are pinned. Optionally specify to only check for pinned rows in either the `top` or `bottom` position. + ### `getLeftHeaderGroups` ```tsx @@ -203,6 +273,30 @@ getCenterLeafColumns: () => Column < TData > [] Returns all center pinned (unpinned) leaf columns. +### `getTopRows` + +```tsx +getTopRows: () => Row < TData > [] +``` + +Returns all top pinned rows. + +### `getBottomRows` + +```tsx +getBottomRows: () => Row < TData > [] +``` + +Returns all bottom pinned rows. + +### `getCenterRows` + +```tsx +getCenterRows: () => Row < TData > [] +``` + +Returns all rows that are not pinned to the top or bottom. + ## Column API ### `getCanPin` @@ -239,6 +333,38 @@ Pins a column to the `'left'` or `'right'`, or unpins the column to the center i ## Row API +### `pin` + +```tsx +pin: (position: RowPinningPosition) => void +``` + +Pins a row to the `'top'` or `'bottom'`, or unpins the row to the center if `false` is passed. + +### `getCanPin` + +```tsx +getCanPin: () => boolean +``` + +Returns whether or not the row can be pinned. + +### `getIsPinned` + +```tsx +getIsPinned: () => RowPinningPosition +``` + +Returns the pinned position of the row. (`'top'`, `'bottom'` or `false`) + +### `getPinnedIndex` + +```tsx +getPinnedIndex: () => number +``` + +Returns the numeric pinned index of the row within a pinned row group. + ### `getLeftVisibleCells` ```tsx diff --git a/docs/config.json b/docs/config.json index 64ecd80892..ea2f73b86a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -77,10 +77,6 @@ "label": "Column Ordering", "to": "guide/column-ordering" }, - { - "label": "Column Pinning", - "to": "guide/column-pinning" - }, { "label": "Column Sizing", "to": "guide/column-sizing" @@ -109,6 +105,10 @@ "label": "Pagination", "to": "guide/pagination" }, + { + "label": "Pinning", + "to": "guide/pinning" + }, { "label": "Row Selection", "to": "guide/row-selection" @@ -155,10 +155,6 @@ "label": "Column Ordering", "to": "api/features/column-ordering" }, - { - "label": "Column Pinning", - "to": "api/features/column-pinning" - }, { "label": "Column Sizing", "to": "api/features/column-sizing" @@ -187,6 +183,10 @@ "label": "Pagination", "to": "api/features/pagination" }, + { + "label": "Pinning", + "to": "api/features/pinning" + }, { "label": "Row Selection", "to": "api/features/row-selection" @@ -269,6 +269,10 @@ "to": "examples/react/row-dnd", "label": "Row DnD" }, + { + "to": "examples/react/row-pinning", + "label": "Row Pinning" + }, { "to": "examples/react/row-selection", "label": "Row Selection" @@ -396,4 +400,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/docs/guide/column-pinning.md b/docs/guide/pinning.md similarity index 68% rename from docs/guide/column-pinning.md rename to docs/guide/pinning.md index 3450c5f696..dded2c74ed 100644 --- a/docs/guide/column-pinning.md +++ b/docs/guide/pinning.md @@ -1,5 +1,5 @@ --- -title: Column Pinning +title: Pinning --- ## Examples @@ -7,10 +7,11 @@ title: Column Pinning Want to skip to the implementation? Check out these examples: - [column-pinning](../examples/react/column-pinning) +- [row-pinning](../examples/react/row-pinning) ## API -[Column Pinning API](../api/features/column-pinning) +[Pinning API](../api/features/pinning) ## Overview @@ -19,3 +20,8 @@ There are 3 table features that can reorder columns, which happen in the followi 1. **Column Pinning** - If pinning, columns are split into left, center (unpinned), and right pinned columns. 2. Manual [Column Ordering](../guide/column-ordering) - A manually specified column order is applied. 3. [Grouping](../guide/grouping) - If grouping is enabled, a grouping state is active, and `tableOptions.columnGroupingMode` is set to `'reorder' | 'remove'`, then the grouped columns are reordered to the start of the column flow. + +There are 2 table features that can reorder rows, which happen in the following order: + +1. **Row Pinning** - If pinning, rows are split into top, center (unpinned), and bottom pinned rows. +2. [Sorting](../guide/sorting) \ No newline at end of file diff --git a/examples/react/row-pinning/.gitignore b/examples/react/row-pinning/.gitignore new file mode 100644 index 0000000000..d451ff16c1 --- /dev/null +++ b/examples/react/row-pinning/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/react/row-pinning/README.md b/examples/react/row-pinning/README.md new file mode 100644 index 0000000000..b168d3c4b1 --- /dev/null +++ b/examples/react/row-pinning/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run start` or `yarn start` diff --git a/examples/react/row-pinning/index.html b/examples/react/row-pinning/index.html new file mode 100644 index 0000000000..dfcecb0b7b --- /dev/null +++ b/examples/react/row-pinning/index.html @@ -0,0 +1,13 @@ + + + + + + Vite App + + + +
+ + + diff --git a/examples/react/row-pinning/package.json b/examples/react/row-pinning/package.json new file mode 100644 index 0000000000..91af231582 --- /dev/null +++ b/examples/react/row-pinning/package.json @@ -0,0 +1,22 @@ +{ + "name": "tanstack-table-example-row-pinning", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview --port 3000", + "start": "vite" + }, + "dependencies": { + "@faker-js/faker": "^8.0.2", + "@tanstack/react-table": "8.9.11", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@rollup/plugin-replace": "^5.0.1", + "@vitejs/plugin-react": "^2.2.0", + "vite": "^3.2.3" + } +} diff --git a/examples/react/row-pinning/src/index.css b/examples/react/row-pinning/src/index.css new file mode 100644 index 0000000000..93034cdd1b --- /dev/null +++ b/examples/react/row-pinning/src/index.css @@ -0,0 +1,35 @@ +html { + font-family: sans-serif; + font-size: 14px; +} + +table { + border: 1px solid lightgray; +} + +tbody { + border-bottom: 1px solid lightgray; +} + +th { + border-bottom: 1px solid lightgray; + border-right: 1px solid lightgray; + padding: 2px 4px; +} + +td { + border-right: 1px solid lightgray; + padding: 2px 4px; +} + +td:last-child { + border-right: 0; +} + +tfoot { + color: gray; +} + +tfoot th { + font-weight: normal; +} diff --git a/examples/react/row-pinning/src/main.tsx b/examples/react/row-pinning/src/main.tsx new file mode 100644 index 0000000000..658b53c864 --- /dev/null +++ b/examples/react/row-pinning/src/main.tsx @@ -0,0 +1,424 @@ +import React, { HTMLProps } from 'react' +import ReactDOM from 'react-dom/client' + +import './index.css' + +import { makeData, Person } from './makeData' + +import { + Column, + ColumnDef, + ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getPaginationRowModel, + Row, + RowPinningState, + Table, + useReactTable, +} from '@tanstack/react-table' + +function App() { + const rerender = React.useReducer(() => ({}), {})[1] + + //table states + const [rowPinning, setRowPinning] = React.useState({ + top: [], + bottom: [], + }) + const [expanded, setExpanded] = React.useState({}) + + //demo states + const [keepPinnedRows, setKeepPinnedRows] = React.useState(true) + const [includeLeafRows, setIncludeLeafRows] = React.useState(true) + const [includeParentRows, setIncludeParentRows] = React.useState(false) + const [copyPinnedRows, setCopyPinnedRows] = React.useState(false) + + const columns = React.useMemo[]>( + () => [ + { + id: 'pin', + header: () => 'Pin', + cell: ({ row }) => + row.getIsPinned() ? ( + + ) : ( +
+ + +
+ ), + }, + { + accessorKey: 'firstName', + header: ({ table }) => ( + <> + {' '} + First Name + + ), + cell: ({ row, getValue }) => ( +
+ <> + {row.getCanExpand() ? ( + + ) : ( + '🔵' + )}{' '} + {getValue()} + +
+ ), + footer: props => props.column.id, + }, + { + accessorFn: row => row.lastName, + id: 'lastName', + cell: info => info.getValue(), + header: () => Last Name, + }, + { + accessorKey: 'age', + header: () => 'Age', + size: 50, + }, + { + accessorKey: 'visits', + header: () => Visits, + size: 50, + }, + { + accessorKey: 'status', + header: 'Status', + }, + { + accessorKey: 'progress', + header: 'Profile Progress', + size: 80, + }, + ], + [includeLeafRows, includeParentRows] + ) + + const [data, setData] = React.useState(() => makeData(1000, 2, 2)) + const refreshData = () => setData(() => makeData(1000, 2, 2)) + + const table = useReactTable({ + data, + columns, + initialState: { pagination: { pageSize: 20, pageIndex: 0 } }, + state: { + expanded, + rowPinning, + }, + onExpandedChange: setExpanded, + onRowPinningChange: setRowPinning, + getSubRows: row => row.subRows, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + keepPinnedRows, + debugRows: true, + }) + + return ( +
+
+
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + return ( + + ) + })} + + ))} + + + {table.getTopRows().map(row => ( + + ))} + {(copyPinnedRows + ? table.getRowModel().rows + : table.getCenterRows() + ).map(row => { + return ( + + {row.getVisibleCells().map(cell => { + return ( + + ) + })} + + ) + })} + {table.getBottomRows().map(row => ( + + ))} + +
+ {header.isPlaceholder ? null : ( + <> + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getCanFilter() ? ( +
+ +
+ ) : null} + + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ +
+
+ + + + + +
Page
+ + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + +
+ + | Go to page: + { + const page = e.target.value ? Number(e.target.value) - 1 : 0 + table.setPageIndex(page) + }} + className="border p-1 rounded w-16" + /> + + +
+
+
+
+
+
+ setKeepPinnedRows(!keepPinnedRows)} + /> + +
+
+ setIncludeLeafRows(!includeLeafRows)} + /> + +
+
+ setIncludeParentRows(!includeParentRows)} + /> + +
+
+ setCopyPinnedRows(!copyPinnedRows)} + /> + +
+
+
+ +
+
+ +
+
{JSON.stringify(rowPinning, null, 2)}
+
+ ) +} + +function PinnedRow({ row, table }: { row: Row; table: Table }) { + return ( + + {row.getVisibleCells().map(cell => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) +} + +function Filter({ + column, + table, +}: { + column: Column + table: Table +}) { + const firstValue = table + .getPreFilteredRowModel() + .flatRows[0]?.getValue(column.id) + + return typeof firstValue === 'number' ? ( +
+ + column.setFilterValue((old: any) => [e.target.value, old?.[1]]) + } + placeholder={`Min`} + className="w-24 border shadow rounded" + /> + + column.setFilterValue((old: any) => [old?.[0], e.target.value]) + } + placeholder={`Max`} + className="w-24 border shadow rounded" + /> +
+ ) : ( + column.setFilterValue(e.target.value)} + placeholder={`Search...`} + className="w-36 border shadow rounded" + /> + ) +} + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +ReactDOM.createRoot(rootElement).render( + + + +) diff --git a/examples/react/row-pinning/src/makeData.ts b/examples/react/row-pinning/src/makeData.ts new file mode 100644 index 0000000000..331dd1eb19 --- /dev/null +++ b/examples/react/row-pinning/src/makeData.ts @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker' + +export type Person = { + firstName: string + lastName: string + age: number + visits: number + progress: number + status: 'relationship' | 'complicated' | 'single' + subRows?: Person[] +} + +const range = (len: number) => { + const arr: number[] = [] + for (let i = 0; i < len; i++) { + arr.push(i) + } + return arr +} + +const newPerson = (): Person => { + return { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + age: faker.number.int(40), + visits: faker.number.int(1000), + progress: faker.number.int(100), + status: faker.helpers.shuffle([ + 'relationship', + 'complicated', + 'single', + ])[0]!, + } +} + +export function makeData(...lens: number[]) { + const makeDataLevel = (depth = 0): Person[] => { + const len = lens[depth]! + return range(len).map((d): Person => { + return { + ...newPerson(), + subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined, + } + }) + } + + return makeDataLevel() +} diff --git a/examples/react/row-pinning/tsconfig.dev.json b/examples/react/row-pinning/tsconfig.dev.json new file mode 100644 index 0000000000..c09bc865f0 --- /dev/null +++ b/examples/react/row-pinning/tsconfig.dev.json @@ -0,0 +1,12 @@ +{ + "composite": true, + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./build/types" + }, + "files": ["src/main.tsx"], + "include": [ + "src" + // "__tests__/**/*.test.*" + ] +} diff --git a/examples/react/row-pinning/vite.config.js b/examples/react/row-pinning/vite.config.js new file mode 100644 index 0000000000..e567193bbf --- /dev/null +++ b/examples/react/row-pinning/vite.config.js @@ -0,0 +1,25 @@ +import * as path from 'path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import rollupReplace from '@rollup/plugin-replace' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(true), + 'process.env.NODE_ENV': JSON.stringify('development'), + }, + }), + react(), + ], + resolve: process.env.USE_SOURCE + ? { + alias: { + 'react-table': path.resolve(__dirname, '../../../src/index.ts'), + }, + } + : {}, +}) diff --git a/package.json b/package.json index be457c4863..0ff6fbd9ea 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "./examples/react/pagination", "./examples/react/pagination-controlled", "./examples/react/row-dnd", + "./examples/react/row-pinning", "./examples/react/row-selection", "./examples/react/sorting", "./examples/react/sub-components", @@ -117,4 +118,4 @@ "vitest": "^0.29.3", "vue": "^3.2.33" } -} +} \ No newline at end of file diff --git a/packages/table-core/__tests__/Pinning.test.ts b/packages/table-core/__tests__/Pinning.test.ts new file mode 100644 index 0000000000..8912362c95 --- /dev/null +++ b/packages/table-core/__tests__/Pinning.test.ts @@ -0,0 +1,243 @@ +import { + ColumnDef, + createColumnHelper, + createTable, + getCoreRowModel, + getPaginationRowModel, +} from '../src' +import * as Pinning from '../src/features/Pinning' +import { makeData, Person } from './makeTestData' + +type personKeys = keyof Person +type PersonColumn = ColumnDef + +function generateColumns(people: Person[]): PersonColumn[] { + const columnHelper = createColumnHelper() + const person = people[0] + return Object.keys(person).map(key => { + const typedKey = key as personKeys + return columnHelper.accessor(typedKey, { id: typedKey }) + }) +} + +describe('Pinning', () => { + describe('createTable', () => { + describe('_getPinnedRows', () => { + it('should return pinned rows when keepPinnedRows is true rows are visible', () => { + const data = makeData(10) + const columns = generateColumns(data) + + const table = createTable({ + enableRowPinning: true, + keepPinnedRows: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { + pagination: { + pageSize: 5, + pageIndex: 0, //pinned rows will be on page 0 + }, + rowPinning: { + top: ['0', '1'], + }, + }, + columns, + getPaginationRowModel: getPaginationRowModel(), + getCoreRowModel: getCoreRowModel(), + }) + + const result = table._getPinnedRows('top') + + expect(result.length).toBe(2) + expect(result[0].id).toBe('0') + expect(result[1].id).toBe('1') + }) + it('should return pinned rows when keepPinnedRows is true rows are not visible', () => { + const data = makeData(10) + const columns = generateColumns(data) + + const table = createTable({ + enableRowPinning: true, + keepPinnedRows: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { + pagination: { + pageSize: 5, + pageIndex: 1, //pinned rows will be on page 0 + }, + rowPinning: { + top: ['0', '1'], + }, + }, + columns, + getPaginationRowModel: getPaginationRowModel(), + getCoreRowModel: getCoreRowModel(), + }) + + const result = table._getPinnedRows('top') + + expect(result.length).toBe(2) + expect(result[0].id).toBe('0') + expect(result[1].id).toBe('1') + }) + it('should return pinned rows when keepPinnedRows is false rows are visible', () => { + const data = makeData(10) + const columns = generateColumns(data) + + const table = createTable({ + enableRowPinning: true, + keepPinnedRows: false, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { + pagination: { + pageSize: 5, + pageIndex: 0, //pinned rows will be on page 0 + }, + rowPinning: { + top: ['0', '1'], + }, + }, + columns, + getPaginationRowModel: getPaginationRowModel(), + getCoreRowModel: getCoreRowModel(), + }) + + const result = table._getPinnedRows('top') + + expect(result.length).toBe(2) + expect(result[0].id).toBe('0') + expect(result[1].id).toBe('1') + }) + it('should not return pinned rows when keepPinnedRows is false and rows are not visible', () => { + const data = makeData(10) + const columns = generateColumns(data) + + const table = createTable({ + enableRowPinning: true, + keepPinnedRows: false, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { + pagination: { + pageSize: 5, + pageIndex: 1, //pinned rows will be on page 0, but this is page 1 + }, + rowPinning: { + top: ['0', '1'], + }, + }, + columns, + getPaginationRowModel: getPaginationRowModel(), + getCoreRowModel: getCoreRowModel(), + }) + + const result = table._getPinnedRows('top') + + expect(result.length).toBe(0) + }) + }) + describe('getTopRows', () => { + it('should return correct top rows', () => { + const data = makeData(10) + const columns = generateColumns(data) + + const table = createTable({ + enableRowPinning: true, + keepPinnedRows: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { + pagination: { + pageSize: 5, + pageIndex: 0, //pinned rows will be on page 0 + }, + rowPinning: { + top: ['1', '3'], + }, + }, + columns, + getPaginationRowModel: getPaginationRowModel(), + getCoreRowModel: getCoreRowModel(), + }) + + const result = table.getTopRows() + + expect(result.length).toBe(2) + expect(result[0].id).toBe('1') + expect(result[1].id).toBe('3') + }) + }) + describe('getBottomRows', () => { + it('should return correct bottom rows', () => { + const data = makeData(10) + const columns = generateColumns(data) + + const table = createTable({ + enableRowPinning: true, + keepPinnedRows: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { + pagination: { + pageSize: 5, + pageIndex: 0, //pinned rows will be on page 0 + }, + rowPinning: { + bottom: ['1', '3'], + }, + }, + columns, + getPaginationRowModel: getPaginationRowModel(), + getCoreRowModel: getCoreRowModel(), + }) + + const result = table.getBottomRows() + + expect(result.length).toBe(2) + expect(result[0].id).toBe('1') + expect(result[1].id).toBe('3') + }) + }) + describe('getCenterRows', () => { + it('should return all rows except any pinned rows', () => { + const data = makeData(6) + const columns = generateColumns(data) + + const table = createTable({ + enableRowPinning: true, + keepPinnedRows: true, + onStateChange() {}, + renderFallbackValue: '', + data, + state: { + pagination: { + pageSize: 10, + pageIndex: 0, + }, + rowPinning: { + top: ['1', '3'], + bottom: ['2', '4'], + }, + }, + columns, + getPaginationRowModel: getPaginationRowModel(), + getCoreRowModel: getCoreRowModel(), + }) + + const result = table.getCenterRows() + + expect(result.length).toBe(2) + expect(result[0].id).toBe('0') // 0 and 5 are the only rows not pinned + expect(result[1].id).toBe('5') + }) + }) + }) +}) diff --git a/packages/table-core/src/core/table.ts b/packages/table-core/src/core/table.ts index 6e1c310114..701cb5cb7b 100644 --- a/packages/table-core/src/core/table.ts +++ b/packages/table-core/src/core/table.ts @@ -99,7 +99,7 @@ export interface CoreInstance { getCoreRowModel: () => RowModel _getCoreRowModel?: () => RowModel getRowModel: () => RowModel - getRow: (id: string) => Row + getRow: (id: string, searchAll?: boolean) => Row _getDefaultColumnDef: () => Partial> _getColumnDefs: () => ColumnDef[] _getAllFlatColumnsById: () => Record> @@ -213,8 +213,9 @@ export function createTable( getRowModel: () => { return table.getPaginationRowModel() }, - getRow: (id: string) => { - const row = table.getRowModel().rowsById[id] + getRow: (id: string, searchAll?: boolean) => { + const row = (searchAll ? table.getCoreRowModel() : table.getRowModel()) + .rowsById[id] if (!row) { if (process.env.NODE_ENV !== 'production') { @@ -311,10 +312,13 @@ export function createTable( _getAllFlatColumnsById: memo( () => [table.getAllFlatColumns()], flatColumns => { - return flatColumns.reduce((acc, column) => { - acc[column.id] = column - return acc - }, {} as Record>) + return flatColumns.reduce( + (acc, column) => { + acc[column.id] = column + return acc + }, + {} as Record> + ) }, { key: process.env.NODE_ENV === 'development' && 'getAllFlatColumnsById', diff --git a/packages/table-core/src/features/Expanding.ts b/packages/table-core/src/features/Expanding.ts index 18eb26f3bc..d86fafe38e 100644 --- a/packages/table-core/src/features/Expanding.ts +++ b/packages/table-core/src/features/Expanding.ts @@ -13,6 +13,7 @@ export interface ExpandedRow { toggleExpanded: (expanded?: boolean) => void getIsExpanded: () => boolean getCanExpand: () => boolean + getIsAllParentsExpanded: () => boolean getToggleExpandedHandler: () => () => void } @@ -210,6 +211,17 @@ export const Expanding: TableFeature = { ((table.options.enableExpanding ?? true) && !!row.subRows?.length) ) } + row.getIsAllParentsExpanded = () => { + let isFullyExpanded = true + let currentRow = row + + while (isFullyExpanded && currentRow.parentId) { + currentRow = table.getRow(currentRow.parentId, true) + isFullyExpanded = currentRow.getIsExpanded() + } + + return isFullyExpanded + } row.getToggleExpandedHandler = () => { const canExpand = row.getCanExpand() diff --git a/packages/table-core/src/features/Pinning.ts b/packages/table-core/src/features/Pinning.ts index a20ff29e00..fa59d470df 100644 --- a/packages/table-core/src/features/Pinning.ts +++ b/packages/table-core/src/features/Pinning.ts @@ -11,25 +11,46 @@ import { import { makeStateUpdater, memo } from '../utils' export type ColumnPinningPosition = false | 'left' | 'right' +export type RowPinningPosition = false | 'top' | 'bottom' export interface ColumnPinningState { left?: string[] right?: string[] } +export interface RowPinningState { + top?: string[] + bottom?: string[] +} + export interface ColumnPinningTableState { columnPinning: ColumnPinningState } +export interface RowPinningTableState { + rowPinning: RowPinningState +} + export interface ColumnPinningOptions { onColumnPinningChange?: OnChangeFn enablePinning?: boolean + enableColumnPinning?: boolean +} + +export interface RowPinningOptions { + onRowPinningChange?: OnChangeFn + enableRowPinning?: boolean | ((row: Row) => boolean) + keepPinnedRows?: boolean } export interface ColumnPinningDefaultOptions { onColumnPinningChange: OnChangeFn } +export interface RowPinningDefaultOptions { + onRowPinningChange: OnChangeFn +} + export interface ColumnPinningColumnDef { enablePinning?: boolean } @@ -47,6 +68,17 @@ export interface ColumnPinningRow { getRightVisibleCells: () => Cell[] } +export interface RowPinningRow { + getCanPin: () => boolean + getIsPinned: () => RowPinningPosition + getPinnedIndex: () => number + pin: ( + position: RowPinningPosition, + includeLeafRows?: boolean, + includeParentRows?: boolean + ) => void +} + export interface ColumnPinningInstance { setColumnPinning: (updater: Updater) => void resetColumnPinning: (defaultState?: boolean) => void @@ -56,26 +88,43 @@ export interface ColumnPinningInstance { getCenterLeafColumns: () => Column[] } +export interface RowPinningInstance { + setRowPinning: (updater: Updater) => void + resetRowPinning: (defaultState?: boolean) => void + getIsSomeRowsPinned: (position?: RowPinningPosition) => boolean + _getPinnedRows: (position: 'top' | 'bottom') => Row[] + getTopRows: () => Row[] + getBottomRows: () => Row[] + getCenterRows: () => Row[] +} + // -const getDefaultPinningState = (): ColumnPinningState => ({ +const getDefaultColumnPinningState = (): ColumnPinningState => ({ left: [], right: [], }) +const getDefaultRowPinningState = (): RowPinningState => ({ + top: [], + bottom: [], +}) + export const Pinning: TableFeature = { - getInitialState: (state): ColumnPinningTableState => { + getInitialState: (state): ColumnPinningTableState & RowPinningState => { return { - columnPinning: getDefaultPinningState(), + columnPinning: getDefaultColumnPinningState(), + rowPinning: getDefaultRowPinningState(), ...state, } }, getDefaultOptions: ( table: Table - ): ColumnPinningDefaultOptions => { + ): ColumnPinningDefaultOptions & RowPinningDefaultOptions => { return { onColumnPinningChange: makeStateUpdater('columnPinning', table), + onRowPinningChange: makeStateUpdater('rowPinning', table), } }, @@ -123,7 +172,9 @@ export const Pinning: TableFeature = { return leafColumns.some( d => (d.columnDef.enablePinning ?? true) && - (table.options.enablePinning ?? true) + (table.options.enableColumnPinning ?? + table.options.enablePinning ?? + true) ) } @@ -151,6 +202,66 @@ export const Pinning: TableFeature = { row: Row, table: Table ): void => { + row.pin = (position, includeLeafRows, includeParentRows) => { + const leafRowIds = includeLeafRows + ? row.getLeafRows().map(({ id }) => id) + : [] + const parentRowIds = includeParentRows + ? row.getParentRows().map(({ id }) => id) + : [] + const rowIds = new Set([...parentRowIds, row.id, ...leafRowIds]) + + table.setRowPinning(old => { + if (position === 'bottom') { + return { + top: (old?.top ?? []).filter(d => !rowIds?.has(d)), + bottom: [ + ...(old?.bottom ?? []).filter(d => !rowIds?.has(d)), + ...rowIds, + ], + } + } + + if (position === 'top') { + return { + top: [...(old?.top ?? []).filter(d => !rowIds?.has(d)), ...rowIds], + bottom: (old?.bottom ?? []).filter(d => !rowIds?.has(d)), + } + } + + return { + top: (old?.top ?? []).filter(d => !rowIds?.has(d)), + bottom: (old?.bottom ?? []).filter(d => !rowIds?.has(d)), + } + }) + } + row.getCanPin = () => { + const { enableRowPinning, enablePinning } = table.options + if (typeof enableRowPinning === 'function') { + return enableRowPinning(row) + } + return enableRowPinning ?? enablePinning ?? true + } + row.getIsPinned = () => { + const rowIds = [row.id] + + const { top, bottom } = table.getState().rowPinning + + const isTop = rowIds.some(d => top?.includes(d)) + const isBottom = rowIds.some(d => bottom?.includes(d)) + + return isTop ? 'top' : isBottom ? 'bottom' : false + } + row.getPinnedIndex = () => { + const position = row.getIsPinned() + if (!position) return -1 + + const visiblePinnedRowIds = table + ._getPinnedRows(position) + ?.map(({ id }) => id) + + return visiblePinnedRowIds?.indexOf(row.id) ?? -1 + } row.getCenterVisibleCells = memo( () => [ row._getAllVisibleCells(), @@ -164,7 +275,7 @@ export const Pinning: TableFeature = { }, { key: - process.env.NODE_ENV === 'production' && 'row.getCenterVisibleCells', + process.env.NODE_ENV === 'development' && 'row.getCenterVisibleCells', debug: () => table.options.debugAll ?? table.options.debugRows, } ) @@ -174,12 +285,13 @@ export const Pinning: TableFeature = { const cells = (left ?? []) .map(columnId => allCells.find(cell => cell.column.id === columnId)!) .filter(Boolean) - .map(d => ({ ...d, position: 'left' } as Cell)) + .map(d => ({ ...d, position: 'left' }) as Cell) return cells }, { - key: process.env.NODE_ENV === 'production' && 'row.getLeftVisibleCells', + key: + process.env.NODE_ENV === 'development' && 'row.getLeftVisibleCells', debug: () => table.options.debugAll ?? table.options.debugRows, } ) @@ -189,13 +301,13 @@ export const Pinning: TableFeature = { const cells = (right ?? []) .map(columnId => allCells.find(cell => cell.column.id === columnId)!) .filter(Boolean) - .map(d => ({ ...d, position: 'right' } as Cell)) + .map(d => ({ ...d, position: 'right' }) as Cell) return cells }, { key: - process.env.NODE_ENV === 'production' && 'row.getRightVisibleCells', + process.env.NODE_ENV === 'development' && 'row.getRightVisibleCells', debug: () => table.options.debugAll ?? table.options.debugRows, } ) @@ -208,8 +320,8 @@ export const Pinning: TableFeature = { table.resetColumnPinning = defaultState => table.setColumnPinning( defaultState - ? getDefaultPinningState() - : table.initialState?.columnPinning ?? getDefaultPinningState() + ? getDefaultColumnPinningState() + : table.initialState?.columnPinning ?? getDefaultColumnPinningState() ) table.getIsSomeColumnsPinned = position => { @@ -263,5 +375,72 @@ export const Pinning: TableFeature = { debug: () => table.options.debugAll ?? table.options.debugColumns, } ) + + table.setRowPinning = updater => table.options.onRowPinningChange?.(updater) + + table.resetRowPinning = defaultState => + table.setRowPinning( + defaultState + ? getDefaultRowPinningState() + : table.initialState?.rowPinning ?? getDefaultRowPinningState() + ) + + table.getIsSomeRowsPinned = position => { + const pinningState = table.getState().rowPinning + + if (!position) { + return Boolean(pinningState.top?.length || pinningState.bottom?.length) + } + return Boolean(pinningState[position]?.length) + } + + table._getPinnedRows = (position: 'top' | 'bottom') => + memo( + () => [table.getRowModel().rows, table.getState().rowPinning[position]], + (visibleRows, pinnedRowIds) => { + const rows = + table.options.keepPinnedRows ?? true + ? //get all rows that are pinned even if they would not be otherwise visible + //account for expanded parent rows, but not pagination or filtering + (pinnedRowIds ?? []).map(rowId => { + const row = table.getRow(rowId, true) + return row.getIsAllParentsExpanded() ? row : null + }) + : //else get only visible rows that are pinned + (pinnedRowIds ?? []).map( + rowId => visibleRows.find(row => row.id === rowId)! + ) + + return rows + .filter(Boolean) + .map(d => ({ ...d, position })) as Row[] + }, + { + key: + process.env.NODE_ENV === 'development' && + `row.get${position === 'top' ? 'Top' : 'Bottom'}Rows`, + debug: () => table.options.debugAll ?? table.options.debugRows, + } + )() + + table.getTopRows = () => table._getPinnedRows('top') + + table.getBottomRows = () => table._getPinnedRows('bottom') + + table.getCenterRows = memo( + () => [ + table.getRowModel().rows, + table.getState().rowPinning.top, + table.getState().rowPinning.bottom, + ], + (allRows, top, bottom) => { + const topAndBottom = new Set([...(top ?? []), ...(bottom ?? [])]) + return allRows.filter(d => !topAndBottom.has(d.id)) + }, + { + key: process.env.NODE_ENV === 'development' && 'row.getCenterRows', + debug: () => table.options.debugAll ?? table.options.debugRows, + } + ) }, } diff --git a/packages/table-core/src/types.ts b/packages/table-core/src/types.ts index 3487c535db..bbc8995fa3 100644 --- a/packages/table-core/src/types.ts +++ b/packages/table-core/src/types.ts @@ -19,6 +19,10 @@ import { ColumnPinningOptions, ColumnPinningRow, ColumnPinningTableState, + RowPinningInstance, + RowPinningOptions, + RowPinningRow, + RowPinningTableState, } from './features/Pinning' import { CoreHeader, @@ -106,6 +110,7 @@ export interface Table VisibilityInstance, ColumnOrderInstance, ColumnPinningInstance, + RowPinningInstance, FiltersInstance, SortingInstance, GroupingInstance, @@ -118,6 +123,7 @@ interface FeatureOptions extends VisibilityOptions, ColumnOrderOptions, ColumnPinningOptions, + RowPinningOptions, FiltersOptions, SortingOptions, GroupingOptions, @@ -140,6 +146,7 @@ export interface TableState VisibilityTableState, ColumnOrderTableState, ColumnPinningTableState, + RowPinningTableState, FiltersTableState, SortingTableState, ExpandedTableState, @@ -153,6 +160,7 @@ interface CompleteInitialTableState VisibilityTableState, ColumnOrderTableState, ColumnPinningTableState, + RowPinningTableState, FiltersTableState, SortingTableState, ExpandedTableState, @@ -167,6 +175,7 @@ export interface Row extends CoreRow, VisibilityRow, ColumnPinningRow, + RowPinningRow, FiltersRow, GroupingRow, RowSelectionRow, diff --git a/tsconfig.json b/tsconfig.json index ac045e0cd7..c5676bd608 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -61,6 +61,9 @@ { "path": "examples/react/row-dnd/tsconfig.dev.json" }, + { + "path": "examples/react/row-pinning/tsconfig.dev.json" + }, { "path": "examples/react/row-selection/tsconfig.dev.json" },