Skip to content

Commit d6fe6d3

Browse files
committed
fix(plugin-timeline): refuse a null gantt date instead of drawing it at the epoch
`new Date(null).getTime()` is `0`, not `NaN` — the epoch, not an invalid date — so a `null` `startDate` / `endDate` passed objectui#6759's parse guard and reached the arithmetic as `1970-01-01`. Measured on c673282, with #6759's guards already in the tree: CASE-NULL-END endDate: null -> no alert, axis 649 columns (Jan 1970 … Jan 2024), bars ["left: 100%; width: -100%;"] CASE-NULL-START startDate: null -> no alert, axis 651 columns, bars ["left: 0%; width: 100%;"] CASE-NULL-BOTH both null -> no alert, axis ["Jan 1970"], bars ["left: 0%; width: 100%;"] The last two are why this refuses rather than repairs: they do not look broken. The arm is #6759's own, not a new one. That card already refuses this input class one spelling over — `new Date(undefined)` is `NaN`, so an omitted `endDate` is refused and named — and which spelling a document carries is decided by the record mapping upstream, not by the author. The scan's predicate is `value === null`, deliberately not "coerces to the epoch": `0` is a legitimate epoch timestamp and `startDate: 1704067200000` renders 2024 correctly today. A PINNED `null` stays unjudged, because the caller's `schema.minDate || dateRange.minDate` discards it before anything reads it. Fixes #6770 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
1 parent c673282 commit d6fe6d3

3 files changed

