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..73d1fc2fd3 --- /dev/null +++ b/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx @@ -0,0 +1,203 @@ +/** + * 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 { 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'); + }); +});