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..c4d6b6385 --- /dev/null +++ b/apps/www/src/app/examples/dataview/page.tsx @@ -0,0 +1,620 @@ +/** 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, + EmptyState, + Flex, + getAvatarColor, + IconButton, + Indicator, + Navbar, + Sidebar, + Text +} from '@raystack/apsara'; +import { BellIcon, FilterIcon, SidebarIcon } from '@raystack/apsara/icons'; + +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 variants of DataView.List. +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 (List variants, Custom, …). Declared once on root. +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 presentation of DataView.List — all fields surfaced as columns. +const tableColumns: DataViewListColumn[] = [ + { accessorKey: 'name', cell: renderNameCell, width: 'minmax(220px, 1.5fr)' }, + { + accessorKey: 'subheading', + cell: renderEmailCell, + width: 'minmax(200px, 1fr)' + }, + { accessorKey: 'role', cell: renderRoleCell, width: '120px' }, + { accessorKey: 'label', cell: renderLabelCell, width: 'minmax(140px, 1fr)' }, + { accessorKey: 'team', cell: renderTeamCell, width: 'minmax(120px, 1fr)' }, + { accessorKey: 'status', cell: renderStatusCell, width: '120px' }, + { + accessorKey: 'collaborators', + cell: renderCollaboratorsCell, + width: 'auto' + }, + { accessorKey: 'updatedAt', cell: renderUpdatedAtCell, width: '140px' } +]; + +// List presentation of DataView.List — the `1fr` middle track on Name pushes +// trailing metadata to the right edge (justify-between effect via grid). +const listColumns: DataViewListColumn[] = [ + { accessorKey: 'name', cell: renderNameCell, width: '1fr' }, + { accessorKey: 'label', cell: renderLabelCell, width: 'auto' }, + { accessorKey: 'team', cell: renderTeamCell, width: 'auto' }, + { + accessorKey: 'collaborators', + cell: renderCollaboratorsCell, + width: 'auto' + }, + { accessorKey: 'status', cell: renderStatusCell, width: 'auto' } +]; + +const INDICATOR_COLOR: Record< + Profile['status'], + 'success' | 'warning' | 'neutral' +> = { + Active: 'success', + Away: 'warning', + Offline: 'neutral' +}; + +function ProfileCard({ profile }: { profile: Profile }) { + return ( + + + + + + + + + + {profile.name} + + + + + {profile.role} + + + + + + } + > + Updated {profile.updatedAt} + + + + + {profile.team} + + + + + ); +} + +const Page = () => { + return ( + + + + + {}} aria-label='Logo'> + + + + Apsara + + + + + } + active + > + DataView + + + + Help & Support + Preferences + + + + + + + + DataView · People directory + + + + + + + + data={profiles} + fields={fields} + mode='client' + defaultSort={{ name: 'name', order: 'asc' }} + getRowId={(row: Profile) => row.id} + views={[ + { value: 'table', label: 'Table' }, + { value: 'list', label: 'List' }, + { value: 'custom', label: 'Custom' } + ]} + defaultView='table' + > + + + + + + + {/* Same renderer, two presentations — switched by the view switcher in DisplayControls */} + + + + name='custom'> + {({ table, hasData }) => { + if (!hasData) return null; + const rows = table + .getRowModel() + .rows.filter(r => !r.subRows?.length); + return ( +
+ {rows.map(row => ( + + ))} +
+ ); + }} + + + {/* Empty/zero state lifted out of renderers — single sibling reads context */} + + } + heading='No matching people' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + + + } + heading='No people yet' + variant='empty1' + subHeading='Add your first teammate to get started.' + /> + + +
+
+
+ ); +}; + +export default Page; diff --git a/docs/rfcs/002-unified-dataview-component.md b/docs/rfcs/002-unified-dataview-component.md new file mode 100644 index 000000000..c4710bb80 --- /dev/null +++ b/docs/rfcs/002-unified-dataview-component.md @@ -0,0 +1,596 @@ +--- +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 (List for Table+List, Timeline, Custom), so the same query/filter/sort/group/search state can drive multiple presentations of the same data — switchable at runtime through a built-in multi-view system. + +## 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) + - [Multi-View Architecture](#multi-view-architecture) + - [Active View in Context](#active-view-in-context) + - [Behavior on Mismatch](#behavior-on-mismatch) + - [Single-Renderer Use](#single-renderer-use) + - [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) + - [Per-View Field Overrides via `fields` on Renderer](#per-view-field-overrides-via-fields-on-renderer) + - [Unified Column Visibility via `DisplayAccess`](#unified-column-visibility-via-displayaccess) + - [Empty and Zero States as Siblings](#empty-and-zero-states-as-siblings) + - [Virtualization as a Prop, Not a Component](#virtualization-as-a-prop-not-a-component) + - [Renderer-Specific Differences](#renderer-specific-differences) + - [List (Table + List)](#list-table--list) + - [Timeline](#timeline) + - [Custom](#custom) + - [Grouping](#grouping) + - [Table of Comparison](#table-of-comparison) + - [Impact](#impact) + - [Future Work](#future-work) + - [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. They also need to let users **switch between presentations at runtime** without losing query state. + +### 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. +- **No view-switching primitive.** Consumers who need a Table↔List toggle have to wire it up themselves above `DataTable`. +- **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. +- **Empty/zero state logic is duplicated** across renderer variants (`Content` and `VirtualizedContent`), with each fork drifting independently. +- **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 and a built-in multi-view system: + +- `DataView.Toolbar`, `DataView.Search`, `DataView.Filters`, `DataView.DisplayControls` — presentation-agnostic controls that read/write query state through context. `DisplayControls` also hosts the view switcher when `views` is set. +- `DataView.List` — single grid+subgrid renderer for both **Table** and **List** presentations, controlled by `variant`. Replaces the separate Table and List components from earlier drafts. +- `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. +- `DataView.EmptyState`, `DataView.ZeroState` — sibling components that render based on context-computed empty/zero conditions; renderers no longer own this UI. + +Target API: + +```tsx + + + + + {/* renders the view switcher when views.length > 1 */} + + + {/* Same renderer, two presentations */} + + + + ( + + + {row.getValue('title')} + + + {row.getValue('priority')} + + + )} + /> + + + {(api) => } + + + {/* Sibling empty/zero states — render based on context, not props on renderer */} + + } /> + + + + + +``` + +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 and built-in multi-view 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/visibility live on context, switching views 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. +- **Built-in view switching**: Consumers declare `views` + give each renderer a `name`; the toolbar's `DisplayControls` hosts the switcher and the matching renderer renders. No bespoke tab UI. +- **Consistent UX**: Filters, search, sort, grouping, and display-visibility work identically across views. +- **Cross-view state persistence**: Users can toggle between renderers in the same `` without losing filters, sort, search, group_by, or visibility. +- **Unified visibility story**: One `DisplayControls` component drives both columnar (List) and non-columnar (Timeline, Custom) renderers via ``. +- **Per-view field overrides without duplication**: Field metadata is declared once on root; a renderer can pass an optional `fields` prop to override metadata for its view only. +- **Cleaner separation of concerns**: Props live where they're read — renderer knobs on the renderer, data-layer concerns on the root. +- **Empty/zero state lifted out of renderers**: Sibling `` / `` consume context-derived conditions, eliminating the empty-vs-zero branch duplicated across `Content` and `VirtualizedContent`. +- **Non-breaking migration path**: `DataTable` stays as an alias; most work is additive (new renderers, multi-view), 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**: New renderers (Timeline, Custom), `DisplayAccess`, `EmptyState`/`ZeroState`, view-switcher, and `name`/`views` plumbing all 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. +- **Per-view fields override can drift from root**: Consumers who pass `fields` to a renderer must keep it in sync with root if they care about cross-view consistency. Spreading root fields and tweaking is the documented pattern. +- **Single global visibility state**: Toggling "show priority" in one view shows it in every view. Trade-off accepted for simpler state shape; can be revisited if per-view persistence demand emerges. + +## Multi-View Architecture + +Multi-view is a first-class concern of `DataView`, not an afterthought. The design must work cleanly for both single-renderer ("just a Table") and multi-renderer ("Table↔List↔Timeline switch") use cases. + +The model is intentionally minimal: + +- **`views` prop on root** — `Array<{ value: string; label: string; icon?: ReactNode }>`. Optional; declares the set of presentations the consumer offers. +- **`name` prop on each renderer** — string identifier matching one `views[].value`. Optional. +- **Active view** — controlled (`view` + `onViewChange`) or uncontrolled (`defaultView`). Held in context. +- **View switcher** — rendered by `` when `views.length > 1`. Also exported standalone as `` for layout flexibility (sidebar, page header, etc.). + +### Active View in Context + +`activeView` lives on context. Each renderer reads it and returns `null` when `name !== activeView`. The Filters, Search, and DisplayControls always read `effectiveFields` (= the active view's overridden `fields` if provided, else root `fields`) so the toolbar reflects the active view's metadata. + +### Behavior on Mismatch + +- A `views[]` entry without a matching renderer → nothing renders for that view. +- A renderer with a `name` not in `views[]` → nothing renders. +- No fallback, no console warning theatre. Consumer is responsible for keeping `views` and renderer `name`s in sync. + +This is intentional: data-layer behavior shouldn't depend on best-guess heuristics. If the wiring is wrong, the missing UI surfaces it immediately. + +### Single-Renderer Use + +When `views` is omitted and only one renderer is mounted, the `name` prop is unnecessary, and the view switcher is not rendered. The component degrades to a single-renderer surface identical in shape to today's `DataTable`. Multi-view is opt-in. + +## 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`, `views`, `defaultView`/`view`, `onViewChange`, `onQueryChange`, `onLoadMore`, `onRowClick`, ...). Each renderer subcomponent takes its own render spec (`columns` for List; `startField`/`endField`/`renderBar` for Timeline) and an optional `name` for view switching. + +```tsx +// Before + + + + + +// After + + + + + + + + +``` + +> [!NOTE] +> **Analysis** +> +> Props live where they're read. `columns` is consumed only by columnar renderers (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.List`). + +```ts +// Field — presentation-agnostic, declared once on root +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 DataViewListColumn { + accessorKey: Extract; // pointer into fields[] + cell?: ColumnDef['cell']; + header?: ColumnDef['header']; + width?: string | number; // grid track + 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 + + + + {/* hosts the view switcher when views.length > 1 */} + {/* 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. `DisplayControls` is also where the view switcher lives by default; consumers who need it elsewhere can use `` directly. Small cost in verbosity; large gain in flexibility. + +#### Per-View Field Overrides via `fields` on Renderer + +- `DataTable`: filter/sort/visibility config is fixed by the column array; no notion of differing per view. +- `DataView`: each renderer accepts an optional `fields?: DataViewField[]`. When provided, it fully replaces the root `fields` for that view's active session. The Filters, Search, and DisplayControls (and the headless TanStack column model) read `effectiveFields` from context. + +```tsx +const listFields = fields.map(f => + f.accessorKey === 'priority' + ? { ...f, hideable: false, defaultHidden: true } + : f, +); + + +``` + +> [!NOTE] +> **Analysis** +> +> A renderer's `fields` prop is a full replacement, not a merge — keeps semantics dead simple (one shape, no merge rules). Consumers tweaking just one or two flags spread root fields and override; consumers wanting a fundamentally different metadata set pass a fresh array. No partial-override DSL to learn. Crucially, this works uniformly for **every** renderer including non-columnar ones (Timeline, Custom) — they're not forced into a `columns` shape just to override `hideable`/`defaultHidden`. Field metadata stays in one declaration shape (`DataViewField`) regardless of renderer. + +#### 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 as a **single global map**. List gates columns internally (hidden grid tracks). Timeline and Custom use `` to wrap any JSX and reactively hide/show it. + +`hideable` and `defaultHidden` live only on `fields[]` (or the active view's overridden `fields`). Effective visibility for the active view = global state ∩ effective `hideable` from active view's fields. A field forced `hideable: false` in the active view stays hidden regardless of stored state. + +```tsx +// Inside a Timeline bar — works the same way for List/Custom + ( + + + {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. List doesn't need it at the call site — its `columns` already carry `accessorKey`, so the renderer gates visibility internally from the same context state. State is **global** (single map, not per-view) — a deliberate simplicity trade-off; per-view persistence can be added later as `Record` if demand emerges. A dev warning fires at mount if a `hideable: true` field is referenced by neither a column spec nor any DisplayAccess instance. + +#### Empty and Zero States as Siblings + +- `DataTable`: `Content` and `VirtualizedContent` each accept their own `emptyState` / `zeroState` props and re-derive the empty-vs-zero branch internally — duplicated across both forks. +- `DataView`: empty/zero detection is computed once in context (`isEmptyState`, `isZeroState`, `hasActiveQuery`). Sibling components consume it. + +```tsx + + ... + + + + } /> + + + + + +``` + +Renderers no longer accept `emptyState` / `zeroState` props. When `!hasData`, the renderer renders nothing (or only its chrome — header row, scroll shell), and the `EmptyState` / `ZeroState` siblings render based on context. If both siblings are omitted, nothing is rendered — Apsara doesn't ship a default-fallback heading. + +> [!NOTE] +> **Analysis** +> +> The empty/zero distinction is a function of `data + filters + search + sort` — all global. Computing it once in context drops the duplicated branch out of every renderer and gives consumers a single composable place to declare the messaging. Per-view variations (different copy on Table vs. List) can be done with conditional children inside one `` (reading `activeView` via `useDataView()`); a `forView` prop is a future addition only if requested. The "N items hidden by filters" footer remains inside each renderer because it's a renderer-DOM concern (positioning, sticky, virtualizer-aware) — not lifted out. + +#### 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 + +#### List (Table + List) + +- `DataTable.Content`: renders `` with `flexRender(columnDef.cell)` per cell, `columnDef.header` per header. +- `DataView.List`: a **single** renderer that handles both the tabular and list presentations. Both presentations use CSS `grid` + `subgrid` over a `
` tree, with appropriate ARIA roles applied so semantic table assistive-tech behavior is preserved. + +```ts +interface DataViewListProps { + name?: string; // for multi-view; matches views[].value + variant?: 'table' | 'list'; // default 'list' + showHeaders?: boolean; // default = (variant === 'table') + role?: 'table' | 'list'; // default derived from variant + columns: DataViewListColumn[]; + fields?: DataViewField[]; // optional view-scoped override + // virtualization, sticky group header, classNames, etc. +} + +interface DataViewListColumn { + accessorKey: string; + cell?: ColumnDef['cell']; + header?: ColumnDef['header']; + width?: string | number; // grid track: '1fr' | '200px' | 'auto' | 'minmax(80px, 1fr)' | number(px) + classNames?: { cell?: string; header?: string }; + styles?: { cell?: CSSProperties; header?: CSSProperties }; +} +``` + +`variant="table"` defaults to `showHeaders={true}` and `role="table"`. `variant="list"` defaults to `showHeaders={false}` and `role="list"`. Both can be overridden independently for fine-grained control. + +The justify-between layout common in list views (primary content on the left, metadata on the right) is the consumer's responsibility — declare a column with `width: '1fr'` between `auto` columns and CSS does the rest: + +```tsx +const listColumns = [ + { accessorKey: 'avatar', width: 'auto', cell: ({ row }) => }, + { accessorKey: 'name', width: '1fr', cell: ({ row }) => {row.original.name} }, + { accessorKey: 'status', width: 'auto', cell: ({ row }) => {row.original.status} }, +]; +``` + +A consumer who wants both a Table and a List in the same `DataView` declares the renderer twice with different `variant` and `name`: + +```tsx + + +``` + +> [!NOTE] +> **Analysis** +> +> Unifying Table and List into one renderer drops ~half the renderer-layer code. CSS grid + subgrid handles both column-aligned tables and justify-between lists with one DOM strategy; semantic table behavior is preserved through ARIA roles. 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. Sticky group header is implemented via `position: sticky` on the group row; works in both variants. + +#### 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 + fields?: DataViewField[] // optional view-scoped override + 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. Timeline accepts the same `fields` override prop as List — consistent shape across renderers. + +#### 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 — declare a `name`, optionally pass a `fields` override, read context. + +#### 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 | +| Per-view field override | Not available | Optional `fields` prop on each renderer (full replacement) | +| Cell/header renderers (table) | `columns` prop | `columns` on `DataView.List` | +| Table renderer | `DataTable.Content` / `DataTable.VirtualizedContent` | `DataView.List variant="table"` | +| List renderer | Not available | `DataView.List variant="list"` | +| Timeline / Gantt renderer | Not available | `DataView.Timeline` with `renderBar` | +| Custom renderer | Not supported | `DataView.Custom` | +| View switching (Table↔List, etc.) | Consumer wires it themselves | Built-in via `views` + `name` + `DataView.DisplayControls` | +| 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 as single global map; List gates 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.List` (not in shared context) | +| Empty vs zero state | Per-renderer `emptyState`/`zeroState` props | Sibling `` / `` driven by context-computed `isEmptyState`/`isZeroState` | +| Filter summary footer | Per-renderer | Stays per-renderer (renderer-DOM concern) | +| Infinite scroll (`onLoadMore`, `totalRowCount`) | `DataTable` | `DataView` root (renderer-independent); each renderer detects bottom-reached | + +## Impact + +- **1 component replaced + multiple new renderers added.** `DataTable` becomes a thin alias over `` + `` during migration. +- **Built-in multi-view.** Consumers stop building bespoke Table↔List toggles above the data primitive. +- **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. +- **Empty/zero state hoisted out of renderers.** Sibling components are a small breaking change for power users who passed `emptyState` / `zeroState` props directly to `Content`/`VirtualizedContent`; addressed in the migration guide. +- **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. + +## Future Work + +Deliberately scoped out of v1 but worth flagging so the design doesn't preclude them: + +- **Density toggle** (`compact | standard | comfortable`) sitting next to the view switcher in `DisplayControls`. Per-view state. Universally requested. +- **Faceted filter values** — TanStack already exposes `column.getFacetedUniqueValues()`. Categorical filters auto-populate from data instead of consumers passing `filterOptions` manually. +- **Keyboard view switching** (`⌘1`, `⌘2`, …). Cheap once `DataView.ViewSwitcher` exists. +- **Row selection + bulk-action slot.** Selection state is global (cross-view). A `` slot in toolbar would surface when selection is non-empty. +- **Saved views as a separate concept.** Today's `views` prop is a renderer-selection mechanism. Linear/Notion-style saved-views (named filter+sort+visibility bundles) would extend this — possibly via `views: Array<{ value, label, query?, visibility? }>` later. The current shape is a strict subset, so additive. +- **`onStateChange` for persistence.** A single callback emitting `{ activeView, query, columnVisibility, ... }` so consumers can persist to URL/localStorage/server. +- **Per-view visibility persistence**, if global-state model proves insufficient. Additive: change `columnVisibility` to `Record`. +- **Column pinning, resizing, reordering** (MUI/ag-Grid features). Useful but large surface area; revisit after v1 ships. +- **CSV export, inline editing, pivot/aggregation, master-detail.** Out of scope; ag-Grid / MUI X territory. + +## 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 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. +- **Separate `DataView.Table` and `DataView.List` renderers.** Earlier draft. Rejected after analysis showed the two share ~90% of their renderer logic; the only structural diff is DOM (`
` vs `
` grid) and the presence of a header row. Unifying them under `DataView.List` with `variant="table" \| "list"` (and ARIA-role + `showHeaders` controllable for fine-grained cases) drops half the renderer code and keeps consumer ergonomics ("declare two renderers if you want both presentations"). +- **Per-renderer `defaultHidden` / `hideable` on column specs.** Considered to give each view its own initial visibility set. Rejected because it works only for columnar renderers — Timeline/Custom don't take a `columns` array, so they'd need a separate "view-specific fields" prop, splitting the metadata story. The `fields` prop on each renderer (full replacement) is the unified mechanism: same shape for every renderer, no parallel APIs. +- **Per-view visibility state (`Record`).** Considered for clean per-view persistence. Rejected for v1 in favor of a single global `columnVisibility` map — simpler shape, simpler mental model, and the additive path to per-view state stays open if demand emerges. +- **Empty/zero state as renderer props.** Today's pattern. Rejected because the empty-vs-zero branch is a function of `data + filters + search + sort` — all global. Computing it in context once and exposing siblings (`` / ``) eliminates the duplicated branch in every renderer and gives consumers one composable place to declare messaging. +- **Auto-fallback when active view has no matching renderer (or vice versa).** Considered as a usability cushion. Rejected because best-guess heuristics hide wiring bugs. Mismatches render nothing — surfaces the bug immediately at integration time. +- **Unifying Timeline's bar layout under the 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. +- **Lifting the filter-summary footer to a sibling ``.** Considered for symmetry with `EmptyState`/`ZeroState`. Rejected because the footer is a renderer-DOM concern (positioning relative to scroll container, sticky behavior, virtualizer awareness) — lifting it forces every renderer to expose its scroll/virtualizer internals to a sibling. Stays inside each renderer. + +### 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.List` only. It's a renderer-DOM concern and doesn't apply to Timeline/Custom; polluting context with renderer-only knobs would invite similar leakage for every new renderer. +- **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. +- **`forView` prop on `DataView.EmptyState` / `DataView.ZeroState`.** Considered for per-view empty messaging. Deferred — consumers can read `activeView` from `useDataView()` inside one EmptyState and branch. Add `forView` only if requested. +- **`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 and built-in view switching. +- [Notion — Databases](https://www.notion.so/help/views-filters-and-sorts) — prior art for swappable renderers driven by one filter/sort model. +- [MUI X Data Grid](https://mui.com/x/react-data-grid/) — reference for column features (pinning, resizing, faceted filters) flagged as future work. +- [ag-Grid](https://www.ag-grid.com/) — reference for advanced features (pivot, aggregation, master-detail) deliberately out of scope. +- Internal reference: `.claude/worktrees/dataview/ANALYSIS.md` — feasibility + architecture analysis backing this RFC. diff --git a/packages/raystack/components/data-view-beta/components/content.tsx b/packages/raystack/components/data-view-beta/components/content.tsx new file mode 100644 index 000000000..0e972e237 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/custom.tsx b/packages/raystack/components/data-view-beta/components/custom.tsx new file mode 100644 index 000000000..28ce9a1a6 --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/custom.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { ReactNode, useEffect } from 'react'; +import { DataViewContextType, DataViewField } from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; + +export interface DataViewCustomProps { + /** Multi-view name. When set, the renderer gates itself on the active view. */ + name?: string; + /** Optional view-scoped field override. Full replacement of root `fields` for this view's active session. */ + fields?: DataViewField[]; + /** + * Render prop that receives the full DataView context (table, fields, + * tableQuery, hasData, isEmptyState, etc.) and returns the rendered view. + * Pair 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 DataViewCustom({ + name, + fields: fieldsOverride, + children +}: DataViewCustomProps) { + const ctx = useDataView(); + const { activeView, registerFieldsForView } = ctx; + + useEffect(() => { + if (!name || !fieldsOverride) return; + return registerFieldsForView(name, fieldsOverride); + }, [name, fieldsOverride, registerFieldsForView]); + + const isActive = !name || activeView === undefined || activeView === name; + if (!isActive) return null; + + return <>{children(ctx)}; +} + +DataViewCustom.displayName = 'DataView.Custom'; diff --git a/packages/raystack/components/data-view-beta/components/display-access.tsx b/packages/raystack/components/data-view-beta/components/display-access.tsx new file mode 100644 index 000000000..cf1f5ff23 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/display-properties.tsx b/packages/raystack/components/data-view-beta/components/display-properties.tsx new file mode 100644 index 000000000..3a67229b2 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/display-settings.tsx b/packages/raystack/components/data-view-beta/components/display-settings.tsx new file mode 100644 index 000000000..f1f1fa249 --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/display-settings.tsx @@ -0,0 +1,131 @@ +'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'; +import { ViewSwitcher } from './view-switcher'; + +interface DisplaySettingsProps { + trigger?: ReactNode; +} + +export function DisplaySettings({ + trigger = ( + + ) +}: DisplaySettingsProps) { + const { + fields, + updateTableQuery, + tableQuery, + defaultSort, + onDisplaySettingsReset, + views + } = 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(); + } + + const showViewSwitcher = (views?.length ?? 0) > 1; + + return ( + + {trigger}} + /> + + + {showViewSwitcher ? ( + + + + ) : null} + + + + + + + + + + + + + + ); +} + +DisplaySettings.displayName = 'DataView.DisplayControls'; diff --git a/packages/raystack/components/data-view-beta/components/empty-state.tsx b/packages/raystack/components/data-view-beta/components/empty-state.tsx new file mode 100644 index 000000000..672e7e7a9 --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/empty-state.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { ReactNode } from 'react'; +import styles from '../data-view.module.css'; +import { useDataView } from '../hooks/useDataView'; + +export interface DataViewEmptyStateProps { + /** Restrict to a specific view's `name`. When set, the EmptyState only renders if both `isEmptyState` is true AND the active view matches. */ + forView?: string; + className?: string; + children: ReactNode; +} + +/** + * Renders its children when the current data + query result in an empty state + * (i.e., a query is active but no rows match). Reads `isEmptyState` from + * DataView context, so the empty/zero distinction is computed in one place. + */ +export function DataViewEmptyState({ + forView, + className, + children +}: DataViewEmptyStateProps) { + const { isEmptyState, activeView } = useDataView(); + if (!isEmptyState) return null; + if (forView && activeView !== forView) return null; + return ( +
{children}
+ ); +} + +DataViewEmptyState.displayName = 'DataView.EmptyState'; diff --git a/packages/raystack/components/data-view-beta/components/filters.tsx b/packages/raystack/components/data-view-beta/components/filters.tsx new file mode 100644 index 000000000..8b7f4220a --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/grouping.tsx b/packages/raystack/components/data-view-beta/components/grouping.tsx new file mode 100644 index 000000000..92e4a04b0 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/list.tsx b/packages/raystack/components/data-view-beta/components/list.tsx new file mode 100644 index 000000000..b233cb1fa --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/list.tsx @@ -0,0 +1,425 @@ +'use client'; + +import { Cross2Icon } 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 { CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; + +import { Badge } from '../../badge'; +import { Button } from '../../button'; +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, + hasActiveTableFiltering +} from '../utils'; + +function formatGridWidth(width: string | number | undefined) { + if (width === undefined) return '1fr'; + if (typeof width === 'number') return `${width}px`; + return width; +} + +export function DataViewList({ + name, + variant = 'list', + showHeaders, + role, + fields: fieldsOverride, + columns, + rowHeight, + virtualized = false, + overscan = 8, + showDividers, + showGroupHeaders = true, + stickyGroupHeader = false, + loadMoreOffset = 100, + classNames = {} +}: DataViewListProps) { + const { + table, + mode, + onRowClick, + isLoading, + loadMoreData, + tableQuery, + totalRowCount, + updateTableQuery, + activeView, + registerFieldsForView, + hasData + } = useDataView(); + + // Register per-view field override so the toolbar's effectiveFields reflects + // this renderer's metadata while it's the active view. + useEffect(() => { + if (!name || !fieldsOverride) return; + return registerFieldsForView(name, fieldsOverride); + }, [name, fieldsOverride, registerFieldsForView]); + + // Multi-view gate. When `name` is set, render only when this is the active + // view. When `name` is unset (single-renderer mode), always render. + const isActive = !name || activeView === undefined || activeView === name; + + const isTableVariant = variant === 'table'; + const headersVisible = showHeaders ?? isTableVariant; + const ariaRole = role ?? (isTableVariant ? 'table' : 'list'); + const dividers = showDividers ?? isTableVariant; + const effectiveRowHeight = rowHeight ?? (isTableVariant ? 40 : 56); + + const visibleLeafColumns = table.getVisibleLeafColumns(); + + const columnMap = useMemo(() => { + const map = new Map>(); + columns.forEach(c => map.set(c.accessorKey, c)); + return map; + }, [columns]); + + // Render order from `columns`, filtered by current 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 gridTemplateColumns = useMemo(() => { + return renderedAccessors + .map(accessor => formatGridWidth(columnMap.get(accessor)?.width)) + .join(' '); + }, [renderedAccessors, columnMap]); + + 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 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 (!isActive) return; + 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; + }; + }, [isActive, 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 : effectiveRowHeight; + }, + overscan + }); + + 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 (!isActive) return null; + if (!hasData) return null; + + const renderHeaderRow = () => { + if (!headersVisible) return null; + return ( +
+
+ {headersInOrder.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} +
+ ); + })} +
+
+ ); + }; + + 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 ( +
+
+ {renderHeaderRow()} + {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-beta/components/ordering.tsx b/packages/raystack/components/data-view-beta/components/ordering.tsx new file mode 100644 index 000000000..4b3ba730f --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/renderer.tsx b/packages/raystack/components/data-view-beta/components/renderer.tsx new file mode 100644 index 000000000..3d7e78627 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/search.tsx b/packages/raystack/components/data-view-beta/components/search.tsx new file mode 100644 index 000000000..330692689 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/table.tsx b/packages/raystack/components/data-view-beta/components/table.tsx new file mode 100644 index 000000000..ea268d3af --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/table.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { DataViewTableProps } from '../data-view.types'; +import { DataViewList } from './list'; + +/** + * @deprecated Prefer ``. Kept as a thin alias + * during the multi-renderer migration. + */ +export function DataViewTable( + props: DataViewTableProps +) { + return {...props} variant='table' />; +} + +DataViewTable.displayName = 'DataView.Table'; diff --git a/packages/raystack/components/data-view-beta/components/toolbar.tsx b/packages/raystack/components/data-view-beta/components/toolbar.tsx new file mode 100644 index 000000000..696dc5e00 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/view-switcher.tsx b/packages/raystack/components/data-view-beta/components/view-switcher.tsx new file mode 100644 index 000000000..833931712 --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/view-switcher.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { Tabs } from '../../tabs'; +import styles from '../data-view.module.css'; +import { useDataView } from '../hooks/useDataView'; + +export interface DataViewViewSwitcherProps { + className?: string; + size?: 'small' | 'medium' | 'large'; +} + +/** + * Renders a tab-based switcher for the configured `views`. Reads `views` and + * `activeView` from DataView context and writes through `setActiveView`. Renders + * nothing when `views` is unset or contains a single entry. + */ +export function ViewSwitcher({ + className, + size = 'small' +}: DataViewViewSwitcherProps) { + const { views, activeView, setActiveView } = useDataView(); + + if (!views || views.length < 2) return null; + + return ( + setActiveView(v)} + size={size} + className={cx(styles.viewSwitcher, className)} + > + + {views.map(v => ( + + {v.label} + + ))} + + + ); +} + +ViewSwitcher.displayName = 'DataView.ViewSwitcher'; diff --git a/packages/raystack/components/data-view-beta/components/virtualized-content.tsx b/packages/raystack/components/data-view-beta/components/virtualized-content.tsx new file mode 100644 index 000000000..9eabfc67d --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/components/zero-state.tsx b/packages/raystack/components/data-view-beta/components/zero-state.tsx new file mode 100644 index 000000000..c5dc7a274 --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/zero-state.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { ReactNode } from 'react'; +import styles from '../data-view.module.css'; +import { useDataView } from '../hooks/useDataView'; + +export interface DataViewZeroStateProps { + /** Restrict to a specific view's `name`. When set, the ZeroState only renders if both `isZeroState` is true AND the active view matches. */ + forView?: string; + className?: string; + children: ReactNode; +} + +/** + * Renders its children when there is no data and no active query (the "first + * use" state). Reads `isZeroState` from DataView context. + */ +export function DataViewZeroState({ + forView, + className, + children +}: DataViewZeroStateProps) { + const { isZeroState, activeView } = useDataView(); + if (!isZeroState) return null; + if (forView && activeView !== forView) return null; + return ( +
{children}
+ ); +} + +DataViewZeroState.displayName = 'DataView.ZeroState'; diff --git a/packages/raystack/components/data-view-beta/context.tsx b/packages/raystack/components/data-view-beta/context.tsx new file mode 100644 index 000000000..bfd4da0fa --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/data-view.module.css b/packages/raystack/components/data-view-beta/data-view.module.css new file mode 100644 index 000000000..23376a41f --- /dev/null +++ b/packages/raystack/components/data-view-beta/data-view.module.css @@ -0,0 +1,320 @@ +.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 (handles both variant="table" and variant="list") */ +.listRoot { + width: 100%; + height: 100%; + overflow: auto; + background: var(--rs-color-background-base-primary); +} + +.listGrid { + display: grid; + width: 100%; + align-items: stretch; +} + +.listHeader { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + position: sticky; + top: 0; + z-index: 1; + background: var(--rs-color-background-base-primary); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.listHeaderRow { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; +} + +.listHeaderCell { + padding: var(--rs-space-3) var(--rs-space-4); + min-width: 0; + display: flex; + align-items: center; + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-base-secondary); + box-sizing: border-box; +} + +.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; +} + +.listGroupHeaderSticky { + position: sticky; + top: var(--rs-space-10); + z-index: 1; +} + +.listEmptyState { + display: flex; + justify-content: center; + align-items: center; + padding: var(--rs-space-9) 0; + width: 100%; + box-sizing: border-box; +} + +/* Empty / Zero state sibling container */ +.dataStateContainer { + display: flex; + justify-content: center; + align-items: center; + padding: var(--rs-space-9) 0; + width: 100%; + box-sizing: border-box; +} + +/* Multi-view switcher inside DisplayControls */ +.viewSwitcher { + flex-shrink: 0; +} diff --git a/packages/raystack/components/data-view-beta/data-view.tsx b/packages/raystack/components/data-view-beta/data-view.tsx new file mode 100644 index 000000000..02b284dc4 --- /dev/null +++ b/packages/raystack/components/data-view-beta/data-view.tsx @@ -0,0 +1,337 @@ +'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 { DataViewCustom } from './components/custom'; +import { DisplayAccess } from './components/display-access'; +import { DisplaySettings } from './components/display-settings'; +import { DataViewEmptyState } from './components/empty-state'; +import { Filters } from './components/filters'; +import { DataViewList } from './components/list'; +import { DataViewSearch } from './components/search'; +import { DataViewTable } from './components/table'; +import { Toolbar } from './components/toolbar'; +import { ViewSwitcher } from './components/view-switcher'; +import { VirtualizedContent } from './components/virtualized-content'; +import { DataViewZeroState } from './components/zero-state'; +import { DataViewContext } from './context'; +import { + DataViewContextType, + DataViewField, + DataViewProps, + defaultGroupOption, + GroupedData, + InternalQuery, + TableQueryUpdateFn +} from './data-view.types'; +import { + hasActiveQuery as computeHasActiveQuery, + 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, + views, + defaultView, + view, + onViewChange +}: React.PropsWithChildren>) { + const defaultTableQuery = useMemo( + () => getDefaultTableQuery(defaultSort, query), + [defaultSort, query] + ); + + // Active view (controlled / uncontrolled) + const isViewControlled = view !== undefined; + const [internalActiveView, setInternalActiveView] = useState< + string | undefined + >(defaultView ?? views?.[0]?.value); + const activeView = isViewControlled ? view : internalActiveView; + const setActiveView = useCallback( + (next: string) => { + if (!isViewControlled) setInternalActiveView(next); + onViewChange?.(next); + }, + [isViewControlled, onViewChange] + ); + + // Per-view field overrides registered by mounted renderers. + const [fieldsByView, setFieldsByView] = useState< + Record[]> + >({}); + + const registerFieldsForView = useCallback( + (name: string, override: DataViewField[]) => { + setFieldsByView(prev => { + if (prev[name] === override) return prev; + return { ...prev, [name]: override }; + }); + return () => { + setFieldsByView(prev => { + if (!(name in prev)) return prev; + const next = { ...prev }; + delete next[name]; + return next; + }); + }; + }, + [] + ); + + const effectiveFields = useMemo(() => { + if (activeView && fieldsByView[activeView]) return fieldsByView[activeView]; + return fields; + }, [activeView, fieldsByView, fields]); + + 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]; + + // Build column defs from the effective (active-view) fields so toolbar + + // headless filter/sort/visibility track per-view metadata overrides. + const columnDefs = useMemo( + () => fieldsToColumnDefs(effectiveFields, tableQuery.filters), + [effectiveFields, tableQuery.filters] + ); + + const groupedData = useMemo( + () => groupData(data, group_by, effectiveFields), + [data, group_by, effectiveFields] + ); + + 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]); + + const rowCount = (() => { + try { + return table.getRowModel().rows.length; + } catch { + return data.length; + } + })(); + + const hasData = rowCount > 0 || isLoading; + + const hasActiveQueryFlag = useMemo( + () => computeHasActiveQuery(tableQuery, defaultSort), + [tableQuery, defaultSort] + ); + + const isZeroState = !hasData && !hasActiveQueryFlag; + const isEmptyState = !hasData && hasActiveQueryFlag; + + // Filters bar: visible when there's data OR an active filter, but hidden + // for the pure zero-state (no data + no query) so the empty surface stays clean. + const shouldShowFilters = hasData || hasActiveQueryFlag; + + const contextValue: DataViewContextType = useMemo(() => { + return { + table, + fields: effectiveFields, + rootFields: fields, + mode, + isLoading, + loadMoreData, + tableQuery, + updateTableQuery, + onDisplaySettingsReset, + defaultSort, + totalRowCount, + loadingRowCount, + onRowClick, + shouldShowFilters, + views, + activeView, + setActiveView, + registerFieldsForView, + hasData, + hasActiveQuery: hasActiveQueryFlag, + isZeroState, + isEmptyState + }; + }, [ + table, + effectiveFields, + fields, + mode, + isLoading, + loadMoreData, + tableQuery, + onDisplaySettingsReset, + defaultSort, + totalRowCount, + loadingRowCount, + onRowClick, + shouldShowFilters, + views, + activeView, + setActiveView, + registerFieldsForView, + hasData, + hasActiveQueryFlag, + isZeroState, + isEmptyState + ]); + + return {children}; +} + +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 + List: DataViewList, + /** @deprecated Use `` */ + Table: DataViewTable, + // Escape hatch — render prop receives the full DataView context. + Custom: DataViewCustom, + /** @deprecated Renamed to `DataView.Custom`. */ + Renderer: DataViewCustom, + // Legacy sub-renderer exports (used by consumers that imported inner pieces). + Content: Content, + VirtualizedContent: VirtualizedContent, + // Visibility primitive for free-form renderers. + DisplayAccess: DisplayAccess, + // Empty / zero state siblings (driven by context). + EmptyState: DataViewEmptyState, + ZeroState: DataViewZeroState, + // View switching primitive (used by DisplayControls; can be placed standalone). + ViewSwitcher: ViewSwitcher, + // Toolbar primitives + Toolbar: Toolbar, + Search: DataViewSearch, + Filters: Filters, + DisplayControls: DisplaySettings +}); diff --git a/packages/raystack/components/data-view-beta/data-view.types.tsx b/packages/raystack/components/data-view-beta/data-view.types.tsx new file mode 100644 index 000000000..297d49197 --- /dev/null +++ b/packages/raystack/components/data-view-beta/data-view.types.tsx @@ -0,0 +1,279 @@ +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; +} + +/** + * Unified column spec for DataView.List. Same shape used for both + * `variant="table"` and `variant="list"`. The `header` slot is only rendered + * when headers are visible (default for `variant="table"`). + */ +export interface DataViewListColumn { + accessorKey: string; + /** TanStack-style cell renderer. */ + cell?: ColumnDef['cell']; + /** TanStack-style header renderer. Overrides the field's `label`. */ + header?: ColumnDef['header']; + /** CSS grid track width. `1fr`, `auto`, `'200px'`, `'minmax(80px, 1fr)'`, or a number (pixels). Defaults to `1fr`. */ + width?: string | number; + classNames?: { cell?: string; header?: string }; + styles?: { cell?: React.CSSProperties; header?: React.CSSProperties }; +} + +/** + * @deprecated Use `DataViewListColumn` with ``. + * Kept as a type alias for backwards compatibility. + */ +export type DataViewTableColumn = DataViewListColumn< + TData, + TValue +>; + +/** + * One entry in the multi-view configuration. `value` matches the `name` prop on + * a renderer; `label` shows in the view switcher. + */ +export interface ViewSpec { + value: string; + label: string; + icon?: React.ReactNode; +} + +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; + /** Multi-view configuration. When set, the toolbar's DisplayControls renders a view switcher and renderers gate themselves on the active view via their `name` prop. */ + views?: ViewSpec[]; + /** Default active view (uncontrolled). Should match a `views[].value`. */ + defaultView?: string; + /** Active view (controlled). */ + view?: string; + /** Called when the active view changes. */ + onViewChange?: (view: string) => void; +} + +export type DataViewContentClassNames = { + root?: string; + table?: string; + header?: string; + body?: string; + row?: string; +}; + +export type DataViewListClassNames = { + root?: string; + header?: string; + headerCell?: string; + row?: string; + cell?: string; + groupHeader?: string; +}; + +export interface DataViewListProps { + /** Multi-view name. When set, the renderer gates itself on the active view. */ + name?: string; + /** Visual variant. `table` renders headers and uses `role="table"`; `list` renders no headers and uses `role="list"`. Default `list`. */ + variant?: 'table' | 'list'; + /** Override the header row visibility. Defaults to `variant === 'table'`. */ + showHeaders?: boolean; + /** Override the ARIA role applied to the renderer root. Defaults to derived from `variant`. */ + role?: 'table' | 'list'; + /** Optional view-scoped field override. Full replacement of root `fields` for this view's active session. */ + fields?: DataViewField[]; + + /** Column render specs (cell/header/width/styles). */ + columns: DataViewListColumn[]; + /** Row height in px. Default 40 for `variant="table"`, 56 for `variant="list"`. */ + 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. Defaults to true for `variant="table"`. */ + showDividers?: boolean; + /** Show group section headers when grouping is active. Default true. */ + showGroupHeaders?: boolean; + /** When true, group headers stick under the table header while scrolling. Default false. */ + stickyGroupHeader?: boolean; + /** Distance in pixels from bottom to trigger load more. */ + loadMoreOffset?: number; + classNames?: DataViewListClassNames; +} + +/** + * @deprecated Pass these to `DataView.List` with `variant="table"` instead. + */ +export type DataViewTableBaseProps = Omit< + DataViewListProps, + 'variant' +>; + +/** + * @deprecated Use `DataViewListProps` with `variant="table"`. + */ +export type DataViewTableProps< + TData, + TValue = unknown +> = DataViewTableBaseProps; + +export type TableQueryUpdateFn = (query: InternalQuery) => InternalQuery; + +export type DataViewContextType = { + table: Table; + /** Effective fields for the active view (= override fields if registered, else root fields). */ + fields: DataViewField[]; + /** Root-declared fields, unchanged by view overrides. */ + rootFields: 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; + + // multi-view + views?: ViewSpec[]; + activeView?: string; + setActiveView: (view: string) => void; + /** Called by each renderer on mount to register its `fields` override for its `name`. Returns a cleanup function. */ + registerFieldsForView: ( + name: string, + fields: DataViewField[] + ) => () => void; + + // global derived state — shared across all renderers and sibling components + hasData: boolean; + hasActiveQuery: boolean; + isZeroState: boolean; + isEmptyState: 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-beta/hooks/useDataView.tsx b/packages/raystack/components/data-view-beta/hooks/useDataView.tsx new file mode 100644 index 000000000..2ae93e9ed --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/hooks/useFilters.tsx b/packages/raystack/components/data-view-beta/hooks/useFilters.tsx new file mode 100644 index 000000000..31530833c --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/index.ts b/packages/raystack/components/data-view-beta/index.ts new file mode 100644 index 000000000..60f37f6a5 --- /dev/null +++ b/packages/raystack/components/data-view-beta/index.ts @@ -0,0 +1,24 @@ +export { EmptyFilterValue } from '~/types/filters'; +export type { DataViewCustomProps } from './components/custom'; +export type { DataViewDisplayAccessProps } from './components/display-access'; +export type { DataViewEmptyStateProps } from './components/empty-state'; +export type { DataViewSearchProps } from './components/search'; +export type { DataViewViewSwitcherProps } from './components/view-switcher'; +export type { DataViewZeroStateProps } from './components/zero-state'; +export { DataView } from './data-view'; +export { + DataViewField, + DataViewFilter, + DataViewListClassNames, + DataViewListColumn, + DataViewListProps, + DataViewProps, + DataViewQuery, + DataViewSort, + DataViewTableColumn, + DataViewTableProps, + InternalFilter, + InternalQuery, + ViewSpec +} from './data-view.types'; +export { useDataView } from './hooks/useDataView'; diff --git a/packages/raystack/components/data-view-beta/utils/filter-operations.tsx b/packages/raystack/components/data-view-beta/utils/filter-operations.tsx new file mode 100644 index 000000000..dcaa28db8 --- /dev/null +++ b/packages/raystack/components/data-view-beta/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-beta/utils/index.tsx b/packages/raystack/components/data-view-beta/utils/index.tsx new file mode 100644 index 000000000..b0adfba9f --- /dev/null +++ b/packages/raystack/components/data-view-beta/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..7da5422a0 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-beta'; export { Dialog } from './components/dialog'; export { Drawer } from './components/drawer'; export { EmptyState } from './components/empty-state';