Lines changed: 438 additions & 10 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/plugin-timeline': patch
3+
---
4+
5+
fix(plugin-timeline): refuse a `null` gantt date instead of drawing it at the epoch
6+
7+
`new Date(null).getTime()` is `0`, not `NaN` — the Unix epoch, not an invalid
8+
date — so a `null` `startDate` / `endDate` passed the objectui#6759 parse guard
9+
and reached the arithmetic as `1970-01-01`. One row item with `endDate: null`
10+
drew a 649-column axis spanning 1970 to 2024 and a bar at
11+
`left: 100%; width: -100%`, with no error and no diagnostic; a `null`
12+
`startDate`, or both null, drew a bar with entirely plausible geometry on an
13+
axis anchored in 1970 — a chart a reader would believe.
14+
15+
A `null` row date now renders the same `role="alert"` diagnostic objectui#6759
16+
established, naming the authored path and spelling the value as `null`. This is
17+
consistency with that card rather than a new policy: it already refuses the same
18+
absence spelled `undefined` (an omitted `endDate`), and which of the two a
19+
document carries is decided by the record mapping upstream, not by the author.
20+
21+
Unchanged: a `minDate` / `maxDate` pinned as `null` is falsy, so the caller
22+
discards it and the rows' own range still renders; numeric timestamps and `Date`
23+
instances still render; objectui#6750's empty-list sentinel and every valid
24+
gantt are untouched.
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#6770 — a `null` gantt date is refused, not silently drawn at 1970.
11+
*
12+
* ## What was wrong
13+
*
14+
* `new Date(null).getTime()` is `0`, not `NaN`. That is the Unix epoch, not an
15+
* invalid date, so a `null` date passed objectui#6759's parse guard untouched
16+
* and reached the arithmetic as `1970-01-01`. Measured on this card's base
17+
* c6732825d — i.e. WITH #6759's guards already in the tree — with a throwaway
18+
* probe, since no pin covered this input:
19+
*
20+
* CASE-NULL-END endDate: null -> alert: null, axis 649 columns
21+
* (Jan 1970 … Jan 2024), bars ["left: 100%; width: -100%;"]
22+
* CASE-NULL-START startDate: null -> alert: null, axis 651 columns,
23+
* bars ["left: 0%; width: 100%;"]
24+
* CASE-NULL-BOTH both null -> alert: null, axis ["Jan 1970"],
25+
* bars ["left: 0%; width: 100%;"]
26+
* CASE-PIN-NULL minDate/maxDate null -> the rows' own range, correct
27+
*
28+
* The filed case is the first: a fifty-four-year axis and a bar with NEGATIVE
29+
* width, hanging off the right-hand edge, with no diagnostic. The other two are
30+
* the reason the answer is a refusal rather than a repair — they do not even
31+
* look broken. A full-width bar under a one-bucket `Jan 1970` axis is a chart a
32+
* reader would believe.
33+
*
34+
* ## The arm taken, and the pin that decided it
35+
*
36+
* Three readings were defensible on paper — refuse, treat as open-ended, treat
37+
* as absent — and the card refused to let a fixer pick one silently. It did not
38+
* need a fresh ruling: #6759 ALREADY refuses this input class one spelling
39+
* over. `new Date(undefined)` is `NaN`, so a row item that merely omits
40+
* `endDate` is refused and named, pinned next door as "an ABSENT date is the
41+
* same input class and is named as `undefined`"
42+
* (`timeline-gantt-unusable-date-range.test.tsx`, pin 1). `null` is that same
43+
* absence with a different spelling: a record mapping that omits the key and
44+
* one that emits `null` describe the same unset field upstream. Rendering them
45+
* differently would make the chart depend on how a mapping layer spells "no
46+
* value", which no author writes and none can predict — so the refusal is
47+
* consistency with an existing pin, not a new policy.
48+
*
49+
* The other two arms fail the test both neighbouring cards already applied:
50+
* "open-ended" is a new rendering behaviour that would need a spec key of its
51+
* own rather than a meaning smuggled into `null`, and "fall back to
52+
* `startDate`" substitutes a plausible value for one the document does not
53+
* carry — the consumer-side tolerance #6750 and #6759 both rejected.
54+
*
55+
* ## The wording is reused, deliberately
56+
*
57+
* The diagnostic is #6759's `timeline.gantt.unusableRange.malformedDate`, whose
58+
* `{{value}}` hole `spellGanttDateValue` already fills as `null` — a branch
59+
* #6759 wrote for "one they wrote as empty" and that nothing could reach until
60+
* this card. A `null`-specific sentence would be a new key in all ten locale
61+
* packs (`packages/i18n`), which is another card's file surface.
62+
*
63+
* ## Assertions count BAR ELEMENTS, never styles
64+
*
65+
* #6759's rule, inherited: a bar whose geometry is `NaN` carries NO `style`
66+
* attribute at all (the CSSOM rejects `left: NaN%`), so an assertion phrased
67+
* over styles reads identically for "the bar is gone" and "the bar is there and
68+
* broken". Every refusal assertion below is positive about the diagnostic and
69+
* counts elements. Styles are read only on the paths that must stay UNCHANGED,
70+
* where their exact float spelling is the point.
71+
*/
72+
73+
import React from 'react';
74+
import { render, screen } from '@testing-library/react';
75+
import { describe, it, expect, vi } from 'vitest';
76+
import { ObjectTimeline } from '../ObjectTimeline';
77+
import { TimelineRenderer } from '../renderer';
78+
79+
vi.mock('@object-ui/react', async (importOriginal) => {
80+
const actual = await (importOriginal() as Promise<Record<string, unknown>>);
81+
return {
82+
...actual,
83+
useDataScope: () => undefined,
84+
useNavigationOverlay: () => ({
85+
isOverlay: false,
86+
handleClick: vi.fn(),
87+
selectedRecord: null,
88+
isOpen: false,
89+
close: vi.fn(),
90+
setIsOpen: vi.fn(),
91+
mode: 'overlay',
92+
view: undefined,
93+
}),
94+
useObjectLabel: () => ({
95+
fieldOptionLabel: (_o: string, _f: string, _v: string, fb: string) => fb,
96+
translateOptions: (_o: string, _f: string, opts: unknown[]) => opts,
97+
fieldLabel: (_o: string, _f: string, fb: string) => fb,
98+
}),
99+
};
100+
});
101+
102+
const TESTID = 'timeline-unusable-date-range';
103+
104+
/** The axis header cells the gantt branch emits, in order. */
105+
const axisOf = (container: HTMLElement): string[] =>
106+
Array.from(container.querySelectorAll('.border-r.text-xs.font-medium.text-center')).map(
107+
(n) => n.textContent ?? '',
108+
);
109+
110+
/** How many bar ELEMENTS exist — not their geometry. See the header. */
111+
const barCountOf = (container: HTMLElement): number =>
112+
container.querySelectorAll('.absolute.h-8.rounded-md').length;
113+
114+
/** Every bar's inline geometry, in order — for the unchanged-path pins only. */
115+
const barStylesOf = (container: HTMLElement): (string | null)[] =>
116+
Array.from(container.querySelectorAll('.absolute.h-8.rounded-md')).map((n) =>
117+
(n as HTMLElement).getAttribute('style'),
118+
);
119+
120+
/** The diagnostic's text, or `null` when the gantt rendered instead. */
121+
const diagnosticOf = (container: HTMLElement): string | null => {
122+
const el = container.querySelector(`[data-testid="${TESTID}"]`);
123+
return el ? el.textContent ?? '' : null;
124+
};
125+
126+
const gantt = (schema: Record<string, unknown>) =>
127+
render(<TimelineRenderer schema={{ type: 'timeline', variant: 'gantt', ...schema } as any} />);
128+
129+
/** One row carrying one item, so a case only has to say what is wrong with it. */
130+
const rowWith = (item: Record<string, unknown>) => [{ label: 'R', items: [{ title: 'T', ...item }] }];
131+
132+
/** One legitimate row — the thing that is NOT at fault in the pinned-range cases. */
133+
const GOOD_ROW = {
134+
label: 'Backend',
135+
items: [{ title: 'API Design', startDate: '2024-01-01', endDate: '2024-03-01' }],
136+
};
137+
138+
describe('pin 1 — a `null` row date refuses and names itself (objectui#6770)', () => {
139+
it('a null `endDate` is refused, spelled as `null`, at the path the author wrote it', () => {
140+
const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: null }) });
141+
142+
const el = screen.getByTestId(TESTID);
143+
// #6655's shape by way of #6759: an alert, not a silent panel.
144+
expect(el.getAttribute('role')).toBe('alert');
145+
146+
const text = el.textContent ?? '';
147+
// Spelled as itself. `undefined` and `null` read differently to an author —
148+
// a key they forgot versus one they wrote as empty — and #6759's
149+
// `spellGanttDateValue` already keeps them apart.
150+
expect(text, 'the diagnostic did not name the offending value').toContain('null');
151+
expect(text, 'the diagnostic did not name the authored path').toContain(
152+
'items[0].items[0].endDate',
153+
);
154+
});
155+
156+
it('the 649-column 1970 axis and the negative-width bar are BOTH gone', () => {
157+
// Base reading: axis 649 columns Jan 1970 … Jan 2024, bars
158+
// ["left: 100%; width: -100%;"]. Counted, not read off a style attribute.
159+
const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: null }) });
160+
161+
expect(axisOf(container), 'the 1970 axis survived the refusal').toEqual([]);
162+
expect(barCountOf(container), 'a bar was drawn from a null date').toBe(0);
163+
expect(screen.queryByText('T')).toBeNull();
164+
});
165+
166+
it('a null `startDate` is refused too — the case that LOOKED fine', () => {
167+
// Base reading: 651 columns and bars ["left: 0%; width: 100%;"] — a bar
168+
// with entirely plausible geometry on an axis starting in 1970. Nothing
169+
// about that render says it is wrong, which is why it is here.
170+
const { container } = gantt({ items: rowWith({ startDate: null, endDate: '2024-03-01' }) });
171+
172+
const text = diagnosticOf(container) ?? '';
173+
expect(text).toContain('items[0].items[0].startDate');
174+
expect(text).toContain('null');
175+
expect(barCountOf(container)).toBe(0);
176+
});
177+
178+
it('both dates null names the first one and draws nothing', () => {
179+
// Base reading: a single `Jan 1970` bucket with a full-width bar — the most
180+
// believable of the three, and the least true.
181+
const { container } = gantt({ items: rowWith({ startDate: null, endDate: null }) });
182+
183+
const text = diagnosticOf(container) ?? '';
184+
expect(text).toContain('items[0].items[0].startDate');
185+
expect(axisOf(container)).toEqual([]);
186+
expect(barCountOf(container)).toBe(0);
187+
});
188+
189+
it('the path is the one the null actually sits at, not a hardcoded first row', () => {
190+
// The scan reports the first fault it meets. A null on the second item of
191+
// the second row must be reported THERE — otherwise "names where you wrote
192+
// it" is only true for a one-row document.
193+
const { container } = gantt({
194+
items: [
195+
{ label: 'A', items: [{ title: 'T', startDate: '2024-01-01', endDate: '2024-02-01' }] },
196+
{
197+
label: 'B',
198+
items: [
199+
{ title: 'U', startDate: '2024-01-01', endDate: '2024-02-01' },
200+
{ title: 'V', startDate: '2024-01-01', endDate: null },
201+
],
202+
},
203+
],
204+
});
205+
206+
expect(diagnosticOf(container) ?? '').toContain('items[1].items[1].endDate');
207+
});
208+
209+
it('`ObjectTimeline` reaches the same refusal on an authored gantt', () => {
210+
// `items` authored means `ObjectTimeline` is a pass-through, so #6655's
211+
// composed-path refusal correctly stays away and the schema reaches
212+
// `TimelineRenderer`. Both entry points drew the 1970 chart on the base;
213+
// both must refuse now.
214+
const { container } = render(
215+
<ObjectTimeline
216+
schema={
217+
{
218+
type: 'timeline',
219+
variant: 'gantt',
220+
items: rowWith({ startDate: '2024-01-01', endDate: null }),
221+
} as any
222+
}
223+
/>,
224+
);
225+
226+
expect(diagnosticOf(container) ?? '').toContain('items[0].items[0].endDate');
227+
expect(
228+
screen.queryByTestId('timeline-unsupported-variant'),
229+
"#6655's refusal fired on an authored gantt",
230+
).toBeNull();
231+
});
232+
});
233+
234+
describe('pin 2 — a PINNED null is not judged (objectui#6770 boundary)', () => {
235+
it('`minDate: null` / `maxDate: null` draw the rows’ own range, with no diagnostic', () => {
236+
// The caller resolves `schema.minDate || dateRange.minDate`, so a null pin
237+
// is discarded before anything reads it — exactly like #6759's empty-string
238+
// pin. Judging a value the render never reads would refuse a chart that
239+
// draws correctly. Measured on the base and unchanged by this card.
240+
const { container } = gantt({ items: [GOOD_ROW], minDate: null, maxDate: null });
241+
242+
expect(diagnosticOf(container), 'a discarded null pin was refused').toBeNull();
243+
expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']);
244+
expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']);
245+
});
246+
});
247+
248+
describe('pin 3 — the line is `null`, not "coerces to the epoch" (objectui#6770)', () => {
249+
it('a numeric millisecond timestamp still renders', () => {
250+
// The reason the test is `value === null` and nothing wider: a number IS a
251+
// date in this encoding. Refusing numbers to catch `0` would break input
252+
// that draws correctly today.
253+
const { container } = gantt({
254+
items: rowWith({ startDate: '2024-01-01', endDate: Date.UTC(2024, 2, 1) }),
255+
});
256+
257+
expect(diagnosticOf(container), 'a valid numeric timestamp was refused').toBeNull();
258+
expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']);
259+
expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']);
260+
});
261+
262+
it('a `Date` instance still renders', () => {
263+
const { container } = gantt({
264+
items: rowWith({ startDate: '2024-01-01', endDate: new Date('2024-03-01') }),
265+
});
266+
267+
expect(diagnosticOf(container), 'a Date instance was refused').toBeNull();
268+
expect(barCountOf(container)).toBe(1);
269+
});
270+
});
271+
272+
describe('pin 4 — the neighbouring cards are untouched (objectui#6759, objectui#6750)', () => {
273+
it('an ABSENT date is still spelled `undefined`, not `null`', () => {
274+
// The two spellings are refused for the same reason and must keep reading
275+
// differently: this card widened WHICH values are refused, not how any of
276+
// them is named.
277+
const { container } = gantt({ items: rowWith({ startDate: '2024-01-01' }) });
278+
279+
const text = diagnosticOf(container) ?? '';
280+
expect(text).toContain('undefined');
281+
expect(text).toContain('items[0].items[0].endDate');
282+
});
283+
284+
it('an unparseable date is still named by its quoted value', () => {
285+
const { container } = gantt({ items: rowWith({ startDate: 'not-a-date', endDate: 'also-bad' }) });
286+
287+
expect(diagnosticOf(container) ?? '').toContain('"not-a-date"');
288+
});
289+
290+
it("#6750's EMPTY gantt still gets its sentinel rather than this card's refusal", () => {
291+
// An empty list has no dates to be null, so it must pass straight through
292+
// to the one-bucket sentinel. The hazard in both directions: this guard
293+
// eating the empty state, or the sentinel being widened to swallow a null.
294+
const { container } = gantt({ items: [] });
295+
296+
expect(diagnosticOf(container), "this card's guard ate #6750's empty state").toBeNull();
297+
expect(axisOf(container).length, 'the empty gantt lost its one-bucket axis').toBe(1);
298+
expect(barCountOf(container)).toBe(0);
299+
});
300+
301+
it('a valid gantt draws the same axis and the same bar geometry as before this card', () => {
302+
// #6759 pin 6's fixture and its baseline, re-asserted here because a guard
303+
// that rounded, clamped or short-circuited the arithmetic would pass a
304+
// tolerance assertion and fail this one.
305+
const { container } = gantt({
306+
scale: 'month',
307+
items: [
308+
{
309+
label: 'Backend Development',
310+
items: [
311+
{ title: 'API Design', startDate: '2024-01-01', endDate: '2024-01-31', variant: 'success' },
312+
{ title: 'Implementation', startDate: '2024-02-01', endDate: '2024-03-31', variant: 'info' },
313+
],
314+
},
315+
{
316+
label: 'Frontend Development',
317+
items: [
318+
{ title: 'UI Design', startDate: '2024-01-15', endDate: '2024-02-15', variant: 'warning' },
319+
],
320+
},
321+
],
322+
});
323+
324+
expect(diagnosticOf(container), 'the guard fired on a perfectly good gantt').toBeNull();
325+
expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']);
326+
expect(barStylesOf(container)).toEqual([
327+
'left: 0%; width: 33.33333333333333%;',
328+
'left: 34.44444444444444%; width: 65.55555555555556%;',
329+
'left: 15.555555555555555%; width: 34.44444444444444%;',
330+
]);
331+
});
332+
333+
it('a `null` date on the VERTICAL variant is untouched', () => {
334+
// The gantt guard stays gantt-only, as #6759's pin 4 has it: the other
335+
// variants never build a date range.
336+
const { container } = render(
337+
<TimelineRenderer
338+
schema={{ type: 'timeline', variant: 'vertical', items: [{ title: 'T', time: null }] } as any}
339+
/>,
340+
);
341+
342+
expect(diagnosticOf(container), 'the gantt guard fired on a vertical timeline').toBeNull();
343+
expect(screen.getByText('T')).toBeDefined();
344+
});
345+
});

0 commit comments

Comments
 (0)