Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions apps/www/src/components/dataview-demo.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -607,6 +611,104 @@ export function DataViewPerViewFieldsDemo() {
);
}

// ---------------------------------------------------------------------------
// Row selection demo — unmanaged checkbox column + a FloatingActions bar.
// ---------------------------------------------------------------------------

const selectionColumn: DataViewListColumn<Person> = {
accessorKey: 'select',
width: 48,
header: ({ table }) => (
<Checkbox
size='small'
checked={table.getIsAllRowsSelected()}
indeterminate={
table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
}
onCheckedChange={checked => table.toggleAllRowsSelected(Boolean(checked))}
aria-label='Select all people'
/>
),
cell: ({ row }) => (
<Checkbox
size='small'
checked={row.getIsSelected()}
onCheckedChange={checked => row.toggleSelected(Boolean(checked))}
onClick={event => event.stopPropagation()}
aria-label={`Select ${row.original.name}`}
/>
)
};

const selectionColumns: DataViewListColumn<Person>[] = [
selectionColumn,
...tableColumns
];

function SelectionBar() {
const { table } = useDataView<Person>();
const selectedCount = table.getSelectedRowModel().flatRows.length;
if (selectedCount === 0) return null;

return (
<FloatingActions aria-label='Selection actions'>
<Chip
variant='outline'
size='large'
color='neutral'
leadingIcon={<TransformIcon />}
isDismissible
onDismiss={() => table.resetRowSelection()}
>
{selectedCount} selected
</Chip>
<FloatingActions.Separator />
<Button variant='outline' color='neutral' size='small'>
Change team
</Button>
<Button variant='outline' color='neutral' size='small'>
Archive
</Button>
</FloatingActions>
);
}

export function DataViewSelectionDemo() {
return (
<Flex direction='column' gap={4} style={{ width: '100%' }}>
{/* `transform` scopes the bar's `position: fixed` to this box. */}
<div
style={{
height: 400,
position: 'relative',
overflow: 'hidden',
transform: 'translateZ(0)'
}}
>
<DataView
data={people}
fields={fields}
defaultSort={defaultSort}
getRowId={person => person.id}
>
<DataView.Toolbar>
<DataView.Search placeholder='Search people…' />
<DataView.Filters />
<DataView.DisplayControls />
</DataView.Toolbar>
<DataView.List
variant='table'
columns={selectionColumns}
classNames={{ root: 'dv-selection-demo-scroll' }}
/>
<SelectionBar />
</DataView>
</div>
<style>{`.dv-selection-demo-scroll { padding-bottom: 64px; }`}</style>
</Flex>
);
}

/* ── Timeline demo ─────────────────────────────────────────────────────── */

