diff --git a/.changeset/6750-gantt-empty-items-guard.md b/.changeset/6750-gantt-empty-items-guard.md new file mode 100644 index 000000000..15025fe92 --- /dev/null +++ b/.changeset/6750-gantt-empty-items-guard.md @@ -0,0 +1,47 @@ +--- +'@object-ui/plugin-timeline': patch +--- + +A gantt timeline with an EMPTY literal `items` array renders a zero-row grid instead of +throwing (objectui#6750). + +`calculateDateRange` reduced the empty list with no guard: `allDates` is `[]`, `Math.min()` +over no arguments is `Infinity`, and `new Date(Infinity).toISOString()` throws `RangeError: +Invalid time value` during render. Both entry points crashed identically — `TimelineRenderer` +given `{ variant: 'gantt', items: [] }`, and `ObjectTimeline` given the same schema (an +authored empty array is truthy, so it passes straight through as authored items). + +An empty gantt is the **ordinary empty state of a valid schema**, not a malformed document. +Any author or generator that builds `items` from a collection emits `items: []` the moment +the collection is empty — a filtered project list with no matches, a fresh workspace, a plan +whose rows are yet to be added. + +The fix covers the whole gantt branch in one pass rather than the one `throw`, because +patching only the crash site moves it two stops down the same branch: + +- `calculateDateRange` returns a one-day sentinel range anchored on today when the rows carry + no dates at all. The span is one day — the smallest coherent range — because how much time + an empty gantt should show is a question about what an empty gantt should look like, which + this change deliberately does not answer. +- `generateTimeScaleHeaders` needed no change, and that is a measured verdict rather than an + assumption: a degenerate `min === max` range is not inverted, so the loop runs once and + every scale emits exactly one bucket. The empty gantt therefore gets a real one-column axis, + not a header row with zero cells. +- `calculateBarDimensions` gains a `totalDuration === 0` guard. A zero-width axis — every task + starting and ending on the same day, or an author pinning `minDate === maxDate` — divided + `0 / 0` into `NaN`, and the bar was handed `left: NaN%; width: NaN%`. That is not a crash + and not a visible error: the CSSOM rejects both declarations, so React left the element with + no `style` attribute at all and the bar rendered unpositioned and zero-width. On a zero-width + axis every task covers the whole of it by definition, so the guard returns `{ start: 0, + width: 100 }`. + +An author-pinned `minDate` / `maxDate` is untouched by the sentinel: the gantt branch resolves +`schema.minDate || dateRange.minDate`, so a pinned range with `items: []` renders exactly that +range with no rows in it — most likely what the author wanted, and free. + +**No product judgment about what an empty gantt should look like.** "Do not crash" is a +correctness floor; whether the empty case should become the repo's standard empty-state panel +instead of a zero-row grid is a separate, still-open question, and substituting one here would +have been taking a decision that was left open on purpose. objectui#6655's object-bound gantt +refusal is also untouched and stays keyed on whether items were authored, which is precisely +why it does not fire on this case. diff --git a/packages/plugin-timeline/src/__tests__/timeline-gantt-empty-items.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-gantt-empty-items.test.tsx new file mode 100644 index 000000000..acd00f3b5 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-gantt-empty-items.test.tsx @@ -0,0 +1,370 @@ +/** + * 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#6750 — a gantt timeline with an EMPTY literal `items` array renders + * a zero-row grid instead of throwing. + * + * ## The defect + * + * `calculateDateRange` reduced the empty list with no guard: `allDates` is + * `[]`, `Math.min()` over no arguments is `Infinity`, and + * `new Date(Infinity).toISOString()` throws `RangeError: Invalid time value` + * during render. Both entry points crashed identically. Measured on this + * card's base b76ca6764, with a throwaway probe before any change: + * + * PROBE-A throw: RangeError: Invalid time value <- TimelineRenderer + * PROBE-B throw: RangeError: Invalid time value <- ObjectTimeline + * + * An empty gantt is the ORDINARY empty state of a valid schema, not a + * malformed document — any generator that builds `items` from a collection + * emits `items: []` the moment the collection is empty. + * + * ## Why three sites, pinned separately + * + * Triage (2026-08-29) put the whole branch in scope, because fixing only the + * one `throw` 「会把崩溃往后推两站」. The three stops, and what each pin below + * holds: + * + * 1. `calculateDateRange` — the throw itself. Now returns a one-day sentinel + * range anchored on today. + * 2. `generateTimeScaleHeaders` — needed no change, and that is a MEASURED + * verdict, not an assumption: a degenerate `min === max` range is not + * inverted, so it emits exactly one bucket. Pin 5a holds the composition. + * 3. `calculateBarDimensions` — `totalDuration === 0` divided `0 / 0` into + * `NaN`, which the CSSOM rejects, leaving the bar with NO `style` + * attribute at all. Pin 5b holds that. Not reachable from the empty case + * (no rows means no bars), which is exactly why it needs its own pin. + * + * ## What is deliberately NOT here + * + * Nothing asserts what an empty gantt should LOOK like. Triage was explicit + * that 「不要崩」 is a correctness floor while 「崩改成空态面板还是零行图表」 is a + * product option it withheld. A zero-row grid is what direction 1 produces; + * substituting the repo's standard empty-state panel would be taking a decision + * that was left open on purpose. + * + * ## Why the real renderer, not a stub + * + * `ObjectTimeline.test.tsx` stubs `./renderer`, so every assertion there stays + * green whether or not the gantt branch was ever reached. The evidence here is + * markup the real `TimelineRenderer` emits — or, before the fix, the throw it + * raised. Nothing is mocked except the ambient React context hooks. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ObjectTimeline } from '../ObjectTimeline'; +import { TimelineRenderer, generateTimeScaleHeaders } 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, + }), + }; +}); + +/** + * The sentinel anchors on "now", so the clock is pinned rather than the + * assertions being written around whatever day CI happens to run on. Only + * `Date` is faked: faking timers wholesale interferes with React's scheduler. + */ +const FROZEN_TODAY = '2026-03-15'; +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(`${FROZEN_TODAY}T09:30:00.000Z`)); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +const EMPTY_GANTT = { type: 'timeline', variant: 'gantt', items: [] }; + +/** The axis header cells the gantt branch emits, in order. */ +const axisOf = (container: HTMLElement): string[] => + Array.from(container.querySelectorAll('.border-r.text-xs.font-medium.text-center')).map( + (n) => n.textContent ?? '', + ); + +/** Every bar's inline geometry, in order — literally `calculateBarDimensions`'s output. */ +const barStylesOf = (container: HTMLElement): (string | null)[] => + Array.from(container.querySelectorAll('.absolute.h-8.rounded-md')).map((n) => + (n as HTMLElement).getAttribute('style'), + ); + +/** Row labels below the header — zero of them is the "zero-row grid". */ +const rowLabelsOf = (container: HTMLElement): string[] => + Array.from(container.querySelectorAll('.px-4.py-2.font-medium.text-sm.truncate')).map( + (n) => n.textContent ?? '', + ); + +describe('pin 1 — `TimelineRenderer` with an empty gantt does not throw (objectui#6750)', () => { + it('renders instead of raising `RangeError: Invalid time value`', () => { + // The whole defect in one assertion. Before the guard this call throws out + // of `calculateDateRange`. + expect(() => render()).not.toThrow(); + }); + + it('renders a zero-row grid — the gantt chrome is there, the rows are not', () => { + const { container } = render(); + + // Chrome: the row-label header the gantt branch always emits. + expect(screen.getByText('Items')).toBeDefined(); + // Zero rows, and therefore zero bars. + expect(rowLabelsOf(container), 'an empty gantt drew rows').toEqual([]); + expect(barStylesOf(container), 'an empty gantt drew bars').toEqual([]); + }); + + it('the axis is VALID, not an empty header row', () => { + const { container } = render(); + + // A header row with zero cells would also "not throw"; that is not what + // was asked for. The sentinel is one day, so every scale yields exactly + // one bucket — here the default `month`, on the frozen clock. + expect(axisOf(container)).toEqual(['Mar 2026']); + }); + + it('every spec scale yields a one-bucket axis on the sentinel range', () => { + // Guards against a sentinel that happens to work for `month` and collapses + // to zero columns on another axis. + for (const [scale, expected] of [ + ['hour', 'Mar 15, 12 AM'], + ['day', 'Mar 15'], + ['week', 'Week 1'], + ['month', 'Mar 2026'], + ['quarter', 'Q1 2026'], + ['year', '2026'], + ] as const) { + const { container, unmount } = render( + , + ); + expect(axisOf(container), `empty gantt on scale '${scale}' drew no axis`).toEqual([expected]); + unmount(); + } + }); +}); + +describe('pin 2 — `ObjectTimeline` with the same schema does not throw (objectui#6750)', () => { + it('passes the AUTHORED empty array straight through and renders it', () => { + // An authored empty array is truthy, so `effectiveItems` returns it as + // authored items — this is not the composed path, and #6655's refusal + // (which keys on whether items were authored) correctly does not fire. + expect(() => render()).not.toThrow(); + }); + + it('produces the same zero-row grid as the renderer', () => { + const { container } = render(); + + expect(screen.getByText('Items')).toBeDefined(); + expect(axisOf(container)).toEqual(['Mar 2026']); + expect(rowLabelsOf(container)).toEqual([]); + expect(barStylesOf(container)).toEqual([]); + // #6655's diagnostic belongs to the COMPOSED path and must stay away. + expect( + screen.queryByTestId('timeline-unsupported-variant'), + "#6655's refusal fired on an authored empty gantt", + ).toBeNull(); + }); +}); + +describe('pin 3 — an author-pinned range survives the sentinel (objectui#6750)', () => { + it('`minDate` / `maxDate` with `items: []` renders EXACTLY that range, with no rows', () => { + // Triage kept this argument for direction 1 because it is a free win: the + // gantt branch resolves `schema.minDate || dateRange.minDate`, so the + // sentinel is only ever a fallback. An author who pinned a quarter gets + // that quarter, empty — most likely what they wanted. + const { container } = render( + , + ); + + expect(axisOf(container), 'the sentinel clobbered an author-pinned range').toEqual([ + 'Jan 2024', + 'Feb 2024', + 'Mar 2024', + ]); + expect(rowLabelsOf(container)).toEqual([]); + expect(barStylesOf(container)).toEqual([]); + }); +}); + +/** + * The catalog fixture's shape, trimmed. Its rendered output below is the + * BASELINE captured on b76ca6764 before any change — this pin is what catches a + * sentinel or a degenerate-range guard leaking into the normal path. + */ +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' }, + ], + }, +]; + +describe('pin 4 — a NON-empty gantt is byte-for-byte unchanged (objectui#6750)', () => { + it('draws the same axis and the same bar geometry as before the guards', () => { + const { container } = render( + , + ); + + // Captured on b76ca6764 with the pre-fix code: + // PROBE-C axis: ["Jan 2024","Feb 2024","Mar 2024"] + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + + // Captured on b76ca6764 with the pre-fix code: + // PROBE-C bars: ["left: 0%; width: 33.33333333333333%;", + // "left: 34.44444444444444%; width: 65.55555555555556%;", + // "left: 15.555555555555555%; width: 34.44444444444444%;"] + // The full float spelling on purpose: a guard that rounded, clamped or + // short-circuited the normal arithmetic would still pass a tolerance + // assertion and fail this one. + expect(barStylesOf(container)).toEqual([ + 'left: 0%; width: 33.33333333333333%;', + 'left: 34.44444444444444%; width: 65.55555555555556%;', + 'left: 15.555555555555555%; width: 34.44444444444444%;', + ]); + + expect(rowLabelsOf(container)).toEqual(['Backend Development', 'Frontend Development']); + }); +}); + +describe('pin 5a — `generateTimeScaleHeaders` on the degenerate range (objectui#6750)', () => { + it('a `min === max` range is not inverted, so every scale emits exactly one bucket', () => { + // The site itself, called directly — this is the guarantee the empty + // gantt's axis rests on, and it is pinned SEPARATELY from the sentinel so + // that a later change to either one alone goes red here. + for (const [scale, expected] of [ + ['hour', 'Mar 15, 12 AM'], + ['day', 'Mar 15'], + ['week', 'Week 1'], + ['month', 'Mar 2026'], + ['quarter', 'Q1 2026'], + ['year', '2026'], + ] as const) { + expect( + generateTimeScaleHeaders(scale, FROZEN_TODAY, FROZEN_TODAY), + `scale '${scale}' drew no axis on a degenerate range`, + ).toEqual([expected]); + } + }); + + it('still refuses an unparseable or inverted range by drawing nothing', () => { + // The pre-existing guard, pinned so the #6750 work is not read as having + // widened it. An inverted author-pinned range is a different input class + // and is left exactly as it was. + expect(generateTimeScaleHeaders('month', '2030-01-01', '2026-03-15')).toEqual([]); + expect(generateTimeScaleHeaders('month', '', '')).toEqual([]); + }); +}); + +describe('pin 5b — `calculateBarDimensions` on a degenerate axis (objectui#6750)', () => { + it('a same-day task gets a real bar instead of `NaN` geometry', () => { + // `totalDuration === 0`, so both divisions were `0 / 0`. Measured on + // b76ca6764: the bar element carried NO `style` attribute at all, because + // the CSSOM rejects `left: NaN%` and `width: NaN%` — an invisible failure, + // not a crash. This case is NOT reachable from the empty gantt (no rows + // means no bars), which is why it has its own pin. + const SAME_DAY = [ + { label: 'One Day', items: [{ title: 'Kickoff', startDate: '2024-05-01', endDate: '2024-05-01' }] }, + ]; + const { container } = render( + , + ); + + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); + // The axis is still real, and still one bucket wide. + expect(axisOf(container)).toEqual(['May 2024']); + }); + + it('an author pinning `minDate === maxDate` reaches the same guard', () => { + // The second way to a zero-width axis, and the one an author can trip + // without any same-day task: the pinned range wins at the call site, so + // `calculateBarDimensions` gets `totalDuration === 0` even though the rows + // span a real interval. + const { container } = render( + , + ); + + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); + }); +}); + +describe("pin 6 — #6655's object-bound refusal is undisturbed (objectui#6750)", () => { + /** Records, not items — what the OBJECT-BOUND path composes from. */ + 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' }, + ]; + const OBJECT_BOUND = { + type: 'timeline', + objectName: 'campaign', + variant: 'gantt', + timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }, + }; + + it('a COMPOSED gantt still refuses loudly — the sentinel did not swallow it', () => { + // The hazard this pin exists for: a guard that returns a sentinel range for + // ANY empty date list would make the object-bound path render a silent + // empty chart instead of #6655's diagnostic, quietly undoing that ruling. + // The refusal fires in `ObjectTimeline`, above the renderer, and keys on + // whether items were AUTHORED — so it is reached before any of this card's + // code, and #6750 leaves it untouched. + const props = { schema: OBJECT_BOUND, data: rows } as unknown as React.ComponentProps< + typeof ObjectTimeline + >; + const { container } = render(); + + const el = screen.queryByTestId('timeline-unsupported-variant'); + expect(el, "#6655's refusal stopped firing on the composed gantt path").not.toBeNull(); + expect(el!.getAttribute('role')).toBe('alert'); + expect(el!.textContent ?? '').toContain('gantt'); + + // And no gantt was drawn in its place. + expect(axisOf(container)).toEqual([]); + expect(barStylesOf(container)).toEqual([]); + expect(screen.queryByText('Spring Launch')).toBeNull(); + }); +}); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index d3d718f7f..683aee563 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -95,6 +95,26 @@ export function resolveTimelineScale(schema: { scale?: unknown }): string { * own defaults table, which is what the channel serves with no `I18nProvider` * mounted, so 3- and 4-argument call sites keep producing byte-identical * English. + * + * ## The empty/degenerate range — the second of #6750's three sites + * + * objectui#6750 asked the same empty-list question at all three stops on the + * gantt branch, so that fixing the one `throw` did not just move the crash two + * stations down. This one needed no change, and that verdict is recorded here + * rather than left to be re-derived: the guard on the next line already refuses + * an unparseable or inverted range by returning NO headers, and a DEGENERATE + * range (`minDate === maxDate`, which is what `emptyGanttDateRange` hands it) + * is not inverted — `start > end` is false when they are equal, so the loop + * runs exactly once and every scale emits exactly one bucket. Measured on + * b76ca6764, min = max = '2026-03-15': hour `["Mar 15, 12 AM"]`, day + * `["Mar 15"]`, week `["Week 1"]`, month `["Mar 2026"]`, quarter `["Q1 2026"]`, + * year `["2026"]`. + * + * So the empty gantt gets a real one-column axis, not a header row with zero + * cells. That composition is what + * `./__tests__/timeline-gantt-empty-items.test.tsx` pins — separately from the + * other two sites, so a later change that fixes one and not the others goes + * red. */ export function generateTimeScaleHeaders( scale: string, @@ -161,15 +181,64 @@ export function generateTimeScaleHeaders( return headers; } +/** + * The date range a gantt falls back to when the rows carry NO dates at all — + * an authored `items: []`, or rows whose `items` are all empty. + * + * ## Why a sentinel and not a throw (objectui#6750) + * + * `calculateDateRange` used to reduce the empty list directly: `Math.min()` + * over no arguments is `Infinity`, and `new Date(Infinity).toISOString()` + * throws `RangeError: Invalid time value` during render. An empty gantt is not + * a malformed document — it is the ORDINARY empty state of a valid schema. Any + * author or generator that builds `items` from a collection produces `items: + * []` the moment the collection is empty: a filtered project list with no + * matches, a fresh workspace, a plan whose rows are yet to be added. Crashing + * the render on that is a correctness defect, not a strict-input policy. + * + * ## Why TODAY, and why a single day + * + * An empty plan carries no dates of its own, so the axis has to be anchored on + * something outside the data, and "now" is the only anchor that is not + * arbitrary. The span is ONE day — the smallest coherent range — because how + * much time an empty gantt should show is a question about what an empty gantt + * should LOOK like, and the 2026-08-29 triage on #6750 deliberately left that + * open (「不要崩」 is the correctness floor it ruled on; 「崩改成空态面板还是零行 + * 图表」 is the product option it did not). A one-day window makes the smallest + * possible claim: `generateTimeScaleHeaders` turns it into exactly one bucket + * on every scale, so the axis is valid and non-empty, and the grid below it has + * zero rows. + * + * ## What this deliberately does NOT do + * + * It does not reach the caller when the author pinned a range. The gantt branch + * resolves `schema.minDate || dateRange.minDate`, so an author who pinned an + * explicit `minDate` / `maxDate` gets EXACTLY that range with no rows in it — + * most likely what they wanted, and free. Pinned by + * `./__tests__/timeline-gantt-empty-items.test.tsx`. + */ +function emptyGanttDateRange(): { minDate: string; maxDate: string } { + const today = new Date().toISOString().split('T')[0]; + return { minDate: today, maxDate: today }; +} + // Helper function to calculate date range from items function calculateDateRange(items: any[]): { minDate: string; maxDate: string } { const allDates = items.flatMap((row: any) => (row.items || []).flatMap((item: any) => [item.startDate, item.endDate]) ); - + + // objectui#6750 — the empty list is an ordinary state, not an error. Guarding + // it HERE rather than at the call site is what keeps the whole gantt branch + // coherent: the caller's `schema.minDate || dateRange.minDate` still resolves, + // `generateTimeScaleHeaders` still gets a parseable min <= max and so still + // emits an axis, and `calculateBarDimensions` is simply never reached because + // there are no rows to draw bars for. See `emptyGanttDateRange` above. + if (allDates.length === 0) return emptyGanttDateRange(); + const minTimestamp = Math.min(...allDates.map((d: string) => new Date(d).getTime())); const maxTimestamp = Math.max(...allDates.map((d: string) => new Date(d).getTime())); - + return { minDate: new Date(minTimestamp).toISOString().split('T')[0], maxDate: new Date(maxTimestamp).toISOString().split('T')[0], @@ -192,6 +261,30 @@ function calculateBarDimensions( const startOffset = start - min; const duration = end - start; + /** + * objectui#6750 — the DEGENERATE axis, the third site of the same empty-list + * question and the one that fails silently instead of loudly. + * + * `totalDuration` is `0` whenever the axis has no width: every task starting + * and ending on the same day (a one-day plan, or a single same-day task), or + * an author pinning `minDate === maxDate`. Both divisions below then evaluate + * `0 / 0`, which is `NaN`, and the bar is handed `left: NaN%; width: NaN%`. + * That is not a crash and not a visible error — the CSSOM REJECTS both + * declarations, so React leaves the element with no `style` attribute at all + * and the bar renders unpositioned and zero-width. Measured on b76ca6764: + * a single `{ startDate: '2024-05-01', endDate: '2024-05-01' }` row produced + * `
` carrying no `style`. + * + * On a zero-width axis every task covers the whole of it, by definition — + * there is no sub-interval for a bar to occupy. `{ start: 0, width: 100 }` is + * that answer written down, and it keeps the bar visible instead of + * collapsing it. Guarded on `totalDuration === 0` and nothing looser, so the + * normal path is arithmetically untouched. + */ + if (totalDuration === 0) { + return { start: 0, width: 100 }; + } + return { start: (startOffset / totalDuration) * 100, width: (duration / totalDuration) * 100,