diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index acd97954e..e50a86a1b 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,104 @@ export function DataViewPerViewFieldsDemo() { ); } +// --------------------------------------------------------------------------- +// Row selection demo — unmanaged checkbox column + a FloatingActions bar. +// --------------------------------------------------------------------------- + +const selectionColumn: DataViewListColumn = { + accessorKey: 'select', + width: 48, + header: ({ table }) => ( + table.toggleAllRowsSelected(Boolean(checked))} + aria-label='Select all people' + /> + ), + cell: ({ row }) => ( + row.toggleSelected(Boolean(checked))} + onClick={event => event.stopPropagation()} + aria-label={`Select ${row.original.name}`} + /> + ) +}; + +const selectionColumns: DataViewListColumn[] = [ + selectionColumn, + ...tableColumns +]; + +function SelectionBar() { + const { table } = useDataView(); + const selectedCount = table.getSelectedRowModel().flatRows.length; + if (selectedCount === 0) return null; + + return ( + + } + isDismissible + onDismiss={() => table.resetRowSelection()} + > + {selectedCount} selected + + + + + + ); +} + +export function DataViewSelectionDemo() { + return ( + + {/* `transform` scopes the bar's `position: fixed` to this box. */} +
+ person.id} + > + + + + + + + + +
+ +
+ ); +} + /* ── 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..fcc4666ba 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -332,6 +332,91 @@ 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"; + +const selectionColumn: DataViewListColumn = { + accessorKey: "select", + width: 48, + header: ({ table }) => ( + table.toggleAllRowsSelected(Boolean(checked))} + aria-label="Select all people" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(Boolean(checked))} + onClick={(event) => event.stopPropagation()} + aria-label={\`Select \${row.original.name}\`} + /> + ), +}; + +function SelectionBar() { + const { table } = useDataView(); + const selectedCount = table.getSelectedRowModel().flatRows.length; + if (selectedCount === 0) return null; + + return ( + + } + isDismissible + onDismiss={() => table.resetRowSelection()} + > + {selectedCount} selected + + + + + + ); +} + + 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..00eeb4d87 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 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 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`) 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; diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx index a6fa78a74..055d91411 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 @@ -34,11 +34,14 @@ import { } from './data-view.types'; import { hasActiveQuery as computeHasActiveQuery, + createRowIdResolver, fieldsToColumnDefs, getDefaultTableQuery, + getFilteredRowModelWithFlatRows, getInitialColumnVisibility, groupData, hasQueryChanged, + isGroupRowData, queryToTableState, transformToDataViewQuery } from './utils'; @@ -57,6 +60,7 @@ function DataViewRoot({ onLoadMore, onRowClick, onColumnVisibilityChange, + onRowSelectionChange, getRowId, views, defaultView, @@ -130,6 +134,21 @@ function DataViewRoot({ [onColumnVisibilityChange] ); + // 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( + (value: Updater) => { + setRowSelectionState(prev => { + const newValue = typeof value === 'function' ? value(prev) : value; + onRowSelectionChange?.(newValue); + return newValue; + }); + }, + [onRowSelectionChange] + ); + const [tableQuery, setTableQuery] = useState(defaultTableQuery); @@ -181,24 +200,32 @@ 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 || [], 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 +287,8 @@ function DataViewRoot({ shouldShowFilters, columnVisibility, setColumnVisibility: handleColumnVisibilityChange, + rowSelection, + setRowSelection: handleRowSelectionChange, views, activeView, setActiveView, @@ -287,6 +316,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..5a28554d3 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'; @@ -122,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 => { @@ -308,7 +339,35 @@ 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. 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 +) => () => RowModel { + const base = getFilteredRowModel(); + return table => { + const getModel = base(table); + return () => { + const model = getModel(); + 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),