From 0e35f6446aa0c5b4166eea079fbeb304e987d63d Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 30 Jul 2026 16:10:59 +0530 Subject: [PATCH 1/3] fix(data-view): propagate row selection state to consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row selection changes never reached `useDataView()` consumers. The context value is memoized over deps that a selection change doesn't touch, and the TanStack table instance identity is stable, so `row.toggleSelected()` re-rendered the root but produced an identical context value. Checkboxes only caught up when something else invalidated the memo — a filter edit or a consumer re-render. Lift `rowSelection` into root state alongside `columnVisibility` so it is a real dependency of the context value, expose it on the context, and add an `onRowSelectionChange` prop for mirroring selection outside the tree. Also backfill `flatRows` on the filtered row model. DataView filters with `filterFromLeafRows` so groups survive when a child matches, and table-core's leaf-first implementation allocates that array but never pushes into it — leaving `table.getIsAllRowsSelected()`, `getIsSomeRowsSelected()` and `toggleAllRowsSelected()` dead whenever a filter or search was active. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/data-view/data-view.tsx | 30 +++++++++++- .../components/data-view/data-view.types.tsx | 14 ++++++ .../components/data-view/utils/index.tsx | 47 ++++++++++++++++++- 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx index a6fa78a74..a85af07a5 100644 --- a/packages/raystack/components/data-view/data-view.tsx +++ b/packages/raystack/components/data-view/data-view.tsx @@ -3,8 +3,8 @@ import { getCoreRowModel, getExpandedRowModel, - getFilteredRowModel, getSortedRowModel, + RowSelectionState, Updater, useReactTable, VisibilityState @@ -36,6 +36,7 @@ import { hasActiveQuery as computeHasActiveQuery, fieldsToColumnDefs, getDefaultTableQuery, + getFilteredRowModelWithFlatRows, getInitialColumnVisibility, groupData, hasQueryChanged, @@ -57,6 +58,7 @@ function DataViewRoot({ onLoadMore, onRowClick, onColumnVisibilityChange, + onRowSelectionChange, getRowId, views, defaultView, @@ -130,6 +132,23 @@ function DataViewRoot({ [onColumnVisibilityChange] ); + // Row selection is held here rather than left to TanStack's internal state: + // the context value is memoized, so a change inside the table instance alone + // would never reach `useDataView()` consumers (the table object identity is + // stable). Lifting it makes selection a real dependency of the context. + const [rowSelection, setRowSelectionState] = useState({}); + + const handleRowSelectionChange = useCallback( + (value: Updater) => { + setRowSelectionState(prev => { + const newValue = typeof value === 'function' ? value(prev) : value; + onRowSelectionChange?.(newValue); + return newValue; + }); + }, + [onRowSelectionChange] + ); + const [tableQuery, setTableQuery] = useState(defaultTableQuery); @@ -189,16 +208,19 @@ function DataViewRoot({ getExpandedRowModel: getExpandedRowModel(), getSubRows: row => (row as unknown as GroupedData)?.subRows || [], getSortedRowModel: mode === 'server' ? undefined : getSortedRowModel(), - getFilteredRowModel: mode === 'server' ? undefined : getFilteredRowModel(), + getFilteredRowModel: + mode === 'server' ? undefined : getFilteredRowModelWithFlatRows(), manualSorting: mode === 'server', manualFiltering: mode === 'server', onColumnVisibilityChange: handleColumnVisibilityChange, + onRowSelectionChange: handleRowSelectionChange, globalFilterFn: mode === 'server' ? undefined : 'auto', initialState: { columnVisibility: initialColumnVisibility }, filterFromLeafRows: true, state: { ...reactTableState, columnVisibility, + rowSelection, expanded: group_by && group_by !== defaultGroupOption.id ? true : undefined } @@ -260,6 +282,8 @@ function DataViewRoot({ shouldShowFilters, columnVisibility, setColumnVisibility: handleColumnVisibilityChange, + rowSelection, + setRowSelection: handleRowSelectionChange, views, activeView, setActiveView, @@ -287,6 +311,8 @@ function DataViewRoot({ shouldShowFilters, columnVisibility, handleColumnVisibilityChange, + rowSelection, + handleRowSelectionChange, views, activeView, setActiveView, diff --git a/packages/raystack/components/data-view/data-view.types.tsx b/packages/raystack/components/data-view/data-view.types.tsx index 89a155a3b..cbe79bee3 100644 --- a/packages/raystack/components/data-view/data-view.types.tsx +++ b/packages/raystack/components/data-view/data-view.types.tsx @@ -1,6 +1,7 @@ import type { ColumnDef, Row, + RowSelectionState, Table, Updater, VisibilityState @@ -145,6 +146,14 @@ export interface DataViewProps { onLoadMore?: () => Promise | void; onRowClick?: (row: TData) => void; onColumnVisibilityChange?: (columnVisibility: VisibilityState) => void; + /** + * Fires with the new selection map whenever rows are selected or deselected + * (`row.toggleSelected()`, `table.toggleAllRowsSelected()`, …). Selection + * itself lives on the table instance — read it through + * `useDataView().table`; this is only for mirroring it outside the tree. + * Keys are `getRowId` values (row indices when `getRowId` is omitted). + */ + onRowSelectionChange?: (rowSelection: RowSelectionState) => void; /** Stable unique id per row (React key). */ getRowId?: (row: TData, index: number) => string; /** Multi-view configuration. When set, `DataView.DisplayControls` renders a view switcher and renderers gate themselves on the active view via their `name` prop. */ @@ -428,6 +437,11 @@ export type DataViewContextType = { columnVisibility: VisibilityState; setColumnVisibility: (value: Updater) => void; + // selection — lifted so a selection change invalidates this context value + // (the table instance identity is stable, so it can't do that on its own). + rowSelection: RowSelectionState; + setRowSelection: (value: Updater) => void; + // multi-view views?: ViewSpec[]; activeView?: string; diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index d11670d9f..587d09649 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -1,5 +1,9 @@ import type { ColumnDef, Row, Table } from '@tanstack/react-table'; -import { TableState } from '@tanstack/table-core'; +import { + getFilteredRowModel, + type RowModel, + TableState +} from '@tanstack/table-core'; import dayjs from 'dayjs'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; @@ -308,7 +312,46 @@ export function dataViewQueryToInternal(query: DataViewQuery): InternalQuery { return { ...rest, filters: internalFilters }; } -/** Leaf count from the row tree. Not `flatRows`: with `filterFromLeafRows`, TanStack's filtered model leaves `flatRows` empty while `rows` is correct. */ +/** + * `getFilteredRowModel` with `flatRows` backfilled. + * + * DataView filters with `filterFromLeafRows` so a group survives when one of + * its children matches. table-core's leaf-first implementation + * (`filterRowModelFromLeafs`) allocates the model's `flatRows` array but never + * pushes into it, so every API reading it goes dead the moment a filter or + * search is active — `table.getIsAllRowsSelected()`, + * `getIsSomeRowsSelected()`, and `toggleAllRowsSelected()` (via + * `getPreGroupedRowModel`) among them. + * + * The tree in `rows` is correct, so walk it and fill the array **in place**: + * the model object identity is preserved, so downstream memos (sorting, + * selection) don't invalidate, and the walk runs once per filter recompute + * rather than once per read. + */ +export function getFilteredRowModelWithFlatRows(): ( + table: Table +) => () => RowModel { + const base = getFilteredRowModel(); + return table => { + const getModel = base(table); + return () => { + const model = getModel(); + // Already populated: the unfiltered pass-through and any model we've + // filled once before. + if (model.flatRows.length > 0) return model; + const collect = (rows: Row[]) => { + rows.forEach(row => { + model.flatRows.push(row); + if (row.subRows?.length) collect(row.subRows); + }); + }; + collect(model.rows); + return model; + }; + }; +} + +/** Leaf count from the row tree. Kept recursive so it stays correct for the grouped tree, where `flatRows` also counts group rows. */ export function countLeafRows(rows: Row[]): number { return rows.reduce( (n, row) => n + (row.subRows?.length ? countLeafRows(row.subRows) : 1), From d4afb400a8d2b5cd99c144e919e37088dbe32b41 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 30 Jul 2026 16:11:15 +0530 Subject: [PATCH 2/3] docs(dataview): add row selection example Mirrors DataTable's pattern: an unmanaged `select` column (absent from `fields`, so it stays out of Display Properties and can't be filtered, sorted, or grouped) plus a FloatingActions bar reading selection from `useDataView()`. Notes cover what the docs example can't show inline: why `getRowId` matters (positional keys survive client sort/filter/search but not a data reorder or a grouping switch), why TanStack's `getToggleSelectedHandler` / `getToggleAllRowsSelectedHandler` don't fit a Base UI checkbox, and that grouping plus selection is still unsupported. Co-Authored-By: Claude Opus 5 (1M context) --- apps/www/src/components/dataview-demo.tsx | 116 +++++++++++++++++- apps/www/src/components/demo/demo.tsx | 2 + .../content/docs/components/dataview/demo.ts | 96 +++++++++++++++ .../docs/components/dataview/index.mdx | 19 +++ .../content/docs/components/dataview/props.ts | 3 + 5 files changed, 234 insertions(+), 2 deletions(-) diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index acd97954e..873f74960 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -1,18 +1,22 @@ 'use client'; -import { ListBulletIcon, RowsIcon } from '@radix-ui/react-icons'; +import { ListBulletIcon, RowsIcon, TransformIcon } from '@radix-ui/react-icons'; import { Avatar, Badge, Button, + Checkbox, + Chip, // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name DataView, DataViewField, DataViewListColumn, Flex, + FloatingActions, Text, type TimelineActions, - type TimelineCardContext + type TimelineCardContext, + useDataView } from '@raystack/apsara'; import { useMemo, useRef, useState } from 'react'; @@ -607,6 +611,114 @@ export function DataViewPerViewFieldsDemo() { ); } +// --------------------------------------------------------------------------- +// Row selection demo — unmanaged checkbox column + a FloatingActions bar. +// --------------------------------------------------------------------------- + +/* `select` is absent from `fields`, so DataView.List treats it as an unmanaged + display column: always rendered, never in Display Properties, and the cell + receives `{ row, table }` instead of a TanStack cell context. */ +const selectionColumn: DataViewListColumn = { + accessorKey: 'select', + /* Wide enough for the leading cell's 24px inset + the checkbox: `.listCell` + clips overflow, so a narrower track would cut the box off. */ + width: 48, + /* Select-all tracks the filtered rows, so it reflects what's on screen. */ + header: ({ table }) => ( + table.toggleAllRowsSelected(Boolean(checked))} + aria-label='Select all people' + /> + ), + cell: ({ row }) => ( + row.toggleSelected(Boolean(checked))} + // Keep a row-level onRowClick from firing when the checkbox is hit. + onClick={event => event.stopPropagation()} + aria-label={`Select ${row.original.name}`} + /> + ) +}; + +const selectionColumns: DataViewListColumn[] = [ + selectionColumn, + ...tableColumns +]; + +/* Selection lives on the table instance, so any sibling can read it from + context — no state threading through the DataView tree. */ +function SelectionBar() { + const { table } = useDataView(); + const selectedCount = table.getSelectedRowModel().rows.length; + if (selectedCount === 0) return null; + + return ( + + } + isDismissible + onDismiss={() => table.resetRowSelection()} + > + {selectedCount} selected + + + + + + ); +} + +export function DataViewSelectionDemo() { + return ( + + {/* `transform` makes this box the containing block for the bar's + `position: fixed`, scoping it to the demo instead of the viewport. */} +
+ person.id} + > + + + + {/* Grouping is hidden: group header rows share the row-id space + with data rows, so selection and grouping don't mix yet. */} + + + + + +
+ +
+ ); +} + /* ── Timeline demo ─────────────────────────────────────────────────────── */ type Task = { diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 8fbd7515d..7017fbb23 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -51,6 +51,7 @@ import { DataViewMultiViewDemo, DataViewPerViewFieldsDemo, DataViewSearchDemo, + DataViewSelectionDemo, DataViewTableDemo, DataViewTimelineDemo, DataViewTimelineGroupingDemo, @@ -90,6 +91,7 @@ export default function Demo(props: DemoProps) { DataViewLoadingDemo, DataViewPerViewFieldsDemo, DataViewSearchDemo, + DataViewSelectionDemo, DataViewTimelineDemo, DataViewTimelineGroupingDemo, DataViewTimelinePointDemo, diff --git a/apps/www/src/content/docs/components/dataview/demo.ts b/apps/www/src/content/docs/components/dataview/demo.ts index 7a81e26f7..768f9e969 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -332,6 +332,102 @@ export const searchPreview = { ] }; +export const rowSelectionPreview = { + type: 'code', + style: { padding: 0 }, + previewCode: false, + code: ``, + codePreview: [ + { + label: 'index.tsx', + code: `import { + Button, + Checkbox, + Chip, + DataView, + FloatingActions, + useDataView, +} from "@raystack/apsara"; +import { TransformIcon } from "@radix-ui/react-icons"; + +// 1. A leading checkbox column. \`select\` is absent from \`fields\`, so +// DataView.List treats it as an unmanaged display column: always +// rendered, never listed in Display Properties, and the cell receives +// { row, table } instead of a TanStack cell context. +const selectionColumn: DataViewListColumn = { + accessorKey: "select", + // Wide enough for the leading cell's 24px inset + the checkbox: cells clip + // overflow, so a narrower track would cut the box off. + width: 48, + // Select-all tracks the filtered rows, so it reflects what's on screen. + header: ({ table }) => ( + table.toggleAllRowsSelected(Boolean(checked))} + aria-label="Select all people" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(Boolean(checked))} + // Keep a row-level onRowClick from firing when the checkbox is hit. + onClick={(event) => event.stopPropagation()} + aria-label={\`Select \${row.original.name}\`} + /> + ), +}; + +// 2. Any sibling can read the selection from context — no state threading. +// FloatingActions defaults to variant="floating" (position: fixed, +// bottom-center), so no positioning CSS is needed at the call site. +function SelectionBar() { + const { table } = useDataView(); + const selectedCount = table.getSelectedRowModel().rows.length; + if (selectedCount === 0) return null; + + return ( + + } + isDismissible + onDismiss={() => table.resetRowSelection()} + > + {selectedCount} selected + + + + + + ); +} + +// 3. Compose. \`getRowId\` keys the selection map by a stable id instead of +// the row index, so it survives sorting, filtering, and refetches. + person.id} +> + + + + + + + +` + } + ] +}; + export const timelinePreview = { type: 'code', style: { padding: 0 }, diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index 107b99e66..c03fa26f3 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -16,6 +16,7 @@ import { virtualizedGroupingPreview, loadingPreview, perViewFieldsPreview, + rowSelectionPreview, timelinePreview, timelineGroupingPreview, timelinePointPreview, @@ -274,6 +275,24 @@ const listFields = fields.map((f) => ; ``` +### Row selection + +DataView doesn't ship a selection toolbar, but the underlying TanStack table instance is exposed via `useDataView()`, so you own the affordance: add a checkbox column to the renderer and float a [`FloatingActions`](/docs/components/floating-actions) bar over the view while rows are selected. + +`select` is deliberately **not** declared in `fields` — an accessor with no matching field is an *unmanaged* display column. `DataView.List` always renders it (it never appears in Display Properties, and can't be filtered, sorted, or grouped) and hands the spec a `{ row, table }` context instead of a TanStack cell context. The same applies to row actions and drag handles. + + + +Notes: + +- `getRowId` is optional but strongly recommended. Without it, selection is keyed by position in `data` (`'0'`, `'1'`, …). Those keys are assigned from the data array rather than the visible order, so client-side sort, filter, and search are safe — a static dataset needs nothing. What breaks is the identity behind a position changing: a refetch or server-mode sort returning rows in a new order silently moves the selection to a different row, and switching Grouping on re-keys leaves to `'.'`, leaving existing keys pointing at group rows. Pass `getRowId` whenever the data can change under you. +- Selection state lives on the table instance and is exposed through `useDataView()`. Read it with `table.getSelectedRowModel()`, clear it with `table.resetRowSelection()` (wired to `Chip`'s `onDismiss` above), and mirror it outside the tree with `onRowSelectionChange` on the root if you need it there. +- The TanStack [row selection API](https://tanstack.com/table/v8/docs/guide/row-selection) is available as documented: `row.getIsSelected()`, `row.toggleSelected()`, `row.getIsSomeSelected()`, `table.getIsAllRowsSelected()`, `getIsSomeRowsSelected()`, `toggleAllRowsSelected()`, `setRowSelection()`, `resetRowSelection()`. The header helpers are computed over the **filtered** rows, so select-all tracks what the user can actually see rather than the whole dataset. +- The two *handler getters* in that guide — `row.getToggleSelectedHandler()` and `table.getToggleAllRowsSelectedHandler()` — are adapters for a native ``: they read `event.target.checked`. Apsara's [`Checkbox`](/docs/components/checkbox) reports a boolean through `onCheckedChange`, so the table-level getter throws on `undefined.checked` and the row-level one only works via its "no value means invert" fallback. Call `toggleSelected` / `toggleAllRowsSelected` with the boolean instead, as above. +- `FloatingActions` defaults to `variant="floating"` (`position: fixed`, bottom-center), so no positioning CSS is needed at the call site. To scope the bar to the view instead of the viewport, give an ancestor `transform`, `filter`, or `contain: paint` so it becomes the containing block for `position: fixed`. Add `padding-bottom` via `classNames.root` if rows would otherwise sit behind the bar. +- Stop propagation in the checkbox's `onClick` when the root has an `onRowClick` — otherwise ticking a checkbox also activates the row. +- **Grouping and selection don't mix yet.** With `group_by` active, group header rows enter the same row-id space as data rows, so ids can collide and a click may toggle the wrong rows. Hide the control (``) while a selection column is present. + ### Custom group buckets (`groupByResolvers`) The wire format keeps `group_by: string[]`. To group by something that isn't a raw accessor (e.g. "by week of created_at"), supply a resolver: diff --git a/apps/www/src/content/docs/components/dataview/props.ts b/apps/www/src/content/docs/components/dataview/props.ts index ba6b9ae9d..8889d8ce0 100644 --- a/apps/www/src/content/docs/components/dataview/props.ts +++ b/apps/www/src/content/docs/components/dataview/props.ts @@ -42,6 +42,9 @@ export interface DataViewProps { /** Column visibility change callback. */ onColumnVisibilityChange?: (visibility: Record) => void; + /** Fires with the new selection map whenever rows are selected or deselected. Keys are `getRowId` values (row indices when `getRowId` is omitted). */ + onRowSelectionChange?: (rowSelection: Record) => void; + /** Return a stable unique id for each row (used as React key). */ getRowId?: (row: T, index: number) => string; From 7baa7efa5fc6b4d787aec32fbfd9b908cf2f56b9 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 30 Jul 2026 16:26:25 +0530 Subject: [PATCH 3/3] fix(data-view): namespace group row ids, add positional id fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group rows are synthesized by `groupData` and carry no consumer id, so `getRowId` returned undefined for them and TanStack fell back to a positional id — which collided with data row ids. With ids `1,2,3` and grouping on, row ids came out as `0=GROUP 1=Ada 2=Grace 1=GROUP 3=Susan`, so clicking Ada resolved to the Design group row and cascaded selection to Susan. React also warned about duplicate keys. Buckets are now keyed `__group__:`, leaves delegate to `getRowId`, and without one we reproduce TanStack's positional default (`'0'`, and `'.'` for sub-rows) since supplying a resolver replaces it. Group rows are also unselectable, so `rowSelection` holds one key per data row and select-all doesn't flag the bands. Grouping and selection now compose: the docs example drops `hideGrouping`, counts off `getSelectedRowModel().flatRows` (`.rows` only walks selected top-level rows, which is empty when grouping pushes data a level down), and guards `indeterminate` with `!getIsAllRowsSelected()`. Co-Authored-By: Claude Opus 5 (1M context) --- apps/www/src/components/dataview-demo.tsx | 22 +++------ .../content/docs/components/dataview/demo.ts | 21 ++------ .../docs/components/dataview/index.mdx | 4 +- .../components/data-view/data-view.tsx | 15 ++++-- .../components/data-view/utils/index.tsx | 48 ++++++++++++------- 5 files changed, 55 insertions(+), 55 deletions(-) diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index 873f74960..e50a86a1b 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -615,20 +615,16 @@ export function DataViewPerViewFieldsDemo() { // Row selection demo — unmanaged checkbox column + a FloatingActions bar. // --------------------------------------------------------------------------- -/* `select` is absent from `fields`, so DataView.List treats it as an unmanaged - display column: always rendered, never in Display Properties, and the cell - receives `{ row, table }` instead of a TanStack cell context. */ const selectionColumn: DataViewListColumn = { accessorKey: 'select', - /* Wide enough for the leading cell's 24px inset + the checkbox: `.listCell` - clips overflow, so a narrower track would cut the box off. */ width: 48, - /* Select-all tracks the filtered rows, so it reflects what's on screen. */ header: ({ table }) => ( table.toggleAllRowsSelected(Boolean(checked))} aria-label='Select all people' /> @@ -638,7 +634,6 @@ const selectionColumn: DataViewListColumn = { size='small' checked={row.getIsSelected()} onCheckedChange={checked => row.toggleSelected(Boolean(checked))} - // Keep a row-level onRowClick from firing when the checkbox is hit. onClick={event => event.stopPropagation()} aria-label={`Select ${row.original.name}`} /> @@ -650,11 +645,9 @@ const selectionColumns: DataViewListColumn[] = [ ...tableColumns ]; -/* Selection lives on the table instance, so any sibling can read it from - context — no state threading through the DataView tree. */ function SelectionBar() { const { table } = useDataView(); - const selectedCount = table.getSelectedRowModel().rows.length; + const selectedCount = table.getSelectedRowModel().flatRows.length; if (selectedCount === 0) return null; return ( @@ -683,8 +676,7 @@ function SelectionBar() { export function DataViewSelectionDemo() { return ( - {/* `transform` makes this box the containing block for the bar's - `position: fixed`, scoping it to the demo instead of the viewport. */} + {/* `transform` scopes the bar's `position: fixed` to this box. */}
- {/* Grouping is hidden: group header rows share the row-id space - with data rows, so selection and grouping don't mix yet. */} - + = { accessorKey: "select", - // Wide enough for the leading cell's 24px inset + the checkbox: cells clip - // overflow, so a narrower track would cut the box off. width: 48, - // Select-all tracks the filtered rows, so it reflects what's on screen. header: ({ table }) => ( table.toggleAllRowsSelected(Boolean(checked))} aria-label="Select all people" /> @@ -374,19 +369,15 @@ const selectionColumn: DataViewListColumn = { size="small" checked={row.getIsSelected()} onCheckedChange={(checked) => row.toggleSelected(Boolean(checked))} - // Keep a row-level onRowClick from firing when the checkbox is hit. onClick={(event) => event.stopPropagation()} aria-label={\`Select \${row.original.name}\`} /> ), }; -// 2. Any sibling can read the selection from context — no state threading. -// FloatingActions defaults to variant="floating" (position: fixed, -// bottom-center), so no positioning CSS is needed at the call site. function SelectionBar() { const { table } = useDataView(); - const selectedCount = table.getSelectedRowModel().rows.length; + const selectedCount = table.getSelectedRowModel().flatRows.length; if (selectedCount === 0) return null; return ( @@ -408,8 +399,6 @@ function SelectionBar() { ); } -// 3. Compose. \`getRowId\` keys the selection map by a stable id instead of -// the row index, so it survives sorting, filtering, and refetches. - + diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index c03fa26f3..00eeb4d87 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -285,13 +285,13 @@ DataView doesn't ship a selection toolbar, but the underlying TanStack table ins Notes: -- `getRowId` is optional but strongly recommended. Without it, selection is keyed by position in `data` (`'0'`, `'1'`, …). Those keys are assigned from the data array rather than the visible order, so client-side sort, filter, and search are safe — a static dataset needs nothing. What breaks is the identity behind a position changing: a refetch or server-mode sort returning rows in a new order silently moves the selection to a different row, and switching Grouping on re-keys leaves to `'.'`, leaving existing keys pointing at group rows. Pass `getRowId` whenever the data can change under you. +- `getRowId` is optional but recommended. Without it, selection falls back to positional keys (`'0'`, `'1'`, … and `'.'` inside a group section). Those come from the `data` array rather than the visible order, so client-side sort, filter, and search are safe — a static dataset needs nothing. What they can't survive is the identity behind a position changing: a refetch or server-mode sort returning rows in a new order moves the selection to whatever now sits at that index, and switching Grouping on or off re-keys the rows, dropping the selection. Pass `getRowId` whenever the data can change under you, and selection follows the row through all of it. - Selection state lives on the table instance and is exposed through `useDataView()`. Read it with `table.getSelectedRowModel()`, clear it with `table.resetRowSelection()` (wired to `Chip`'s `onDismiss` above), and mirror it outside the tree with `onRowSelectionChange` on the root if you need it there. - The TanStack [row selection API](https://tanstack.com/table/v8/docs/guide/row-selection) is available as documented: `row.getIsSelected()`, `row.toggleSelected()`, `row.getIsSomeSelected()`, `table.getIsAllRowsSelected()`, `getIsSomeRowsSelected()`, `toggleAllRowsSelected()`, `setRowSelection()`, `resetRowSelection()`. The header helpers are computed over the **filtered** rows, so select-all tracks what the user can actually see rather than the whole dataset. - The two *handler getters* in that guide — `row.getToggleSelectedHandler()` and `table.getToggleAllRowsSelectedHandler()` — are adapters for a native ``: they read `event.target.checked`. Apsara's [`Checkbox`](/docs/components/checkbox) reports a boolean through `onCheckedChange`, so the table-level getter throws on `undefined.checked` and the row-level one only works via its "no value means invert" fallback. Call `toggleSelected` / `toggleAllRowsSelected` with the boolean instead, as above. - `FloatingActions` defaults to `variant="floating"` (`position: fixed`, bottom-center), so no positioning CSS is needed at the call site. To scope the bar to the view instead of the viewport, give an ancestor `transform`, `filter`, or `contain: paint` so it becomes the containing block for `position: fixed`. Add `padding-bottom` via `classNames.root` if rows would otherwise sit behind the bar. - Stop propagation in the checkbox's `onClick` when the root has an `onRowClick` — otherwise ticking a checkbox also activates the row. -- **Grouping and selection don't mix yet.** With `group_by` active, group header rows enter the same row-id space as data rows, so ids can collide and a click may toggle the wrong rows. Hide the control (``) while a selection column is present. +- Grouping works alongside selection. Group header rows are keyed in their own id space and are not selectable — `rowSelection` holds one key per data row, and select-all covers the visible data rows without also flagging the bands. Read the count off `getSelectedRowModel().flatRows` rather than `.rows`: the latter only walks selected *top-level* rows, which is empty while grouping puts every data row one level down. ### Custom group buckets (`groupByResolvers`) diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx index a85af07a5..055d91411 100644 --- a/packages/raystack/components/data-view/data-view.tsx +++ b/packages/raystack/components/data-view/data-view.tsx @@ -34,12 +34,14 @@ import { } from './data-view.types'; import { hasActiveQuery as computeHasActiveQuery, + createRowIdResolver, fieldsToColumnDefs, getDefaultTableQuery, getFilteredRowModelWithFlatRows, getInitialColumnVisibility, groupData, hasQueryChanged, + isGroupRowData, queryToTableState, transformToDataViewQuery } from './utils'; @@ -132,10 +134,8 @@ function DataViewRoot({ [onColumnVisibilityChange] ); - // Row selection is held here rather than left to TanStack's internal state: - // the context value is memoized, so a change inside the table instance alone - // would never reach `useDataView()` consumers (the table object identity is - // stable). Lifting it makes selection a real dependency of the context. + // Lifted so a selection change invalidates the memoized context value; the + // table instance identity is stable and can't do it. const [rowSelection, setRowSelectionState] = useState({}); const handleRowSelectionChange = useCallback( @@ -200,10 +200,15 @@ function DataViewRoot({ } }, [tableQuery, onTableQueryChange, mode]); + const resolveRowId = useMemo(() => createRowIdResolver(getRowId), [getRowId]); + const table = useReactTable({ data: groupedData as unknown as TData[], columns: columnDefs, - getRowId, + getRowId: resolveRowId, + // Group rows render without cells, so they have no checkbox — keeping them + // unselectable keeps `rowSelection` one key per data row. + enableRowSelection: row => !isGroupRowData(row.original), getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), getSubRows: row => (row as unknown as GroupedData)?.subRows || [], diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index 587d09649..5a28554d3 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -126,6 +126,33 @@ export function groupData( return groupedData; } +export const GROUP_ROW_ID_PREFIX = '__group__:'; + +export function isGroupRowData(row: unknown): row is GroupedData { + if (!row || typeof row !== 'object') return false; + const candidate = row as GroupedData; + return ( + Array.isArray(candidate.subRows) && typeof candidate.group_key === 'string' + ); +} + +/** + * Keys group buckets by `group_key` so their ids can't collide with data row + * ids. Leaves delegate to `getRowId`, falling back to TanStack's positional + * default (which supplying any resolver would otherwise replace). + */ +export function createRowIdResolver( + getRowId?: (row: TData, index: number) => string +) { + return (row: TData, index: number, parent?: Row): string => { + if (isGroupRowData(row)) { + return `${GROUP_ROW_ID_PREFIX}${row.group_key}`; + } + if (getRowId) return getRowId(row, index); + return parent ? `${parent.id}.${index}` : String(index); + }; +} + const generateFilterMap = ( filters: InternalFilter[] = [] ): Map => { @@ -313,20 +340,11 @@ export function dataViewQueryToInternal(query: DataViewQuery): InternalQuery { } /** - * `getFilteredRowModel` with `flatRows` backfilled. - * - * DataView filters with `filterFromLeafRows` so a group survives when one of - * its children matches. table-core's leaf-first implementation - * (`filterRowModelFromLeafs`) allocates the model's `flatRows` array but never - * pushes into it, so every API reading it goes dead the moment a filter or - * search is active — `table.getIsAllRowsSelected()`, - * `getIsSomeRowsSelected()`, and `toggleAllRowsSelected()` (via - * `getPreGroupedRowModel`) among them. - * - * The tree in `rows` is correct, so walk it and fill the array **in place**: - * the model object identity is preserved, so downstream memos (sorting, - * selection) don't invalidate, and the walk runs once per filter recompute - * rather than once per read. + * `getFilteredRowModel` with `flatRows` backfilled. table-core's + * `filterFromLeafRows` path never fills that array, which kills every API + * reading it (`getIsAllRowsSelected`, `toggleAllRowsSelected`, …) while a + * filter is active. Filled in place to preserve the model identity downstream + * memos key on. */ export function getFilteredRowModelWithFlatRows(): ( table: Table @@ -336,8 +354,6 @@ export function getFilteredRowModelWithFlatRows(): ( const getModel = base(table); return () => { const model = getModel(); - // Already populated: the unfiltered pass-through and any model we've - // filled once before. if (model.flatRows.length > 0) return model; const collect = (rows: Row[]) => { rows.forEach(row => {