Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/6655-object-bound-gantt-refuse-loudly.md
Original file line numberDiff line numberDiff line change
@@ -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.
19 changes: 12 additions & 7 deletions packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(“)', () => {
Expand All@@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -678,6 +678,9 @@ const ar = {
gantt: {
rowLabel: "العناصر",
},
unsupported: {
objectBoundGantt: "النوع \"gantt\" غير مدعوم — المخطط الزمني المرتبط بكائن يعرض أنواع التغذية ({{variants}}). يحتاج مخطط جانت إلى صفوف صريحة، لكل منها عناصر items متداخلة خاصة به، لذلك لا يؤثر محور جانت (scale) هنا.",
},
},
gantt: {
viewMode: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -674,6 +674,9 @@ const ja = {
gantt: {
rowLabel: "項目",
},
unsupported: {
objectBoundGantt: "サポートされていないバリアント \"gantt\" — オブジェクト連動のタイムラインはフィードバリアント({{variants}})を描画します。ガントには、それぞれが入れ子の items を持つリテラル行が必要なため、ここではガント軸(scale)は効果がありません。",
},
},
gantt: {
viewMode: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -674,6 +674,9 @@ const ko = {
gantt: {
rowLabel: "항목",
},
unsupported: {
objectBoundGantt: "지원되지 않는 variant \"gantt\" — 객체 바인딩 타임라인은 피드 variant({{variants}})를 렌더링합니다. 간트는 각각 중첩된 items를 가진 리터럴 행이 필요하므로 여기서는 간트 축(scale)이 적용되지 않습니다.",
},
},
gantt: {
viewMode: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -680,6 +680,9 @@ const ru = {
gantt: {
rowLabel: "Элементы",
},
unsupported: {
objectBoundGantt: "Неподдерживаемый вариант \"gantt\" — временная шкала, привязанная к объекту, отображает ленточные варианты ({{variants}}). Для гантта нужны литеральные строки, каждая со своим вложенным items, поэтому ось гантта (scale) здесь не действует.",
},
},
gantt: {
viewMode: {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -698,6 +698,9 @@ const zh = {
gantt: {
rowLabel: '条目',
},
unsupported: {
objectBoundGantt: '不支持的变体 "gantt" —— 对象绑定的时间线只渲染 feed 变体({{variants}})。甘特图需要字面量行,每行自带嵌套的 items,因此此处的甘特轴(scale)不起作用。',
},
},
gantt: {
column: {
Expand Down
79 changes: 79 additions & 0 deletions packages/plugin-timeline/src/ObjectTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(),
Expand DownExpand Up@@ -397,6 +419,63 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
// 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 (
<div className="p-4 text-destructive" data-testid="timeline-unsupported-variant" role="alert">
{t('timeline.unsupported.objectBoundGantt', {
variants: OBJECT_BOUND_TIMELINE_VARIANTS.join(', '),
})}
</div>
);
}

const effectiveSchema = {
...schema,
items: effectiveItems || [],
Expand Down
Loading
Loading