diff --git a/.changeset/6655-object-bound-gantt-refuse-loudly.md b/.changeset/6655-object-bound-gantt-refuse-loudly.md new file mode 100644 index 0000000000..39fd94e8b6 --- /dev/null +++ b/.changeset/6655-object-bound-gantt-refuse-loudly.md @@ -0,0 +1,32 @@ +--- +'@object-ui/plugin-timeline': patch +--- + +An object-bound timeline with `variant: 'gantt'` refuses loudly instead of +throwing (objectui#6655). + +The two timeline item shapes are not interchangeable, and this path crossed +them. `ObjectTimeline` maps each record to a flat FEED item — one per record, +no nested `items` — while the renderer's gantt branch reads a gantt ROW +(`row.items[].startDate`). Every `row.items` was therefore `undefined`, +`calculateDateRange` reduced an empty list, `Math.min()` over it was `Infinity`, +and `new Date(Infinity).toISOString()` threw `RangeError: Invalid time value` +mid-render. There was no guard and no diagnostic — the component simply threw. + +Per the maintainer ruling of 2026-08-29, the object-bound path now rejects +`variant: 'gantt'` with an author-facing diagnostic naming the limitation +(object-bound timelines render the feed variants; gantt needs literal rows, each +carrying its own nested items). Composing real gantt rows from records was +considered and NOT adopted; that capability stays open and unruled. + +The refusal keys on whether the items were AUTHORED, not on the variant alone, +so a literal gantt is untouched — including the bare `timeline` key that this +component answers, which is what the in-repo catalog fixture +`plugin-timeline/gantt-style-timeline.json` uses. The feed variants +(`vertical` / `horizontal`) and the presentational `TimelineRenderer` are +unchanged. + +Side effect the ruling asked for: the gantt-only axis this path composes +(`timeline.scale ?? scale`) is no longer silently inert on the gantt variant — +an author who set it is now told why it has no effect, rather than getting a +crash. diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 874cfdabec..1430dea422 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -264,8 +264,12 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 54 once objectui#6301 added `packagedAutomation.cloneCreated`, which names // the flow a clone just produced — „{{name}}“, an interpolated span like // `flowRunner.completed` above, so the pairing is again asserted around a - // hole rather than around literal prose. - expect(okSpans, 'correctly paired spans').toBe(54); + // hole rather than around literal prose, + // 55 once objectui#6655 added `timeline.unsupported.objectBoundGantt`, the + // object-bound timeline's refusal of `variant: gantt`, which names the + // refused variant — „gantt“, a literal span, because the quoted thing is an + // authoring value the author typed rather than data the runtime filled in. + expect(okSpans, 'correctly paired spans').toBe(55); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -284,11 +288,12 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // identity below is asserted as arithmetic rather than as `close === open`. // 53 / 53 / 0 after objectui#5232 added // `console.objectView.viewConfigPermissionDenied`; 54 / 54 / 0 after - // objectui#6301 added `packagedAutomation.cloneCreated`. `rdq` staying at 0 - // is the load-bearing half: each new value added a MATCHED „…“ pair, not a - // stray closer that would have made `close === open` true for the wrong - // reason. - expect({ open, close, rdq }).toEqual({ open: 54, close: 54, rdq: 0 }); + // objectui#6301 added `packagedAutomation.cloneCreated`; 55 / 55 / 0 after + // objectui#6655 added `timeline.unsupported.objectBoundGantt`. `rdq` staying + // at 0 is the load-bearing half: each new value added a MATCHED „…“ pair, + // not a stray closer that would have made `close === open` true for the + // wrong reason. + expect({ open, close, rdq }).toEqual({ open: 55, close: 55, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 48a65b241d..b878c65402 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -678,6 +678,9 @@ const ar = { gantt: { rowLabel: "العناصر", }, + unsupported: { + objectBoundGantt: "النوع \"gantt\" غير مدعوم — المخطط الزمني المرتبط بكائن يعرض أنواع التغذية ({{variants}}). يحتاج مخطط جانت إلى صفوف صريحة، لكل منها عناصر items متداخلة خاصة به، لذلك لا يؤثر محور جانت (scale) هنا.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d1ca3ba9d8..3494a684d7 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -674,6 +674,9 @@ const de = { gantt: { rowLabel: "Elemente", }, + 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.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7607a8dd39..b29d23dad4 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -760,6 +760,9 @@ const en = { gantt: { rowLabel: 'Items', }, + 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.', + }, }, gantt: { column: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 88a5b3c037..becec29480 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -678,6 +678,9 @@ const es = { gantt: { rowLabel: "Elementos", }, + 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í.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index fa8552472e..e9925d7085 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -674,6 +674,9 @@ const fr = { gantt: { rowLabel: "Éléments", }, + 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.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 9873d3aabc..7c3a0fbc5c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -674,6 +674,9 @@ const ja = { gantt: { rowLabel: "項目", }, + unsupported: { + objectBoundGantt: "サポートされていないバリアント \"gantt\" — オブジェクト連動のタイムラインはフィードバリアント({{variants}})を描画します。ガントには、それぞれが入れ子の items を持つリテラル行が必要なため、ここではガント軸(scale)は効果がありません。", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 181bc1806d..f5010dc7c4 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -674,6 +674,9 @@ const ko = { gantt: { rowLabel: "항목", }, + unsupported: { + objectBoundGantt: "지원되지 않는 variant \"gantt\" — 객체 바인딩 타임라인은 피드 variant({{variants}})를 렌더링합니다. 간트는 각각 중첩된 items를 가진 리터럴 행이 필요하므로 여기서는 간트 축(scale)이 적용되지 않습니다.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 97b7a3a1ce..3b5cf8d975 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -673,6 +673,9 @@ const pt = { gantt: { rowLabel: "Itens", }, + 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.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index e53a40fc5e..f2b22cf951 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -680,6 +680,9 @@ const ru = { gantt: { rowLabel: "Элементы", }, + unsupported: { + objectBoundGantt: "Неподдерживаемый вариант \"gantt\" — временная шкала, привязанная к объекту, отображает ленточные варианты ({{variants}}). Для гантта нужны литеральные строки, каждая со своим вложенным items, поэтому ось гантта (scale) здесь не действует.", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 5ec6b91199..58516a94f5 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -698,6 +698,9 @@ const zh = { gantt: { rowLabel: '条目', }, + unsupported: { + objectBoundGantt: '不支持的变体 "gantt" —— 对象绑定的时间线只渲染 feed 变体({{variants}})。甘特图需要字面量行,每行自带嵌套的 items,因此此处的甘特轴(scale)不起作用。', + }, }, gantt: { column: { diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index d0604c86b3..1b67bd7e1d 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -27,6 +27,28 @@ import { useTimelineTranslation } from './useTimelineTranslation'; */ export const DEFAULT_TIMELINE_LIMIT = 100; +/** + * The variants an OBJECT-BOUND timeline can render. + * + * `TimelineSchema.variant` is `vertical | horizontal | gantt`. These two are its + * FEED half — sequential event rails, one entry per record, which is exactly the + * shape this component composes below (`{ title, time, startDate, endDate, … }`, + * flat, no nested `items`). + * + * `gantt` is deliberately absent. That branch of the renderer reads the OTHER + * item shape — a ROW owning a nested `items` array — which this component has + * never produced. Composing real gantt rows from records was considered and NOT + * adopted (maintainer ruling, 2026-08-29, objectui#6655); the capability stays + * open and unruled. Until it exists, the object-bound path refuses gantt. + * + * Module-local on purpose: the refusal's message interpolates THIS list rather + * than restating it in prose, and nothing outside this file needs it. (It is + * also not a new public export — an exported array trips + * `react-refresh/only-export-components`, whose `allowConstantExport` covers + * primitives like `DEFAULT_TIMELINE_LIMIT` above but not an array literal.) + */ +const OBJECT_BOUND_TIMELINE_VARIANTS = ['vertical', 'horizontal'] as const; + const TimelineMappingSchema = z.object({ title: z.string().optional(), date: z.string().optional(), @@ -397,6 +419,63 @@ export const ObjectTimeline: React.FC = ({ // Resolve scale: spec timeline.scale takes priority over flat schema.scale const resolvedScale = timelineConfig?.scale ?? schema.scale; + /** + * Whether `items` were AUTHORED rather than composed from records. + * + * This is the same test `effectiveItems` makes at its first line + * (`if (schema.items) return schema.items;`): with `items` set, this component + * is a pass-through and the author owns the item shape; without it, every item + * below was mapped from a record into the flat feed shape. The two must not + * drift — see the refusal directly below, which keys on it. + */ + const hasAuthoredItems = !!schema.items; + + /** + * objectui#6655 — refuse `variant: 'gantt'` on the COMPOSED path. + * + * The renderer's gantt branch reads gantt ROWS (`row.items[].startDate`); + * every item this component composes is a flat feed item with no nested + * `items`. `calculateDateRange` therefore reduced an empty list, `Math.min()` + * over it yielded `Infinity`, and `new Date(Infinity).toISOString()` threw + * `RangeError: Invalid time value` mid-render. The maintainer ruling + * (2026-08-29) adopted refusing loudly over composing rows from records, so + * the author gets a diagnostic naming the limitation instead of a crash. + * + * ## Three things this condition is careful about + * + * 1. `hasAuthoredItems` — a LITERAL gantt is legitimate and untouched. This + * component also answers the bare `timeline` key (`view:timeline` in + * `./index`; the presentational registration in `./renderer` carries + * `skipFallback` so the bare key lands here), and the in-repo catalog + * fixture `plugin-timeline/gantt-style-timeline.json` is exactly that: + * `variant: 'gantt'` with authored rows. Refusing on `variant` alone would + * take it, and every other authored gantt, down with it. + * 2. `=== 'gantt'`, never "not a feed variant" — an absent `variant` means the + * renderer's `vertical` default, not an unsupported one. + * 3. Placed above the `error` and `loading` returns on purpose. This is a + * STATIC authoring fact: it does not depend on the fetch, and no fetch + * outcome changes it. Showing a transient network error first would send + * the author to debug the wrong layer, and a skeleton would resolve into a + * chart that cannot exist. + * + * It also settles the ruling's second clause. `resolvedScale` above is a + * gantt-only axis that this path composes unconditionally; on the gantt + * variant it used to be configuration for a render that crashed. The author + * who set it is now told why it has no effect, so it is no longer silently + * inert here. The composition itself is unchanged for the feed variants, + * where objectui#6355's pin (`ObjectTimeline.scaleComposition.test.tsx`) + * requires it to keep happening. + */ + if (!hasAuthoredItems && schema.variant === 'gantt') { + return ( +
+ {t('timeline.unsupported.objectBoundGantt', { + variants: OBJECT_BOUND_TIMELINE_VARIANTS.join(', '), + })} +
+ ); + } + const effectiveSchema = { ...schema, items: effectiveItems || [], diff --git a/packages/plugin-timeline/src/__tests__/timeline-object-bound-gantt-refusal.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-object-bound-gantt-refusal.test.tsx new file mode 100644 index 0000000000..470142a80b --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-object-bound-gantt-refusal.test.tsx @@ -0,0 +1,251 @@ +/** + * 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#6655 — an object-bound timeline REFUSES `variant: 'gantt'` with a + * diagnostic instead of throwing. + * + * ## The defect this pins + * + * The two timeline item shapes are not interchangeable. `ObjectTimeline` maps + * each record to a flat FEED item (`{ title, time, startDate, endDate, … }`), + * one per record and with no nested `items`. `calculateDateRange`, on the + * renderer's gantt branch, reads the GANTT ROW shape instead + * (`row.items[].startDate`). Against composed feed items every `row.items` is + * `undefined`, so `allDates` is empty, `Math.min()` over it is `Infinity`, and + * `new Date(Infinity).toISOString()` throws `RangeError: Invalid time value` + * during render. + * + * ## What was ruled, and what is therefore NOT here + * + * The 2026-08-29 maintainer ruling adopted "refuse loudly": the object-bound + * path rejects gantt with a real diagnostic naming the limitation. Composing + * real gantt rows from records (grouping by `groupByField`) was presented and + * explicitly NOT taken — that capability stays unruled. So nothing below + * asserts that an object-bound gantt ever renders a chart; the pin is that it + * says why it cannot. + * + * ## Why the refusal is not simply `variant === 'gantt'` + * + * `ObjectTimeline` is also the renderer behind the BARE `timeline` key + * (`view:timeline` in `../index`; `../renderer`'s presentational registration + * carries `skipFallback` precisely so the bare key lands here). The in-repo + * catalog fixture `plugin-timeline/gantt-style-timeline.json` is exactly that: + * `type: 'timeline'`, `variant: 'gantt'`, literal gantt ROWS. Those pass + * straight through `effectiveItems` untouched and must keep rendering. The + * refusal therefore keys on whether this component COMPOSED the items from + * records, which is the same `schema.items` test `effectiveItems` itself makes. + * An over-broad refusal would take the fixture down with it, which is what the + * literal-rows cases below exist to catch. + * + * ## Why the real renderer, not a stub + * + * The evidence is markup the real `TimelineRenderer` emits (or, before the fix, + * the throw it raises). `ObjectTimeline.test.tsx` stubs `./renderer` and prints + * only `item.title`, so every assertion there stays green whether or not the + * gantt branch was ever reached — the same trap `timeline-date-binding.test.tsx` + * documents for date bucketing. Nothing is mocked here except the ambient React + * context hooks. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ObjectTimeline } from '../ObjectTimeline'; +import { TimelineRenderer } from '../renderer'; + +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, + }), + useObjectLabel: () => ({ + fieldOptionLabel: (_o: string, _f: string, _v: string, fb: string) => fb, + translateOptions: (_o: string, _f: string, opts: unknown[]) => opts, + fieldLabel: (_o: string, _f: string, fb: string) => fb, + }), + }; +}); + +/** Records, not items — this is what the object-bound path is handed. */ +const rows = [ + { id: '1', name: 'Spring Launch', start_date: '2099-09-01', end_date: '2099-09-30' }, + { id: '2', name: 'Summer Push', start_date: '2100-10-01', end_date: '2100-10-31' }, +]; + +/** + * The object-bound schema: no `items`, so `ObjectTimeline` composes feed items + * from `rows`. `data` is an undeclared passthrough prop (read off the rest + * args, the way `ListView` feeds it), so it is applied untyped. + */ +const OBJECT_BOUND = { + type: 'timeline', + objectName: 'campaign', + timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }, +}; + +/** Literal gantt ROWS — the shape `calculateDateRange` actually reads. Trimmed + * from the in-repo catalog fixture `gantt-style-timeline.json`. */ +const GANTT_ROWS = [ + { + label: 'Backend Development', + items: [ + { title: 'API Design', startDate: '2024-01-01', endDate: '2024-01-31', variant: 'success' }, + { title: 'Implementation', startDate: '2024-02-01', endDate: '2024-03-31', variant: 'info' }, + ], + }, + { + label: 'Frontend Development', + items: [ + { title: 'UI Design', startDate: '2024-01-15', endDate: '2024-02-15', variant: 'warning' }, + ], + }, +]; + +function renderObjectBound(schema: Record) { + const props = { schema, data: rows } as unknown as React.ComponentProps; + return render(); +} + +const diagnostic = () => screen.queryByTestId('timeline-unsupported-variant'); + +describe('pin 1 — object-bound + `variant: gantt` refuses loudly (objectui#6655)', () => { + it('renders the diagnostic instead of throwing `RangeError: Invalid time value`', () => { + // The whole defect in one assertion: before the refusal this call throws + // out of `calculateDateRange`. + expect(() => renderObjectBound({ ...OBJECT_BOUND, variant: 'gantt' })).not.toThrow(); + + const el = diagnostic(); + expect(el, 'the object-bound gantt path renders no diagnostic').not.toBeNull(); + expect(el!.getAttribute('role'), 'the diagnostic is not announced').toBe('alert'); + }); + + it('names the limitation the ruling requires it to name', () => { + renderObjectBound({ ...OBJECT_BOUND, variant: 'gantt' }); + const text = diagnostic()!.textContent ?? ''; + + // Not a bare "something went wrong": the author has to be able to act on + // it. The refused value, the variants that DO work here, and the reason + // gantt does not. + expect(text).toContain('gantt'); + expect(text).toContain('vertical'); + expect(text).toContain('horizontal'); + expect(text.toLowerCase()).toContain('literal rows'); + }); + + it('renders no gantt chrome at all — the crash site is never reached', () => { + renderObjectBound({ ...OBJECT_BOUND, variant: 'gantt' }); + + // A rendered gantt would carry the row-label header from + // `timeline.gantt.rowLabel` ("Items") and a bar per record. + expect(screen.queryByText('Items')).toBeNull(); + expect(screen.queryByText('Spring Launch')).toBeNull(); + }); +}); + +describe('pin 1b — the composed `scale` is no longer silently inert on this path', () => { + it('an authored gantt axis now produces the diagnostic, not an ignored option', () => { + // `ObjectTimeline` resolves `timeline.scale ?? schema.scale` and composes it + // into the schema it hands the renderer (objectui#6355). That axis is a + // GANTT-only concern, and on the object-bound path gantt was unreachable — + // the axis was configuration for a variant that crashed. The author now + // gets told why instead of getting nothing. + renderObjectBound({ + ...OBJECT_BOUND, + variant: 'gantt', + timeline: { ...OBJECT_BOUND.timeline, scale: 'year' }, + }); + + expect(diagnostic(), 'no diagnostic for an object-bound gantt carrying an axis').not.toBeNull(); + // The `year` axis these records would have produced. Its absence is the + // measurement: no axis was drawn, and the author was told, rather than the + // option being consumed by a path that cannot use it. + expect(screen.queryByText('2099')).toBeNull(); + expect(screen.queryByText('2100')).toBeNull(); + }); +}); + +describe('pin 2 — object-bound feed variants are unchanged (objectui#6655)', () => { + it('`vertical` still renders its records and its bucket header', () => { + renderObjectBound({ ...OBJECT_BOUND, variant: 'vertical' }); + + expect(screen.getByText('Spring Launch')).toBeDefined(); + expect(screen.getByText('Summer Push')).toBeDefined(); + // The bucket header the vertical renderer emits — proof the real renderer + // ran, not just that some text survived. + expect(screen.getByText('Later')).toBeDefined(); + expect(diagnostic(), 'the refusal leaked onto a feed variant').toBeNull(); + }); + + it('`horizontal` still renders its records', () => { + renderObjectBound({ ...OBJECT_BOUND, variant: 'horizontal' }); + + expect(screen.getByText('Spring Launch')).toBeDefined(); + expect(screen.getByText('Summer Push')).toBeDefined(); + expect(diagnostic(), 'the refusal leaked onto a feed variant').toBeNull(); + }); + + it('the default variant (no `variant` key at all) still renders', () => { + // `TimelineRenderer` defaults to `vertical`; a refusal keyed on anything + // looser than an explicit `gantt` would take this with it. + renderObjectBound({ ...OBJECT_BOUND }); + + expect(screen.getByText('Spring Launch')).toBeDefined(); + expect(diagnostic()).toBeNull(); + }); +}); + +describe('pin 3 — literal gantt rows are unchanged (objectui#6655)', () => { + it('`ObjectTimeline` passes authored gantt rows straight through and renders them', () => { + // The in-repo catalog fixture's shape, through the component that owns the + // bare `timeline` key. This is the case an over-broad refusal breaks. + render( + , + ); + + expect(diagnostic(), 'the refusal caught an authored gantt').toBeNull(); + expect(screen.getByText('Backend Development')).toBeDefined(); + expect(screen.getByText('Frontend Development')).toBeDefined(); + expect(screen.getByText('API Design')).toBeDefined(); + expect(screen.getByText('UI Design')).toBeDefined(); + }); + + it('an authored `rowLabel` still reaches the gantt header', () => { + render( + , + ); + + expect(diagnostic()).toBeNull(); + expect(screen.getByText('Projects')).toBeDefined(); + }); + + it('the presentational `TimelineRenderer` is untouched by this card', () => { + // The literal-rows path as the renderer sees it directly — no + // `ObjectTimeline` in the way. The crash site (`calculateDateRange`) still + // does exactly what it did. + render(); + + expect(diagnostic()).toBeNull(); + expect(screen.getByText('Backend Development')).toBeDefined(); + expect(screen.getByText('API Design')).toBeDefined(); + }); +}); diff --git a/packages/plugin-timeline/src/useTimelineTranslation.ts b/packages/plugin-timeline/src/useTimelineTranslation.ts index dae3484f97..a34cae3b9c 100644 --- a/packages/plugin-timeline/src/useTimelineTranslation.ts +++ b/packages/plugin-timeline/src/useTimelineTranslation.ts @@ -37,6 +37,20 @@ export const TIMELINE_DEFAULT_TRANSLATIONS: Record = { 'timeline.scale.week': 'Week {{n}}', 'timeline.scale.quarter': 'Q{{quarter}} {{year}}', 'timeline.gantt.rowLabel': 'Items', + // objectui#6655 — the object-bound path REFUSES `variant: 'gantt'`. + // + // It composes one flat FEED item per record; the renderer's gantt branch + // reads gantt ROWS (`row.items[].startDate`), so `calculateDateRange` used to + // reduce an empty list and throw `RangeError: Invalid time value` mid-render. + // The maintainer ruling (2026-08-29) chose to refuse loudly rather than to + // compose rows from records, so this string IS the feature on that path and + // has to name the limitation, not just report a failure. + // + // `{{variants}}` is a hole rather than prose because the list is derived from + // the component's own declaration of what it can render — see + // `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.', }; const TEST_KEY = 'timeline.bucket.today';