Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/timeline-binds-to-the-calendar-date-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@object-ui/plugin-list": minor
"@object-ui/app-shell": minor
---

fix(timeline): the timeline binds to the date axis the view actually declares (#3129)

A view whose date axis is bound under `calendar` was **offered** the Timeline
visualization and then bucketed every record into "No date" — while the calendar
rendered the very same field correctly. Two read-sites disagreed about what
counts as a timeline binding:

- `ListView`'s capability gate accepted `options.calendar.startDateField` as a
timeline-resolvable axis; the render branch never read calendar config at all,
so it fell through to its `created_at` last resort.
- `app-shell`'s object page emitted `startDateField: 'due_date'` into
`options.timeline` for **every** object view, declared or not. Downstream that
is indistinguishable from a real binding, and because it is always present it
shadowed the fallback entirely.

`ListView` now resolves the axis once — `resolveTimelineDateBinding`, consumed by
the capability gate and the render branch alike, reading spec key before legacy
alias and `timeline` before `calendar` in both nestings — and the object page
forwards only what the view declared. A declared `timeline.startDateField` still
wins wherever both appear, and a view that declares no date axis anywhere keeps
the historical `created_at` fallback.

Observable rendering change (records move out of "No date" into real date
buckets), hence `minor`.
69 changes: 69 additions & 0 deletions packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#3129 — the object page must not invent a timeline date axis.
*
* This face used to emit `startDateField: 'due_date'` into `options.timeline`
* for EVERY object view, declared or not. Downstream that is indistinguishable
* from a real binding, and because it is always present it shadowed the
* calendar binding `ListView.resolveTimelineDateBinding` falls back to — so a
* calendar-bound view rendered as a timeline bucketed every record into
* "No date" (the reported symptom), while the calendar rendered the same field
* correctly.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore `startDateField: viewDef.timeline?.startDateField || … || 'due_date'`
* in `timelineViewOptions` and the two "does not invent" cases below go RED
* (they read `'due_date'`), while every other case in this file and in
* `ListView.timeline-binding.test.tsx` stays green — the fabricated value is
* only ever observable when the view declared nothing.
*/

import { describe, it, expect } from 'vitest';
import { timelineViewOptions } from './ObjectView';

const objectDef = { name: 'crm_campaign', titleField: 'name' };

describe('timelineViewOptions — the object page forwards, it does not resolve (#3129)', () => {
it('forwards a declared spec binding untouched', () => {
const out = timelineViewOptions(
{ timeline: { startDateField: 'start_date', endDateField: 'end_date', scale: 'month' } },
objectDef,
);
expect(out.startDateField).toBe('start_date');
expect(out.endDateField).toBe('end_date');
// Every spec key survives — the whole config is spread, not whitelisted.
expect(out.scale).toBe('month');
});

it('promotes the legacy `dateField` alias onto the spec key', () => {
expect(timelineViewOptions({ timeline: { dateField: 'start_date' } }, objectDef).startDateField)
.toBe('start_date');
});

it('invents NO date field when the view declares none', () => {
const out = timelineViewOptions({ timeline: { titleField: 'campaign_name' } }, objectDef);
expect(out.startDateField).toBeUndefined();
expect(out.titleField).toBe('campaign_name');
});

it('invents NO date field for a view with no timeline config at all', () => {
// The calendar-bound view from the report: the axis lives under `calendar`,
// and leaving `startDateField` absent here is what lets ListView find it.
const out = timelineViewOptions({ calendar: { startDateField: 'start_date' } }, objectDef);
expect(out.startDateField).toBeUndefined();
// The object's declared title field is the one thing this layer still
// contributes — ListView has no access to objectDef.
expect(out.titleField).toBe('name');
});

it("falls back to 'name' when the object declares no titleField", () => {
expect(timelineViewOptions({}, { name: 'crm_campaign' }).titleField).toBe('name');
});
});
45 changes: 34 additions & 11 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,36 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* persisted into saved view metadata (a saved view must not fossilize a
* posture-dependent column set).
*/
/**
* The `options.timeline` config this page hands to `ListView`.
*
* Deliberately does NOT resolve the date axis. `ListView.resolveTimelineDateBinding`
* is the single read-site that decides which field a timeline buckets by, and it
* reads the calendar binding when the view carries no timeline one. This face used
* to fabricate `startDateField: 'due_date'` for every object view — a field name
* the view never declared and most objects do not have — which both looked like a
* real binding downstream and, because it is always present, shadowed the calendar
* fallback entirely. The result on a calendar-bound view was a Timeline the
* switcher offered and the renderer bucketed wholly into "No date" (objectui#3129).
*
* What stays here is the one thing this layer knows and `ListView` does not: the
* object's declared `titleField`.
*
* Exported for the regression suite.
*/
export function timelineViewOptions(viewDef: any, objectDef: any): Record<string, unknown> {
const declaredStart = viewDef?.timeline?.startDateField || viewDef?.timeline?.dateField;
return {
// Spread the full view-defined timeline config first so the spec fields
// (startDateField/endDateField/groupByField/colorField/scale) survive.
...(viewDef?.timeline || {}),
// Only ever restate a binding the view actually declared.
...(declaredStart ? { startDateField: declaredStart } : {}),
titleField: viewDef?.timeline?.titleField || objectDef?.titleField || 'name',
descriptionField: viewDef?.timeline?.descriptionField,
};
}

export function defaultListColumnsFromObject(
objectDef: any,
limit = 5,
Expand Down Expand Up @@ -1544,17 +1574,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
allDayField: viewDef.calendar?.allDayField,
defaultView: viewDef.calendar?.defaultView,
},
timeline: {
// Spread the full view-defined timeline config first so the spec
// fields (startDateField/endDateField/groupByField/colorField/scale)
// survive; then layer the defaults. (Mirrors the gallery and gantt
// branches — a bare whitelist here was dropping every spec key and
// pinning the axis to the legacy `dateField` fallback.)
...(viewDef.timeline || {}),
startDateField: viewDef.timeline?.startDateField || viewDef.timeline?.dateField || 'due_date',
titleField: viewDef.timeline?.titleField || objectDef.titleField || 'name',
descriptionField: viewDef.timeline?.descriptionField,
},
// The date axis is resolved once, in ListView — this face only
// forwards what the view declared plus the object's title field
// (objectui#3129). See `timelineViewOptions`.
timeline: timelineViewOptions(viewDef, objectDef),
map: {
locationField: viewDef.map?.locationField,
titleField: viewDef.map?.titleField || objectDef.titleField || 'name',
Expand Down
85 changes: 74 additions & 11 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,58 @@ export function resolveAddRecordPlacement(position: unknown): { top: boolean; bo
}
}

/**
* The date axis a timeline view renders on.
*
* ONE resolution, consumed by both read-sites that decide the timeline's fate:
* the capability gate (may this view offer the Timeline visualization?) and the
* timeline render branch (which field does it bucket by?). Those two used to
* carry separate, unequal source lists, and the gate was the wider of the pair —
* it accepted `options.calendar.startDateField` as a timeline-resolvable axis
* while the render branch never read calendar config at all. A view that binds
* its dates under `calendar` therefore got the Timeline option offered and then
* bucketed every record into "No date" (objectui#3129), which is exactly the
* shape the report isolated: the same fields render fine in Calendar and Gantt.
*
* A calendar binding IS a legitimate timeline axis in this product, not a
* lenient fallback bolted on here: the capability gate has always said so, and
* `InterfaceListPage` derives a timeline's default binding from the very same
* `defaultCalendarFromObject` helper it uses for calendars. What was missing is
* that one of the two read-sites never honoured the promise the other made.
*
* Both nestings are read at each level, spec-canonical key first: the
* spec-authored `schema.timeline` / `schema.calendar` and the legacy
* `schema.options.*` twin that app-shell's object pages still emit. `dateField`
* is the pre-#2231 alias for `startDateField`.
*
* Exported for the regression suite, which pins each authoring shape.
*/
export function resolveTimelineDateBinding(schema: any): {
startDateField?: string;
endDateField?: string;
titleField?: string;
} {
const sources = [
schema?.timeline,
schema?.options?.timeline,
schema?.calendar,
schema?.options?.calendar,
];
const pick = (read: (src: any) => unknown): string | undefined => {
for (const src of sources) {
if (!src) continue;
const value = read(src);
if (typeof value === 'string' && value) return value;
}
return undefined;
};
return {
startDateField: pick((s) => s.startDateField ?? s.dateField),
endDateField: pick((s) => s.endDateField),
titleField: pick((s) => s.titleField),
};
}

/**
* Normalize an array of filter conditions, expanding `in`/`not in` operators
* and ensuring consistent AST structure.
Expand Down Expand Up @@ -1362,8 +1414,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
resolvable.push('calendar');
}

// Check for Timeline capabilities (spec config takes precedence)
if (schema.timeline?.startDateField || (schema.timeline as any)?.dateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
// Check for Timeline capabilities — the SAME resolution the render branch
// buckets by, so the switcher can never offer a Timeline the renderer then
// fails to bind (objectui#3129).
if (resolveTimelineDateBinding(schema).startDateField) {
resolvable.push('timeline');
}

Expand Down Expand Up @@ -1637,19 +1691,28 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...(schema.options?.timeline || {}),
...(schema.timeline || {}),
};
// The one resolution the capability gate above also uses (objectui#3129).
const dateBinding = resolveTimelineDateBinding(schema);
// The resolved axis has to appear on the NESTED config too, not just on
// the flat prop: `ObjectTimeline` prefers `timeline.startDateField` over
// `schema.startDateField`, so a timeline config object that exists but
// carries no date key (app-shell emits one for every object view) would
// otherwise mask a binding resolved from elsewhere.
const resolvedTimeline = {
...mergedTimeline,
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
};
return {
type: 'object-timeline',
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(mergedTimeline).length > 0 ? mergedTimeline : undefined,
// Deprecated top-level props for backward compat
// `dateField` is the deprecated alias for `startDateField`. It was read
// from `options.timeline` but not from the spec-canonical
// `schema.timeline`, so the spec nesting + legacy key silently fell
// through to `created_at` (objectui#3129).
startDateField: schema.timeline?.startDateField || (schema.timeline as any)?.dateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
titleField: schema.timeline?.titleField || schema.options?.timeline?.titleField || 'name',
...(schema.timeline?.endDateField ? { endDateField: schema.timeline.endDateField } : {}),
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
...(schema.timeline?.colorField ? { colorField: schema.timeline.colorField } : {}),
...(schema.timeline?.scale ? { scale: schema.timeline.scale } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComponentRegistry } from '@object-ui/core';
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
import { ListView } from '../ListView';
import { ListView, resolveTimelineDateBinding } from '../ListView';
import { SchemaRendererProvider } from '@object-ui/react';

const rows = [
Expand Down Expand Up @@ -122,6 +122,70 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129
expect(viaAlias.schema.startDateField).toBe('start_date');
});

it('binds to the CALENDAR date axis when the view declares no timeline one', async () => {
// The gap the report isolated: "the same start_date / end_date fields render
// correctly in the Calendar and Gantt views". A view whose date axis lives
// under `calendar` is OFFERED the Timeline visualization — the capability
// gate has always accepted `options.calendar.startDateField` as a
// timeline-resolvable axis — but the render branch never read calendar
// config, so it bucketed every record under "No date" while the calendar
// rendered the very same field.
const props = await timelineProps({
...BASE,
options: { calendar: { startDateField: 'start_date', endDateField: 'end_date' } },
});
expect(props.schema.startDateField).toBe('start_date');
expect(props.schema.endDateField).toBe('end_date');
// Also on the NESTED config: ObjectTimeline prefers it over the flat prop.
expect(props.schema.timeline?.startDateField).toBe('start_date');
expect(findCalls[0].$select).toContain('start_date');
});

it('binds to the spec-canonical `calendar` nesting too', async () => {
const props = await timelineProps({ ...BASE, calendar: { startDateField: 'start_date' } });
expect(props.schema.startDateField).toBe('start_date');
expect(props.schema.timeline?.startDateField).toBe('start_date');
});

it('a timeline config with no date key does not shadow the calendar binding', async () => {
// app-shell emits an `options.timeline` object for every object view (it
// carries the object's titleField), so "the config object exists" must not
// be read as "the axis is bound".
const props = await timelineProps({
...BASE,
options: { timeline: { titleField: 'name' }, calendar: { startDateField: 'start_date' } },
});
expect(props.schema.startDateField).toBe('start_date');
expect(props.schema.timeline.startDateField).toBe('start_date');
});

it('the declared timeline axis still WINS over a calendar one', async () => {
const props = await timelineProps({
...BASE,
timeline: { startDateField: 'end_date' },
calendar: { startDateField: 'start_date' },
});
expect(props.schema.startDateField).toBe('end_date');
});

it('keeps the historical fallback when the view declares no date axis at all', async () => {
// The other direction, pinned honestly: nothing is invented from the object,
// and the pre-existing `created_at` last resort is unchanged.
const props = await timelineProps({ ...BASE, timeline: { titleField: 'name' } });
expect(props.schema.startDateField).toBe('created_at');
expect(props.schema.timeline.startDateField).toBeUndefined();
});

it('the capability gate and the render branch read ONE resolution', () => {
// The gate used to be the wider of the two: it accepted a calendar binding
// the renderer could not use. Same function now answers both questions.
expect(resolveTimelineDateBinding({ options: { calendar: { startDateField: 'start_date' } } }))
.toEqual({ startDateField: 'start_date', endDateField: undefined, titleField: undefined });
expect(resolveTimelineDateBinding({ timeline: { dateField: 'a' }, calendar: { startDateField: 'b' } }).startDateField)
.toBe('a');
expect(resolveTimelineDateBinding({}).startDateField).toBeUndefined();
});

it('offers the Timeline visualization for a config using only the alias', async () => {
// The capability gate had the same vocabulary gap, so a grid view carrying
// only the aliased timeline config never offered the Timeline option.
Expand Down
Loading