From 31911e090a932b1e4118aad428501c5d308f51e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:05:41 +0000 Subject: [PATCH 1/2] fix(plugin-list,plugin-view): retire the 'created_at' timeline date-axis floors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7070 step ③ of the maintainer ruling of 2026-09-01 (总监批 #28): house posture 日期轴永不虚构 — a date axis is never fabricated. `ListView` and `plugin-view`'s `ObjectView` both floored `startDateField` at 'created_at' for a timeline view that declared no axis. `ObjectTimeline` reads that flat prop at the tail of its resolver chain, so the faces answered "the axis is bound" for every view and the refusal screen step ① installed (objectui#7459, landed as 20cb8db9b) was unreachable from either route — which is why ①② changed nothing a user could see. Both branches now forward a declared axis or no key at all. The floor was never a harmless default: the $select projection is collected from the DECLARED timeline / options.timeline blocks and never from this prop, so an undeclared view rendered a timeline bound to a column the query had not requested and bucketed every record into "No date". The ruling also explicitly replaces the written decision that stood on the deleted ListView line, and asks the objectui#3129 note at the app-shell face to be realigned to the new reality once ③ lands; both are done here. `titleField` keeps its 'name' floor at both faces — it is not a date axis. Tests: the two face-level pins measure the prop; a new console-level test composes the real ListView / ObjectView with the REAL ObjectTimeline and asserts the refusal on screen, since neither package can see the join. Part of #7070 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .../7070-timeline-date-axis-floors-retired.md | 43 ++++ .../timelineAxisRefusalReach-7070.test.tsx | 204 ++++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 22 +- packages/plugin-list/src/ListView.tsx | 30 ++- .../ListView.timeline-binding.test.tsx | 168 ++++++++++++++- packages/plugin-view/src/ObjectView.tsx | 26 ++- .../ObjectView.timelineBinding-7070.test.tsx | 159 ++++++++++++++ 7 files changed, 629 insertions(+), 23 deletions(-) create mode 100644 .changeset/7070-timeline-date-axis-floors-retired.md create mode 100644 apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx create mode 100644 packages/plugin-view/src/__tests__/ObjectView.timelineBinding-7070.test.tsx diff --git a/.changeset/7070-timeline-date-axis-floors-retired.md b/.changeset/7070-timeline-date-axis-floors-retired.md new file mode 100644 index 0000000000..c38bd4a691 --- /dev/null +++ b/.changeset/7070-timeline-date-axis-floors-retired.md @@ -0,0 +1,43 @@ +--- +'@object-ui/plugin-list': minor +'@object-ui/plugin-view': minor +--- + +Retire the `'created_at'` timeline date-axis floors at both plugin faces +(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28). + +**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no +longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand +`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward +a declared axis or no key at all, and the renderer shows its "declare a date axis" +refusal instead. + +House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never +fabricated. This is the third and last step of a sequence the ruling ordered and forbade +reordering: `ObjectTimeline` gained the refusal screen and lost its own internal +`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a +user could see — precisely because these two faces still supplied a name. They are the +supply. + +The floor was not a harmless default. `'created_at'` is a column nearly every object +carries, so downstream it was indistinguishable from a real binding and could never +resolve to nothing — while the `$select` projection is collected from the **declared** +`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was +therefore given a timeline bound to a column the query had not requested, and every +record bucketed into "No date": a screen that looks built, is wrong, and gives the +author no signal. The ruling also explicitly replaced the written decision that stood on +the deleted `ListView` line ("`created_at` stays the last resort for a view that +declares no date axis anywhere") — it was a second, de-facto contract held at one face, +on the very literal objectui#3129 had retired at the app-shell face. + +**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical), +`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129 +established that a calendar binding is a legitimate timeline axis, and it still is. All +three keep rendering exactly as before; only the *undeclared* case changes. A view that +really did want records laid out by creation time says so in one key: +`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on +screen, so an affected view reports its own fix. + +`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date +axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out +for separate evaluation. diff --git a/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx b/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx new file mode 100644 index 0000000000..d1e36769f5 --- /dev/null +++ b/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx @@ -0,0 +1,204 @@ +/** + * 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES. + * + * The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three + * packages, and no one of them can observe the whole: + * + * ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it + * at `'date'` — pinned in `plugin-timeline`, which never sees a face; + * ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned + * in `plugin-list` / `plugin-view`, which stub the renderer and so can + * only measure the PROP, never the screen. + * + * ①② landed first and, by their own measurement, changed nothing a user could + * see — precisely because the faces still filled the flat `startDateField` rung + * that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So + * "the refusal exists" and "the face stopped inventing" were both true and still + * did not add up to a refusal on screen. This file is the join, and the console + * is where it can be made: it is the only package that depends on all three. + * + * ⭐ The rows carry a real `created_at` column, deliberately. That is the data + * shape under which a restored floor renders a CONVINCING timeline — two real + * events off a real column — rather than an empty one, so a pin that only + * counted events would pass in both worlds. The same trick, for the same + * reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`. + * + * ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because + * the failure this most resembles is a component that threw: "no timeline" is + * satisfied by a crash. Every block opens with a render proof, and every + * absence asserted here is asserted PRESENT by a control in the same run. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore either face's `|| 'created_at'` and that face's refusal case goes RED + * — and it goes red rendering a healthy two-event timeline, not an error. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '@object-ui/plugin-list'; +import { ObjectView } from '@object-ui/plugin-view'; +import { ObjectTimeline } from '@object-ui/plugin-timeline'; + +// The REAL renderer, registered under the type both faces emit. Stubbing it is +// what every face-level test does and exactly what this file exists not to do. +ComponentRegistry.register('object-timeline', ObjectTimeline as never, { + namespace: 'test', + label: 'Object Timeline (real)', + category: 'view', +}); + +const ROWS = [ + { id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' }, + { id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' }, +]; + +const objectDef = { + name: 'crm_campaign', + label: 'Campaign', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Name' }, + start_date: { name: 'start_date', type: 'date', label: 'Start Date' }, + created_at: { name: 'created_at', type: 'datetime', label: 'Created At' }, + }, +}; + +const makeDataSource = () => + ({ + find: vi.fn(async () => ROWS), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + }) as any; + +const refusal = () => screen.queryByTestId('timeline-missing-date-axis'); +const canvas = () => screen.queryByTestId('timeline-canvas'); + +/** Mount `ListView` on a timeline view, with the real renderer downstream. */ +async function mountListView(schema: Record) { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); +} + +/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */ +async function mountObjectView(view: Record) { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); +} + +const LIST_BASE = { + type: 'list-view', + objectName: 'crm_campaign', + viewType: 'timeline', + columns: ['name'], +} as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => { + it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => { + // First, and load-bearing. It proves three things the negative case below + // silently assumes: the real component is what the registry resolves, it + // mounts through this face without throwing, and `timeline-canvas` is a + // marker this harness can actually observe. Without it, "no canvas" is + // equally well explained by a crash. + await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } }); + await waitFor(() => expect(canvas()).not.toBeNull()); + expect(refusal()).toBeNull(); + expect(screen.getByText('Spring Launch')).toBeDefined(); + }); + + it('a view that declares NO date axis now refuses, on screen', async () => { + // ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at` + // — a column these rows really do carry — so it looked built and was not. + await mountListView({ ...LIST_BASE }); + await waitFor(() => expect(refusal()).not.toBeNull()); + + expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert'); + // Not an EMPTY timeline: the outcome the ruling rejects would still emit the + // canvas. Asserted present by the render proof above, in this same run. + expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull(); + // …and specifically not the convincing-but-wrong chart the floor produced. + expect(screen.queryByText('Spring Launch')).toBeNull(); + expect(screen.queryByText('Summer Push')).toBeNull(); + }); + + it('the refusal names the keys the author has to declare', async () => { + // A refusal the author cannot act on is a different defect. The list is + // interpolated from the component's own binding vocabulary. + await mountListView({ ...LIST_BASE }); + await waitFor(() => expect(refusal()).not.toBeNull()); + expect(refusal()!.textContent ?? '').toContain('timeline.startDateField'); + }); + + it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => { + // The alias is resolved by the face, not by a floor. If step ③ had taken it + // with the fabrication, a pre-#2231 view would start refusing — a regression + // the ruling did not order. + await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } }); + await waitFor(() => expect(canvas()).not.toBeNull()); + expect(refusal()).toBeNull(); + }); + + it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => { + // objectui#3129: a calendar binding is a legitimate timeline axis in this + // product. This is the shape most at risk from a fix aimed at "declared + // timeline config only". + await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } }); + await waitFor(() => expect(canvas()).not.toBeNull()); + expect(refusal()).toBeNull(); + }); +}); + +describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => { + it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => { + await mountObjectView({ timeline: { startDateField: 'start_date' } }); + await waitFor(() => expect(canvas()).not.toBeNull()); + expect(refusal()).toBeNull(); + }); + + it('a view that declares NO date axis now refuses, on screen', async () => { + // The route `generateViewSchema` owns — the authored `object-view` element, + // which never passes through `ListView`. Fixing one face and not the other + // is how this defect survived objectui#3129 for so long. + await mountObjectView({}); + await waitFor(() => expect(refusal()).not.toBeNull()); + expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull(); + expect(screen.queryByText('Spring Launch')).toBeNull(); + }); + + it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => { + await mountObjectView({ timeline: { dateField: 'start_date' } }); + await waitFor(() => expect(canvas()).not.toBeNull()); + expect(refusal()).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 0b107eab4a..a5f432a67b 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any { * `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'` * floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s * own "Gantt configuration required" screen reachable from this route. - * - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the - * TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and - * `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'` - * — the very literal objectui#3129 retired HERE. `ListView` carries it as a - * stated decision ("`created_at` stays the last resort for a view that - * declares no date axis anywhere"), so the two faces hold contradictory - * DOCUMENTED postures on one literal. objectui#7070 routes that to a single - * ruling instead of settling it per-face. Until it is answered: this note - * describes the timeline axis at THIS face only, and says nothing about the - * other two. + * - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx` + * and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at + * `'created_at'`, the very literal objectui#3129 retired HERE, and + * `ListView` carried it as a stated DECISION ("`created_at` stays the last + * resort for a view that declares no date axis anywhere") — two faces + * holding documented and OPPOSITE postures on one field name. That is what + * objectui#7070 routed to a single ruling instead of settling per-face, and + * the ruling (2026-09-01, 总监批 #28) answered it as house posture: + * 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both + * floors, so all three faces now forward a declared axis or none, and + * `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable + * from every one of them. This note no longer describes only THIS face. * * Exported for the regression suite. */ diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 39617b03d4..96f6c3a084 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef(({ ...baseProps, // Nested timeline config (spec-compliant, used by ObjectTimeline) 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', + // Deprecated top-level props for backward compat. + // + // objectui#7070 step ③ — house posture, entered on the maintainer's + // ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is + // never fabricated. The two lines of prose that used to sit here + // ("`created_at` stays the last resort for a view that declares no + // date axis anywhere") were not an oversight, they stated a decision — + // and that decision is what the ruling explicitly replaced. It was a + // second, de-facto contract held at ONE face, on the very literal + // objectui#3129 retired at the app-shell face, so the product held two + // documented and opposite postures on one field name. + // + // `ObjectTimeline` reads this FLAT prop at the tail of its resolver + // chain, so a floor here answered "the axis is bound" for every view + // and made its refusal screen (objectui#7459, step ① of the same + // ruling) unreachable from this route. Worse, the axis it invented was + // never FETCHED: the `$select` projection is collected from the + // DECLARED `timeline` / `options.timeline` blocks above, never from + // this prop — so an undeclared view rendered a timeline bound to a + // column the query had not requested and bucketed every record into + // "No date". Absent is now absent, and the renderer says which keys to + // declare instead. + // + // ⛔ `titleField` is NOT a date axis and keeps its floor — the same + // display-name rung the gallery and gantt branches carry here, and + // `timelineViewOptions` carries at app-shell. + ...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}), titleField: dateBinding.titleField || 'name', ...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}), ...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}), 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 308d8eae68..bde8ee7f26 100644 --- a/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx @@ -101,9 +101,11 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 it('forwards the nested LEGACY alias `timeline.dateField`', async () => { // The gap this pins: `dateField` was resolved out of `options.timeline` but // NOT out of the spec-canonical `schema.timeline`, so the flat prop fell - // through to `created_at` — a field the projection does not request, which - // is why the timeline rendered every record under "No date" even though the - // rows carried the configured date all along. + // through to the `'created_at'` floor of the day — a field the projection + // does not request, which is why the timeline rendered every record under + // "No date" even though the rows carried the configured date all along. + // (That floor is gone as of objectui#7070 step ③; an unresolved alias now + // reaches the renderer's refusal instead of a wrong axis.) const props = await timelineProps({ ...BASE, timeline: { dateField: 'start_date' } }); expect(props.schema.startDateField).toBe('start_date'); expect(findCalls[0].$select).toContain('start_date'); @@ -168,11 +170,15 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 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. + it('invents NO axis when the view declares no date axis at all', async () => { + // This case used to assert `startDateField` was `'created_at'`, under the + // title "keeps the historical fallback". That floor is RETIRED by + // objectui#7070 step ③ (maintainer ruling 2026-09-01, 总监批 #28) — the + // dedicated block at the foot of this file carries the reasoning. Kept here + // and inverted rather than deleted: this is the objectui#3129 case that + // measured the floor, so it is where the retirement has to become visible. const props = await timelineProps({ ...BASE, timeline: { titleField: 'name' } }); - expect(props.schema.startDateField).toBe('created_at'); + expect(props.schema.startDateField).toBeUndefined(); expect(props.schema.timeline.startDateField).toBeUndefined(); }); @@ -206,3 +212,151 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 expect(option).toBeTruthy(); }); }); + +/** + * objectui#7070 step ③ — this face stops flooring the timeline axis at + * `'created_at'`. + * + * Maintainer ruling 2026-09-01 (总监批 #28, objectui#7070): house posture + * 日期轴永不虚构 — a date axis is never fabricated. The ruling sequenced three + * steps and forbade reordering them: + * + * ① `ObjectTimeline` gains a refusal screen for an absent date axis; + * ② the renderer's own `|| 'date'` floor is retired; + * ③ — THIS — the two plugin faces stop supplying `'created_at'`. + * + * ①② landed as `20cb8db9b` (PR #7467) and changed nothing a user could see, + * precisely because this floor still stood: `ObjectTimeline` reads the FLAT + * `schema.startDateField` at the tail of its resolver chain, so this branch + * answered "the axis is bound" for every view and the screen ① installed was + * unreachable from here. The ruling also names the two lines of prose that used + * to sit on the deleted line ("`created_at` stays the last resort for a view + * that declares no date axis anywhere") as a written decision it EXPLICITLY + * replaces — a second, de-facto contract held at one face only, on the very + * literal objectui#3129 retired at app-shell. + * + * ⭐ What the floor actually produced, measured rather than assumed: the axis it + * invented was never even FETCHED. The `$select` projection is collected from + * the DECLARED `schema.timeline` / `schema.options.timeline` blocks (see + * `collectViewFields`), never from this flat prop — so an undeclared view got a + * timeline bound to a column the query did not request and bucketed every record + * into "No date". That is the outcome the refusal replaces. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore `|| 'created_at'` on the single line step ③ deletes and the three + * "invents NO axis" cases (one of them above, in the objectui#3129 block) go + * RED, while every CONTROL here stays GREEN in both worlds. + */ +describe('ListView timeline branch — the date-axis floor is retired (objectui#7070 step ③)', () => { + beforeEach(() => { + captured = []; + findCalls = []; + }); + + it('RENDER PROOF: the timeline branch is reached and forwards a declared axis', async () => { + // First, and deliberately. Every "is undefined" below reads a key off the + // props the spy captured, and an absent key is indistinguishable from a + // branch that never ran — a render failure would satisfy the negative cases + // for the wrong reason. This row fails loudly instead. + const props = await timelineProps({ ...BASE, timeline: { startDateField: 'start_date' } }); + expect(props.schema.type).toBe('object-timeline'); + expect(props.schema.startDateField).toBe('start_date'); + }); + + it('invents NO axis for a timeline view that declares nothing at all', async () => { + // THE DEFECT, at this face. `'created_at'` is a name the view never wrote — + // and one nearly every object DOES carry, which is what made it read as a + // real binding to everything downstream and made it never resolve to + // nothing. Absence is the only route to the renderer's refusal. + const props = await timelineProps({ ...BASE }); + expect(props.schema.startDateField).toBeUndefined(); + }); + + it('invents no axis for the empty `options.timeline` bag the object page emits', async () => { + // app-shell's `timelineViewOptions` emits a bag carrying only the title for + // a view that declared no axis. "The config object exists" is not "the axis + // is bound" — the same distinction the objectui#3129 block above pins for + // the NESTED config, asked here of the flat prop. + const props = await timelineProps({ ...BASE, options: { timeline: { titleField: 'name' } } }); + expect(props.schema.startDateField).toBeUndefined(); + }); + + it('CONTROL: `titleField` is NOT a date axis and keeps its `name` floor', async () => { + // ⛔ Scope, made visible. The ruling retires fabricated DATE AXES. `'name'` + // is the display-name rung every sibling branch on this face carries, and + // the one `timelineViewOptions` carries at app-shell. A later card retiring + // it declares so here. + const props = await timelineProps({ ...BASE }); + expect(props.schema.titleField).toBe('name'); + }); + + it('CONTROL: a declared axis still reaches BOTH the flat prop and the nested config', async () => { + // The half that must not change. `ObjectTimeline` prefers the nested key, so + // a fix that only emptied the flat prop would look right here and still + // break a correctly authored view. + const props = await timelineProps({ + ...BASE, + options: { calendar: { startDateField: 'start_date', endDateField: 'end_date' } }, + }); + expect(props.schema.startDateField).toBe('start_date'); + expect(props.schema.timeline.startDateField).toBe('start_date'); + expect(props.schema.endDateField).toBe('end_date'); + expect(findCalls[0].$select).toContain('start_date'); + }); +}); + +/** + * The ADR-0047 capability gate, asked in both directions. + * + * ⚠️ Read this block for what it is: a pair of CONTROLS, not a claim about + * step ③. The gate reads `resolveTimelineDateBinding`, which never consulted + * the flat floor, so both cases hold identically before and after the deletion. + * That is the finding worth recording — unlike the gantt face (objectui#7070 + * flavour 1), where the object page's fabricated `options.gantt.startDateField` + * kept the toggle live for every view in the product, the Timeline toggle was + * ALREADY correct here. So the deletion changes what a view RENDERS, and + * changes nothing about what the switcher OFFERS. + */ +describe('ListView capability gate — unchanged by step ③, in both directions (objectui#7070)', () => { + const GRID = { ...BASE, viewType: 'grid' } as const; + + beforeEach(() => { + captured = []; + findCalls = []; + }); + + /** + * The switcher has two forms — an inline segmented control (role="tab") and a + * collapsed dropdown (plain buttons behind a trigger) — so the trigger has to + * be opened before querying and both roles accepted. Querying without opening + * returns null in BOTH worlds, which would make the negative case pass while + * measuring nothing. + */ + const mountSwitcher = async (schema: Record) => { + const dataSource = makeDataSource() as any; + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + const trigger = screen.queryByTestId('view-switcher-dropdown'); + if (trigger) fireEvent.click(trigger); + }; + + const queryViewOption = (name: string) => + screen.queryByRole('tab', { name }) ?? screen.queryByRole('button', { name }); + + it('CONTROL: offers Timeline to a view that declared an axis', async () => { + // The positive control comes FIRST: it proves this harness can see the + // option at all, so the negative case below is a measurement rather than a + // query that never had anything to find. + await mountSwitcher({ ...GRID, timeline: { startDateField: 'start_date' } }); + await waitFor(() => expect(queryViewOption('Timeline')).toBeInTheDocument()); + }); + + it('CONTROL: does NOT offer Timeline to a view that declared no axis', async () => { + await mountSwitcher({ ...GRID, appearance: { allowedVisualizations: ['grid', 'timeline'] } }); + expect(queryViewOption('Timeline')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index eea2e7fa36..6dc421d05f 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1346,15 +1346,35 @@ export const ObjectView: React.FC = ({ titleField: viewOptions.gallery?.titleField || 'name', ...(viewOptions.gallery || {}), }; - case 'timeline': + case 'timeline': { + // objectui#7070 step ③: the SECOND route to `ObjectTimeline`, fixed the + // same way objectui#7029 fixed the calendar branch above. + // `generateViewSchema` runs precisely when no host supplied + // `renderListView` — the authored `object-view` element — so it never + // passes through `ListView`, and the deletion made there does not reach + // it. Left alone it would keep flooring the axis at `'created_at'` for a + // view that declared none, which is what makes the renderer's own + // refusal screen unreachable (it decides by asking whether a start-date + // binding is PRESENT). House posture, ruled 2026-09-01 (总监批 #28): + // 日期轴永不虚构 — a date axis is never fabricated. + // + // ⛔ `titleField` keeps its `'name'` floor: not a date axis, and the same + // display-name rung the gallery and kanban branches carry here. + // + // `startDateField` is the spec key; `dateField` is the legacy alias, and + // this flat prop is the only place on this face that translates one into + // the other — the trailing `...viewOptions.timeline` spread does not, so + // the alias has to be resolved before it, not folded into the spread. + const timelineStartDateField = + viewOptions.timeline?.startDateField || viewOptions.timeline?.dateField; return { type: 'object-timeline', ...baseProps, - // `startDateField` is the spec key; `dateField` is the legacy alias. - startDateField: viewOptions.timeline?.startDateField || viewOptions.timeline?.dateField || 'created_at', + ...(timelineStartDateField ? { startDateField: timelineStartDateField } : {}), titleField: viewOptions.timeline?.titleField || 'name', ...(viewOptions.timeline || {}), }; + } case 'gantt': // objectui#7070: only ever restate a binding the view actually DECLARED // — the same correction objectui#7029 made to the calendar branch above. diff --git a/packages/plugin-view/src/__tests__/ObjectView.timelineBinding-7070.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.timelineBinding-7070.test.tsx new file mode 100644 index 0000000000..3ebef08191 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.timelineBinding-7070.test.tsx @@ -0,0 +1,159 @@ +/** + * 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#7070 step ③ — `generateViewSchema`'s TIMELINE branch stops flooring + * the date axis at `'created_at'`. + * + * Maintainer ruling 2026-09-01 (总监批 #28, objectui#7070): house posture + * 日期轴永不虚构 — a date axis is never fabricated. The ruling sequenced three + * steps and forbade reordering them, because the floors and the refusal are + * only meaningful together: + * + * ① `ObjectTimeline` gains a refusal screen for an absent date axis; + * ② the renderer's own `|| 'date'` floor is retired; + * ③ — THIS — the two plugin faces stop supplying `'created_at'`. + * + * ①② landed as `20cb8db9b` (PR #7467). With them in and this floor still + * standing, nothing in the product refuses: this branch answers "the axis is + * bound" for every view, so the screen ① installed is unreachable from here. + * `ObjectTimeline` reads this FLAT prop (`schema.startDateField`) at the tail of + * its own resolver chain, which is exactly why a floor here is load-bearing. + * + * The sibling of `ObjectView.ganttBinding-7070` and `ObjectView.calendarBinding + * -7029` next door — the same one-rung fabrication, the same face, the third + * and last of its date axes to be retired. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore `|| 'created_at'` on the single line this step deletes and the two + * "invents NO binding" cases go RED (the recorded schema carries the fabricated + * name) while every CONTROL below stays GREEN in both worlds. That asymmetry is + * the whole claim: this branch stopped INVENTING, it did not stop FORWARDING. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +/** Every schema the view hands to SchemaRenderer, in order. */ +const rendered: any[] = []; + +vi.mock('@object-ui/react', async (importOriginal) => { + const React = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', () => ({ ObjectGrid: () =>
})); +vi.mock('@object-ui/plugin-form', () => ({ ObjectForm: () =>
})); + +async function renderTimelineView(view: Record) { + rendered.length = 0; + const ds: any = { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'crm_campaign', fields: {} }), + }; + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; +} + +describe('ObjectView.generateViewSchema — timeline restates only a DECLARED axis (objectui#7070 step ③)', () => { + it('RENDER PROOF: this harness reaches the timeline branch at all', async () => { + // First, and deliberately: every "is undefined" below reads a key off the + // recorded schema, and an absent key is indistinguishable from a branch that + // never ran. This row is what makes the rest measurements — it fails loudly + // if the mount, the mock or the `views` shape stops producing an + // `object-timeline` schema, instead of letting the file pass vacuously. + const schema = await renderTimelineView({ timeline: { startDateField: 'start_date' } }); + expect(schema.type).toBe('object-timeline'); + expect(schema.startDateField).toBe('start_date'); + }); + + it('invents NO date axis for a timeline view that declares no config', async () => { + // THE DEFECT. `startDateField` used to read `'created_at'` here — a name + // this view never wrote, and one most objects DO carry, so downstream it is + // indistinguishable from a real binding and it never resolves to nothing. + // Absence is the only route to `ObjectTimeline`'s refusal screen. + const schema = await renderTimelineView({}); + expect(schema.type).toBe('object-timeline'); + expect(schema.startDateField).toBeUndefined(); + }); + + it('invents no date axis for an EMPTY timeline block', async () => { + // ⚠️ `timeline` sits at the VIEW's top level, not under `options`: + // `viewOptions` is `currentNamedViewConfig?.options || activeView`, and a raw + // `views` entry takes the `activeView` leg. The sibling gantt file records + // measuring that the hard way — written as `{ options: { timeline } }` this + // case AND the controls below all read `undefined`, and the controls are + // what exposes it. + const schema = await renderTimelineView({ timeline: {} }); + expect(schema.startDateField).toBeUndefined(); + }); + + it('a timeline block with no date key does not become a binding', async () => { + // The bag app-shell's object page emits for a view that declared nothing: + // `timelineViewOptions` floors the TITLE at `'name'` and emits no axis. "The + // config object exists" must not be read as "the axis is bound". + const schema = await renderTimelineView({ timeline: { titleField: 'name' } }); + expect(schema.startDateField).toBeUndefined(); + expect(schema.titleField).toBe('name'); + }); + + it('CONTROL: forwards the spec-canonical `timeline.startDateField`', async () => { + const schema = await renderTimelineView({ + timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'subject' }, + }); + expect(schema.startDateField).toBe('start_date'); + expect(schema.endDateField).toBe('end_date'); + expect(schema.titleField).toBe('subject'); + }); + + it('CONTROL: still resolves the LEGACY `timeline.dateField` alias', async () => { + // The alias is the half a conditional spread is easiest to drop by accident: + // the flat prop is the ONLY place `dateField` was ever translated into the + // spec key on this face, and the trailing `...viewOptions.timeline` spread + // does not do it. A view authored pre-#2231 must keep rendering. + const schema = await renderTimelineView({ timeline: { dateField: 'start_date' } }); + expect(schema.startDateField).toBe('start_date'); + }); + + it('CONTROL: the spec key still WINS over the legacy alias', async () => { + const schema = await renderTimelineView({ + timeline: { startDateField: 'start_date', dateField: 'end_date' }, + }); + expect(schema.startDateField).toBe('start_date'); + }); + + it('CONTROL: `titleField` is NOT a date axis and keeps its floor', async () => { + // ⛔ Scope, made visible. The ruling retires FABRICATED DATE AXES; `'name'` + // is the display-name rung every sibling branch on this face carries + // (gallery, kanban, gantt) and `timelineViewOptions` carries at app-shell. + // If a later card retires it, this is where that card declares it did. + const schema = await renderTimelineView({}); + expect(schema.titleField).toBe('name'); + }); +}); From 64d8fbe2aedc710893402add6b9185fd952c0f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:28:48 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(console):=20drop=20the=20unused=20React?= =?UTF-8?q?=20import=20from=20the=20step-=E2=91=A2=20composition=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsc --noEmit` in apps/console reads it as TS6133; the automatic JSX runtime makes the import unnecessary. Caught by the package's own type-check, not by vitest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .../console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx b/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx index d1e36769f5..73d1fc2fd3 100644 --- a/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx +++ b/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx @@ -41,7 +41,6 @@ * — and it goes red rendering a healthy two-event timeline, not an error. */ -import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core';