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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
43 changes: 43 additions & 0 deletions .changeset/7070-timeline-date-axis-floors-retired.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Retire the `'created_at'` timeline date-axis floors at both plugin faces
(objectui#7070 step ③, maintainer ruling 2026-09-01, 总监批 #28).

**Breaking, deliberately.** A timeline view that declares **no** date axis anywhere no
longer renders. `ListView`'s and `ObjectView`'s timeline branches used to hand
`ObjectTimeline` a `startDateField` of `'created_at'` for such a view; both now forward
a declared axis or no key at all, and the renderer shows its "declare a date axis"
refusal instead.

House posture, entered with the ruling: **日期轴永不虚构** — a date axis is never
fabricated. This is the third and last step of a sequence the ruling ordered and forbade
reordering: `ObjectTimeline` gained the refusal screen and lost its own internal
`|| 'date'` floor first (objectui#7459), which by its own measurement changed nothing a
user could see — precisely because these two faces still supplied a name. They are the
supply.

The floor was not a harmless default. `'created_at'` is a column nearly every object
carries, so downstream it was indistinguishable from a real binding and could never
resolve to nothing — while the `$select` projection is collected from the **declared**
`timeline` / `options.timeline` blocks and never from this prop. An undeclared view was
therefore given a timeline bound to a column the query had not requested, and every
record bucketed into "No date": a screen that looks built, is wrong, and gives the
author no signal. The ruling also explicitly replaced the written decision that stood on
the deleted `ListView` line ("`created_at` stays the last resort for a view that
declares no date axis anywhere") — it was a second, de-facto contract held at one face,
on the very literal objectui#3129 had retired at the app-shell face.

**Migration.** Declare the axis on the view: `timeline.startDateField` (spec-canonical),
`timeline.dateField` (legacy alias), or a `calendar.startDateField` — objectui#3129
established that a calendar binding is a legitimate timeline axis, and it still is. All
three keep rendering exactly as before; only the *undeclared* case changes. A view that
really did want records laid out by creation time says so in one key:
`timeline: { startDateField: 'created_at' }`. The refusal names the accepted keys on
screen, so an affected view reports its own fix.

`titleField` is unaffected and keeps its `'name'` floor at both faces — it is not a date
axis. So do gantt's `progressField` / `dependenciesField`, which the ruling scoped out
for separate evaluation.
203 changes: 203 additions & 0 deletions apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
/**
* 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#7070 step ③ — the refusal is REACHABLE THROUGH THE PLUGIN FACES.
*
* The three-step ruling of 2026-09-01 (总监批 #28) put its halves in three
* packages, and no one of them can observe the whole:
*
* ① / ② `ObjectTimeline` refuses an absent date axis and no longer floors it
* at `'date'` — pinned in `plugin-timeline`, which never sees a face;
* ③ `ListView` and `ObjectView` stop supplying `'created_at'` — pinned
* in `plugin-list` / `plugin-view`, which stub the renderer and so can
* only measure the PROP, never the screen.
*
* ①② landed first and, by their own measurement, changed nothing a user could
* see — precisely because the faces still filled the flat `startDateField` rung
* that `plugin-timeline`'s CONTROL block proves is a fully honoured binding. So
* "the refusal exists" and "the face stopped inventing" were both true and still
* did not add up to a refusal on screen. This file is the join, and the console
* is where it can be made: it is the only package that depends on all three.
*
* ⭐ The rows carry a real `created_at` column, deliberately. That is the data
* shape under which a restored floor renders a CONVINCING timeline — two real
* events off a real column — rather than an empty one, so a pin that only
* counted events would pass in both worlds. The same trick, for the same
* reason, as the `date` column in `ObjectTimeline.absentDateAxisRefusal-7459`.
*
* ⚠️ A refusal is asserted POSITIVELY and paired with the canvas marker, because
* the failure this most resembles is a component that threw: "no timeline" is
* satisfied by a crash. Every block opens with a render proof, and every
* absence asserted here is asserted PRESENT by a control in the same run.
*
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore either face's `|| 'created_at'` and that face's refusal case goes RED
* — and it goes red rendering a healthy two-event timeline, not an error.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectView } from '@object-ui/plugin-view';
import { ObjectTimeline } from '@object-ui/plugin-timeline';

// The REAL renderer, registered under the type both faces emit. Stubbing it is
// what every face-level test does and exactly what this file exists not to do.
ComponentRegistry.register('object-timeline', ObjectTimeline as never, {
namespace: 'test',
label: 'Object Timeline (real)',
category: 'view',
});

const ROWS = [
{ id: '1', name: 'Spring Launch', start_date: '2099-09-01', created_at: '2099-09-01T00:00:00Z' },
{ id: '2', name: 'Summer Push', start_date: '2100-10-01', created_at: '2100-10-01T00:00:00Z' },
];

const objectDef = {
name: 'crm_campaign',
label: 'Campaign',
fields: {
id: { name: 'id', type: 'text' },
name: { name: 'name', type: 'text', label: 'Name' },
start_date: { name: 'start_date', type: 'date', label: 'Start Date' },
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
},
};

const makeDataSource = () =>
({
find: vi.fn(async () => ROWS),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => objectDef),
}) as any;

const refusal = () => screen.queryByTestId('timeline-missing-date-axis');
const canvas = () => screen.queryByTestId('timeline-canvas');

/** Mount `ListView` on a timeline view, with the real renderer downstream. */
async function mountListView(schema: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ListView schema={schema as never} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

/** Mount `plugin-view`'s `ObjectView` on a timeline view, same downstream. */
async function mountObjectView(view: Record<string, unknown>) {
const dataSource = makeDataSource();
render(
<SchemaRendererProvider dataSource={dataSource}>
<ObjectView
schema={{ type: 'object-view', objectName: 'crm_campaign' } as never}
views={[{ id: 't', label: 'Timeline', type: 'timeline' as never, ...view }]}
dataSource={dataSource}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.find).toHaveBeenCalled());
}

const LIST_BASE = {
type: 'list-view',
objectName: 'crm_campaign',
viewType: 'timeline',
columns: ['name'],
} as const;

beforeEach(() => {
vi.clearAllMocks();
});

describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
// First, and load-bearing. It proves three things the negative case below
// silently assumes: the real component is what the registry resolves, it
// mounts through this face without throwing, and `timeline-canvas` is a
// marker this harness can actually observe. Without it, "no canvas" is
// equally well explained by a crash.
await mountListView({ ...LIST_BASE, timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
expect(screen.getByText('Spring Launch')).toBeDefined();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// ⭐ THE JOIN. Before step ③ this rendered a timeline bound to `created_at`
// — a column these rows really do carry — so it looked built and was not.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());

expect(refusal()!.getAttribute('role'), 'the refusal is not announced').toBe('alert');
// Not an EMPTY timeline: the outcome the ruling rejects would still emit the
// canvas. Asserted present by the render proof above, in this same run.
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
// …and specifically not the convincing-but-wrong chart the floor produced.
expect(screen.queryByText('Spring Launch')).toBeNull();
expect(screen.queryByText('Summer Push')).toBeNull();
});

it('the refusal names the keys the author has to declare', async () => {
// A refusal the author cannot act on is a different defect. The list is
// interpolated from the component's own binding vocabulary.
await mountListView({ ...LIST_BASE });
await waitFor(() => expect(refusal()).not.toBeNull());
expect(refusal()!.textContent ?? '').toContain('timeline.startDateField');
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders', async () => {
// The alias is resolved by the face, not by a floor. If step ③ had taken it
// with the fabrication, a pre-#2231 view would start refusing — a regression
// the ruling did not order.
await mountListView({ ...LIST_BASE, timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('CONTROL: a CALENDAR-bound view still renders its timeline', async () => {
// objectui#3129: a calendar binding is a legitimate timeline axis in this
// product. This is the shape most at risk from a fix aimed at "declared
// timeline config only".
await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});

describe('ObjectView → ObjectTimeline: the second face reaches it too (objectui#7070 step ③)', () => {
it('RENDER PROOF: a DECLARED axis renders the real timeline canvas', async () => {
await mountObjectView({ timeline: { startDateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});

it('a view that declares NO date axis now refuses, on screen', async () => {
// The route `generateViewSchema` owns — the authored `object-view` element,
// which never passes through `ListView`. Fixing one face and not the other
// is how this defect survived objectui#3129 for so long.
await mountObjectView({});
await waitFor(() => expect(refusal()).not.toBeNull());
expect(canvas(), 'a timeline canvas was rendered beside the refusal').toBeNull();
expect(screen.queryByText('Spring Launch')).toBeNull();
});

it('CONTROL: the LEGACY `timeline.dateField` alias still renders here too', async () => {
await mountObjectView({ timeline: { dateField: 'start_date' } });
await waitFor(() => expect(canvas()).not.toBeNull());
expect(refusal()).toBeNull();
});
});
22 changes: 12 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,16 +182,18 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
* `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'`
* floors were deleted by objectui#7070; that is what makes `ObjectGantt`'s
* own "Gantt configuration required" screen reachable from this route.
* - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the
* TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and
* `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'`
* — the very literal objectui#3129 retired HERE. `ListView` carries it as a
* stated decision ("`created_at` stays the last resort for a view that
* declares no date axis anywhere"), so the two faces hold contradictory
* DOCUMENTED postures on one literal. objectui#7070 routes that to a single
* ruling instead of settling it per-face. Until it is answered: this note
* describes the timeline axis at THIS face only, and says nothing about the
* other two.
* - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx`
* and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at
* `'created_at'`, the very literal objectui#3129 retired HERE, and
* `ListView` carried it as a stated DECISION ("`created_at` stays the last
* resort for a view that declares no date axis anywhere") — two faces
* holding documented and OPPOSITE postures on one field name. That is what
* objectui#7070 routed to a single ruling instead of settling per-face, and
* the ruling (2026-09-01, 总监批 #28) answered it as house posture:
* 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both
* floors, so all three faces now forward a declared axis or none, and
* `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable
* from every one of them. This note no longer describes only THIS face.
*
* Exported for the regression suite.
*/
Expand Down
30 changes: 27 additions & 3 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2491,9 +2491,33 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...baseProps,
// Nested timeline config (spec-compliant, used by ObjectTimeline)
timeline: Object.keys(resolvedTimeline).length > 0 ? resolvedTimeline : undefined,
// Deprecated top-level props for backward compat. `created_at` stays
// the last resort for a view that declares no date axis anywhere.
startDateField: dateBinding.startDateField || 'created_at',
// Deprecated top-level props for backward compat.
//
// objectui#7070 step ③ — house posture, entered on the maintainer's
// ruling of 2026-09-01 (总监批 #28): 日期轴永不虚构 — a date axis is
// never fabricated. The two lines of prose that used to sit here
// ("`created_at` stays the last resort for a view that declares no
// date axis anywhere") were not an oversight, they stated a decision —
// and that decision is what the ruling explicitly replaced. It was a
// second, de-facto contract held at ONE face, on the very literal
// objectui#3129 retired at the app-shell face, so the product held two
// documented and opposite postures on one field name.
//
// `ObjectTimeline` reads this FLAT prop at the tail of its resolver
// chain, so a floor here answered "the axis is bound" for every view
// and made its refusal screen (objectui#7459, step ① of the same
// ruling) unreachable from this route. Worse, the axis it invented was
// never FETCHED: the `$select` projection is collected from the
// DECLARED `timeline` / `options.timeline` blocks above, never from
// this prop — so an undeclared view rendered a timeline bound to a
// column the query had not requested and bucketed every record into
// "No date". Absent is now absent, and the renderer says which keys to
// declare instead.
//
// ⛔ `titleField` is NOT a date axis and keeps its floor — the same
// display-name rung the gallery and gantt branches carry here, and
// `timelineViewOptions` carries at app-shell.
...(dateBinding.startDateField ? { startDateField: dateBinding.startDateField } : {}),
titleField: dateBinding.titleField || 'name',
...(dateBinding.endDateField ? { endDateField: dateBinding.endDateField } : {}),
...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
Expand Down
Loading
Loading