From 631dddec8511f0c31247a6487cf20cb08f74174a Mon Sep 17 00:00:00 2001 From: Rishabh Date: Tue, 18 Aug 2026 21:29:37 +0530 Subject: [PATCH 1/4] feat(data-view): lane per field value in the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `lanePacking="one-per-field"` + `laneField`: every distinct value of a field gets its own lane, that value's cards packed by date within it, and a sub-lane only where two of its own cards genuinely overlap in time. A priority timeline reads as a High lane, a Medium lane and a Low lane. Lane order can't come from sorting (text sort gives High, Low, Medium), so it comes from a declared ranking: the new `DataViewField.groupOrder`, overridable per renderer with `laneOrder`. `groupOrder` also orders group sections in `groupData`, so one declaration ranks sections and lanes alike — both share the ordering rule in `orderBucketKeys` (declared, then first-seen, no-value last). Co-Authored-By: Claude Opus 5 (1M context) --- apps/www/src/components/dataview-demo.tsx | 132 +++++-- apps/www/src/components/demo/demo.tsx | 2 + .../content/docs/components/dataview/demo.ts | 40 ++ .../docs/components/dataview/index.mdx | 52 ++- .../content/docs/components/dataview/props.ts | 33 +- .../data-view/__tests__/group-data.test.ts | 83 ++++ .../__tests__/order-bucket-keys.test.ts | 69 ++++ .../data-view/__tests__/pack-lanes.test.ts | 151 ++++++- .../data-view/__tests__/timeline.test.tsx | 370 ++++++++++++++++++ .../data-view/components/timeline.tsx | 95 ++++- .../components/data-view/data-view.types.tsx | 44 ++- .../components/data-view/utils/index.tsx | 12 +- .../data-view/utils/order-bucket-keys.tsx | 44 +++ .../components/data-view/utils/pack-lanes.tsx | 56 +++ 14 files changed, 1136 insertions(+), 47 deletions(-) create mode 100644 packages/raystack/components/data-view/__tests__/group-data.test.ts create mode 100644 packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts create mode 100644 packages/raystack/components/data-view/utils/order-bucket-keys.tsx diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index e092eaa25..7f23f4110 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -671,6 +671,7 @@ type Task = { title: string; team: 'Eng' | 'Design' | 'Ops'; status: 'todo' | 'active' | 'done'; + priority: 'High' | 'Medium' | 'Low'; start: string; end: string; }; @@ -686,38 +687,49 @@ const taskDate = (days: number) => { }; const taskSpec: Array< - [string, string, Task['team'], Task['status'], number, number] + [ + string, + string, + Task['team'], + Task['status'], + Task['priority'], + number, + number + ] > = [ - ['t1', 'Design audit', 'Design', 'done', -16, -9], - ['t2', 'API contracts', 'Eng', 'done', -13, -6], - ['t3', 'Billing revamp', 'Eng', 'active', -7, 2], - ['t4', 'Docs sprint', 'Design', 'active', -4, 4], - ['t5', 'Bug bash', 'Ops', 'todo', 1, 2], - ['t6', 'Load testing', 'Ops', 'todo', 3, 9], - ['t7', 'Beta rollout', 'Eng', 'todo', 6, 14], - ['t8', 'Launch comms', 'Design', 'todo', 10, 16], + ['t1', 'Design audit', 'Design', 'done', 'High', -16, -9], + ['t2', 'API contracts', 'Eng', 'done', 'High', -13, -6], + ['t3', 'Billing revamp', 'Eng', 'active', 'High', -7, 2], + ['t4', 'Docs sprint', 'Design', 'active', 'Medium', -4, 4], + ['t5', 'Bug bash', 'Ops', 'todo', 'Low', 1, 2], + ['t6', 'Load testing', 'Ops', 'todo', 'Low', 3, 9], + ['t7', 'Beta rollout', 'Eng', 'todo', 'High', 6, 14], + ['t8', 'Launch comms', 'Design', 'todo', 'Medium', 10, 16], // Overlapping work per team, so packing stacks a few lanes deep inside each // group section rather than every band being a single row. - ['t9', 'Schema migration', 'Eng', 'done', -15, -10], - ['t10', 'Icon refresh', 'Design', 'done', -12, -5], - ['t11', 'On-call rotation', 'Ops', 'active', -11, -2], - ['t12', 'Runbook cleanup', 'Ops', 'done', -14, -8], - ['t13', 'Search indexing', 'Eng', 'active', -3, 6], - ['t14', 'Motion pass', 'Design', 'active', -2, 5], - ['t15', 'Cost review', 'Ops', 'todo', 4, 12], - ['t16', 'SSO hardening', 'Eng', 'todo', 8, 15], - ['t17', 'Empty states', 'Design', 'todo', 7, 13], - ['t18', 'Chaos drill', 'Ops', 'todo', 11, 15] + ['t9', 'Schema migration', 'Eng', 'done', 'Medium', -15, -10], + ['t10', 'Icon refresh', 'Design', 'done', 'Low', -12, -5], + ['t11', 'On-call rotation', 'Ops', 'active', 'Medium', -11, -2], + ['t12', 'Runbook cleanup', 'Ops', 'done', 'Low', -14, -8], + ['t13', 'Search indexing', 'Eng', 'active', 'Medium', -3, 6], + ['t14', 'Motion pass', 'Design', 'active', 'Low', -2, 5], + ['t15', 'Cost review', 'Ops', 'todo', 'Medium', 4, 12], + ['t16', 'SSO hardening', 'Eng', 'todo', 'Low', 8, 15], + ['t17', 'Empty states', 'Design', 'todo', 'Medium', 7, 13], + ['t18', 'Chaos drill', 'Ops', 'todo', 'High', 11, 15] ]; -const tasks: Task[] = taskSpec.map(([id, title, team, status, from, to]) => ({ - id, - title, - team, - status, - start: taskDate(from), - end: taskDate(to) -})); +const tasks: Task[] = taskSpec.map( + ([id, title, team, status, priority, from, to]) => ({ + id, + title, + team, + status, + priority, + start: taskDate(from), + end: taskDate(to) + }) +); const taskFields: DataViewField[] = [ { @@ -757,6 +769,24 @@ const taskFields: DataViewField[] = [ { label: 'Done', value: 'done' } ] }, + { + accessorKey: 'priority', + label: 'Priority', + filterable: true, + filterType: 'select', + hideable: true, + // groupOrder ranks the values once — it orders group sections *and* seeds + // lane order for lanePacking="one-per-field", which sorting can't do + // (text sort would give High, Low, Medium). + groupable: true, + showGroupCount: true, + groupOrder: ['High', 'Medium', 'Low'], + filterOptions: [ + { label: 'High', value: 'High' }, + { label: 'Medium', value: 'Medium' }, + { label: 'Low', value: 'Low' } + ] + }, { accessorKey: 'start', label: 'Start', @@ -839,6 +869,11 @@ function TaskCard({ {task.team} + + + {task.priority} + + ); @@ -1035,3 +1070,46 @@ export function DataViewTimelineGroupingDemo() { ); } + +/* ── Timeline field-lane demo (lanePacking="one-per-field") ────────────── */ + +export function DataViewTimelineFieldLaneDemo() { + return ( + + + data={tasks} + fields={taskFields} + defaultSort={{ name: 'start', order: 'asc' }} + getRowId={task => task.id} + > + + + + + + + startField='start' + endField='end' + lanePacking='one-per-field' + laneField='priority' + // Lane order comes from the priority field's groupOrder; pass + // laneOrder here to override it for this renderer only. + renderCard={(row, context) => ( + + )} + /> + + No tasks match your filters. + + + + + ); +} diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 7b9df8814..66e304a6c 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -49,6 +49,7 @@ import { DataViewSelectionDemo, DataViewTableDemo, DataViewTimelineDemo, + DataViewTimelineFieldLaneDemo, DataViewTimelineGroupingDemo, DataViewTimelinePointDemo, DataViewVirtualizedDemo, @@ -84,6 +85,7 @@ export default function Demo(props: DemoProps) { DataViewSearchDemo, DataViewSelectionDemo, DataViewTimelineDemo, + DataViewTimelineFieldLaneDemo, DataViewTimelineGroupingDemo, DataViewTimelinePointDemo, ChipInputDemo, diff --git a/apps/www/src/content/docs/components/dataview/demo.ts b/apps/www/src/content/docs/components/dataview/demo.ts index fcc4666ba..dad520569 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -503,6 +503,46 @@ export const timelineGroupingPreview = { ] }; +export const timelineFieldLanePreview = { + type: 'code', + style: { padding: 0 }, + previewCode: false, + code: ``, + codePreview: [ + { + label: 'index.tsx', + code: ` + /* One lane per priority: rows sharing a value share a lane, and a value + only takes a second lane where two of its own cards overlap in time. + Lane order comes from the field's groupOrder, so one declaration ranks + both group sections and lanes: + + { accessorKey: "priority", label: "Priority", groupable: true, + groupOrder: ["High", "Medium", "Low"] } */ + + t.id}> + + + + + + } + /> + ` + } + ] +}; + export const timelinePointPreview = { type: 'code', style: { padding: 0 }, diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index a4eb369b6..11862c9af 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -18,6 +18,7 @@ import { perViewFieldsPreview, rowSelectionPreview, timelinePreview, + timelineFieldLanePreview, timelineGroupingPreview, timelinePointPreview, } from "./demo.ts"; @@ -419,7 +420,7 @@ In the demo below: drag the background to pan (with a momentum glide), hover for ### Cards -The Timeline owns **positioning** — the time scale (date → x, span → width, using real timestamps so variable-length months don't distort placement), lane packing (non-overlapping cards share a lane; `lanePacking="one-per-row"` opts out), the sticky two-tier axis, and scrolling. You own the **card**: `renderCard(row, context)` draws everything visual, the same split as `DataView.List`'s `columns[].cell`. +The Timeline owns **positioning** — the time scale (date → x, span → width, using real timestamps so variable-length months don't distort placement), lane packing (non-overlapping cards share a lane; `lanePacking` opts out — see [Lane packing](#lane-packing)), the sticky two-tier axis, and scrolling. You own the **card**: `renderCard(row, context)` draws everything visual, the same split as `DataView.List`'s `columns[].cell`. ```tsx +### Lane packing + +`lanePacking` decides what a lane *means*. All three modes run per group section — a card never shares a lane across sections. + +| Mode | A lane is | Use it for | +| --- | --- | --- | +| `auto` (default) | a dense chronological track: cards that don't overlap in time share it | fitting many cards into the least vertical space | +| `one-per-row` | one row | a Gantt chart, where every row needs its own visible track | +| `one-per-field` | one distinct value of `laneField` | grouping by a property while keeping the flat single-axis layout | + +Under `one-per-field`, rows sharing a value share a lane and are packed by date within it; a value only claims a **sub-lane** where two of its own cards overlap in time. So a priority timeline shows a High lane, a Medium lane and a Low lane, and only the priority with genuinely concurrent work grows a second row. + + + +```tsx +// groupOrder ranks the values once — it orders group sections and seeds lane +// order, which sorting can't do (text sort gives High, Low, Medium). +const fields = [ + { accessorKey: "priority", label: "Priority", groupable: true, + groupOrder: ["High", "Medium", "Low"] }, + … +]; + + +``` + +- **Lane order** is `laneOrder`, else the lane field's `groupOrder`, else first-seen row order. Values missing from the list follow in first-seen order, and a listed value with no rows takes no lane — lanes are never empty. +- **Rows with no usable value** — null, undefined, `""`, or a non-primitive (which also logs a dev warning) — share one lane, always last. +- **Values are keyed by their string form**, so `1` and `"1"` land in the same lane. Resolve an object-valued field to a primitive before handing it to `laneField`. +- **`laneField` is required** by this mode: without it there's nothing to lane on, so packing falls back to `auto` with a dev warning. Passing `laneField` under another mode warns too, and is ignored. +- **With `group_by` active**, each section gets its own lane set and `context.laneIndex` stays section-relative. Grouping by the lane field is allowed and simply degenerates: a section already holds one value, so it renders as one lane (plus sub-lanes on overlap). + ### Grouping Set `group_by` (from `DataView.DisplayControls` → Grouping, or on the initial `query`) and the timeline splits into **swim-lane sections** stacked under the single shared time axis: a full-width header band per group, that group's cards lane-packed beneath it. Horizontal position stays purely time — grouping reorganizes vertically only. @@ -461,9 +501,11 @@ const fields = [ ``` -The timeline consumes the **same group rows** `DataView.List` renders as section headers — the root's `groupData` output, in first-occurrence order — so section order, labels (`groupLabelsMap`), and counts (`groupCountMap`, or the bucket size) match between views, in client and server mode alike. There's no timeline-specific grouping path to keep in sync. +The timeline consumes the **same group rows** `DataView.List` renders as section headers — the root's `groupData` output — so section order, labels (`groupLabelsMap`), and counts (`groupCountMap`, or the bucket size) match between views, in client and server mode alike. There's no timeline-specific grouping path to keep in sync. -- **Packing is per section.** A card only ever shares a lane with cards in its own group, and `context.laneIndex` is section-relative (every section starts at lane 0). `lanePacking="one-per-row"` applies within each section too. +Section order is the field's `groupOrder` where it declares one (`['High', 'Medium', 'Low']` — the ranking sorting can't express), then values it doesn't list in first-occurrence order, with rows that have no value in the last section. A declared value with no rows renders no section. + +- **Packing is per section.** A card only ever shares a lane with cards in its own group, and `context.laneIndex` is section-relative (every section starts at lane 0). `lanePacking="one-per-row"` and `"one-per-field"` apply within each section too. - **Bands pin while their section is in view.** The active band sticks directly under the time axis and is pushed off by the next section's band; its label sticks to the left edge so it stays readable while you pan to a distant month. Always on — pure CSS, no prop. - **Empty sections disappear.** A group whose cards all fall outside an explicit `range` (or that has no valid `startField` values) renders nothing — no band, no empty strip. A band that *does* render shows the full group count, even when some of its cards are culled, matching List. - **`showGroupHeaders={false}`** hides the bands but keeps the sections — same semantics as the prop on `DataView.List`. Style a band with `classNames.groupHeader`. @@ -474,6 +516,8 @@ Bands are labels only in this release: no chevron, no collapsing. Sort can't move a card horizontally — x is locked to the start date — so it surfaces in exactly one place: `lanePacking="one-per-row"`, where vertical row order follows the active sort (within each section when grouped). With the default `auto` packing, lanes are assigned by dense chronological first-fit and the sort has no visible effect, so hide the Ordering control (``) or leave `sortable` off the timeline's per-view `fields` unless you use `one-per-row`. +Under `one-per-field`, vertical order is a property ranking rather than a sort: it comes from `laneOrder` or the field's `groupOrder` (see [Lane packing](#lane-packing)), and the active sort only decides where undeclared values land. + ### Scale and axis Four props control the axis, and they compose rather than overlap: @@ -553,7 +597,7 @@ Start with `isLoading={true}` and fire an initial fetch on mount: with no data a ### Notes -- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. +- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` (lane order under `one-per-field` comes from `laneOrder`/`groupOrder`) — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. - **`virtualized`** culls both axes: cards, gridlines, tick labels, month bands, and markers render only near the viewport, and the grid and marker lines span the visible window rather than the full canvas height. A frame costs what is on screen rather than what is in the data, so a long domain and deep grouping stay affordable. It defaults to `false` — pass it explicitly. Recommended whenever the domain is long or rows are numerous. - **Without `virtualized`, nothing is culled vertically.** Every lane in the domain stays mounted and every card in the visible time window renders, so deep grouping over thousands of rows builds a tall, fully populated canvas. The trade is content-driven lane heights (see [Cards](#cards)), which virtualization gives up. - **Interaction** — cards receive row clicks via the root's `onRowClick`; the background supports mouse drag-to-pan with a momentum glide; scrolling past the domain edge won't trigger browser back-swipe. The pane is a focusable, labelled region (`aria-label`, default "Timeline"), so keyboard users can Tab to it and scroll with the arrow keys. diff --git a/apps/www/src/content/docs/components/dataview/props.ts b/apps/www/src/content/docs/components/dataview/props.ts index 1beedd650..23c544eb2 100644 --- a/apps/www/src/content/docs/components/dataview/props.ts +++ b/apps/www/src/content/docs/components/dataview/props.ts @@ -112,6 +112,15 @@ export interface DataViewField { /** Override group bucket labels (key → label). */ groupLabelsMap?: Record; + + /** + * Section order while this field is the active `group_by`, by raw group value — + * e.g. `['High', 'Medium', 'Low']`. Undeclared values follow in first-seen order, + * rows with no value land in the last section, and a declared value with no rows + * takes no section. Also the default lane order for + * `DataView.Timeline`'s `lanePacking="one-per-field"`. + */ + groupOrder?: string[]; } export interface DataViewListProps { @@ -279,11 +288,29 @@ export interface DataViewTimelineProps { /** * `auto` packs non-overlapping cards into shared lanes; `one-per-row` gives every row - * its own lane, in row-model (sorted) order. Both apply per group section while - * `group_by` is active — cards never share a lane across sections. + * its own lane, in row-model (sorted) order; `one-per-field` gives every distinct + * `laneField` value its own lane, packing that value's cards by date within it. All + * apply per group section while `group_by` is active — cards never share a lane + * across sections. * @defaultValue "auto" */ - lanePacking?: 'auto' | 'one-per-row'; + lanePacking?: 'auto' | 'one-per-row' | 'one-per-field'; + + /** + * Accessor key whose values become lanes under `lanePacking="one-per-field"` — rows + * sharing a value share a lane, and a value only takes a sub-lane where two of its own + * cards overlap in time. Rows with no usable value (null, empty, non-primitive) share + * the last lane. Required by `one-per-field`, which falls back to `auto` without it. + */ + laneField?: string; + + /** + * Lane order for `lanePacking="one-per-field"`, by raw `laneField` value — e.g. + * `['High', 'Medium', 'Low']`. Undeclared values follow in first-seen row order, the + * no-value lane comes last, and a listed value with no rows takes no lane. Defaults to + * the lane field's `groupOrder`. + */ + laneOrder?: string[]; /** * Estimated card height in px, same contract as `DataView.List`: cards render at their diff --git a/packages/raystack/components/data-view/__tests__/group-data.test.ts b/packages/raystack/components/data-view/__tests__/group-data.test.ts new file mode 100644 index 000000000..e636798c3 --- /dev/null +++ b/packages/raystack/components/data-view/__tests__/group-data.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import type { DataViewField } from '../data-view.types'; +import { groupData } from '../utils'; + +/** + * `groupData` produces the sections every renderer walks, so its bucket order + * *is* the rendered section order — `DataView.List`'s bands and + * `DataView.Timeline`'s group sections both read it. + */ +interface Task { + id: string; + priority?: string | null; +} + +const field = (groupOrder?: string[]): DataViewField => ({ + accessorKey: 'priority', + label: 'Priority', + groupable: true, + ...(groupOrder ? { groupOrder } : {}) +}); + +const keys = (data: Task[], fields: DataViewField[]) => + groupData(data, 'priority', fields).map(group => group.group_key); + +describe('groupData ordering', () => { + const tasks: Task[] = [ + { id: '1', priority: 'Low' }, + { id: '2', priority: 'High' }, + { id: '3', priority: 'Low' }, + { id: '4', priority: 'Medium' } + ]; + + it('keeps first-seen order when the field declares none', () => { + expect(keys(tasks, [field()])).toEqual(['Low', 'High', 'Medium']); + }); + + it('follows the field groupOrder', () => { + expect(keys(tasks, [field(['High', 'Medium', 'Low'])])).toEqual([ + 'High', + 'Medium', + 'Low' + ]); + }); + + it('appends undeclared values in first-seen order', () => { + expect(keys(tasks, [field(['Medium'])])).toEqual(['Medium', 'Low', 'High']); + }); + + it('skips declared values with no rows', () => { + expect(keys(tasks, [field(['Urgent', 'High', 'Low', 'Medium'])])).toEqual([ + 'High', + 'Low', + 'Medium' + ]); + }); + + it('puts rows with no value in the last section', () => { + const withEmpty: Task[] = [ + { id: '0', priority: null }, + ...tasks, + { id: '5' } + ]; + expect(keys(withEmpty, [field(['High', 'Medium', 'Low'])])).toEqual([ + 'High', + 'Medium', + 'Low', + '' + ]); + }); + + it('puts rows with no value last without a declared order too', () => { + const withEmpty: Task[] = [{ id: '0', priority: null }, ...tasks]; + expect(keys(withEmpty, [field()])).toEqual(['Low', 'High', 'Medium', '']); + }); + + it('preserves rows and counts per section', () => { + const groups = groupData(tasks, 'priority', [ + field(['High', 'Medium', 'Low']) + ]); + expect(groups.map(group => group.count)).toEqual([1, 1, 2]); + expect(groups[2].subRows.map(row => row.id)).toEqual(['1', '3']); + }); +}); diff --git a/packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts b/packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts new file mode 100644 index 000000000..8e00f57c9 --- /dev/null +++ b/packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { EMPTY_BUCKET_KEY, orderBucketKeys } from '../utils/order-bucket-keys'; + +/** + * `orderBucketKeys` is the single ordering rule shared by `groupData`'s + * sections and Timeline's field lanes: declared order first, undeclared in + * first-seen order, the empty bucket last. Both callers treat its output as + * visible layout, so every clause below is observable API. + */ +describe('orderBucketKeys', () => { + it('returns first-seen order when nothing is declared', () => { + expect(orderBucketKeys(['Low', 'High', 'Medium'])).toEqual([ + 'Low', + 'High', + 'Medium' + ]); + }); + + it('emits declared keys in declared order', () => { + expect( + orderBucketKeys(['Low', 'High', 'Medium'], ['High', 'Medium', 'Low']) + ).toEqual(['High', 'Medium', 'Low']); + }); + + it('appends undeclared keys in first-seen order after declared ones', () => { + expect( + orderBucketKeys(['Blocked', 'Low', 'High', 'Urgent'], ['High', 'Low']) + ).toEqual(['High', 'Low', 'Blocked', 'Urgent']); + }); + + it('skips declared keys with no bucket', () => { + expect(orderBucketKeys(['Low'], ['High', 'Medium', 'Low'])).toEqual([ + 'Low' + ]); + }); + + it('pins the empty bucket last', () => { + expect( + orderBucketKeys([EMPTY_BUCKET_KEY, 'Low', 'High'], ['High', 'Low']) + ).toEqual(['High', 'Low', EMPTY_BUCKET_KEY]); + }); + + it('pins the empty bucket last even when declared earlier', () => { + expect( + orderBucketKeys( + ['Low', EMPTY_BUCKET_KEY, 'High'], + [EMPTY_BUCKET_KEY, 'High', 'Low'] + ) + ).toEqual(['High', 'Low', EMPTY_BUCKET_KEY]); + }); + + it('pins the empty bucket last with nothing declared', () => { + expect(orderBucketKeys([EMPTY_BUCKET_KEY, 'Low'])).toEqual([ + 'Low', + EMPTY_BUCKET_KEY + ]); + }); + + it('emits a repeated declaration once', () => { + expect(orderBucketKeys(['Low', 'High'], ['High', 'High', 'Low'])).toEqual([ + 'High', + 'Low' + ]); + }); + + it('returns an empty list for no buckets', () => { + expect(orderBucketKeys([], ['High'])).toEqual([]); + }); +}); diff --git a/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts index 5463fab99..58964f66e 100644 --- a/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts +++ b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { packLanes } from '../utils/pack-lanes'; +import { packLanes, packLanesByField } from '../utils/pack-lanes'; import { digest, randomItems } from './helpers'; /** @@ -299,3 +299,152 @@ describe('packLanes', () => { } }); }); + +/** + * `packLanesByField` layers value bucketing over `packLanes`: one lane per + * distinct `laneKey`, sub-lanes only where a bucket's own cards overlap. Lane + * numbers are vertical position and reach `renderCard` as `context.laneIndex`, + * so both the bucket order and the sub-lane split are visible output. + */ +describe('packLanesByField', () => { + /** `x`/`width` far apart enough that only same-bucket collisions matter. */ + const item = (laneKey: string | null, x: number, width = 40) => ({ + laneKey, + x, + width + }); + + it('returns no lanes for empty input', () => { + expect(packLanesByField([])).toEqual({ lanes: [], laneCount: 0 }); + }); + + it('gives one lane per value and shares it across rows', () => { + const items = [ + item('High', 0), + item('Low', 0), + item('High', 200), + item('Low', 400) + ]; + expect(packLanesByField(items)).toEqual({ + lanes: [0, 1, 0, 1], + laneCount: 2 + }); + }); + + it('orders buckets first-seen when no order is given', () => { + const items = [item('Low', 0), item('High', 0), item('Medium', 0)]; + expect(packLanesByField(items).lanes).toEqual([0, 1, 2]); + }); + + it('follows a declared order', () => { + const items = [item('Low', 0), item('High', 0), item('Medium', 0)]; + expect( + packLanesByField(items, { order: ['High', 'Medium', 'Low'] }).lanes + ).toEqual([2, 0, 1]); + }); + + it('adds a sub-lane only where a value overlaps itself', () => { + const items = [ + item('High', 0), + item('High', 10), // overlaps the first High → sub-lane + item('High', 400), + item('Low', 0) + ]; + expect(packLanesByField(items, { order: ['High', 'Low'] })).toEqual({ + lanes: [0, 1, 0, 2], + laneCount: 3 + }); + }); + + it('offsets later buckets past every sub-lane of earlier ones', () => { + const items = [ + item('High', 0), + item('High', 10), + item('High', 20), // three mutually overlapping → lanes 0,1,2 + item('Low', 0), + item('Low', 10) // two overlapping → lanes 3,4 + ]; + expect(packLanesByField(items, { order: ['High', 'Low'] })).toEqual({ + lanes: [0, 1, 2, 3, 4], + laneCount: 5 + }); + }); + + it('puts the no-value bucket last', () => { + const items = [item(null, 0), item('High', 0), item('Low', 0)]; + expect(packLanesByField(items).lanes).toEqual([2, 0, 1]); + }); + + it('buckets the empty string with no-value rows', () => { + const items = [item('', 0), item('High', 0), item(null, 400)]; + expect(packLanesByField(items)).toEqual({ + lanes: [1, 0, 1], + laneCount: 2 + }); + }); + + it('ignores declared values with no items', () => { + const items = [item('Low', 0), item('High', 0)]; + expect( + packLanesByField(items, { order: ['Urgent', 'High', 'Blocked', 'Low'] }) + .lanes + ).toEqual([1, 0]); + }); + + it('honours gapPx when deciding a bucket sub-lane', () => { + // Same bucket, 50px apart: a 60px gap forces a sub-lane, 8px does not. + const items = [item('High', 0, 40), item('High', 50, 40)]; + expect(packLanesByField(items, { gapPx: 8 }).laneCount).toBe(1); + expect(packLanesByField(items, { gapPx: 60 }).laneCount).toBe(2); + }); + + it('packs a single bucket exactly like packLanes', () => { + const items = randomItems(300, 7); + const flat = packLanes(items); + const bucketed = packLanesByField( + items.map(({ x, width }) => ({ laneKey: 'one', x, width })) + ); + expect(digest(bucketed.lanes)).toBe(digest(flat.lanes)); + expect(bucketed.laneCount).toBe(flat.laneCount); + }); + + it('never lets two cards in one lane overlap', () => { + const KEYS = ['High', 'Medium', 'Low', null]; + const items = randomItems(400, 11).map((it, i) => ({ + ...it, + laneKey: KEYS[i % KEYS.length] + })); + const { lanes, laneCount } = packLanesByField(items); + + const byLane = new Map(); + lanes.forEach((lane, index) => { + const list = byLane.get(lane) || []; + list.push(items[index]); + byLane.set(lane, list); + }); + expect(byLane.size).toBe(laneCount); + for (const list of byLane.values()) { + list.sort((a, b) => a.x - b.x); + for (let i = 1; i < list.length; i++) { + expect(list[i].x).toBeGreaterThanOrEqual( + list[i - 1].x + list[i - 1].width + DEFAULT_GAP_PX + ); + } + } + }); + + it('keeps every lane inside one bucket', () => { + const KEYS = ['High', 'Medium', 'Low']; + const items = randomItems(200, 13).map((it, i) => ({ + ...it, + laneKey: KEYS[i % KEYS.length] + })); + const { lanes } = packLanesByField(items, { order: KEYS }); + const keyByLane = new Map(); + lanes.forEach((lane, index) => { + const seen = keyByLane.get(lane); + if (seen === undefined) keyByLane.set(lane, items[index].laneKey); + else expect(items[index].laneKey).toBe(seen); + }); + }); +}); diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index bc3770db9..a3e88d6a6 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -303,6 +303,8 @@ type Order = { start: string | null; end: string | null; team?: string; + // biome-ignore lint/suspicious/noExplicitAny: one-per-field takes any value + priority?: any; }; const fields: DataViewField[] = [ @@ -1785,3 +1787,371 @@ describe('DataView.Timeline actionsRef', () => { expect(actionsRef.current!.getVisibleRange()).toBeNull(); }); }); + +/* ─────────────────────── lanePacking="one-per-field" ─────────────────────── */ + +/** + * Lanes come from a field's values: rows sharing a value share a lane, and a + * value only claims a sub-lane where two of its own cards overlap in time. Lane + * order is the `laneOrder` prop, else the field's `groupOrder`, else first-seen; + * rows with no usable value lane last. + */ +describe('DataView.Timeline field lanes', () => { + // Jan 5 → 80px, Jan 6 → 100px (overlaps Jan 5's span), Jan 12 → 220px (clear). + const tasks: Order[] = [ + { + id: 't1', + title: 'A', + priority: 'Low', + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 't2', + title: 'B', + priority: 'High', + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 't3', + title: 'C', + priority: 'High', + start: '2025-01-12', + end: '2025-01-15' + }, + { + id: 't4', + title: 'D', + priority: 'Medium', + start: '2025-01-05', + end: '2025-01-10' + } + ]; + + const priorityFields = (groupOrder?: string[]): DataViewField[] => [ + { accessorKey: 'title', label: 'Title', sortable: true }, + { + accessorKey: 'priority', + label: 'Priority', + groupable: true, + ...(groupOrder ? { groupOrder } : {}) + } + ]; + + const laneOf = (id: string) => + screen.getByTestId(`card-${id}`).dataset.lane as string; + + const renderFieldLanes = ( + props: Partial> = {}, + data: Order[] = tasks, + fieldList: DataViewField[] = priorityFields() + ) => + renderTimeline( + { lanePacking: 'one-per-field', laneField: 'priority', ...props }, + data, + { fields: fieldList } + ); + + it('gives each distinct value one lane, shared by its rows', () => { + renderFieldLanes(); + // First-seen order over the sorted row model (title asc): Low, High, Medium. + expect(laneOf('t1')).toBe('0'); + expect(laneOf('t2')).toBe('1'); + expect(laneOf('t3')).toBe('1'); // same value as t2, no time overlap + expect(laneOf('t4')).toBe('2'); + }); + + it('adds a sub-lane only where one value overlaps itself', () => { + renderFieldLanes(undefined, [ + ...tasks, + // Overlaps t2 [80..180] and shares its value → High takes a second lane. + { + id: 't5', + title: 'E', + priority: 'High', + start: '2025-01-06', + end: '2025-01-09' + } + ]); + expect(laneOf('t2')).toBe('1'); + expect(laneOf('t5')).toBe('2'); + // Medium sits below every lane High claimed. + expect(laneOf('t4')).toBe('3'); + }); + + it('orders lanes by the laneOrder prop', () => { + renderFieldLanes({ laneOrder: ['High', 'Medium', 'Low'] }); + expect(laneOf('t2')).toBe('0'); + expect(laneOf('t3')).toBe('0'); + expect(laneOf('t4')).toBe('1'); + expect(laneOf('t1')).toBe('2'); + }); + + it("defaults lane order to the field's groupOrder", () => { + renderFieldLanes( + undefined, + tasks, + priorityFields(['High', 'Medium', 'Low']) + ); + expect(laneOf('t2')).toBe('0'); + expect(laneOf('t4')).toBe('1'); + expect(laneOf('t1')).toBe('2'); + }); + + it('lets laneOrder override the field groupOrder', () => { + renderFieldLanes( + { laneOrder: ['Low', 'Medium', 'High'] }, + tasks, + priorityFields(['High', 'Medium', 'Low']) + ); + expect(laneOf('t1')).toBe('0'); + expect(laneOf('t4')).toBe('1'); + expect(laneOf('t2')).toBe('2'); + }); + + it('appends values missing from laneOrder in first-seen order', () => { + renderFieldLanes({ laneOrder: ['Medium'] }); + expect(laneOf('t4')).toBe('0'); + expect(laneOf('t1')).toBe('1'); // Low seen first + expect(laneOf('t2')).toBe('2'); + }); + + it('ignores laneOrder values with no rows', () => { + renderFieldLanes({ + laneOrder: ['Urgent', 'High', 'Blocked', 'Low', 'Medium'] + }); + expect(laneOf('t2')).toBe('0'); + expect(laneOf('t1')).toBe('1'); + expect(laneOf('t4')).toBe('2'); + }); + + it('lanes rows with no value last', () => { + renderFieldLanes({ laneOrder: ['High', 'Medium', 'Low'] }, [ + { + id: 'n1', + title: 'N1', + priority: null, + start: '2025-01-05', + end: '2025-01-10' + }, + { id: 'n2', title: 'N2', start: '2025-01-12', end: '2025-01-15' }, + { + id: 'n3', + title: 'N3', + priority: '', + start: '2025-01-20', + end: '2025-01-25' + }, + ...tasks + ]); + expect(laneOf('t2')).toBe('0'); + expect(laneOf('t4')).toBe('1'); + expect(laneOf('t1')).toBe('2'); + // null, undefined and '' share the one trailing lane. + expect(laneOf('n1')).toBe('3'); + expect(laneOf('n2')).toBe('3'); + expect(laneOf('n3')).toBe('3'); + }); + + it('lanes non-primitive values last, with a dev warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderFieldLanes(undefined, [ + { + id: 'obj', + title: 'Obj', + priority: { id: 'High' }, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'ok', + title: 'Ok', + priority: 'High', + start: '2025-01-12', + end: '2025-01-15' + } + ]); + expect(laneOf('ok')).toBe('0'); + expect(laneOf('obj')).toBe('1'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('non-primitive "priority" value') + ); + }); + + it('keys numeric and boolean values by their string form', () => { + renderFieldLanes({ laneOrder: ['1', '2'] }, [ + { + id: 'p1', + title: 'P1', + priority: 1, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'p2', + title: 'P2', + priority: 2, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'p3', + title: 'P3', + priority: 1, + start: '2025-01-12', + end: '2025-01-15' + } + ]); + expect(laneOf('p1')).toBe('0'); + expect(laneOf('p3')).toBe('0'); + expect(laneOf('p2')).toBe('1'); + }); + + it('stacks lanes at the fixed pitch like any other packing', () => { + renderFieldLanes({ laneOrder: ['High', 'Medium', 'Low'] }); + // lane 0 at laneGap 16, lane 1 at 16 + 66 + 16, lane 2 at 16 + 2 × 82. + expect(screen.getByTestId('card-t2').parentElement!.style.top).toBe('16px'); + expect(screen.getByTestId('card-t4').parentElement!.style.top).toBe('98px'); + expect(screen.getByTestId('card-t1').parentElement!.style.top).toBe( + '180px' + ); + }); + + it('lanes per group section when group_by is active', () => { + const grouped: Order[] = [ + { + id: 'e1', + title: 'E1', + team: 'Eng', + priority: 'High', + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'e2', + title: 'E2', + team: 'Eng', + priority: 'Low', + start: '2025-01-12', + end: '2025-01-15' + }, + { + id: 'd1', + title: 'D1', + team: 'Design', + priority: 'Low', + start: '2025-01-05', + end: '2025-01-10' + } + ]; + renderTimeline( + { + lanePacking: 'one-per-field', + laneField: 'priority', + laneOrder: ['High', 'Low'] + }, + grouped, + { + fields: [ + { accessorKey: 'title', label: 'Title', sortable: true }, + { accessorKey: 'team', label: 'Team', groupable: true }, + { accessorKey: 'priority', label: 'Priority' } + ], + query: { group_by: ['team'] } + } + ); + // Section-relative lanes: Design's only value starts back at lane 0, and a + // value never spans sections. + expect(laneOf('e1')).toBe('0'); + expect(laneOf('e2')).toBe('1'); + expect(laneOf('d1')).toBe('0'); + }); + + it('degenerates to time packing when group_by is the lane field', () => { + const grouped: Order[] = [ + { + id: 'h1', + title: 'H1', + priority: 'High', + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'h2', + title: 'H2', + priority: 'High', + start: '2025-01-12', + end: '2025-01-15' + }, + { + id: 'l1', + title: 'L1', + priority: 'Low', + start: '2025-01-05', + end: '2025-01-10' + } + ]; + renderTimeline( + { lanePacking: 'one-per-field', laneField: 'priority' }, + grouped, + { + fields: priorityFields(['High', 'Low']), + query: { group_by: ['priority'] } + } + ); + // Each section already holds one value → one lane per section. + expect(laneOf('h1')).toBe('0'); + expect(laneOf('h2')).toBe('0'); + expect(laneOf('l1')).toBe('0'); + }); + + it('falls back to auto packing with a dev warning when laneField is missing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderTimeline({ lanePacking: 'one-per-field' }); + // The auto-packing expectations, unchanged: o1 and o3 overlap, o2 reuses 0. + expect(laneOf('o1')).toBe('0'); + expect(laneOf('o3')).toBe('1'); + expect(laneOf('o2')).toBe('0'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('needs a `laneField`') + ); + }); + + it('ignores laneField under another packing mode, with a dev warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderFieldLanes({ lanePacking: 'auto' }); + // Time packing: t1, t2 and t4 all overlap at Jan 5 regardless of value. + expect(new Set([laneOf('t1'), laneOf('t2'), laneOf('t4')]).size).toBe(3); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('laneField="priority" is ignored') + ); + }); + + it('culls field lanes when virtualized', () => { + stubPane(); + const many: Order[] = Array.from({ length: 12 }, (_, i) => ({ + id: `v${String(i + 1).padStart(2, '0')}`, + title: String(i + 1).padStart(2, '0'), + priority: `p${String(i + 1).padStart(2, '0')}`, + start: '2025-01-05', + end: '2025-01-10' + })); + renderFieldLanes({ virtualized: true }, many, [ + { accessorKey: 'title', label: 'Title', sortable: true }, + { accessorKey: 'priority', label: 'Priority' } + ]); + // One lane per value at the fixed 82px pitch; the 200px pane plus overscan + // reaches lane 4 (top 344px) and stops before lane 5 (426px). + const rendered = Array.from( + document.querySelectorAll('[data-testid^="card-v"]') + ).map(card => (card as HTMLElement).dataset.testid); + expect(rendered).toEqual([ + 'card-v01', + 'card-v02', + 'card-v03', + 'card-v04', + 'card-v05' + ]); + }); +}); diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index 3fd95d7c4..ef59c61cf 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -27,7 +27,7 @@ import { } from '../data-view.types'; import { useDataView } from '../hooks/useDataView'; import { orderByX } from '../utils/order-by-x'; -import { packLanes } from '../utils/pack-lanes'; +import { packLanes, packLanesByField } from '../utils/pack-lanes'; import { buildAxis, createTimeScale, @@ -55,6 +55,11 @@ const DEFAULT_MIN_CARD_WIDTH = 60; const DEFAULT_POINT_WIDTH = 120; /** Floor for the rendered wrapper so near-zero spans stay visible and clickable. */ const MIN_RENDER_WIDTH = 24; +/** + * Joins `laneOrder` into one memo key. A character no lane value realistically + * contains, so serialize-and-split round-trips the list unchanged. + */ +const LANE_ORDER_SEPARATOR = '\u0000'; /** Units of padding around the data extent when no explicit `range` is given. */ const DOMAIN_PAD_UNITS = 2; /** Half-window (in units) of the fallback domain used while loading with no data. */ @@ -235,6 +240,12 @@ interface TimedItem { startTime: number; /** Null when `endField` is omitted (point marker). */ endTime: number | null; + /** + * Bucket the row falls in under `lanePacking="one-per-field"` — its + * `laneField` value as a string, or null for no usable value (that bucket + * lanes last). Null throughout for every other packing mode. + */ + laneKey: string | null; } /** A timed row placed on the time scale. */ @@ -425,6 +436,8 @@ export function DataViewTimeline({ onVisibleRangeChange, actionsRef, lanePacking = 'auto', + laneField, + laneOrder, estimatedRowHeight = DEFAULT_ROW_HEIGHT, laneGap = DEFAULT_LANE_GAP, minCardWidth = DEFAULT_MIN_CARD_WIDTH, @@ -435,6 +448,7 @@ export function DataViewTimeline({ }: DataViewTimelineProps) { const { table, + fields, onRowClick, activeView, registerFieldsForView, @@ -516,11 +530,49 @@ export function DataViewTimeline({ return list; }, [rows]); + // `one-per-field` needs a key to bucket rows by, so without `laneField` there + // is nothing to lane on and packing degrades to 'auto'; a `laneField` handed + // to any other mode is inert. Resolved to one flag the memos below read. + const fieldLanes = lanePacking === 'one-per-field' && laneField !== undefined; + + // Config warnings, once per config rather than per render. + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + if (lanePacking === 'one-per-field' && laneField === undefined) { + console.warn( + '[DataView.Timeline] lanePacking="one-per-field" needs a `laneField` to build lanes from — falling back to "auto" packing.' + ); + } else if (lanePacking !== 'one-per-field' && laneField !== undefined) { + console.warn( + `[DataView.Timeline] laneField="${laneField}" is ignored under lanePacking="${lanePacking}" — set lanePacking="one-per-field" to lane by it.` + ); + } + }, [lanePacking, laneField]); + + // Lane order: the prop, else the lane field's declared `groupOrder` (so one + // declaration drives both group sections and lanes). Serialized to a string + // and rebuilt, because an inline array literal would otherwise hand the + // layout memos a new identity on every render. + const laneOrderKey = fieldLanes + ? (( + laneOrder ?? + fields.find(field => field.accessorKey === laneField)?.groupOrder + )?.join(LANE_ORDER_SEPARATOR) ?? '') + : ''; + const effectiveLaneOrder = useMemo( + () => + laneOrderKey === '' + ? undefined + : laneOrderKey.split(LANE_ORDER_SEPARATOR), + [laneOrderKey] + ); + // Resolve each row's start/end timestamps, per section. Rows without a valid // start are skipped (one dev warning for the whole model); inverted ranges // clamp to zero-length spans. const timedSections = useMemo(() => { let dropped = 0; + let unlaned = 0; const list = sections.map(section => { const items: TimedItem[] = []; for (const row of section.items) { @@ -535,7 +587,19 @@ export function DataViewTimeline({ endTime = toTimestamp(original?.[endField]); if (endTime !== null && endTime < startTime) endTime = startTime; } - items.push({ row, startTime, endTime }); + // Lane bucket, resolved here so packing below is pure geometry. Only a + // primitive identifies a lane; anything else (an object, an array) + // shares the no-value lane rather than collapsing into one + // "[object Object]" bucket. + let laneKey: string | null = null; + if (fieldLanes) { + const value = original?.[laneField as string]; + if (value == null || value === '') laneKey = null; + else if (typeof value === 'object' || typeof value === 'function') { + unlaned++; + } else laneKey = String(value); + } + items.push({ row, startTime, endTime, laneKey }); } const timed: TimelineSection> = { key: section.key, @@ -549,8 +613,13 @@ export function DataViewTimeline({ `[DataView.Timeline] Skipped ${dropped} row(s) with a missing or invalid "${startField}" value.` ); } + if (process.env.NODE_ENV !== 'production' && unlaned > 0) { + console.warn( + `[DataView.Timeline] ${unlaned} row(s) have a non-primitive "${laneField}" value and share the last lane — "${laneField}" should resolve to a string or number.` + ); + } return list; - }, [sections, startField, endField]); + }, [sections, startField, endField, fieldLanes, laneField]); // Data extent, for the domain below — grouping never changes the time domain. // Reduced in place rather than through a flattened copy: the extent is two @@ -698,15 +767,27 @@ export function DataViewTimeline({ lanes: section.items.map((_, i) => i), laneCount: section.items.length } - : packLanes( - section.items.map(item => ({ x: item.x, width: item.packWidth })) - ); + : fieldLanes + ? packLanesByField( + section.items.map(item => ({ + laneKey: item.laneKey, + x: item.x, + width: item.packWidth + })), + { order: effectiveLaneOrder } + ) + : packLanes( + section.items.map(item => ({ + x: item.x, + width: item.packWidth + })) + ); const entry = { ...section, ...packed, laneOffset: offset }; offset += packed.laneCount; return entry; }); return { laidOutSections: list, laneCount: offset }; - }, [positionedSections, lanePacking]); + }, [positionedSections, lanePacking, fieldLanes, effectiveLaneOrder]); /** * Virtualizing vertically means a card off-screen never mounts and so never diff --git a/packages/raystack/components/data-view/data-view.types.tsx b/packages/raystack/components/data-view/data-view.types.tsx index 79601c67a..eaa12969e 100644 --- a/packages/raystack/components/data-view/data-view.types.tsx +++ b/packages/raystack/components/data-view/data-view.types.tsx @@ -94,6 +94,19 @@ export interface DataViewField { showGroupCount?: boolean; groupCountMap?: Record; groupLabelsMap?: Record; + /** + * Section order when this field is the active `group_by`, keyed by raw group + * value (the same keys `groupLabelsMap` uses) — e.g. + * `['High', 'Medium', 'Low']` for a priority field, which text sorting alone + * can't produce. + * + * Values absent from the list follow in first-seen data order, and rows with + * no value always land in the last section. A listed value with no rows + * produces no section. Honoured by every renderer that groups, and used by + * `DataView.Timeline` as the default lane order for + * `lanePacking="one-per-field"`. + */ + groupOrder?: string[]; } /** @@ -402,10 +415,35 @@ export interface DataViewTimelineProps { /** * 'auto' (default) packs non-overlapping cards into shared lanes (greedy * interval scheduling); 'one-per-row' gives every row its own lane, in - * row-model (sorted) order. Both apply per group section when `group_by` is - * active — cards never share a lane across sections. + * row-model (sorted) order; 'one-per-field' gives every distinct `laneField` + * value its own lane, packing that value's cards by date within it. All + * apply per group section when `group_by` is active — cards never share a + * lane across sections. + */ + lanePacking?: 'auto' | 'one-per-row' | 'one-per-field'; + /** + * Accessor key whose values become lanes under + * `lanePacking="one-per-field"` — e.g. `"priority"` puts every High task on + * one lane, every Low task on the next. Rows sharing a value share a lane, + * and a value only takes an extra sub-lane where two of its own cards + * overlap in time. + * + * Rows whose value is null, undefined, empty, or a non-primitive share one + * lane placed last. Required by `one-per-field` (which falls back to 'auto' + * without it) and ignored by the other packing modes. + */ + laneField?: string; + /** + * Lane order for `lanePacking="one-per-field"`, by raw `laneField` value — + * e.g. `['High', 'Medium', 'Low']`, which sorting alone can't produce. + * + * Values absent from the list follow in first-seen row order, and the + * no-value lane always comes last. A listed value with no rows takes no + * lane. Defaults to the `laneField` field's `groupOrder`, so declaring the + * order once on the field drives both group sections and lanes; this prop + * overrides it for this renderer. */ - lanePacking?: 'auto' | 'one-per-row'; + laneOrder?: string[]; /** * Lane height in px. Default 66. * diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index 5a28554d3..f4c9e21bd 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -23,6 +23,7 @@ import { getFilterOperator, getFilterValue } from './filter-operations'; +import { orderBucketKeys } from './order-bucket-keys'; export function queryToTableState(query: InternalQuery): Partial { const columnFilters = @@ -84,6 +85,10 @@ export function fieldsToColumnDefs( * Bucket data into `GroupedData` entries keyed by `group_by`. When a resolver * is supplied for that key, the resolver runs per-row; otherwise the field is * accessed directly. Used in client mode only. + * + * Sections come out in the field's `groupOrder` where it declares one, with + * undeclared values following in first-seen order and the null-valued bucket + * last — see `orderBucketKeys`. */ export function groupData( data: TData[], @@ -113,7 +118,10 @@ export function groupData( const groupCountMap = field?.groupCountMap || {}; const groupedData: GroupedData[] = []; - groupMap.forEach((value, key) => { + // Section order: the field's declared `groupOrder` first, then undeclared + // values in first-seen order, then the empty (null-valued) bucket last. + for (const key of orderBucketKeys([...groupMap.keys()], field?.groupOrder)) { + const value = groupMap.get(key) as TData[]; groupedData.push({ label: groupLabelsMap[key] || key, group_key: key, @@ -121,7 +129,7 @@ export function groupData( count: groupCountMap[key] ?? value.length, showGroupCount }); - }); + } return groupedData; } diff --git a/packages/raystack/components/data-view/utils/order-bucket-keys.tsx b/packages/raystack/components/data-view/utils/order-bucket-keys.tsx new file mode 100644 index 000000000..8c5958cf2 --- /dev/null +++ b/packages/raystack/components/data-view/utils/order-bucket-keys.tsx @@ -0,0 +1,44 @@ +/** + * Bucket key standing in for a null/undefined/empty grouping value. `groupData` + * already keys that bucket by the empty string; Timeline lanes map their `null` + * lane key onto it so both share the ordering rule below. + */ +export const EMPTY_BUCKET_KEY = ''; + +/** + * Order bucket keys for a grouped renderer: declared keys first in the order + * they are declared, then everything undeclared in first-seen order, then the + * empty bucket last. + * + * `keys` arrives in first-seen order (a `Map`'s key order, which is insertion + * order), and only keys that are actually present are emitted — a declared + * value with no rows produces no section and no lane, so ordering never + * conjures empty bands. The empty bucket is pinned last regardless of where it + * appears in `order`, so a declared list doesn't have to mention it. + * + * Shared by `groupData` (section order for every renderer) and + * `packLanesByField` (Timeline lane order) so sections and lanes can never + * disagree about where a value sits. + */ +export function orderBucketKeys(keys: string[], order?: string[]): string[] { + const hasEmpty = keys.includes(EMPTY_BUCKET_KEY); + const present = new Set(keys); + present.delete(EMPTY_BUCKET_KEY); + + const ordered: string[] = []; + if (order) { + for (const key of order) { + if (!present.has(key)) continue; + present.delete(key); + ordered.push(key); + } + } + // Undeclared keys keep first-seen order — `keys`, not the Set, drives this. + for (const key of keys) { + if (!present.has(key)) continue; + present.delete(key); + ordered.push(key); + } + if (hasEmpty) ordered.push(EMPTY_BUCKET_KEY); + return ordered; +} diff --git a/packages/raystack/components/data-view/utils/pack-lanes.tsx b/packages/raystack/components/data-view/utils/pack-lanes.tsx index 6b1a404c0..fea15fcee 100644 --- a/packages/raystack/components/data-view/utils/pack-lanes.tsx +++ b/packages/raystack/components/data-view/utils/pack-lanes.tsx @@ -1,3 +1,4 @@ +import { EMPTY_BUCKET_KEY, orderBucketKeys } from './order-bucket-keys'; import { orderByX } from './order-by-x'; export interface PackLaneItem { @@ -47,6 +48,61 @@ export function packLanes( : packBySweep(items, gapPx, order); } +/** An item plus the field-lane bucket it belongs to. `null` = no value. */ +export interface PackFieldLaneItem extends PackLaneItem { + laneKey: string | null; +} + +/** + * Lane per distinct `laneKey`, packed by time within each: rows sharing a value + * share a lane, and a value only takes extra (sub-)lanes when two of its own + * cards overlap in time. Backs `lanePacking="one-per-field"`. + * + * Buckets come out in `order` where it lists them, then in first-seen order, + * with the no-value bucket last — the same rule `groupData` applies to sections, + * so lanes and sections agree (see `orderBucketKeys`). Within a bucket the + * greedy first-fit of `packLanes` decides sub-lanes, so a value with no + * overlapping cards occupies exactly one lane. + */ +export function packLanesByField( + items: PackFieldLaneItem[], + options: { order?: string[]; gapPx?: number } = {} +): PackLanesResult { + const { order, gapPx = DEFAULT_CARD_GAP_PX } = options; + const lanes = new Array(items.length).fill(0); + if (items.length === 0) return { lanes, laneCount: 0 }; + + // Indices per bucket, in input order — Map insertion order is the first-seen + // order `orderBucketKeys` expects. + const buckets = new Map(); + for (let index = 0; index < items.length; index++) { + const key = items[index].laneKey ?? EMPTY_BUCKET_KEY; + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(index); + } + + let laneCount = 0; + for (const key of orderBucketKeys([...buckets.keys()], order)) { + const bucket = buckets.get(key) as number[]; + // Packing runs on the bucket alone, so lanes are bucket-relative and shift + // up by the lanes every earlier bucket already claimed. + const packed = packLanes( + bucket.map(index => ({ x: items[index].x, width: items[index].width })), + gapPx + ); + for (let i = 0; i < bucket.length; i++) { + lanes[bucket[i]] = laneCount + packed.lanes[i]; + } + laneCount += packed.laneCount; + } + + return { lanes, laneCount }; +} + /** First-fit by scanning every lane end — O(items × lanes). */ function packByScan( items: PackLaneItem[], From eb0b3d3f8332b2221bca66c36ea1072a90b6ad0a Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 19 Aug 2026 09:36:27 +0530 Subject: [PATCH 2/4] refactor(data-view): lane by the sorted field, drop laneField/laneOrder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lanePacking="one-per-field"` now lanes by whatever the view is sorted by instead of taking its own field and order props. The row model already arrives grouped and ranked by the sort, so lane membership and lane order both fall out of it: one vocabulary instead of three, and the Ordering control rebuilds lanes live. Ranking values that don't sort naturally (High/Medium/Low) is a numeric rank field you sort on — the docs demo does exactly that. Drops `laneField`, `laneOrder`, `packLanesByField`'s `order` option, and the content-keyed memo the inline `laneOrder` array needed. `groupOrder` stays, now scoped to group sections alone. Co-Authored-By: Claude Opus 5 (1M context) --- apps/www/src/components/dataview-demo.tsx | 34 ++- .../content/docs/components/dataview/demo.ts | 14 +- .../docs/components/dataview/index.mdx | 50 +-- .../content/docs/components/dataview/props.ts | 30 +- .../data-view/__tests__/pack-lanes.test.ts | 30 +- .../data-view/__tests__/timeline.test.tsx | 289 +++++++++--------- .../data-view/components/timeline.tsx | 65 +--- .../components/data-view/data-view.types.tsx | 42 +-- .../components/data-view/utils/pack-lanes.tsx | 16 +- 9 files changed, 247 insertions(+), 323 deletions(-) diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index 7f23f4110..b360d9a9b 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -672,10 +672,20 @@ type Task = { team: 'Eng' | 'Design' | 'Ops'; status: 'todo' | 'active' | 'done'; priority: 'High' | 'Medium' | 'Low'; + /* Priority as a number. Sorting the label alphabetically gives High, Low, + Medium — this is what "sort by priority" has to mean to be useful, and it + is what the field-lane timeline lanes on. */ + rank: 1 | 2 | 3; start: string; end: string; }; +const TASK_RANK: Record = { + High: 1, + Medium: 2, + Low: 3 +}; + const TASK_DAY_MS = 86_400_000; /* Dates relative to today, pinned to midnight so the today line is always @@ -726,6 +736,7 @@ const tasks: Task[] = taskSpec.map( team, status, priority, + rank: TASK_RANK[priority], start: taskDate(from), end: taskDate(to) }) @@ -775,9 +786,8 @@ const taskFields: DataViewField[] = [ filterable: true, filterType: 'select', hideable: true, - // groupOrder ranks the values once — it orders group sections *and* seeds - // lane order for lanePacking="one-per-field", which sorting can't do - // (text sort would give High, Low, Medium). + // groupOrder ranks the sections when grouping by priority — text sort + // would give High, Low, Medium. groupable: true, showGroupCount: true, groupOrder: ['High', 'Medium', 'Low'], @@ -787,6 +797,15 @@ const taskFields: DataViewField[] = [ { label: 'Low', value: 'Low' } ] }, + { + // Sortable because the field-lane timeline lanes by whatever is sorted: + // sorting on rank yields a High lane, a Medium lane and a Low lane. + accessorKey: 'rank', + label: 'Priority rank', + sortable: true, + hideable: true, + defaultHidden: true + }, { accessorKey: 'start', label: 'Start', @@ -1082,12 +1101,14 @@ export function DataViewTimelineFieldLaneDemo() { data={tasks} fields={taskFields} - defaultSort={{ name: 'start', order: 'asc' }} + // The sort defines the lanes: rank asc → High, Medium, Low. + defaultSort={{ name: 'rank', order: 'asc' }} getRowId={task => task.id} > - + {/* Ordering stays visible — it repositions and rebuilds lanes. */} + ( )} diff --git a/apps/www/src/content/docs/components/dataview/demo.ts b/apps/www/src/content/docs/components/dataview/demo.ts index dad520569..323c07864 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -514,28 +514,26 @@ export const timelineFieldLanePreview = { code: ` /* One lane per priority: rows sharing a value share a lane, and a value only takes a second lane where two of its own cards overlap in time. - Lane order comes from the field's groupOrder, so one declaration ranks - both group sections and lanes: + The sort picks the lane field and orders the lanes, so try the Ordering + control. Sorting the "High"/"Medium"/"Low" label alphabetically gives + High, Low, Medium — carry a numeric rank and sort on that instead: - { accessorKey: "priority", label: "Priority", groupable: true, - groupOrder: ["High", "Medium", "Low"] } */ + { accessorKey: "rank", label: "Priority rank", sortable: true } */ t.id}> - + } /> ` diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index 11862c9af..4c0bca4a5 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -452,36 +452,36 @@ Omit `endField` and rows render as point markers at their date — releases, inc | --- | --- | --- | | `auto` (default) | a dense chronological track: cards that don't overlap in time share it | fitting many cards into the least vertical space | | `one-per-row` | one row | a Gantt chart, where every row needs its own visible track | -| `one-per-field` | one distinct value of `laneField` | grouping by a property while keeping the flat single-axis layout | +| `one-per-field` | one distinct value of the **sorted-by** field | grouping by a property while keeping the flat single-axis layout | -Under `one-per-field`, rows sharing a value share a lane and are packed by date within it; a value only claims a **sub-lane** where two of its own cards overlap in time. So a priority timeline shows a High lane, a Medium lane and a Low lane, and only the priority with genuinely concurrent work grows a second row. +Under `one-per-field`, rows sharing a value share a lane and are packed by date within it; a value only claims a **sub-lane** where two of its own cards overlap in time. So a timeline sorted by priority shows a High lane, a Medium lane and a Low lane, and only the priority with genuinely concurrent work grows a second row. + +The active sort does double duty here: it picks the field lanes are built from *and* orders them, so the Ordering control repositions lanes live and there's no second ordering vocabulary to keep in sync. ```tsx -// groupOrder ranks the values once — it orders group sections and seeds lane -// order, which sorting can't do (text sort gives High, Low, Medium). -const fields = [ - { accessorKey: "priority", label: "Priority", groupable: true, - groupOrder: ["High", "Medium", "Low"] }, - … -]; - - + t.id}> + + ``` -- **Lane order** is `laneOrder`, else the lane field's `groupOrder`, else first-seen row order. Values missing from the list follow in first-seen order, and a listed value with no rows takes no lane — lanes are never empty. -- **Rows with no usable value** — null, undefined, `""`, or a non-primitive (which also logs a dev warning) — share one lane, always last. -- **Values are keyed by their string form**, so `1` and `"1"` land in the same lane. Resolve an object-valued field to a primitive before handing it to `laneField`. -- **`laneField` is required** by this mode: without it there's nothing to lane on, so packing falls back to `auto` with a dev warning. Passing `laneField` under another mode warns too, and is ignored. -- **With `group_by` active**, each section gets its own lane set and `context.laneIndex` stays section-relative. Grouping by the lane field is allowed and simply degenerates: a section already holds one value, so it renders as one lane (plus sub-lanes on overlap). +- **Rank what doesn't sort naturally.** Sorting `priority` alphabetically gives High, Low, Medium. Carry a numeric `rank` alongside it and sort on that for High, Medium, Low — the lane values are then the ranks, which is invisible unless your card shows them. +- **Rows with no usable value** — null, `undefined`, `""`, or a non-primitive (which also logs a dev warning) — share one lane, always last, wherever the sort would have put them. +- **Values are keyed by their string form**, so `1` and `"1"` share a lane. Resolve an object-valued field to a primitive before sorting on it. +- **No sort, no lanes.** The mode falls back to `auto` if the query carries no sort — `defaultSort` is required on the root, so that's a guard rather than a configuration. +- **With `group_by` active**, each section gets its own lane set and `context.laneIndex` stays section-relative. Grouping by the sorted field is allowed and simply degenerates: a section already holds one value, so it renders as one lane (plus sub-lanes on overlap). + ### Grouping @@ -516,7 +516,7 @@ Bands are labels only in this release: no chevron, no collapsing. Sort can't move a card horizontally — x is locked to the start date — so it surfaces in exactly one place: `lanePacking="one-per-row"`, where vertical row order follows the active sort (within each section when grouped). With the default `auto` packing, lanes are assigned by dense chronological first-fit and the sort has no visible effect, so hide the Ordering control (``) or leave `sortable` off the timeline's per-view `fields` unless you use `one-per-row`. -Under `one-per-field`, vertical order is a property ranking rather than a sort: it comes from `laneOrder` or the field's `groupOrder` (see [Lane packing](#lane-packing)), and the active sort only decides where undeclared values land. +`lanePacking="one-per-field"` is the other place sort reaches, and there it does more than reorder: the sorted-by field *defines* the lanes, so changing the sort field rebuilds them (see [Lane packing](#lane-packing)). Leave the Ordering control visible for that mode. ### Scale and axis @@ -597,7 +597,7 @@ Start with `isLoading={true}` and fire an initial fetch on mount: with no data a ### Notes -- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` (lane order under `one-per-field` comes from `laneOrder`/`groupOrder`) — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. +- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` and `"one-per-field"` (where it defines the lanes) — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. - **`virtualized`** culls both axes: cards, gridlines, tick labels, month bands, and markers render only near the viewport, and the grid and marker lines span the visible window rather than the full canvas height. A frame costs what is on screen rather than what is in the data, so a long domain and deep grouping stay affordable. It defaults to `false` — pass it explicitly. Recommended whenever the domain is long or rows are numerous. - **Without `virtualized`, nothing is culled vertically.** Every lane in the domain stays mounted and every card in the visible time window renders, so deep grouping over thousands of rows builds a tall, fully populated canvas. The trade is content-driven lane heights (see [Cards](#cards)), which virtualization gives up. - **Interaction** — cards receive row clicks via the root's `onRowClick`; the background supports mouse drag-to-pan with a momentum glide; scrolling past the domain edge won't trigger browser back-swipe. The pane is a focusable, labelled region (`aria-label`, default "Timeline"), so keyboard users can Tab to it and scroll with the arrow keys. diff --git a/apps/www/src/content/docs/components/dataview/props.ts b/apps/www/src/content/docs/components/dataview/props.ts index 23c544eb2..e95c28177 100644 --- a/apps/www/src/content/docs/components/dataview/props.ts +++ b/apps/www/src/content/docs/components/dataview/props.ts @@ -117,8 +117,7 @@ export interface DataViewField { * Section order while this field is the active `group_by`, by raw group value — * e.g. `['High', 'Medium', 'Low']`. Undeclared values follow in first-seen order, * rows with no value land in the last section, and a declared value with no rows - * takes no section. Also the default lane order for - * `DataView.Timeline`'s `lanePacking="one-per-field"`. + * takes no section. */ groupOrder?: string[]; } @@ -288,30 +287,19 @@ export interface DataViewTimelineProps { /** * `auto` packs non-overlapping cards into shared lanes; `one-per-row` gives every row - * its own lane, in row-model (sorted) order; `one-per-field` gives every distinct - * `laneField` value its own lane, packing that value's cards by date within it. All - * apply per group section while `group_by` is active — cards never share a lane + * its own lane, in row-model (sorted) order; `one-per-field` gives every distinct value + * of the *sorted-by* field its own lane, packing that value's cards by date within it. + * All apply per group section while `group_by` is active — cards never share a lane * across sections. + * + * Under `one-per-field` the active sort picks the field lanes are built from and orders + * them, so the Ordering control moves lanes live. Rank values that don't sort naturally + * (High/Medium/Low) with a numeric field and sort on that. Rows with no usable value + * (null, empty, non-primitive) share the last lane. * @defaultValue "auto" */ lanePacking?: 'auto' | 'one-per-row' | 'one-per-field'; - /** - * Accessor key whose values become lanes under `lanePacking="one-per-field"` — rows - * sharing a value share a lane, and a value only takes a sub-lane where two of its own - * cards overlap in time. Rows with no usable value (null, empty, non-primitive) share - * the last lane. Required by `one-per-field`, which falls back to `auto` without it. - */ - laneField?: string; - - /** - * Lane order for `lanePacking="one-per-field"`, by raw `laneField` value — e.g. - * `['High', 'Medium', 'Low']`. Undeclared values follow in first-seen row order, the - * no-value lane comes last, and a listed value with no rows takes no lane. Defaults to - * the lane field's `groupOrder`. - */ - laneOrder?: string[]; - /** * Estimated card height in px, same contract as `DataView.List`: cards render at their * natural content height and auto-measure after paint; the estimate seeds lane layout diff --git a/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts index 58964f66e..c51d13690 100644 --- a/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts +++ b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts @@ -331,16 +331,12 @@ describe('packLanesByField', () => { }); }); - it('orders buckets first-seen when no order is given', () => { + it("orders buckets first-seen — the caller's order", () => { const items = [item('Low', 0), item('High', 0), item('Medium', 0)]; expect(packLanesByField(items).lanes).toEqual([0, 1, 2]); - }); - - it('follows a declared order', () => { - const items = [item('Low', 0), item('High', 0), item('Medium', 0)]; - expect( - packLanesByField(items, { order: ['High', 'Medium', 'Low'] }).lanes - ).toEqual([2, 0, 1]); + // Same values, caller-sorted differently → lanes follow the new order. + const resorted = [item('High', 0), item('Low', 0), item('Medium', 0)]; + expect(packLanesByField(resorted).lanes).toEqual([0, 1, 2]); }); it('adds a sub-lane only where a value overlaps itself', () => { @@ -350,7 +346,7 @@ describe('packLanesByField', () => { item('High', 400), item('Low', 0) ]; - expect(packLanesByField(items, { order: ['High', 'Low'] })).toEqual({ + expect(packLanesByField(items)).toEqual({ lanes: [0, 1, 0, 2], laneCount: 3 }); @@ -364,7 +360,7 @@ describe('packLanesByField', () => { item('Low', 0), item('Low', 10) // two overlapping → lanes 3,4 ]; - expect(packLanesByField(items, { order: ['High', 'Low'] })).toEqual({ + expect(packLanesByField(items)).toEqual({ lanes: [0, 1, 2, 3, 4], laneCount: 5 }); @@ -383,19 +379,11 @@ describe('packLanesByField', () => { }); }); - it('ignores declared values with no items', () => { - const items = [item('Low', 0), item('High', 0)]; - expect( - packLanesByField(items, { order: ['Urgent', 'High', 'Blocked', 'Low'] }) - .lanes - ).toEqual([1, 0]); - }); - it('honours gapPx when deciding a bucket sub-lane', () => { // Same bucket, 50px apart: a 60px gap forces a sub-lane, 8px does not. const items = [item('High', 0, 40), item('High', 50, 40)]; - expect(packLanesByField(items, { gapPx: 8 }).laneCount).toBe(1); - expect(packLanesByField(items, { gapPx: 60 }).laneCount).toBe(2); + expect(packLanesByField(items, 8).laneCount).toBe(1); + expect(packLanesByField(items, 60).laneCount).toBe(2); }); it('packs a single bucket exactly like packLanes', () => { @@ -439,7 +427,7 @@ describe('packLanesByField', () => { ...it, laneKey: KEYS[i % KEYS.length] })); - const { lanes } = packLanesByField(items, { order: KEYS }); + const { lanes } = packLanesByField(items); const keyByLane = new Map(); lanes.forEach((lane, index) => { const seen = keyByLane.get(lane); diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index a3e88d6a6..853ffeb7e 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -305,6 +305,7 @@ type Order = { team?: string; // biome-ignore lint/suspicious/noExplicitAny: one-per-field takes any value priority?: any; + rank?: number; }; const fields: DataViewField[] = [ @@ -1791,18 +1792,21 @@ describe('DataView.Timeline actionsRef', () => { /* ─────────────────────── lanePacking="one-per-field" ─────────────────────── */ /** - * Lanes come from a field's values: rows sharing a value share a lane, and a - * value only claims a sub-lane where two of its own cards overlap in time. Lane - * order is the `laneOrder` prop, else the field's `groupOrder`, else first-seen; - * rows with no usable value lane last. + * Lanes come from the field the view is *sorted* by: rows sharing a value share + * a lane, and a value only claims a sub-lane where two of its own cards overlap + * in time. The sort orders the lanes too, so the Ordering control moves them. + * Rows with no usable value lane last. */ describe('DataView.Timeline field lanes', () => { // Jan 5 → 80px, Jan 6 → 100px (overlaps Jan 5's span), Jan 12 → 220px (clear). + // `rank` is the numeric ranking of `priority`, for sorts that need High before + // Medium before Low — alphabetically that order is impossible. const tasks: Order[] = [ { id: 't1', title: 'A', priority: 'Low', + rank: 3, start: '2025-01-05', end: '2025-01-10' }, @@ -1810,6 +1814,7 @@ describe('DataView.Timeline field lanes', () => { id: 't2', title: 'B', priority: 'High', + rank: 1, start: '2025-01-05', end: '2025-01-10' }, @@ -1817,6 +1822,7 @@ describe('DataView.Timeline field lanes', () => { id: 't3', title: 'C', priority: 'High', + rank: 1, start: '2025-01-12', end: '2025-01-15' }, @@ -1824,19 +1830,16 @@ describe('DataView.Timeline field lanes', () => { id: 't4', title: 'D', priority: 'Medium', + rank: 2, start: '2025-01-05', end: '2025-01-10' } ]; - const priorityFields = (groupOrder?: string[]): DataViewField[] => [ + const sortableFields: DataViewField[] = [ { accessorKey: 'title', label: 'Title', sortable: true }, - { - accessorKey: 'priority', - label: 'Priority', - groupable: true, - ...(groupOrder ? { groupOrder } : {}) - } + { accessorKey: 'priority', label: 'Priority', sortable: true }, + { accessorKey: 'rank', label: 'Rank', sortable: true } ]; const laneOf = (id: string) => @@ -1845,23 +1848,56 @@ describe('DataView.Timeline field lanes', () => { const renderFieldLanes = ( props: Partial> = {}, data: Order[] = tasks, - fieldList: DataViewField[] = priorityFields() + root: { + sort?: { name: string; order: 'asc' | 'desc' }; + fields?: DataViewField[]; + query?: DataViewQuery; + } = {} ) => - renderTimeline( - { lanePacking: 'one-per-field', laneField: 'priority', ...props }, - data, - { fields: fieldList } - ); + renderTimeline({ lanePacking: 'one-per-field', ...props }, data, { + fields: root.fields ?? sortableFields, + sort: root.sort ?? { name: 'priority', order: 'asc' }, + query: root.query + }); - it('gives each distinct value one lane, shared by its rows', () => { + it('lanes by the sorted-by field, one lane per value', () => { renderFieldLanes(); - // First-seen order over the sorted row model (title asc): Low, High, Medium. - expect(laneOf('t1')).toBe('0'); - expect(laneOf('t2')).toBe('1'); - expect(laneOf('t3')).toBe('1'); // same value as t2, no time overlap + // priority asc → High, Low, Medium (text order). + expect(laneOf('t2')).toBe('0'); + expect(laneOf('t3')).toBe('0'); // same value as t2, no time overlap + expect(laneOf('t1')).toBe('1'); expect(laneOf('t4')).toBe('2'); }); + it('reorders lanes when the sort direction flips', () => { + renderFieldLanes(undefined, tasks, { + sort: { name: 'priority', order: 'desc' } + }); + expect(laneOf('t4')).toBe('0'); + expect(laneOf('t1')).toBe('1'); + expect(laneOf('t2')).toBe('2'); + }); + + it("lanes by a rank field for orders text sorting can't produce", () => { + renderFieldLanes(undefined, tasks, { + sort: { name: 'rank', order: 'asc' } + }); + // rank asc → High(1), Medium(2), Low(3). + expect(laneOf('t2')).toBe('0'); + expect(laneOf('t3')).toBe('0'); + expect(laneOf('t4')).toBe('1'); + expect(laneOf('t1')).toBe('2'); + }); + + it('relanes when the sort field changes', () => { + // Sorting by title instead lanes by title — every value distinct, so one + // lane per row, in title order. + renderFieldLanes(undefined, tasks, { + sort: { name: 'title', order: 'asc' } + }); + expect(['t1', 't2', 't3', 't4'].map(laneOf)).toEqual(['0', '1', '2', '3']); + }); + it('adds a sub-lane only where one value overlaps itself', () => { renderFieldLanes(undefined, [ ...tasks, @@ -1870,84 +1906,47 @@ describe('DataView.Timeline field lanes', () => { id: 't5', title: 'E', priority: 'High', + rank: 1, start: '2025-01-06', end: '2025-01-09' } ]); - expect(laneOf('t2')).toBe('1'); - expect(laneOf('t5')).toBe('2'); - // Medium sits below every lane High claimed. - expect(laneOf('t4')).toBe('3'); - }); - - it('orders lanes by the laneOrder prop', () => { - renderFieldLanes({ laneOrder: ['High', 'Medium', 'Low'] }); - expect(laneOf('t2')).toBe('0'); - expect(laneOf('t3')).toBe('0'); - expect(laneOf('t4')).toBe('1'); - expect(laneOf('t1')).toBe('2'); - }); - - it("defaults lane order to the field's groupOrder", () => { - renderFieldLanes( - undefined, - tasks, - priorityFields(['High', 'Medium', 'Low']) - ); expect(laneOf('t2')).toBe('0'); - expect(laneOf('t4')).toBe('1'); + expect(laneOf('t5')).toBe('1'); + // Low sits below every lane High claimed. expect(laneOf('t1')).toBe('2'); }); - it('lets laneOrder override the field groupOrder', () => { - renderFieldLanes( - { laneOrder: ['Low', 'Medium', 'High'] }, - tasks, - priorityFields(['High', 'Medium', 'Low']) - ); - expect(laneOf('t1')).toBe('0'); - expect(laneOf('t4')).toBe('1'); - expect(laneOf('t2')).toBe('2'); - }); - - it('appends values missing from laneOrder in first-seen order', () => { - renderFieldLanes({ laneOrder: ['Medium'] }); - expect(laneOf('t4')).toBe('0'); - expect(laneOf('t1')).toBe('1'); // Low seen first - expect(laneOf('t2')).toBe('2'); - }); - - it('ignores laneOrder values with no rows', () => { - renderFieldLanes({ - laneOrder: ['Urgent', 'High', 'Blocked', 'Low', 'Medium'] - }); - expect(laneOf('t2')).toBe('0'); - expect(laneOf('t1')).toBe('1'); - expect(laneOf('t4')).toBe('2'); - }); - - it('lanes rows with no value last', () => { - renderFieldLanes({ laneOrder: ['High', 'Medium', 'Low'] }, [ + it('lanes rows with no value last, whatever the sort puts first', () => { + renderFieldLanes(undefined, [ { id: 'n1', title: 'N1', priority: null, + rank: 0, start: '2025-01-05', end: '2025-01-10' }, - { id: 'n2', title: 'N2', start: '2025-01-12', end: '2025-01-15' }, + { + id: 'n2', + title: 'N2', + rank: 0, + start: '2025-01-12', + end: '2025-01-15' + }, { id: 'n3', title: 'N3', priority: '', + rank: 0, start: '2025-01-20', end: '2025-01-25' }, ...tasks ]); expect(laneOf('t2')).toBe('0'); - expect(laneOf('t4')).toBe('1'); - expect(laneOf('t1')).toBe('2'); + expect(laneOf('t1')).toBe('1'); + expect(laneOf('t4')).toBe('2'); // null, undefined and '' share the one trailing lane. expect(laneOf('n1')).toBe('3'); expect(laneOf('n2')).toBe('3'); @@ -1961,6 +1960,7 @@ describe('DataView.Timeline field lanes', () => { id: 'obj', title: 'Obj', priority: { id: 'High' }, + rank: 1, start: '2025-01-05', end: '2025-01-10' }, @@ -1968,6 +1968,7 @@ describe('DataView.Timeline field lanes', () => { id: 'ok', title: 'Ok', priority: 'High', + rank: 1, start: '2025-01-12', end: '2025-01-15' } @@ -1979,37 +1980,46 @@ describe('DataView.Timeline field lanes', () => { ); }); - it('keys numeric and boolean values by their string form', () => { - renderFieldLanes({ laneOrder: ['1', '2'] }, [ - { - id: 'p1', - title: 'P1', - priority: 1, - start: '2025-01-05', - end: '2025-01-10' - }, - { - id: 'p2', - title: 'P2', - priority: 2, - start: '2025-01-05', - end: '2025-01-10' - }, - { - id: 'p3', - title: 'P3', - priority: 1, - start: '2025-01-12', - end: '2025-01-15' - } - ]); + it('keys numeric values by their string form', () => { + renderFieldLanes( + undefined, + [ + { + id: 'p1', + title: 'P1', + priority: 'x', + rank: 1, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'p2', + title: 'P2', + priority: 'x', + rank: 2, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'p3', + title: 'P3', + priority: 'x', + rank: 1, + start: '2025-01-12', + end: '2025-01-15' + } + ], + { sort: { name: 'rank', order: 'asc' } } + ); expect(laneOf('p1')).toBe('0'); expect(laneOf('p3')).toBe('0'); expect(laneOf('p2')).toBe('1'); }); it('stacks lanes at the fixed pitch like any other packing', () => { - renderFieldLanes({ laneOrder: ['High', 'Medium', 'Low'] }); + renderFieldLanes(undefined, tasks, { + sort: { name: 'rank', order: 'asc' } + }); // lane 0 at laneGap 16, lane 1 at 16 + 66 + 16, lane 2 at 16 + 2 × 82. expect(screen.getByTestId('card-t2').parentElement!.style.top).toBe('16px'); expect(screen.getByTestId('card-t4').parentElement!.style.top).toBe('98px'); @@ -2025,6 +2035,7 @@ describe('DataView.Timeline field lanes', () => { title: 'E1', team: 'Eng', priority: 'High', + rank: 1, start: '2025-01-05', end: '2025-01-10' }, @@ -2033,6 +2044,7 @@ describe('DataView.Timeline field lanes', () => { title: 'E2', team: 'Eng', priority: 'Low', + rank: 3, start: '2025-01-12', end: '2025-01-15' }, @@ -2041,26 +2053,18 @@ describe('DataView.Timeline field lanes', () => { title: 'D1', team: 'Design', priority: 'Low', + rank: 3, start: '2025-01-05', end: '2025-01-10' } ]; - renderTimeline( - { - lanePacking: 'one-per-field', - laneField: 'priority', - laneOrder: ['High', 'Low'] - }, - grouped, - { - fields: [ - { accessorKey: 'title', label: 'Title', sortable: true }, - { accessorKey: 'team', label: 'Team', groupable: true }, - { accessorKey: 'priority', label: 'Priority' } - ], - query: { group_by: ['team'] } - } - ); + renderFieldLanes(undefined, grouped, { + fields: [ + ...sortableFields, + { accessorKey: 'team', label: 'Team', groupable: true } + ], + query: { group_by: ['team'] } + }); // Section-relative lanes: Design's only value starts back at lane 0, and a // value never spans sections. expect(laneOf('e1')).toBe('0'); @@ -2068,12 +2072,13 @@ describe('DataView.Timeline field lanes', () => { expect(laneOf('d1')).toBe('0'); }); - it('degenerates to time packing when group_by is the lane field', () => { + it('degenerates to time packing when grouped by the sorted field', () => { const grouped: Order[] = [ { id: 'h1', title: 'H1', priority: 'High', + rank: 1, start: '2025-01-05', end: '2025-01-10' }, @@ -2081,6 +2086,7 @@ describe('DataView.Timeline field lanes', () => { id: 'h2', title: 'H2', priority: 'High', + rank: 1, start: '2025-01-12', end: '2025-01-15' }, @@ -2088,46 +2094,30 @@ describe('DataView.Timeline field lanes', () => { id: 'l1', title: 'L1', priority: 'Low', + rank: 3, start: '2025-01-05', end: '2025-01-10' } ]; - renderTimeline( - { lanePacking: 'one-per-field', laneField: 'priority' }, - grouped, - { - fields: priorityFields(['High', 'Low']), - query: { group_by: ['priority'] } - } - ); + renderFieldLanes(undefined, grouped, { + fields: [ + { accessorKey: 'title', label: 'Title', sortable: true }, + { + accessorKey: 'priority', + label: 'Priority', + sortable: true, + groupable: true + }, + { accessorKey: 'rank', label: 'Rank', sortable: true } + ], + query: { group_by: ['priority'] } + }); // Each section already holds one value → one lane per section. expect(laneOf('h1')).toBe('0'); expect(laneOf('h2')).toBe('0'); expect(laneOf('l1')).toBe('0'); }); - it('falls back to auto packing with a dev warning when laneField is missing', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - renderTimeline({ lanePacking: 'one-per-field' }); - // The auto-packing expectations, unchanged: o1 and o3 overlap, o2 reuses 0. - expect(laneOf('o1')).toBe('0'); - expect(laneOf('o3')).toBe('1'); - expect(laneOf('o2')).toBe('0'); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('needs a `laneField`') - ); - }); - - it('ignores laneField under another packing mode, with a dev warning', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - renderFieldLanes({ lanePacking: 'auto' }); - // Time packing: t1, t2 and t4 all overlap at Jan 5 regardless of value. - expect(new Set([laneOf('t1'), laneOf('t2'), laneOf('t4')]).size).toBe(3); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('laneField="priority" is ignored') - ); - }); - it('culls field lanes when virtualized', () => { stubPane(); const many: Order[] = Array.from({ length: 12 }, (_, i) => ({ @@ -2137,10 +2127,7 @@ describe('DataView.Timeline field lanes', () => { start: '2025-01-05', end: '2025-01-10' })); - renderFieldLanes({ virtualized: true }, many, [ - { accessorKey: 'title', label: 'Title', sortable: true }, - { accessorKey: 'priority', label: 'Priority' } - ]); + renderFieldLanes({ virtualized: true }, many); // One lane per value at the fixed 82px pitch; the 200px pane plus overscan // reaches lane 4 (top 344px) and stops before lane 5 (426px). const rendered = Array.from( diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index ef59c61cf..f3b9947bd 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -55,11 +55,6 @@ const DEFAULT_MIN_CARD_WIDTH = 60; const DEFAULT_POINT_WIDTH = 120; /** Floor for the rendered wrapper so near-zero spans stay visible and clickable. */ const MIN_RENDER_WIDTH = 24; -/** - * Joins `laneOrder` into one memo key. A character no lane value realistically - * contains, so serialize-and-split round-trips the list unchanged. - */ -const LANE_ORDER_SEPARATOR = '\u0000'; /** Units of padding around the data extent when no explicit `range` is given. */ const DOMAIN_PAD_UNITS = 2; /** Half-window (in units) of the fallback domain used while loading with no data. */ @@ -436,8 +431,6 @@ export function DataViewTimeline({ onVisibleRangeChange, actionsRef, lanePacking = 'auto', - laneField, - laneOrder, estimatedRowHeight = DEFAULT_ROW_HEIGHT, laneGap = DEFAULT_LANE_GAP, minCardWidth = DEFAULT_MIN_CARD_WIDTH, @@ -448,7 +441,6 @@ export function DataViewTimeline({ }: DataViewTimelineProps) { const { table, - fields, onRowClick, activeView, registerFieldsForView, @@ -530,43 +522,15 @@ export function DataViewTimeline({ return list; }, [rows]); - // `one-per-field` needs a key to bucket rows by, so without `laneField` there - // is nothing to lane on and packing degrades to 'auto'; a `laneField` handed - // to any other mode is inert. Resolved to one flag the memos below read. + // `one-per-field` lanes by the field the view is *sorted* by: the row model + // already arrives grouped and ranked by it, so lane membership and lane order + // both fall out of the active sort — no second ordering vocabulary, and the + // Ordering control repositions lanes live. Falls back to 'auto' if the query + // somehow carries no sort (the root requires `defaultSort`, so this is a + // guard rather than a mode). + const laneField = tableQuery.sort?.[0]?.name; const fieldLanes = lanePacking === 'one-per-field' && laneField !== undefined; - // Config warnings, once per config rather than per render. - useEffect(() => { - if (process.env.NODE_ENV === 'production') return; - if (lanePacking === 'one-per-field' && laneField === undefined) { - console.warn( - '[DataView.Timeline] lanePacking="one-per-field" needs a `laneField` to build lanes from — falling back to "auto" packing.' - ); - } else if (lanePacking !== 'one-per-field' && laneField !== undefined) { - console.warn( - `[DataView.Timeline] laneField="${laneField}" is ignored under lanePacking="${lanePacking}" — set lanePacking="one-per-field" to lane by it.` - ); - } - }, [lanePacking, laneField]); - - // Lane order: the prop, else the lane field's declared `groupOrder` (so one - // declaration drives both group sections and lanes). Serialized to a string - // and rebuilt, because an inline array literal would otherwise hand the - // layout memos a new identity on every render. - const laneOrderKey = fieldLanes - ? (( - laneOrder ?? - fields.find(field => field.accessorKey === laneField)?.groupOrder - )?.join(LANE_ORDER_SEPARATOR) ?? '') - : ''; - const effectiveLaneOrder = useMemo( - () => - laneOrderKey === '' - ? undefined - : laneOrderKey.split(LANE_ORDER_SEPARATOR), - [laneOrderKey] - ); - // Resolve each row's start/end timestamps, per section. Rows without a valid // start are skipped (one dev warning for the whole model); inverted ranges // clamp to zero-length spans. @@ -587,10 +551,10 @@ export function DataViewTimeline({ endTime = toTimestamp(original?.[endField]); if (endTime !== null && endTime < startTime) endTime = startTime; } - // Lane bucket, resolved here so packing below is pure geometry. Only a - // primitive identifies a lane; anything else (an object, an array) - // shares the no-value lane rather than collapsing into one - // "[object Object]" bucket. + // Lane bucket — the sorted-by field's value. Resolved here so packing + // below is pure geometry. Only a primitive identifies a lane; anything + // else (an object, an array) shares the no-value lane rather than + // collapsing into one "[object Object]" bucket. let laneKey: string | null = null; if (fieldLanes) { const value = original?.[laneField as string]; @@ -615,7 +579,7 @@ export function DataViewTimeline({ } if (process.env.NODE_ENV !== 'production' && unlaned > 0) { console.warn( - `[DataView.Timeline] ${unlaned} row(s) have a non-primitive "${laneField}" value and share the last lane — "${laneField}" should resolve to a string or number.` + `[DataView.Timeline] ${unlaned} row(s) have a non-primitive "${laneField}" value and share the last lane — the sorted-by field should resolve to a string or number under lanePacking="one-per-field".` ); } return list; @@ -773,8 +737,7 @@ export function DataViewTimeline({ laneKey: item.laneKey, x: item.x, width: item.packWidth - })), - { order: effectiveLaneOrder } + })) ) : packLanes( section.items.map(item => ({ @@ -787,7 +750,7 @@ export function DataViewTimeline({ return entry; }); return { laidOutSections: list, laneCount: offset }; - }, [positionedSections, lanePacking, fieldLanes, effectiveLaneOrder]); + }, [positionedSections, lanePacking, fieldLanes]); /** * Virtualizing vertically means a card off-screen never mounts and so never diff --git a/packages/raystack/components/data-view/data-view.types.tsx b/packages/raystack/components/data-view/data-view.types.tsx index eaa12969e..6606e86dc 100644 --- a/packages/raystack/components/data-view/data-view.types.tsx +++ b/packages/raystack/components/data-view/data-view.types.tsx @@ -102,9 +102,7 @@ export interface DataViewField { * * Values absent from the list follow in first-seen data order, and rows with * no value always land in the last section. A listed value with no rows - * produces no section. Honoured by every renderer that groups, and used by - * `DataView.Timeline` as the default lane order for - * `lanePacking="one-per-field"`. + * produces no section. Honoured by every renderer that groups. */ groupOrder?: string[]; } @@ -415,35 +413,19 @@ export interface DataViewTimelineProps { /** * 'auto' (default) packs non-overlapping cards into shared lanes (greedy * interval scheduling); 'one-per-row' gives every row its own lane, in - * row-model (sorted) order; 'one-per-field' gives every distinct `laneField` - * value its own lane, packing that value's cards by date within it. All - * apply per group section when `group_by` is active — cards never share a - * lane across sections. - */ - lanePacking?: 'auto' | 'one-per-row' | 'one-per-field'; - /** - * Accessor key whose values become lanes under - * `lanePacking="one-per-field"` — e.g. `"priority"` puts every High task on - * one lane, every Low task on the next. Rows sharing a value share a lane, - * and a value only takes an extra sub-lane where two of its own cards - * overlap in time. + * row-model (sorted) order; 'one-per-field' gives every distinct value of + * the **sorted-by** field its own lane, packing that value's cards by date + * within it. All apply per group section when `group_by` is active — cards + * never share a lane across sections. * - * Rows whose value is null, undefined, empty, or a non-primitive share one - * lane placed last. Required by `one-per-field` (which falls back to 'auto' - * without it) and ignored by the other packing modes. + * Under 'one-per-field' the active sort does double duty: it picks the field + * lanes are built from (sort by `priority` → a High lane, a Medium lane, a + * Low lane) and it orders them, so the Ordering control repositions lanes + * live. Lane order is the sort's order, so rank values that don't sort + * naturally (High/Medium/Low) with a numeric field and sort on that. Rows + * whose value is null, empty, or a non-primitive share one lane, placed last. */ - laneField?: string; - /** - * Lane order for `lanePacking="one-per-field"`, by raw `laneField` value — - * e.g. `['High', 'Medium', 'Low']`, which sorting alone can't produce. - * - * Values absent from the list follow in first-seen row order, and the - * no-value lane always comes last. A listed value with no rows takes no - * lane. Defaults to the `laneField` field's `groupOrder`, so declaring the - * order once on the field drives both group sections and lanes; this prop - * overrides it for this renderer. - */ - laneOrder?: string[]; + lanePacking?: 'auto' | 'one-per-row' | 'one-per-field'; /** * Lane height in px. Default 66. * diff --git a/packages/raystack/components/data-view/utils/pack-lanes.tsx b/packages/raystack/components/data-view/utils/pack-lanes.tsx index fea15fcee..cd3d5881a 100644 --- a/packages/raystack/components/data-view/utils/pack-lanes.tsx +++ b/packages/raystack/components/data-view/utils/pack-lanes.tsx @@ -58,17 +58,17 @@ export interface PackFieldLaneItem extends PackLaneItem { * share a lane, and a value only takes extra (sub-)lanes when two of its own * cards overlap in time. Backs `lanePacking="one-per-field"`. * - * Buckets come out in `order` where it lists them, then in first-seen order, - * with the no-value bucket last — the same rule `groupData` applies to sections, - * so lanes and sections agree (see `orderBucketKeys`). Within a bucket the - * greedy first-fit of `packLanes` decides sub-lanes, so a value with no - * overlapping cards occupies exactly one lane. + * Buckets come out in first-seen order, with the no-value bucket last — the + * same rule `groupData` applies to sections (see `orderBucketKeys`). Callers + * hand over an already-ordered list (the timeline passes the sorted row model), + * so first-seen *is* the caller's order. Within a bucket the greedy first-fit + * of `packLanes` decides sub-lanes, so a value with no overlapping cards + * occupies exactly one lane. */ export function packLanesByField( items: PackFieldLaneItem[], - options: { order?: string[]; gapPx?: number } = {} + gapPx: number = DEFAULT_CARD_GAP_PX ): PackLanesResult { - const { order, gapPx = DEFAULT_CARD_GAP_PX } = options; const lanes = new Array(items.length).fill(0); if (items.length === 0) return { lanes, laneCount: 0 }; @@ -86,7 +86,7 @@ export function packLanesByField( } let laneCount = 0; - for (const key of orderBucketKeys([...buckets.keys()], order)) { + for (const key of orderBucketKeys([...buckets.keys()])) { const bucket = buckets.get(key) as number[]; // Packing runs on the bucket alone, so lanes are bucket-relative and shift // up by the lanes every earlier bucket already claimed. From 93c6efa7263f4efbb2a1d5aa213231f0f615bf12 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 19 Aug 2026 09:46:39 +0530 Subject: [PATCH 3/4] refactor(data-view): rename the mode to one-per-sort-value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `one-per-field` said nothing about what a lane holds, and read literally it was wrong — a lane is a value, not a field, and there is no field prop any more. `one-per-sort-value` names both halves: the unit (one value) and where it comes from (the sort), which is the part a reader can't otherwise guess. Internals follow: packLanesByField → packLanesBySortValue, PackFieldLaneItem → PackSortValueLaneItem, fieldLanes → sortValueLanes, and the demo/test names. Co-Authored-By: Claude Opus 5 (1M context) --- apps/www/src/components/dataview-demo.tsx | 10 +++--- apps/www/src/components/demo/demo.tsx | 4 +-- .../content/docs/components/dataview/demo.ts | 6 ++-- .../docs/components/dataview/index.mdx | 16 ++++----- .../content/docs/components/dataview/props.ts | 6 ++-- .../__tests__/order-bucket-keys.test.ts | 2 +- .../data-view/__tests__/pack-lanes.test.ts | 32 ++++++++--------- .../data-view/__tests__/timeline.test.tsx | 36 +++++++++---------- .../data-view/components/timeline.tsx | 13 +++---- .../components/data-view/data-view.types.tsx | 6 ++-- .../data-view/utils/order-bucket-keys.tsx | 2 +- .../components/data-view/utils/pack-lanes.tsx | 10 +++--- 12 files changed, 72 insertions(+), 71 deletions(-) diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index b360d9a9b..5b42ae67a 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -674,7 +674,7 @@ type Task = { priority: 'High' | 'Medium' | 'Low'; /* Priority as a number. Sorting the label alphabetically gives High, Low, Medium — this is what "sort by priority" has to mean to be useful, and it - is what the field-lane timeline lanes on. */ + is what the sort-value lane timeline lanes on. */ rank: 1 | 2 | 3; start: string; end: string; @@ -798,7 +798,7 @@ const taskFields: DataViewField[] = [ ] }, { - // Sortable because the field-lane timeline lanes by whatever is sorted: + // Sortable because the sort-value lane timeline lanes by whatever is sorted: // sorting on rank yields a High lane, a Medium lane and a Low lane. accessorKey: 'rank', label: 'Priority rank', @@ -1090,9 +1090,9 @@ export function DataViewTimelineGroupingDemo() { ); } -/* ── Timeline field-lane demo (lanePacking="one-per-field") ────────────── */ +/* ── Timeline sort-value lane demo (lanePacking="one-per-sort-value") ────────────── */ -export function DataViewTimelineFieldLaneDemo() { +export function DataViewTimelineSortValueLaneDemo() { return ( startField='start' endField='end' - lanePacking='one-per-field' + lanePacking='one-per-sort-value' renderCard={(row, context) => ( )} diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 66e304a6c..fcc92a29e 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -49,9 +49,9 @@ import { DataViewSelectionDemo, DataViewTableDemo, DataViewTimelineDemo, - DataViewTimelineFieldLaneDemo, DataViewTimelineGroupingDemo, DataViewTimelinePointDemo, + DataViewTimelineSortValueLaneDemo, DataViewVirtualizedDemo, DataViewVirtualizedGroupingDemo } from '../dataview-demo'; @@ -85,7 +85,7 @@ export default function Demo(props: DemoProps) { DataViewSearchDemo, DataViewSelectionDemo, DataViewTimelineDemo, - DataViewTimelineFieldLaneDemo, + DataViewTimelineSortValueLaneDemo, DataViewTimelineGroupingDemo, DataViewTimelinePointDemo, ChipInputDemo, diff --git a/apps/www/src/content/docs/components/dataview/demo.ts b/apps/www/src/content/docs/components/dataview/demo.ts index 323c07864..2d15f8087 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -503,11 +503,11 @@ export const timelineGroupingPreview = { ] }; -export const timelineFieldLanePreview = { +export const timelineSortValueLanePreview = { type: 'code', style: { padding: 0 }, previewCode: false, - code: ``, + code: ``, codePreview: [ { label: 'index.tsx', @@ -533,7 +533,7 @@ export const timelineFieldLanePreview = { } /> ` diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index 4c0bca4a5..396b46f0d 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -18,7 +18,7 @@ import { perViewFieldsPreview, rowSelectionPreview, timelinePreview, - timelineFieldLanePreview, + timelineSortValueLanePreview, timelineGroupingPreview, timelinePointPreview, } from "./demo.ts"; @@ -452,13 +452,13 @@ Omit `endField` and rows render as point markers at their date — releases, inc | --- | --- | --- | | `auto` (default) | a dense chronological track: cards that don't overlap in time share it | fitting many cards into the least vertical space | | `one-per-row` | one row | a Gantt chart, where every row needs its own visible track | -| `one-per-field` | one distinct value of the **sorted-by** field | grouping by a property while keeping the flat single-axis layout | +| `one-per-sort-value` | one distinct value of the **sorted-by** field | grouping by a property while keeping the flat single-axis layout | -Under `one-per-field`, rows sharing a value share a lane and are packed by date within it; a value only claims a **sub-lane** where two of its own cards overlap in time. So a timeline sorted by priority shows a High lane, a Medium lane and a Low lane, and only the priority with genuinely concurrent work grows a second row. +Under `one-per-sort-value`, rows sharing a value share a lane and are packed by date within it; a value only claims a **sub-lane** where two of its own cards overlap in time. So a timeline sorted by priority shows a High lane, a Medium lane and a Low lane, and only the priority with genuinely concurrent work grows a second row. The active sort does double duty here: it picks the field lanes are built from *and* orders them, so the Ordering control repositions lanes live and there's no second ordering vocabulary to keep in sync. - + ```tsx @@ -505,7 +505,7 @@ The timeline consumes the **same group rows** `DataView.List` renders as section Section order is the field's `groupOrder` where it declares one (`['High', 'Medium', 'Low']` — the ranking sorting can't express), then values it doesn't list in first-occurrence order, with rows that have no value in the last section. A declared value with no rows renders no section. -- **Packing is per section.** A card only ever shares a lane with cards in its own group, and `context.laneIndex` is section-relative (every section starts at lane 0). `lanePacking="one-per-row"` and `"one-per-field"` apply within each section too. +- **Packing is per section.** A card only ever shares a lane with cards in its own group, and `context.laneIndex` is section-relative (every section starts at lane 0). `lanePacking="one-per-row"` and `"one-per-sort-value"` apply within each section too. - **Bands pin while their section is in view.** The active band sticks directly under the time axis and is pushed off by the next section's band; its label sticks to the left edge so it stays readable while you pan to a distant month. Always on — pure CSS, no prop. - **Empty sections disappear.** A group whose cards all fall outside an explicit `range` (or that has no valid `startField` values) renders nothing — no band, no empty strip. A band that *does* render shows the full group count, even when some of its cards are culled, matching List. - **`showGroupHeaders={false}`** hides the bands but keeps the sections — same semantics as the prop on `DataView.List`. Style a band with `classNames.groupHeader`. @@ -516,7 +516,7 @@ Bands are labels only in this release: no chevron, no collapsing. Sort can't move a card horizontally — x is locked to the start date — so it surfaces in exactly one place: `lanePacking="one-per-row"`, where vertical row order follows the active sort (within each section when grouped). With the default `auto` packing, lanes are assigned by dense chronological first-fit and the sort has no visible effect, so hide the Ordering control (``) or leave `sortable` off the timeline's per-view `fields` unless you use `one-per-row`. -`lanePacking="one-per-field"` is the other place sort reaches, and there it does more than reorder: the sorted-by field *defines* the lanes, so changing the sort field rebuilds them (see [Lane packing](#lane-packing)). Leave the Ordering control visible for that mode. +`lanePacking="one-per-sort-value"` is the other place sort reaches, and there it does more than reorder: the sorted-by field *defines* the lanes, so changing the sort field rebuilds them (see [Lane packing](#lane-packing)). Leave the Ordering control visible for that mode. ### Scale and axis @@ -597,7 +597,7 @@ Start with `isLoading={true}` and fire an initial fetch on mount: with no data a ### Notes -- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` and `"one-per-field"` (where it defines the lanes) — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. +- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` and `"one-per-sort-value"` (where it defines the lanes) — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. - **`virtualized`** culls both axes: cards, gridlines, tick labels, month bands, and markers render only near the viewport, and the grid and marker lines span the visible window rather than the full canvas height. A frame costs what is on screen rather than what is in the data, so a long domain and deep grouping stay affordable. It defaults to `false` — pass it explicitly. Recommended whenever the domain is long or rows are numerous. - **Without `virtualized`, nothing is culled vertically.** Every lane in the domain stays mounted and every card in the visible time window renders, so deep grouping over thousands of rows builds a tall, fully populated canvas. The trade is content-driven lane heights (see [Cards](#cards)), which virtualization gives up. - **Interaction** — cards receive row clicks via the root's `onRowClick`; the background supports mouse drag-to-pan with a momentum glide; scrolling past the domain edge won't trigger browser back-swipe. The pane is a focusable, labelled region (`aria-label`, default "Timeline"), so keyboard users can Tab to it and scroll with the arrow keys. diff --git a/apps/www/src/content/docs/components/dataview/props.ts b/apps/www/src/content/docs/components/dataview/props.ts index e95c28177..91a8bd61f 100644 --- a/apps/www/src/content/docs/components/dataview/props.ts +++ b/apps/www/src/content/docs/components/dataview/props.ts @@ -287,18 +287,18 @@ export interface DataViewTimelineProps { /** * `auto` packs non-overlapping cards into shared lanes; `one-per-row` gives every row - * its own lane, in row-model (sorted) order; `one-per-field` gives every distinct value + * its own lane, in row-model (sorted) order; `one-per-sort-value` gives every distinct value * of the *sorted-by* field its own lane, packing that value's cards by date within it. * All apply per group section while `group_by` is active — cards never share a lane * across sections. * - * Under `one-per-field` the active sort picks the field lanes are built from and orders + * Under `one-per-sort-value` the active sort picks the field lanes are built from and orders * them, so the Ordering control moves lanes live. Rank values that don't sort naturally * (High/Medium/Low) with a numeric field and sort on that. Rows with no usable value * (null, empty, non-primitive) share the last lane. * @defaultValue "auto" */ - lanePacking?: 'auto' | 'one-per-row' | 'one-per-field'; + lanePacking?: 'auto' | 'one-per-row' | 'one-per-sort-value'; /** * Estimated card height in px, same contract as `DataView.List`: cards render at their diff --git a/packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts b/packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts index 8e00f57c9..02a9105a5 100644 --- a/packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts +++ b/packages/raystack/components/data-view/__tests__/order-bucket-keys.test.ts @@ -3,7 +3,7 @@ import { EMPTY_BUCKET_KEY, orderBucketKeys } from '../utils/order-bucket-keys'; /** * `orderBucketKeys` is the single ordering rule shared by `groupData`'s - * sections and Timeline's field lanes: declared order first, undeclared in + * sections and Timeline's sort-value lanes: declared order first, undeclared in * first-seen order, the empty bucket last. Both callers treat its output as * visible layout, so every clause below is observable API. */ diff --git a/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts index c51d13690..09067913f 100644 --- a/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts +++ b/packages/raystack/components/data-view/__tests__/pack-lanes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { packLanes, packLanesByField } from '../utils/pack-lanes'; +import { packLanes, packLanesBySortValue } from '../utils/pack-lanes'; import { digest, randomItems } from './helpers'; /** @@ -301,12 +301,12 @@ describe('packLanes', () => { }); /** - * `packLanesByField` layers value bucketing over `packLanes`: one lane per + * `packLanesBySortValue` layers value bucketing over `packLanes`: one lane per * distinct `laneKey`, sub-lanes only where a bucket's own cards overlap. Lane * numbers are vertical position and reach `renderCard` as `context.laneIndex`, * so both the bucket order and the sub-lane split are visible output. */ -describe('packLanesByField', () => { +describe('packLanesBySortValue', () => { /** `x`/`width` far apart enough that only same-bucket collisions matter. */ const item = (laneKey: string | null, x: number, width = 40) => ({ laneKey, @@ -315,7 +315,7 @@ describe('packLanesByField', () => { }); it('returns no lanes for empty input', () => { - expect(packLanesByField([])).toEqual({ lanes: [], laneCount: 0 }); + expect(packLanesBySortValue([])).toEqual({ lanes: [], laneCount: 0 }); }); it('gives one lane per value and shares it across rows', () => { @@ -325,7 +325,7 @@ describe('packLanesByField', () => { item('High', 200), item('Low', 400) ]; - expect(packLanesByField(items)).toEqual({ + expect(packLanesBySortValue(items)).toEqual({ lanes: [0, 1, 0, 1], laneCount: 2 }); @@ -333,10 +333,10 @@ describe('packLanesByField', () => { it("orders buckets first-seen — the caller's order", () => { const items = [item('Low', 0), item('High', 0), item('Medium', 0)]; - expect(packLanesByField(items).lanes).toEqual([0, 1, 2]); + expect(packLanesBySortValue(items).lanes).toEqual([0, 1, 2]); // Same values, caller-sorted differently → lanes follow the new order. const resorted = [item('High', 0), item('Low', 0), item('Medium', 0)]; - expect(packLanesByField(resorted).lanes).toEqual([0, 1, 2]); + expect(packLanesBySortValue(resorted).lanes).toEqual([0, 1, 2]); }); it('adds a sub-lane only where a value overlaps itself', () => { @@ -346,7 +346,7 @@ describe('packLanesByField', () => { item('High', 400), item('Low', 0) ]; - expect(packLanesByField(items)).toEqual({ + expect(packLanesBySortValue(items)).toEqual({ lanes: [0, 1, 0, 2], laneCount: 3 }); @@ -360,7 +360,7 @@ describe('packLanesByField', () => { item('Low', 0), item('Low', 10) // two overlapping → lanes 3,4 ]; - expect(packLanesByField(items)).toEqual({ + expect(packLanesBySortValue(items)).toEqual({ lanes: [0, 1, 2, 3, 4], laneCount: 5 }); @@ -368,12 +368,12 @@ describe('packLanesByField', () => { it('puts the no-value bucket last', () => { const items = [item(null, 0), item('High', 0), item('Low', 0)]; - expect(packLanesByField(items).lanes).toEqual([2, 0, 1]); + expect(packLanesBySortValue(items).lanes).toEqual([2, 0, 1]); }); it('buckets the empty string with no-value rows', () => { const items = [item('', 0), item('High', 0), item(null, 400)]; - expect(packLanesByField(items)).toEqual({ + expect(packLanesBySortValue(items)).toEqual({ lanes: [1, 0, 1], laneCount: 2 }); @@ -382,14 +382,14 @@ describe('packLanesByField', () => { it('honours gapPx when deciding a bucket sub-lane', () => { // Same bucket, 50px apart: a 60px gap forces a sub-lane, 8px does not. const items = [item('High', 0, 40), item('High', 50, 40)]; - expect(packLanesByField(items, 8).laneCount).toBe(1); - expect(packLanesByField(items, 60).laneCount).toBe(2); + expect(packLanesBySortValue(items, 8).laneCount).toBe(1); + expect(packLanesBySortValue(items, 60).laneCount).toBe(2); }); it('packs a single bucket exactly like packLanes', () => { const items = randomItems(300, 7); const flat = packLanes(items); - const bucketed = packLanesByField( + const bucketed = packLanesBySortValue( items.map(({ x, width }) => ({ laneKey: 'one', x, width })) ); expect(digest(bucketed.lanes)).toBe(digest(flat.lanes)); @@ -402,7 +402,7 @@ describe('packLanesByField', () => { ...it, laneKey: KEYS[i % KEYS.length] })); - const { lanes, laneCount } = packLanesByField(items); + const { lanes, laneCount } = packLanesBySortValue(items); const byLane = new Map(); lanes.forEach((lane, index) => { @@ -427,7 +427,7 @@ describe('packLanesByField', () => { ...it, laneKey: KEYS[i % KEYS.length] })); - const { lanes } = packLanesByField(items); + const { lanes } = packLanesBySortValue(items); const keyByLane = new Map(); lanes.forEach((lane, index) => { const seen = keyByLane.get(lane); diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index 853ffeb7e..7520e15e8 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -303,7 +303,7 @@ type Order = { start: string | null; end: string | null; team?: string; - // biome-ignore lint/suspicious/noExplicitAny: one-per-field takes any value + // biome-ignore lint/suspicious/noExplicitAny: one-per-sort-value takes any value priority?: any; rank?: number; }; @@ -1789,7 +1789,7 @@ describe('DataView.Timeline actionsRef', () => { }); }); -/* ─────────────────────── lanePacking="one-per-field" ─────────────────────── */ +/* ─────────────────────── lanePacking="one-per-sort-value" ─────────────────────── */ /** * Lanes come from the field the view is *sorted* by: rows sharing a value share @@ -1797,7 +1797,7 @@ describe('DataView.Timeline actionsRef', () => { * in time. The sort orders the lanes too, so the Ordering control moves them. * Rows with no usable value lane last. */ -describe('DataView.Timeline field lanes', () => { +describe('DataView.Timeline sort-value lanes', () => { // Jan 5 → 80px, Jan 6 → 100px (overlaps Jan 5's span), Jan 12 → 220px (clear). // `rank` is the numeric ranking of `priority`, for sorts that need High before // Medium before Low — alphabetically that order is impossible. @@ -1845,7 +1845,7 @@ describe('DataView.Timeline field lanes', () => { const laneOf = (id: string) => screen.getByTestId(`card-${id}`).dataset.lane as string; - const renderFieldLanes = ( + const renderSortValueLanes = ( props: Partial> = {}, data: Order[] = tasks, root: { @@ -1854,14 +1854,14 @@ describe('DataView.Timeline field lanes', () => { query?: DataViewQuery; } = {} ) => - renderTimeline({ lanePacking: 'one-per-field', ...props }, data, { + renderTimeline({ lanePacking: 'one-per-sort-value', ...props }, data, { fields: root.fields ?? sortableFields, sort: root.sort ?? { name: 'priority', order: 'asc' }, query: root.query }); it('lanes by the sorted-by field, one lane per value', () => { - renderFieldLanes(); + renderSortValueLanes(); // priority asc → High, Low, Medium (text order). expect(laneOf('t2')).toBe('0'); expect(laneOf('t3')).toBe('0'); // same value as t2, no time overlap @@ -1870,7 +1870,7 @@ describe('DataView.Timeline field lanes', () => { }); it('reorders lanes when the sort direction flips', () => { - renderFieldLanes(undefined, tasks, { + renderSortValueLanes(undefined, tasks, { sort: { name: 'priority', order: 'desc' } }); expect(laneOf('t4')).toBe('0'); @@ -1879,7 +1879,7 @@ describe('DataView.Timeline field lanes', () => { }); it("lanes by a rank field for orders text sorting can't produce", () => { - renderFieldLanes(undefined, tasks, { + renderSortValueLanes(undefined, tasks, { sort: { name: 'rank', order: 'asc' } }); // rank asc → High(1), Medium(2), Low(3). @@ -1892,14 +1892,14 @@ describe('DataView.Timeline field lanes', () => { it('relanes when the sort field changes', () => { // Sorting by title instead lanes by title — every value distinct, so one // lane per row, in title order. - renderFieldLanes(undefined, tasks, { + renderSortValueLanes(undefined, tasks, { sort: { name: 'title', order: 'asc' } }); expect(['t1', 't2', 't3', 't4'].map(laneOf)).toEqual(['0', '1', '2', '3']); }); it('adds a sub-lane only where one value overlaps itself', () => { - renderFieldLanes(undefined, [ + renderSortValueLanes(undefined, [ ...tasks, // Overlaps t2 [80..180] and shares its value → High takes a second lane. { @@ -1918,7 +1918,7 @@ describe('DataView.Timeline field lanes', () => { }); it('lanes rows with no value last, whatever the sort puts first', () => { - renderFieldLanes(undefined, [ + renderSortValueLanes(undefined, [ { id: 'n1', title: 'N1', @@ -1955,7 +1955,7 @@ describe('DataView.Timeline field lanes', () => { it('lanes non-primitive values last, with a dev warning', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - renderFieldLanes(undefined, [ + renderSortValueLanes(undefined, [ { id: 'obj', title: 'Obj', @@ -1981,7 +1981,7 @@ describe('DataView.Timeline field lanes', () => { }); it('keys numeric values by their string form', () => { - renderFieldLanes( + renderSortValueLanes( undefined, [ { @@ -2017,7 +2017,7 @@ describe('DataView.Timeline field lanes', () => { }); it('stacks lanes at the fixed pitch like any other packing', () => { - renderFieldLanes(undefined, tasks, { + renderSortValueLanes(undefined, tasks, { sort: { name: 'rank', order: 'asc' } }); // lane 0 at laneGap 16, lane 1 at 16 + 66 + 16, lane 2 at 16 + 2 × 82. @@ -2058,7 +2058,7 @@ describe('DataView.Timeline field lanes', () => { end: '2025-01-10' } ]; - renderFieldLanes(undefined, grouped, { + renderSortValueLanes(undefined, grouped, { fields: [ ...sortableFields, { accessorKey: 'team', label: 'Team', groupable: true } @@ -2099,7 +2099,7 @@ describe('DataView.Timeline field lanes', () => { end: '2025-01-10' } ]; - renderFieldLanes(undefined, grouped, { + renderSortValueLanes(undefined, grouped, { fields: [ { accessorKey: 'title', label: 'Title', sortable: true }, { @@ -2118,7 +2118,7 @@ describe('DataView.Timeline field lanes', () => { expect(laneOf('l1')).toBe('0'); }); - it('culls field lanes when virtualized', () => { + it('culls sort-value lanes when virtualized', () => { stubPane(); const many: Order[] = Array.from({ length: 12 }, (_, i) => ({ id: `v${String(i + 1).padStart(2, '0')}`, @@ -2127,7 +2127,7 @@ describe('DataView.Timeline field lanes', () => { start: '2025-01-05', end: '2025-01-10' })); - renderFieldLanes({ virtualized: true }, many); + renderSortValueLanes({ virtualized: true }, many); // One lane per value at the fixed 82px pitch; the 200px pane plus overscan // reaches lane 4 (top 344px) and stops before lane 5 (426px). const rendered = Array.from( diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index f3b9947bd..5120df29a 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -27,7 +27,7 @@ import { } from '../data-view.types'; import { useDataView } from '../hooks/useDataView'; import { orderByX } from '../utils/order-by-x'; -import { packLanes, packLanesByField } from '../utils/pack-lanes'; +import { packLanes, packLanesBySortValue } from '../utils/pack-lanes'; import { buildAxis, createTimeScale, @@ -236,7 +236,7 @@ interface TimedItem { /** Null when `endField` is omitted (point marker). */ endTime: number | null; /** - * Bucket the row falls in under `lanePacking="one-per-field"` — its + * Bucket the row falls in under `lanePacking="one-per-sort-value"` — its * `laneField` value as a string, or null for no usable value (that bucket * lanes last). Null throughout for every other packing mode. */ @@ -522,14 +522,15 @@ export function DataViewTimeline({ return list; }, [rows]); - // `one-per-field` lanes by the field the view is *sorted* by: the row model + // `one-per-sort-value` lanes by the field the view is *sorted* by: the row model // already arrives grouped and ranked by it, so lane membership and lane order // both fall out of the active sort — no second ordering vocabulary, and the // Ordering control repositions lanes live. Falls back to 'auto' if the query // somehow carries no sort (the root requires `defaultSort`, so this is a // guard rather than a mode). const laneField = tableQuery.sort?.[0]?.name; - const fieldLanes = lanePacking === 'one-per-field' && laneField !== undefined; + const fieldLanes = + lanePacking === 'one-per-sort-value' && laneField !== undefined; // Resolve each row's start/end timestamps, per section. Rows without a valid // start are skipped (one dev warning for the whole model); inverted ranges @@ -579,7 +580,7 @@ export function DataViewTimeline({ } if (process.env.NODE_ENV !== 'production' && unlaned > 0) { console.warn( - `[DataView.Timeline] ${unlaned} row(s) have a non-primitive "${laneField}" value and share the last lane — the sorted-by field should resolve to a string or number under lanePacking="one-per-field".` + `[DataView.Timeline] ${unlaned} row(s) have a non-primitive "${laneField}" value and share the last lane — the sorted-by field should resolve to a string or number under lanePacking="one-per-sort-value".` ); } return list; @@ -732,7 +733,7 @@ export function DataViewTimeline({ laneCount: section.items.length } : fieldLanes - ? packLanesByField( + ? packLanesBySortValue( section.items.map(item => ({ laneKey: item.laneKey, x: item.x, diff --git a/packages/raystack/components/data-view/data-view.types.tsx b/packages/raystack/components/data-view/data-view.types.tsx index 6606e86dc..ef96e6468 100644 --- a/packages/raystack/components/data-view/data-view.types.tsx +++ b/packages/raystack/components/data-view/data-view.types.tsx @@ -413,19 +413,19 @@ export interface DataViewTimelineProps { /** * 'auto' (default) packs non-overlapping cards into shared lanes (greedy * interval scheduling); 'one-per-row' gives every row its own lane, in - * row-model (sorted) order; 'one-per-field' gives every distinct value of + * row-model (sorted) order; 'one-per-sort-value' gives every distinct value of * the **sorted-by** field its own lane, packing that value's cards by date * within it. All apply per group section when `group_by` is active — cards * never share a lane across sections. * - * Under 'one-per-field' the active sort does double duty: it picks the field + * Under 'one-per-sort-value' the active sort does double duty: it picks the field * lanes are built from (sort by `priority` → a High lane, a Medium lane, a * Low lane) and it orders them, so the Ordering control repositions lanes * live. Lane order is the sort's order, so rank values that don't sort * naturally (High/Medium/Low) with a numeric field and sort on that. Rows * whose value is null, empty, or a non-primitive share one lane, placed last. */ - lanePacking?: 'auto' | 'one-per-row' | 'one-per-field'; + lanePacking?: 'auto' | 'one-per-row' | 'one-per-sort-value'; /** * Lane height in px. Default 66. * diff --git a/packages/raystack/components/data-view/utils/order-bucket-keys.tsx b/packages/raystack/components/data-view/utils/order-bucket-keys.tsx index 8c5958cf2..283651fd3 100644 --- a/packages/raystack/components/data-view/utils/order-bucket-keys.tsx +++ b/packages/raystack/components/data-view/utils/order-bucket-keys.tsx @@ -17,7 +17,7 @@ export const EMPTY_BUCKET_KEY = ''; * appears in `order`, so a declared list doesn't have to mention it. * * Shared by `groupData` (section order for every renderer) and - * `packLanesByField` (Timeline lane order) so sections and lanes can never + * `packLanesBySortValue` (Timeline lane order) so sections and lanes can never * disagree about where a value sits. */ export function orderBucketKeys(keys: string[], order?: string[]): string[] { diff --git a/packages/raystack/components/data-view/utils/pack-lanes.tsx b/packages/raystack/components/data-view/utils/pack-lanes.tsx index cd3d5881a..b5306866d 100644 --- a/packages/raystack/components/data-view/utils/pack-lanes.tsx +++ b/packages/raystack/components/data-view/utils/pack-lanes.tsx @@ -48,15 +48,15 @@ export function packLanes( : packBySweep(items, gapPx, order); } -/** An item plus the field-lane bucket it belongs to. `null` = no value. */ -export interface PackFieldLaneItem extends PackLaneItem { +/** An item plus the sort-value lane bucket it belongs to. `null` = no value. */ +export interface PackSortValueLaneItem extends PackLaneItem { laneKey: string | null; } /** * Lane per distinct `laneKey`, packed by time within each: rows sharing a value * share a lane, and a value only takes extra (sub-)lanes when two of its own - * cards overlap in time. Backs `lanePacking="one-per-field"`. + * cards overlap in time. Backs `lanePacking="one-per-sort-value"`. * * Buckets come out in first-seen order, with the no-value bucket last — the * same rule `groupData` applies to sections (see `orderBucketKeys`). Callers @@ -65,8 +65,8 @@ export interface PackFieldLaneItem extends PackLaneItem { * of `packLanes` decides sub-lanes, so a value with no overlapping cards * occupies exactly one lane. */ -export function packLanesByField( - items: PackFieldLaneItem[], +export function packLanesBySortValue( + items: PackSortValueLaneItem[], gapPx: number = DEFAULT_CARD_GAP_PX ): PackLanesResult { const lanes = new Array(items.length).fill(0); From 5a20a0ed92c8a9c2a3556ecea784d0cf83ef095f Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 19 Aug 2026 13:52:59 +0530 Subject: [PATCH 4/4] fix(data-view): address review on sort-value lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lane values now come from `row.getValue(...)` rather than `row.original[...]`. TanStack reads a dotted `accessorKey` as a path, so `original['meta.rank']` was undefined for the very key it sorted by — every row looked valueless and collapsed onto one lane, with no warning to explain it. - Warn when the sort key matches no field at all: same silent collapse, now named. - Name `laneField` in the lane-layout memo's deps instead of relying on the cascade through `timedSections`. - `packLanesBySortValue` allocates its lane array after the empty-input return, and completes the `fieldLanes` → `sortValueLanes` rename that a BSD `sed \b` silently skipped. - Correct two comments that claimed sections and lanes can never disagree about order. They can, by design: sections rank by `groupOrder`, lanes follow the sort. Only the empty-bucket-last half of `orderBucketKeys` is shared. - Docs: the Ordering section no longer says sort surfaces in "exactly one place". Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/components/dataview/index.mdx | 4 +- .../data-view/__tests__/timeline.test.tsx | 58 +++++++++++++++++++ .../data-view/components/timeline.tsx | 28 +++++++-- .../data-view/utils/order-bucket-keys.tsx | 9 ++- .../components/data-view/utils/pack-lanes.tsx | 20 ++++--- 5 files changed, 100 insertions(+), 19 deletions(-) diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index 396b46f0d..6bc6f56f7 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -514,9 +514,9 @@ Bands are labels only in this release: no chevron, no collapsing. ### Ordering -Sort can't move a card horizontally — x is locked to the start date — so it surfaces in exactly one place: `lanePacking="one-per-row"`, where vertical row order follows the active sort (within each section when grouped). With the default `auto` packing, lanes are assigned by dense chronological first-fit and the sort has no visible effect, so hide the Ordering control (``) or leave `sortable` off the timeline's per-view `fields` unless you use `one-per-row`. +Sort can't move a card horizontally — x is locked to the start date — so it reaches the vertical axis only, and only under two of the three packing modes. With `lanePacking="one-per-row"`, row order follows the active sort (within each section when grouped). With `"one-per-sort-value"` it does more than reorder: the sorted-by field *defines* the lanes, so changing the sort field rebuilds them (see [Lane packing](#lane-packing)). Leave the Ordering control visible for both. -`lanePacking="one-per-sort-value"` is the other place sort reaches, and there it does more than reorder: the sorted-by field *defines* the lanes, so changing the sort field rebuilds them (see [Lane packing](#lane-packing)). Leave the Ordering control visible for that mode. +Under the default `auto` packing the sort has no visible effect at all — lanes are assigned by dense chronological first-fit — so there, hide the control (``) or leave `sortable` off the timeline's per-view `fields`. ### Scale and axis diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index 7520e15e8..30aaecbdf 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -306,6 +306,7 @@ type Order = { // biome-ignore lint/suspicious/noExplicitAny: one-per-sort-value takes any value priority?: any; rank?: number; + meta?: { rank: number }; }; const fields: DataViewField[] = [ @@ -2118,6 +2119,63 @@ describe('DataView.Timeline sort-value lanes', () => { expect(laneOf('l1')).toBe('0'); }); + it('lanes by a dotted accessorKey the way the sort reads it', () => { + // TanStack treats a dotted key as a path, so the lane value has to come + // through the row — `original['meta.rank']` would be undefined for every + // row and pile them all onto the no-value lane. + renderSortValueLanes( + undefined, + [ + { + id: 'd1', + title: 'D1', + meta: { rank: 3 }, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'd2', + title: 'D2', + meta: { rank: 1 }, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'd3', + title: 'D3', + meta: { rank: 2 }, + start: '2025-01-05', + end: '2025-01-10' + } + ], + { + fields: [ + { accessorKey: 'title', label: 'Title', sortable: true }, + { accessorKey: 'meta.rank', label: 'Rank', sortable: true } + ], + sort: { name: 'meta.rank', order: 'asc' } + } + ); + expect(laneOf('d2')).toBe('0'); + expect(laneOf('d3')).toBe('1'); + expect(laneOf('d1')).toBe('2'); + }); + + it('warns when the sort key matches no field', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderSortValueLanes(undefined, tasks, { + sort: { name: 'nope', order: 'asc' } + }); + // Nothing to read → one bucket for every row, which then sub-lanes on time + // overlap alone (t1/t2/t4 all start Jan 5; t3 is clear of them). Visually + // indistinguishable from `auto`, hence the warning. + expect(['t1', 't2', 't4'].map(laneOf)).toEqual(['0', '1', '2']); + expect(laneOf('t3')).toBe('0'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('which matches no field') + ); + }); + it('culls sort-value lanes when virtualized', () => { stubPane(); const many: Order[] = Array.from({ length: 12 }, (_, i) => ({ diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index 5120df29a..e4bc2a9ba 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -529,9 +529,20 @@ export function DataViewTimeline({ // somehow carries no sort (the root requires `defaultSort`, so this is a // guard rather than a mode). const laneField = tableQuery.sort?.[0]?.name; - const fieldLanes = + const sortValueLanes = lanePacking === 'one-per-sort-value' && laneField !== undefined; + // A sort key with no column behind it reads as undefined on every row, which + // would silently collapse the timeline onto the single no-value lane. + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + if (!sortValueLanes || !table || table.getColumn(laneField as string)) + return; + console.warn( + `[DataView.Timeline] lanePacking="one-per-sort-value" is sorted by "${laneField}", which matches no field — every card lands on one lane. Sort by a declared field.` + ); + }, [sortValueLanes, laneField, table]); + // Resolve each row's start/end timestamps, per section. Rows without a valid // start are skipped (one dev warning for the whole model); inverted ranges // clamp to zero-length spans. @@ -557,8 +568,13 @@ export function DataViewTimeline({ // else (an object, an array) shares the no-value lane rather than // collapsing into one "[object Object]" bucket. let laneKey: string | null = null; - if (fieldLanes) { - const value = original?.[laneField as string]; + if (sortValueLanes) { + // Read through the row, not `original`: TanStack treats a dotted + // `accessorKey` as a path, so `original['meta.rank']` is undefined + // for the very key it sorted by — every row would look valueless and + // pile onto one lane. `getValue` yields exactly what the sort saw + // (undefined rather than a throw if the key names no column). + const value = row.getValue(laneField as string); if (value == null || value === '') laneKey = null; else if (typeof value === 'object' || typeof value === 'function') { unlaned++; @@ -584,7 +600,7 @@ export function DataViewTimeline({ ); } return list; - }, [sections, startField, endField, fieldLanes, laneField]); + }, [sections, startField, endField, sortValueLanes, laneField]); // Data extent, for the domain below — grouping never changes the time domain. // Reduced in place rather than through a flattened copy: the extent is two @@ -732,7 +748,7 @@ export function DataViewTimeline({ lanes: section.items.map((_, i) => i), laneCount: section.items.length } - : fieldLanes + : sortValueLanes ? packLanesBySortValue( section.items.map(item => ({ laneKey: item.laneKey, @@ -751,7 +767,7 @@ export function DataViewTimeline({ return entry; }); return { laidOutSections: list, laneCount: offset }; - }, [positionedSections, lanePacking, fieldLanes]); + }, [positionedSections, lanePacking, sortValueLanes, laneField]); /** * Virtualizing vertically means a card off-screen never mounts and so never diff --git a/packages/raystack/components/data-view/utils/order-bucket-keys.tsx b/packages/raystack/components/data-view/utils/order-bucket-keys.tsx index 283651fd3..384d7aeff 100644 --- a/packages/raystack/components/data-view/utils/order-bucket-keys.tsx +++ b/packages/raystack/components/data-view/utils/order-bucket-keys.tsx @@ -16,9 +16,12 @@ export const EMPTY_BUCKET_KEY = ''; * conjures empty bands. The empty bucket is pinned last regardless of where it * appears in `order`, so a declared list doesn't have to mention it. * - * Shared by `groupData` (section order for every renderer) and - * `packLanesBySortValue` (Timeline lane order) so sections and lanes can never - * disagree about where a value sits. + * Shared by `groupData`, which passes the grouped field's declared `groupOrder`, + * and by `packLanesBySortValue`, which passes nothing — timeline lanes follow + * the active sort, so only the empty-bucket-last half of the rule applies + * there. One field that is both grouped and sorted can therefore rank its + * sections and its lanes differently; that's the documented contract of + * `lanePacking="one-per-sort-value"`, not an oversight. */ export function orderBucketKeys(keys: string[], order?: string[]): string[] { const hasEmpty = keys.includes(EMPTY_BUCKET_KEY); diff --git a/packages/raystack/components/data-view/utils/pack-lanes.tsx b/packages/raystack/components/data-view/utils/pack-lanes.tsx index b5306866d..8d26c0877 100644 --- a/packages/raystack/components/data-view/utils/pack-lanes.tsx +++ b/packages/raystack/components/data-view/utils/pack-lanes.tsx @@ -58,22 +58,26 @@ export interface PackSortValueLaneItem extends PackLaneItem { * share a lane, and a value only takes extra (sub-)lanes when two of its own * cards overlap in time. Backs `lanePacking="one-per-sort-value"`. * - * Buckets come out in first-seen order, with the no-value bucket last — the - * same rule `groupData` applies to sections (see `orderBucketKeys`). Callers - * hand over an already-ordered list (the timeline passes the sorted row model), - * so first-seen *is* the caller's order. Within a bucket the greedy first-fit - * of `packLanes` decides sub-lanes, so a value with no overlapping cards - * occupies exactly one lane. + * Buckets come out in first-seen order, with the no-value bucket last. Lane + * order is therefore the caller's row order — the timeline hands over the sorted + * row model, so lanes follow the active sort and nothing else. That's + * deliberately *not* `groupData`'s rule, which ranks sections by the field's + * declared `groupOrder`: a field that is both grouped and sorted can order its + * sections and its lanes differently, and the sort is what the mode promises. + * Only the no-value-bucket-last half is shared (see `orderBucketKeys`). + * + * Within a bucket the greedy first-fit of `packLanes` decides sub-lanes, so a + * value with no overlapping cards occupies exactly one lane. */ export function packLanesBySortValue( items: PackSortValueLaneItem[], gapPx: number = DEFAULT_CARD_GAP_PX ): PackLanesResult { + if (items.length === 0) return { lanes: [], laneCount: 0 }; const lanes = new Array(items.length).fill(0); - if (items.length === 0) return { lanes, laneCount: 0 }; // Indices per bucket, in input order — Map insertion order is the first-seen - // order `orderBucketKeys` expects. + // (caller-sorted) order `orderBucketKeys` preserves. const buckets = new Map(); for (let index = 0; index < items.length; index++) { const key = items[index].laneKey ?? EMPTY_BUCKET_KEY;