diff --git a/.changeset/timeline-binds-to-the-calendar-date-axis.md b/.changeset/timeline-binds-to-the-calendar-date-axis.md new file mode 100644 index 0000000000..2a6c1ade7e --- /dev/null +++ b/.changeset/timeline-binds-to-the-calendar-date-axis.md @@ -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`. diff --git a/packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx b/packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx new file mode 100644 index 0000000000..79e010919b --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx @@ -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'); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index ac79fae217..a92cc47302 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -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 { + 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, @@ -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', diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index a7a101ffd3..ebaf7fc70a 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -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. @@ -1362,8 +1414,10 @@ export const ListView = React.forwardRef(({ 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'); } @@ -1637,19 +1691,28 @@ export const ListView = React.forwardRef(({ ...(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 } : {}), diff --git a/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx b/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx index addba378d9..308d8eae68 100644 --- a/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx @@ -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 = [ @@ -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.