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..8851929cd --- /dev/null +++ b/apps/www/src/app/examples/dataview/page.tsx @@ -0,0 +1,649 @@ +/** biome-ignore-all lint/suspicious/noShadowRestrictedNames: TODO: look into this later */ +'use client'; + +import { CalendarIcon } from '@radix-ui/react-icons'; +import { + Avatar, + AvatarGroup, + Badge, + Chip, + DataView, + type DataViewField, + type DataViewListColumn, + type DataViewTableColumn, + EmptyState, + Flex, + getAvatarColor, + IconButton, + Indicator, + Navbar, + Sidebar, + Tabs, + Text +} from '@raystack/apsara'; +import { BellIcon, FilterIcon, SidebarIcon } from '@raystack/apsara/icons'; +import { useState } from 'react'; + +type ProfileCell = { row: { original: Profile } }; + +type Profile = { + id: string; + name: string; + subheading: string; + role: 'Admin' | 'User' | 'Manager'; + label: string; + status: 'Active' | 'Away' | 'Offline'; + collaborators: { id: string; name: string }[]; + team: string; + updatedAt: string; +}; + +const profiles: Profile[] = [ + { + id: '1', + name: 'Alice Cooper', + subheading: 'alice@example.com', + role: 'Admin', + label: 'Platform Lead', + status: 'Active', + collaborators: [ + { id: 'c1', name: 'Bob' }, + { id: 'c2', name: 'Carol' }, + { id: 'c3', name: 'Dan' } + ], + team: 'Frontend', + updatedAt: '2024-02-15' + }, + { + id: '2', + name: 'Bob Nguyen', + subheading: 'bob@example.com', + role: 'User', + label: 'Designer', + status: 'Active', + collaborators: [ + { id: 'c4', name: 'Eve' }, + { id: 'c5', name: 'Grace' } + ], + team: 'Design', + updatedAt: '2024-03-01' + }, + { + id: '3', + name: 'Carol Park', + subheading: 'carol@example.com', + role: 'Manager', + label: 'Backend Mgr', + status: 'Active', + collaborators: [ + { id: 'c6', name: 'Henry' }, + { id: 'c7', name: 'Ryan' }, + { id: 'c8', name: 'Wendy' }, + { id: 'c9', name: 'Leo' } + ], + team: 'Backend', + updatedAt: '2024-01-22' + }, + { + id: '4', + name: 'Dave Sanders', + subheading: 'dave@example.com', + role: 'User', + label: 'Sales AE', + status: 'Away', + collaborators: [{ id: 'c10', name: 'Paul' }], + team: 'Sales East', + updatedAt: '2024-02-28' + }, + { + id: '5', + name: 'Eve Okafor', + subheading: 'eve@example.com', + role: 'Admin', + label: 'Eng Lead', + status: 'Active', + collaborators: [ + { id: 'c11', name: 'Uma' }, + { id: 'c12', name: 'Jack' } + ], + team: 'Frontend', + updatedAt: '2024-03-10' + }, + { + id: '6', + name: 'Frank Liu', + subheading: 'frank@example.com', + role: 'User', + label: 'Support', + status: 'Active', + collaborators: [], + team: 'Tier 1', + updatedAt: '2024-03-04' + }, + { + id: '7', + name: 'Grace Romero', + subheading: 'grace@example.com', + role: 'Manager', + label: 'Design Mgr', + status: 'Active', + collaborators: [ + { id: 'c13', name: 'Bob' }, + { id: 'c14', name: 'Mia' }, + { id: 'c15', name: 'Tom' } + ], + team: 'Design', + updatedAt: '2024-02-02' + }, + { + id: '8', + name: 'Henry Becker', + subheading: 'henry@example.com', + role: 'Admin', + label: 'SRE', + status: 'Offline', + collaborators: [ + { id: 'c16', name: 'Carol' }, + { id: 'c17', name: 'Amy' } + ], + team: 'DevOps', + updatedAt: '2024-01-11' + }, + { + id: '9', + name: 'Ivy Chen', + subheading: 'ivy@example.com', + role: 'User', + label: 'Content Writer', + status: 'Active', + collaborators: [{ id: 'c18', name: 'Quinn' }], + team: 'Content', + updatedAt: '2024-03-08' + }, + { + id: '10', + name: 'Jack Patel', + subheading: 'jack@example.com', + role: 'User', + label: 'Frontend Eng', + status: 'Active', + collaborators: [ + { id: 'c19', name: 'Alice' }, + { id: 'c20', name: 'Eve' }, + { id: 'c21', name: 'Olivia' } + ], + team: 'Frontend', + updatedAt: '2024-02-20' + }, + { + id: '11', + name: 'Kate Rhodes', + subheading: 'kate@example.com', + role: 'Manager', + label: 'Sales Mgr', + status: 'Active', + collaborators: [ + { id: 'c22', name: 'Victor' }, + { id: 'c23', name: 'Dave' } + ], + team: 'Sales West', + updatedAt: '2024-01-30' + }, + { + id: '12', + name: 'Leo Braganza', + subheading: 'leo@example.com', + role: 'Admin', + label: 'DevOps Lead', + status: 'Active', + collaborators: [ + { id: 'c24', name: 'Amy' }, + { id: 'c25', name: 'Henry' } + ], + team: 'DevOps', + updatedAt: '2024-02-11' + } +]; + +const STATUS_COLOR: Record< + Profile['status'], + 'success' | 'warning' | 'neutral' +> = { + Active: 'success', + Away: 'warning', + Offline: 'neutral' +}; + +// Cell renderers shared between Table and List renderers. +const renderNameCell = ({ row }: ProfileCell) => ( + + + + + {row.original.name} + + + {row.original.subheading} + + + +); + +const renderEmailCell = ({ row }: ProfileCell) => ( + + {row.original.subheading} + +); + +const renderRoleCell = ({ row }: ProfileCell) => ( + {row.original.role} +); + +const renderLabelCell = ({ row }: ProfileCell) => ( + + {row.original.label} + +); + +const renderTeamCell = ({ row }: ProfileCell) => ( + + {row.original.team} + +); + +const renderStatusCell = ({ row }: ProfileCell) => { + const status = row.original.status; + return {status}; +}; + +const renderCollaboratorsCell = ({ row }: ProfileCell) => { + const collaborators = row.original.collaborators; + if (!collaborators.length) { + return ( + + — + + ); + } + return ( + + {collaborators.map(c => ( + + ))} + + ); +}; + +const renderUpdatedAtCell = ({ row }: ProfileCell) => ( + + {row.original.updatedAt} + +); + +// Renderer-agnostic metadata — drives filters, sort, group, visibility across +// every renderer (Table, List, Timeline, …). +const fields: DataViewField[] = [ + { + accessorKey: 'name', + label: 'Name', + filterable: true, + filterType: 'string', + sortable: true, + hideable: false + }, + { + accessorKey: 'subheading', + label: 'Email', + filterable: true, + filterType: 'string', + hideable: true + }, + { + accessorKey: 'role', + label: 'Role', + filterable: true, + filterType: 'select', + groupable: true, + hideable: true, + showGroupCount: true, + filterOptions: [ + { value: 'Admin', label: 'Admin' }, + { value: 'User', label: 'User' }, + { value: 'Manager', label: 'Manager' } + ] + }, + { + accessorKey: 'label', + label: 'Label', + filterable: true, + filterType: 'string', + hideable: true + }, + { + accessorKey: 'team', + label: 'Team', + filterable: true, + filterType: 'string', + groupable: true, + hideable: true + }, + { + accessorKey: 'status', + label: 'Status', + filterable: true, + filterType: 'select', + groupable: true, + hideable: true, + filterOptions: [ + { value: 'Active', label: 'Active' }, + { value: 'Away', label: 'Away' }, + { value: 'Offline', label: 'Offline' } + ] + }, + { + accessorKey: 'collaborators', + label: 'Collaborators', + hideable: true + }, + { + accessorKey: 'updatedAt', + label: 'Updated', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + } +]; + +// Table renderer spec — pairs a cell renderer to each field that should +// appear as a table column. +const tableColumns: DataViewTableColumn[] = [ + { accessorKey: 'name', cell: renderNameCell }, + { accessorKey: 'subheading', cell: renderEmailCell }, + { accessorKey: 'role', cell: renderRoleCell }, + { accessorKey: 'label', cell: renderLabelCell }, + { accessorKey: 'team', cell: renderTeamCell }, + { accessorKey: 'status', cell: renderStatusCell }, + { accessorKey: 'collaborators', cell: renderCollaboratorsCell }, + { accessorKey: 'updatedAt', cell: renderUpdatedAtCell } +]; + +// List renderer spec — composes Name+Email into a single cell and omits +// fields it doesn't show. Each column gets its own grid track width. +const listColumns: DataViewListColumn[] = [ + { + accessorKey: 'name', + cell: renderNameCell, + width: 'minmax(240px, 1.5fr)' + }, + { + accessorKey: 'label', + cell: renderLabelCell, + width: 'minmax(160px, 1fr)' + }, + { + accessorKey: 'team', + cell: renderTeamCell, + width: 'minmax(140px, 1fr)' + }, + { + accessorKey: 'collaborators', + cell: renderCollaboratorsCell, + width: 'auto' + }, + { + accessorKey: 'status', + cell: renderStatusCell, + width: '120px' + } +]; + +const 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} + + + + + ); +} + +type ViewMode = 'table' | 'list' | 'custom'; + +const Page = () => { + const [view, setView] = useState('table'); + + 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} + > + + + + setView(v as ViewMode)} + size='small' + style={{ width: '400px' }} + > + + Table View + List View + Custom View + + + + + + {view === 'table' && ( + } + heading='No matching people' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + } + /> + )} + {view === 'list' && ( + } + heading='No matching people' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + } + /> + )} + {view === 'custom' && ( + > + {({ table }) => { + const rows = table + .getRowModel() + .rows.filter(r => !r.subRows?.length); + if (!rows.length) { + return ( + } + heading='No matching people' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + ); + } + return ( +
+ {rows.map(row => ( + + ))} +
+ ); + }} + + )} + +
+
+
+ ); +}; + +export default Page; diff --git a/docs/rfcs/002-unified-dataview-component.md b/docs/rfcs/002-unified-dataview-component.md new file mode 100644 index 000000000..a9eeb0a8f --- /dev/null +++ b/docs/rfcs/002-unified-dataview-component.md @@ -0,0 +1,428 @@ +--- +ID: RFC 002 +Created: April 23, 2026 +Status: Draft +RFC PR: https://github.com/raystack/apsara/pull/752 +--- + +# Unified DataView Component + +This RFC proposes replacing the current `DataTable` with a unified `DataView` root that owns data-modeling state and exposes swappable renderer subcomponents (Table, List, Timeline, Custom), so the same query/filter/sort/group/search state can drive multiple presentations. + +## Table of Contents + +- [Unified DataView Component](#unified-dataview-component) + - [Table of Contents](#table-of-contents) + - [Background](#background) + - [Current Problems](#current-problems) + - [Proposal](#proposal) + - [Why a Unified DataView?](#why-a-unified-dataview) + - [Pros and Cons](#pros-and-cons) + - [Pros](#pros) + - [Cons](#cons) + - [Differences and Analysis](#differences-and-analysis) + - [General Differences from DataTable](#general-differences-from-datatable) + - [Root Owns Data, Renderers Own Presentation](#root-owns-data-renderers-own-presentation) + - [`columns` Renamed to `fields` on Root](#columns-renamed-to-fields-on-root) + - [Explicit Toolbar Composition](#explicit-toolbar-composition) + - [Unified Column Visibility via `DisplayAccess`](#unified-column-visibility-via-displayaccess) + - [Virtualization as a Prop, Not a Component](#virtualization-as-a-prop-not-a-component) + - [Renderer-Specific Differences](#renderer-specific-differences) + - [Table](#table) + - [List](#list) + - [Timeline](#timeline) + - [Custom](#custom) + - [Grouping](#grouping) + - [Table of Comparison](#table-of-comparison) + - [Impact](#impact) + - [Discarded Approaches and Considerations](#discarded-approaches-and-considerations) + - [Rejected Alternatives](#rejected-alternatives) + - [Scoped-Out Decisions](#scoped-out-decisions) + - [Helpful Links](#helpful-links) + +## Background + +Apsara currently ships a single data-presentation primitive: `DataTable` (`packages/raystack/components/data-table/`). It bundles two layers that are conceptually separate: + +- **Data-modeling layer** — query state (`filters`, `sort`, `group_by`, `search`, `offset`, `limit`), client-vs-server mode, row model derivation via TanStack Table. +- **Tabular rendering layer** — table header/body/row/cell DOM, column visibility UI, virtualization, sticky group headers. + +Consumer apps increasingly need non-tabular presentations of the same data: list views (person cards), timeline / Gantt views (range bars on a time axis), and ad-hoc custom renderers (Kanban, Gallery, Map). Today each of these would have to re-implement the data-modeling layer from scratch. + +### Current Problems + +- **Layer 1 is not reusable.** Query state, filter predicates, client/server mode, `groupData`, and the `useFilters` hook all live inside `DataTable` and cannot drive any non-tabular renderer without duplication. +- **No cross-view state persistence.** A user who applies filters/sorts in a tabular view and switches to a list/timeline view loses that state because each view owns its own data layer. +- **Grouping is accessor-only.** `groupData()` groups by a plain `accessorKey` string. Timeline-style bucketing (by day/week/month) or "updated this week / earlier" grouping in a list view is not expressible. +- **Visibility story does not generalize.** Column visibility is hard-coded to `` collapse in the table renderer; non-columnar renderers (timeline bars, custom cards) have no uniform way to react to the Display Properties toggle, so the control silently no-ops for them. +- **Table-specific concerns leak into shared state.** `stickyGroupHeader`, `VirtualizedContent` as a separate export, and `shouldShowFilters` mixing data-layer checks with `table.getRowModel()` all blur the line between data and presentation. + +**Roughly 80% of today's `DataTable` logic is already renderer-agnostic; the work is largely an extraction + renaming exercise rather than a rewrite.** + +## Proposal + +We propose introducing a single `DataView` root that owns the data-modeling layer, alongside swappable renderer subcomponents for presentation: + +- `DataView.Toolbar`, `DataView.Search`, `DataView.Filters`, `DataView.DisplayControls` — presentation-agnostic controls that read/write query state through context. +- `DataView.Table` — the current tabular renderer, unchanged in behavior. Takes a `columns` prop of `DataViewTableColumn[]`. +- `DataView.List` — CSS `grid` + `subgrid` renderer. Shares the column-spec shape with Table (drops `header`, adds a grid-track `width`). +- `DataView.Timeline` — variable-width range bars on a continuous time axis (Gantt-style). Takes `startField`, `endField`, `renderBar`. +- `DataView.Custom` — escape hatch; render lives in children, context is passed as argument. +- `DataView.DisplayAccess` — foundational visibility primitive: wraps any JSX and gates it on the current `columnVisibility` state so non-columnar renderers honor the same Display Properties toggle. + +Target API: + +```tsx + + + + + + + + {/* pick one — or switch between them with a tab/toggle */} + + + ( + + + {row.getValue('title')} + + + {row.getValue('priority')} + + + )} + /> + {(api) => /* ... */} + +``` + +The migration will preserve the existing `DataTable` export as a thin alias over `` + `` through at least one major version, so consumers can migrate incrementally. + +## Why a Unified DataView? + +A `DataView` root with swappable renderers is the right fit for Apsara's needs: + +- **Headless core already exists.** TanStack Table (already a dependency) is headless — the `table` object produces a `Row` tree from `data + column defs + state` and emits no DOM. The same row model can drive Table, List, Timeline, or anything else. +- **Presentation-agnostic logic is already >80% of the surface.** Query state, filter predicates (`filterOperationsMap`), `useFilters`, wire-format translation (`transformToDataTableQuery` / `dataTableQueryToInternal`), client/server mode — all of it reuses as-is. +- **Cross-renderer state persistence comes for free.** Because filters/sort/search live on context, switching from Table to List to Timeline inside one `DataView` preserves the user's query state with zero wiring. +- **Familiar composition pattern.** Same `.` idiom already used by every Apsara primitive (Dialog, Popover, Select, etc.) — no new mental model. +- **Open to future renderers.** `DataView.Custom` + shared context supports Kanban, Gallery, Map, or a third-party `` without any root changes. +- **No new dependency.** TanStack Table is already used by `DataTable`; this RFC only restructures how its output is consumed. + +## Pros and Cons + +### Pros + +- **Code reuse**: One data layer serves every renderer. Eliminates the duplicate-or-fork tax on new presentation formats. +- **Consistent UX**: Filters, search, sort, grouping, and display-visibility work identically across Table/List/Timeline/Custom. +- **Cross-view state persistence**: Users can toggle between renderers in the same `` without losing query state. +- **Unified visibility story**: One `DisplayControls` component drives both columnar (Table/List) and non-columnar (Timeline, Custom) renderers via ``. +- **Cleaner separation of concerns**: Props live where they're read — renderer knobs on the renderer, data-layer concerns on the root. +- **Non-breaking migration path**: `DataTable` stays as an alias; most work is additive (new renderers), not refactor. +- **Richer grouping**: Function resolvers unlock "group by week", "group by status bucket", etc. — impossible with today's accessor-only API. +- **Future-proof**: `DataView.Custom` + `DisplayAccess` handle any future renderer without touching the root type. + +### Cons + +- **Surface area grows**: Three new renderer components (List, Timeline, Custom) plus `DisplayAccess` need to be designed, documented, and tested. +- **Two API shapes at once**: During migration, both `DataTable` (alias) and `DataView` (new) co-exist. Cognitive cost for consumers until the alias is removed. +- **Timeline complexity**: Two-axis virtualization + lane packing + time-axis math is genuinely new code (~15 lines for the packer, plus the axis component and virtualization glue). +- **DisplayAccess adoption**: Consumers building Timeline/Custom renderers must remember to wrap fields in ``, or the Display Properties toggle silently no-ops for those renderers. Mitigated by a dev warning at mount. +- **Shared column-spec shape invites visual drift**: Table and List share the spec but need intentionally different chrome. Risk of the two bleeding into each other visually; handled by design-review per renderer and token-driven styling. + +## Differences and Analysis + +### General Differences from DataTable + +#### Root Owns Data, Renderers Own Presentation + +- `DataTable`: data props, render specs, and rendering all live on a single component. +- `DataView`: root takes only data-layer props (`data`, `fields`, `defaultSort`, `query`, `mode`, `isLoading`, `onQueryChange`, `onLoadMore`, `onItemClick`, ...). Each renderer subcomponent takes its own render spec (`columns` for Table/List; `startField`/`endField`/`renderBar` for Timeline). + +```tsx +// Before + + + + + +// After + + + + + + + + +``` + +> [!NOTE] +> **Analysis** +> +> Props live where they're read. `columns` is consumed only by column-based renderers (Table, List); `renderBar` only by Timeline. Declaring them on each renderer is the natural React shape and keeps the root type small. + +#### `columns` Renamed to `fields` on Root + +- `DataTable`: `columns` prop mixed field metadata (filterable, sortable, groupable) with cell/header renderers. +- `DataView`: `fields` on root carries only presentation-agnostic metadata. Cell/header renderers live on `columns` declared per-renderer (`DataView.Table`, `DataView.List`). + +```ts +// Field — presentation-agnostic +interface DataViewField { + accessorKey: Extract; + label: string; + filterable?: boolean; + filterType?: FilterTypes; + sortable?: boolean; + groupable?: boolean; + hideable?: boolean; + defaultHidden?: boolean; + // ... filter capability, group presentation +} + +// Renderer column — pure reference + cell rendering +interface DataViewTableColumn { + accessorKey: Extract; // pointer into fields[] + cell?: ColumnDef['cell']; + header?: ColumnDef['header']; + classNames?: { cell?: string; header?: string }; + styles?: { cell?: CSSProperties; header?: CSSProperties }; +} +``` + +> [!NOTE] +> **Analysis** +> +> Disambiguates metadata (shared across renderers) from render spec (per-renderer). Also renames `enableColumnFilter` → `filterable` et al. to drop table-speak. Old prop names can be kept as aliases for one release. + +#### Explicit Toolbar Composition + +- `DataTable`: `` auto-renders `Filters + DisplaySettings`; `Search` is a separate peer. +- `DataView`: user composes children explicitly. + +```tsx +// Before — Toolbar is opaque + + + +// After — explicit composition + + + + + {/* user can also add: , bulk-action chips, etc. */} + +``` + +> [!NOTE] +> **Analysis** +> +> Lets consumers place search outside the toolbar (common in master-detail layouts), add custom actions (bulk actions, "Export", "New"), and reorder elements. Small cost in verbosity; large gain in flexibility. + +#### Unified Column Visibility via `DisplayAccess` + +- `DataTable`: column visibility is local TanStack state; only the `` renderer reacts to it. +- `DataView`: `columnVisibility` + `setColumnVisibility` are lifted onto context. Table and List gate columns internally (hidden grid tracks / `` collapse). Timeline and Custom use `` to wrap any JSX and reactively hide/show it. + +```tsx +// Inside a Timeline bar + ( + + + {row.getValue('priority')} + + + {row.getValue('title')} + + + )} +/> +``` + +> [!NOTE] +> **Analysis** +> +> Without this primitive, non-columnar renderers would each need a bespoke visibility mechanism — or the toggle would silently no-op. `DisplayAccess` is the one cross-renderer primitive consumers compose inside `renderBar` or custom renderers. Table/List don't need it at the call site — their `columns` already carry `accessorKey`, so the renderer gates visibility internally from the same context state. A dev warning fires at mount if a `hideable: true` field is referenced by neither a column spec nor any DisplayAccess instance. + +#### Virtualization as a Prop, Not a Component + +- `DataTable`: exports both `DataTable.Content` and `DataTable.VirtualizedContent` as separate components. +- `DataView`: virtualization is a prop on the renderer. + +```tsx +// Before + + +// After + +``` + +> [!NOTE] +> **Analysis** +> +> Cleaner API. Both exports can coexist during migration. + +### Renderer-Specific Differences + +#### Table + +- `DataTable.Content`: renders `` with `flexRender(columnDef.cell)` per cell, `columnDef.header` per header. +- `DataView.Table`: same renderer internals, same `flexRender` pipeline, same sticky group header, same virtualization. Difference is the source of its column spec (a local `columns` prop, not a root prop) and that it reads context for query state / visibility. + +> [!NOTE] +> **Analysis** +> +> Behaviorally unchanged. The extraction is mechanical. + +#### List + +- `DataTable`: no list renderer exists today; consumers roll their own. +- `DataView.List`: shares the column-spec shape with Table (drops `header`, adds `width`). Renders rows inside a CSS `grid` container; each row is `display: subgrid; grid-column: 1 / -1` so cells align vertically across rows. + +```ts +interface DataViewListColumn { + accessorKey: Extract; + cell?: ColumnDef['cell']; + width?: string | number; // CSS grid track — '1fr' | '200px' | 'auto' | 'minmax(80px, 1fr)' | number(px) + classNames?: { cell?: string }; + styles?: { cell?: CSSProperties }; +} +``` + +> [!NOTE] +> **Analysis** +> +> Column visibility works identically — `table.getVisibleLeafColumns()` already respects `ctx.columnVisibility`, so toggling a column off collapses its grid track across every row with no extra code. Swapping `` ↔ `` is close to a tag change. + +#### Timeline + +- `DataTable`: no timeline renderer exists. +- `DataView.Timeline`: variable-width range bars on a continuous time axis. Uses `renderBar(row)` because a shared `grid-template-columns` can't fit both 1-day and month-long bars. Visibility inside the bar is composed via ``. + +```tsx +) => ReactNode + scale?: 'day' | 'week' | 'month' | 'quarter' + today?: boolean | Date + lanePacking?: 'auto' | 'one-per-row' + rowHeight?: number + laneGap?: number + viewportRange?: [Date, Date] + onViewportChange?: (range: [Date, Date]) => void + renderLaneGroup?: (group: GroupedData) => ReactNode +/> +``` + +> [!NOTE] +> **Analysis** +> +> Timeline bypasses `groupData` and buckets internally (horizontal pixel math). Lane packing is a small, pure utility (`packLanes`, ~15 lines of greedy interval scheduling). Two-axis virtualization (time × lanes) is solvable with one `useVirtualizer` per axis. None of this leaks into the data layer. + +#### Custom + +- `DataTable`: escape hatch requires consumers to build their own root. +- `DataView.Custom`: receives the full context as a render prop argument; users emit any DOM they like. + +```tsx + + {({ rows, fields, tableQuery, updateTableQuery, columnVisibility, ... }) => ( + updateTableQuery(q => ({...q, ...}))} /> + )} + +``` + +> [!NOTE] +> **Analysis** +> +> Keeps the root surface small while supporting unbounded future renderers (Kanban, Gallery, Map, etc.). Third-party renderers compose cleanly via the same pattern. + +#### Grouping + +- `DataTable`: `group_by` is a list of `accessorKey` strings. `groupData()` groups by exact field value. +- `DataView`: `group_by` strings stay on the wire (so server-mode is unchanged), but an optional `groupByResolvers: Record string>` map on root lets string ids resolve to functions locally. Timeline can bypass `groupData` entirely and bucket by its own pixel math. + +```ts +// Root prop +groupByResolvers?: Record string>; + +// Example: "group by week of createdAt" +groupByResolvers={{ + createdAt_week: (row) => dayjs(row.createdAt).startOf('week').format('YYYY-[W]WW'), +}} +``` + +> [!NOTE] +> **Analysis** +> +> Keeps the wire format (`group_by: string[]`) intact while unlocking non-accessor buckets. Renderers that need bespoke grouping (Timeline) can ignore `group_by` and do their own thing — they still read the same filtered `rows`. + +## Table of Comparison + +| Concern | Today (`DataTable`) | Proposed (`DataView`) | +| :--- | :--- | :--- | +| Query state (`filters`, `sort`, `group_by`, `search`) | In `DataTable` | On `DataView` root context (unchanged shape) | +| Filter predicates (`filterOperationsMap`) | In `DataTable` | Reused as-is | +| Client/server mode | `mode: 'client' \| 'server'` | Same | +| Row model engine | TanStack Table | TanStack Table (unchanged) | +| Column/field metadata | `columns` prop | `fields` prop on root | +| Table cell/header renderers | `columns` prop | `columns` on `DataView.Table` | +| List renderer | Not available | `DataView.List` with grid/subgrid | +| Timeline / Gantt renderer | Not available | `DataView.Timeline` with `renderBar` | +| Custom renderer | Not supported | `DataView.Custom` | +| Toolbar composition | `` auto-renders Filters + DisplaySettings | Explicit children: `Search`, `Filters`, `DisplayControls`, custom actions | +| Virtualization | `DataTable.VirtualizedContent` (separate export) | `virtualized` prop on each renderer | +| Column visibility | TanStack local state, Table-only | Lifted to context; Table/List gate internally, Timeline/Custom use `` | +| Grouping | `group_by: string[]`, accessor-only | Same wire format + optional `groupByResolvers` map | +| Sticky group header | `stickyGroupHeader` on root | Prop on `DataView.Table` (not in shared context) | +| Empty vs zero state | Per-renderer implementation | `emptyState` / `zeroState` props on each renderer; decision driven by root-computed `hasActiveQuery` | +| Infinite scroll (`onLoadMore`, `totalRowCount`) | `DataTable` | `DataView` root (renderer-independent); each renderer detects bottom-reached | + +## Impact + +- **1 component replaced + 4 new renderers added.** `DataTable` becomes a thin alias over `` + `` during migration. +- **Consumers unlock non-tabular presentations with the same data layer.** Existing tabular usage is near-zero-change; list/timeline/custom views are net-new capabilities. +- **Prop surface grows on the renderer side, shrinks on the root side.** Net result is clearer separation of concerns. +- **Breaking changes for deep imports only.** Public `` keeps working through at least one major version. + +## Discarded Approaches and Considerations + +Several alternatives were evaluated and rejected, and a handful of ideas were deliberately scoped out of the root type. Capturing them here so the decisions don't have to be re-litigated. + +### Rejected Alternatives + +- **Hand-rolled query state + predicates (no TanStack).** Would mean reimplementing filter operators, stable sort, filter-from-leaf-rows, and expanded sub-rows. Pure churn for no user-visible gain, and TanStack Table is already a dependency. Kept as the engine. +- **`useReactTable` only for `DataView.Table`; bespoke hooks for List/Timeline.** Forks the filter-predicate path per renderer, and cross-renderer switches (Table ↔ List toggle) would have to re-derive state. Defeats the whole reason for extracting the data layer. Rejected. +- **`ag-grid` / `react-data-grid` / `material-react-table`.** Heavy, opinionated renderers that aren't pluggable at the DOM level. Wrong fit for a headless-core-plus-swappable-renderers architecture. +- **Putting renderer row specs (`columns`, `renderBar`) on the `DataView` root.** Considered for symmetry with today's `DataTable`. Rejected because each spec is consumed by exactly one renderer — declaring them on the root inflates the root type and makes third-party renderers (e.g. ``) awkward. Props live where they're read. +- **Unifying Timeline's bar layout under the Table/List column spec.** Considered because it would mean one spec shape for all three renderers. Rejected because bars are variable-width: a `grid-template-columns` that fits a month-long bar overflows a 1-day bar. Timeline uses `renderBar` + `` instead, which gives the same visibility story without forcing a column grid onto bar content. +- **Making Display Properties visibility a per-renderer concern.** Considered because Timeline/Custom are structurally different from columnar renderers. Rejected because users expect one Display Properties toggle to work everywhere; otherwise the control silently no-ops on non-columnar views. `` (one context-reading wrapper) solves this with ~10 lines of shared code. + +### Scoped-Out Decisions + +- **`DataView.VirtualizedContent` as a separate export** — folded into ``. Two exports for the same renderer was redundant. +- **`stickyGroupHeader` on shared context** — kept as a prop on `DataView.Table` only. It's a table-DOM concern and doesn't apply to List/Timeline/Custom; polluting context with Table-only knobs would invite similar leakage for every new renderer. +- **Implicit zero-state / empty-state rendering inside renderers** — moved to explicit `emptyState` / `zeroState` props on each renderer, with the empty-vs-zero decision driven by root-computed `hasActiveQuery`. Today's `Content` and `VirtualizedContent` duplicate this branch; consolidating it removes drift. +- **Recomputing `shouldShowFilters` from `table.getRowModel()`.** Dropped the `try/catch` around a TanStack call inside a data-layer check. Computed from `data.length + filters.length + search` instead — keeps the data layer free of rendering-engine hooks. +- **`onItemDrag` / `onResize` on Timeline (editable Gantt).** Out of scope for v1. Completely renderer-local when added later — doesn't touch `DataView` root. +- **Responsive hiding inside Timeline bars** (hide subtitle when bar is narrow). Separate concern from user-driven visibility; solved by container queries or a priority-aware wrapper at the call site. `DisplayAccess` is only for the Display Properties toggle, not viewport-driven hiding. +- **Backward-compat cell/header renaming.** Considered keeping `enableColumnFilter`, `enableGrouping`, etc. permanently. Decision: accept both the old and new names (`enableColumnFilter` as an alias of `filterable`) for one release, then drop the aliases. Short-term migration cost, long-term cleaner API. + +## Helpful Links + +- [TanStack Table — Headless API](https://tanstack.com/table/latest/docs/introduction) — core engine already used by `DataTable`. +- [TanStack Virtual](https://tanstack.com/virtual/latest) — virtualization library already used; supports the two-axis case needed by Timeline. +- [CSS Subgrid — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout/Subgrid) — underpins the List renderer's cross-row cell alignment. +- [Linear — Views & Layouts](https://linear.app/docs/views) — prior art for the Table/List/Timeline triad over a shared query model. +- [Notion — Databases](https://www.notion.so/help/views-filters-and-sorts) — prior art for swappable renderers driven by one filter/sort model. +- Internal reference: `.claude/worktrees/dataview/ANALYSIS.md` — feasibility + architecture analysis backing this RFC. diff --git a/packages/raystack/components/data-view-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/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..acad27792 --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/display-settings.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { MixerHorizontalIcon } from '@radix-ui/react-icons'; + +import { isValidElement, ReactNode } from 'react'; +import { Button } from '../../button'; +import { Flex } from '../../flex'; +import { Popover } from '../../popover'; +import styles from '../data-view.module.css'; +import { defaultGroupOption, SortOrdersValues } from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { DisplayProperties } from './display-properties'; +import { Grouping } from './grouping'; +import { Ordering } from './ordering'; + +interface DisplaySettingsProps { + trigger?: ReactNode; +} + +export function DisplaySettings({ + trigger = ( + + ) +}: DisplaySettingsProps) { + const { + fields, + updateTableQuery, + tableQuery, + defaultSort, + onDisplaySettingsReset + } = useDataView(); + + const sortableColumns = (fields ?? []) + .filter(f => f.sortable) + .map(f => ({ + label: f.label, + id: f.accessorKey + })); + + function onSortChange(columnId: string, order: SortOrdersValues) { + updateTableQuery(query => { + return { + ...query, + sort: [{ name: columnId, order }] + }; + }); + } + + function onGroupChange(columnId: string) { + updateTableQuery(query => { + return { + ...query, + group_by: [columnId] + }; + }); + } + + function onGroupRemove() { + updateTableQuery(query => { + return { + ...query, + group_by: [] + }; + }); + } + + function onReset() { + onDisplaySettingsReset(); + } + + return ( + + {trigger}} + /> + + + + + + + + + + + + + + + + ); +} + +DisplaySettings.displayName = 'DataView.DisplayControls'; diff --git a/packages/raystack/components/data-view-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..ffe1a9f9a --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/list.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { Cross2Icon, TableIcon } from '@radix-ui/react-icons'; +import type { Row } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { cx } from 'class-variance-authority'; +import { CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; + +import { Badge } from '../../badge'; +import { Button } from '../../button'; +import { EmptyState } from '../../empty-state'; +import { Flex } from '../../flex'; +import styles from '../data-view.module.css'; +import { + DataViewListColumn, + DataViewListProps, + GroupedData +} from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { + countLeafRows, + getClientHiddenLeafRowCount, + hasActiveQuery, + hasActiveTableFiltering +} from '../utils'; + +const DefaultEmptyComponent = () => ( + } heading='No Data' /> +); + +function formatGridWidth(width: string | number | undefined) { + if (width === undefined) return '1fr'; + if (typeof width === 'number') return `${width}px`; + return width; +} + +export function DataViewList({ + columns, + rowHeight = 56, + virtualized = false, + overscan = 8, + showDividers = false, + showGroupHeaders = true, + loadMoreOffset = 100, + emptyState, + zeroState, + classNames = {} +}: DataViewListProps) { + const { + table, + mode, + onRowClick, + isLoading, + loadMoreData, + tableQuery, + defaultSort, + totalRowCount, + updateTableQuery + } = useDataView(); + + const rowModel = table?.getRowModel(); + const { rows = [] } = rowModel || {}; + + const visibleLeafColumns = table.getVisibleLeafColumns(); + + const columnMap = useMemo(() => { + const map = new Map>(); + columns.forEach(c => map.set(c.accessorKey, c)); + return map; + }, [columns]); + + // Render order comes from `columns` prop; filter out any accessor whose + // TanStack column is currently hidden. + const renderedAccessors = useMemo(() => { + const visibleSet = new Set(visibleLeafColumns.map(c => c.id)); + return columns.map(c => c.accessorKey).filter(k => visibleSet.has(k)); + }, [columns, visibleLeafColumns]); + + const gridTemplateColumns = useMemo(() => { + return renderedAccessors + .map(accessor => formatGridWidth(columnMap.get(accessor)?.width)) + .join(' '); + }, [renderedAccessors, columnMap]); + + const scrollRef = useRef(null); + const observerRef = useRef(null); + const lastRowRef = useRef(null); + + const loadMoreDataRef = useRef(loadMoreData); + const isLoadingRef = useRef(isLoading); + loadMoreDataRef.current = loadMoreData; + isLoadingRef.current = isLoading; + + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { + const target = entries[0]; + if (!target?.isIntersecting) return; + if (isLoadingRef.current) return; + const loadMore = loadMoreDataRef.current; + if (loadMore) loadMore(); + }, []); + + // Non-virtualized load-more via last-row IntersectionObserver + useEffect(() => { + if (mode !== 'server' || virtualized) return; + + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + const lastRow = lastRowRef.current; + if (!lastRow) return; + + observerRef.current = new IntersectionObserver(handleObserver, { + threshold: 0.1 + }); + observerRef.current.observe(lastRow); + + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }; + }, [mode, virtualized, rows.length, handleObserver]); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: i => { + const row = rows[i]; + const isGroupHeader = row?.subRows && row.subRows.length > 0; + return isGroupHeader ? 36 : rowHeight; + }, + overscan + }); + + const hasData = rows.length > 0 || isLoading; + const hasChanges = hasActiveQuery(tableQuery || {}, defaultSort); + const isZeroState = !hasData && !hasChanges; + const isEmptyState = !hasData && hasChanges; + + const stateToShow = isZeroState + ? (zeroState ?? emptyState ?? ) + : isEmptyState + ? (emptyState ?? ) + : null; + + const hiddenLeafRowCount = + mode === 'client' + ? getClientHiddenLeafRowCount(table) + : totalRowCount !== undefined + ? Math.max(0, totalRowCount - countLeafRows(rows)) + : null; + const hasActiveFiltering = !isLoading && hasActiveTableFiltering(table); + const showFilterSummary = + hasActiveFiltering && + (mode === 'server' || + (typeof hiddenLeafRowCount === 'number' && hiddenLeafRowCount > 0)); + + const handleClearFilters = useCallback(() => { + updateTableQuery(prev => ({ + ...prev, + filters: [], + search: '' + })); + }, [updateTableQuery]); + + const handleVirtualScroll = useCallback(() => { + if (!virtualized) return; + const el = scrollRef.current; + if (!el) return; + if (isLoading) return; + const { scrollTop, scrollHeight, clientHeight } = el; + if (scrollHeight - scrollTop - clientHeight < loadMoreOffset!) { + loadMoreData(); + } + }, [virtualized, isLoading, loadMoreData, loadMoreOffset]); + + if (!hasData) { + return ( +
+
{stateToShow}
+
+ ); + } + + const renderRowCells = (row: Row) => + renderedAccessors.map(accessor => { + const spec = columnMap.get(accessor); + const cell = row.getVisibleCells().find(c => c.column.id === accessor); + if (!cell) { + return ( +
+ ); + } + return ( +
+ {spec?.cell + ? flexRender(spec.cell, cell.getContext()) + : ((cell.getValue() as React.ReactNode) ?? null)} +
+ ); + }); + + const renderGroupHeader = ( + row: Row, + style?: CSSProperties, + key?: string + ) => { + const data = row.original as GroupedData; + return ( +
+ + {data?.label} + {data?.showGroupCount ? ( + {data?.count} + ) : null} + +
+ ); + }; + + const renderDataRow = ( + row: Row, + style?: CSSProperties, + key?: string, + refCb?: (el: HTMLDivElement | null) => void + ) => ( +
onRowClick?.(row.original)} + > + {renderRowCells(row)} +
+ ); + + return ( +
+
+ {virtualized ? ( +
+ {virtualizer.getVirtualItems().map(item => { + const row = rows[item.index]; + if (!row) return null; + const isGroupHeader = row.subRows && row.subRows.length > 0; + const positionStyle: CSSProperties = { + position: 'absolute', + top: item.start, + left: 0, + right: 0, + height: item.size + }; + if (isGroupHeader) { + return showGroupHeaders + ? renderGroupHeader( + row, + positionStyle, + row.id + '-' + item.index + ) + : null; + } + return renderDataRow( + row, + positionStyle, + row.id + '-' + item.index + ); + })} +
+ ) : ( + rows.map((row, idx) => { + const isGroupHeader = row.subRows && row.subRows.length > 0; + const isLast = idx === rows.length - 1; + if (isGroupHeader) { + return showGroupHeaders ? renderGroupHeader(row) : null; + } + return renderDataRow(row, undefined, undefined, el => { + if (isLast) lastRowRef.current = el; + }); + }) + )} +
+ {showFilterSummary ? ( + + {mode === 'server' && hiddenLeafRowCount === null ? ( + + Some items might be hidden by filters + + ) : ( + + + {hiddenLeafRowCount} + + + items hidden by filters + + + )} + + + ) : null} +
+ ); +} + +DataViewList.displayName = 'DataView.List'; diff --git a/packages/raystack/components/data-view-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..940c98974 --- /dev/null +++ b/packages/raystack/components/data-view-beta/components/table.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { DataViewTableProps } from '../data-view.types'; +import { Content } from './content'; +import { VirtualizedContent } from './virtualized-content'; + +/** + * DataView.Table — renders the current filtered/sorted/grouped rows as an + * aligned-column table. Takes its own `columns` (DataViewTableColumn[]) that + * install cell/header renderers keyed by field accessorKey. Metadata like + * filterable/sortable/groupable/hideable is declared on `fields` at the root, + * not duplicated here. + * + * Set `virtualized` to render rows through a virtualizer with sticky headers. + */ +export function DataViewTable({ + columns, + virtualized = false, + rowHeight, + groupHeaderHeight, + overscan, + loadMoreOffset, + stickyGroupHeader, + emptyState, + zeroState, + classNames +}: DataViewTableProps) { + if (virtualized) { + return ( + + ); + } + return ( + + ); +} + +DataViewTable.displayName = 'DataView.Table'; diff --git a/packages/raystack/components/data-view-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/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/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..13c51a51a --- /dev/null +++ b/packages/raystack/components/data-view-beta/data-view.module.css @@ -0,0 +1,271 @@ +.toolbar { + padding: var(--rs-space-3) 0; + align-self: stretch; + + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + background: var(--rs-color-background-base-primary); +} + +.display-popover-content { + padding: 0px; + /* Todo: var does not exist for 300px */ + min-width: 300px; +} + +.display-popover-properties-container { + /* Todo: var does not exist for 160px */ + --select-width: 160px; + padding: var(--rs-space-5); + border-bottom: 1px solid var(--rs-color-border-base-primary); +} + +.display-popover-properties-select { + width: var(--select-width); +} + +.display-popover-properties-select[with-icon-button] { + /* Reduce Icon button with "--rs-space-7" and flex gap "--rs-space-2" */ + width: calc(var(--select-width) - var(--rs-space-7) - var(--rs-space-2)); +} + +.display-popover-properties-select > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.display-popover-reset-container { + padding: var(--rs-space-3) var(--rs-space-5); +} + +.display-popover-sort-icon { + height: var(--rs-space-6); + width: var(--rs-space-6); +} + +.flex-1 { + flex: 1; +} + +.filterSummaryFooter { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: var(--rs-space-4); + width: 100%; + padding: var(--rs-space-9) 0; + box-sizing: border-box; +} + +.filterSummaryCount { + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-style: normal; + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.filterSummaryLabel { + color: var(--rs-color-foreground-base-secondary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-style: normal; + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.contentRoot { + height: 100%; + overflow: auto; +} + +.row { + background: var(--rs-color-background-base-primary); +} + +.row:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.clickable { + cursor: pointer; +} + +.head { + position: sticky; + top: 0; + z-index: 1; +} + +.cell { + background: inherit; +} + +.emptyStateCell { + border-bottom: none; +} + +/* Virtualization styles */ +.scrollContainer { + overflow-y: auto; + overflow-x: auto; + position: relative; + height: 100%; + overscroll-behavior: auto; +} + +.stickyHeader { + position: sticky; + top: 0; + z-index: 1; + background: var(--rs-color-background-base-primary); +} + +/* Virtual table (div-based) styles */ +.virtualTable { + display: flex; + flex-direction: column; + width: 100%; +} + +.virtualHeaderGroup { + display: flex; + flex-direction: column; +} + +.virtualHeaderRow { + display: flex; +} + +.virtualBodyGroup { + position: relative; +} + +.virtualRow { + display: flex; + position: absolute; + width: 100%; + left: 0; +} + +.virtualHead { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; +} + +.virtualCell { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; + box-sizing: border-box; +} + +.virtualSectionHeader { + position: absolute; + width: 100%; + left: 0; + display: flex; + align-items: center; + background: var(--rs-color-background-base-secondary); + font-weight: var(--rs-font-weight-medium); + padding: var(--rs-space-3); +} + +/* Sticky group anchor: shows current group label while scrolling (virtualized) */ +.stickyGroupAnchor { + position: sticky; + z-index: 1; + display: flex; + align-items: center; + background: var(--rs-color-background-base-secondary); + font-weight: var(--rs-font-weight-medium); + padding: var(--rs-space-3); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary); +} + +.stickyLoaderContainer { + position: sticky; + bottom: 0; + z-index: 1; + background: var(--rs-color-background-base-primary); +} + +.loaderRow { + position: relative; +} + +/* Non-virtualized: sticky section header under table header */ +.stickySectionHeader { + position: sticky; + top: var(--rs-space-10); + z-index: 1; + background: var(--rs-color-background-base-secondary); + box-shadow: 0 1px 0 0 var(--rs-color-border-base-primary); +} + +/* List renderer styles */ +.listRoot { + width: 100%; + height: 100%; + overflow: auto; + background: var(--rs-color-background-base-primary); +} + +.listGrid { + display: grid; + width: 100%; + align-items: stretch; +} + +.listRow { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + background: var(--rs-color-background-base-primary); + box-sizing: border-box; +} + +.listRow:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.listRowDivider { + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.listCell { + padding: var(--rs-space-3) var(--rs-space-4); + min-width: 0; + display: flex; + align-items: center; + box-sizing: border-box; +} + +.listGroupHeader { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: var(--rs-space-3); + background: var(--rs-color-background-base-secondary); + font-weight: var(--rs-font-weight-medium); + padding: var(--rs-space-3) var(--rs-space-4); + box-sizing: border-box; +} + +.listEmptyState { + display: flex; + justify-content: center; + align-items: center; + padding: var(--rs-space-9) 0; + width: 100%; + box-sizing: border-box; +} diff --git a/packages/raystack/components/data-view-beta/data-view.tsx b/packages/raystack/components/data-view-beta/data-view.tsx new file mode 100644 index 000000000..80927c22a --- /dev/null +++ b/packages/raystack/components/data-view-beta/data-view.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getSortedRowModel, + Updater, + useReactTable, + VisibilityState +} from '@tanstack/react-table'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Content } from './components/content'; +import { DisplayAccess } from './components/display-access'; +import { DisplaySettings } from './components/display-settings'; +import { Filters } from './components/filters'; +import { DataViewList } from './components/list'; +import { DataViewRenderer } from './components/renderer'; +import { DataViewSearch } from './components/search'; +import { DataViewTable } from './components/table'; +import { Toolbar } from './components/toolbar'; +import { VirtualizedContent } from './components/virtualized-content'; +import { DataViewContext } from './context'; +import { + DataViewContextType, + DataViewProps, + defaultGroupOption, + GroupedData, + InternalQuery, + TableQueryUpdateFn +} from './data-view.types'; +import { + fieldsToColumnDefs, + getDefaultTableQuery, + getInitialColumnVisibility, + groupData, + hasQueryChanged, + queryToTableState, + transformToDataViewQuery +} from './utils'; + +function DataViewRoot({ + data = [], + fields, + query, + mode = 'client', + isLoading = false, + totalRowCount, + loadingRowCount = 3, + defaultSort, + children, + onTableQueryChange, + onLoadMore, + onRowClick, + onColumnVisibilityChange, + getRowId +}: React.PropsWithChildren>) { + const defaultTableQuery = useMemo( + () => getDefaultTableQuery(defaultSort, query), + [defaultSort, query] + ); + const initialColumnVisibility = useMemo( + () => getInitialColumnVisibility(fields), + [fields] + ); + + const [columnVisibility, setColumnVisibility] = useState( + initialColumnVisibility + ); + const handleColumnVisibilityChange = useCallback( + (value: Updater) => { + setColumnVisibility(prev => { + const newValue = typeof value === 'function' ? value(prev) : value; + onColumnVisibilityChange?.(newValue); + return newValue; + }); + }, + [onColumnVisibilityChange] + ); + + const [tableQuery, setTableQuery] = + useState(defaultTableQuery); + + const oldQueryRef = useRef(null); + + const reactTableState = useMemo( + () => queryToTableState(tableQuery), + [tableQuery] + ); + + const onDisplaySettingsReset = useCallback(() => { + setTableQuery(prev => ({ + ...prev, + ...defaultTableQuery, + sort: [defaultSort], + group_by: [defaultGroupOption.id] + })); + handleColumnVisibilityChange(initialColumnVisibility); + }, [ + defaultSort, + defaultTableQuery, + initialColumnVisibility, + handleColumnVisibilityChange + ]); + + const group_by = tableQuery.group_by?.[0]; + + // Metadata-only ColumnDefs. Filter fn is installed from field metadata and + // the current filter query; cell/header rendering lives on each renderer's + // column spec (DataView.Table / DataView.List), not here. + const columnDefs = useMemo( + () => fieldsToColumnDefs(fields, tableQuery.filters), + [fields, tableQuery.filters] + ); + + const groupedData = useMemo( + () => groupData(data, group_by, fields), + [data, group_by, fields] + ); + + useEffect(() => { + if ( + tableQuery && + onTableQueryChange && + hasQueryChanged(oldQueryRef.current, tableQuery) && + mode === 'server' + ) { + onTableQueryChange(transformToDataViewQuery(tableQuery)); + oldQueryRef.current = tableQuery; + } + }, [tableQuery, onTableQueryChange]); + + const table = useReactTable({ + data: groupedData as unknown as TData[], + columns: columnDefs, + getRowId, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getSubRows: row => (row as unknown as GroupedData)?.subRows || [], + getSortedRowModel: mode === 'server' ? undefined : getSortedRowModel(), + getFilteredRowModel: mode === 'server' ? undefined : getFilteredRowModel(), + manualSorting: mode === 'server', + manualFiltering: mode === 'server', + onColumnVisibilityChange: handleColumnVisibilityChange, + globalFilterFn: mode === 'server' ? undefined : 'auto', + initialState: { + columnVisibility: initialColumnVisibility + }, + filterFromLeafRows: true, + state: { + ...reactTableState, + columnVisibility: columnVisibility, + expanded: + group_by && group_by !== defaultGroupOption.id ? true : undefined + } + }); + + function updateTableQuery(fn: TableQueryUpdateFn) { + setTableQuery(prev => fn(prev)); + } + + const loadMoreData = useCallback(() => { + if (mode === 'server' && onLoadMore) { + onLoadMore(); + } + }, [mode, onLoadMore]); + + const searchQuery = query?.search; + useEffect(() => { + if (searchQuery) { + updateTableQuery(prev => ({ + ...prev, + search: searchQuery + })); + } + }, [searchQuery]); + + // Determine if filters should be visible + // Filters should be visible if there is data OR if filters are applied (empty state) + // Filters should NOT be visible if no data AND no filters (zero state) + // Note: Search alone does not show the filter bar + const shouldShowFilters = useMemo(() => { + const hasFilters = + (tableQuery?.filters && tableQuery.filters.length > 0) || false; + + try { + const rowModel = table.getRowModel(); + const hasData = (rowModel?.rows?.length ?? 0) > 0; + return hasData || hasFilters; + } catch { + // If table is not ready yet, check if we have initial data + // If no filters and no data, don't show filters + return hasFilters || data.length > 0; + } + }, [table, tableQuery, data.length]); + + const contextValue: DataViewContextType = useMemo(() => { + return { + table, + fields, + mode, + isLoading, + loadMoreData, + tableQuery, + updateTableQuery, + onDisplaySettingsReset, + defaultSort, + totalRowCount, + loadingRowCount, + onRowClick, + shouldShowFilters + }; + }, [ + table, + fields, + mode, + isLoading, + loadMoreData, + tableQuery, + updateTableQuery, + onDisplaySettingsReset, + defaultSort, + totalRowCount, + loadingRowCount, + onRowClick, + shouldShowFilters + ]); + + return {children}; +} + +DataViewRoot.displayName = 'DataView'; + +/** + * @preview + * `DataView` is a preview component. Its API is not yet stable and + * **will have breaking changes** before the 1.0 release — prop names, + * sub-component shapes, and context surface may all change without + * following semver. Pin to exact versions if depending on it. + */ +// biome-ignore lint/suspicious/noShadowRestrictedNames: public component name intentionally matches the package export +export const DataView = Object.assign(DataViewRoot, { + // Renderers — each takes its own row render spec (`columns` on Table/List). + Table: DataViewTable, + List: DataViewList, + // Escape hatch — render prop receives the full DataView context. + Renderer: DataViewRenderer, + // Legacy sub-renderer exports (used by consumers that imported inner pieces). + Content: Content, + VirtualizedContent: VirtualizedContent, + // Visibility primitive for free-form renderers. + DisplayAccess: DisplayAccess, + // Toolbar primitives + Toolbar: Toolbar, + Search: DataViewSearch, + Filters: Filters, + DisplayControls: DisplaySettings +}); diff --git a/packages/raystack/components/data-view-beta/data-view.types.tsx b/packages/raystack/components/data-view-beta/data-view.types.tsx new file mode 100644 index 000000000..1913ca4c2 --- /dev/null +++ b/packages/raystack/components/data-view-beta/data-view.types.tsx @@ -0,0 +1,248 @@ +import type { ColumnDef, Table, VisibilityState } from '@tanstack/table-core'; +import type { + DataTableFilterOperatorTypes, + FilterOperatorTypes, + FilterSelectOption, + FilterTypes, + FilterValueType +} from '~/types/filters'; +import type { BaseSelectProps } from '../select/select-root'; + +export type DataViewMode = 'client' | 'server'; + +export const SortOrders = { + ASC: 'asc', + DESC: 'desc' +} as const; + +type SortOrdersKeys = keyof typeof SortOrders; +export type SortOrdersValues = (typeof SortOrders)[SortOrdersKeys]; + +export interface DataViewSort { + name: string; + order: SortOrdersValues; +} + +export interface DataViewFilterValues { + value: any; + // Only one of these value fields should be present at a time + boolValue?: boolean; + stringValue?: string; + numberValue?: number; +} + +// Internal filter with UI operators and metadata +export interface InternalFilter extends DataViewFilterValues { + _type?: FilterTypes; + _dataType?: FilterValueType; + name: string; + operator: FilterOperatorTypes; +} + +// DataView filter for backend API (no internal fields) +export interface DataViewFilter extends DataViewFilterValues { + name: string; + operator: DataTableFilterOperatorTypes; +} + +// Internal query with UI operators and metadata +export interface InternalQuery { + filters?: InternalFilter[]; + sort?: DataViewSort[]; + group_by?: string[]; + offset?: number; + limit?: number; + search?: string; +} + +// DataView query for backend API (clean, no internal fields) +export interface DataViewQuery extends Omit { + filters?: DataViewFilter[]; +} + +/** + * Renderer-agnostic field metadata. One entry per logical column of the data + * model. Declared once on ``; drives filters, sort, group, visibility + * across every renderer. Cell/header rendering belongs on the renderer's own + * column spec, not here. + */ +export interface DataViewField { + accessorKey: string; + /** Human-readable label shown in filter chips, Display controls, and the default Table header. */ + label: string; + icon?: React.ReactNode; + + // filter capability + filterable?: boolean; + filterType?: FilterTypes; + dataType?: FilterValueType; + filterOptions?: FilterSelectOption[]; + defaultFilterValue?: unknown; + filterProps?: { + select?: BaseSelectProps; + }; + + // ordering / grouping / visibility capability + sortable?: boolean; + groupable?: boolean; + hideable?: boolean; + defaultHidden?: boolean; + + // group-header presentation (used by any renderer that groups) + showGroupCount?: boolean; + groupCountMap?: Record; + groupLabelsMap?: Record; +} + +/** + * Table row render spec. Points at a field via `accessorKey` and adds + * per-column cell/header renderers. Filterable/sortable/etc. live on the field, + * not duplicated here. + */ +export interface DataViewTableColumn { + accessorKey: string; + /** TanStack-style cell renderer. Receives `CellContext` and returns ReactNode. */ + cell?: ColumnDef['cell']; + /** TanStack-style header renderer. Overrides the field's `label` for this renderer. */ + header?: ColumnDef['header']; + classNames?: { + cell?: string; + header?: string; + }; + styles?: { + cell?: React.CSSProperties; + header?: React.CSSProperties; + }; +} + +/** + * List row render spec. Same data shape as Table plus a CSS grid `width` hint. + * List has no column headers, so no `header` renderer. + */ +export interface DataViewListColumn { + accessorKey: string; + /** TanStack-style cell renderer. */ + cell?: ColumnDef['cell']; + /** CSS grid track width. `1fr`, `auto`, `'200px'`, `'minmax(80px, 1fr)'`, or a number (pixels). Defaults to `1fr`. */ + width?: string | number; + classNames?: { cell?: string }; + styles?: { cell?: React.CSSProperties }; +} + +export interface DataViewProps { + data: TData[]; + /** Renderer-agnostic field metadata. Drives filter/sort/group/visibility. */ + fields: DataViewField[]; + query?: DataViewQuery; // Initial query (will be transformed to internal format) + mode?: DataViewMode; + isLoading?: boolean; + totalRowCount?: number; + loadingRowCount?: number; + onTableQueryChange?: (query: DataViewQuery) => void; + defaultSort: DataViewSort; + onLoadMore?: () => Promise; + onRowClick?: (row: TData) => void; + onColumnVisibilityChange?: (columnVisibility: VisibilityState) => void; + /** Return a stable unique id for each row (used as React key). Use for sortable/filterable tables. */ + getRowId?: (row: TData, index: number) => string; +} + +export type DataViewContentClassNames = { + root?: string; + table?: string; + header?: string; + body?: string; + row?: string; +}; + +export type DataViewTableBaseProps = { + /** Table column render specs (cell/header/styles) keyed by field accessor. */ + columns: DataViewTableColumn[]; + emptyState?: React.ReactNode; + zeroState?: React.ReactNode; + classNames?: DataViewContentClassNames; + /** When true, renders with virtualized rows. */ + virtualized?: boolean; + /** Height of each row in pixels. Used for virtualized mode. */ + rowHeight?: number; + /** Height of group header rows in pixels. Falls back to rowHeight if not set. */ + groupHeaderHeight?: number; + /** Number of rows to render outside visible area. */ + overscan?: number; + /** Distance in pixels from bottom to trigger load more. */ + loadMoreOffset?: number; + /** When true, group headers stick under the table header while scrolling. Default is false. */ + stickyGroupHeader?: boolean; +}; + +export type DataViewTableProps< + TData, + TValue = unknown +> = DataViewTableBaseProps; + +export type DataViewListClassNames = { + root?: string; + row?: string; + cell?: string; + groupHeader?: string; +}; + +export interface DataViewListProps { + /** List column render specs (cell/width/styles) keyed by field accessor. */ + columns: DataViewListColumn[]; + /** Row height in px. Used when virtualized. Default 56. */ + rowHeight?: number; + /** When true, virtualizes rows. */ + virtualized?: boolean; + /** Number of rows to render outside the viewport when virtualized. */ + overscan?: number; + /** Render thin dividers between rows. */ + showDividers?: boolean; + /** Show group section headers when grouping is active. Default true. */ + showGroupHeaders?: boolean; + /** Distance in pixels from bottom to trigger load more. */ + loadMoreOffset?: number; + emptyState?: React.ReactNode; + zeroState?: React.ReactNode; + classNames?: DataViewListClassNames; +} + +export type TableQueryUpdateFn = (query: InternalQuery) => InternalQuery; + +export type DataViewContextType = { + table: Table; + /** Renderer-agnostic field metadata — Filters/DisplayControls/every renderer reads from here. */ + fields: DataViewField[]; + isLoading?: boolean; + loadMoreData: () => void; + mode: DataViewMode; + defaultSort: DataViewSort; + tableQuery?: InternalQuery; + totalRowCount?: number; + loadingRowCount?: number; + onDisplaySettingsReset: () => void; + updateTableQuery: (fn: TableQueryUpdateFn) => void; + onRowClick?: (row: TData) => void; + shouldShowFilters?: boolean; +}; + +export interface ColumnData { + label: string; + id: string; + isVisible?: boolean; +} + +interface SubRows<_T> {} + +export interface GroupedData extends SubRows { + label: string; + group_key: string; + subRows: T[]; + count?: number; + showGroupCount?: boolean; +} + +export const defaultGroupOption = { + id: '--', + label: 'No grouping' +}; diff --git a/packages/raystack/components/data-view-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..d71a88dfc --- /dev/null +++ b/packages/raystack/components/data-view-beta/index.ts @@ -0,0 +1,19 @@ +export { EmptyFilterValue } from '~/types/filters'; +export type { DataViewDisplayAccessProps } from './components/display-access'; +export type { DataViewRendererProps } from './components/renderer'; +export type { DataViewSearchProps } from './components/search'; +export { DataView } from './data-view'; +export { + DataViewField, + DataViewFilter, + DataViewListColumn, + DataViewListProps, + DataViewProps, + DataViewQuery, + DataViewSort, + DataViewTableColumn, + DataViewTableProps, + InternalFilter, + InternalQuery +} from './data-view.types'; +export { useDataView } from './hooks/useDataView'; diff --git a/packages/raystack/components/data-view-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';