diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index e092eaa25..5b42ae67a 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -671,10 +671,21 @@ type Task = { title: string; 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 sort-value 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 @@ -686,38 +697,50 @@ 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, + rank: TASK_RANK[priority], + start: taskDate(from), + end: taskDate(to) + }) +); const taskFields: DataViewField[] = [ { @@ -757,6 +780,32 @@ const taskFields: DataViewField[] = [ { label: 'Done', value: 'done' } ] }, + { + accessorKey: 'priority', + label: 'Priority', + filterable: true, + filterType: 'select', + hideable: true, + // groupOrder ranks the sections when grouping by priority — 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' } + ] + }, + { + // 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', + sortable: true, + hideable: true, + defaultHidden: true + }, { accessorKey: 'start', label: 'Start', @@ -839,6 +888,11 @@ function TaskCard({ {task.team} + + + {task.priority} + + ); @@ -1035,3 +1089,45 @@ export function DataViewTimelineGroupingDemo() { ); } + +/* ── Timeline sort-value lane demo (lanePacking="one-per-sort-value") ────────────── */ + +export function DataViewTimelineSortValueLaneDemo() { + return ( + + + data={tasks} + fields={taskFields} + // 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. */} + + + + + startField='start' + endField='end' + lanePacking='one-per-sort-value' + 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..fcc92a29e 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -51,6 +51,7 @@ import { DataViewTimelineDemo, DataViewTimelineGroupingDemo, DataViewTimelinePointDemo, + DataViewTimelineSortValueLaneDemo, DataViewVirtualizedDemo, DataViewVirtualizedGroupingDemo } from '../dataview-demo'; @@ -84,6 +85,7 @@ export default function Demo(props: DemoProps) { DataViewSearchDemo, DataViewSelectionDemo, DataViewTimelineDemo, + 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 fcc4666ba..2d15f8087 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -503,6 +503,44 @@ export const timelineGroupingPreview = { ] }; +export const timelineSortValueLanePreview = { + 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. + 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: "rank", label: "Priority rank", sortable: true } */ + + 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..6bc6f56f7 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, + timelineSortValueLanePreview, 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-sort-value` | one distinct value of the **sorted-by** field | grouping by a property while keeping the flat single-axis layout | + +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 + t.id}> + + +``` + +- **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 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-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`. @@ -472,7 +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. + +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 @@ -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"` 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 1beedd650..91a8bd61f 100644 --- a/apps/www/src/content/docs/components/dataview/props.ts +++ b/apps/www/src/content/docs/components/dataview/props.ts @@ -112,6 +112,14 @@ 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. + */ + groupOrder?: string[]; } export interface DataViewListProps { @@ -279,11 +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. 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-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-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'; + 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__/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..02a9105a5 --- /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 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. + */ +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..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 } from '../utils/pack-lanes'; +import { packLanes, packLanesBySortValue } from '../utils/pack-lanes'; import { digest, randomItems } from './helpers'; /** @@ -299,3 +299,140 @@ describe('packLanes', () => { } }); }); + +/** + * `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('packLanesBySortValue', () => { + /** `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(packLanesBySortValue([])).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(packLanesBySortValue(items)).toEqual({ + lanes: [0, 1, 0, 1], + laneCount: 2 + }); + }); + + it("orders buckets first-seen — the caller's order", () => { + const items = [item('Low', 0), item('High', 0), item('Medium', 0)]; + 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(packLanesBySortValue(resorted).lanes).toEqual([0, 1, 2]); + }); + + 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(packLanesBySortValue(items)).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(packLanesBySortValue(items)).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(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(packLanesBySortValue(items)).toEqual({ + lanes: [1, 0, 1], + laneCount: 2 + }); + }); + + 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(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 = packLanesBySortValue( + 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 } = packLanesBySortValue(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 } = packLanesBySortValue(items); + 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..30aaecbdf 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -303,6 +303,10 @@ type Order = { start: string | null; end: string | null; team?: string; + // biome-ignore lint/suspicious/noExplicitAny: one-per-sort-value takes any value + priority?: any; + rank?: number; + meta?: { rank: number }; }; const fields: DataViewField[] = [ @@ -1785,3 +1789,414 @@ describe('DataView.Timeline actionsRef', () => { expect(actionsRef.current!.getVisibleRange()).toBeNull(); }); }); + +/* ─────────────────────── lanePacking="one-per-sort-value" ─────────────────────── */ + +/** + * 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 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. + const tasks: Order[] = [ + { + id: 't1', + title: 'A', + priority: 'Low', + rank: 3, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 't2', + title: 'B', + priority: 'High', + rank: 1, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 't3', + title: 'C', + priority: 'High', + rank: 1, + start: '2025-01-12', + end: '2025-01-15' + }, + { + id: 't4', + title: 'D', + priority: 'Medium', + rank: 2, + start: '2025-01-05', + end: '2025-01-10' + } + ]; + + const sortableFields: DataViewField[] = [ + { accessorKey: 'title', label: 'Title', sortable: true }, + { accessorKey: 'priority', label: 'Priority', sortable: true }, + { accessorKey: 'rank', label: 'Rank', sortable: true } + ]; + + const laneOf = (id: string) => + screen.getByTestId(`card-${id}`).dataset.lane as string; + + const renderSortValueLanes = ( + props: Partial> = {}, + data: Order[] = tasks, + root: { + sort?: { name: string; order: 'asc' | 'desc' }; + fields?: DataViewField[]; + query?: DataViewQuery; + } = {} + ) => + 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', () => { + 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 + expect(laneOf('t1')).toBe('1'); + expect(laneOf('t4')).toBe('2'); + }); + + it('reorders lanes when the sort direction flips', () => { + renderSortValueLanes(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", () => { + renderSortValueLanes(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. + 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', () => { + renderSortValueLanes(undefined, [ + ...tasks, + // Overlaps t2 [80..180] and shares its value → High takes a second lane. + { + id: 't5', + title: 'E', + priority: 'High', + rank: 1, + start: '2025-01-06', + end: '2025-01-09' + } + ]); + expect(laneOf('t2')).toBe('0'); + expect(laneOf('t5')).toBe('1'); + // Low sits below every lane High claimed. + expect(laneOf('t1')).toBe('2'); + }); + + it('lanes rows with no value last, whatever the sort puts first', () => { + renderSortValueLanes(undefined, [ + { + id: 'n1', + title: 'N1', + priority: null, + rank: 0, + start: '2025-01-05', + end: '2025-01-10' + }, + { + 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('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'); + expect(laneOf('n3')).toBe('3'); + }); + + it('lanes non-primitive values last, with a dev warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderSortValueLanes(undefined, [ + { + id: 'obj', + title: 'Obj', + priority: { id: 'High' }, + rank: 1, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'ok', + title: 'Ok', + priority: 'High', + rank: 1, + 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 values by their string form', () => { + renderSortValueLanes( + 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', () => { + 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. + 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', + rank: 1, + start: '2025-01-05', + end: '2025-01-10' + }, + { + id: 'e2', + title: 'E2', + team: 'Eng', + priority: 'Low', + rank: 3, + start: '2025-01-12', + end: '2025-01-15' + }, + { + id: 'd1', + title: 'D1', + team: 'Design', + priority: 'Low', + rank: 3, + start: '2025-01-05', + end: '2025-01-10' + } + ]; + renderSortValueLanes(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'); + expect(laneOf('e2')).toBe('1'); + expect(laneOf('d1')).toBe('0'); + }); + + 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' + }, + { + id: 'h2', + title: 'H2', + priority: 'High', + rank: 1, + start: '2025-01-12', + end: '2025-01-15' + }, + { + id: 'l1', + title: 'L1', + priority: 'Low', + rank: 3, + start: '2025-01-05', + end: '2025-01-10' + } + ]; + renderSortValueLanes(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('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) => ({ + 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' + })); + 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( + 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..e4bc2a9ba 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, packLanesBySortValue } from '../utils/pack-lanes'; import { buildAxis, createTimeScale, @@ -235,6 +235,12 @@ interface TimedItem { startTime: number; /** Null when `endField` is omitted (point marker). */ endTime: number | null; + /** + * 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. + */ + laneKey: string | null; } /** A timed row placed on the time scale. */ @@ -516,11 +522,33 @@ export function DataViewTimeline({ return list; }, [rows]); + // `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 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. 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 +563,24 @@ export function DataViewTimeline({ endTime = toTimestamp(original?.[endField]); if (endTime !== null && endTime < startTime) endTime = startTime; } - items.push({ row, startTime, endTime }); + // 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 (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++; + } else laneKey = String(value); + } + items.push({ row, startTime, endTime, laneKey }); } const timed: TimelineSection> = { key: section.key, @@ -549,8 +594,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 — the sorted-by field should resolve to a string or number under lanePacking="one-per-sort-value".` + ); + } return list; - }, [sections, startField, endField]); + }, [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 @@ -698,15 +748,26 @@ export function DataViewTimeline({ lanes: section.items.map((_, i) => i), laneCount: section.items.length } - : packLanes( - section.items.map(item => ({ x: item.x, width: item.packWidth })) - ); + : sortValueLanes + ? packLanesBySortValue( + section.items.map(item => ({ + laneKey: item.laneKey, + x: item.x, + width: item.packWidth + })) + ) + : 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, sortValueLanes, laneField]); /** * 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..ef96e6468 100644 --- a/packages/raystack/components/data-view/data-view.types.tsx +++ b/packages/raystack/components/data-view/data-view.types.tsx @@ -94,6 +94,17 @@ 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. + */ + groupOrder?: string[]; } /** @@ -402,10 +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. Both apply per group section when `group_by` is - * active — cards never share a lane across sections. + * 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-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'; + lanePacking?: 'auto' | 'one-per-row' | 'one-per-sort-value'; /** * 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..384d7aeff --- /dev/null +++ b/packages/raystack/components/data-view/utils/order-bucket-keys.tsx @@ -0,0 +1,47 @@ +/** + * 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`, 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); + 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..8d26c0877 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,65 @@ export function packLanes( : packBySweep(items, gapPx, order); } +/** 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-sort-value"`. + * + * 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); + + // Indices per bucket, in input order — Map insertion order is the first-seen + // (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; + 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()])) { + 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[],