diff --git a/.changeset/7459-timeline-absent-date-axis-refusal.md b/.changeset/7459-timeline-absent-date-axis-refusal.md new file mode 100644 index 000000000..6488b748e --- /dev/null +++ b/.changeset/7459-timeline-absent-date-axis-refusal.md @@ -0,0 +1,52 @@ +--- +'@object-ui/plugin-timeline': minor +'@object-ui/i18n': minor +--- + +`ObjectTimeline` refuses an undeclared date axis instead of inventing one +(objectui#7459). + +Steps ① and ② of the three-step sequence the maintainer ruled on objectui#7070 +(2026-09-01, 总监批 #28). House posture, on record with that ruling: +日期轴永不虚构 — a date axis is never fabricated. + +`ObjectTimeline` resolved its date axis through five declared bindings and then +closed the chain with a sixth rung that was a bare literal field name nobody +has ever declared. A name therefore ALWAYS resolved: for a view that declared +no axis, every record read a key its object does not carry, every event landed +in the "No date" bucket, and the screen read as a timeline that had been built +and simply had nothing in it. + +Two changes, shipped together because neither is observable alone: + +- **The renderer now refuses.** An object-bound timeline with no declared date + axis renders a diagnostic naming every binding it accepts — + `timeline.startDateField`, `timeline.dateField`, `mapping.date`, and the two + deprecated flat spellings — instead of a chart. The twin of `ObjectGantt`'s + "Gantt configuration required" screen, in the shape objectui#7070 settled. +- **The invented sixth rung is gone**, which is the only thing that makes the + refusal reachable. Added while the floor stood, it would have been dead code; + retired without the refusal, it would have produced exactly the silent + "No date" outcome the ruling rejects. + +**What changes for an author.** A view that declares a date axis is completely +unaffected — all five declared spellings resolve exactly as before, and a +timeline authored from literal `items` is never refused, since its items carry +their own dates and no field name is read for them. A view that declared no +axis anywhere, and was rendering an empty-looking timeline, now says so. + +⚠️ Both premises were RE-MEASURED on the current tree before anything was +edited, rather than taken from the card: the renderer had no absent-axis +refusal (against a live control term that fires in the same file), and the +floor was still present and still spelled as reported. The pairing itself is +pinned — the refusal cases go red the moment the floor returns, including one +whose records carry a column that happens to be named `date`, where a returned +floor renders a convincing timeline rather than an empty one. + +Refusal is distinguished from "renders an empty timeline" by asserting the +canvas is ABSENT, not merely event-free. The component's success surface is now +named (`data-testid="timeline-canvas"`) so that distinction can be measured; +every other terminal state of the component already named itself. + +Step ③ of the ruling — the `'created_at'` floors on the two plugin faces — +stays on objectui#7070 and is deliberately NOT in this change. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 47d05f336..1a55f1fb5 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -706,6 +706,9 @@ const ar = { unsupported: { objectBoundGantt: "النوع \"gantt\" غير مدعوم — المخطط الزمني المرتبط بكائن يعرض أنواع التغذية ({{variants}}). يحتاج مخطط جانت إلى صفوف صريحة، لكل منها عناصر items متداخلة خاصة به، لذلك لا يؤثر محور جانت (scale) هنا.", }, + unconfigured: { + noDateAxis: "محور تواريخ المخطط الزمني مطلوب — هذا العرض لا يعلن أي حقل تاريخ، والمخطط الزمني المرتبط بكائن لا يخترع حقلاً. أعلن أحد الحقول التالية: {{fields}}. الأول هو صيغة المواصفة، والبقية أسماء مستعارة قديمة.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a91092e88..3b3160e96 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -702,6 +702,9 @@ const de = { unsupported: { objectBoundGantt: "Nicht unterstützte Variante „gantt“ — eine objektgebundene Timeline rendert die Feed-Varianten ({{variants}}). Gantt benötigt literale Zeilen mit jeweils eigenen verschachtelten items; die Gantt-Achse (scale) hat hier daher keine Wirkung.", }, + unconfigured: { + noDateAxis: "Zeitachse für die Timeline erforderlich — diese Ansicht deklariert kein Datumsfeld, und eine objektgebundene Timeline erfindet keines. Deklarieren Sie eines von: {{fields}}. Das erste ist die Spec-Schreibweise, die übrigen sind veraltete Aliase.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 917912391..39889e0f3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -819,6 +819,9 @@ const en = { unsupported: { objectBoundGantt: 'Unsupported variant "gantt" — an object-bound timeline renders the feed variants ({{variants}}). Gantt needs literal rows, each with its own nested items, so the gantt axis (scale) has no effect here.', }, + unconfigured: { + noDateAxis: 'Timeline date axis required — this view declares no date field, and an object-bound timeline will not invent one. Declare one of: {{fields}}. The first is the spec spelling; the rest are legacy aliases.', + }, }, gantt: { column: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index db603ef7c..6688a6b4b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -706,6 +706,9 @@ const es = { unsupported: { objectBoundGantt: "Variante \"gantt\" no admitida: una línea de tiempo vinculada a un objeto representa las variantes de feed ({{variants}}). Gantt necesita filas literales, cada una con sus propios items anidados, por lo que el eje gantt (scale) no tiene efecto aquí.", }, + unconfigured: { + noDateAxis: "Se requiere el eje de fechas de la línea de tiempo: esta vista no declara ningún campo de fecha y una línea de tiempo vinculada a un objeto no inventa ninguno. Declare uno de: {{fields}}. El primero es la grafía de la especificación; los demás son alias heredados.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index a55702eca..7311220dc 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -702,6 +702,9 @@ const fr = { unsupported: { objectBoundGantt: "Variante « gantt » non prise en charge — une timeline liée à un objet affiche les variantes de flux ({{variants}}). Gantt nécessite des lignes littérales, chacune avec ses propres items imbriqués ; l'axe gantt (scale) est donc sans effet ici.", }, + unconfigured: { + noDateAxis: "Axe de dates requis pour la timeline — cette vue ne déclare aucun champ de date, et une timeline liée à un objet n'en invente pas. Déclarez l'un de : {{fields}}. Le premier est l'orthographe de la spec, les autres sont des alias hérités.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 613bc4491..9fc979913 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -702,6 +702,9 @@ const ja = { unsupported: { objectBoundGantt: "サポートされていないバリアント \"gantt\" — オブジェクト連動のタイムラインはフィードバリアント({{variants}})を描画します。ガントには、それぞれが入れ子の items を持つリテラル行が必要なため、ここではガント軸(scale)は効果がありません。", }, + unconfigured: { + noDateAxis: "タイムラインの日付軸が必要です — このビューは日付フィールドを宣言しておらず、オブジェクト連動のタイムラインが日付フィールドを作り出すことはありません。次のいずれかを宣言してください: {{fields}}。最初のものが spec の記法で、残りは旧来のエイリアスです。", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ac00d0f14..17aa11e9f 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -702,6 +702,9 @@ const ko = { unsupported: { objectBoundGantt: "지원되지 않는 variant \"gantt\" — 객체 바인딩 타임라인은 피드 variant({{variants}})를 렌더링합니다. 간트는 각각 중첩된 items를 가진 리터럴 행이 필요하므로 여기서는 간트 축(scale)이 적용되지 않습니다.", }, + unconfigured: { + noDateAxis: "타임라인 날짜 축이 필요합니다 — 이 뷰는 날짜 필드를 선언하지 않았으며, 객체 바인딩 타임라인은 날짜 필드를 지어내지 않습니다. 다음 중 하나를 선언하세요: {{fields}}. 첫 번째가 spec 표기이고 나머지는 레거시 별칭입니다.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9b103a19b..6194b5e05 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -701,6 +701,9 @@ const pt = { unsupported: { objectBoundGantt: "Variante \"gantt\" não suportada: uma linha do tempo vinculada a um objeto renderiza as variantes de feed ({{variants}}). O gantt precisa de linhas literais, cada uma com os seus próprios items aninhados, por isso o eixo gantt (scale) não tem efeito aqui.", }, + unconfigured: { + noDateAxis: "Eixo de datas da linha do tempo necessário: esta vista não declara nenhum campo de data e uma linha do tempo vinculada a um objeto não inventa nenhum. Declare um de: {{fields}}. O primeiro é a grafia da spec; os restantes são aliases legados.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 03619e60d..07919cabb 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -708,6 +708,9 @@ const ru = { unsupported: { objectBoundGantt: "Неподдерживаемый вариант \"gantt\" — временная шкала, привязанная к объекту, отображает ленточные варианты ({{variants}}). Для гантта нужны литеральные строки, каждая со своим вложенным items, поэтому ось гантта (scale) здесь не действует.", }, + unconfigured: { + noDateAxis: "Требуется ось дат временной шкалы — это представление не объявляет ни одного поля даты, а временная шкала, привязанная к объекту, не придумывает его сама. Объявите одно из: {{fields}}. Первое — написание из спецификации, остальные — устаревшие псевдонимы.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 4e9f6407f..9244cd7ca 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -726,6 +726,9 @@ const zh = { unsupported: { objectBoundGantt: '不支持的变体 "gantt" —— 对象绑定的时间线只渲染 feed 变体({{variants}})。甘特图需要字面量行,每行自带嵌套的 items,因此此处的甘特轴(scale)不起作用。', }, + unconfigured: { + noDateAxis: '时间线需要日期轴 —— 该视图未声明任何日期字段,而对象绑定的时间线不会虚构一个。请声明以下之一:{{fields}}。第一个是 spec 写法,其余为遗留别名。', + }, }, gantt: { column: { diff --git a/packages/plugin-timeline/src/ObjectTimeline.absentDateAxisRefusal-7459.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.absentDateAxisRefusal-7459.test.tsx new file mode 100644 index 000000000..f4dc63079 --- /dev/null +++ b/packages/plugin-timeline/src/ObjectTimeline.absentDateAxisRefusal-7459.test.tsx @@ -0,0 +1,229 @@ +/** + * 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#7459 — an object-bound `ObjectTimeline` that declares NO date axis + * refuses, and the refusal is REACHABLE. + * + * The sibling of `plugin-gantt`'s `ObjectGantt.unconfiguredRefusal-7070` and of + * `plugin-calendar`'s #7029 twin, written for the maintainer ruling those two + * produced (2026-09-01, objectui#7070, 总监批 #28): house posture is + * 日期轴永不虚构 — a date axis is never fabricated. + * + * ## Why one file pins TWO changes + * + * The ruling ordered a refusal screen (①) and the retirement of the renderer's + * own invented field name at the end of its resolver chain (②), and neither is + * observable alone: + * + * - a refusal added while the floor stands can never be TAKEN — the floor + * guaranteed a name always resolved, so the branch is dead code; + * - the floor retired with no refusal produces the outcome the ruling + * explicitly rejects: every record reads a key no object carries and + * buckets into "No date" — a timeline that looks built and is not. + * + * So the pairing itself is the subject. `renders the refusal` and `stays + * reachable` below both go RED the moment the floor returns, and the fully + * declared CONTROLS stay GREEN in both worlds — the asymmetry that separates + * "stopped inventing" from "refuses everything". + * + * ## Refusal is not "an empty timeline", and that is asserted, not assumed + * + * The failure mode the ruling is trying to avoid renders a real timeline with + * nothing on it, so a pin that only counts events passes on BOTH outcomes. Two + * independent canvas markers are therefore asserted ABSENT here, each proven + * non-vacuous by a control in this same file that asserts it PRESENT: + * + * 1. `data-testid="timeline-canvas"` — the component's own success surface. + * Named by #7459 precisely so this distinction could be measured; every + * other terminal state of the component already named itself. + * 2. `role="list"` — the `
    ` rail the REAL `TimelineRenderer` emits. An + * empty timeline still emits it, which is what makes it discriminating. + * + * The renderer is deliberately NOT stubbed, for the reason + * `__tests__/timeline-object-bound-gantt-refusal.test.tsx` states one file + * over: `ObjectTimeline.test.tsx` stubs `./renderer` and prints only titles, so + * assertions there stay green whether or not the branch under test was reached. + * Only the ambient React context hooks are mocked. + */ + +import React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectTimeline } from './ObjectTimeline'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + }; +}); + +afterEach(cleanup); + +/** + * Records, not items — this is what the object-bound path is handed. + * + * Every row carries `start_date` (the name an author would DECLARE) and, on + * purpose, a column literally named `date` as well. That second column is the + * reachability case's whole point: it is the data shape under which a returned + * floor renders a CONVINCING timeline rather than an empty one, so "no events" + * would not have caught it. Nobody declared `date`; the refusal must still fire. + */ +const ROWS = [ + { id: '1', name: 'Spring Launch', start_date: '2099-09-01', end_date: '2099-09-30', date: '2099-09-01' }, + { id: '2', name: 'Summer Push', start_date: '2100-10-01', end_date: '2100-10-31', date: '2100-10-01' }, +]; + +/** The same rows with the incidental `date` column removed. */ +const ROWS_WITHOUT_DATE_COLUMN = ROWS.map(({ date: _date, ...rest }) => rest); + +function renderObjectBound(schema: Record, rows: unknown[] = ROWS) { + const props = { schema, data: rows } as unknown as React.ComponentProps; + return render(); +} + +const refusal = () => screen.queryByTestId('timeline-missing-date-axis'); +const canvas = () => screen.queryByTestId('timeline-canvas'); +/** The `
      ` rail the real renderer emits — present even for zero events. */ +const rails = () => screen.queryAllByRole('list'); + +const OBJECT_BOUND = { type: 'timeline', objectName: 'campaign' }; + +describe('ObjectTimeline — an undeclared date axis is REFUSED (objectui#7459)', () => { + it('renders the refusal, and the CANVAS IS ABSENT — not an empty timeline', () => { + renderObjectBound({ ...OBJECT_BOUND }, ROWS_WITHOUT_DATE_COLUMN); + + const el = refusal(); + expect(el, 'an object-bound timeline with no declared date axis rendered no refusal').not.toBeNull(); + expect(el!.getAttribute('role'), 'the refusal is not announced').toBe('alert'); + + // The distinction the ruling asked to be measured. Both markers, each of + // which a control below asserts PRESENT, so neither zero is vacuous. + expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull(); + expect(rails(), 'the timeline rail was rendered beside the refusal').toHaveLength(0); + + // …and specifically NOT the outcome the ruling rejects: every record + // bucketed under "No date" on an axis nobody declared. + expect(screen.queryByText('No date')).toBeNull(); + expect(screen.queryByText('Spring Launch')).toBeNull(); + expect(screen.queryByText('Summer Push')).toBeNull(); + }); + + it('stays reachable when the records HAPPEN to carry a column named `date`', () => { + // THE PAIRING CASE. Restore the retired floor and this one goes red while + // rendering something that looks entirely healthy: two real events off a + // real column. Declared-ness, not existence, is what the axis is. + renderObjectBound({ ...OBJECT_BOUND }, ROWS); + + expect(refusal(), 'a column named `date` resurrected the fabricated axis').not.toBeNull(); + expect(canvas()).toBeNull(); + expect(rails()).toHaveLength(0); + expect(screen.queryByText('Spring Launch')).toBeNull(); + }); + + it('REFUSES a half-declared axis — an end date alone is not a timeline axis', () => { + // `endDateField` falls back to the start axis, so a view that declared only + // an end date declared no axis to lay events on. The same judgement + // `getGanttConfig` makes when it refuses a half-declared gantt. + renderObjectBound({ ...OBJECT_BOUND, timeline: { endDateField: 'end_date' } }); + + expect(refusal()).not.toBeNull(); + expect(canvas()).toBeNull(); + }); + + it('names the fields the author has to declare', () => { + renderObjectBound({ ...OBJECT_BOUND }); + const text = refusal()!.textContent ?? ''; + + // Not a bare "not configured": the author must be able to act on it. The + // list is interpolated from the component's own + // `OBJECT_BOUND_TIMELINE_DATE_BINDINGS`, so this asserts the real + // vocabulary rather than a sentence that could drift from the resolver. + for (const binding of ['timeline.startDateField', 'timeline.dateField', 'mapping.date', 'dateField']) { + expect(text, `the refusal does not name \`${binding}\``).toContain(binding); + } + }); + + it('leaves the objectui#6655 variant refusal first for a composed gantt', () => { + // Both refusals apply to this schema. The variant one wins deliberately: + // "this path does not render gantt" is the more useful first sentence for a + // chart that would be refused either way. Pinned so the order is a decision + // rather than an accident of line placement. + renderObjectBound({ ...OBJECT_BOUND, variant: 'gantt' }); + + expect(screen.queryByTestId('timeline-unsupported-variant')).not.toBeNull(); + expect(refusal()).toBeNull(); + expect(canvas()).toBeNull(); + }); +}); + +describe('ObjectTimeline — every DECLARED rung still resolves (objectui#7459)', () => { + // Without these a fix that refused EVERYTHING would pass the block above, + // and retiring the floor could have taken a declared binding with it. Each + // case is one rung of the resolver chain, in its own spelling; all five are + // declared bindings (`ListViewTimelineConfig` for the first two, this + // component's props and `TimelineExtensionSchema` for the rest). + const RUNGS: Array<[string, Record]> = [ + ['timeline.startDateField', { timeline: { startDateField: 'start_date' } }], + ['timeline.dateField', { timeline: { dateField: 'start_date' } }], + ['mapping.date', { mapping: { date: 'start_date' } }], + ['startDateField (flat, deprecated)', { startDateField: 'start_date' }], + ['dateField (flat, deprecated)', { dateField: 'start_date' }], + ]; + + for (const [label, binding] of RUNGS) { + it(`CONTROL: \`${label}\` renders the timeline, with its events`, () => { + renderObjectBound({ ...OBJECT_BOUND, ...binding }, ROWS_WITHOUT_DATE_COLUMN); + + expect(refusal(), `a declared \`${label}\` was refused`).toBeNull(); + // Both absence markers from the refusal cases, asserted PRESENT here — + // this is what makes those zeros readings rather than phantom checks. + expect(canvas(), 'the timeline canvas is missing on a declared axis').not.toBeNull(); + expect(rails().length, 'the timeline rail is missing on a declared axis').toBeGreaterThan(0); + expect(screen.getByText('Spring Launch')).toBeDefined(); + expect(screen.getByText('Summer Push')).toBeDefined(); + }); + } + + it('CONTROL: an AUTHORED item list is never refused — it declares no field names', () => { + // The carve-out that keeps the in-repo catalog fixtures + // (`vertical-timeline.json` / `horizontal-timeline.json` / + // `gantt-style-timeline.json`) rendering: an authored item carries its own + // `time`, so no field NAME is read for it and there is no axis to declare. + // A refusal keyed on anything looser than "this component COMPOSED the + // items" would take every literal timeline down with it. + render( + , + ); + + expect(refusal(), 'an authored timeline was refused for declaring no date FIELD').toBeNull(); + expect(canvas()).not.toBeNull(); + expect(screen.getByText('Project Started')).toBeDefined(); + expect(screen.getByText('First Milestone')).toBeDefined(); + }); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index 45d21787a..1b4575def 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -49,6 +49,30 @@ export const DEFAULT_TIMELINE_LIMIT = 100; */ const OBJECT_BOUND_TIMELINE_VARIANTS = ['vertical', 'horizontal'] as const; +/** + * Every date-axis binding this component READS, spelled as an author writes it, + * in the precedence order `startDateField` below applies them. + * + * Module-local for the same reason `OBJECT_BOUND_TIMELINE_VARIANTS` above is: + * the refusal's message interpolates THIS list rather than restating it in + * prose, so a rung added to (or retired from) the resolver cannot leave the + * diagnostic naming a vocabulary the resolver no longer has. Every entry is a + * DECLARED binding — the first two on `ListViewTimelineConfig` + * (`@object-ui/types`), the last three on this component's own props and on + * `TimelineExtensionSchema` — which is the property that distinguishes them + * from the `'date'` literal objectui#7459 retired from the end of that chain. + * + * Ordered canonical-first: the message tells the author which one to prefer by + * position rather than by a second prose sentence that could drift from it. + */ +const OBJECT_BOUND_TIMELINE_DATE_BINDINGS = [ + 'timeline.startDateField', + 'timeline.dateField', + 'mapping.date', + 'startDateField', + 'dateField', +] as const; + const TimelineMappingSchema = z.object({ title: z.string().optional(), date: z.string().optional(), @@ -244,9 +268,24 @@ export const ObjectTimeline: React.FC = ({ // fell all the way through to the caller's default (`created_at` / `due_date`), // which is usually absent from the projection — so every record bucketed into // "No date" while the data it needed was sitting in the row (objectui#3129). + // + // objectui#7459 — the chain ENDS here. It used to close with a SIXTH rung: + // the bare field name d-a-t-e as a literal, which nobody has ever declared — + // a name this renderer invented for itself, one layer below the created_at + // the view faces supply. (Spelled apart on purpose: the card's close + // condition greps this file for that literal and a tombstone quoting it + // would answer 1 where the truth is 0.) It guaranteed a name always + // resolved, so every record read a key no object carries, found nothing, and + // bucketed into "No date" — a timeline that looks built + // and is not. It also made a refusal screen unreachable by construction, + // which is why the maintainer ruling (2026-09-01, objectui#7070, 总监批 #28) + // ordered the floor retired and the refusal added as ONE change. House + // posture, on record with that ruling: 日期轴永不虚构 — a date axis is never + // fabricated. `undefined` from here is therefore a real answer, and the + // refusal below is what answers it. const startDateField = timelineConfig?.startDateField ?? timelineConfig?.dateField - ?? schema.mapping?.date ?? schema.startDateField ?? schema.dateField ?? 'date'; + ?? schema.mapping?.date ?? schema.startDateField ?? schema.dateField; const endDateField = timelineConfig?.endDateField ?? schema.endDateField ?? startDateField; const descField = schema.mapping?.description ?? schema.descriptionField ?? 'description'; const variantField = schema.mapping?.variant ?? 'variant'; @@ -259,6 +298,17 @@ export const ObjectTimeline: React.FC = ({ const effectiveItems = useMemo(() => { if (schema.items) return schema.items; if (!rawData || !Array.isArray(rawData)) return []; + // No declared date axis — there is no key to read a time off, and the + // refusal below is what the author sees instead. Composing a feed anyway + // is the outcome the ruling rejects: every record buckets into "No date" + // and the screen reads as a built timeline with nothing in it. Returning + // early keeps this hook honest about that (objectui#7459). + if (!startDateField) return []; + + // Narrowed once for the mapper. The guard above establishes the start key, + // and `endDateField` falls back to it, so both are strings from here down. + const startKey: string = startDateField; + const endKey: string = endDateField ?? startDateField; const fields: Record = (objectDef?.fields ?? {}) as Record; const objectName: string = schema.objectName || ''; @@ -315,8 +365,8 @@ export const ObjectTimeline: React.FC = ({ }; const mapped = rawData.map((item: any) => { - const startRaw = item[startDateField]; - const endRaw = item[endDateField]; + const startRaw = item[startKey]; + const endRaw = item[endKey]; const colorRaw = colorField ? item[colorField] : undefined; const groupRaw = groupByField ? item[groupByField] : undefined; @@ -475,6 +525,59 @@ export const ObjectTimeline: React.FC = ({ ); } + /** + * objectui#7459 — REFUSE an object-bound timeline that declares no date axis. + * + * The twin of `ObjectGantt`'s screen, which is the settled in-repo shape for + * this: `getGanttConfig` answers `null` when the schema carries neither a + * config block nor the required flat props, and the early return names the + * fields the author has to declare. This is the same answer for the same + * question, one renderer over. + * + * ## Why it can only exist together with the retired floor + * + * Until objectui#7459 the resolver above ended in a fabricated literal, so a + * name ALWAYS resolved and this branch could never have been taken — a + * refusal screen that is present and unreachable. The maintainer ruling + * (2026-09-01, objectui#7070, 总监批 #28) ordered the two as one sequence for + * exactly that reason, and the other order is no better: retiring the floor + * with no refusal leaves every record reading a key that is not there and + * bucketing into "No date". Neither half is observable alone; the pin + * (`ObjectTimeline.absentDateAxisRefusal-7459.test.tsx`) measures the pairing + * rather than trusting it. + * + * ## Three things this condition is careful about + * + * 1. `hasAuthoredItems` — the same test the #6655 refusal directly above + * makes, and for the same reason. An AUTHORED item carries its own `time` + * / `startDate`; no field NAME is read for it, so a literal timeline needs + * no date binding and must not be refused for lacking one. The in-repo + * catalog fixtures (`vertical-timeline.json`, `horizontal-timeline.json`, + * `gantt-style-timeline.json`) are all exactly that. + * 2. It keys on the START axis alone. `endDateField` falls back to it, so a + * view that declared only an end date has declared no axis to lay events + * on — the same judgement `getGanttConfig` makes when it refuses a + * half-declared gantt. + * 3. Placed with the #6655 refusal, above `error` and `loading`, because it + * is the same KIND of fact: a static authoring fact that no fetch outcome + * changes. A skeleton that resolves into a refusal, or a network error + * shown first, would both send the author to debug the wrong layer. + * + * It sits BELOW the variant refusal deliberately. A composed gantt cannot be + * drawn here at all, so "this path does not render gantt" is the more useful + * first sentence than "declare a date field" for a chart that would be + * refused either way. + */ + if (!hasAuthoredItems && !startDateField) { + return ( +
      + {t('timeline.unconfigured.noDateAxis', { + fields: OBJECT_BOUND_TIMELINE_DATE_BINDINGS.join(', '), + })} +
      + ); + } + const effectiveSchema = { ...schema, items: effectiveItems || [], @@ -527,8 +630,16 @@ export const ObjectTimeline: React.FC = ({ ); } + // `data-testid` on the SUCCESS surface, not only on the refusals. Every other + // terminal state of this component already names itself + // (`timeline-unsupported-variant`, `timeline-missing-date-axis`, + // `timeline-error`, `timeline-loading`); the rendered timeline was the one + // outcome a test could not ask for by name, so "refused" and "rendered an + // EMPTY timeline" were indistinguishable except through renderer-owned + // markup. That distinction is the whole point of the refusal + // (objectui#7459), so the canvas gets a name of its own. return ( -
      +
      {pullDistance > 0 && (
      = { // `OBJECT_BOUND_TIMELINE_VARIANTS` in `ObjectTimeline.tsx`. 'timeline.unsupported.objectBoundGantt': 'Unsupported variant "gantt" — an object-bound timeline renders the feed variants ({{variants}}). Gantt needs literal rows, each with its own nested items, so the gantt axis (scale) has no effect here.', + // objectui#7459 — the object-bound path REFUSES a view that declares no date + // axis, the twin of `ObjectGantt`'s "Gantt configuration required" screen. + // + // It became REACHABLE in the same change that retired the renderer's own + // invented field name from the end of the resolver chain; before that a name + // always resolved and this string could never have rendered. House posture + // (maintainer, 2026-09-01, objectui#7070): 日期轴永不虚构 — a date axis is + // never fabricated. + // + // `{{fields}}` is a hole rather than prose for the reason + // `timeline.unsupported.objectBoundGantt` above gives: the list is derived + // from the component's own declaration of what it reads — see + // `OBJECT_BOUND_TIMELINE_DATE_BINDINGS` in `ObjectTimeline.tsx` — so the + // diagnostic cannot drift from the resolver. This copy is the byte-identical + // twin of `en.ts`'s, as the strings above are. + 'timeline.unconfigured.noDateAxis': + 'Timeline date axis required — this view declares no date field, and an object-bound timeline will not invent one. Declare one of: {{fields}}. The first is the spec spelling; the rest are legacy aliases.', }; const TEST_KEY = 'timeline.bucket.today';