type Task = {
Expand Down
2 changes: 2 additions & 0 deletions apps/www/src/components/demo/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
DataViewMultiViewDemo,
DataViewPerViewFieldsDemo,
DataViewSearchDemo,
DataViewSelectionDemo,
DataViewTableDemo,
DataViewTimelineDemo,
DataViewTimelineGroupingDemo,
Expand Down Expand Up @@ -90,6 +91,7 @@ export default function Demo(props: DemoProps) {
DataViewLoadingDemo,
DataViewPerViewFieldsDemo,
DataViewSearchDemo,
DataViewSelectionDemo,
DataViewTimelineDemo,
DataViewTimelineGroupingDemo,
DataViewTimelinePointDemo,
Expand Down
85 changes: 85 additions & 0 deletions apps/www/src/content/docs/components/dataview/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,91 @@ export const searchPreview = {
]
};

export const rowSelectionPreview = {
type: 'code',
style: { padding: 0 },
previewCode: false,
code: `<DataViewSelectionDemo />`,
codePreview: [
{
label: 'index.tsx',
code: `import {
Button,
Checkbox,
Chip,
DataView,
FloatingActions,
useDataView,
} from "@raystack/apsara";
Comment on lines +343 to +350

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 'DataViewListColumn<Person>|DataViewListColumn' \
  apps/www/src/content/docs/components/dataview/demo.ts \
  packages/raystack/components/data-view/index.ts

Repository: raystack/apsara

Length of output: 1092


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== demo snippet =="
sed -n '330,370p' apps/www/src/content/docs/components/dataview/demo.ts

echo
echo "== data-view index exports =="
sed -n '1,40p' packages/raystack/components/data-view/index.ts

echo
echo "== occurrences of DataViewListColumn import =="
rg -n 'Import\s*<\s*.*DataViewListColumn|dataViewListColumn|DataViewListColumn' apps/www/src/content docs packages --glob '!**/node_modules/**' | head -200

Repository: raystack/apsara

Length of output: 4806


Import DataViewListColumn in the example.

selectionColumn is typed as DataViewListColumn<Person>, but that type is not imported from @raystack/apsara, so copied TSX fails type checking.

Proposed fix
 import {
   Button,
   Checkbox,
   Chip,
   DataView,
+  type DataViewListColumn,
   FloatingActions,
   useDataView,
 } from "`@raystack/apsara`";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
code: `import {
Button,
Checkbox,
Chip,
DataView,
FloatingActions,
useDataView,
} from "@raystack/apsara";
code: `import {
Button,
Checkbox,
Chip,
DataView,
type DataViewListColumn,
FloatingActions,
useDataView,
} from "`@raystack/apsara`";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/dataview/demo.ts` around lines 343 -
350, Update the import list in the demo snippet to include the
DataViewListColumn type from `@raystack/apsara`, so the selectionColumn
declaration resolves correctly when the example is copied and type-checked.

import { TransformIcon } from "@radix-ui/react-icons";

const selectionColumn: DataViewListColumn<Person> = {
accessorKey: "select",
width: 48,
header: ({ table }) => (
<Checkbox
size="small"
checked={table.getIsAllRowsSelected()}
indeterminate={
table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
}
onCheckedChange={(checked) => table.toggleAllRowsSelected(Boolean(checked))}
aria-label="Select all people"
/>
),
cell: ({ row }) => (
<Checkbox
size="small"
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(Boolean(checked))}
onClick={(event) => event.stopPropagation()}
aria-label={\`Select \${row.original.name}\`}
/>
),
};

function SelectionBar() {
const { table } = useDataView<Person>();
const selectedCount = table.getSelectedRowModel().flatRows.length;
if (selectedCount === 0) return null;

return (
<FloatingActions aria-label="Selection actions">
<Chip
variant="outline"
size="large"
color="neutral"
leadingIcon={<TransformIcon />}
isDismissible
onDismiss={() => table.resetRowSelection()}
>
{selectedCount} selected
</Chip>
<FloatingActions.Separator />
<Button variant="outline" color="neutral" size="small">Change team</Button>
<Button variant="outline" color="neutral" size="small">Archive</Button>
</FloatingActions>
);
}

<DataView
data={people}
fields={fields}
defaultSort={{ name: "name", order: "asc" }}
getRowId={(person) => person.id}
>
<DataView.Toolbar>
<DataView.Search placeholder="Search people…" />
<DataView.Filters />
<DataView.DisplayControls />
</DataView.Toolbar>
<DataView.List variant="table" columns={[selectionColumn, ...tableColumns]} />
<SelectionBar />
</DataView>`
}
]
};

export const timelinePreview = {
type: 'code',
style: { padding: 0 },
Expand Down
19 changes: 19 additions & 0 deletions apps/www/src/content/docs/components/dataview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
virtualizedGroupingPreview,
loadingPreview,
perViewFieldsPreview,
rowSelectionPreview,
timelinePreview,
timelineGroupingPreview,
timelinePointPreview,
Expand Down Expand Up @@ -274,6 +275,24 @@ const listFields = fields.map((f) =>
<DataView.List name="list" variant="list" columns={listColumns} fields={listFields} />;
```

### 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.

<Demo data={rowSelectionPreview} />

Notes:

- `getRowId` is optional but recommended. Without it, selection falls back to positional keys (`'0'`, `'1'`, … and `'<group>.<index>'` 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 `<input type="checkbox" onChange>`: 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:
Expand Down
3 changes: 3 additions & 0 deletions apps/www/src/content/docs/components/dataview/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export interface DataViewProps {
/** Column visibility change callback. */
onColumnVisibilityChange?: (visibility: Record<string, boolean>) => 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<string, boolean>) => void;

/** Return a stable unique id for each row (used as React key). */
getRowId?: (row: T, index: number) => string;

Expand Down
37 changes: 34 additions & 3 deletions packages/raystack/components/data-view/data-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import {
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getSortedRowModel,
RowSelectionState,
Updater,
useReactTable,
VisibilityState
Expand Down Expand Up @@ -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';
Expand All @@ -57,6 +60,7 @@ function DataViewRoot<TData>({
onLoadMore,
onRowClick,
onColumnVisibilityChange,
onRowSelectionChange,
getRowId,
views,
defaultView,
Expand Down Expand Up @@ -130,6 +134,21 @@ function DataViewRoot<TData>({
[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<RowSelectionState>({});

const handleRowSelectionChange = useCallback(
(value: Updater<RowSelectionState>) => {
setRowSelectionState(prev => {
const newValue = typeof value === 'function' ? value(prev) : value;
onRowSelectionChange?.(newValue);
return newValue;
});
Comment on lines +141 to +147

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
rg -n -C3 'onRowSelectionChange|StrictMode|toggleSelected|setRowSelection' \
  packages/raystack/components/data-view/__tests__/data-view.test.tsx

Repository: raystack/apsara

Length of output: 153


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== locate files =="
fd -a 'data-view\.tsx$|data-view\.test\.tsx$' . | sed 's#^\./##' | head -50

echo "== outline target =="
ast-grep outline packages/raystack/components/data-view/data-view.tsx || true

echo "== relevant source lines =="
cat -n packages/raystack/components/data-view/data-view.tsx | sed -n '1,240p'

echo "== tests relevant mentions =="
python3 - <<'PY'
from pathlib import Path
p = Path('packages/raystack/components/data-view/__tests__/data-view.test.tsx')
if p.exists():
    txt = p.read_text()
    for term in ['onRowSelectionChange','StrictMode','toggleSelected','setRowSelection','selection']:
        print(f'\\n-- {term} --')
        try:
            hits = list(p.rglob('*'))  # only one file so rg would work; search text here
            for i,line in enumerate(txt.splitlines(),1):
                if term.lower() in line.lower():
                    print(f'{i}: {line}')
        except Exception as e:
            print('ERR', e)
else:
    print('test file missing')
PY

Repository: raystack/apsara

Length of output: 9993


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import subprocess, tempfile, os, re

src = subprocess.check_output(['git','show','HEAD:packages/raystack/components/data-view/data-view.tsx'], text=True)
lines = src.splitlines()
selected = 89 if selected == 89 else None

# Show relevant later lines
for i,line in enumerate(lines[240:360], 241):
    if i>330: break
    print(f"{i}: {line}")

# Extract hook body and dependency usage patterns
for name in ['useCallback','useEffect','useRef','useState','useReactTable','rootProps']:
    print(f"\n-- occurrences of {name} --")
    for i,l in enumerate(lines,1):
        if re.search(rf"\b{name}\b", l):
            print(f"{i}: {l}")
PY

echo "== data-view test selection snippets =="
python3 - <<'PY'
from pathlib import Path
p = Path('packages/raystack/components/data-view/__tests__/data-view.test.tsx')
text = p.read_text()
for i,line in enumerate(text.splitlines(),1):
    if i >= 1120 and i <= 1205:
        print(f"{i}: {line}")
PY

Repository: raystack/apsara

Length of output: 268


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== later DataViewRoot/source context =="
cat -n packages/raystack/components/data-view/data-view.tsx | sed -n '241,380p'

echo "== hook/context usages =="
rg -n -C2 'useCallback|useEffect|useRef|useState|useReactTable|onRowSelectionChange|onColumnVisibilityChange' packages/raystack/components/data-view/data-view.tsx packages/raystack/components/data-view/__tests__/data-view.test.tsx

echo "== selection-related test snippets =="
cat -n packages/raystack/components/data-view/__tests__/data-view.test.tsx | sed -n '1128,1205p'

echo "== package react versions from lock/package data =="
python3 - <<'PY'
from pathlib import Path
for p in [Path('package.json'), Path('pnpm-lock.yaml'), Path('yarn.lock')]:
    if p.exists():
        print(f"\n-- {p} --")
        text = p.read_text(errors='ignore')
        for needle in ['"react"', '"react-dom"', '`@types/react`']:
            if needle in text:
                for line in text.splitlines():
                    if needle in line:
                        print(line)
PY

Repository: raystack/apsara

Length of output: 50371


Move onRowSelectionChange out of the state updater.

Calling a consumer callback inside the setRowSelectionState(prev => ...) function lets it run during renders or React 18/19 Strict Mode double-invocation paths, so consumers can receive duplicate or out-of-order selection updates. Track the last reported selection and report from an effect after the commit/update path.

Proposed fix
 const [rowSelection, setRowSelectionState] = useState<RowSelectionState>({});
+const lastReportedRowSelection = useRef(rowSelection);

 const handleRowSelectionChange = useCallback(
   (value: Updater<RowSelectionState>) => {
     setRowSelectionState(prev => {
       const newValue = typeof value === 'function' ? value(prev) : value;
-      onRowSelectionChange?.(newValue);
       return newValue;
     });
   },
-  [onRowSelectionChange]
+  []
 );
+
+useEffect(() => {
+  if (lastReportedRowSelection.current === rowSelection) return;
+  lastReportedRowSelection.current = rowSelection;
+  onRowSelectionChange?.(rowSelection);
+}, [rowSelection, onRowSelectionChange]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleRowSelectionChange = useCallback(
(value: Updater<RowSelectionState>) => {
setRowSelectionState(prev => {
const newValue = typeof value === 'function' ? value(prev) : value;
onRowSelectionChange?.(newValue);
return newValue;
});
const handleRowSelectionChange = useCallback(
(value: Updater<RowSelectionState>) => {
setRowSelectionState(prev => {
const newValue = typeof value === 'function' ? value(prev) : value;
return newValue;
});
},
[]
);
useEffect(() => {
if (lastReportedRowSelection.current === rowSelection) return;
lastReportedRowSelection.current = rowSelection;
onRowSelectionChange?.(rowSelection);
}, [rowSelection, onRowSelectionChange]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/data-view/data-view.tsx` around lines 141 - 147,
Update handleRowSelectionChange so the setRowSelectionState updater only
computes and returns the next selection state; move consumer notification out of
the updater by tracking the latest reported selection and invoking
onRowSelectionChange from an effect after the state commit, while avoiding
duplicate reports.

},
[onRowSelectionChange]
);

const [tableQuery, setTableQuery] =
useState<InternalQuery>(defaultTableQuery);

Expand Down Expand Up @@ -181,24 +200,32 @@ function DataViewRoot<TData>({
}
}, [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<TData>)?.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
}
Expand Down Expand Up @@ -260,6 +287,8 @@ function DataViewRoot<TData>({
shouldShowFilters,
columnVisibility,
setColumnVisibility: handleColumnVisibilityChange,
rowSelection,
setRowSelection: handleRowSelectionChange,
views,
activeView,
setActiveView,
Expand Down Expand Up @@ -287,6 +316,8 @@ function DataViewRoot<TData>({
shouldShowFilters,
columnVisibility,
handleColumnVisibilityChange,
rowSelection,
handleRowSelectionChange,
views,
activeView,
setActiveView,
Expand Down
Loading
Loading