From be114098d84577c9fd12c5bfdfa70e2e1f3e425d Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 23 Apr 2026 04:10:18 +0530 Subject: [PATCH 1/5] chore: analyse dataview --- ANALYSIS.md | 1449 +++++++++++++++++ apps/www/src/app/examples/dataview/page.tsx | 643 ++++++++ .../data-view/components/content.tsx | 407 +++++ .../data-view/components/display-access.tsx | 33 + .../components/display-properties.tsx | 39 + .../data-view/components/display-settings.tsx | 122 ++ .../data-view/components/filters.tsx | 159 ++ .../data-view/components/grouping.tsx | 62 + .../components/data-view/components/list.tsx | 349 ++++ .../data-view/components/ordering.tsx | 76 + .../data-view/components/renderer.tsx | 28 + .../data-view/components/search.tsx | 64 + .../components/data-view/components/table.tsx | 54 + .../data-view/components/toolbar.tsx | 50 + .../components/virtualized-content.tsx | 432 +++++ .../raystack/components/data-view/context.tsx | 9 + .../components/data-view/data-view.module.css | 271 +++ .../components/data-view/data-view.tsx | 251 +++ .../components/data-view/data-view.types.tsx | 248 +++ .../data-view/hooks/useDataView.tsx | 13 + .../components/data-view/hooks/useFilters.tsx | 89 + .../raystack/components/data-view/index.ts | 19 + .../data-view/utils/filter-operations.tsx | 292 ++++ .../components/data-view/utils/index.tsx | 360 ++++ packages/raystack/index.tsx | 12 + 25 files changed, 5531 insertions(+) create mode 100644 ANALYSIS.md create mode 100644 apps/www/src/app/examples/dataview/page.tsx create mode 100644 packages/raystack/components/data-view/components/content.tsx create mode 100644 packages/raystack/components/data-view/components/display-access.tsx create mode 100644 packages/raystack/components/data-view/components/display-properties.tsx create mode 100644 packages/raystack/components/data-view/components/display-settings.tsx create mode 100644 packages/raystack/components/data-view/components/filters.tsx create mode 100644 packages/raystack/components/data-view/components/grouping.tsx create mode 100644 packages/raystack/components/data-view/components/list.tsx create mode 100644 packages/raystack/components/data-view/components/ordering.tsx create mode 100644 packages/raystack/components/data-view/components/renderer.tsx create mode 100644 packages/raystack/components/data-view/components/search.tsx create mode 100644 packages/raystack/components/data-view/components/table.tsx create mode 100644 packages/raystack/components/data-view/components/toolbar.tsx create mode 100644 packages/raystack/components/data-view/components/virtualized-content.tsx create mode 100644 packages/raystack/components/data-view/context.tsx create mode 100644 packages/raystack/components/data-view/data-view.module.css create mode 100644 packages/raystack/components/data-view/data-view.tsx create mode 100644 packages/raystack/components/data-view/data-view.types.tsx create mode 100644 packages/raystack/components/data-view/hooks/useDataView.tsx create mode 100644 packages/raystack/components/data-view/hooks/useFilters.tsx create mode 100644 packages/raystack/components/data-view/index.ts create mode 100644 packages/raystack/components/data-view/utils/filter-operations.tsx create mode 100644 packages/raystack/components/data-view/utils/index.tsx diff --git a/ANALYSIS.md b/ANALYSIS.md new file mode 100644 index 000000000..316c10ae2 --- /dev/null +++ b/ANALYSIS.md @@ -0,0 +1,1449 @@ +# DataView Component — Feasibility & Architecture Analysis + +> **Verdict:** ✅ Highly feasible. ~80% of today's `DataTable` logic is already renderer-agnostic; the proposal is largely an **extraction + renaming** exercise, not a rewrite. TanStack Table's headless core can drive Table, List, and Timeline renderers. Real work is in defining the **field / renderer contract** and extending grouping to support non-accessor buckets (e.g. time ranges). + +--- + +## 1. Problem Statement + +Today's `DataTable` (`packages/raystack/components/data-table/`) couples two layers: + +1. **A data-modeling layer** — query state (filters, sort, group, search, pagination), client-vs-server mode, row model derivation. +2. **A tabular rendering layer** — table header/body/row/cell DOM, column visibility UI, virtualization, sticky group headers. + +Non-table presentations (List, Timeline, Kanban, Gallery) would need to duplicate layer 1 unless we extract it. The proposal: a single `DataView` root that owns layer 1, plus swappable renderer subcomponents for layer 2. + +### Target API + +```tsx + + + + + + + + {/* pick one — or switch between them with a tab/toggle */} + + {/* same column shape as Table + grid `width` */} + ( + + + {row.getValue('title')} + + + {row.getValue('priority')} + + + )} + /> + {(api) => ...} + +``` + +--- + +## 2. Inventory of Current DataTable + +Source: `packages/raystack/components/data-table/` + +### 2.1 Presentation-agnostic (lives in `DataView` root/context) + +| Concern | Current location | Notes | +| --- | --- | --- | +| Query state `{ filters, sort, group_by, search, offset, limit }` | `data-table.tsx:75-76`, `data-table.types.tsx:52-60` (`InternalQuery`) | Pure state. Reusable as-is. | +| `updateTableQuery` mutator | `data-table.tsx:149-151` | Single entry point for all state changes. | +| Client↔server wire format | `utils/index.tsx:208-316` (`transformToDataTableQuery`, `dataTableQueryToInternal`) | Normalizes `ilike` wildcards, operator mapping. Reusable. | +| `mode: 'client' \| 'server'` | `data-table.types.tsx:16`, `data-table.tsx:124-136` | Switches whether TanStack does filter/sort locally or defers to backend. Renderer-independent. | +| `hasActiveQuery` / `hasQueryChanged` | `utils/index.tsx:167-195` | Distinguishes zero-state vs empty-state; detects change for `onTableQueryChange`. | +| `onLoadMore`, `totalRowCount`, `loadingRowCount`, pagination | `data-table.tsx:153-158` | Server-mode infinite scroll signals. Renderer-independent; each renderer implements its own "bottom-reached" detection. | +| `defaultSort`, `onDisplaySettingsReset` | `data-table.tsx:85-98` | Config. | +| Column-filter predicates (`filterOperationsMap`) | `utils/filter-operations.tsx:33-153` | TanStack `FilterFn` signatures. Reusable for any renderer that runs TanStack's row model. | +| Filter operator UI metadata (`filterOperators`) | `types/filters.tsx:85-117` | Already lives in `~/types/filters`, shared. | +| `groupData()` — groups flat list into `GroupedData[]` with label/count/subRows | `utils/index.tsx:71-107` | Today only groups by **accessorKey string**. Needs extension to support a `groupBy` *function* so Timeline can bucket by day/week. | +| `useFilters()` hook — add/remove/change filters | `hooks/useFilters.tsx` | Pure logic over `updateTableQuery`. Reusable. | + +### 2.2 Table-specific (belongs in `DataView.Table`) + +| Concern | Current location | +| --- | --- | +| `` / `` / `` DOM | `components/content.tsx:29-174` | +| `flexRender(columnDef.header, header.getContext())` | `content.tsx:52` | +| `flexRender(columnDef.cell, cell.getContext())` | `content.tsx:167` | +| `table.getVisibleLeafColumns()` → `colSpan` | `content.tsx:242` | +| Virtualized table layout with absolute-positioned rows | `components/virtualized-content.tsx` | +| Sticky group header positioned under table `` | `virtualized-content.tsx:267-307` | +| Skeleton loader rows (column-shaped) | `content.tsx:72-92`, `virtualized-content.tsx:160-204` | +| IntersectionObserver on last `` for server load-more | `content.tsx:212-240` | +| "N items hidden by filters" footer | `content.tsx:314-344` — arguably renderer-agnostic, but uses table metrics | + +### 2.3 Ambiguously coupled (needs explicit repositioning) + +- **`columnDef.cell` / `columnDef.header`**: return `ReactNode` with TanStack's `CellContext`/`HeaderContext`. Consumed by any renderer that lays cells out in aligned columns — i.e. both `DataView.Table` (table DOM) and `DataView.List` (CSS `grid` + `subgrid`). They share the column spec shape, differ only in visual chrome. `DataView.Timeline` is different: bars are variable-width, so a shared column grid doesn't apply; Timeline uses `renderBar(row)` with `` (§4.6) for visibility. **Decision:** keep `cell`/`header` on both Table and List column specs; Timeline uses the DisplayAccess primitive. +- **`enableColumnFilter / enableGrouping / enableSorting / enableHiding / defaultHidden`**: these describe the *field's* capability, not any particular cell. Stay on DataView root fields. Rename `enableColumnFilter` → `filterable` (keep alias) to drop table-speak. +- **Column visibility state**: meaningful for Table (hides columns), ambiguous for List (could hide metadata chips), meaningless for Timeline. **Decision:** keep visibility state in DataView root but let each renderer interpret it (or ignore it). +- **`onRowClick`**: fine as-is but conceptually "onItemClick". Keep name for continuity; rename is cosmetic. +- **`shouldShowFilters` computed from `table.getRowModel().rows.length`** (`data-table.tsx:173-185`): mixes data-layer and render-layer. Can be recomputed from `data.length + filters.length + search` without TanStack — decoupling improves clarity. + +--- + +## 3. Can TanStack Table drive non-table renderers? + +### Short answer: **Yes** — it's the right engine, with two caveats. + +TanStack Table is *headless*. The core (`@tanstack/table-core`, already a dep) produces a `table` object whose job is to turn `data + column defs + state` into a **row model**: a tree of `Row` with `.original`, `.getValue(colId)`, `.subRows`, `.getIsSelected()`, filtered/sorted/grouped/expanded per current state. Emitting DOM is entirely the caller's job. + +So for any renderer we can do: + +```tsx +const rows = table.getRowModel().rows; +// Table: rows.map(row => …cells…) +// List: rows.map(row => {columns.map(c => {c.cell(row)})}) +// Timeline: bucketByDate(rows).map(bucket => ) +``` + +All three renderers share: filter state, sort state, search state, selection state, client/server mode, load-more. **This is the whole point of the extraction.** + +### Caveat 1: `columnDef.cell` fits aligned-column layouts only + +The `cell` render function returns a `ReactNode`. Any renderer that lays cells out in aligned columns can consume it — Table wraps each call in ``, List wraps it in a CSS `grid` cell (`display: subgrid` per row). The two share the column spec shape. **Timeline is the exception**: bars are variable-width (a 1-day bar and a month-long bar can't share a `grid-template-columns`), so Timeline accepts `renderBar(row)` and relies on `` (§4.6) to gate per-field visibility inside the bar. This keeps the visibility story uniform across all three renderers without forcing a column grid onto bar content. + +### Caveat 2: Grouping only by accessor-key + +`groupData()` (`utils/index.tsx:71`) is accessor-only. Timeline needs to bucket rows by computed keys (e.g. `dayjs(row.createdAt).format('YYYY-MM-DD')`). Fix: allow `group_by` to also be a user-supplied `(row: TData) => string` function, or let each renderer bypass `groupData` and bucket its own way (preferred: keeps root simple). + +### Why keep TanStack (vs. rolling our own)? + +- **Already a dep** and already powering DataTable. No new surface area. +- **Client-mode filter/sort row model is non-trivial** (stable sort, filter-from-leaf-rows, expanded sub-rows). Reimplementing this is pure churn. +- Users never import TanStack types directly — they import `DataViewField`, `DataViewTableColumn` — so the dep stays an implementation detail. Tree-shakes to ~20–25 kB. +- TanStack Virtual (also already a dep) works for all renderers. + +### Rejected alternatives + +- **Hand-rolled query state + predicates**: loses free row model, duplicates filter operators. Not worth it. +- **`useReactTable` only for `DataView.Table`, bespoke hooks for List/Timeline**: forks the filter-predicate path; cross-renderer switches (Table ↔ List toggle) would re-derive state. No. +- **`ag-grid`, `react-data-grid`, `material-react-table`**: heavy, opinionated renderers, not pluggable at the DOM level. Wrong fit. + +--- + +## 4. Proposed Architecture + +### 4.1 Layer cake + +``` +┌─────────────────────────────────────────────────────────────┐ +│ — owns TanStack Table instance + tableQuery │ +│ state = { filters, sort, group_by, search, offset, limit } │ +│ context exposes: table, rows, tableQuery, updateTableQuery,│ +│ fields, mode, isLoading, loadMoreData, onItemClick, … │ +└───────────────┬─────────────────────────────────────────────┘ + │ + ┌────────────┼────────────┬───────────────┬──────────────┐ + ▼ ▼ ▼ ▼ ▼ + + (reads (reads (reads/writes (renderers; each reads `rows` + search) filters) sort/group/hide) or `table` from context and + emits its own DOM) +``` + +### 4.2 Type contract + +```ts +// Data-model field — presentation-agnostic +export interface DataViewField { + accessorKey: Extract; + label: string; // was `header` string form + icon?: ReactNode; + + // filter capability + filterable?: boolean; // alias: enableColumnFilter + filterType?: FilterTypes; // number|string|date|select|multiselect + dataType?: FilterValueType; + filterOptions?: FilterSelectOption[]; + defaultFilterValue?: unknown; + filterProps?: { select?: BaseSelectProps }; + + // ordering / grouping / visibility capability + sortable?: boolean; + groupable?: boolean; + hideable?: boolean; + defaultHidden?: boolean; + + // group-header presentation (used by any renderer that groups) + showGroupCount?: boolean; + groupCountMap?: Record; + groupLabelsMap?: Record; +} + +// Table render spec — pure reference. Points at a field by `accessorKey`; +// only adds table-cell rendering. Does NOT extend DataViewField (filterable / +// sortable / filterType etc. live on the field, not duplicated here). +export interface DataViewTableColumn { + accessorKey: Extract; // pointer into fields[] + cell?: ColumnDef['cell']; // flexRender-able body cell + header?: ColumnDef['header']; // flexRender-able header cell (overrides field.label) + classNames?: { cell?: string; header?: string }; + styles?: { cell?: CSSProperties; header?: CSSProperties }; +} + +// List render spec — same column shape as Table plus a CSS-grid `width` hint. +// List renders rows inside a CSS `grid` container; each row uses `display: +// subgrid` so cells align vertically across rows (same semantic as Table +// columns, different visual chrome — no thead, looser row styling, optional +// dividers). +export interface DataViewListColumn { + accessorKey: Extract; // pointer into fields[] + cell?: ColumnDef['cell']; // flexRender-able cell + width?: string | number; // CSS grid track — '1fr' | '200px' | 'auto' | 'minmax(80px, 1fr)' | number(px) + classNames?: { cell?: string }; + styles?: { cell?: CSSProperties }; +} + +// DisplayAccess — foundational visibility primitive (see §4.6). +// Wrap any JSX with it; the wrapper reads `columnVisibility` from DataView +// context and renders children only when the named field is currently visible. +// Used inside Timeline's `renderBar` and any custom renderer. Table/List use +// column specs, so they gate visibility internally from the same state. +export interface DataViewDisplayAccessProps { + accessorKey: string; + children: ReactNode; + fallback?: ReactNode; // rendered when hidden (default: null) +} + +// Root props — data layer only. No render specs here; each renderer takes its own. +export interface DataViewProps { + data: TData[]; + fields: DataViewField[]; + defaultSort: DataViewSort; + query?: DataViewQuery; + mode?: 'client' | 'server'; + isLoading?: boolean; + totalRowCount?: number; + loadingRowCount?: number; + onQueryChange?: (query: DataViewQuery) => void; + onLoadMore?: () => Promise; + onItemClick?: (row: TData) => void; + onColumnVisibilityChange?: (v: VisibilityState) => void; + getRowId?: (row: TData, index: number) => string; + groupByResolvers?: Record string>; +} +``` + +> **Renderer row render specs (`columns` on Table/List; `startField`/`endField`/`renderBar` on Timeline; …) are declared on their renderer subcomponent, not on ``.** Rationale: they're consumed by exactly one component each, so they belong there (classic React composition). `` (§4.6) is the one cross-renderer primitive consumers compose inside `renderBar` or custom renderers so visibility state reaches free-form JSX. See §4.5. + +### 4.3 Context shape + +```ts +export interface DataViewContext { + table: Table; // full TanStack instance (renderers read rows/columns from here) + rows: Row[]; // filtered+sorted+grouped (convenience) + fields: DataViewField[]; // metadata (Filters/DisplayControls/every renderer use it) + tableQuery: InternalQuery; + updateTableQuery: (fn: TableQueryUpdateFn) => void; + mode: 'client' | 'server'; + isLoading: boolean; + loadMoreData: () => void; + defaultSort: DataViewSort; + onItemClick?: (row: TData) => void; + onDisplaySettingsReset: () => void; + shouldShowFilters: boolean; // computed from data.length + filters + search + hasActiveQuery: boolean; // drives empty-vs-zero state at each renderer + totalRowCount?: number; + loadingRowCount?: number; + columnVisibility: VisibilityState; // single source for DisplayAccess + column gating + setColumnVisibility: (v: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => void; +} +``` + +Context is strictly the **data layer**: state, derived rows, field metadata, mutators. Renderer-specific render specs are props on the renderer, not context. + +Note: the current context already matches this almost 1:1. The diff is: rename `columns` → `fields`, expose `rows` as a convenience, surface `columnVisibility` + `setColumnVisibility` so `` (§4.6) and every renderer read from the same source of truth, drop `stickyGroupHeader` (Table-only prop, not context). + +### 4.4 Data flow + +1. **Mount.** `DataView` constructs `tableQuery` from `defaultSort` + `query?` prop, builds TanStack Table over `data` + `fields` (translated to `ColumnDef`s with filter predicates wired from `filterOperationsMap`). +2. **User interacts with a toolbar control:** + - `Search` → `updateTableQuery({search})` → TanStack applies `globalFilter` (client) OR parent `onQueryChange` fires with normalized `DataViewQuery` (server). + - `Filters` → same dispatch path, updates `columnFilters`. + - `DisplayControls` → sort / group_by / columnVisibility dispatch. +3. **Row model recomputes** (memoized). `rows = table.getRowModel().rows`. +4. **Renderer reads rows from context** and emits DOM. + - `Table` renders TanStack rows as ``; columns hidden via `columnVisibility` collapse automatically. + - `List` renders each row as a CSS-grid track (`display: subgrid`) with `flexRender(cell)` per column; hidden columns collapse their grid track the same way Table collapses its `` + denser rows + borders; List has no header + looser spacing + optional dividers + card-ish row styling. Token-driven so drift is contained. | +| Backwards-compat of the existing `DataTable` consumer API | Keep `DataTable` export as an alias through at least one major version. Only `DataTableDemo`-style imports need to change (`DataTable` → `DataView`, `DataTable.Content` → `DataView.Table`). | + +--- + +## 9. Feasibility Scorecard + +| Dimension | Score | Reason | +| --- | --- | --- | +| Technical feasibility | ★★★★★ | Existing code already separates layers; TanStack is headless. | +| API ergonomics of proposal | ★★★★☆ | Clean; one gotcha (fields vs columns naming) — solvable with typing. | +| Migration cost | ★★★★☆ | Non-breaking path exists. Most work is new code (List, Timeline) not refactor. | +| Coverage of future renderers (Kanban, Gallery, Map) | ★★★★★ | `DataView.Custom` + shared context handles anything. | +| Library choice (TanStack Table Core) | ★★★★★ | Already a dep; ideal fit for headless row modeling. | +| Risk | ★★★★☆ | Timeline grouping and column-visibility semantics are the only non-trivial issues. | + +--- + +## 10. Case Study: Gantt-style Timeline (screenshot reference) + +The task-prioritisation screenshot shows a **range-based timeline / Gantt**, not a point-in-time event timeline. Properties visible in the image: + +- Continuous horizontal time axis (Jan 2025 → Feb), day-level ticks, month headers, a "today" marker (17 Jan). +- Each item is a **bar** spanning from its **start date to its end date** — width encodes duration. +- Items are **packed into lanes** (vertical rows) so they don't visually overlap. +- Each bar has a title, due date, assignee, priority chip, status icon — i.e. the same fields any renderer would read. +- Filter + Display controls sit above the canvas — a direct match for ``. + +### Does the proposed architecture support this? + +**Yes, end-to-end.** The split between data layer and renderer holds up exactly: + +| Concern in the screenshot | Where it lives | +| --- | --- | +| "Filter" button, applied filter chips, search | `` / `` — unchanged. Filters read from `fields[]` metadata and dispatch through `updateTableQuery`. | +| "Display" button (sort / group / visible properties) | `` — unchanged. | +| Data subset actually visible on the canvas | Context `rows` after filter+sort+search. The Gantt renderer consumes `rows`, not raw `data`. | +| Ordering of items/lanes | `query.sort` — the renderer reads `rows` already in sorted order. Could sort by startDate, priority, assignee. | +| Grouping into lane-bands (e.g. one band per assignee) | `query.group_by` — renderer maps group → horizontal band, packs items into lanes *within* the band. | +| Start/end positioning of each bar | Pure renderer math: `left = pxPerDay * (row.start - viewportStart)`, `width = pxPerDay * (row.end - row.start)`. Data layer doesn't need to know about pixels. | +| Lane packing (collision-free vertical placement) | Renderer-owned algorithm: greedy interval-scheduling over sorted items. | +| Time-axis ruler + today marker | Renderer-owned. | +| Virtualization (the canvas clearly extends beyond the viewport in both axes) | Renderer-owned, two-axis: time-window virtualization + lane virtualization. TanStack Virtual handles each axis. | + +### What the Timeline renderer needs to expose + +A point-timeline shape (single date field) is insufficient — the screenshot forces a more general range-based shape: + +```tsx + void + today?: Date | boolean // draw today line (defaults: true, new Date()) + + // Layout + lanePacking?: 'auto' | 'one-per-row' // auto = greedy pack to avoid overlap + laneGap?: number + rowHeight?: number + + // Rendering — bars are variable-width, so cells can't share a grid. + // Compose freely; wrap each field in so + // "Display Properties" toggles apply to bar content the same way they + // apply to Table/List columns. + renderBar: (row: Row) => ReactNode + renderLaneGroup?: (group: GroupedData) => ReactNode // band header when grouped + + // Interaction + onItemClick?: (row: TData) => void // inherits from context if omitted + // future: onItemDrag, onResize for editable Gantt +/> +``` + +### Why this still fits the shared-context story + +Nothing above touches the data layer. Filtering out low-priority items with `` simply shrinks `rows` — the Gantt renderer re-packs lanes. Switching from Gantt to Table (in a multi-renderer ``) preserves filters, search, sort, selection, and column-visibility state. Grouping by assignee in the Display panel turns lanes into assignee-bands. Toggling "Priority" off in Display Properties hides the `` inside the bar because it's wrapped in `` — same toggle that hides the "Priority" column in Table/List. + +### What the Gantt renderer adds that List/Table don't + +1. **Two-axis virtualization** (time × lanes). Non-trivial but solvable; TanStack Virtual instance per axis, or a 2D library. +2. **Interval-scheduling lane packer.** ~15 lines, independent module, testable. +3. **Date-range operations on filters.** Already in `filterOperationsMap.date`. A "due this week" filter works as-is. +4. **Editable bars (future).** Drag to change start, resize to change end → callbacks that dispatch back into the parent's data source. Completely renderer-local; doesn't leak into DataView root. + +### Risks specific to this view + +| Risk | Mitigation | +| --- | --- | +| Items without an `endField` value | Default to point marker, or treat as same-day bar (`width = minBarWidth`). | +| Items with `start > end` (data bugs) | Renderer decides: swap, clamp, or hide + warn. Doesn't affect query state. | +| Extremely wide time range with sparse items | Timeline computes auto-viewport to fit `min(startField) → max(endField)` of filtered rows. | +| Screen text in bars overflowing (visible in screenshot — bars at viewport edges are clipped) | Rendering concern — standard text-overflow handling inside the bar. | +| Heavy re-renders when dragging | Renderer memoizes lane layout per `(rows, viewport)` tuple. | + +### Bottom line + +The screenshot is the **strongest validation** of the DataView proposal, not a counter-example. It exercises every toolbar primitive the proposal adds (`Search`, `Filters`, `DisplayControls` = "Filter" + "Display" in the image), requires no changes to the data model, and isolates the view-specific complexity (lane packing, time-axis math, two-axis virtualization) inside ``. If a tabular view of the same tasks were needed tomorrow, swapping `` for `` under the same `` root would show the same filtered/sorted/grouped tasks with zero logic rewiring — which is exactly the goal. + +--- + +## 11. Case Study: Uniform List view (screenshot reference) + +The list screenshot (avatars + name/subheading + label chip + collaborator stack + status) is structurally a **row of aligned cells** — the same mental model as Table, just with different chrome. Using CSS `grid` on the list container + `display: subgrid` per row, cells align vertically across rows without a `
flexRender(cell)
`. + - `Timeline` skips `groupData`; instead reads filtered `rows`, positions each item by `startField`/`endField`, lane-packs to avoid overlap, and calls `renderBar(row)` — inside which any `` reads the same `columnVisibility` state. + - `Custom` receives the whole context and does anything. + +### 4.5 Renderer-specific prop shapes + +**Rule:** each renderer owns its row render spec + view knobs. Only data-layer concerns live on ``. Props live where they're read. + +**Table and List share the column-spec shape** — both lay cells out in aligned columns (Table uses table DOM; List uses CSS `grid` + `subgrid`). Column visibility flows through both automatically because each column is keyed by `accessorKey` and the renderer reads `columnVisibility` from context. **Timeline is structurally different** — bars are variable-width — so it uses `renderBar(row)` and relies on `` (§4.6) to gate field visibility inside bars. + +```tsx +// Table — aligned columns in table DOM +[] // required — per-column cell / header renderers + virtualized? + rowHeight? groupHeaderHeight? overscan? + stickyGroupHeader? + emptyState? zeroState? + classNames? +/> + +// List — aligned columns in CSS grid/subgrid; same data shape as Table, different chrome +[] // required — per-column cell + grid `width` + virtualized? // needs constant or estimated rowHeight + rowHeight? overscan? + showDividers? + showGroupHeaders? // when group_by is active + emptyState? zeroState? + classNames? +/> + +// Timeline — variable-width bars on a time axis + // required — drives bar X position + endField?: Extract // omitted → point marker; present → Gantt bar + renderBar: (row: Row) => ReactNode // required — compose with for visibility + laneField?: Extract // optional — partition into bands per group value + scale? // 'day' | 'week' | 'month' | 'quarter' + today? // boolean | Date + lanePacking? // 'auto' | 'one-per-row' + rowHeight? laneGap? overscan? + viewportRange? onViewportChange? + renderLaneGroup?: (group: GroupedData) => ReactNode // band header + emptyState? zeroState? + classNames? +/> + +// Custom — escape hatch; render lives in children + + {({ rows, fields, tableQuery, updateTableQuery, columnVisibility, ... }) => ReactNode} + +``` + +**Why this split:** + +- **Props live where they're read.** `columns` is consumed only by the column-based renderers; `renderBar` only by Timeline. Declaring them on each renderer is the natural React shape. +- **Shared column shape (Table ↔ List) is the ergonomic win.** Same mental model for declaring a row of cells; swapping renderers is close to a tag change. `DataViewTableColumn` adds `header`; `DataViewListColumn` adds grid `width`. Otherwise identical. +- **Timeline uses DisplayAccess, not columns.** Bars are variable-width, so a shared `grid-template-columns` can't apply — a template that fits a month bar overflows a 1-day bar. `` (§4.6) gives Timeline the same visibility story as Table/List without forcing a column grid onto bar content. +- **Root stays lean.** `` props describe the data; no renderer slots. +- **Third-party renderers compose cleanly.** A `` reads from context and defines its own prop surface; it uses `` for visibility without needing a slot on the root type. + +**Renderer-switcher UI:** userland holds the specs and passes them conditionally. Toolbar/filters/search/sort persist across switches because they live on context, untouched. + +```tsx +{view === 'table' && } +{view === 'list' && } +{view === 'timeline' && } +``` + +### 4.6 DisplayAccess — foundational visibility primitive + +One component that gates its children on the current `columnVisibility` state in context: + +```tsx + + {row.getValue('priority')} + +``` + +Semantics: + +1. Reads `ctx.columnVisibility[accessorKey]` (defaults to `true` if the field has never been toggled). +2. Renders `children` when visible, `fallback` (default `null`) when hidden. +3. Renderer-agnostic. Works inside any JSX a renderer emits — Timeline bars, custom Kanban cards, map tooltips, detail drawers. + +```tsx +// Typical use inside a Timeline bar + ( + + + {row.getValue('title')} + + + {formatDate(row.getValue('dueAt'))} + + + )} +/> +``` + +**Why this primitive exists.** Without it, non-columnar renderers (Timeline bars, custom) have no way to react to the single Display Properties toggle — each would need a bespoke visibility mechanism, or DisplayOptions would silently fail to affect their content. `DisplayAccess` is the one place where the data layer's visibility state reaches a free-form render function. + +**Role in Table/List.** Table and List don't need it at the call site — `columns` already declares each cell's `accessorKey`, so the renderer gates the column internally (hiding the whole grid track or `` when invisible). Consumers can still drop `DisplayAccess` inside a `cell` renderer for sub-field visibility (e.g. hide a secondary label inside a "name" cell), but the common case is handled automatically. + +**Dev diagnostics.** If a field appears in `fields[]` with `hideable: true` but is referenced by **neither** a column spec **nor** a `` (across any mounted renderer), log a dev warning at mount — the toggle would be a silent no-op. Cheap: one pass over fields × columns at mount, plus a registration callback from each `DisplayAccess` instance. + +**Complementary, not a replacement.** Responsive hiding inside Timeline bars (hide subtitle when a bar is narrow) is a separate concern — solved by container queries or a `priority`-aware wrapper. `DisplayAccess` only handles user-driven visibility from DisplayOptions. + +--- + +## 5. What to Improve vs Drop + +### Improvements +1. **Explicit composition of Search in Toolbar.** Today `Toolbar` auto-renders `Filters + DisplaySettings`; `Search` is a peer but not included. Proposed API makes the user compose them, which lets consumers: + - Place search outside the toolbar (common in master-detail layouts). + - Add custom toolbar children (bulk actions, "Export", "New"). +2. **Rename `columns` → `fields` at the root.** Disambiguates from renderer columns. `DataView.Table` still takes `columns` which are fields + cell/header renderers. +3. **Group-by function support.** Extend `InternalQuery.group_by` so a non-accessor resolver can be named. Unlocks Timeline bucketing, "updated this week / earlier" grouping in List, etc. +4. **Unified Display Properties across all renderers.** With `` (§4.6) as the visibility primitive and `columnVisibility` on context, every renderer honors the same toggle uniformly — Table/List via column specs (hidden tracks collapse), Timeline via `DisplayAccess` wrappers inside `renderBar`. `DisplayControls` stays a single component; no capability gating required. +5. **Recompute `shouldShowFilters` from data-layer only.** Remove the try/catch around `table.getRowModel()`. +6. **Virtualization as a prop, not a separate component.** `` is cleaner than two exported components. Can keep both during migration. + +### Drop / simplify +1. **`DataView.VirtualizedContent` as separate export** — fold into `DataView.Table virtualized`. +2. **`stickyGroupHeader` from context** — it's a Table renderer prop; doesn't need to be in shared context. +3. **Implicit zero-state rendering inside renderers** — push the zero/empty-state decision up to the root or expose as a `` slot, so it's consistent across renderers instead of each renderer re-implementing the logic (today `Content` and `VirtualizedContent` duplicate the `hasActiveQuery` branch). + +### Keep as-is +- All of `utils/filter-operations.tsx`. +- `useFilters` hook. +- `transformToDataTableQuery` / `dataTableQueryToInternal` (rename types to `DataViewQuery`). +- `mode: 'client' | 'server'` semantics. + +--- + +## 6. Data Model Summary + +Unchanged core query shape (just renamed): + +```ts +// Wire format (what parents pass in / receive back) +export interface DataViewQuery { + filters?: DataViewFilter[]; // each: { name, operator, value, stringValue?, numberValue?, boolValue? } + sort?: DataViewSort[]; // each: { name, order: 'asc' | 'desc' } + group_by?: string[]; // field accessor or resolver key + search?: string; + offset?: number; + limit?: number; +} + +// Internal (with UI metadata) +export interface InternalQuery { + filters?: InternalFilter[]; // adds _type, _dataType for operator picker + sort?: DataViewSort[]; + group_by?: string[]; + search?: string; + offset?: number; + limit?: number; +} +``` + +Row model state (TanStack-owned, exposed via `context.table` + `context.rows`): +- `globalFilter` ← `query.search` +- `columnFilters` ← `query.filters` +- `sorting` ← `query.sort` +- `expanded` ← true when grouping active +- `columnVisibility` ← local state + `onColumnVisibilityChange` callback + +--- + +## 7. Migration Path + +Recommended phased rollout to avoid a breaking "big bang": + +**Phase 0 — rename internals (non-breaking):** +- Extract everything presentation-agnostic in `data-table/` into a `data-view/` package, re-export `DataTable` from there as a thin alias (`` with `` only). +- Rename types: `DataTableQuery` → `DataViewQuery`, `TableContextType` → `DataViewContext`, etc. Keep old names as type aliases. + +**Phase 1 — add renderer primitives:** +- Surface `columnVisibility` + `setColumnVisibility` on context (today it's a local TanStack state; just lift it for `DisplayAccess`). +- Ship `` (tiny component — reads from context, gates children). +- Ship `` (reuses Table column spec shape, adds `width`, CSS grid/subgrid) and ``. +- Add `fields` as an alternative to `columns` on root (accept either; translate internally). + +**Phase 2 — Timeline:** +- Ship ``: lane-packer util, time-axis component, `renderBar` contract. +- Extend `groupData` to accept function resolvers, OR let Timeline bypass `groupData` and bucket internally (preferred). +- Optionally: sugar wrapper `` for Table ↔ List ↔ Timeline toggle inside a single ``. + +**Phase 3 (breaking, optional):** +- Remove old `DataTable` aliases one minor version later, after consumers migrate. + +--- + +## 8. Risks & Open Questions + +| Risk | Mitigation | +| --- | --- | +| Consumers of Timeline/custom renderers forget to wrap fields in ``, making DisplayOptions a silent no-op | Dev warning at mount when a `hideable: true` field is referenced by neither a column spec nor any DisplayAccess instance (§4.6). Documented prominently. | +| Custom grouping function in Timeline breaks `InternalQuery.group_by: string[]` contract | Introduce a `groupByResolvers` map on root: keys are string ids (go into query) but resolve to functions locally. Wire format stays a string. | +| Column visibility and Timeline interact weirdly | Resolved by `` — Timeline bars wrap each field in DisplayAccess, hidden fields disappear from the bar exactly like hidden columns disappear from Table/List. One toggle drives all three. | +| Perf: large unvirtualized List/Timeline | Each renderer must have a virtualized variant. `rowHeight` prop on List (grid track height); Timeline buckets are rendered, items inside a lane virtualize. | +| Multi-renderer mode (user toggles Table↔List↔Timeline in one DataView) | Already free — all renderers consume shared context. Filter/sort/search persist across switches. This is actually a feature, not a risk. | +| Keeping Table and List visually distinct despite sharing the column spec | Design review per renderer: Table has `
`. The column spec is the natural fit; the `width` hint maps straight to `grid-template-columns`. + +### Mapping + +```tsx + + + + + + + + + +``` + +where the column spec mirrors Table (no `header`, adds `width`): + +```tsx +const userListColumns: DataViewListColumn[] = [ + { + accessorKey: 'identity', + width: '1fr', + cell: ({ row }) => ( + + + + {row.original.name} + {row.original.subheading} + + + ), + }, + { + accessorKey: 'label', + width: 'auto', + cell: ({ row }) => ( + }>{row.getValue('label')} + ), + }, + { + accessorKey: 'collaborators', + width: 'auto', + cell: ({ row }) => , + }, + { + accessorKey: 'status', + width: '120px', + cell: ({ row }) => , + }, +]; +``` + +`identity` is a synthetic column key that reads multiple fields from `row.original` — totally fine; the `accessorKey` just needs to match an entry in `fields[]` (a computed field with `hideable: false` if you don't want users toggling the avatar/name/subheading as one block). Or split it into three real columns for individual toggle control. + +### What the shared context gives for free + +- Filter "status = Draft" shrinks the list — renderer unchanged. +- Sort by name reorders rows via ``. +- Group by status produces "Draft / Published / Archived" section headers (same `groupData` path as Table). +- Search narrows rows through `globalFilter`. +- **Display Properties toggle a column off → its grid track collapses**, identical to Table. No extra wiring. No per-renderer visibility story to maintain. +- Infinite scroll via `loadMoreData` when bottom enters viewport — identical pattern to Table, just a different observer target. + +### Why columns (not slots, not renderItem) + +- **Native visibility**: each cell's `accessorKey` is explicit, so DisplayOptions works the same way it does in Table. +- **Vertical alignment across rows**: CSS subgrid keeps avatars/statuses aligned across every row — what the screenshot shows. A free-form `renderItem` per row can't guarantee that without consumers hard-coding widths themselves. +- **One API to learn**: Table↔List is a tag swap (drop `header`, add `width`, rename the tag), not a new render contract. + +### Visual chrome difference from Table + +Same column shape, intentionally different CSS: + +| | Table | List | +| --- | --- | --- | +| Container element | `
` | `
` with `display: grid` + `grid-template-columns: ...` | +| Row element | `
` | `
` with `display: subgrid; grid-column: 1 / -1` | +| Header row | `
` | *none* (List has no column headers) | +| Separators | cell borders | optional `showDividers` between rows | +| Default density | compact | looser spacing; card-ish rows | +| `stickyGroupHeader` | supported | not supported (v1) | + +--- + +## 12. Implementation Examples + +Full end-to-end examples for each renderer using the root-owned spec shape from §4.5. Each subsection shows: (1) field metadata, (2) the renderer's row render spec, (3) full consumer JSX, (4) a sketch of the renderer's internals. + +--- + +### 12.1 Table + +Consumer-facing tasks table. Exercises filtering, sorting, grouping, column visibility, virtualization, sticky group headers. + +#### 12.1.1 Field metadata + +```ts +// types.ts +export type Task = { + id: string; + title: string; + status: 'backlog' | 'in_progress' | 'review' | 'done'; + priority: 'P0' | 'P1' | 'P2' | 'P3'; + assignee: string; + createdAt: string; + dueAt: string; +}; + +// fields.ts +import type { DataViewField } from '@raystack/apsara'; +import type { Task } from './types'; + +export const taskFields: DataViewField[] = [ + { accessorKey: 'title', label: 'Title', filterable: true, filterType: 'string', sortable: true }, + { accessorKey: 'status', label: 'Status', filterable: true, filterType: 'select', + filterOptions: [ + { label: 'Backlog', value: 'backlog' }, + { label: 'In progress', value: 'in_progress' }, + { label: 'Review', value: 'review' }, + { label: 'Done', value: 'done' }, + ], + sortable: true, groupable: true, showGroupCount: true, hideable: true, + }, + { accessorKey: 'priority', label: 'Priority', filterable: true, filterType: 'select', + filterOptions: [ + { label: 'P0', value: 'P0' }, { label: 'P1', value: 'P1' }, + { label: 'P2', value: 'P2' }, { label: 'P3', value: 'P3' }, + ], + sortable: true, groupable: true, hideable: true, + }, + { accessorKey: 'assignee', label: 'Assignee', filterable: true, filterType: 'string', sortable: true, hideable: true }, + { accessorKey: 'createdAt', label: 'Created', filterable: true, filterType: 'date', sortable: true, hideable: true, defaultHidden: true }, + { accessorKey: 'dueAt', label: 'Due', filterable: true, filterType: 'date', sortable: true, hideable: true }, +]; +``` + +#### 12.1.2 Table columns (the row render spec) + +```tsx +// table-columns.tsx +import type { DataViewTableColumn } from '@raystack/apsara'; +import { Checkbox, Flex, Text, Avatar } from '@raystack/apsara'; +import dayjs from 'dayjs'; +import { StatusChip, PriorityBadge } from './atoms'; +import type { Task } from './types'; + +export const taskTableColumns: DataViewTableColumn[] = [ + { + accessorKey: 'title', + cell: ({ row }) => ( + + row.toggleSelected(!!v)} /> + {row.getValue('title')} + + ), + styles: { cell: { minWidth: 240 }, header: { minWidth: 240 } }, + }, + { + accessorKey: 'status', + cell: ({ row }) => , + styles: { cell: { width: 120 }, header: { width: 120 } }, + }, + { + accessorKey: 'priority', + cell: ({ row }) => , + styles: { cell: { width: 80 }, header: { width: 80 } }, + }, + { + accessorKey: 'assignee', + cell: ({ row }) => , + styles: { cell: { width: 120 }, header: { width: 120 } }, + }, + { + accessorKey: 'dueAt', + cell: ({ row }) => {dayjs(row.getValue('dueAt')).format('MMM D')}, + styles: { cell: { width: 100 }, header: { width: 100 } }, + }, +]; +``` + +#### 12.1.3 Full consumer usage + +```tsx +// tasks-page.tsx +import { DataView } from '@raystack/apsara'; +import { taskFields } from './fields'; +import { taskTableColumns } from './table-columns'; + +export function TasksPage() { + const { data: tasks = [], isLoading } = useTasksQuery(); + + return ( + + data={tasks} + fields={taskFields} + defaultSort={{ name: 'dueAt', order: 'asc' }} + mode="client" + isLoading={isLoading} + getRowId={row => row.id} + onItemClick={task => navigate(`/tasks/${task.id}`)} + > + + + + + + + } heading="No matching tasks" />} + zeroState={} heading="No tasks yet" />} + /> + + ); +} +``` + +#### 12.1.4 Renderer internal sketch + +```tsx +// packages/raystack/components/data-view/renderers/table.tsx +'use client'; +import { flexRender } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useRef } from 'react'; +import { cx } from 'class-variance-authority'; +import { Table } from '../../table'; +import { useDataView } from '../hooks/useDataView'; +import styles from '../data-view.module.css'; + +export function DataViewTable({ + columns, + virtualized = false, + rowHeight = 40, + groupHeaderHeight, + overscan = 5, + stickyGroupHeader = false, + emptyState, + zeroState, + classNames, +}: DataViewTableProps) { + const ctx = useDataView(); + + // Merge columns (presentation) with fields (metadata) at render time. + // Context's TanStack table was built from fields only; here we install + // cell/header renderers from `columns` keyed by accessorKey. + const table = useTableWithColumns(ctx.table, ctx.fields, columns); + const rows = table.getRowModel().rows; + + if (!rows.length && !ctx.isLoading) { + return
{ctx.hasActiveQuery ? emptyState : zeroState}
; + } + + return virtualized + ? + : ; +} + +function PlainTableBody({ table, rows, onItemClick, classNames }) { + return ( +
+ + {table.getHeaderGroups().map(hg => ( + + {hg.headers.map(h => ( + {flexRender(h.column.columnDef.header, h.getContext())} + ))} + + ))} + + + {rows.map(row => + row.subRows?.length + ? + : ( + onItemClick?.(row.original)} + className={cx(onItemClick && styles.clickable)}> + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ), + )} + +
+ ); +} + +// VirtualTableBody omitted for brevity — same shape as today's virtualized-content.tsx, +// but reads `spec` from context instead of a `columns` prop. +``` + +--- + +### 12.2 List + +Matches the List screenshot (§11). CSS `grid` + `subgrid` gives Table-style column alignment without the ``; same column spec as Table, plus a `width` per column driving the grid track. + +#### 12.2.1 Field metadata + +```ts +// types.ts +export type Profile = { + id: string; + name: string; + subheading: string; + avatarUrl: string; + label: string; + collaborators: { id: string; avatarUrl: string }[]; + status: 'draft' | 'published' | 'archived'; + updatedAt: string; +}; + +// fields.ts +export const profileFields: DataViewField[] = [ + { accessorKey: 'identity', label: 'Person', hideable: false }, // synthetic: avatar + name + subheading + { accessorKey: 'name', label: 'Name', filterable: true, filterType: 'string', sortable: true }, + { accessorKey: 'subheading', label: 'Subtitle', filterable: true, filterType: 'string' }, + { accessorKey: 'label', label: 'Label', filterable: true, filterType: 'select', + filterOptions: [/* … */], groupable: true, showGroupCount: true, hideable: true }, + { accessorKey: 'collaborators', label: 'Collaborators', hideable: true }, + { accessorKey: 'status', label: 'Status', filterable: true, filterType: 'select', + filterOptions: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Archived', value: 'archived' }, + ], + sortable: true, groupable: true, hideable: true, + }, + { accessorKey: 'updatedAt', label: 'Updated', filterable: true, filterType: 'date', sortable: true }, +]; +``` + +#### 12.2.2 List columns (the row render spec) + +```tsx +// list-columns.tsx +import type { DataViewListColumn } from '@raystack/apsara'; +import { Avatar, AvatarStack, Chip, Flex, Text } from '@raystack/apsara'; +import { SparkleIcon } from '~/icons'; +import { StatusChip } from './atoms'; +import type { Profile } from './types'; + +export const profileListColumns: DataViewListColumn[] = [ + { + accessorKey: 'identity', + width: '1fr', + cell: ({ row }) => ( + + + + {row.original.name} + {row.original.subheading} + + + ), + }, + { + accessorKey: 'label', + width: 'auto', + cell: ({ row }) => ( + }>{row.getValue('label')} + ), + }, + { + accessorKey: 'collaborators', + width: 'auto', + cell: ({ row }) => , + }, + { + accessorKey: 'status', + width: '120px', + cell: ({ row }) => , + }, +]; +``` + +Toggling the "Label" property off in DisplayControls collapses that grid track across every row — no per-cell logic needed. The column spec's `accessorKey` is the only binding. + +#### 12.2.3 Full consumer usage + +```tsx +// people-page.tsx +import { DataView } from '@raystack/apsara'; +import { profileFields } from './fields'; +import { profileListColumns } from './list-columns'; + +export function PeoplePage() { + const { data: profiles = [], isLoading } = useProfilesQuery(); + + return ( + + data={profiles} + fields={profileFields} + defaultSort={{ name: 'name', order: 'asc' }} + mode="client" + isLoading={isLoading} + getRowId={row => row.id} + onItemClick={profile => openProfile(profile.id)} + > + + + + + + + } heading="No matching people" />} + /> + + ); +} +``` + +#### 12.2.4 Renderer internal sketch + +```tsx +// packages/raystack/components/data-view/renderers/list.tsx +'use client'; +import { flexRender } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useRef, useMemo } from 'react'; +import { cx } from 'class-variance-authority'; +import { useDataView } from '../hooks/useDataView'; +import styles from '../data-view.module.css'; + +export function DataViewList({ + columns, + virtualized = false, + rowHeight = 56, + showDividers = false, + showGroupHeaders = true, + overscan = 5, + emptyState, + zeroState, + classNames, +}: DataViewListProps) { + const ctx = useDataView(); + + // Same merge as Table: install per-column cell renderers onto the context's + // TanStack table (built from fields only). + const table = useTableWithColumns(ctx.table, ctx.fields, columns); + const rows = table.getRowModel().rows; + + // Build grid-template-columns from visible columns only. Hidden columns are + // already filtered out by TanStack via ctx.columnVisibility — same source + // DisplayAccess reads from. + const gridTemplateColumns = useMemo( + () => table.getVisibleLeafColumns() + .map(col => { + const spec = columns.find(c => c.accessorKey === col.id); + const w = spec?.width ?? '1fr'; + return typeof w === 'number' ? `${w}px` : w; + }) + .join(' '), + [table.getVisibleLeafColumns(), columns], + ); + + if (!rows.length && !ctx.isLoading) { + return
{ctx.hasActiveQuery ? emptyState : zeroState}
; + } + + const scrollRef = useRef(null); + const virtualizer = virtualized + ? useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: i => rows[i].subRows?.length ? 36 : rowHeight, + overscan, + }) + : null; + + const renderRow = (row: typeof rows[number], style?: React.CSSProperties) => { + if (row.subRows?.length) { + if (!showGroupHeaders) return null; + return ( +
+ {(row.original as any).label} + {(row.original as any).showGroupCount && {(row.original as any).count}} +
+ ); + } + return ( +
ctx.onItemClick?.(row.original)} + > + {row.getVisibleCells().map(cell => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} +
+ ); + }; + + return ( +
+ {virtualizer + ? ( +
+ {virtualizer.getVirtualItems().map(v => + renderRow(rows[v.index], { position: 'absolute', top: v.start, height: v.size, width: '100%' }), + )} +
+ ) + : rows.map(r => renderRow(r))} +
+ ); +} +``` + +Key point: the renderer uses `table.getVisibleLeafColumns()` (already respects `ctx.columnVisibility`) to build `grid-template-columns`. When a user toggles a column off in DisplayControls, that column disappears from both the row's `getVisibleCells()` and the container's grid template — the track collapses, rows reflow, no extra code. + +--- + +### 12.3 Timeline (Gantt-range) + +Matches the Task Prioritisation screenshot (§10). Items are bars with start/end dates, packed into lanes, rendered against a continuous time axis with a "today" marker. + +#### 12.3.1 Field metadata + +```ts +// types.ts +export type GanttTask = { + id: string; + title: string; + priority: 'P1'|'P2'|'P3'|'P4'|'P5'|'P6'|'P7'|'P8'|'P9'|'P10'; + client: 'corteva' | 'gridline' | 'nasa'; + status: 'pending' | 'in_progress' | 'done'; + startDate: string; + endDate: string; + dueAt: string; +}; + +// fields.ts +export const ganttFields: DataViewField[] = [ + { accessorKey: 'title', label: 'Title', filterable: true, filterType: 'string', sortable: true }, + { accessorKey: 'priority', label: 'Priority', filterable: true, filterType: 'select', + filterOptions: Array.from({ length: 10 }, (_, i) => ({ label: `P${i+1}`, value: `P${i+1}` })), + sortable: true, groupable: true, + }, + { accessorKey: 'client', label: 'Client', filterable: true, filterType: 'select', + filterOptions: [ + { label: 'Corteva Agriscience', value: 'corteva' }, + { label: 'Gridline Surveys', value: 'gridline' }, + { label: 'NASA', value: 'nasa' }, + ], + groupable: true, showGroupCount: true, + }, + { accessorKey: 'status', label: 'Status', filterable: true, filterType: 'select' }, + { accessorKey: 'startDate', label: 'Start', filterable: true, filterType: 'date', sortable: true }, + { accessorKey: 'endDate', label: 'End', filterable: true, filterType: 'date', sortable: true }, + { accessorKey: 'dueAt', label: 'Due', filterable: true, filterType: 'date', sortable: true }, +]; +``` + +#### 12.3.2 The timeline bar render spec + +Timeline bars are variable-width, so no column grid. Compose freely with `` so each field responds to the same DisplayControls toggle that drives columns in Table/List. + +```tsx +// gantt-bar.tsx +import type { Row } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import { DataView, Chip, Flex, Text } from '@raystack/apsara'; +import { CalendarIcon } from '~/icons'; +import { StatusDot } from './atoms'; +import type { GanttTask } from './types'; + +export const renderGanttBar = (row: Row) => ( + + + + + + + + {row.getValue('title')} + + + + {row.getValue('priority')} + + + + + }> + Due {dayjs(row.getValue('dueAt')).format('D MMM')} + + + + {clientLabel(row.original.client)} + + + +); +``` + +#### 12.3.3 Full consumer usage + +```tsx +// task-prioritisation-page.tsx +import { DataView } from '@raystack/apsara'; +import { ganttFields } from './fields'; +import { renderGanttBar } from './gantt-bar'; + +export function TaskPrioritisationPage() { + const { data: tasks = [], isLoading } = useTasksQuery(); + + return ( + + data={tasks} + fields={ganttFields} + defaultSort={{ name: 'startDate', order: 'asc' }} + mode="client" + isLoading={isLoading} + getRowId={row => row.id} + onItemClick={task => openTaskPanel(task.id)} + > + + + + + + + + + ); +} +``` + +#### 12.3.4 Renderer internal sketch + +```tsx +// packages/raystack/components/data-view/renderers/timeline.tsx +'use client'; +import { useMemo, useRef } from 'react'; +import dayjs from 'dayjs'; +import { cx } from 'class-variance-authority'; +import { useDataView } from '../hooks/useDataView'; +import { packLanes } from '../utils/pack-lanes'; +import { TimelineAxis } from './timeline-axis'; +import styles from '../data-view.module.css'; + +const SCALE_PX: Record<'day'|'week'|'month'|'quarter', number> = { + day: 40, week: 28, month: 16, quarter: 8, +}; + +export function DataViewTimeline({ + startField, + endField, + renderBar, + scale = 'day', + today = true, + lanePacking = 'auto', + rowHeight = 56, + laneGap = 6, + viewportRange, + onViewportChange, + emptyState, + zeroState, + classNames, +}: DataViewTimelineProps) { + const ctx = useDataView(); + const rows = ctx.rows.filter(r => !(r.subRows?.length)); // ignore group headers; timeline handles its own grouping + const scrollRef = useRef(null); + + // 1. Auto-viewport from filtered rows if not controlled + const [viewStart, viewEnd] = useMemo<[Date, Date]>(() => { + if (viewportRange) return viewportRange; + if (!rows.length) return [dayjs().subtract(7, 'day').toDate(), dayjs().add(14, 'day').toDate()]; + const starts = rows.map(r => +new Date(r.getValue(startField))); + const ends = rows.map(r => +new Date(r.getValue(endField ?? startField))); + return [new Date(Math.min(...starts)), new Date(Math.max(...ends))]; + }, [rows, startField, endField, viewportRange]); + + const pxPerDay = SCALE_PX[scale]; + const totalDays = dayjs(viewEnd).diff(viewStart, 'day') + 1; + const totalW = totalDays * pxPerDay; + + // 2. Lane pack (greedy interval scheduling) + const lanes = useMemo( + () => packLanes(rows, { + start: r => +new Date(r.getValue(startField)), + end: r => +new Date(r.getValue(endField ?? startField)), + mode: lanePacking, + }), + [rows, startField, endField, lanePacking], + ); + + if (!rows.length && !ctx.isLoading) { + return
{ctx.hasActiveQuery ? emptyState : zeroState}
; + } + + const todayDate = today === true ? new Date() : (today || null); + const bodyHeight = lanes.length * (rowHeight + laneGap); + + return ( +
onViewportChange?.(computeVisibleRange(scrollRef.current, viewStart, pxPerDay))} + > +
+ + +
+ {lanes.flatMap((lane, laneIdx) => + lane.map(row => { + const s = +new Date(row.getValue(startField)); + const e = +new Date(row.getValue(endField ?? startField)); + const left = dayjs(s).diff(viewStart, 'day') * pxPerDay; + const width = Math.max((dayjs(e).diff(s, 'day') + 1) * pxPerDay, 80); + return ( +
ctx.onItemClick?.(row.original)} + > + {renderBar(row)} +
+ ); + }), + )} +
+
+
+ ); +} +``` + +```ts +// packages/raystack/components/data-view/utils/pack-lanes.ts +export function packLanes( + rows: T[], + opts: { + start: (r: T) => number; + end: (r: T) => number; + mode: 'auto' | 'one-per-row'; + }, +): T[][] { + if (opts.mode === 'one-per-row') return rows.map(r => [r]); + + const sorted = [...rows].sort((a, b) => opts.start(a) - opts.start(b)); + const lanes: { end: number; items: T[] }[] = []; + + for (const row of sorted) { + const start = opts.start(row); + const end = opts.end(row); + const lane = lanes.find(l => l.end <= start); + if (lane) { lane.items.push(row); lane.end = end; } + else { lanes.push({ end, items: [row] }); } + } + return lanes.map(l => l.items); +} +``` + +The timeline renderer bypasses `groupData` entirely — it reads filtered `rows` from context and does its own horizontal bucketing via pixel math. If the user turns on grouping in `DisplayControls` (say, by `client`), lanes can optionally be partitioned per-group; the snippet above renders a single flat lane set for brevity. + +--- + +### 12.4 What didn't change across the three examples + +| | Table | List | Timeline | +| --- | --- | --- | --- | +| `data` prop | same | same | same | +| `fields` metadata | same | same | same | +| `` | same | same | same | +| `` behavior | same | same | same | +| `` menu & chips | same | same | same | +| `` ordering/grouping | same | same | partial (visibility ignored) | +| Filter predicates | same | same | same | +| `mode: 'client' \| 'server'` | same | same | same | +| `onItemClick` | same | same | same | +| `onLoadMore` / `totalRowCount` | same | same | same | +| Query state `{ filters, sort, group_by, search }` | same | same | same | + +The difference across the three: the renderer subcomponent and its own render-spec props. Table and List share the **column spec shape** (List drops `header`, adds `width`); switching between them is mostly a tag change + renaming the column-spec type: + +```tsx +- ++ +``` + +Timeline replaces `columns` with `renderBar` (bars are variable-width) + `startField`/`endField`; field visibility inside the bar is handled by wrapping each field in ``: + +```tsx ++ +``` + +Everything in between — toolbar, filters, search, sort, group, selection, load-more, *and column visibility* — keeps working unchanged across all three renderers. + +--- + +## 13. Recommendation + +**Build it.** Ship in the phased order above. Start with Phase 0 (internal rename + extraction) as a pure refactor PR with no consumer-visible change — that validates the architecture without any risk. Then build `` as the first new renderer (simplest shape, reuses the Table column spec + adds a `width` hint — cheap to ship, exercises the shared contract). Add `` alongside List so Timeline inherits a proven visibility story. `` lands last — bucketization, lane packing, and two-axis virtualization are the only novel complexity. + +Keep TanStack Table as the internal engine for all renderers. Do not roll a custom data-model library — the maintenance cost is real and the only "benefit" would be removing a dep the design system already uses. diff --git a/apps/www/src/app/examples/dataview/page.tsx b/apps/www/src/app/examples/dataview/page.tsx new file mode 100644 index 000000000..62154b3dd --- /dev/null +++ b/apps/www/src/app/examples/dataview/page.tsx @@ -0,0 +1,643 @@ +/** biome-ignore-all lint/suspicious/noShadowRestrictedNames: TODO: look into this later */ +'use client'; + +import { CalendarIcon } from '@radix-ui/react-icons'; +import { + Avatar, + AvatarGroup, + Badge, + Chip, + DataView, + type DataViewField, + type DataViewListColumn, + type DataViewTableColumn, + EmptyState, + Flex, + IconButton, + Navbar, + Sidebar, + Tabs, + Text +} from '@raystack/apsara'; +import { BellIcon, FilterIcon, SidebarIcon } from '@raystack/apsara/icons'; +import { useState } from 'react'; + +type ProfileCell = { row: { original: Profile } }; + +type Profile = { + id: string; + name: string; + subheading: string; + role: 'Admin' | 'User' | 'Manager'; + label: string; + status: 'Active' | 'Away' | 'Offline'; + collaborators: { id: string; name: string }[]; + team: string; + updatedAt: string; +}; + +const profiles: Profile[] = [ + { + id: '1', + name: 'Alice Cooper', + subheading: 'alice@example.com', + role: 'Admin', + label: 'Platform Lead', + status: 'Active', + collaborators: [ + { id: 'c1', name: 'Bob' }, + { id: 'c2', name: 'Carol' }, + { id: 'c3', name: 'Dan' } + ], + team: 'Frontend', + updatedAt: '2024-02-15' + }, + { + id: '2', + name: 'Bob Nguyen', + subheading: 'bob@example.com', + role: 'User', + label: 'Designer', + status: 'Active', + collaborators: [ + { id: 'c4', name: 'Eve' }, + { id: 'c5', name: 'Grace' } + ], + team: 'Design', + updatedAt: '2024-03-01' + }, + { + id: '3', + name: 'Carol Park', + subheading: 'carol@example.com', + role: 'Manager', + label: 'Backend Mgr', + status: 'Active', + collaborators: [ + { id: 'c6', name: 'Henry' }, + { id: 'c7', name: 'Ryan' }, + { id: 'c8', name: 'Wendy' }, + { id: 'c9', name: 'Leo' } + ], + team: 'Backend', + updatedAt: '2024-01-22' + }, + { + id: '4', + name: 'Dave Sanders', + subheading: 'dave@example.com', + role: 'User', + label: 'Sales AE', + status: 'Away', + collaborators: [{ id: 'c10', name: 'Paul' }], + team: 'Sales East', + updatedAt: '2024-02-28' + }, + { + id: '5', + name: 'Eve Okafor', + subheading: 'eve@example.com', + role: 'Admin', + label: 'Eng Lead', + status: 'Active', + collaborators: [ + { id: 'c11', name: 'Uma' }, + { id: 'c12', name: 'Jack' } + ], + team: 'Frontend', + updatedAt: '2024-03-10' + }, + { + id: '6', + name: 'Frank Liu', + subheading: 'frank@example.com', + role: 'User', + label: 'Support', + status: 'Active', + collaborators: [], + team: 'Tier 1', + updatedAt: '2024-03-04' + }, + { + id: '7', + name: 'Grace Romero', + subheading: 'grace@example.com', + role: 'Manager', + label: 'Design Mgr', + status: 'Active', + collaborators: [ + { id: 'c13', name: 'Bob' }, + { id: 'c14', name: 'Mia' }, + { id: 'c15', name: 'Tom' } + ], + team: 'Design', + updatedAt: '2024-02-02' + }, + { + id: '8', + name: 'Henry Becker', + subheading: 'henry@example.com', + role: 'Admin', + label: 'SRE', + status: 'Offline', + collaborators: [ + { id: 'c16', name: 'Carol' }, + { id: 'c17', name: 'Amy' } + ], + team: 'DevOps', + updatedAt: '2024-01-11' + }, + { + id: '9', + name: 'Ivy Chen', + subheading: 'ivy@example.com', + role: 'User', + label: 'Content Writer', + status: 'Active', + collaborators: [{ id: 'c18', name: 'Quinn' }], + team: 'Content', + updatedAt: '2024-03-08' + }, + { + id: '10', + name: 'Jack Patel', + subheading: 'jack@example.com', + role: 'User', + label: 'Frontend Eng', + status: 'Active', + collaborators: [ + { id: 'c19', name: 'Alice' }, + { id: 'c20', name: 'Eve' }, + { id: 'c21', name: 'Olivia' } + ], + team: 'Frontend', + updatedAt: '2024-02-20' + }, + { + id: '11', + name: 'Kate Rhodes', + subheading: 'kate@example.com', + role: 'Manager', + label: 'Sales Mgr', + status: 'Active', + collaborators: [ + { id: 'c22', name: 'Victor' }, + { id: 'c23', name: 'Dave' } + ], + team: 'Sales West', + updatedAt: '2024-01-30' + }, + { + id: '12', + name: 'Leo Braganza', + subheading: 'leo@example.com', + role: 'Admin', + label: 'DevOps Lead', + status: 'Active', + collaborators: [ + { id: 'c24', name: 'Amy' }, + { id: 'c25', name: 'Henry' } + ], + team: 'DevOps', + updatedAt: '2024-02-11' + } +]; + +const STATUS_COLOR: Record< + Profile['status'], + 'success' | 'warning' | 'neutral' +> = { + Active: 'success', + Away: 'warning', + Offline: 'neutral' +}; + +// Cell renderers shared between Table and List renderers. +const renderNameCell = ({ row }: ProfileCell) => ( + + + + + {row.original.name} + + + {row.original.subheading} + + + +); + +const renderEmailCell = ({ row }: ProfileCell) => ( + + {row.original.subheading} + +); + +const renderRoleCell = ({ row }: ProfileCell) => ( + {row.original.role} +); + +const renderLabelCell = ({ row }: ProfileCell) => ( + + {row.original.label} + +); + +const renderTeamCell = ({ row }: ProfileCell) => ( + + {row.original.team} + +); + +const renderStatusCell = ({ row }: ProfileCell) => { + const status = row.original.status; + return {status}; +}; + +const renderCollaboratorsCell = ({ row }: ProfileCell) => { + const collaborators = row.original.collaborators; + if (!collaborators.length) { + return ( + + — + + ); + } + return ( + + {collaborators.map(c => ( + + ))} + + ); +}; + +const renderUpdatedAtCell = ({ row }: ProfileCell) => ( + + {row.original.updatedAt} + +); + +// Renderer-agnostic metadata — drives filters, sort, group, visibility across +// every renderer (Table, List, Timeline, …). +const fields: DataViewField[] = [ + { + accessorKey: 'name', + label: 'Name', + filterable: true, + filterType: 'string', + sortable: true, + hideable: false + }, + { + accessorKey: 'subheading', + label: 'Email', + filterable: true, + filterType: 'string', + hideable: true + }, + { + accessorKey: 'role', + label: 'Role', + filterable: true, + filterType: 'select', + groupable: true, + hideable: true, + showGroupCount: true, + filterOptions: [ + { value: 'Admin', label: 'Admin' }, + { value: 'User', label: 'User' }, + { value: 'Manager', label: 'Manager' } + ] + }, + { + accessorKey: 'label', + label: 'Label', + filterable: true, + filterType: 'string', + hideable: true + }, + { + accessorKey: 'team', + label: 'Team', + filterable: true, + filterType: 'string', + groupable: true, + hideable: true + }, + { + accessorKey: 'status', + label: 'Status', + filterable: true, + filterType: 'select', + groupable: true, + hideable: true, + filterOptions: [ + { value: 'Active', label: 'Active' }, + { value: 'Away', label: 'Away' }, + { value: 'Offline', label: 'Offline' } + ] + }, + { + accessorKey: 'collaborators', + label: 'Collaborators', + hideable: true + }, + { + accessorKey: 'updatedAt', + label: 'Updated', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + } +]; + +// Table renderer spec — pairs a cell renderer to each field that should +// appear as a table column. +const tableColumns: DataViewTableColumn[] = [ + { accessorKey: 'name', cell: renderNameCell }, + { accessorKey: 'subheading', cell: renderEmailCell }, + { accessorKey: 'role', cell: renderRoleCell }, + { accessorKey: 'label', cell: renderLabelCell }, + { accessorKey: 'team', cell: renderTeamCell }, + { accessorKey: 'status', cell: renderStatusCell }, + { accessorKey: 'collaborators', cell: renderCollaboratorsCell }, + { accessorKey: 'updatedAt', cell: renderUpdatedAtCell } +]; + +// List renderer spec — composes Name+Email into a single cell and omits +// fields it doesn't show. Each column gets its own grid track width. +const listColumns: DataViewListColumn[] = [ + { + accessorKey: 'name', + cell: renderNameCell, + width: 'minmax(240px, 1.5fr)' + }, + { + accessorKey: 'label', + cell: renderLabelCell, + width: 'minmax(160px, 1fr)' + }, + { + accessorKey: 'team', + cell: renderTeamCell, + width: 'minmax(140px, 1fr)' + }, + { + accessorKey: 'collaborators', + cell: renderCollaboratorsCell, + width: 'auto' + }, + { + accessorKey: 'status', + cell: renderStatusCell, + width: '120px' + } +]; + +const STATUS_DOT_COLOR: Record = { + Active: 'var(--rs-color-foreground-success-primary, #16a34a)', + Away: 'var(--rs-color-foreground-attention-primary, #d97706)', + Offline: 'var(--rs-color-foreground-base-tertiary, #9ca3af)' +}; + +function StatusRing({ status }: { status: Profile['status'] }) { + const color = STATUS_DOT_COLOR[status]; + return ( +
+ ); +} + +function ProfileCard({ profile }: { profile: Profile }) { + return ( + + + + + + + + {profile.name} + + + + + {profile.role} + + + + + + } + > + Updated {profile.updatedAt} + + + + + {profile.team} + + + + + ); +} + +type ViewMode = 'table' | 'list' | 'custom'; + +const Page = () => { + const [navbarSearch, setNavbarSearch] = useState(''); + const [view, setView] = useState('table'); + + return ( + + + + + {}} aria-label='Logo'> + + + + Raystack + + + + + }> + Examples + + } + > + DataView · People + + + + Help & Support + Preferences + + + + + + + + DataView · People directory + + + + setView(v as ViewMode)} + size='small' + style={{ width: '400px' }} + > + + Table View + List View + Custom View + + + + + + + + data={profiles} + fields={fields} + mode='client' + defaultSort={{ name: 'name', order: 'asc' }} + getRowId={(row: Profile) => row.id} + > + + + + + {view === 'table' && ( + } + heading='No matching people' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + } + /> + )} + {view === 'list' && ( + } + heading='No matching people' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + } + /> + )} + {view === 'custom' && ( + > + {({ table }) => { + const rows = table + .getRowModel() + .rows.filter(r => !r.subRows?.length); + if (!rows.length) { + return ( + } + heading='No matching people' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + ); + } + return ( +
+ {rows.map(row => ( + + ))} +
+ ); + }} + + )} + +
+
+
+ ); +}; + +export default Page; diff --git a/packages/raystack/components/data-view/components/content.tsx b/packages/raystack/components/data-view/components/content.tsx new file mode 100644 index 000000000..0e972e237 --- /dev/null +++ b/packages/raystack/components/data-view/components/content.tsx @@ -0,0 +1,407 @@ +'use client'; + +import { Cross2Icon, TableIcon } from '@radix-ui/react-icons'; +import type { Header, Row } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; +import { cx } from 'class-variance-authority'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { Badge } from '../../badge'; +import { Button } from '../../button'; +import { EmptyState } from '../../empty-state'; +import { Flex } from '../../flex'; +import { Skeleton } from '../../skeleton'; +import { Table } from '../../table'; +import styles from '../data-view.module.css'; +import { + DataViewContentClassNames, + DataViewTableColumn, + GroupedData +} from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { + countLeafRows, + getClientHiddenLeafRowCount, + hasActiveQuery, + hasActiveTableFiltering +} from '../utils'; + +export interface ContentProps { + columns: DataViewTableColumn[]; + emptyState?: React.ReactNode; + zeroState?: React.ReactNode; + classNames?: DataViewContentClassNames; + stickyGroupHeader?: boolean; + loadingRowCount?: number; +} + +interface HeadersProps { + headers: Header[]; + columnMap: Map>; + className?: string; +} + +function Headers({ + headers, + columnMap, + className +}: HeadersProps) { + return ( + + + {headers.map(header => { + const spec = columnMap.get(header.column.id); + const content = + spec?.header !== undefined + ? flexRender(spec.header, header.getContext()) + : flexRender(header.column.columnDef.header, header.getContext()); + return ( + + {content} + + ); + })} + + + ); +} + +function LoaderRows({ + rowCount, + columnCount +}: { + rowCount: number; + columnCount: number; +}) { + const rows = Array.from({ length: rowCount }); + return rows.map((_, rowIndex) => { + const columns = Array.from({ length: columnCount }); + return ( + + {columns.map((_, colIndex) => ( + + + + ))} + + ); + }); +} + +function GroupHeader({ + colSpan, + data, + stickySectionHeader +}: { + colSpan: number; + data: GroupedData; + stickySectionHeader?: boolean; +}) { + return ( + + + {data?.label} + {data.showGroupCount ? ( + {data?.count} + ) : null} + + + ); +} + +interface RowsProps { + rows: Row[]; + renderedAccessors: string[]; + columnMap: Map>; + onRowClick?: (row: TData) => void; + classNames?: { row?: string }; + lastRowRef?: React.RefObject; + stickyGroupHeader?: boolean; +} + +function Rows({ + rows, + renderedAccessors, + columnMap, + onRowClick, + classNames, + lastRowRef, + stickyGroupHeader = false +}: RowsProps) { + return rows.map((row, idx) => { + const isSelected = row.getIsSelected(); + const cells = row.getVisibleCells() || []; + const isGroupHeader = row.subRows && row.subRows.length > 0; + const isLastRow = idx === rows.length - 1; + + if (isGroupHeader) { + return ( + } + stickySectionHeader={stickyGroupHeader} + /> + ); + } + + return ( + onRowClick?.(row.original)} + > + {renderedAccessors.map(accessor => { + const spec = columnMap.get(accessor); + const cell = cells.find(c => c.column.id === accessor); + if (!cell) { + return ( + + ); + } + return ( + + {spec?.cell + ? flexRender(spec.cell, cell.getContext()) + : ((cell.getValue() as React.ReactNode) ?? null)} + + ); + })} + + ); + }); +} + +const DefaultEmptyComponent = () => ( + } heading='No Data' /> +); + +export function Content({ + columns, + emptyState, + zeroState, + classNames = {}, + stickyGroupHeader = false, + loadingRowCount +}: ContentProps) { + const { + onRowClick, + table, + mode, + totalRowCount, + isLoading, + loadMoreData, + loadingRowCount: ctxLoadingRowCount = 3, + tableQuery, + defaultSort, + updateTableQuery + } = useDataView(); + + const effectiveLoadingRowCount = loadingRowCount ?? ctxLoadingRowCount; + + const columnMap = useMemo(() => { + const map = new Map>(); + columns.forEach(c => map.set(c.accessorKey, c)); + return map; + }, [columns]); + + const visibleLeafColumns = table.getVisibleLeafColumns(); + + // Render order is taken from `columns` prop, filtered by TanStack visibility. + const renderedAccessors = useMemo(() => { + const visibleSet = new Set(visibleLeafColumns.map(c => c.id)); + return columns.map(c => c.accessorKey).filter(k => visibleSet.has(k)); + }, [columns, visibleLeafColumns]); + + const headerGroups = table?.getHeaderGroups() ?? []; + const lastHeaderGroup = headerGroups[headerGroups.length - 1]; + const headersInOrder = useMemo(() => { + if (!lastHeaderGroup) return [] as Header[]; + return renderedAccessors + .map( + accessor => + lastHeaderGroup.headers.find(h => h.column.id === accessor) as + | Header + | undefined + ) + .filter((h): h is Header => Boolean(h)); + }, [lastHeaderGroup, renderedAccessors]); + + const rowModel = table?.getRowModel(); + const { rows = [] } = rowModel || {}; + + const lastRowRef = useRef(null); + const observerRef = useRef(null); + + /* Refs keep callback stable so observer is only recreated when mode/rows.length change; */ + const loadMoreDataRef = useRef(loadMoreData); + const isLoadingRef = useRef(isLoading); + loadMoreDataRef.current = loadMoreData; + isLoadingRef.current = isLoading; + + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { + const target = entries[0]; + if (!target?.isIntersecting) return; + if (isLoadingRef.current) return; + const loadMore = loadMoreDataRef.current; + if (loadMore) loadMore(); + }, []); + + useEffect(() => { + if (mode !== 'server') return; + + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + const lastRow = lastRowRef.current; + if (!lastRow) return; + + observerRef.current = new IntersectionObserver(handleObserver, { + threshold: 0.1 + }); + observerRef.current.observe(lastRow); + + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }; + }, [mode, rows.length, handleObserver]); + + const visibleColumnsLength = renderedAccessors.length; + + const hasData = rows?.length > 0 || isLoading; + + const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); + + const isZeroState = !hasData && !hasChanges; + const isEmptyState = !hasData && hasChanges; + + const stateToShow: React.ReactNode = isZeroState + ? (zeroState ?? emptyState ?? ) + : isEmptyState + ? (emptyState ?? ) + : null; + + const hiddenLeafRowCount = + mode === 'client' + ? getClientHiddenLeafRowCount(table) + : totalRowCount !== undefined + ? Math.max(0, totalRowCount - countLeafRows(rows)) + : null; + const hasActiveFiltering = !isLoading && hasActiveTableFiltering(table); + const showFilterSummary = + hasActiveFiltering && + (mode === 'server' || + (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); + + const handleClearFilters = useCallback(() => { + updateTableQuery(prev => ({ + ...prev, + filters: [], + search: '' + })); + }, [updateTableQuery]); + + return ( +
+
+ {hasData && ( + + )} + + {hasData ? ( + <> + + {isLoading ? ( + + ) : null} + + ) : ( + + + {stateToShow} + + + )} + +
+ {showFilterSummary ? ( + + {mode === 'server' && hiddenLeafRowCount === null ? ( + + Some items might be hidden by filters + + ) : ( + + + {hiddenLeafRowCount} + + + items hidden by filters + + + )} + + + ) : null} + + ); +} + +Content.displayName = 'DataView.Content'; diff --git a/packages/raystack/components/data-view/components/display-access.tsx b/packages/raystack/components/data-view/components/display-access.tsx new file mode 100644 index 000000000..cf1f5ff23 --- /dev/null +++ b/packages/raystack/components/data-view/components/display-access.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { ReactNode } from 'react'; +import { useDataView } from '../hooks/useDataView'; + +export interface DataViewDisplayAccessProps { + /** Field (column) accessor key. Gates rendering on the column's current visibility state. */ + accessorKey: string; + children: ReactNode; + /** Rendered when the referenced field is currently hidden. Defaults to null. */ + fallback?: ReactNode; +} + +/** + * Gates children on the current column visibility state from DataView context. + * Use inside free-form renderers (Timeline bars, custom renderers, cell overrides) + * so the single DisplayControls toggle reaches the same visibility story that + * Table/List rows get through their column specs. + */ +export function DisplayAccess({ + accessorKey, + children, + fallback = null +}: DataViewDisplayAccessProps) { + const { table } = useDataView(); + const column = table?.getColumn(accessorKey); + // If the column doesn't exist, default to visible so consumers can wrap JSX + // in DisplayAccess without worrying about typos silently breaking the render. + const isVisible = column ? column.getIsVisible() : true; + return <>{isVisible ? children : fallback}; +} + +DisplayAccess.displayName = 'DataView.DisplayAccess'; diff --git a/packages/raystack/components/data-view/components/display-properties.tsx b/packages/raystack/components/data-view/components/display-properties.tsx new file mode 100644 index 000000000..3a67229b2 --- /dev/null +++ b/packages/raystack/components/data-view/components/display-properties.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Chip } from '../../chip'; +import { Flex } from '../../flex'; +import { Text } from '../../text'; +import { DataViewField } from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; + +export function DisplayProperties({ + fields +}: { + fields: DataViewField[]; +}) { + const { table } = useDataView(); + const hidableFields = fields?.filter(f => f.hideable) ?? []; + + return ( + + Display Properties + + {hidableFields.map(field => { + const column = table.getColumn(field.accessorKey); + const isVisible = column ? column.getIsVisible() : true; + return ( + column?.toggleVisibility()} + > + {field.label} + + ); + })} + + + ); +} diff --git a/packages/raystack/components/data-view/components/display-settings.tsx b/packages/raystack/components/data-view/components/display-settings.tsx new file mode 100644 index 000000000..acad27792 --- /dev/null +++ b/packages/raystack/components/data-view/components/display-settings.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { MixerHorizontalIcon } from '@radix-ui/react-icons'; + +import { isValidElement, ReactNode } from 'react'; +import { Button } from '../../button'; +import { Flex } from '../../flex'; +import { Popover } from '../../popover'; +import styles from '../data-view.module.css'; +import { defaultGroupOption, SortOrdersValues } from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { DisplayProperties } from './display-properties'; +import { Grouping } from './grouping'; +import { Ordering } from './ordering'; + +interface DisplaySettingsProps { + trigger?: ReactNode; +} + +export function DisplaySettings({ + trigger = ( + + ) +}: DisplaySettingsProps) { + const { + fields, + updateTableQuery, + tableQuery, + defaultSort, + onDisplaySettingsReset + } = useDataView(); + + const sortableColumns = (fields ?? []) + .filter(f => f.sortable) + .map(f => ({ + label: f.label, + id: f.accessorKey + })); + + function onSortChange(columnId: string, order: SortOrdersValues) { + updateTableQuery(query => { + return { + ...query, + sort: [{ name: columnId, order }] + }; + }); + } + + function onGroupChange(columnId: string) { + updateTableQuery(query => { + return { + ...query, + group_by: [columnId] + }; + }); + } + + function onGroupRemove() { + updateTableQuery(query => { + return { + ...query, + group_by: [] + }; + }); + } + + function onReset() { + onDisplaySettingsReset(); + } + + return ( + + {trigger}} + /> + + + + + + + + + + + + + + + + ); +} + +DisplaySettings.displayName = 'DataView.DisplayControls'; diff --git a/packages/raystack/components/data-view/components/filters.tsx b/packages/raystack/components/data-view/components/filters.tsx new file mode 100644 index 000000000..8b7f4220a --- /dev/null +++ b/packages/raystack/components/data-view/components/filters.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { isValidElement, ReactNode, useMemo } from 'react'; +import { FilterIcon } from '~/icons'; +import { FilterOperatorTypes, FilterType } from '~/types/filters'; +import { Button } from '../../button'; +import { FilterChip } from '../../filter-chip'; +import { Flex } from '../../flex'; +import { IconButton } from '../../icon-button'; +import { Menu } from '../../menu'; +import { DataViewField } from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { useFilters } from '../hooks/useFilters'; + +type Trigger = + | ReactNode + | (({ + availableFilters, + appliedFilters + }: { + availableFilters: DataViewField[]; + appliedFilters: Set; + }) => ReactNode); + +interface AddFilterProps { + fieldList: DataViewField[]; + appliedFiltersSet: Set; + onAddFilter: (field: DataViewField) => void; + children?: Trigger; +} + +function AddFilter({ + fieldList = [], + appliedFiltersSet, + onAddFilter, + children +}: AddFilterProps) { + const availableFilters = fieldList?.filter( + f => !appliedFiltersSet.has(f.accessorKey) + ); + + const trigger = useMemo(() => { + if (typeof children === 'function') + return children({ availableFilters, appliedFilters: appliedFiltersSet }); + else if (children) return children; + else if (appliedFiltersSet.size > 0) + return ( + + + + ); + else + return ( + + ); + }, [children, appliedFiltersSet, availableFilters]); + + return availableFilters.length > 0 ? ( + + {trigger}} + /> + + {availableFilters?.map(field => ( + onAddFilter(field)}> + {field.label} + + ))} + + + ) : null; +} + +export function Filters({ + classNames, + className, + trigger +}: { + classNames?: { + container?: string; + filterChips?: string; + addFilter?: string; + }; + className?: string; + trigger?: Trigger; +}) { + const { fields, tableQuery } = useDataView(); + + const { + onAddFilter, + handleRemoveFilter, + handleFilterValueChange, + handleFilterOperationChange + } = useFilters(); + + const filterableFields = fields?.filter(f => f.filterable) ?? []; + + const appliedFiltersSet = new Set( + tableQuery?.filters?.map(filter => filter.name) + ); + + const appliedFilters = + tableQuery?.filters?.map(filter => { + const field = fields?.find(f => f.accessorKey === filter.name); + return { + filterType: field?.filterType || FilterType.string, + label: field?.label || '', + options: field?.filterOptions || [], + selectProps: field?.filterProps?.select, + ...filter + }; + }) || []; + + return ( + + {appliedFilters.length > 0 && ( + + {appliedFilters.map(filter => ( + handleRemoveFilter(filter.name)} + onValueChange={value => + handleFilterValueChange(filter.name, value) + } + onOperationChange={operator => + handleFilterOperationChange( + filter.name, + operator as FilterOperatorTypes + ) + } + columnType={filter.filterType} + options={filter.options} + selectProps={filter.selectProps} + className={classNames?.filterChips} + /> + ))} + + )} + + {trigger} + + + ); +} + +Filters.displayName = 'DataView.Filters'; diff --git a/packages/raystack/components/data-view/components/grouping.tsx b/packages/raystack/components/data-view/components/grouping.tsx new file mode 100644 index 000000000..92e4a04b0 --- /dev/null +++ b/packages/raystack/components/data-view/components/grouping.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { Flex } from '../../flex'; +import { Select } from '../../select'; +import { Text } from '../../text'; +import styles from '../data-view.module.css'; +import { DataViewField, defaultGroupOption } from '../data-view.types'; + +interface GroupingProps { + fields: DataViewField[]; + onChange: (fieldAccessor: string) => void; + onRemove: () => void; + value: string; +} + +export function Grouping({ + fields = [], + onChange, + onRemove, + value +}: GroupingProps) { + const groupableFields = fields.filter(f => f.groupable); + + const handleGroupChange = (fieldAccessor: string) => { + if (fieldAccessor === defaultGroupOption.id) { + onRemove(); + return; + } + const field = fields.find(f => f.accessorKey === fieldAccessor); + if (field) { + onChange(field.accessorKey); + } + }; + + return ( + + + Grouping + + + + + + ); +} diff --git a/packages/raystack/components/data-view/components/list.tsx b/packages/raystack/components/data-view/components/list.tsx new file mode 100644 index 000000000..ffe1a9f9a --- /dev/null +++ b/packages/raystack/components/data-view/components/list.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { Cross2Icon, TableIcon } from '@radix-ui/react-icons'; +import type { Row } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { cx } from 'class-variance-authority'; +import { CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; + +import { Badge } from '../../badge'; +import { Button } from '../../button'; +import { EmptyState } from '../../empty-state'; +import { Flex } from '../../flex'; +import styles from '../data-view.module.css'; +import { + DataViewListColumn, + DataViewListProps, + GroupedData +} from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { + countLeafRows, + getClientHiddenLeafRowCount, + hasActiveQuery, + hasActiveTableFiltering +} from '../utils'; + +const DefaultEmptyComponent = () => ( + } heading='No Data' /> +); + +function formatGridWidth(width: string | number | undefined) { + if (width === undefined) return '1fr'; + if (typeof width === 'number') return `${width}px`; + return width; +} + +export function DataViewList({ + columns, + rowHeight = 56, + virtualized = false, + overscan = 8, + showDividers = false, + showGroupHeaders = true, + loadMoreOffset = 100, + emptyState, + zeroState, + classNames = {} +}: DataViewListProps) { + const { + table, + mode, + onRowClick, + isLoading, + loadMoreData, + tableQuery, + defaultSort, + totalRowCount, + updateTableQuery + } = useDataView(); + + const rowModel = table?.getRowModel(); + const { rows = [] } = rowModel || {}; + + const visibleLeafColumns = table.getVisibleLeafColumns(); + + const columnMap = useMemo(() => { + const map = new Map>(); + columns.forEach(c => map.set(c.accessorKey, c)); + return map; + }, [columns]); + + // Render order comes from `columns` prop; filter out any accessor whose + // TanStack column is currently hidden. + const renderedAccessors = useMemo(() => { + const visibleSet = new Set(visibleLeafColumns.map(c => c.id)); + return columns.map(c => c.accessorKey).filter(k => visibleSet.has(k)); + }, [columns, visibleLeafColumns]); + + const gridTemplateColumns = useMemo(() => { + return renderedAccessors + .map(accessor => formatGridWidth(columnMap.get(accessor)?.width)) + .join(' '); + }, [renderedAccessors, columnMap]); + + const scrollRef = useRef(null); + const observerRef = useRef(null); + const lastRowRef = useRef(null); + + const loadMoreDataRef = useRef(loadMoreData); + const isLoadingRef = useRef(isLoading); + loadMoreDataRef.current = loadMoreData; + isLoadingRef.current = isLoading; + + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { + const target = entries[0]; + if (!target?.isIntersecting) return; + if (isLoadingRef.current) return; + const loadMore = loadMoreDataRef.current; + if (loadMore) loadMore(); + }, []); + + // Non-virtualized load-more via last-row IntersectionObserver + useEffect(() => { + if (mode !== 'server' || virtualized) return; + + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + const lastRow = lastRowRef.current; + if (!lastRow) return; + + observerRef.current = new IntersectionObserver(handleObserver, { + threshold: 0.1 + }); + observerRef.current.observe(lastRow); + + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }; + }, [mode, virtualized, rows.length, handleObserver]); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: i => { + const row = rows[i]; + const isGroupHeader = row?.subRows && row.subRows.length > 0; + return isGroupHeader ? 36 : rowHeight; + }, + overscan + }); + + const hasData = rows.length > 0 || isLoading; + const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); + const isZeroState = !hasData && !hasChanges; + const isEmptyState = !hasData && hasChanges; + + const stateToShow = isZeroState + ? (zeroState ?? emptyState ?? ) + : isEmptyState + ? (emptyState ?? ) + : null; + + const hiddenLeafRowCount = + mode === 'client' + ? getClientHiddenLeafRowCount(table) + : totalRowCount !== undefined + ? Math.max(0, totalRowCount - countLeafRows(rows)) + : null; + const hasActiveFiltering = !isLoading && hasActiveTableFiltering(table); + const showFilterSummary = + hasActiveFiltering && + (mode === 'server' || + (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); + + const handleClearFilters = useCallback(() => { + updateTableQuery(prev => ({ + ...prev, + filters: [], + search: '' + })); + }, [updateTableQuery]); + + const handleVirtualScroll = useCallback(() => { + if (!virtualized) return; + const el = scrollRef.current; + if (!el) return; + if (isLoading) return; + const { scrollTop, scrollHeight, clientHeight } = el; + if (scrollHeight - scrollTop - clientHeight < loadMoreOffset!) { + loadMoreData(); + } + }, [virtualized, isLoading, loadMoreData, loadMoreOffset]); + + if (!hasData) { + return ( +
+
{stateToShow}
+
+ ); + } + + const renderRowCells = (row: Row) => + renderedAccessors.map(accessor => { + const spec = columnMap.get(accessor); + const cell = row.getVisibleCells().find(c => c.column.id === accessor); + if (!cell) { + return ( +
+ ); + } + return ( +
+ {spec?.cell + ? flexRender(spec.cell, cell.getContext()) + : ((cell.getValue() as React.ReactNode) ?? null)} +
+ ); + }); + + const renderGroupHeader = ( + row: Row, + style?: CSSProperties, + key?: string + ) => { + const data = row.original as GroupedData; + return ( +
+ + {data?.label} + {data?.showGroupCount ? ( + {data?.count} + ) : null} + +
+ ); + }; + + const renderDataRow = ( + row: Row, + style?: CSSProperties, + key?: string, + refCb?: (el: HTMLDivElement | null) => void + ) => ( +
onRowClick?.(row.original)} + > + {renderRowCells(row)} +
+ ); + + return ( +
+
+ {virtualized ? ( +
+ {virtualizer.getVirtualItems().map(item => { + const row = rows[item.index]; + if (!row) return null; + const isGroupHeader = row.subRows && row.subRows.length > 0; + const positionStyle: CSSProperties = { + position: 'absolute', + top: item.start, + left: 0, + right: 0, + height: item.size + }; + if (isGroupHeader) { + return showGroupHeaders + ? renderGroupHeader( + row, + positionStyle, + row.id + '-' + item.index + ) + : null; + } + return renderDataRow( + row, + positionStyle, + row.id + '-' + item.index + ); + })} +
+ ) : ( + rows.map((row, idx) => { + const isGroupHeader = row.subRows && row.subRows.length > 0; + const isLast = idx === rows.length - 1; + if (isGroupHeader) { + return showGroupHeaders ? renderGroupHeader(row) : null; + } + return renderDataRow(row, undefined, undefined, el => { + if (isLast) lastRowRef.current = el; + }); + }) + )} +
+ {showFilterSummary ? ( + + {mode === 'server' && hiddenLeafRowCount === null ? ( + + Some items might be hidden by filters + + ) : ( + + + {hiddenLeafRowCount} + + + items hidden by filters + + + )} + + + ) : null} +
+ ); +} + +DataViewList.displayName = 'DataView.List'; diff --git a/packages/raystack/components/data-view/components/ordering.tsx b/packages/raystack/components/data-view/components/ordering.tsx new file mode 100644 index 000000000..4b3ba730f --- /dev/null +++ b/packages/raystack/components/data-view/components/ordering.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { TextAlignBottomIcon, TextAlignTopIcon } from '@radix-ui/react-icons'; + +import { Flex } from '../../flex'; +import { IconButton } from '../../icon-button'; +import { Select } from '../../select'; +import { Text } from '../../text'; +import styles from '../data-view.module.css'; +import { + ColumnData, + DataViewSort, + SortOrders, + SortOrdersValues +} from '../data-view.types'; + +export interface OrderingProps { + columnList: ColumnData[]; + onChange: (columnId: string, order: SortOrdersValues) => void; + value: DataViewSort; +} + +export function Ordering({ columnList, onChange, value }: OrderingProps) { + function handleColumnChange(columnId: string) { + onChange(columnId, value.order); + } + + function handleOrderChange() { + const newOrder = + value.order === SortOrders.ASC ? SortOrders.DESC : SortOrders.ASC; + onChange(value.name, newOrder); + } + + return ( + + + Ordering + + + + + {value.order === SortOrders?.ASC ? ( + + ) : ( + + )} + + + + ); +} diff --git a/packages/raystack/components/data-view/components/renderer.tsx b/packages/raystack/components/data-view/components/renderer.tsx new file mode 100644 index 000000000..3d7e78627 --- /dev/null +++ b/packages/raystack/components/data-view/components/renderer.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { ReactNode } from 'react'; +import { DataViewContextType } from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; + +export interface DataViewRendererProps { + /** + * Render prop that receives the full DataView context (table, fields, + * tableQuery, etc.) and returns the rendered view. Use together with + * `` to keep field visibility in sync with the + * single Display Properties toggle. + */ + children: (context: DataViewContextType) => ReactNode; +} + +/** + * Escape-hatch renderer for free-form views (cards, kanban, map, etc.). + * Consumes the DataView context and hands it to a render prop. + */ +export function DataViewRenderer({ + children +}: DataViewRendererProps) { + const ctx = useDataView(); + return <>{children(ctx)}; +} + +DataViewRenderer.displayName = 'DataView.Renderer'; diff --git a/packages/raystack/components/data-view/components/search.tsx b/packages/raystack/components/data-view/components/search.tsx new file mode 100644 index 000000000..330692689 --- /dev/null +++ b/packages/raystack/components/data-view/components/search.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { ChangeEvent, type ComponentProps } from 'react'; +import { Search } from '../../search'; +import { useDataView } from '../hooks/useDataView'; + +export interface DataViewSearchProps extends ComponentProps { + /** + * Automatically disable search in zero state (when no data and no filters/search applied). + * @defaultValue true + */ + autoDisableInZeroState?: boolean; +} + +export function DataViewSearch({ + autoDisableInZeroState = true, + disabled, + ...props +}: DataViewSearchProps) { + const { + updateTableQuery, + tableQuery, + shouldShowFilters = false + } = useDataView(); + + const handleSearch = (e: ChangeEvent) => { + const value = e.target.value; + updateTableQuery(query => { + return { + ...query, + search: value + }; + }); + }; + + const handleClear = () => { + updateTableQuery(query => { + return { + ...query, + search: '' + }; + }); + }; + + // Auto-disable in zero state if enabled, but allow manual override + // Once search is applied, keep it enabled (even if shouldShowFilters is false) + const hasSearch = Boolean( + tableQuery?.search && tableQuery.search.trim() !== '' + ); + const isDisabled = + disabled ?? (autoDisableInZeroState && !shouldShowFilters && !hasSearch); + + return ( + + ); +} + +DataViewSearch.displayName = 'DataView.Search'; diff --git a/packages/raystack/components/data-view/components/table.tsx b/packages/raystack/components/data-view/components/table.tsx new file mode 100644 index 000000000..940c98974 --- /dev/null +++ b/packages/raystack/components/data-view/components/table.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { DataViewTableProps } from '../data-view.types'; +import { Content } from './content'; +import { VirtualizedContent } from './virtualized-content'; + +/** + * DataView.Table — renders the current filtered/sorted/grouped rows as an + * aligned-column table. Takes its own `columns` (DataViewTableColumn[]) that + * install cell/header renderers keyed by field accessorKey. Metadata like + * filterable/sortable/groupable/hideable is declared on `fields` at the root, + * not duplicated here. + * + * Set `virtualized` to render rows through a virtualizer with sticky headers. + */ +export function DataViewTable({ + columns, + virtualized = false, + rowHeight, + groupHeaderHeight, + overscan, + loadMoreOffset, + stickyGroupHeader, + emptyState, + zeroState, + classNames +}: DataViewTableProps) { + if (virtualized) { + return ( + + ); + } + return ( + + ); +} + +DataViewTable.displayName = 'DataView.Table'; diff --git a/packages/raystack/components/data-view/components/toolbar.tsx b/packages/raystack/components/data-view/components/toolbar.tsx new file mode 100644 index 000000000..696dc5e00 --- /dev/null +++ b/packages/raystack/components/data-view/components/toolbar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { PropsWithChildren } from 'react'; +import { Flex } from '../../flex'; +import styles from '../data-view.module.css'; +import { useDataView } from '../hooks/useDataView'; +import { DisplaySettings } from './display-settings'; +import { Filters } from './filters'; + +interface ToolbarProps { + className?: string; +} + +export function Toolbar({ + className, + children +}: PropsWithChildren) { + const { shouldShowFilters = false } = useDataView(); + + if (!shouldShowFilters) { + return null; + } + + // If children are provided, render them so consumers can compose Search / Filters / DisplayControls. + if (children) { + return ( + + {children} + + ); + } + + return ( + + /> + /> + + ); +} + +Toolbar.displayName = 'DataView.Toolbar'; diff --git a/packages/raystack/components/data-view/components/virtualized-content.tsx b/packages/raystack/components/data-view/components/virtualized-content.tsx new file mode 100644 index 000000000..9eabfc67d --- /dev/null +++ b/packages/raystack/components/data-view/components/virtualized-content.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { TableIcon } from '@radix-ui/react-icons'; +import type { Header, Row } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { cx } from 'class-variance-authority'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import tableStyles from '~/components/table/table.module.css'; +import { Badge } from '../../badge'; +import { EmptyState } from '../../empty-state'; +import { Flex } from '../../flex'; +import { Skeleton } from '../../skeleton'; +import styles from '../data-view.module.css'; +import { + DataViewContentClassNames, + DataViewTableColumn, + defaultGroupOption, + GroupedData +} from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { hasActiveQuery } from '../utils'; + +export interface VirtualizedContentProps { + columns: DataViewTableColumn[]; + emptyState?: React.ReactNode; + zeroState?: React.ReactNode; + classNames?: DataViewContentClassNames; + rowHeight?: number; + groupHeaderHeight?: number; + overscan?: number; + loadMoreOffset?: number; + stickyGroupHeader?: boolean; +} + +function VirtualHeaders({ + headers, + columnMap, + className +}: { + headers: Header[]; + columnMap: Map>; + className?: string; +}) { + return ( +
+
+ {headers.map(header => { + const spec = columnMap.get(header.column.id); + const content = + spec?.header !== undefined + ? flexRender(spec.header, header.getContext()) + : flexRender(header.column.columnDef.header, header.getContext()); + return ( +
+ {content} +
+ ); + })} +
+
+ ); +} + +function VirtualGroupHeader({ + data, + style +}: { + data: GroupedData; + style?: React.CSSProperties; +}) { + return ( +
+ + {data?.label} + {data.showGroupCount ? ( + {data?.count} + ) : null} + +
+ ); +} + +function VirtualRows({ + rows, + virtualizer, + renderedAccessors, + columnMap, + onRowClick, + classNames +}: { + rows: Row[]; + virtualizer: ReturnType; + renderedAccessors: string[]; + columnMap: Map>; + onRowClick?: (row: TData) => void; + classNames?: { row?: string }; +}) { + const items = virtualizer.getVirtualItems(); + + return items.map(item => { + const row = rows[item.index]; + if (!row) return null; + + const isSelected = row.getIsSelected(); + const cells = row.getVisibleCells() || []; + const isGroupHeader = row.subRows && row.subRows.length > 0; + const rowKey = row.id + '-' + item.index; + + const positionStyle: React.CSSProperties = { + height: item.size, + top: item.start + }; + + if (isGroupHeader) { + return ( + } + style={positionStyle} + /> + ); + } + + return ( +
onRowClick?.(row.original)} + > + {renderedAccessors.map(accessor => { + const spec = columnMap.get(accessor); + const cell = cells.find(c => c.column.id === accessor); + if (!cell) { + return ( +
+ ); + } + return ( +
+ {spec?.cell + ? flexRender(spec.cell, cell.getContext()) + : ((cell.getValue() as React.ReactNode) ?? null)} +
+ ); + })} +
+ ); + }); +} + +function VirtualLoaderRows({ + renderedAccessors, + columnMap, + rowHeight, + count +}: { + renderedAccessors: string[]; + columnMap: Map>; + rowHeight: number; + count: number; +}) { + return ( +
+ {Array.from({ length: count }).map((_, rowIndex) => ( +
+ {renderedAccessors.map(accessor => { + const spec = columnMap.get(accessor); + return ( +
+ +
+ ); + })} +
+ ))} +
+ ); +} + +const DefaultEmptyComponent = () => ( + } heading='No Data' /> +); + +export function VirtualizedContent({ + columns, + rowHeight = 40, + groupHeaderHeight, + overscan = 5, + loadMoreOffset = 100, + emptyState, + zeroState, + classNames = {}, + stickyGroupHeader = false +}: VirtualizedContentProps) { + const { + onRowClick, + table, + isLoading, + loadMoreData, + tableQuery, + defaultSort, + loadingRowCount = 3 + } = useDataView(); + + const columnMap = useMemo(() => { + const map = new Map>(); + columns.forEach(c => map.set(c.accessorKey, c)); + return map; + }, [columns]); + + const visibleLeafColumns = table.getVisibleLeafColumns(); + + const renderedAccessors = useMemo(() => { + const visibleSet = new Set(visibleLeafColumns.map(c => c.id)); + return columns.map(c => c.accessorKey).filter(k => visibleSet.has(k)); + }, [columns, visibleLeafColumns]); + + const headerGroups = table?.getHeaderGroups() ?? []; + const lastHeaderGroup = headerGroups[headerGroups.length - 1]; + const headersInOrder = useMemo(() => { + if (!lastHeaderGroup) return [] as Header[]; + return renderedAccessors + .map( + accessor => + lastHeaderGroup.headers.find(h => h.column.id === accessor) as + | Header + | undefined + ) + .filter((h): h is Header => Boolean(h)); + }, [lastHeaderGroup, renderedAccessors]); + + const rowModel = table?.getRowModel(); + const { rows = [] } = rowModel || {}; + + const scrollContainerRef = useRef(null); + const headerRef = useRef(null); + const [stickyGroup, setStickyGroup] = useState | null>( + null + ); + const [headerHeight, setHeaderHeight] = useState(40); + + const groupBy = tableQuery?.group_by?.[0]; + const isGrouped = Boolean(groupBy) && groupBy !== defaultGroupOption.id; + + const groupHeaderList = useMemo(() => { + const list: { index: number; data: GroupedData }[] = []; + rows.forEach((row, i) => { + if (row.subRows && row.subRows.length > 0) { + list.push({ index: i, data: row.original as GroupedData }); + } + }); + return list; + }, [rows]); + + const showLoaderRows = isLoading && rows.length > 0; + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: index => { + const row = rows[index]; + const isGroupHeader = row?.subRows && row.subRows.length > 0; + return isGroupHeader ? (groupHeaderHeight ?? rowHeight) : rowHeight; + }, + overscan + }); + + const updateStickyGroup = useCallback(() => { + if (!stickyGroupHeader || !isGrouped || groupHeaderList.length === 0) { + setStickyGroup(null); + return; + } + const items = virtualizer.getVirtualItems(); + const firstIndex = items[0]?.index ?? 0; + const current = groupHeaderList + .filter(g => g.index <= firstIndex) + .pop()?.data; + setStickyGroup(current ?? null); + }, [stickyGroupHeader, isGrouped, groupHeaderList, virtualizer]); + + const handleVirtualScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + if (stickyGroupHeader) updateStickyGroup(); + if (isLoading) return; + const { scrollTop, scrollHeight, clientHeight } = el; + if (scrollHeight - scrollTop - clientHeight < loadMoreOffset!) { + loadMoreData(); + } + }, [ + stickyGroupHeader, + isLoading, + loadMoreData, + loadMoreOffset, + updateStickyGroup + ]); + + const totalHeight = virtualizer.getTotalSize(); + + useLayoutEffect(() => { + if (headerRef.current) { + setHeaderHeight(headerRef.current.getBoundingClientRect().height); + } + }, [headersInOrder]); + + useLayoutEffect(() => { + if (stickyGroupHeader) updateStickyGroup(); + }, [stickyGroupHeader, updateStickyGroup, groupHeaderList, isGrouped]); + + const hasData = rows?.length > 0 || isLoading; + + const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); + + const isZeroState = !hasData && !hasChanges; + const isEmptyState = !hasData && hasChanges; + + const stateToShow: React.ReactNode = isZeroState + ? (zeroState ?? emptyState ?? ) + : isEmptyState + ? (emptyState ?? ) + : null; + + if (!hasData) { + return
{stateToShow}
; + } + + return ( +
+
+
+ +
+ {stickyGroupHeader && isGrouped && stickyGroup && ( +
+ + {stickyGroup.label} + {stickyGroup.showGroupCount ? ( + {stickyGroup.count} + ) : null} + +
+ )} +
+ +
+
+ {showLoaderRows && ( + + )} +
+ ); +} + +VirtualizedContent.displayName = 'DataView.VirtualizedContent'; diff --git a/packages/raystack/components/data-view/context.tsx b/packages/raystack/components/data-view/context.tsx new file mode 100644 index 000000000..bfd4da0fa --- /dev/null +++ b/packages/raystack/components/data-view/context.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { createContext } from 'react'; + +import { DataViewContextType } from './data-view.types'; + +export const DataViewContext = createContext | null>( + null +); diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view/data-view.module.css new file mode 100644 index 000000000..13c51a51a --- /dev/null +++ b/packages/raystack/components/data-view/data-view.module.css @@ -0,0 +1,271 @@ +.toolbar { + padding: var(--rs-space-3) 0; + align-self: stretch; + + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + background: var(--rs-color-background-base-primary); +} + +.display-popover-content { + padding: 0px; + /* Todo: var does not exist for 300px */ + min-width: 300px; +} + +.display-popover-properties-container { + /* Todo: var does not exist for 160px */ + --select-width: 160px; + padding: var(--rs-space-5); + border-bottom: 1px solid var(--rs-color-border-base-primary); +} + +.display-popover-properties-select { + width: var(--select-width); +} + +.display-popover-properties-select[with-icon-button] { + /* Reduce Icon button with "--rs-space-7" and flex gap "--rs-space-2" */ + width: calc(var(--select-width) - var(--rs-space-7) - var(--rs-space-2)); +} + +.display-popover-properties-select > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.display-popover-reset-container { + padding: var(--rs-space-3) var(--rs-space-5); +} + +.display-popover-sort-icon { + height: var(--rs-space-6); + width: var(--rs-space-6); +} + +.flex-1 { + flex: 1; +} + +.filterSummaryFooter { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: var(--rs-space-4); + width: 100%; + padding: var(--rs-space-9) 0; + box-sizing: border-box; +} + +.filterSummaryCount { + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-style: normal; + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.filterSummaryLabel { + color: var(--rs-color-foreground-base-secondary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-style: normal; + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.contentRoot { + height: 100%; + overflow: auto; +} + +.row { + background: var(--rs-color-background-base-primary); +} + +.row:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.clickable { + cursor: pointer; +} + +.head { + position: sticky; + top: 0; + z-index: 1; +} + +.cell { + background: inherit; +} + +.emptyStateCell { + border-bottom: none; +} + +/* Virtualization styles */ +.scrollContainer { + overflow-y: auto; + overflow-x: auto; + position: relative; + height: 100%; + overscroll-behavior: auto; +} + +.stickyHeader { + position: sticky; + top: 0; + z-index: 1; + background: var(--rs-color-background-base-primary); +} + +/* Virtual table (div-based) styles */ +.virtualTable { + display: flex; + flex-direction: column; + width: 100%; +} + +.virtualHeaderGroup { + display: flex; + flex-direction: column; +} + +.virtualHeaderRow { + display: flex; +} + +.virtualBodyGroup { + position: relative; +} + +.virtualRow { + display: flex; + position: absolute; + width: 100%; + left: 0; +} + +.virtualHead { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; +} + +.virtualCell { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; + box-sizing: border-box; +} + +.virtualSectionHeader { + position: absolute; + width: 100%; + left: 0; + display: flex; + align-items: center; + background: var(--rs-color-background-base-secondary); + font-weight: var(--rs-font-weight-medium); + padding: var(--rs-space-3); +} + +/* Sticky group anchor: shows current group label while scrolling (virtualized) */ +.stickyGroupAnchor { + position: sticky; + z-index: 1; + display: flex; + align-items: center; + background: var(--rs-color-background-base-secondary); + font-weight: var(--rs-font-weight-medium); + padding: var(--rs-space-3); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary); +} + +.stickyLoaderContainer { + position: sticky; + bottom: 0; + z-index: 1; + background: var(--rs-color-background-base-primary); +} + +.loaderRow { + position: relative; +} + +/* Non-virtualized: sticky section header under table header */ +.stickySectionHeader { + position: sticky; + top: var(--rs-space-10); + z-index: 1; + background: var(--rs-color-background-base-secondary); + box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary); +} + +/* List renderer styles */ +.listRoot { + width: 100%; + height: 100%; + overflow: auto; + background: var(--rs-color-background-base-primary); +} + +.listGrid { + display: grid; + width: 100%; + align-items: stretch; +} + +.listRow { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + background: var(--rs-color-background-base-primary); + box-sizing: border-box; +} + +.listRow:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.listRowDivider { + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.listCell { + padding: var(--rs-space-3) var(--rs-space-4); + min-width: 0; + display: flex; + align-items: center; + box-sizing: border-box; +} + +.listGroupHeader { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: var(--rs-space-3); + background: var(--rs-color-background-base-secondary); + font-weight: var(--rs-font-weight-medium); + padding: var(--rs-space-3) var(--rs-space-4); + box-sizing: border-box; +} + +.listEmptyState { + display: flex; + justify-content: center; + align-items: center; + padding: var(--rs-space-9) 0; + width: 100%; + box-sizing: border-box; +} diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx new file mode 100644 index 000000000..fd7ad3371 --- /dev/null +++ b/packages/raystack/components/data-view/data-view.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getSortedRowModel, + Updater, + useReactTable, + VisibilityState +} from '@tanstack/react-table'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Content } from './components/content'; +import { DisplayAccess } from './components/display-access'; +import { DisplaySettings } from './components/display-settings'; +import { Filters } from './components/filters'; +import { DataViewList } from './components/list'; +import { DataViewRenderer } from './components/renderer'; +import { DataViewSearch } from './components/search'; +import { DataViewTable } from './components/table'; +import { Toolbar } from './components/toolbar'; +import { VirtualizedContent } from './components/virtualized-content'; +import { DataViewContext } from './context'; +import { + DataViewContextType, + DataViewProps, + defaultGroupOption, + GroupedData, + InternalQuery, + TableQueryUpdateFn +} from './data-view.types'; +import { + fieldsToColumnDefs, + getDefaultTableQuery, + getInitialColumnVisibility, + groupData, + hasQueryChanged, + queryToTableState, + transformToDataViewQuery +} from './utils'; + +function DataViewRoot({ + data = [], + fields, + query, + mode = 'client', + isLoading = false, + totalRowCount, + loadingRowCount = 3, + defaultSort, + children, + onTableQueryChange, + onLoadMore, + onRowClick, + onColumnVisibilityChange, + getRowId +}: React.PropsWithChildren>) { + const defaultTableQuery = useMemo( + () => getDefaultTableQuery(defaultSort, query), + [defaultSort, query] + ); + const initialColumnVisibility = useMemo( + () => getInitialColumnVisibility(fields), + [fields] + ); + + const [columnVisibility, setColumnVisibility] = useState( + initialColumnVisibility + ); + const handleColumnVisibilityChange = useCallback( + (value: Updater) => { + setColumnVisibility(prev => { + const newValue = typeof value === 'function' ? value(prev) : value; + onColumnVisibilityChange?.(newValue); + return newValue; + }); + }, + [onColumnVisibilityChange] + ); + + const [tableQuery, setTableQuery] = + useState(defaultTableQuery); + + const oldQueryRef = useRef(null); + + const reactTableState = useMemo( + () => queryToTableState(tableQuery), + [tableQuery] + ); + + const onDisplaySettingsReset = useCallback(() => { + setTableQuery(prev => ({ + ...prev, + ...defaultTableQuery, + sort: [defaultSort], + group_by: [defaultGroupOption.id] + })); + handleColumnVisibilityChange(initialColumnVisibility); + }, [ + defaultSort, + defaultTableQuery, + initialColumnVisibility, + handleColumnVisibilityChange + ]); + + const group_by = tableQuery.group_by?.[0]; + + // Metadata-only ColumnDefs. Filter fn is installed from field metadata and + // the current filter query; cell/header rendering lives on each renderer's + // column spec (DataView.Table / DataView.List), not here. + const columnDefs = useMemo( + () => fieldsToColumnDefs(fields, tableQuery.filters), + [fields, tableQuery.filters] + ); + + const groupedData = useMemo( + () => groupData(data, group_by, fields), + [data, group_by, fields] + ); + + useEffect(() => { + if ( + tableQuery && + onTableQueryChange && + hasQueryChanged(oldQueryRef.current, tableQuery) && + mode === 'server' + ) { + onTableQueryChange(transformToDataViewQuery(tableQuery)); + oldQueryRef.current = tableQuery; + } + }, [tableQuery, onTableQueryChange]); + + const table = useReactTable({ + data: groupedData as unknown as TData[], + columns: columnDefs, + getRowId, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getSubRows: row => (row as unknown as GroupedData)?.subRows || [], + getSortedRowModel: mode === 'server' ? undefined : getSortedRowModel(), + getFilteredRowModel: mode === 'server' ? undefined : getFilteredRowModel(), + manualSorting: mode === 'server', + manualFiltering: mode === 'server', + onColumnVisibilityChange: handleColumnVisibilityChange, + globalFilterFn: mode === 'server' ? undefined : 'auto', + initialState: { + columnVisibility: initialColumnVisibility + }, + filterFromLeafRows: true, + state: { + ...reactTableState, + columnVisibility: columnVisibility, + expanded: + group_by && group_by !== defaultGroupOption.id ? true : undefined + } + }); + + function updateTableQuery(fn: TableQueryUpdateFn) { + setTableQuery(prev => fn(prev)); + } + + const loadMoreData = useCallback(() => { + if (mode === 'server' && onLoadMore) { + onLoadMore(); + } + }, [mode, onLoadMore]); + + const searchQuery = query?.search; + useEffect(() => { + if (searchQuery) { + updateTableQuery(prev => ({ + ...prev, + search: searchQuery + })); + } + }, [searchQuery]); + + // Determine if filters should be visible + // Filters should be visible if there is data OR if filters are applied (empty state) + // Filters should NOT be visible if no data AND no filters (zero state) + // Note: Search alone does not show the filter bar + const shouldShowFilters = useMemo(() => { + const hasFilters = + (tableQuery?.filters && tableQuery.filters.length > 0) || false; + + try { + const rowModel = table.getRowModel(); + const hasData = (rowModel?.rows?.length ?? 0) > 0; + return hasData || hasFilters; + } catch { + // If table is not ready yet, check if we have initial data + // If no filters and no data, don't show filters + return hasFilters || data.length > 0; + } + }, [table, tableQuery, data.length]); + + const contextValue: DataViewContextType = useMemo(() => { + return { + table, + fields, + mode, + isLoading, + loadMoreData, + tableQuery, + updateTableQuery, + onDisplaySettingsReset, + defaultSort, + totalRowCount, + loadingRowCount, + onRowClick, + shouldShowFilters + }; + }, [ + table, + fields, + mode, + isLoading, + loadMoreData, + tableQuery, + updateTableQuery, + onDisplaySettingsReset, + defaultSort, + totalRowCount, + loadingRowCount, + onRowClick, + shouldShowFilters + ]); + + return {children}; +} + +DataViewRoot.displayName = 'DataView'; + +// biome-ignore lint/suspicious/noShadowRestrictedNames: public component name intentionally matches the package export +export const DataView = Object.assign(DataViewRoot, { + // Renderers — each takes its own row render spec (`columns` on Table/List). + Table: DataViewTable, + List: DataViewList, + // Escape hatch — render prop receives the full DataView context. + Renderer: DataViewRenderer, + // Legacy sub-renderer exports (used by consumers that imported inner pieces). + Content: Content, + VirtualizedContent: VirtualizedContent, + // Visibility primitive for free-form renderers. + DisplayAccess: DisplayAccess, + // Toolbar primitives + Toolbar: Toolbar, + Search: DataViewSearch, + Filters: Filters, + DisplayControls: DisplaySettings +}); diff --git a/packages/raystack/components/data-view/data-view.types.tsx b/packages/raystack/components/data-view/data-view.types.tsx new file mode 100644 index 000000000..1913ca4c2 --- /dev/null +++ b/packages/raystack/components/data-view/data-view.types.tsx @@ -0,0 +1,248 @@ +import type { ColumnDef, Table, VisibilityState } from '@tanstack/table-core'; +import type { + DataTableFilterOperatorTypes, + FilterOperatorTypes, + FilterSelectOption, + FilterTypes, + FilterValueType +} from '~/types/filters'; +import type { BaseSelectProps } from '../select/select-root'; + +export type DataViewMode = 'client' | 'server'; + +export const SortOrders = { + ASC: 'asc', + DESC: 'desc' +} as const; + +type SortOrdersKeys = keyof typeof SortOrders; +export type SortOrdersValues = (typeof SortOrders)[SortOrdersKeys]; + +export interface DataViewSort { + name: string; + order: SortOrdersValues; +} + +export interface DataViewFilterValues { + value: any; + // Only one of these value fields should be present at a time + boolValue?: boolean; + stringValue?: string; + numberValue?: number; +} + +// Internal filter with UI operators and metadata +export interface InternalFilter extends DataViewFilterValues { + _type?: FilterTypes; + _dataType?: FilterValueType; + name: string; + operator: FilterOperatorTypes; +} + +// DataView filter for backend API (no internal fields) +export interface DataViewFilter extends DataViewFilterValues { + name: string; + operator: DataTableFilterOperatorTypes; +} + +// Internal query with UI operators and metadata +export interface InternalQuery { + filters?: InternalFilter[]; + sort?: DataViewSort[]; + group_by?: string[]; + offset?: number; + limit?: number; + search?: string; +} + +// DataView query for backend API (clean, no internal fields) +export interface DataViewQuery extends Omit { + filters?: DataViewFilter[]; +} + +/** + * Renderer-agnostic field metadata. One entry per logical column of the data + * model. Declared once on ``; drives filters, sort, group, visibility + * across every renderer. Cell/header rendering belongs on the renderer's own + * column spec, not here. + */ +export interface DataViewField { + accessorKey: string; + /** Human-readable label shown in filter chips, Display controls, and the default Table header. */ + label: string; + icon?: React.ReactNode; + + // filter capability + filterable?: boolean; + filterType?: FilterTypes; + dataType?: FilterValueType; + filterOptions?: FilterSelectOption[]; + defaultFilterValue?: unknown; + filterProps?: { + select?: BaseSelectProps; + }; + + // ordering / grouping / visibility capability + sortable?: boolean; + groupable?: boolean; + hideable?: boolean; + defaultHidden?: boolean; + + // group-header presentation (used by any renderer that groups) + showGroupCount?: boolean; + groupCountMap?: Record; + groupLabelsMap?: Record; +} + +/** + * Table row render spec. Points at a field via `accessorKey` and adds + * per-column cell/header renderers. Filterable/sortable/etc. live on the field, + * not duplicated here. + */ +export interface DataViewTableColumn { + accessorKey: string; + /** TanStack-style cell renderer. Receives `CellContext` and returns ReactNode. */ + cell?: ColumnDef['cell']; + /** TanStack-style header renderer. Overrides the field's `label` for this renderer. */ + header?: ColumnDef['header']; + classNames?: { + cell?: string; + header?: string; + }; + styles?: { + cell?: React.CSSProperties; + header?: React.CSSProperties; + }; +} + +/** + * List row render spec. Same data shape as Table plus a CSS grid `width` hint. + * List has no column headers, so no `header` renderer. + */ +export interface DataViewListColumn { + accessorKey: string; + /** TanStack-style cell renderer. */ + cell?: ColumnDef['cell']; + /** CSS grid track width. `1fr`, `auto`, `'200px'`, `'minmax(80px, 1fr)'`, or a number (pixels). Defaults to `1fr`. */ + width?: string | number; + classNames?: { cell?: string }; + styles?: { cell?: React.CSSProperties }; +} + +export interface DataViewProps { + data: TData[]; + /** Renderer-agnostic field metadata. Drives filter/sort/group/visibility. */ + fields: DataViewField[]; + query?: DataViewQuery; // Initial query (will be transformed to internal format) + mode?: DataViewMode; + isLoading?: boolean; + totalRowCount?: number; + loadingRowCount?: number; + onTableQueryChange?: (query: DataViewQuery) => void; + defaultSort: DataViewSort; + onLoadMore?: () => Promise; + onRowClick?: (row: TData) => void; + onColumnVisibilityChange?: (columnVisibility: VisibilityState) => void; + /** Return a stable unique id for each row (used as React key). Use for sortable/filterable tables. */ + getRowId?: (row: TData, index: number) => string; +} + +export type DataViewContentClassNames = { + root?: string; + table?: string; + header?: string; + body?: string; + row?: string; +}; + +export type DataViewTableBaseProps = { + /** Table column render specs (cell/header/styles) keyed by field accessor. */ + columns: DataViewTableColumn[]; + emptyState?: React.ReactNode; + zeroState?: React.ReactNode; + classNames?: DataViewContentClassNames; + /** When true, renders with virtualized rows. */ + virtualized?: boolean; + /** Height of each row in pixels. Used for virtualized mode. */ + rowHeight?: number; + /** Height of group header rows in pixels. Falls back to rowHeight if not set. */ + groupHeaderHeight?: number; + /** Number of rows to render outside visible area. */ + overscan?: number; + /** Distance in pixels from bottom to trigger load more. */ + loadMoreOffset?: number; + /** When true, group headers stick under the table header while scrolling. Default is false. */ + stickyGroupHeader?: boolean; +}; + +export type DataViewTableProps< + TData, + TValue = unknown +> = DataViewTableBaseProps; + +export type DataViewListClassNames = { + root?: string; + row?: string; + cell?: string; + groupHeader?: string; +}; + +export interface DataViewListProps { + /** List column render specs (cell/width/styles) keyed by field accessor. */ + columns: DataViewListColumn[]; + /** Row height in px. Used when virtualized. Default 56. */ + rowHeight?: number; + /** When true, virtualizes rows. */ + virtualized?: boolean; + /** Number of rows to render outside the viewport when virtualized. */ + overscan?: number; + /** Render thin dividers between rows. */ + showDividers?: boolean; + /** Show group section headers when grouping is active. Default true. */ + showGroupHeaders?: boolean; + /** Distance in pixels from bottom to trigger load more. */ + loadMoreOffset?: number; + emptyState?: React.ReactNode; + zeroState?: React.ReactNode; + classNames?: DataViewListClassNames; +} + +export type TableQueryUpdateFn = (query: InternalQuery) => InternalQuery; + +export type DataViewContextType = { + table: Table; + /** Renderer-agnostic field metadata — Filters/DisplayControls/every renderer reads from here. */ + fields: DataViewField[]; + isLoading?: boolean; + loadMoreData: () => void; + mode: DataViewMode; + defaultSort: DataViewSort; + tableQuery?: InternalQuery; + totalRowCount?: number; + loadingRowCount?: number; + onDisplaySettingsReset: () => void; + updateTableQuery: (fn: TableQueryUpdateFn) => void; + onRowClick?: (row: TData) => void; + shouldShowFilters?: boolean; +}; + +export interface ColumnData { + label: string; + id: string; + isVisible?: boolean; +} + +interface SubRows<_T> {} + +export interface GroupedData extends SubRows { + label: string; + group_key: string; + subRows: T[]; + count?: number; + showGroupCount?: boolean; +} + +export const defaultGroupOption = { + id: '--', + label: 'No grouping' +}; diff --git a/packages/raystack/components/data-view/hooks/useDataView.tsx b/packages/raystack/components/data-view/hooks/useDataView.tsx new file mode 100644 index 000000000..2ae93e9ed --- /dev/null +++ b/packages/raystack/components/data-view/hooks/useDataView.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; + +import { DataViewContext } from '../context'; +import { DataViewContextType } from '../data-view.types'; + +export const useDataView = (): DataViewContextType => { + const ctx = useContext(DataViewContext); + if (ctx === null) { + throw new Error('useDataView must be used inside of a DataView.Provider'); + } + + return ctx as DataViewContextType; +}; diff --git a/packages/raystack/components/data-view/hooks/useFilters.tsx b/packages/raystack/components/data-view/hooks/useFilters.tsx new file mode 100644 index 000000000..31530833c --- /dev/null +++ b/packages/raystack/components/data-view/hooks/useFilters.tsx @@ -0,0 +1,89 @@ +import { + FilterOperatorTypes, + FilterType, + filterOperators +} from '~/types/filters'; +import { DataViewField } from '../data-view.types'; +import { getDataType } from '../utils/filter-operations'; +import { useDataView } from './useDataView'; + +export function useFilters() { + const { updateTableQuery } = useDataView(); + + function onAddFilter(field: DataViewField) { + const options = field.filterOptions || []; + const filterType = field.filterType || FilterType.string; + const dataType = getDataType({ filterType, dataType: field.dataType }); + const defaultFilter = filterOperators[filterType][0]; + const defaultValue = + field.defaultFilterValue ?? + (filterType === FilterType.date + ? new Date() + : filterType === FilterType.select + ? options[0]?.value + : ''); + + updateTableQuery(query => { + return { + ...query, + filters: [ + ...(query.filters || []), + { + _dataType: dataType, + _type: filterType, + name: field.accessorKey, + value: defaultValue, + operator: defaultFilter.value + } + ] + }; + }); + } + + function handleRemoveFilter(fieldAccessor: string) { + updateTableQuery(query => { + return { + ...query, + filters: query.filters?.filter(filter => filter.name !== fieldAccessor) + }; + }); + } + + function handleFilterValueChange(fieldAccessor: string, value: any) { + updateTableQuery(query => { + return { + ...query, + filters: query.filters?.map(filter => { + if (filter.name === fieldAccessor) { + return { ...filter, value }; + } + return filter; + }) + }; + }); + } + + function handleFilterOperationChange( + fieldAccessor: string, + operator: FilterOperatorTypes + ) { + updateTableQuery(query => { + return { + ...query, + filters: query.filters?.map(filter => { + if (filter.name === fieldAccessor) { + return { ...filter, operator }; + } + return filter; + }) + }; + }); + } + + return { + onAddFilter, + handleRemoveFilter, + handleFilterValueChange, + handleFilterOperationChange + }; +} diff --git a/packages/raystack/components/data-view/index.ts b/packages/raystack/components/data-view/index.ts new file mode 100644 index 000000000..d71a88dfc --- /dev/null +++ b/packages/raystack/components/data-view/index.ts @@ -0,0 +1,19 @@ +export { EmptyFilterValue } from '~/types/filters'; +export type { DataViewDisplayAccessProps } from './components/display-access'; +export type { DataViewRendererProps } from './components/renderer'; +export type { DataViewSearchProps } from './components/search'; +export { DataView } from './data-view'; +export { + DataViewField, + DataViewFilter, + DataViewListColumn, + DataViewListProps, + DataViewProps, + DataViewQuery, + DataViewSort, + DataViewTableColumn, + DataViewTableProps, + InternalFilter, + InternalQuery +} from './data-view.types'; +export { useDataView } from './hooks/useDataView'; diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx new file mode 100644 index 000000000..dcaa28db8 --- /dev/null +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -0,0 +1,292 @@ +import type { FilterFn } from '@tanstack/table-core'; +import dayjs from 'dayjs'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; + +import { + DataTableFilterOperatorTypes, + DateFilterOperatorType, + EmptyFilterValue, + FilterOperatorTypes, + FilterType, + FilterTypes, + FilterValue, + FilterValueType, + MultiSelectFilterOperatorType, + NumberFilterOperatorType, + SelectFilterOperatorType, + StringFilterOperatorType +} from '~/types/filters'; +import { DataViewFilterValues } from '../data-view.types'; + +dayjs.extend(isSameOrAfter); +dayjs.extend(isSameOrBefore); + +export type FilterFunctionsMap = { + number: Record>; + string: Record>; + date: Record>; + select: Record>; + multiselect: Record>; +}; + +export const filterOperationsMap: FilterFunctionsMap = { + number: { + eq: (row, columnId, filterValue: FilterValue, _addMeta) => { + return Number(row.getValue(columnId)) === Number(filterValue.value); + }, + neq: (row, columnId, filterValue: FilterValue, _addMeta) => { + return Number(row.getValue(columnId)) !== Number(filterValue.value); + }, + lt: (row, columnId, filterValue: FilterValue, _addMeta) => { + return Number(row.getValue(columnId)) < Number(filterValue.value); + }, + lte: (row, columnId, filterValue: FilterValue, _addMeta) => { + return Number(row.getValue(columnId)) <= Number(filterValue.value); + }, + gt: (row, columnId, filterValue: FilterValue, _addMeta) => { + return Number(row.getValue(columnId)) > Number(filterValue.value); + }, + gte: (row, columnId, filterValue: FilterValue, _addMeta) => { + return Number(row.getValue(columnId)) >= Number(filterValue.value); + } + }, + string: { + eq: (row, columnId, filterValue: FilterValue, _addMeta) => { + return ( + String(row.getValue(columnId)).toLowerCase() === + String(filterValue.value).toLowerCase() + ); + }, + neq: (row, columnId, filterValue: FilterValue, _addMeta) => { + return ( + String(row.getValue(columnId)).toLowerCase() !== + String(filterValue.value).toLowerCase() + ); + }, + contains: (row, columnId, filterValue: FilterValue, _addMeta) => { + const columnValue = String(row.getValue(columnId)).toLowerCase(); + const filterStr = String(filterValue.value).toLowerCase(); + return columnValue.includes(filterStr); + }, + starts_with: (row, columnId, filterValue: FilterValue, _addMeta) => { + const columnValue = String(row.getValue(columnId)).toLowerCase(); + const filterStr = String(filterValue.value).toLowerCase(); + return columnValue.startsWith(filterStr); + }, + ends_with: (row, columnId, filterValue: FilterValue, _addMeta) => { + const columnValue = String(row.getValue(columnId)).toLowerCase(); + const filterStr = String(filterValue.value).toLowerCase(); + return columnValue.endsWith(filterStr); + } + }, + date: { + eq: (row, columnId, filterValue: FilterValue, _addMeta) => { + return dayjs(row.getValue(columnId)).isSame( + dayjs(filterValue.date), + 'day' + ); + }, + neq: (row, columnId, filterValue: FilterValue, _addMeta) => { + return !dayjs(row.getValue(columnId)).isSame( + dayjs(filterValue.date), + 'day' + ); + }, + lt: (row, columnId, filterValue: FilterValue, _addMeta) => { + return dayjs(row.getValue(columnId)).isBefore( + dayjs(filterValue.date), + 'day' + ); + }, + lte: (row, columnId, filterValue: FilterValue, _addMeta) => { + return dayjs(row.getValue(columnId)).isSameOrBefore( + dayjs(filterValue.date), + 'day' + ); + }, + gt: (row, columnId, filterValue: FilterValue, _addMeta) => { + return dayjs(row.getValue(columnId)).isAfter( + dayjs(filterValue.date), + 'day' + ); + }, + gte: (row, columnId, filterValue: FilterValue, _addMeta) => { + return dayjs(row.getValue(columnId)).isSameOrAfter( + dayjs(filterValue.date), + 'day' + ); + } + }, + select: { + eq: (row, columnId, filterValue: FilterValue, _addMeta) => { + if (String(filterValue.value) === EmptyFilterValue) { + return row.getValue(columnId) === ''; + } + // Select only supports string values + return String(row.getValue(columnId)) === String(filterValue.value); + }, + neq: (row, columnId, filterValue: FilterValue, _addMeta) => { + if (String(filterValue.value) === EmptyFilterValue) { + return row.getValue(columnId) !== ''; + } + // Select only supports string values + return String(row.getValue(columnId)) !== String(filterValue.value); + } + }, + multiselect: { + in: (row, columnId, filterValue: FilterValue, _addMeta) => { + if (!Array.isArray(filterValue.value)) return false; + + return filterValue.value + .map(value => (value === EmptyFilterValue ? '' : String(value))) + .includes(String(row.getValue(columnId))); + }, + notin: (row, columnId, filterValue: FilterValue, _addMeta) => { + if (!Array.isArray(filterValue.value)) return false; + + return !filterValue.value + .map(value => (value === EmptyFilterValue ? '' : String(value))) + .includes(String(row.getValue(columnId))); + } + } +} as const; + +export function getFilterFn( + type: T, + operator: FilterOperatorTypes +) { + // @ts-expect-error FilterOperatorTypes is union of all possible operators + return filterOperationsMap[type][operator]; +} + +const handleStringBasedTypes = ( + filterType: FilterTypes, + value: any, + operator?: FilterOperatorTypes | DataTableFilterOperatorTypes +): DataViewFilterValues => { + switch (filterType) { + case FilterType.date: { + const dateValue = dayjs(value); + let stringValue = ''; + if (dateValue.isValid()) { + try { + stringValue = dateValue.toISOString(); + } catch { + stringValue = ''; + } + } + return { + value, + stringValue + }; + } + case FilterType.select: + return { + stringValue: value === EmptyFilterValue ? '' : value, + value + }; + case FilterType.multiselect: + return { + value, + stringValue: value + .map((value: any) => + value === EmptyFilterValue ? '' : String(value) + ) + .join() + }; + case FilterType.string: { + // Apply wildcards for ilike operations + let processedValue = value; + // Check if we need to apply wildcards (operator could be UI type or already converted to 'ilike') + if (operator === 'contains') { + processedValue = `%${value}%`; + } else if (operator === 'starts_with') { + processedValue = `${value}%`; + } else if (operator === 'ends_with') { + processedValue = `%${value}`; + } else if (operator === 'ilike') { + // If already converted to ilike, assume it needs contains-style wildcards + // unless the value already has wildcards + if (!value.includes('%')) { + processedValue = `%${value}%`; + } + } + return { + stringValue: processedValue, + value + }; + } + default: + return { + stringValue: value, + value + }; + } +}; + +export const getFilterOperator = ({ + value, + filterType, + operator +}: { + value: any; + filterType?: FilterTypes; + operator: FilterOperatorTypes; +}): DataTableFilterOperatorTypes => { + if (value === EmptyFilterValue && filterType === FilterType.select) { + return 'empty'; + } + + // Map string filter operators to ilike for DataViewFilter + if ( + filterType === FilterType.string && + (operator === 'contains' || + operator === 'starts_with' || + operator === 'ends_with') + ) { + return 'ilike'; + } + + return operator as DataTableFilterOperatorTypes; +}; + +export const getFilterValue = ({ + value, + dataType = 'string', + filterType = FilterType.string, + operator +}: { + value: any; + dataType?: FilterValueType; + filterType?: FilterTypes; + operator?: FilterOperatorTypes | DataTableFilterOperatorTypes; +}): DataViewFilterValues => { + if (dataType === 'boolean') { + return { boolValue: value, value }; + } + if (dataType === 'number') { + return { numberValue: value, value }; + } + + // Handle string-based types + return handleStringBasedTypes(filterType, value, operator); +}; + +export const getDataType = ({ + filterType = FilterType.string, + dataType = 'string' +}: { + dataType?: FilterValueType; + filterType?: FilterTypes; +}): FilterValueType => { + switch (filterType) { + case FilterType.multiselect: + case FilterType.select: + return dataType; + case FilterType.date: + return 'string'; + default: + return filterType; + } +}; diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx new file mode 100644 index 000000000..b0adfba9f --- /dev/null +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -0,0 +1,360 @@ +import type { ColumnDef, Row, Table } from '@tanstack/react-table'; +import { TableState } from '@tanstack/table-core'; +import dayjs from 'dayjs'; + +import { FilterOperatorTypes, FilterType } from '~/types/filters'; +import { + DataViewField, + DataViewQuery, + DataViewSort, + defaultGroupOption, + GroupedData, + InternalFilter, + InternalQuery, + SortOrders +} from '../data-view.types'; +import { + getFilterFn, + getFilterOperator, + getFilterValue +} from './filter-operations'; + +export function queryToTableState(query: InternalQuery): Partial { + const columnFilters = + query.filters + ?.filter(data => { + if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data.value !== '') return true; + return false; + }) + ?.map(data => { + const valueObject = + data._type === FilterType.date + ? { date: data.value } + : { value: data.value }; + return { + value: valueObject, + id: data?.name + }; + }) || []; + + const sorting = query.sort?.map(data => ({ + id: data?.name, + desc: data?.order === SortOrders.DESC + })); + return { + columnFilters: columnFilters, + sorting: sorting, + globalFilter: query.search + }; +} + +/** + * Convert field metadata to TanStack ColumnDefs. These carry filter/sort/group/ + * visibility capability and the filter predicate, but no `cell` renderer — + * cell/header rendering is installed by each renderer from its own column spec. + * `header` is set to `field.label` so any renderer that falls back to the + * TanStack header string sees the right label. + */ +export function fieldsToColumnDefs( + fields: DataViewField[] = [], + filters: InternalFilter[] = [] +): ColumnDef[] { + return fields.map(field => { + const colFilter = filters?.find(f => f.name === field.accessorKey); + const filterFn = colFilter?.operator + ? getFilterFn(field.filterType || FilterType.string, colFilter.operator) + : undefined; + + return { + id: field.accessorKey, + accessorKey: field.accessorKey, + header: field.label, + enableColumnFilter: field.filterable ?? false, + enableSorting: field.sortable ?? false, + enableGrouping: field.groupable ?? false, + enableHiding: field.hideable ?? false, + filterFn + } as ColumnDef; + }); +} + +export function groupData( + data: TData[], + group_by?: string, + fields: DataViewField[] = [] +): GroupedData[] { + if (!data) return []; + if (!group_by || group_by === defaultGroupOption.id) + return data as GroupedData[]; + + const groupMap = new Map(); + data.forEach((currentData: TData) => { + const item = currentData as Record; + const keyValue = item[group_by]; + if (!groupMap.has(keyValue)) { + groupMap.set(keyValue, []); + } + groupMap.get(keyValue)?.push(item as TData); + }); + + const field = fields.find(f => f.accessorKey === group_by); + const showGroupCount = field?.showGroupCount || false; + const groupLablesMap = field?.groupLabelsMap || {}; + const groupCountMap = field?.groupCountMap || {}; + const groupedData: GroupedData[] = []; + + groupMap.forEach((value, key) => { + groupedData.push({ + label: groupLablesMap[key] || key, + group_key: key, + subRows: value, + count: groupCountMap[key] ?? value.length, + showGroupCount + }); + }); + + return groupedData; +} + +const generateFilterMap = ( + filters: InternalFilter[] = [] +): Map => { + return new Map( + filters + ?.filter(data => data._type === FilterType.select || data.value !== '') + .map(({ name, operator, value }) => [`${name}-${operator}`, value]) + ); +}; + +const generateSortMap = (sort: DataViewSort[] = []): Map => { + return new Map(sort.map(({ name, order }) => [name, order])); +}; + +const isFilterChanged = ( + oldFilters: InternalFilter[] = [], + newFilters: InternalFilter[] = [] +): boolean => { + const oldFilterMap = generateFilterMap(oldFilters); + const newFilterMap = generateFilterMap(newFilters); + + if (oldFilterMap.size !== newFilterMap.size) return true; + + return [...newFilterMap].some( + ([key, value]) => oldFilterMap.get(key) !== value + ); +}; + +const isSortChanged = ( + oldSort: DataViewSort[] = [], + newSort: DataViewSort[] = [] +): boolean => { + if (oldSort.length !== newSort.length) return true; + + const oldSortMap = generateSortMap(oldSort); + const newSortMap = generateSortMap(newSort); + + return [...newSortMap].some(([key, order]) => oldSortMap.get(key) !== order); +}; + +const isGroupChanged = ( + oldGroupBy: string[] = [], + newGroupBy: string[] = [] +): boolean => { + if (oldGroupBy.length !== newGroupBy.length) return true; + + const oldGroupSet = new Set(oldGroupBy); + return newGroupBy.some(item => !oldGroupSet.has(item)); +}; + +const isSearchChanged = (oldSearch?: string, newSearch?: string): boolean => { + return oldSearch !== newSearch; +}; + +/** + * Checks if there is an active filter, search, or updated sort/grouping + * compared to the defaults. Used to distinguish zero state from empty state. + */ +export const hasActiveQuery = ( + tableQuery: InternalQuery, + defaultSort: DataViewSort +): boolean => { + const hasFilters = + (tableQuery?.filters && tableQuery.filters.length > 0) || false; + const hasSearch = Boolean( + tableQuery?.search && tableQuery.search.trim() !== '' + ); + const sortChanged = isSortChanged([defaultSort], tableQuery.sort || []); + const groupChanged = isGroupChanged( + [defaultGroupOption.id], + tableQuery.group_by || [] + ); + return hasFilters || hasSearch || sortChanged || groupChanged; +}; + +export const hasQueryChanged = ( + oldQuery: InternalQuery | null, + newQuery: InternalQuery +): boolean => { + if (!oldQuery) return true; + return ( + isFilterChanged(oldQuery.filters, newQuery.filters) || + isSortChanged(oldQuery.sort, newQuery.sort) || + isGroupChanged(oldQuery.group_by, newQuery.group_by) || + isSearchChanged(oldQuery.search, newQuery.search) + ); +}; + +export function getInitialColumnVisibility( + fields: DataViewField[] = [] +): Record { + return fields.reduce>((acc, field) => { + acc[field.accessorKey] = field.defaultHidden ? false : true; + return acc; + }, {}); +} + +export function transformToDataViewQuery(query: InternalQuery): DataViewQuery { + const { group_by = [], filters = [], sort = [], ...rest } = query; + const sanitizedGroupBy = group_by?.filter( + key => key !== defaultGroupOption.id + ); + + const sanitizedFilters = + filters + ?.filter(data => { + if (data._type === FilterType.select) return true; + if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data.value !== '') return true; + return false; + }) + ?.map(data => ({ + name: data.name, + operator: getFilterOperator({ + operator: data.operator, + value: data.value, + filterType: data._type + }), + ...getFilterValue({ + value: data.value, + filterType: data._type, + dataType: data._dataType, + operator: data.operator + }) + })) || []; + + return { + ...rest, + sort: sort, + group_by: sanitizedGroupBy, + filters: sanitizedFilters + }; +} + +// Transform DataViewQuery to InternalQuery +// This reverses the transformation done by transformToDataViewQuery +export function dataViewQueryToInternal(query: DataViewQuery): InternalQuery { + const { filters, ...rest } = query; + + if (!filters) { + return rest; + } + + // Convert DataViewFilter[] to InternalFilter[] + const internalFilters: InternalFilter[] = filters.map(filter => { + const { + operator, + value, + stringValue, + numberValue, + boolValue, + ...filterRest + } = filter; + + // Reverse the operator mapping and wildcard transformation + let transformedFilter = { + operator: operator as FilterOperatorTypes, + value: value, + ...(stringValue !== undefined && { stringValue }), + ...(numberValue !== undefined && { numberValue }), + ...(boolValue !== undefined && { boolValue }) + }; + + // If operator is 'ilike', determine the original operator based on wildcards + if (operator === 'ilike' && stringValue) { + if (stringValue.startsWith('%') && stringValue.endsWith('%')) { + transformedFilter = { + operator: 'contains', + value: stringValue.slice(1, -1) // Remove % from both ends + }; + } else if (stringValue.endsWith('%')) { + transformedFilter = { + operator: 'starts_with', + value: stringValue.slice(0, -1) // Remove % from end + }; + } else if (stringValue.startsWith('%')) { + transformedFilter = { + operator: 'ends_with', + value: stringValue.slice(1) // Remove % from start + }; + } else { + // Default to contains if no wildcards (shouldn't happen normally) + transformedFilter = { + operator: 'contains', + value: stringValue + }; + } + } + + return { + ...filterRest, + ...transformedFilter, + // We don't have type information, so leave it undefined + // The UI will need to infer or set these based on column definitions + _type: undefined, + _dataType: undefined + } as InternalFilter; + }); + + return { + ...rest, + filters: internalFilters + }; +} + +/** Leaf count from the row tree. Do not use `model.flatRows` here: with `filterFromLeafRows`, TanStack's filtered model leaves `flatRows` empty while `rows` is correct. */ +export function countLeafRows(rows: Row[]): number { + return rows.reduce( + (n, row) => n + (row.subRows?.length ? countLeafRows(row.subRows) : 1), + 0 + ); +} + +/** Difference between pre- and post-filter leaf rows (client mode only). */ +export function getClientHiddenLeafRowCount(table: Table): number { + const pre = table.getPreFilteredRowModel(); + const post = table.getFilteredRowModel(); + return Math.max(0, countLeafRows(pre.rows) - countLeafRows(post.rows)); +} + +export function hasActiveTableFiltering(table: Table): boolean { + const state = table.getState(); + if (state.columnFilters?.length > 0) return true; + const gf = state.globalFilter; + if (gf === undefined || gf === null) return false; + return String(gf).trim() !== ''; +} + +export function getDefaultTableQuery( + defaultSort: DataViewSort, + oldQuery: DataViewQuery = {} +): InternalQuery { + // Convert DataViewQuery to InternalQuery + const internalQuery = dataViewQueryToInternal(oldQuery); + + return { + sort: [defaultSort], + group_by: [defaultGroupOption.id], + ...internalQuery + }; +} diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 36933d4f9..5f723f720 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -30,6 +30,18 @@ export { EmptyFilterValue, useDataTable } from './components/data-table'; +export { + DataView, + DataViewField, + DataViewListColumn, + DataViewListProps, + DataViewProps, + DataViewQuery, + DataViewSort, + DataViewTableColumn, + DataViewTableProps, + useDataView +} from './components/data-view'; export { Dialog } from './components/dialog'; export { Drawer } from './components/drawer'; export { EmptyState } from './components/empty-state'; From ca8e88dcbef830eb843c6fb152447d2f2d8c4a36 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 23 Apr 2026 17:51:45 +0530 Subject: [PATCH 2/5] chore: add rfc --- docs/rfcs/002-unified-dataview-component.md | 428 ++++++++++++++++++ .../components/data-view/data-view.tsx | 7 + 2 files changed, 435 insertions(+) create mode 100644 docs/rfcs/002-unified-dataview-component.md diff --git a/docs/rfcs/002-unified-dataview-component.md b/docs/rfcs/002-unified-dataview-component.md new file mode 100644 index 000000000..a9eeb0a8f --- /dev/null +++ b/docs/rfcs/002-unified-dataview-component.md @@ -0,0 +1,428 @@ +--- +ID: RFC 002 +Created: April 23, 2026 +Status: Draft +RFC PR: https://github.com/raystack/apsara/pull/752 +--- + +# Unified DataView Component + +This RFC proposes replacing the current `DataTable` with a unified `DataView` root that owns data-modeling state and exposes swappable renderer subcomponents (Table, List, Timeline, Custom), so the same query/filter/sort/group/search state can drive multiple presentations. + +## Table of Contents + +- [Unified DataView Component](#unified-dataview-component) + - [Table of Contents](#table-of-contents) + - [Background](#background) + - [Current Problems](#current-problems) + - [Proposal](#proposal) + - [Why a Unified DataView?](#why-a-unified-dataview) + - [Pros and Cons](#pros-and-cons) + - [Pros](#pros) + - [Cons](#cons) + - [Differences and Analysis](#differences-and-analysis) + - [General Differences from DataTable](#general-differences-from-datatable) + - [Root Owns Data, Renderers Own Presentation](#root-owns-data-renderers-own-presentation) + - [`columns` Renamed to `fields` on Root](#columns-renamed-to-fields-on-root) + - [Explicit Toolbar Composition](#explicit-toolbar-composition) + - [Unified Column Visibility via `DisplayAccess`](#unified-column-visibility-via-displayaccess) + - [Virtualization as a Prop, Not a Component](#virtualization-as-a-prop-not-a-component) + - [Renderer-Specific Differences](#renderer-specific-differences) + - [Table](#table) + - [List](#list) + - [Timeline](#timeline) + - [Custom](#custom) + - [Grouping](#grouping) + - [Table of Comparison](#table-of-comparison) + - [Impact](#impact) + - [Discarded Approaches and Considerations](#discarded-approaches-and-considerations) + - [Rejected Alternatives](#rejected-alternatives) + - [Scoped-Out Decisions](#scoped-out-decisions) + - [Helpful Links](#helpful-links) + +## Background + +Apsara currently ships a single data-presentation primitive: `DataTable` (`packages/raystack/components/data-table/`). It bundles two layers that are conceptually separate: + +- **Data-modeling layer** — query state (`filters`, `sort`, `group_by`, `search`, `offset`, `limit`), client-vs-server mode, row model derivation via TanStack Table. +- **Tabular rendering layer** — table header/body/row/cell DOM, column visibility UI, virtualization, sticky group headers. + +Consumer apps increasingly need non-tabular presentations of the same data: list views (person cards), timeline / Gantt views (range bars on a time axis), and ad-hoc custom renderers (Kanban, Gallery, Map). Today each of these would have to re-implement the data-modeling layer from scratch. + +### Current Problems + +- **Layer 1 is not reusable.** Query state, filter predicates, client/server mode, `groupData`, and the `useFilters` hook all live inside `DataTable` and cannot drive any non-tabular renderer without duplication. +- **No cross-view state persistence.** A user who applies filters/sorts in a tabular view and switches to a list/timeline view loses that state because each view owns its own data layer. +- **Grouping is accessor-only.** `groupData()` groups by a plain `accessorKey` string. Timeline-style bucketing (by day/week/month) or "updated this week / earlier" grouping in a list view is not expressible. +- **Visibility story does not generalize.** Column visibility is hard-coded to `` collapse in the table renderer; non-columnar renderers (timeline bars, custom cards) have no uniform way to react to the Display Properties toggle, so the control silently no-ops for them. +- **Table-specific concerns leak into shared state.** `stickyGroupHeader`, `VirtualizedContent` as a separate export, and `shouldShowFilters` mixing data-layer checks with `table.getRowModel()` all blur the line between data and presentation. + +**Roughly 80% of today's `DataTable` logic is already renderer-agnostic; the work is largely an extraction + renaming exercise rather than a rewrite.** + +## Proposal + +We propose introducing a single `DataView` root that owns the data-modeling layer, alongside swappable renderer subcomponents for presentation: + +- `DataView.Toolbar`, `DataView.Search`, `DataView.Filters`, `DataView.DisplayControls` — presentation-agnostic controls that read/write query state through context. +- `DataView.Table` — the current tabular renderer, unchanged in behavior. Takes a `columns` prop of `DataViewTableColumn[]`. +- `DataView.List` — CSS `grid` + `subgrid` renderer. Shares the column-spec shape with Table (drops `header`, adds a grid-track `width`). +- `DataView.Timeline` — variable-width range bars on a continuous time axis (Gantt-style). Takes `startField`, `endField`, `renderBar`. +- `DataView.Custom` — escape hatch; render lives in children, context is passed as argument. +- `DataView.DisplayAccess` — foundational visibility primitive: wraps any JSX and gates it on the current `columnVisibility` state so non-columnar renderers honor the same Display Properties toggle. + +Target API: + +```tsx + + + + + + + + {/* pick one — or switch between them with a tab/toggle */} + + + ( + + + {row.getValue('title')} + + + {row.getValue('priority')} + + + )} + /> + {(api) => /* ... */} + +``` + +The migration will preserve the existing `DataTable` export as a thin alias over `` + `` through at least one major version, so consumers can migrate incrementally. + +## Why a Unified DataView? + +A `DataView` root with swappable renderers is the right fit for Apsara's needs: + +- **Headless core already exists.** TanStack Table (already a dependency) is headless — the `table` object produces a `Row` tree from `data + column defs + state` and emits no DOM. The same row model can drive Table, List, Timeline, or anything else. +- **Presentation-agnostic logic is already >80% of the surface.** Query state, filter predicates (`filterOperationsMap`), `useFilters`, wire-format translation (`transformToDataTableQuery` / `dataTableQueryToInternal`), client/server mode — all of it reuses as-is. +- **Cross-renderer state persistence comes for free.** Because filters/sort/search live on context, switching from Table to List to Timeline inside one `DataView` preserves the user's query state with zero wiring. +- **Familiar composition pattern.** Same `.` idiom already used by every Apsara primitive (Dialog, Popover, Select, etc.) — no new mental model. +- **Open to future renderers.** `DataView.Custom` + shared context supports Kanban, Gallery, Map, or a third-party `` without any root changes. +- **No new dependency.** TanStack Table is already used by `DataTable`; this RFC only restructures how its output is consumed. + +## Pros and Cons + +### Pros + +- **Code reuse**: One data layer serves every renderer. Eliminates the duplicate-or-fork tax on new presentation formats. +- **Consistent UX**: Filters, search, sort, grouping, and display-visibility work identically across Table/List/Timeline/Custom. +- **Cross-view state persistence**: Users can toggle between renderers in the same `` without losing query state. +- **Unified visibility story**: One `DisplayControls` component drives both columnar (Table/List) and non-columnar (Timeline, Custom) renderers via ``. +- **Cleaner separation of concerns**: Props live where they're read — renderer knobs on the renderer, data-layer concerns on the root. +- **Non-breaking migration path**: `DataTable` stays as an alias; most work is additive (new renderers), not refactor. +- **Richer grouping**: Function resolvers unlock "group by week", "group by status bucket", etc. — impossible with today's accessor-only API. +- **Future-proof**: `DataView.Custom` + `DisplayAccess` handle any future renderer without touching the root type. + +### Cons + +- **Surface area grows**: Three new renderer components (List, Timeline, Custom) plus `DisplayAccess` need to be designed, documented, and tested. +- **Two API shapes at once**: During migration, both `DataTable` (alias) and `DataView` (new) co-exist. Cognitive cost for consumers until the alias is removed. +- **Timeline complexity**: Two-axis virtualization + lane packing + time-axis math is genuinely new code (~15 lines for the packer, plus the axis component and virtualization glue). +- **DisplayAccess adoption**: Consumers building Timeline/Custom renderers must remember to wrap fields in ``, or the Display Properties toggle silently no-ops for those renderers. Mitigated by a dev warning at mount. +- **Shared column-spec shape invites visual drift**: Table and List share the spec but need intentionally different chrome. Risk of the two bleeding into each other visually; handled by design-review per renderer and token-driven styling. + +## Differences and Analysis + +### General Differences from DataTable + +#### Root Owns Data, Renderers Own Presentation + +- `DataTable`: data props, render specs, and rendering all live on a single component. +- `DataView`: root takes only data-layer props (`data`, `fields`, `defaultSort`, `query`, `mode`, `isLoading`, `onQueryChange`, `onLoadMore`, `onItemClick`, ...). Each renderer subcomponent takes its own render spec (`columns` for Table/List; `startField`/`endField`/`renderBar` for Timeline). + +```tsx +// Before + + + + + +// After + + + + + + + + +``` + +> [!NOTE] +> **Analysis** +> +> Props live where they're read. `columns` is consumed only by column-based renderers (Table, List); `renderBar` only by Timeline. Declaring them on each renderer is the natural React shape and keeps the root type small. + +#### `columns` Renamed to `fields` on Root + +- `DataTable`: `columns` prop mixed field metadata (filterable, sortable, groupable) with cell/header renderers. +- `DataView`: `fields` on root carries only presentation-agnostic metadata. Cell/header renderers live on `columns` declared per-renderer (`DataView.Table`, `DataView.List`). + +```ts +// Field — presentation-agnostic +interface DataViewField { + accessorKey: Extract; + label: string; + filterable?: boolean; + filterType?: FilterTypes; + sortable?: boolean; + groupable?: boolean; + hideable?: boolean; + defaultHidden?: boolean; + // ... filter capability, group presentation +} + +// Renderer column — pure reference + cell rendering +interface DataViewTableColumn { + accessorKey: Extract; // pointer into fields[] + cell?: ColumnDef['cell']; + header?: ColumnDef['header']; + classNames?: { cell?: string; header?: string }; + styles?: { cell?: CSSProperties; header?: CSSProperties }; +} +``` + +> [!NOTE] +> **Analysis** +> +> Disambiguates metadata (shared across renderers) from render spec (per-renderer). Also renames `enableColumnFilter` → `filterable` et al. to drop table-speak. Old prop names can be kept as aliases for one release. + +#### Explicit Toolbar Composition + +- `DataTable`: `` auto-renders `Filters + DisplaySettings`; `Search` is a separate peer. +- `DataView`: user composes children explicitly. + +```tsx +// Before — Toolbar is opaque + + + +// After — explicit composition + + + + + {/* user can also add: , bulk-action chips, etc. */} + +``` + +> [!NOTE] +> **Analysis** +> +> Lets consumers place search outside the toolbar (common in master-detail layouts), add custom actions (bulk actions, "Export", "New"), and reorder elements. Small cost in verbosity; large gain in flexibility. + +#### Unified Column Visibility via `DisplayAccess` + +- `DataTable`: column visibility is local TanStack state; only the `` renderer reacts to it. +- `DataView`: `columnVisibility` + `setColumnVisibility` are lifted onto context. Table and List gate columns internally (hidden grid tracks / `` collapse). Timeline and Custom use `` to wrap any JSX and reactively hide/show it. + +```tsx +// Inside a Timeline bar + ( + + + {row.getValue('priority')} + + + {row.getValue('title')} + + + )} +/> +``` + +> [!NOTE] +> **Analysis** +> +> Without this primitive, non-columnar renderers would each need a bespoke visibility mechanism — or the toggle would silently no-op. `DisplayAccess` is the one cross-renderer primitive consumers compose inside `renderBar` or custom renderers. Table/List don't need it at the call site — their `columns` already carry `accessorKey`, so the renderer gates visibility internally from the same context state. A dev warning fires at mount if a `hideable: true` field is referenced by neither a column spec nor any DisplayAccess instance. + +#### Virtualization as a Prop, Not a Component + +- `DataTable`: exports both `DataTable.Content` and `DataTable.VirtualizedContent` as separate components. +- `DataView`: virtualization is a prop on the renderer. + +```tsx +// Before + + +// After + +``` + +> [!NOTE] +> **Analysis** +> +> Cleaner API. Both exports can coexist during migration. + +### Renderer-Specific Differences + +#### Table + +- `DataTable.Content`: renders `` with `flexRender(columnDef.cell)` per cell, `columnDef.header` per header. +- `DataView.Table`: same renderer internals, same `flexRender` pipeline, same sticky group header, same virtualization. Difference is the source of its column spec (a local `columns` prop, not a root prop) and that it reads context for query state / visibility. + +> [!NOTE] +> **Analysis** +> +> Behaviorally unchanged. The extraction is mechanical. + +#### List + +- `DataTable`: no list renderer exists today; consumers roll their own. +- `DataView.List`: shares the column-spec shape with Table (drops `header`, adds `width`). Renders rows inside a CSS `grid` container; each row is `display: subgrid; grid-column: 1 / -1` so cells align vertically across rows. + +```ts +interface DataViewListColumn { + accessorKey: Extract; + cell?: ColumnDef['cell']; + width?: string | number; // CSS grid track — '1fr' | '200px' | 'auto' | 'minmax(80px, 1fr)' | number(px) + classNames?: { cell?: string }; + styles?: { cell?: CSSProperties }; +} +``` + +> [!NOTE] +> **Analysis** +> +> Column visibility works identically — `table.getVisibleLeafColumns()` already respects `ctx.columnVisibility`, so toggling a column off collapses its grid track across every row with no extra code. Swapping `` ↔ `` is close to a tag change. + +#### Timeline + +- `DataTable`: no timeline renderer exists. +- `DataView.Timeline`: variable-width range bars on a continuous time axis. Uses `renderBar(row)` because a shared `grid-template-columns` can't fit both 1-day and month-long bars. Visibility inside the bar is composed via ``. + +```tsx +) => ReactNode + scale?: 'day' | 'week' | 'month' | 'quarter' + today?: boolean | Date + lanePacking?: 'auto' | 'one-per-row' + rowHeight?: number + laneGap?: number + viewportRange?: [Date, Date] + onViewportChange?: (range: [Date, Date]) => void + renderLaneGroup?: (group: GroupedData) => ReactNode +/> +``` + +> [!NOTE] +> **Analysis** +> +> Timeline bypasses `groupData` and buckets internally (horizontal pixel math). Lane packing is a small, pure utility (`packLanes`, ~15 lines of greedy interval scheduling). Two-axis virtualization (time × lanes) is solvable with one `useVirtualizer` per axis. None of this leaks into the data layer. + +#### Custom + +- `DataTable`: escape hatch requires consumers to build their own root. +- `DataView.Custom`: receives the full context as a render prop argument; users emit any DOM they like. + +```tsx + + {({ rows, fields, tableQuery, updateTableQuery, columnVisibility, ... }) => ( + updateTableQuery(q => ({...q, ...}))} /> + )} + +``` + +> [!NOTE] +> **Analysis** +> +> Keeps the root surface small while supporting unbounded future renderers (Kanban, Gallery, Map, etc.). Third-party renderers compose cleanly via the same pattern. + +#### Grouping + +- `DataTable`: `group_by` is a list of `accessorKey` strings. `groupData()` groups by exact field value. +- `DataView`: `group_by` strings stay on the wire (so server-mode is unchanged), but an optional `groupByResolvers: Record string>` map on root lets string ids resolve to functions locally. Timeline can bypass `groupData` entirely and bucket by its own pixel math. + +```ts +// Root prop +groupByResolvers?: Record string>; + +// Example: "group by week of createdAt" +groupByResolvers={{ + createdAt_week: (row) => dayjs(row.createdAt).startOf('week').format('YYYY-[W]WW'), +}} +``` + +> [!NOTE] +> **Analysis** +> +> Keeps the wire format (`group_by: string[]`) intact while unlocking non-accessor buckets. Renderers that need bespoke grouping (Timeline) can ignore `group_by` and do their own thing — they still read the same filtered `rows`. + +## Table of Comparison + +| Concern | Today (`DataTable`) | Proposed (`DataView`) | +| :--- | :--- | :--- | +| Query state (`filters`, `sort`, `group_by`, `search`) | In `DataTable` | On `DataView` root context (unchanged shape) | +| Filter predicates (`filterOperationsMap`) | In `DataTable` | Reused as-is | +| Client/server mode | `mode: 'client' \| 'server'` | Same | +| Row model engine | TanStack Table | TanStack Table (unchanged) | +| Column/field metadata | `columns` prop | `fields` prop on root | +| Table cell/header renderers | `columns` prop | `columns` on `DataView.Table` | +| List renderer | Not available | `DataView.List` with grid/subgrid | +| Timeline / Gantt renderer | Not available | `DataView.Timeline` with `renderBar` | +| Custom renderer | Not supported | `DataView.Custom` | +| Toolbar composition | `` auto-renders Filters + DisplaySettings | Explicit children: `Search`, `Filters`, `DisplayControls`, custom actions | +| Virtualization | `DataTable.VirtualizedContent` (separate export) | `virtualized` prop on each renderer | +| Column visibility | TanStack local state, Table-only | Lifted to context; Table/List gate internally, Timeline/Custom use `` | +| Grouping | `group_by: string[]`, accessor-only | Same wire format + optional `groupByResolvers` map | +| Sticky group header | `stickyGroupHeader` on root | Prop on `DataView.Table` (not in shared context) | +| Empty vs zero state | Per-renderer implementation | `emptyState` / `zeroState` props on each renderer; decision driven by root-computed `hasActiveQuery` | +| Infinite scroll (`onLoadMore`, `totalRowCount`) | `DataTable` | `DataView` root (renderer-independent); each renderer detects bottom-reached | + +## Impact + +- **1 component replaced + 4 new renderers added.** `DataTable` becomes a thin alias over `` + `` during migration. +- **Consumers unlock non-tabular presentations with the same data layer.** Existing tabular usage is near-zero-change; list/timeline/custom views are net-new capabilities. +- **Prop surface grows on the renderer side, shrinks on the root side.** Net result is clearer separation of concerns. +- **Breaking changes for deep imports only.** Public `` keeps working through at least one major version. + +## Discarded Approaches and Considerations + +Several alternatives were evaluated and rejected, and a handful of ideas were deliberately scoped out of the root type. Capturing them here so the decisions don't have to be re-litigated. + +### Rejected Alternatives + +- **Hand-rolled query state + predicates (no TanStack).** Would mean reimplementing filter operators, stable sort, filter-from-leaf-rows, and expanded sub-rows. Pure churn for no user-visible gain, and TanStack Table is already a dependency. Kept as the engine. +- **`useReactTable` only for `DataView.Table`; bespoke hooks for List/Timeline.** Forks the filter-predicate path per renderer, and cross-renderer switches (Table ↔ List toggle) would have to re-derive state. Defeats the whole reason for extracting the data layer. Rejected. +- **`ag-grid` / `react-data-grid` / `material-react-table`.** Heavy, opinionated renderers that aren't pluggable at the DOM level. Wrong fit for a headless-core-plus-swappable-renderers architecture. +- **Putting renderer row specs (`columns`, `renderBar`) on the `DataView` root.** Considered for symmetry with today's `DataTable`. Rejected because each spec is consumed by exactly one renderer — declaring them on the root inflates the root type and makes third-party renderers (e.g. ``) awkward. Props live where they're read. +- **Unifying Timeline's bar layout under the Table/List column spec.** Considered because it would mean one spec shape for all three renderers. Rejected because bars are variable-width: a `grid-template-columns` that fits a month-long bar overflows a 1-day bar. Timeline uses `renderBar` + `` instead, which gives the same visibility story without forcing a column grid onto bar content. +- **Making Display Properties visibility a per-renderer concern.** Considered because Timeline/Custom are structurally different from columnar renderers. Rejected because users expect one Display Properties toggle to work everywhere; otherwise the control silently no-ops on non-columnar views. `` (one context-reading wrapper) solves this with ~10 lines of shared code. + +### Scoped-Out Decisions + +- **`DataView.VirtualizedContent` as a separate export** — folded into ``. Two exports for the same renderer was redundant. +- **`stickyGroupHeader` on shared context** — kept as a prop on `DataView.Table` only. It's a table-DOM concern and doesn't apply to List/Timeline/Custom; polluting context with Table-only knobs would invite similar leakage for every new renderer. +- **Implicit zero-state / empty-state rendering inside renderers** — moved to explicit `emptyState` / `zeroState` props on each renderer, with the empty-vs-zero decision driven by root-computed `hasActiveQuery`. Today's `Content` and `VirtualizedContent` duplicate this branch; consolidating it removes drift. +- **Recomputing `shouldShowFilters` from `table.getRowModel()`.** Dropped the `try/catch` around a TanStack call inside a data-layer check. Computed from `data.length + filters.length + search` instead — keeps the data layer free of rendering-engine hooks. +- **`onItemDrag` / `onResize` on Timeline (editable Gantt).** Out of scope for v1. Completely renderer-local when added later — doesn't touch `DataView` root. +- **Responsive hiding inside Timeline bars** (hide subtitle when bar is narrow). Separate concern from user-driven visibility; solved by container queries or a priority-aware wrapper at the call site. `DisplayAccess` is only for the Display Properties toggle, not viewport-driven hiding. +- **Backward-compat cell/header renaming.** Considered keeping `enableColumnFilter`, `enableGrouping`, etc. permanently. Decision: accept both the old and new names (`enableColumnFilter` as an alias of `filterable`) for one release, then drop the aliases. Short-term migration cost, long-term cleaner API. + +## Helpful Links + +- [TanStack Table — Headless API](https://tanstack.com/table/latest/docs/introduction) — core engine already used by `DataTable`. +- [TanStack Virtual](https://tanstack.com/virtual/latest) — virtualization library already used; supports the two-axis case needed by Timeline. +- [CSS Subgrid — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout/Subgrid) — underpins the List renderer's cross-row cell alignment. +- [Linear — Views & Layouts](https://linear.app/docs/views) — prior art for the Table/List/Timeline triad over a shared query model. +- [Notion — Databases](https://www.notion.so/help/views-filters-and-sorts) — prior art for swappable renderers driven by one filter/sort model. +- Internal reference: `.claude/worktrees/dataview/ANALYSIS.md` — feasibility + architecture analysis backing this RFC. diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx index fd7ad3371..80927c22a 100644 --- a/packages/raystack/components/data-view/data-view.tsx +++ b/packages/raystack/components/data-view/data-view.tsx @@ -231,6 +231,13 @@ function DataViewRoot({ DataViewRoot.displayName = 'DataView'; +/** + * @preview + * `DataView` is a preview component. Its API is not yet stable and + * **will have breaking changes** before the 1.0 release — prop names, + * sub-component shapes, and context surface may all change without + * following semver. Pin to exact versions if depending on it. + */ // biome-ignore lint/suspicious/noShadowRestrictedNames: public component name intentionally matches the package export export const DataView = Object.assign(DataViewRoot, { // Renderers — each takes its own row render spec (`columns` on Table/List). From de5b9f84e784eda8b1301ec34f37f327fd331602 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 23 Apr 2026 17:52:18 +0530 Subject: [PATCH 3/5] chore: remove analysis.md --- ANALYSIS.md | 1449 --------------------------------------------------- 1 file changed, 1449 deletions(-) delete mode 100644 ANALYSIS.md diff --git a/ANALYSIS.md b/ANALYSIS.md deleted file mode 100644 index 316c10ae2..000000000 --- a/ANALYSIS.md +++ /dev/null @@ -1,1449 +0,0 @@ -# DataView Component — Feasibility & Architecture Analysis - -> **Verdict:** ✅ Highly feasible. ~80% of today's `DataTable` logic is already renderer-agnostic; the proposal is largely an **extraction + renaming** exercise, not a rewrite. TanStack Table's headless core can drive Table, List, and Timeline renderers. Real work is in defining the **field / renderer contract** and extending grouping to support non-accessor buckets (e.g. time ranges). - ---- - -## 1. Problem Statement - -Today's `DataTable` (`packages/raystack/components/data-table/`) couples two layers: - -1. **A data-modeling layer** — query state (filters, sort, group, search, pagination), client-vs-server mode, row model derivation. -2. **A tabular rendering layer** — table header/body/row/cell DOM, column visibility UI, virtualization, sticky group headers. - -Non-table presentations (List, Timeline, Kanban, Gallery) would need to duplicate layer 1 unless we extract it. The proposal: a single `DataView` root that owns layer 1, plus swappable renderer subcomponents for layer 2. - -### Target API - -```tsx - - - - - - - - {/* pick one — or switch between them with a tab/toggle */} - - {/* same column shape as Table + grid `width` */} - ( - - - {row.getValue('title')} - - - {row.getValue('priority')} - - - )} - /> - {(api) => ...} - -``` - ---- - -## 2. Inventory of Current DataTable - -Source: `packages/raystack/components/data-table/` - -### 2.1 Presentation-agnostic (lives in `DataView` root/context) - -| Concern | Current location | Notes | -| --- | --- | --- | -| Query state `{ filters, sort, group_by, search, offset, limit }` | `data-table.tsx:75-76`, `data-table.types.tsx:52-60` (`InternalQuery`) | Pure state. Reusable as-is. | -| `updateTableQuery` mutator | `data-table.tsx:149-151` | Single entry point for all state changes. | -| Client↔server wire format | `utils/index.tsx:208-316` (`transformToDataTableQuery`, `dataTableQueryToInternal`) | Normalizes `ilike` wildcards, operator mapping. Reusable. | -| `mode: 'client' \| 'server'` | `data-table.types.tsx:16`, `data-table.tsx:124-136` | Switches whether TanStack does filter/sort locally or defers to backend. Renderer-independent. | -| `hasActiveQuery` / `hasQueryChanged` | `utils/index.tsx:167-195` | Distinguishes zero-state vs empty-state; detects change for `onTableQueryChange`. | -| `onLoadMore`, `totalRowCount`, `loadingRowCount`, pagination | `data-table.tsx:153-158` | Server-mode infinite scroll signals. Renderer-independent; each renderer implements its own "bottom-reached" detection. | -| `defaultSort`, `onDisplaySettingsReset` | `data-table.tsx:85-98` | Config. | -| Column-filter predicates (`filterOperationsMap`) | `utils/filter-operations.tsx:33-153` | TanStack `FilterFn` signatures. Reusable for any renderer that runs TanStack's row model. | -| Filter operator UI metadata (`filterOperators`) | `types/filters.tsx:85-117` | Already lives in `~/types/filters`, shared. | -| `groupData()` — groups flat list into `GroupedData[]` with label/count/subRows | `utils/index.tsx:71-107` | Today only groups by **accessorKey string**. Needs extension to support a `groupBy` *function* so Timeline can bucket by day/week. | -| `useFilters()` hook — add/remove/change filters | `hooks/useFilters.tsx` | Pure logic over `updateTableQuery`. Reusable. | - -### 2.2 Table-specific (belongs in `DataView.Table`) - -| Concern | Current location | -| --- | --- | -| `` / `` / `` DOM | `components/content.tsx:29-174` | -| `flexRender(columnDef.header, header.getContext())` | `content.tsx:52` | -| `flexRender(columnDef.cell, cell.getContext())` | `content.tsx:167` | -| `table.getVisibleLeafColumns()` → `colSpan` | `content.tsx:242` | -| Virtualized table layout with absolute-positioned rows | `components/virtualized-content.tsx` | -| Sticky group header positioned under table `` | `virtualized-content.tsx:267-307` | -| Skeleton loader rows (column-shaped) | `content.tsx:72-92`, `virtualized-content.tsx:160-204` | -| IntersectionObserver on last `` for server load-more | `content.tsx:212-240` | -| "N items hidden by filters" footer | `content.tsx:314-344` — arguably renderer-agnostic, but uses table metrics | - -### 2.3 Ambiguously coupled (needs explicit repositioning) - -- **`columnDef.cell` / `columnDef.header`**: return `ReactNode` with TanStack's `CellContext`/`HeaderContext`. Consumed by any renderer that lays cells out in aligned columns — i.e. both `DataView.Table` (table DOM) and `DataView.List` (CSS `grid` + `subgrid`). They share the column spec shape, differ only in visual chrome. `DataView.Timeline` is different: bars are variable-width, so a shared column grid doesn't apply; Timeline uses `renderBar(row)` with `` (§4.6) for visibility. **Decision:** keep `cell`/`header` on both Table and List column specs; Timeline uses the DisplayAccess primitive. -- **`enableColumnFilter / enableGrouping / enableSorting / enableHiding / defaultHidden`**: these describe the *field's* capability, not any particular cell. Stay on DataView root fields. Rename `enableColumnFilter` → `filterable` (keep alias) to drop table-speak. -- **Column visibility state**: meaningful for Table (hides columns), ambiguous for List (could hide metadata chips), meaningless for Timeline. **Decision:** keep visibility state in DataView root but let each renderer interpret it (or ignore it). -- **`onRowClick`**: fine as-is but conceptually "onItemClick". Keep name for continuity; rename is cosmetic. -- **`shouldShowFilters` computed from `table.getRowModel().rows.length`** (`data-table.tsx:173-185`): mixes data-layer and render-layer. Can be recomputed from `data.length + filters.length + search` without TanStack — decoupling improves clarity. - ---- - -## 3. Can TanStack Table drive non-table renderers? - -### Short answer: **Yes** — it's the right engine, with two caveats. - -TanStack Table is *headless*. The core (`@tanstack/table-core`, already a dep) produces a `table` object whose job is to turn `data + column defs + state` into a **row model**: a tree of `Row` with `.original`, `.getValue(colId)`, `.subRows`, `.getIsSelected()`, filtered/sorted/grouped/expanded per current state. Emitting DOM is entirely the caller's job. - -So for any renderer we can do: - -```tsx -const rows = table.getRowModel().rows; -// Table: rows.map(row => …cells…) -// List: rows.map(row => {columns.map(c => {c.cell(row)})}) -// Timeline: bucketByDate(rows).map(bucket => ) -``` - -All three renderers share: filter state, sort state, search state, selection state, client/server mode, load-more. **This is the whole point of the extraction.** - -### Caveat 1: `columnDef.cell` fits aligned-column layouts only - -The `cell` render function returns a `ReactNode`. Any renderer that lays cells out in aligned columns can consume it — Table wraps each call in `
`, List wraps it in a CSS `grid` cell (`display: subgrid` per row). The two share the column spec shape. **Timeline is the exception**: bars are variable-width (a 1-day bar and a month-long bar can't share a `grid-template-columns`), so Timeline accepts `renderBar(row)` and relies on `` (§4.6) to gate per-field visibility inside the bar. This keeps the visibility story uniform across all three renderers without forcing a column grid onto bar content. - -### Caveat 2: Grouping only by accessor-key - -`groupData()` (`utils/index.tsx:71`) is accessor-only. Timeline needs to bucket rows by computed keys (e.g. `dayjs(row.createdAt).format('YYYY-MM-DD')`). Fix: allow `group_by` to also be a user-supplied `(row: TData) => string` function, or let each renderer bypass `groupData` and bucket its own way (preferred: keeps root simple). - -### Why keep TanStack (vs. rolling our own)? - -- **Already a dep** and already powering DataTable. No new surface area. -- **Client-mode filter/sort row model is non-trivial** (stable sort, filter-from-leaf-rows, expanded sub-rows). Reimplementing this is pure churn. -- Users never import TanStack types directly — they import `DataViewField`, `DataViewTableColumn` — so the dep stays an implementation detail. Tree-shakes to ~20–25 kB. -- TanStack Virtual (also already a dep) works for all renderers. - -### Rejected alternatives - -- **Hand-rolled query state + predicates**: loses free row model, duplicates filter operators. Not worth it. -- **`useReactTable` only for `DataView.Table`, bespoke hooks for List/Timeline**: forks the filter-predicate path; cross-renderer switches (Table ↔ List toggle) would re-derive state. No. -- **`ag-grid`, `react-data-grid`, `material-react-table`**: heavy, opinionated renderers, not pluggable at the DOM level. Wrong fit. - ---- - -## 4. Proposed Architecture - -### 4.1 Layer cake - -``` -┌─────────────────────────────────────────────────────────────┐ -│ — owns TanStack Table instance + tableQuery │ -│ state = { filters, sort, group_by, search, offset, limit } │ -│ context exposes: table, rows, tableQuery, updateTableQuery,│ -│ fields, mode, isLoading, loadMoreData, onItemClick, … │ -└───────────────┬─────────────────────────────────────────────┘ - │ - ┌────────────┼────────────┬───────────────┬──────────────┐ - ▼ ▼ ▼ ▼ ▼ - - (reads (reads (reads/writes (renderers; each reads `rows` - search) filters) sort/group/hide) or `table` from context and - emits its own DOM) -``` - -### 4.2 Type contract - -```ts -// Data-model field — presentation-agnostic -export interface DataViewField { - accessorKey: Extract; - label: string; // was `header` string form - icon?: ReactNode; - - // filter capability - filterable?: boolean; // alias: enableColumnFilter - filterType?: FilterTypes; // number|string|date|select|multiselect - dataType?: FilterValueType; - filterOptions?: FilterSelectOption[]; - defaultFilterValue?: unknown; - filterProps?: { select?: BaseSelectProps }; - - // ordering / grouping / visibility capability - sortable?: boolean; - groupable?: boolean; - hideable?: boolean; - defaultHidden?: boolean; - - // group-header presentation (used by any renderer that groups) - showGroupCount?: boolean; - groupCountMap?: Record; - groupLabelsMap?: Record; -} - -// Table render spec — pure reference. Points at a field by `accessorKey`; -// only adds table-cell rendering. Does NOT extend DataViewField (filterable / -// sortable / filterType etc. live on the field, not duplicated here). -export interface DataViewTableColumn { - accessorKey: Extract; // pointer into fields[] - cell?: ColumnDef['cell']; // flexRender-able body cell - header?: ColumnDef['header']; // flexRender-able header cell (overrides field.label) - classNames?: { cell?: string; header?: string }; - styles?: { cell?: CSSProperties; header?: CSSProperties }; -} - -// List render spec — same column shape as Table plus a CSS-grid `width` hint. -// List renders rows inside a CSS `grid` container; each row uses `display: -// subgrid` so cells align vertically across rows (same semantic as Table -// columns, different visual chrome — no thead, looser row styling, optional -// dividers). -export interface DataViewListColumn { - accessorKey: Extract; // pointer into fields[] - cell?: ColumnDef['cell']; // flexRender-able cell - width?: string | number; // CSS grid track — '1fr' | '200px' | 'auto' | 'minmax(80px, 1fr)' | number(px) - classNames?: { cell?: string }; - styles?: { cell?: CSSProperties }; -} - -// DisplayAccess — foundational visibility primitive (see §4.6). -// Wrap any JSX with it; the wrapper reads `columnVisibility` from DataView -// context and renders children only when the named field is currently visible. -// Used inside Timeline's `renderBar` and any custom renderer. Table/List use -// column specs, so they gate visibility internally from the same state. -export interface DataViewDisplayAccessProps { - accessorKey: string; - children: ReactNode; - fallback?: ReactNode; // rendered when hidden (default: null) -} - -// Root props — data layer only. No render specs here; each renderer takes its own. -export interface DataViewProps { - data: TData[]; - fields: DataViewField[]; - defaultSort: DataViewSort; - query?: DataViewQuery; - mode?: 'client' | 'server'; - isLoading?: boolean; - totalRowCount?: number; - loadingRowCount?: number; - onQueryChange?: (query: DataViewQuery) => void; - onLoadMore?: () => Promise; - onItemClick?: (row: TData) => void; - onColumnVisibilityChange?: (v: VisibilityState) => void; - getRowId?: (row: TData, index: number) => string; - groupByResolvers?: Record string>; -} -``` - -> **Renderer row render specs (`columns` on Table/List; `startField`/`endField`/`renderBar` on Timeline; …) are declared on their renderer subcomponent, not on ``.** Rationale: they're consumed by exactly one component each, so they belong there (classic React composition). `` (§4.6) is the one cross-renderer primitive consumers compose inside `renderBar` or custom renderers so visibility state reaches free-form JSX. See §4.5. - -### 4.3 Context shape - -```ts -export interface DataViewContext { - table: Table; // full TanStack instance (renderers read rows/columns from here) - rows: Row[]; // filtered+sorted+grouped (convenience) - fields: DataViewField[]; // metadata (Filters/DisplayControls/every renderer use it) - tableQuery: InternalQuery; - updateTableQuery: (fn: TableQueryUpdateFn) => void; - mode: 'client' | 'server'; - isLoading: boolean; - loadMoreData: () => void; - defaultSort: DataViewSort; - onItemClick?: (row: TData) => void; - onDisplaySettingsReset: () => void; - shouldShowFilters: boolean; // computed from data.length + filters + search - hasActiveQuery: boolean; // drives empty-vs-zero state at each renderer - totalRowCount?: number; - loadingRowCount?: number; - columnVisibility: VisibilityState; // single source for DisplayAccess + column gating - setColumnVisibility: (v: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => void; -} -``` - -Context is strictly the **data layer**: state, derived rows, field metadata, mutators. Renderer-specific render specs are props on the renderer, not context. - -Note: the current context already matches this almost 1:1. The diff is: rename `columns` → `fields`, expose `rows` as a convenience, surface `columnVisibility` + `setColumnVisibility` so `` (§4.6) and every renderer read from the same source of truth, drop `stickyGroupHeader` (Table-only prop, not context). - -### 4.4 Data flow - -1. **Mount.** `DataView` constructs `tableQuery` from `defaultSort` + `query?` prop, builds TanStack Table over `data` + `fields` (translated to `ColumnDef`s with filter predicates wired from `filterOperationsMap`). -2. **User interacts with a toolbar control:** - - `Search` → `updateTableQuery({search})` → TanStack applies `globalFilter` (client) OR parent `onQueryChange` fires with normalized `DataViewQuery` (server). - - `Filters` → same dispatch path, updates `columnFilters`. - - `DisplayControls` → sort / group_by / columnVisibility dispatch. -3. **Row model recomputes** (memoized). `rows = table.getRowModel().rows`. -4. **Renderer reads rows from context** and emits DOM. - - `Table` renders TanStack rows as ``; columns hidden via `columnVisibility` collapse automatically. - - `List` renders each row as a CSS-grid track (`display: subgrid`) with `flexRender(cell)` per column; hidden columns collapse their grid track the same way Table collapses its `` + denser rows + borders; List has no header + looser spacing + optional dividers + card-ish row styling. Token-driven so drift is contained. | -| Backwards-compat of the existing `DataTable` consumer API | Keep `DataTable` export as an alias through at least one major version. Only `DataTableDemo`-style imports need to change (`DataTable` → `DataView`, `DataTable.Content` → `DataView.Table`). | - ---- - -## 9. Feasibility Scorecard - -| Dimension | Score | Reason | -| --- | --- | --- | -| Technical feasibility | ★★★★★ | Existing code already separates layers; TanStack is headless. | -| API ergonomics of proposal | ★★★★☆ | Clean; one gotcha (fields vs columns naming) — solvable with typing. | -| Migration cost | ★★★★☆ | Non-breaking path exists. Most work is new code (List, Timeline) not refactor. | -| Coverage of future renderers (Kanban, Gallery, Map) | ★★★★★ | `DataView.Custom` + shared context handles anything. | -| Library choice (TanStack Table Core) | ★★★★★ | Already a dep; ideal fit for headless row modeling. | -| Risk | ★★★★☆ | Timeline grouping and column-visibility semantics are the only non-trivial issues. | - ---- - -## 10. Case Study: Gantt-style Timeline (screenshot reference) - -The task-prioritisation screenshot shows a **range-based timeline / Gantt**, not a point-in-time event timeline. Properties visible in the image: - -- Continuous horizontal time axis (Jan 2025 → Feb), day-level ticks, month headers, a "today" marker (17 Jan). -- Each item is a **bar** spanning from its **start date to its end date** — width encodes duration. -- Items are **packed into lanes** (vertical rows) so they don't visually overlap. -- Each bar has a title, due date, assignee, priority chip, status icon — i.e. the same fields any renderer would read. -- Filter + Display controls sit above the canvas — a direct match for ``. - -### Does the proposed architecture support this? - -**Yes, end-to-end.** The split between data layer and renderer holds up exactly: - -| Concern in the screenshot | Where it lives | -| --- | --- | -| "Filter" button, applied filter chips, search | `` / `` — unchanged. Filters read from `fields[]` metadata and dispatch through `updateTableQuery`. | -| "Display" button (sort / group / visible properties) | `` — unchanged. | -| Data subset actually visible on the canvas | Context `rows` after filter+sort+search. The Gantt renderer consumes `rows`, not raw `data`. | -| Ordering of items/lanes | `query.sort` — the renderer reads `rows` already in sorted order. Could sort by startDate, priority, assignee. | -| Grouping into lane-bands (e.g. one band per assignee) | `query.group_by` — renderer maps group → horizontal band, packs items into lanes *within* the band. | -| Start/end positioning of each bar | Pure renderer math: `left = pxPerDay * (row.start - viewportStart)`, `width = pxPerDay * (row.end - row.start)`. Data layer doesn't need to know about pixels. | -| Lane packing (collision-free vertical placement) | Renderer-owned algorithm: greedy interval-scheduling over sorted items. | -| Time-axis ruler + today marker | Renderer-owned. | -| Virtualization (the canvas clearly extends beyond the viewport in both axes) | Renderer-owned, two-axis: time-window virtualization + lane virtualization. TanStack Virtual handles each axis. | - -### What the Timeline renderer needs to expose - -A point-timeline shape (single date field) is insufficient — the screenshot forces a more general range-based shape: - -```tsx - void - today?: Date | boolean // draw today line (defaults: true, new Date()) - - // Layout - lanePacking?: 'auto' | 'one-per-row' // auto = greedy pack to avoid overlap - laneGap?: number - rowHeight?: number - - // Rendering — bars are variable-width, so cells can't share a grid. - // Compose freely; wrap each field in so - // "Display Properties" toggles apply to bar content the same way they - // apply to Table/List columns. - renderBar: (row: Row) => ReactNode - renderLaneGroup?: (group: GroupedData) => ReactNode // band header when grouped - - // Interaction - onItemClick?: (row: TData) => void // inherits from context if omitted - // future: onItemDrag, onResize for editable Gantt -/> -``` - -### Why this still fits the shared-context story - -Nothing above touches the data layer. Filtering out low-priority items with `` simply shrinks `rows` — the Gantt renderer re-packs lanes. Switching from Gantt to Table (in a multi-renderer ``) preserves filters, search, sort, selection, and column-visibility state. Grouping by assignee in the Display panel turns lanes into assignee-bands. Toggling "Priority" off in Display Properties hides the `` inside the bar because it's wrapped in `` — same toggle that hides the "Priority" column in Table/List. - -### What the Gantt renderer adds that List/Table don't - -1. **Two-axis virtualization** (time × lanes). Non-trivial but solvable; TanStack Virtual instance per axis, or a 2D library. -2. **Interval-scheduling lane packer.** ~15 lines, independent module, testable. -3. **Date-range operations on filters.** Already in `filterOperationsMap.date`. A "due this week" filter works as-is. -4. **Editable bars (future).** Drag to change start, resize to change end → callbacks that dispatch back into the parent's data source. Completely renderer-local; doesn't leak into DataView root. - -### Risks specific to this view - -| Risk | Mitigation | -| --- | --- | -| Items without an `endField` value | Default to point marker, or treat as same-day bar (`width = minBarWidth`). | -| Items with `start > end` (data bugs) | Renderer decides: swap, clamp, or hide + warn. Doesn't affect query state. | -| Extremely wide time range with sparse items | Timeline computes auto-viewport to fit `min(startField) → max(endField)` of filtered rows. | -| Screen text in bars overflowing (visible in screenshot — bars at viewport edges are clipped) | Rendering concern — standard text-overflow handling inside the bar. | -| Heavy re-renders when dragging | Renderer memoizes lane layout per `(rows, viewport)` tuple. | - -### Bottom line - -The screenshot is the **strongest validation** of the DataView proposal, not a counter-example. It exercises every toolbar primitive the proposal adds (`Search`, `Filters`, `DisplayControls` = "Filter" + "Display" in the image), requires no changes to the data model, and isolates the view-specific complexity (lane packing, time-axis math, two-axis virtualization) inside ``. If a tabular view of the same tasks were needed tomorrow, swapping `` for `` under the same `` root would show the same filtered/sorted/grouped tasks with zero logic rewiring — which is exactly the goal. - ---- - -## 11. Case Study: Uniform List view (screenshot reference) - -The list screenshot (avatars + name/subheading + label chip + collaborator stack + status) is structurally a **row of aligned cells** — the same mental model as Table, just with different chrome. Using CSS `grid` on the list container + `display: subgrid` per row, cells align vertically across rows without a `
flexRender(cell)
`. - - `Timeline` skips `groupData`; instead reads filtered `rows`, positions each item by `startField`/`endField`, lane-packs to avoid overlap, and calls `renderBar(row)` — inside which any `` reads the same `columnVisibility` state. - - `Custom` receives the whole context and does anything. - -### 4.5 Renderer-specific prop shapes - -**Rule:** each renderer owns its row render spec + view knobs. Only data-layer concerns live on ``. Props live where they're read. - -**Table and List share the column-spec shape** — both lay cells out in aligned columns (Table uses table DOM; List uses CSS `grid` + `subgrid`). Column visibility flows through both automatically because each column is keyed by `accessorKey` and the renderer reads `columnVisibility` from context. **Timeline is structurally different** — bars are variable-width — so it uses `renderBar(row)` and relies on `` (§4.6) to gate field visibility inside bars. - -```tsx -// Table — aligned columns in table DOM -[] // required — per-column cell / header renderers - virtualized? - rowHeight? groupHeaderHeight? overscan? - stickyGroupHeader? - emptyState? zeroState? - classNames? -/> - -// List — aligned columns in CSS grid/subgrid; same data shape as Table, different chrome -[] // required — per-column cell + grid `width` - virtualized? // needs constant or estimated rowHeight - rowHeight? overscan? - showDividers? - showGroupHeaders? // when group_by is active - emptyState? zeroState? - classNames? -/> - -// Timeline — variable-width bars on a time axis - // required — drives bar X position - endField?: Extract // omitted → point marker; present → Gantt bar - renderBar: (row: Row) => ReactNode // required — compose with for visibility - laneField?: Extract // optional — partition into bands per group value - scale? // 'day' | 'week' | 'month' | 'quarter' - today? // boolean | Date - lanePacking? // 'auto' | 'one-per-row' - rowHeight? laneGap? overscan? - viewportRange? onViewportChange? - renderLaneGroup?: (group: GroupedData) => ReactNode // band header - emptyState? zeroState? - classNames? -/> - -// Custom — escape hatch; render lives in children - - {({ rows, fields, tableQuery, updateTableQuery, columnVisibility, ... }) => ReactNode} - -``` - -**Why this split:** - -- **Props live where they're read.** `columns` is consumed only by the column-based renderers; `renderBar` only by Timeline. Declaring them on each renderer is the natural React shape. -- **Shared column shape (Table ↔ List) is the ergonomic win.** Same mental model for declaring a row of cells; swapping renderers is close to a tag change. `DataViewTableColumn` adds `header`; `DataViewListColumn` adds grid `width`. Otherwise identical. -- **Timeline uses DisplayAccess, not columns.** Bars are variable-width, so a shared `grid-template-columns` can't apply — a template that fits a month bar overflows a 1-day bar. `` (§4.6) gives Timeline the same visibility story as Table/List without forcing a column grid onto bar content. -- **Root stays lean.** `` props describe the data; no renderer slots. -- **Third-party renderers compose cleanly.** A `` reads from context and defines its own prop surface; it uses `` for visibility without needing a slot on the root type. - -**Renderer-switcher UI:** userland holds the specs and passes them conditionally. Toolbar/filters/search/sort persist across switches because they live on context, untouched. - -```tsx -{view === 'table' && } -{view === 'list' && } -{view === 'timeline' && } -``` - -### 4.6 DisplayAccess — foundational visibility primitive - -One component that gates its children on the current `columnVisibility` state in context: - -```tsx - - {row.getValue('priority')} - -``` - -Semantics: - -1. Reads `ctx.columnVisibility[accessorKey]` (defaults to `true` if the field has never been toggled). -2. Renders `children` when visible, `fallback` (default `null`) when hidden. -3. Renderer-agnostic. Works inside any JSX a renderer emits — Timeline bars, custom Kanban cards, map tooltips, detail drawers. - -```tsx -// Typical use inside a Timeline bar - ( - - - {row.getValue('title')} - - - {formatDate(row.getValue('dueAt'))} - - - )} -/> -``` - -**Why this primitive exists.** Without it, non-columnar renderers (Timeline bars, custom) have no way to react to the single Display Properties toggle — each would need a bespoke visibility mechanism, or DisplayOptions would silently fail to affect their content. `DisplayAccess` is the one place where the data layer's visibility state reaches a free-form render function. - -**Role in Table/List.** Table and List don't need it at the call site — `columns` already declares each cell's `accessorKey`, so the renderer gates the column internally (hiding the whole grid track or `` when invisible). Consumers can still drop `DisplayAccess` inside a `cell` renderer for sub-field visibility (e.g. hide a secondary label inside a "name" cell), but the common case is handled automatically. - -**Dev diagnostics.** If a field appears in `fields[]` with `hideable: true` but is referenced by **neither** a column spec **nor** a `` (across any mounted renderer), log a dev warning at mount — the toggle would be a silent no-op. Cheap: one pass over fields × columns at mount, plus a registration callback from each `DisplayAccess` instance. - -**Complementary, not a replacement.** Responsive hiding inside Timeline bars (hide subtitle when a bar is narrow) is a separate concern — solved by container queries or a `priority`-aware wrapper. `DisplayAccess` only handles user-driven visibility from DisplayOptions. - ---- - -## 5. What to Improve vs Drop - -### Improvements -1. **Explicit composition of Search in Toolbar.** Today `Toolbar` auto-renders `Filters + DisplaySettings`; `Search` is a peer but not included. Proposed API makes the user compose them, which lets consumers: - - Place search outside the toolbar (common in master-detail layouts). - - Add custom toolbar children (bulk actions, "Export", "New"). -2. **Rename `columns` → `fields` at the root.** Disambiguates from renderer columns. `DataView.Table` still takes `columns` which are fields + cell/header renderers. -3. **Group-by function support.** Extend `InternalQuery.group_by` so a non-accessor resolver can be named. Unlocks Timeline bucketing, "updated this week / earlier" grouping in List, etc. -4. **Unified Display Properties across all renderers.** With `` (§4.6) as the visibility primitive and `columnVisibility` on context, every renderer honors the same toggle uniformly — Table/List via column specs (hidden tracks collapse), Timeline via `DisplayAccess` wrappers inside `renderBar`. `DisplayControls` stays a single component; no capability gating required. -5. **Recompute `shouldShowFilters` from data-layer only.** Remove the try/catch around `table.getRowModel()`. -6. **Virtualization as a prop, not a separate component.** `` is cleaner than two exported components. Can keep both during migration. - -### Drop / simplify -1. **`DataView.VirtualizedContent` as separate export** — fold into `DataView.Table virtualized`. -2. **`stickyGroupHeader` from context** — it's a Table renderer prop; doesn't need to be in shared context. -3. **Implicit zero-state rendering inside renderers** — push the zero/empty-state decision up to the root or expose as a `` slot, so it's consistent across renderers instead of each renderer re-implementing the logic (today `Content` and `VirtualizedContent` duplicate the `hasActiveQuery` branch). - -### Keep as-is -- All of `utils/filter-operations.tsx`. -- `useFilters` hook. -- `transformToDataTableQuery` / `dataTableQueryToInternal` (rename types to `DataViewQuery`). -- `mode: 'client' | 'server'` semantics. - ---- - -## 6. Data Model Summary - -Unchanged core query shape (just renamed): - -```ts -// Wire format (what parents pass in / receive back) -export interface DataViewQuery { - filters?: DataViewFilter[]; // each: { name, operator, value, stringValue?, numberValue?, boolValue? } - sort?: DataViewSort[]; // each: { name, order: 'asc' | 'desc' } - group_by?: string[]; // field accessor or resolver key - search?: string; - offset?: number; - limit?: number; -} - -// Internal (with UI metadata) -export interface InternalQuery { - filters?: InternalFilter[]; // adds _type, _dataType for operator picker - sort?: DataViewSort[]; - group_by?: string[]; - search?: string; - offset?: number; - limit?: number; -} -``` - -Row model state (TanStack-owned, exposed via `context.table` + `context.rows`): -- `globalFilter` ← `query.search` -- `columnFilters` ← `query.filters` -- `sorting` ← `query.sort` -- `expanded` ← true when grouping active -- `columnVisibility` ← local state + `onColumnVisibilityChange` callback - ---- - -## 7. Migration Path - -Recommended phased rollout to avoid a breaking "big bang": - -**Phase 0 — rename internals (non-breaking):** -- Extract everything presentation-agnostic in `data-table/` into a `data-view/` package, re-export `DataTable` from there as a thin alias (`` with `` only). -- Rename types: `DataTableQuery` → `DataViewQuery`, `TableContextType` → `DataViewContext`, etc. Keep old names as type aliases. - -**Phase 1 — add renderer primitives:** -- Surface `columnVisibility` + `setColumnVisibility` on context (today it's a local TanStack state; just lift it for `DisplayAccess`). -- Ship `` (tiny component — reads from context, gates children). -- Ship `` (reuses Table column spec shape, adds `width`, CSS grid/subgrid) and ``. -- Add `fields` as an alternative to `columns` on root (accept either; translate internally). - -**Phase 2 — Timeline:** -- Ship ``: lane-packer util, time-axis component, `renderBar` contract. -- Extend `groupData` to accept function resolvers, OR let Timeline bypass `groupData` and bucket internally (preferred). -- Optionally: sugar wrapper `` for Table ↔ List ↔ Timeline toggle inside a single ``. - -**Phase 3 (breaking, optional):** -- Remove old `DataTable` aliases one minor version later, after consumers migrate. - ---- - -## 8. Risks & Open Questions - -| Risk | Mitigation | -| --- | --- | -| Consumers of Timeline/custom renderers forget to wrap fields in ``, making DisplayOptions a silent no-op | Dev warning at mount when a `hideable: true` field is referenced by neither a column spec nor any DisplayAccess instance (§4.6). Documented prominently. | -| Custom grouping function in Timeline breaks `InternalQuery.group_by: string[]` contract | Introduce a `groupByResolvers` map on root: keys are string ids (go into query) but resolve to functions locally. Wire format stays a string. | -| Column visibility and Timeline interact weirdly | Resolved by `` — Timeline bars wrap each field in DisplayAccess, hidden fields disappear from the bar exactly like hidden columns disappear from Table/List. One toggle drives all three. | -| Perf: large unvirtualized List/Timeline | Each renderer must have a virtualized variant. `rowHeight` prop on List (grid track height); Timeline buckets are rendered, items inside a lane virtualize. | -| Multi-renderer mode (user toggles Table↔List↔Timeline in one DataView) | Already free — all renderers consume shared context. Filter/sort/search persist across switches. This is actually a feature, not a risk. | -| Keeping Table and List visually distinct despite sharing the column spec | Design review per renderer: Table has `
`. The column spec is the natural fit; the `width` hint maps straight to `grid-template-columns`. - -### Mapping - -```tsx - - - - - - - - - -``` - -where the column spec mirrors Table (no `header`, adds `width`): - -```tsx -const userListColumns: DataViewListColumn[] = [ - { - accessorKey: 'identity', - width: '1fr', - cell: ({ row }) => ( - - - - {row.original.name} - {row.original.subheading} - - - ), - }, - { - accessorKey: 'label', - width: 'auto', - cell: ({ row }) => ( - }>{row.getValue('label')} - ), - }, - { - accessorKey: 'collaborators', - width: 'auto', - cell: ({ row }) => , - }, - { - accessorKey: 'status', - width: '120px', - cell: ({ row }) => , - }, -]; -``` - -`identity` is a synthetic column key that reads multiple fields from `row.original` — totally fine; the `accessorKey` just needs to match an entry in `fields[]` (a computed field with `hideable: false` if you don't want users toggling the avatar/name/subheading as one block). Or split it into three real columns for individual toggle control. - -### What the shared context gives for free - -- Filter "status = Draft" shrinks the list — renderer unchanged. -- Sort by name reorders rows via ``. -- Group by status produces "Draft / Published / Archived" section headers (same `groupData` path as Table). -- Search narrows rows through `globalFilter`. -- **Display Properties toggle a column off → its grid track collapses**, identical to Table. No extra wiring. No per-renderer visibility story to maintain. -- Infinite scroll via `loadMoreData` when bottom enters viewport — identical pattern to Table, just a different observer target. - -### Why columns (not slots, not renderItem) - -- **Native visibility**: each cell's `accessorKey` is explicit, so DisplayOptions works the same way it does in Table. -- **Vertical alignment across rows**: CSS subgrid keeps avatars/statuses aligned across every row — what the screenshot shows. A free-form `renderItem` per row can't guarantee that without consumers hard-coding widths themselves. -- **One API to learn**: Table↔List is a tag swap (drop `header`, add `width`, rename the tag), not a new render contract. - -### Visual chrome difference from Table - -Same column shape, intentionally different CSS: - -| | Table | List | -| --- | --- | --- | -| Container element | `
` | `
` with `display: grid` + `grid-template-columns: ...` | -| Row element | `
` | `
` with `display: subgrid; grid-column: 1 / -1` | -| Header row | `
` | *none* (List has no column headers) | -| Separators | cell borders | optional `showDividers` between rows | -| Default density | compact | looser spacing; card-ish rows | -| `stickyGroupHeader` | supported | not supported (v1) | - ---- - -## 12. Implementation Examples - -Full end-to-end examples for each renderer using the root-owned spec shape from §4.5. Each subsection shows: (1) field metadata, (2) the renderer's row render spec, (3) full consumer JSX, (4) a sketch of the renderer's internals. - ---- - -### 12.1 Table - -Consumer-facing tasks table. Exercises filtering, sorting, grouping, column visibility, virtualization, sticky group headers. - -#### 12.1.1 Field metadata - -```ts -// types.ts -export type Task = { - id: string; - title: string; - status: 'backlog' | 'in_progress' | 'review' | 'done'; - priority: 'P0' | 'P1' | 'P2' | 'P3'; - assignee: string; - createdAt: string; - dueAt: string; -}; - -// fields.ts -import type { DataViewField } from '@raystack/apsara'; -import type { Task } from './types'; - -export const taskFields: DataViewField[] = [ - { accessorKey: 'title', label: 'Title', filterable: true, filterType: 'string', sortable: true }, - { accessorKey: 'status', label: 'Status', filterable: true, filterType: 'select', - filterOptions: [ - { label: 'Backlog', value: 'backlog' }, - { label: 'In progress', value: 'in_progress' }, - { label: 'Review', value: 'review' }, - { label: 'Done', value: 'done' }, - ], - sortable: true, groupable: true, showGroupCount: true, hideable: true, - }, - { accessorKey: 'priority', label: 'Priority', filterable: true, filterType: 'select', - filterOptions: [ - { label: 'P0', value: 'P0' }, { label: 'P1', value: 'P1' }, - { label: 'P2', value: 'P2' }, { label: 'P3', value: 'P3' }, - ], - sortable: true, groupable: true, hideable: true, - }, - { accessorKey: 'assignee', label: 'Assignee', filterable: true, filterType: 'string', sortable: true, hideable: true }, - { accessorKey: 'createdAt', label: 'Created', filterable: true, filterType: 'date', sortable: true, hideable: true, defaultHidden: true }, - { accessorKey: 'dueAt', label: 'Due', filterable: true, filterType: 'date', sortable: true, hideable: true }, -]; -``` - -#### 12.1.2 Table columns (the row render spec) - -```tsx -// table-columns.tsx -import type { DataViewTableColumn } from '@raystack/apsara'; -import { Checkbox, Flex, Text, Avatar } from '@raystack/apsara'; -import dayjs from 'dayjs'; -import { StatusChip, PriorityBadge } from './atoms'; -import type { Task } from './types'; - -export const taskTableColumns: DataViewTableColumn[] = [ - { - accessorKey: 'title', - cell: ({ row }) => ( - - row.toggleSelected(!!v)} /> - {row.getValue('title')} - - ), - styles: { cell: { minWidth: 240 }, header: { minWidth: 240 } }, - }, - { - accessorKey: 'status', - cell: ({ row }) => , - styles: { cell: { width: 120 }, header: { width: 120 } }, - }, - { - accessorKey: 'priority', - cell: ({ row }) => , - styles: { cell: { width: 80 }, header: { width: 80 } }, - }, - { - accessorKey: 'assignee', - cell: ({ row }) => , - styles: { cell: { width: 120 }, header: { width: 120 } }, - }, - { - accessorKey: 'dueAt', - cell: ({ row }) => {dayjs(row.getValue('dueAt')).format('MMM D')}, - styles: { cell: { width: 100 }, header: { width: 100 } }, - }, -]; -``` - -#### 12.1.3 Full consumer usage - -```tsx -// tasks-page.tsx -import { DataView } from '@raystack/apsara'; -import { taskFields } from './fields'; -import { taskTableColumns } from './table-columns'; - -export function TasksPage() { - const { data: tasks = [], isLoading } = useTasksQuery(); - - return ( - - data={tasks} - fields={taskFields} - defaultSort={{ name: 'dueAt', order: 'asc' }} - mode="client" - isLoading={isLoading} - getRowId={row => row.id} - onItemClick={task => navigate(`/tasks/${task.id}`)} - > - - - - - - - } heading="No matching tasks" />} - zeroState={} heading="No tasks yet" />} - /> - - ); -} -``` - -#### 12.1.4 Renderer internal sketch - -```tsx -// packages/raystack/components/data-view/renderers/table.tsx -'use client'; -import { flexRender } from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useRef } from 'react'; -import { cx } from 'class-variance-authority'; -import { Table } from '../../table'; -import { useDataView } from '../hooks/useDataView'; -import styles from '../data-view.module.css'; - -export function DataViewTable({ - columns, - virtualized = false, - rowHeight = 40, - groupHeaderHeight, - overscan = 5, - stickyGroupHeader = false, - emptyState, - zeroState, - classNames, -}: DataViewTableProps) { - const ctx = useDataView(); - - // Merge columns (presentation) with fields (metadata) at render time. - // Context's TanStack table was built from fields only; here we install - // cell/header renderers from `columns` keyed by accessorKey. - const table = useTableWithColumns(ctx.table, ctx.fields, columns); - const rows = table.getRowModel().rows; - - if (!rows.length && !ctx.isLoading) { - return
{ctx.hasActiveQuery ? emptyState : zeroState}
; - } - - return virtualized - ? - : ; -} - -function PlainTableBody({ table, rows, onItemClick, classNames }) { - return ( -
- - {table.getHeaderGroups().map(hg => ( - - {hg.headers.map(h => ( - {flexRender(h.column.columnDef.header, h.getContext())} - ))} - - ))} - - - {rows.map(row => - row.subRows?.length - ? - : ( - onItemClick?.(row.original)} - className={cx(onItemClick && styles.clickable)}> - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ), - )} - -
- ); -} - -// VirtualTableBody omitted for brevity — same shape as today's virtualized-content.tsx, -// but reads `spec` from context instead of a `columns` prop. -``` - ---- - -### 12.2 List - -Matches the List screenshot (§11). CSS `grid` + `subgrid` gives Table-style column alignment without the ``; same column spec as Table, plus a `width` per column driving the grid track. - -#### 12.2.1 Field metadata - -```ts -// types.ts -export type Profile = { - id: string; - name: string; - subheading: string; - avatarUrl: string; - label: string; - collaborators: { id: string; avatarUrl: string }[]; - status: 'draft' | 'published' | 'archived'; - updatedAt: string; -}; - -// fields.ts -export const profileFields: DataViewField[] = [ - { accessorKey: 'identity', label: 'Person', hideable: false }, // synthetic: avatar + name + subheading - { accessorKey: 'name', label: 'Name', filterable: true, filterType: 'string', sortable: true }, - { accessorKey: 'subheading', label: 'Subtitle', filterable: true, filterType: 'string' }, - { accessorKey: 'label', label: 'Label', filterable: true, filterType: 'select', - filterOptions: [/* … */], groupable: true, showGroupCount: true, hideable: true }, - { accessorKey: 'collaborators', label: 'Collaborators', hideable: true }, - { accessorKey: 'status', label: 'Status', filterable: true, filterType: 'select', - filterOptions: [ - { label: 'Draft', value: 'draft' }, - { label: 'Published', value: 'published' }, - { label: 'Archived', value: 'archived' }, - ], - sortable: true, groupable: true, hideable: true, - }, - { accessorKey: 'updatedAt', label: 'Updated', filterable: true, filterType: 'date', sortable: true }, -]; -``` - -#### 12.2.2 List columns (the row render spec) - -```tsx -// list-columns.tsx -import type { DataViewListColumn } from '@raystack/apsara'; -import { Avatar, AvatarStack, Chip, Flex, Text } from '@raystack/apsara'; -import { SparkleIcon } from '~/icons'; -import { StatusChip } from './atoms'; -import type { Profile } from './types'; - -export const profileListColumns: DataViewListColumn[] = [ - { - accessorKey: 'identity', - width: '1fr', - cell: ({ row }) => ( - - - - {row.original.name} - {row.original.subheading} - - - ), - }, - { - accessorKey: 'label', - width: 'auto', - cell: ({ row }) => ( - }>{row.getValue('label')} - ), - }, - { - accessorKey: 'collaborators', - width: 'auto', - cell: ({ row }) => , - }, - { - accessorKey: 'status', - width: '120px', - cell: ({ row }) => , - }, -]; -``` - -Toggling the "Label" property off in DisplayControls collapses that grid track across every row — no per-cell logic needed. The column spec's `accessorKey` is the only binding. - -#### 12.2.3 Full consumer usage - -```tsx -// people-page.tsx -import { DataView } from '@raystack/apsara'; -import { profileFields } from './fields'; -import { profileListColumns } from './list-columns'; - -export function PeoplePage() { - const { data: profiles = [], isLoading } = useProfilesQuery(); - - return ( - - data={profiles} - fields={profileFields} - defaultSort={{ name: 'name', order: 'asc' }} - mode="client" - isLoading={isLoading} - getRowId={row => row.id} - onItemClick={profile => openProfile(profile.id)} - > - - - - - - - } heading="No matching people" />} - /> - - ); -} -``` - -#### 12.2.4 Renderer internal sketch - -```tsx -// packages/raystack/components/data-view/renderers/list.tsx -'use client'; -import { flexRender } from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useRef, useMemo } from 'react'; -import { cx } from 'class-variance-authority'; -import { useDataView } from '../hooks/useDataView'; -import styles from '../data-view.module.css'; - -export function DataViewList({ - columns, - virtualized = false, - rowHeight = 56, - showDividers = false, - showGroupHeaders = true, - overscan = 5, - emptyState, - zeroState, - classNames, -}: DataViewListProps) { - const ctx = useDataView(); - - // Same merge as Table: install per-column cell renderers onto the context's - // TanStack table (built from fields only). - const table = useTableWithColumns(ctx.table, ctx.fields, columns); - const rows = table.getRowModel().rows; - - // Build grid-template-columns from visible columns only. Hidden columns are - // already filtered out by TanStack via ctx.columnVisibility — same source - // DisplayAccess reads from. - const gridTemplateColumns = useMemo( - () => table.getVisibleLeafColumns() - .map(col => { - const spec = columns.find(c => c.accessorKey === col.id); - const w = spec?.width ?? '1fr'; - return typeof w === 'number' ? `${w}px` : w; - }) - .join(' '), - [table.getVisibleLeafColumns(), columns], - ); - - if (!rows.length && !ctx.isLoading) { - return
{ctx.hasActiveQuery ? emptyState : zeroState}
; - } - - const scrollRef = useRef(null); - const virtualizer = virtualized - ? useVirtualizer({ - count: rows.length, - getScrollElement: () => scrollRef.current, - estimateSize: i => rows[i].subRows?.length ? 36 : rowHeight, - overscan, - }) - : null; - - const renderRow = (row: typeof rows[number], style?: React.CSSProperties) => { - if (row.subRows?.length) { - if (!showGroupHeaders) return null; - return ( -
- {(row.original as any).label} - {(row.original as any).showGroupCount && {(row.original as any).count}} -
- ); - } - return ( -
ctx.onItemClick?.(row.original)} - > - {row.getVisibleCells().map(cell => ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ))} -
- ); - }; - - return ( -
- {virtualizer - ? ( -
- {virtualizer.getVirtualItems().map(v => - renderRow(rows[v.index], { position: 'absolute', top: v.start, height: v.size, width: '100%' }), - )} -
- ) - : rows.map(r => renderRow(r))} -
- ); -} -``` - -Key point: the renderer uses `table.getVisibleLeafColumns()` (already respects `ctx.columnVisibility`) to build `grid-template-columns`. When a user toggles a column off in DisplayControls, that column disappears from both the row's `getVisibleCells()` and the container's grid template — the track collapses, rows reflow, no extra code. - ---- - -### 12.3 Timeline (Gantt-range) - -Matches the Task Prioritisation screenshot (§10). Items are bars with start/end dates, packed into lanes, rendered against a continuous time axis with a "today" marker. - -#### 12.3.1 Field metadata - -```ts -// types.ts -export type GanttTask = { - id: string; - title: string; - priority: 'P1'|'P2'|'P3'|'P4'|'P5'|'P6'|'P7'|'P8'|'P9'|'P10'; - client: 'corteva' | 'gridline' | 'nasa'; - status: 'pending' | 'in_progress' | 'done'; - startDate: string; - endDate: string; - dueAt: string; -}; - -// fields.ts -export const ganttFields: DataViewField[] = [ - { accessorKey: 'title', label: 'Title', filterable: true, filterType: 'string', sortable: true }, - { accessorKey: 'priority', label: 'Priority', filterable: true, filterType: 'select', - filterOptions: Array.from({ length: 10 }, (_, i) => ({ label: `P${i+1}`, value: `P${i+1}` })), - sortable: true, groupable: true, - }, - { accessorKey: 'client', label: 'Client', filterable: true, filterType: 'select', - filterOptions: [ - { label: 'Corteva Agriscience', value: 'corteva' }, - { label: 'Gridline Surveys', value: 'gridline' }, - { label: 'NASA', value: 'nasa' }, - ], - groupable: true, showGroupCount: true, - }, - { accessorKey: 'status', label: 'Status', filterable: true, filterType: 'select' }, - { accessorKey: 'startDate', label: 'Start', filterable: true, filterType: 'date', sortable: true }, - { accessorKey: 'endDate', label: 'End', filterable: true, filterType: 'date', sortable: true }, - { accessorKey: 'dueAt', label: 'Due', filterable: true, filterType: 'date', sortable: true }, -]; -``` - -#### 12.3.2 The timeline bar render spec - -Timeline bars are variable-width, so no column grid. Compose freely with `` so each field responds to the same DisplayControls toggle that drives columns in Table/List. - -```tsx -// gantt-bar.tsx -import type { Row } from '@tanstack/react-table'; -import dayjs from 'dayjs'; -import { DataView, Chip, Flex, Text } from '@raystack/apsara'; -import { CalendarIcon } from '~/icons'; -import { StatusDot } from './atoms'; -import type { GanttTask } from './types'; - -export const renderGanttBar = (row: Row) => ( - - - - - - - - {row.getValue('title')} - - - - {row.getValue('priority')} - - - - - }> - Due {dayjs(row.getValue('dueAt')).format('D MMM')} - - - - {clientLabel(row.original.client)} - - - -); -``` - -#### 12.3.3 Full consumer usage - -```tsx -// task-prioritisation-page.tsx -import { DataView } from '@raystack/apsara'; -import { ganttFields } from './fields'; -import { renderGanttBar } from './gantt-bar'; - -export function TaskPrioritisationPage() { - const { data: tasks = [], isLoading } = useTasksQuery(); - - return ( - - data={tasks} - fields={ganttFields} - defaultSort={{ name: 'startDate', order: 'asc' }} - mode="client" - isLoading={isLoading} - getRowId={row => row.id} - onItemClick={task => openTaskPanel(task.id)} - > - - - - - - - - - ); -} -``` - -#### 12.3.4 Renderer internal sketch - -```tsx -// packages/raystack/components/data-view/renderers/timeline.tsx -'use client'; -import { useMemo, useRef } from 'react'; -import dayjs from 'dayjs'; -import { cx } from 'class-variance-authority'; -import { useDataView } from '../hooks/useDataView'; -import { packLanes } from '../utils/pack-lanes'; -import { TimelineAxis } from './timeline-axis'; -import styles from '../data-view.module.css'; - -const SCALE_PX: Record<'day'|'week'|'month'|'quarter', number> = { - day: 40, week: 28, month: 16, quarter: 8, -}; - -export function DataViewTimeline({ - startField, - endField, - renderBar, - scale = 'day', - today = true, - lanePacking = 'auto', - rowHeight = 56, - laneGap = 6, - viewportRange, - onViewportChange, - emptyState, - zeroState, - classNames, -}: DataViewTimelineProps) { - const ctx = useDataView(); - const rows = ctx.rows.filter(r => !(r.subRows?.length)); // ignore group headers; timeline handles its own grouping - const scrollRef = useRef(null); - - // 1. Auto-viewport from filtered rows if not controlled - const [viewStart, viewEnd] = useMemo<[Date, Date]>(() => { - if (viewportRange) return viewportRange; - if (!rows.length) return [dayjs().subtract(7, 'day').toDate(), dayjs().add(14, 'day').toDate()]; - const starts = rows.map(r => +new Date(r.getValue(startField))); - const ends = rows.map(r => +new Date(r.getValue(endField ?? startField))); - return [new Date(Math.min(...starts)), new Date(Math.max(...ends))]; - }, [rows, startField, endField, viewportRange]); - - const pxPerDay = SCALE_PX[scale]; - const totalDays = dayjs(viewEnd).diff(viewStart, 'day') + 1; - const totalW = totalDays * pxPerDay; - - // 2. Lane pack (greedy interval scheduling) - const lanes = useMemo( - () => packLanes(rows, { - start: r => +new Date(r.getValue(startField)), - end: r => +new Date(r.getValue(endField ?? startField)), - mode: lanePacking, - }), - [rows, startField, endField, lanePacking], - ); - - if (!rows.length && !ctx.isLoading) { - return
{ctx.hasActiveQuery ? emptyState : zeroState}
; - } - - const todayDate = today === true ? new Date() : (today || null); - const bodyHeight = lanes.length * (rowHeight + laneGap); - - return ( -
onViewportChange?.(computeVisibleRange(scrollRef.current, viewStart, pxPerDay))} - > -
- - -
- {lanes.flatMap((lane, laneIdx) => - lane.map(row => { - const s = +new Date(row.getValue(startField)); - const e = +new Date(row.getValue(endField ?? startField)); - const left = dayjs(s).diff(viewStart, 'day') * pxPerDay; - const width = Math.max((dayjs(e).diff(s, 'day') + 1) * pxPerDay, 80); - return ( -
ctx.onItemClick?.(row.original)} - > - {renderBar(row)} -
- ); - }), - )} -
-
-
- ); -} -``` - -```ts -// packages/raystack/components/data-view/utils/pack-lanes.ts -export function packLanes( - rows: T[], - opts: { - start: (r: T) => number; - end: (r: T) => number; - mode: 'auto' | 'one-per-row'; - }, -): T[][] { - if (opts.mode === 'one-per-row') return rows.map(r => [r]); - - const sorted = [...rows].sort((a, b) => opts.start(a) - opts.start(b)); - const lanes: { end: number; items: T[] }[] = []; - - for (const row of sorted) { - const start = opts.start(row); - const end = opts.end(row); - const lane = lanes.find(l => l.end <= start); - if (lane) { lane.items.push(row); lane.end = end; } - else { lanes.push({ end, items: [row] }); } - } - return lanes.map(l => l.items); -} -``` - -The timeline renderer bypasses `groupData` entirely — it reads filtered `rows` from context and does its own horizontal bucketing via pixel math. If the user turns on grouping in `DisplayControls` (say, by `client`), lanes can optionally be partitioned per-group; the snippet above renders a single flat lane set for brevity. - ---- - -### 12.4 What didn't change across the three examples - -| | Table | List | Timeline | -| --- | --- | --- | --- | -| `data` prop | same | same | same | -| `fields` metadata | same | same | same | -| `` | same | same | same | -| `` behavior | same | same | same | -| `` menu & chips | same | same | same | -| `` ordering/grouping | same | same | partial (visibility ignored) | -| Filter predicates | same | same | same | -| `mode: 'client' \| 'server'` | same | same | same | -| `onItemClick` | same | same | same | -| `onLoadMore` / `totalRowCount` | same | same | same | -| Query state `{ filters, sort, group_by, search }` | same | same | same | - -The difference across the three: the renderer subcomponent and its own render-spec props. Table and List share the **column spec shape** (List drops `header`, adds `width`); switching between them is mostly a tag change + renaming the column-spec type: - -```tsx -- -+ -``` - -Timeline replaces `columns` with `renderBar` (bars are variable-width) + `startField`/`endField`; field visibility inside the bar is handled by wrapping each field in ``: - -```tsx -+ -``` - -Everything in between — toolbar, filters, search, sort, group, selection, load-more, *and column visibility* — keeps working unchanged across all three renderers. - ---- - -## 13. Recommendation - -**Build it.** Ship in the phased order above. Start with Phase 0 (internal rename + extraction) as a pure refactor PR with no consumer-visible change — that validates the architecture without any risk. Then build `` as the first new renderer (simplest shape, reuses the Table column spec + adds a `width` hint — cheap to ship, exercises the shared contract). Add `` alongside List so Timeline inherits a proven visibility story. `` lands last — bucketization, lane packing, and two-axis virtualization are the only novel complexity. - -Keep TanStack Table as the internal engine for all renderers. Do not roll a custom data-model library — the maintenance cost is real and the only "benefit" would be removing a dep the design system already uses. From 6af72a8ce64f40848e694f7a98121b6c25d5ddd6 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 23 Apr 2026 18:33:19 +0530 Subject: [PATCH 4/5] feat: update dataview example --- apps/www/src/app/examples/dataview/page.tsx | 92 +++++++++++---------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/apps/www/src/app/examples/dataview/page.tsx b/apps/www/src/app/examples/dataview/page.tsx index 62154b3dd..8851929cd 100644 --- a/apps/www/src/app/examples/dataview/page.tsx +++ b/apps/www/src/app/examples/dataview/page.tsx @@ -13,7 +13,9 @@ import { type DataViewTableColumn, EmptyState, Flex, + getAvatarColor, IconButton, + Indicator, Navbar, Sidebar, Tabs, @@ -215,7 +217,12 @@ const STATUS_COLOR: Record< // Cell renderers shared between Table and List renderers. const renderNameCell = ({ row }: ProfileCell) => ( - + {row.original.name} @@ -266,7 +273,13 @@ const renderCollaboratorsCell = ({ row }: ProfileCell) => { return ( {collaborators.map(c => ( - + ))} ); @@ -396,26 +409,15 @@ const listColumns: DataViewListColumn[] = [ } ]; -const STATUS_DOT_COLOR: Record = { - Active: 'var(--rs-color-foreground-success-primary, #16a34a)', - Away: 'var(--rs-color-foreground-attention-primary, #d97706)', - Offline: 'var(--rs-color-foreground-base-tertiary, #9ca3af)' +const INDICATOR_COLOR: Record< + Profile['status'], + 'success' | 'warning' | 'neutral' +> = { + Active: 'success', + Away: 'warning', + Offline: 'neutral' }; -function StatusRing({ status }: { status: Profile['status'] }) { - const color = STATUS_DOT_COLOR[status]; - return ( -
- ); -} - function ProfileCard({ profile }: { profile: Profile }) { return ( - + + + { - const [navbarSearch, setNavbarSearch] = useState(''); const [view, setView] = useState('table'); return ( @@ -504,19 +511,17 @@ const Page = () => { - Raystack + Apsara - }> - Examples - } + active > - DataView · People + DataView @@ -535,20 +540,7 @@ const Page = () => { DataView · People directory - - setView(v as ViewMode)} - size='small' - style={{ width: '400px' }} - > - - Table View - List View - Custom View - - - + { getRowId={(row: Profile) => row.id} > - + + + setView(v as ViewMode)} + size='small' + style={{ width: '400px' }} + > + + Table View + List View + Custom View + + + {view === 'table' && ( From 0aebe7eaf6644d647b4047e476b933fc9cdd83e6 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 23 Apr 2026 18:41:57 +0530 Subject: [PATCH 5/5] chore: rename to data-view-beta --- .../{data-view => data-view-beta}/components/content.tsx | 0 .../{data-view => data-view-beta}/components/display-access.tsx | 0 .../components/display-properties.tsx | 0 .../components/display-settings.tsx | 0 .../{data-view => data-view-beta}/components/filters.tsx | 0 .../{data-view => data-view-beta}/components/grouping.tsx | 0 .../{data-view => data-view-beta}/components/list.tsx | 0 .../{data-view => data-view-beta}/components/ordering.tsx | 0 .../{data-view => data-view-beta}/components/renderer.tsx | 0 .../{data-view => data-view-beta}/components/search.tsx | 0 .../{data-view => data-view-beta}/components/table.tsx | 0 .../{data-view => data-view-beta}/components/toolbar.tsx | 0 .../components/virtualized-content.tsx | 0 .../components/{data-view => data-view-beta}/context.tsx | 0 .../{data-view => data-view-beta}/data-view.module.css | 0 .../components/{data-view => data-view-beta}/data-view.tsx | 0 .../{data-view => data-view-beta}/data-view.types.tsx | 0 .../{data-view => data-view-beta}/hooks/useDataView.tsx | 0 .../{data-view => data-view-beta}/hooks/useFilters.tsx | 0 .../raystack/components/{data-view => data-view-beta}/index.ts | 0 .../{data-view => data-view-beta}/utils/filter-operations.tsx | 0 .../components/{data-view => data-view-beta}/utils/index.tsx | 0 packages/raystack/index.tsx | 2 +- 23 files changed, 1 insertion(+), 1 deletion(-) rename packages/raystack/components/{data-view => data-view-beta}/components/content.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/display-access.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/display-properties.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/display-settings.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/filters.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/grouping.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/list.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/ordering.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/renderer.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/search.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/table.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/toolbar.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/components/virtualized-content.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/context.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/data-view.module.css (100%) rename packages/raystack/components/{data-view => data-view-beta}/data-view.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/data-view.types.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/hooks/useDataView.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/hooks/useFilters.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/index.ts (100%) rename packages/raystack/components/{data-view => data-view-beta}/utils/filter-operations.tsx (100%) rename packages/raystack/components/{data-view => data-view-beta}/utils/index.tsx (100%) diff --git a/packages/raystack/components/data-view/components/content.tsx b/packages/raystack/components/data-view-beta/components/content.tsx similarity index 100% rename from packages/raystack/components/data-view/components/content.tsx rename to packages/raystack/components/data-view-beta/components/content.tsx diff --git a/packages/raystack/components/data-view/components/display-access.tsx b/packages/raystack/components/data-view-beta/components/display-access.tsx similarity index 100% rename from packages/raystack/components/data-view/components/display-access.tsx rename to packages/raystack/components/data-view-beta/components/display-access.tsx diff --git a/packages/raystack/components/data-view/components/display-properties.tsx b/packages/raystack/components/data-view-beta/components/display-properties.tsx similarity index 100% rename from packages/raystack/components/data-view/components/display-properties.tsx rename to packages/raystack/components/data-view-beta/components/display-properties.tsx diff --git a/packages/raystack/components/data-view/components/display-settings.tsx b/packages/raystack/components/data-view-beta/components/display-settings.tsx similarity index 100% rename from packages/raystack/components/data-view/components/display-settings.tsx rename to packages/raystack/components/data-view-beta/components/display-settings.tsx diff --git a/packages/raystack/components/data-view/components/filters.tsx b/packages/raystack/components/data-view-beta/components/filters.tsx similarity index 100% rename from packages/raystack/components/data-view/components/filters.tsx rename to packages/raystack/components/data-view-beta/components/filters.tsx diff --git a/packages/raystack/components/data-view/components/grouping.tsx b/packages/raystack/components/data-view-beta/components/grouping.tsx similarity index 100% rename from packages/raystack/components/data-view/components/grouping.tsx rename to packages/raystack/components/data-view-beta/components/grouping.tsx diff --git a/packages/raystack/components/data-view/components/list.tsx b/packages/raystack/components/data-view-beta/components/list.tsx similarity index 100% rename from packages/raystack/components/data-view/components/list.tsx rename to packages/raystack/components/data-view-beta/components/list.tsx diff --git a/packages/raystack/components/data-view/components/ordering.tsx b/packages/raystack/components/data-view-beta/components/ordering.tsx similarity index 100% rename from packages/raystack/components/data-view/components/ordering.tsx rename to packages/raystack/components/data-view-beta/components/ordering.tsx diff --git a/packages/raystack/components/data-view/components/renderer.tsx b/packages/raystack/components/data-view-beta/components/renderer.tsx similarity index 100% rename from packages/raystack/components/data-view/components/renderer.tsx rename to packages/raystack/components/data-view-beta/components/renderer.tsx diff --git a/packages/raystack/components/data-view/components/search.tsx b/packages/raystack/components/data-view-beta/components/search.tsx similarity index 100% rename from packages/raystack/components/data-view/components/search.tsx rename to packages/raystack/components/data-view-beta/components/search.tsx diff --git a/packages/raystack/components/data-view/components/table.tsx b/packages/raystack/components/data-view-beta/components/table.tsx similarity index 100% rename from packages/raystack/components/data-view/components/table.tsx rename to packages/raystack/components/data-view-beta/components/table.tsx diff --git a/packages/raystack/components/data-view/components/toolbar.tsx b/packages/raystack/components/data-view-beta/components/toolbar.tsx similarity index 100% rename from packages/raystack/components/data-view/components/toolbar.tsx rename to packages/raystack/components/data-view-beta/components/toolbar.tsx diff --git a/packages/raystack/components/data-view/components/virtualized-content.tsx b/packages/raystack/components/data-view-beta/components/virtualized-content.tsx similarity index 100% rename from packages/raystack/components/data-view/components/virtualized-content.tsx rename to packages/raystack/components/data-view-beta/components/virtualized-content.tsx diff --git a/packages/raystack/components/data-view/context.tsx b/packages/raystack/components/data-view-beta/context.tsx similarity index 100% rename from packages/raystack/components/data-view/context.tsx rename to packages/raystack/components/data-view-beta/context.tsx diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view-beta/data-view.module.css similarity index 100% rename from packages/raystack/components/data-view/data-view.module.css rename to packages/raystack/components/data-view-beta/data-view.module.css diff --git a/packages/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view-beta/data-view.tsx similarity index 100% rename from packages/raystack/components/data-view/data-view.tsx rename to packages/raystack/components/data-view-beta/data-view.tsx diff --git a/packages/raystack/components/data-view/data-view.types.tsx b/packages/raystack/components/data-view-beta/data-view.types.tsx similarity index 100% rename from packages/raystack/components/data-view/data-view.types.tsx rename to packages/raystack/components/data-view-beta/data-view.types.tsx diff --git a/packages/raystack/components/data-view/hooks/useDataView.tsx b/packages/raystack/components/data-view-beta/hooks/useDataView.tsx similarity index 100% rename from packages/raystack/components/data-view/hooks/useDataView.tsx rename to packages/raystack/components/data-view-beta/hooks/useDataView.tsx diff --git a/packages/raystack/components/data-view/hooks/useFilters.tsx b/packages/raystack/components/data-view-beta/hooks/useFilters.tsx similarity index 100% rename from packages/raystack/components/data-view/hooks/useFilters.tsx rename to packages/raystack/components/data-view-beta/hooks/useFilters.tsx diff --git a/packages/raystack/components/data-view/index.ts b/packages/raystack/components/data-view-beta/index.ts similarity index 100% rename from packages/raystack/components/data-view/index.ts rename to packages/raystack/components/data-view-beta/index.ts diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view-beta/utils/filter-operations.tsx similarity index 100% rename from packages/raystack/components/data-view/utils/filter-operations.tsx rename to packages/raystack/components/data-view-beta/utils/filter-operations.tsx diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view-beta/utils/index.tsx similarity index 100% rename from packages/raystack/components/data-view/utils/index.tsx rename to packages/raystack/components/data-view-beta/utils/index.tsx diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 5f723f720..7da5422a0 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -41,7 +41,7 @@ export { DataViewTableColumn, DataViewTableProps, useDataView -} from './components/data-view'; +} from './components/data-view-beta'; export { Dialog } from './components/dialog'; export { Drawer } from './components/drawer'; export { EmptyState } from './components/empty-state';