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
57 changes: 57 additions & 0 deletions .changeset/6355-retire-timeline-timescale-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'@object-ui/plugin-timeline': minor
'@object-ui/components': minor
'@object-ui/types': minor
---

Retire the `timeScale` alias on the timeline node — `scale` is the only axis spelling
(objectui#6355, maintainer ruling 2026-08-27).

**BREAKING for authored metadata.** `timeScale` was this renderer's pre-spec spelling of the
Gantt axis bucket. `scale` is canonical — it is `@objectstack/spec` `ui/TimelineConfig.json`'s
axis key and the key the renderer preferred (objectui#6170 ruling, 2026-08-25: `timeScale`
goes the alias-retirement route, not a silent second spelling). objectui#6355's ruling
retires it immediately, with no phased window, while the project is at startup stage.

**What breaks, and how you will find out.** A timeline document that spells `timeScale` is
now **refused**, loudly, at the authoring boundary:

- `TimelineSchema.timeScale` is declared `?: never` — writing it is a type error;
- the Zod twin declares `z.never().optional()` — parsing a document that carries the key
fails with `invalid_type` / `expected: never` on the `timeScale` path.

The fix is a rename: `timeScale` → `scale`. The accepted values are unchanged (`hour`,
`day`, `week`, `month`, `quarter`, `year`), so no value needs rewriting.

**Why a tombstone rather than deleting the key.** `BaseSchema` is `.passthrough()` on the
Zod side and carries `[key: string]: any` on the TS side, so an *undeclared* key is accepted
unvalidated by both halves. Deleting `timeScale` outright would have let the retired spelling
parse green and type-check green while the renderer no longer read it — the Gantt axis would
silently fall back to the `month` default, the chart would change bucket, and nothing would
error. That is the silent axis breakage objectui#2942 closed, running in the other direction,
and it is the specific outcome this retirement is shaped to prevent. Keeping the key declared
as `never` on both halves is what makes the removal audible. Absent stays valid on both, so a
document that never wrote the alias is untouched.

Also in this change:

- `resolveTimelineScale` drops the `?? schema.timeScale` fallback read; its parameter narrows
to `{ scale?: unknown }`.
- The designer drops its deprecated `timeScale` input. The `scale` input already offers all
six buckets.
- `ObjectTimeline` now emits the resolved axis under `scale` when it composes the schema it
hands to the renderer. It previously wrote the alias, which would have made **every**
object-bound Gantt fall through to the `month` default the moment the fallback read went —
silently, since that is a composed schema no author ever sees. Writing `scale` after the
spread also restores the precedence the surrounding code intends: a `timelineConfig.scale`
now actually beats a flat `schema.scale`, where under the alias the resolver's
`scale ?? timeScale` ordering let the flat key win.
- The two in-repo authors are migrated in the same change: the schema-catalog
`gantt-style-timeline.json` fixture and the registration's own `examples.gantt` block.
- Docs drop the `timeScale` row and gain a retirement callout;
`packages/components/.../TIMELINE.md`'s Gantt table now documents `scale` with the full
six-value vocabulary it has accepted since objectui#2942 (its row still claimed three).

Version note: `minor`, not `major`, per AGENTS.md §版本号策略 — objectui's major tracks the
`@objectstack` major and all publishable packages share one `fixed` group, so a breaking
narrowing is declared `minor` with the break spelled out here.
22 changes: 14 additions & 8 deletions content/docs/plugins/plugin-timeline.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,7 +73,6 @@ const schema = {
dateFormat?: 'short' | 'long' | 'iso',
// Gantt-specific
scale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year',
timeScale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year', // deprecated alias of `scale`
rowLabel?: string,
minDate?: string,
maxDate?: string,
Expand DownExpand Up@@ -117,16 +116,23 @@ const schema = {
| `items` | array | `[]` | Array of timeline items |
| `dateFormat` | string | `'short'` | Date formatting: short, long, or iso |
| `scale` | string | `'month'` | Gantt axis bucket: hour, day, week, month, quarter, or year |
| `timeScale` | string | — | **Deprecated** — the pre-spec spelling of `scale`, still read as a fallback |
| `rowLabel` | string | `'Items'` | Label for Gantt rows |
| `minDate` | string | auto | Override min date for Gantt (YYYY-MM-DD) |
| `maxDate` | string | auto | Override max date for Gantt (YYYY-MM-DD) |
| `className` | string | `''` | Additional Tailwind CSS classes |

Every key above is declared on the exported `TimelineSchema`, so an editor
completes them and a wrong value is a type error. `scale` is the canonical axis
key — it is `@objectstack/spec`'s `ui/TimelineConfig.json` spelling and the one
the renderer reads first (`scale ?? timeScale`).
key and the only one — it is `@objectstack/spec`'s `ui/TimelineConfig.json`
spelling and the one the renderer reads.

<Callout type="warn">
`timeScale`, the pre-spec spelling of `scale`, is **retired** (objectui#6355).
It is no longer read, and it is no longer authorable: the key is tombstoned on
`TimelineSchema`, so writing it is a type error and a validation failure rather
than a chart that quietly falls back to the `month` default. Rename the key to
`scale` — the accepted values are unchanged.
</Callout>

<Callout type="warn">
`events`, `orientation` and `position` are also declared on `TimelineSchema`
Expand DownExpand Up@@ -195,7 +201,7 @@ const roadmap = {
const schedule = {
type: 'timeline',
variant: 'gantt',
timeScale: 'week',
scale: 'week',
rowLabel: 'Teams',
items: [
{
Expand DownExpand Up@@ -291,13 +297,13 @@ For Gantt-style timelines, choose the appropriate time scale:

```tsx
// Day scale - for short projects
{ timeScale: 'day' }
{ scale: 'day' }

// Week scale - for medium projects
{ timeScale: 'week' }
{ scale: 'week' }

// Month scale - for long projects
{ timeScale: 'month' }
{ scale: 'month' }
```

## Customization
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"type": "timeline",
"variant": "gantt",
"dateFormat": "short",
"timeScale": "month",
"scale": "month",
"rowLabel": "Projects",
"items": [
{
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/renderers/complex/TIMELINE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,7 @@ Perfect for project management, resource planning, and multi-track timelines.
"type": "timeline",
"variant": "gantt",
"dateFormat": "short",
"timeScale": "month",
"scale": "month",
"rowLabel": "Project Tasks",
"items": [
{
Expand DownExpand Up@@ -216,7 +216,7 @@ Perfect for project management, resource planning, and multi-track timelines.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `timeScale` | `'day' \| 'week' \| 'month'` | `'month'` | Time scale for the timeline header |
| `scale` | `'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'` | `'month'` | Time scale for the timeline header. The pre-spec `timeScale` spelling is retired (objectui#6355) |
| `rowLabel` | `string` | `'Items'` | Label for the row header column |
| `minDate` | `string` | Auto-calculated | Override minimum date (YYYY-MM-DD) |
| `maxDate` | `string` | Auto-calculated | Override maximum date (YYYY-MM-DD) |
Expand Down
195 changes: 195 additions & 0 deletions packages/plugin-timeline/src/ObjectTimeline.scaleComposition.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
/**
* 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.
*/

/**
* `ObjectTimeline` composes the gantt axis under the canonical `scale`
* (objectui#6355).
*
* ## Why this one needed its own pin
*
* `ObjectTimeline` resolves the axis (`timelineConfig?.scale ?? schema.scale`)
* and writes it into the schema it hands to `TimelineRenderer`. Until this card
* it wrote the RETIRED `timeScale` alias.
*
* That made it the highest-risk writer of the three the retirement had to
* migrate, and the only one no grep of authored metadata would find: it is a
* COMPOSED object, not a document anybody authors. Dropping
* `resolveTimelineScale`'s fallback read while leaving this write in place would
* have sent EVERY object-bound gantt — every one driven by a spec
* `timeline.scale` — back to the `month` default. Silently: the tombstone
* refuses documents at the authoring boundary, and this object never crosses
* one, so nothing would have errored and no fixture would have changed.
*
* ## What is asserted, and why it is not the string
*
* The captured object is run through `resolveTimelineScale` — the real read
* path, the actual function whose behaviour changed. Asserting `captured.scale
* === 'year'` alone would still pass if the resolver stopped reading `scale`;
* asserting the resolver's output makes the two halves agree or fail.
*
* `TimelineRenderer` is stubbed here ON PURPOSE, and the stub is the
* measurement rather than a shortcut around it: what is under test is the value
* of one key on the object crossing that boundary. (`timeline-date-binding.test.tsx`
* documents the opposite case — for DATE BUCKETING a stub hides the defect,
* because the evidence there lives in the markup the real renderer emits.)
*
* ## Why this file sits in `src/` and not in `src/__tests__/`
*
* It mocks `./renderer` with the SAME specifier `ObjectTimeline.tsx` itself
* uses, from the same directory — the arrangement `ObjectTimeline.test.tsx`
* already relies on. Written from `__tests__/` as `vi.mock('../renderer')` the
* stand-in did not install: the real `TimelineRenderer` rendered and every
* assertion failed on a missing stub marker. That is the inert-mock class
* `scripts/check-vi-mock-specifiers.mjs` exists for, and the reason
* `renderTimeline` below asserts the stub actually ran instead of trusting it.
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { ObjectTimeline } from './ObjectTimeline';

/**
* Every schema handed to `TimelineRenderer` during a test.
*
* `vi.hoisted` because `vi.mock` factories are hoisted above module-level
* declarations: a plain `const` here is in its temporal dead zone when the
* factory runs, and the stand-in never installs — the suite then exercises the
* REAL renderer, which is the inert-mock failure `scripts/check-vi-mock-specifiers.mjs`
* documents one level down (a mock that reports the same green a working one does).
*/
const { composed } = vi.hoisted(() => ({ composed: [] as Array<Record<string, unknown>> }));

// A PLAIN factory, no `importOriginal`. The `importOriginal` form left this
// stand-in inert here — the real `TimelineRenderer` rendered and no assertion
// could tell — so this follows the arrangement `ObjectTimeline.test.tsx` already
// proves works against this same module.
vi.mock('./renderer', () => ({
TimelineRenderer: ({ schema }: { schema: Record<string, unknown> }) => {
composed.push(schema);
return <div data-testid="captured">{String((schema.items as unknown[])?.length ?? 0)}</div>;
},
}));

/**
* The REAL `resolveTimelineScale`, reached past the whole-module mock above.
* Asserting through it rather than reading the key directly is the point: it is
* the production read path, so a migration that renamed the key but left the
* value unreachable cannot pass.
*/
let resolveTimelineScale: (schema: { scale?: unknown }) => string;

beforeAll(async () => {
const actual = await vi.importActual<typeof import('./renderer')>('./renderer');
resolveTimelineScale = actual.resolveTimelineScale;
expect(typeof resolveTimelineScale, 'could not reach the real resolveTimelineScale').toBe('function');
});

vi.mock('@object-ui/react', async (importOriginal) => {
const actual = await (importOriginal() as Promise<Record<string, unknown>>);
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,
}),
};
});

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' },
];

async function renderTimeline(schema: Record<string, unknown>) {
const props = { schema, data: rows } as unknown as React.ComponentProps<typeof ObjectTimeline>;
render(<ObjectTimeline {...props} />);
// Waiting on the STUB's own marker proves the stand-in actually installed. An
// inert `vi.mock` reports the same green a working one does
// (`scripts/check-vi-mock-specifiers.mjs`), so the capture is asserted, never assumed.
await waitFor(() => expect(screen.getAllByTestId('captured').length).toBeGreaterThan(0));
expect(composed.length, 'the TimelineRenderer stand-in never ran — the mock is inert').toBeGreaterThan(0);
return composed[composed.length - 1];
}

/**
* `variant: 'vertical'`, deliberately, even though the axis it carries is a
* GANTT concern. The composition under test is variant-independent —
* `ObjectTimeline` writes the resolved axis unconditionally — and rendering the
* gantt variant here would exercise an unrelated pre-existing crash:
* `calculateDateRange` reads gantt ROW shape (`row.items[].startDate`) while
* `ObjectTimeline` composes feed items, so `Math.min()` over an empty list
* yields `Infinity` and `new Date(Infinity).toISOString()` throws
* `RangeError: Invalid time value`. That is filed separately; pinning it here
* would couple this card's pin to a defect it does not own.
*/
const BASE = {
type: 'timeline',
variant: 'vertical',
objectName: 'campaign',
timeline: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' },
};

beforeEach(() => {
composed.length = 0;
});

describe('ObjectTimeline composes the axis under `scale` (objectui#6355)', () => {
it('emits the spec `timeline.scale` as `scale`, and the resolver reads it', async () => {
const schema = await renderTimeline({ ...BASE, timeline: { ...BASE.timeline, scale: 'year' } });

expect(schema.timeScale, 'ObjectTimeline still composes the RETIRED alias').toBeUndefined();
expect(schema.scale).toBe('year');
// The read path, not the key name: these must agree or the migration is half done.
expect(resolveTimelineScale(schema)).toBe('year');
});

it('emits a flat `schema.scale` the same way', async () => {
const schema = await renderTimeline({ ...BASE, scale: 'quarter' });

expect(schema.timeScale).toBeUndefined();
expect(resolveTimelineScale(schema)).toBe('quarter');
});

it('lets `timeline.scale` win over a flat `scale`, as the code intends', async () => {
// Under the alias this was INVERTED and nothing noticed: ObjectTimeline
// resolved `timelineConfig.scale ?? schema.scale` and wrote the winner to
// `timeScale`, but the spread carried the flat `scale` through untouched and
// `resolveTimelineScale`'s `scale ?? timeScale` ordering then preferred it.
// Writing the resolved value under `scale` after the spread is what makes
// the stated precedence real.
const schema = await renderTimeline({
...BASE,
scale: 'day',
timeline: { ...BASE.timeline, scale: 'year' },
});

expect(resolveTimelineScale(schema)).toBe('year');
});

it('composes no axis at all when none is configured', async () => {
// Counter-probe: the assertions above must be reading a value this component
// actually put there, not one it always writes.
const schema = await renderTimeline({ ...BASE });

expect(schema.scale).toBeUndefined();
expect(schema.timeScale).toBeUndefined();
expect(resolveTimelineScale(schema)).toBe('month');
});
});
12 changes: 10 additions & 2 deletions packages/plugin-timeline/src/ObjectTimeline.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -401,8 +401,16 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
...schema,
items: effectiveItems || [],
className: className || schema.className,
// Map spec 'scale' to renderer 'timeScale' (used by gantt variant)
...(resolvedScale ? { timeScale: resolvedScale } : {}),
// Emit the resolved axis under the canonical `scale` (used by the gantt
// variant). This used to write the `timeScale` alias, which objectui#6355
// retired: leaving it would have made EVERY object-bound gantt fall
// through to the `month` default the moment `resolveTimelineScale` stopped
// reading the alias — silently, since this is a composed schema no author
// ever sees. Writing `scale` after the spread also restores the priority
// the line above intends: a `timelineConfig.scale` now actually beats a
// flat `schema.scale`, where under the alias the resolver's `scale ??
// timeScale` ordering let the flat key win.
...(resolvedScale ? { scale: resolvedScale } : {}),
onItemClick: (item: any) => {
const record = item._data || item;
navigation.handleClick(record);
Expand Down
Loading
Loading