diff --git a/.changeset/8654-timeline-navigation.md b/.changeset/8654-timeline-navigation.md
new file mode 100644
index 0000000000..f9df6339ad
--- /dev/null
+++ b/.changeset/8654-timeline-navigation.md
@@ -0,0 +1,11 @@
+---
+'@object-ui/plugin-timeline': minor
+---
+
+`navigation` is declared on the `object-timeline` block, by reference to `@objectstack/spec` (objectui#8654). This is the timeline arm of the objectui#8652 maintainer ruling: the spec declares the key on the `object-timeline` element from 17.5.0, for the block standalone, and objectui now mirrors it.
+
+- `ObjectTimelineProps['schema']` declares `navigation` with the spec's `NavigationConfig` type (`ViewNavigationConfig`), the type the grid, kanban and calendar schemas use. `ObjectTimeline` reads the key without a cast. `TimelineSchema` in `@object-ui/types` is unchanged: it describes the presentational rail, which does not read the key.
+- The `object-timeline` and `view:timeline` registrations publish `navigation` in their `inputs`, so the designer offers it.
+- An authored `{ "type": "object-timeline", "properties": { … } }` document was already judged by the spec row: a bad `mode` is refused at `properties.navigation.mode`, and a member the spec does not declare inside the block is refused. That is unchanged and now pinned.
+
+What a click does with each member is unchanged. `drawer`, `modal` and `popover` open the entry's record in that overlay, and `new_window` or `openNewTab: true` opens the record page in a new tab. Three things open nothing on a timeline that no parent view navigates for: an absent key, `page` (also what a block without `mode` resolves to), and `split`. The published input description says so.
diff --git a/content/docs/plugins/plugin-timeline.mdx b/content/docs/plugins/plugin-timeline.mdx
index 8eb0071385..7c2f82c3cf 100644
--- a/content/docs/plugins/plugin-timeline.mdx
+++ b/content/docs/plugins/plugin-timeline.mdx
@@ -177,6 +177,48 @@ writing one is a type error and a validation failure. Write `items` instead of
the vertical rail is always drawn on the left.
+## Object-bound timeline (`object-timeline`)
+
+`object-timeline` fetches records from `objectName` and draws one entry per
+record, from the fields its `timeline` config names. An authored node takes its
+props in the `properties` bag, where `@objectstack/spec`'s
+`ComponentPropsMap['object-timeline']` row declares them:
+
+```json
+{
+ "type": "object-timeline",
+ "properties": {
+ "objectName": "project_task",
+ "timeline": { "startDateField": "start_date", "titleField": "name" },
+ "navigation": { "mode": "drawer" }
+ }
+}
+```
+
+### Entry-click navigation
+
+`navigation` is what an entry click opens — the spec's `NavigationConfig` by
+reference, `ViewNavigationConfig` in `@object-ui/types`, the type
+`ObjectGridSchema.navigation` uses: `mode` (`page`, `drawer`, `modal`, `split`,
+`popover`, `new_window` or `none`) with `size`, `openNewTab` and
+`preventNavigation`.
+
+| Member | On an entry click |
+|--------|-------------------|
+| `mode: 'drawer'` / `'modal'` / `'popover'` | Opens the entry's record in that overlay |
+| `mode: 'new_window'`, or `openNewTab: true` | Opens the record page in a new tab; `openNewTab` outranks the mode |
+| `preventNavigation: true`, or `mode: 'none'` | Opens nothing |
+| `size` | Sets the overlay width (`sm` … `full`, or `auto` for the default) |
+
+A click handler from a parent view outranks the whole key.
+
+
+Write the block with `mode`. With the key absent, or with a block written
+without `mode` (it takes the spec's `page` default), a click opens nothing on a
+timeline no parent view navigates for (objectui#11293). `split` opens nothing on
+this block either, because the timeline gives the split shell no main panel.
+
+
## Variants
### Marker Variants
diff --git a/packages/plugin-timeline/README.md b/packages/plugin-timeline/README.md
index 8882462c0f..11176a59d4 100644
--- a/packages/plugin-timeline/README.md
+++ b/packages/plugin-timeline/README.md
@@ -75,6 +75,37 @@ const schema = {
};
```
+### Object-bound timeline (`object-timeline`)
+
+`object-timeline` fetches records from `objectName` and draws one entry per
+record, from the fields its `timeline` config names. An authored node takes its
+props in the `properties` bag, where `@objectstack/spec`'s
+`ComponentPropsMap['object-timeline']` row declares them:
+
+```json
+{
+ "type": "object-timeline",
+ "properties": {
+ "objectName": "project_task",
+ "timeline": { "startDateField": "start_date", "titleField": "name" },
+ "navigation": { "mode": "drawer" }
+ }
+}
+```
+
+`navigation` is what an entry click opens — the spec's `NavigationConfig` by
+reference, `ViewNavigationConfig` in `@object-ui/types`: `mode` (`page`,
+`drawer`, `modal`, `split`, `popover`, `new_window` or `none`) with `size`,
+`openNewTab` and `preventNavigation`. `drawer`, `modal` and `popover` open the
+entry's record in that overlay, `new_window` and `openNewTab: true` open the
+record page in a new tab, `preventNavigation: true` opens nothing, and `size`
+sets the overlay width. A click handler from a parent view outranks the whole
+key. ⚠️ Write the block with `mode`: with the key absent, or with a block
+written without `mode` (it takes the spec's `page` default), a click opens
+nothing on a timeline no parent view navigates for (objectui#11293). `split`
+opens nothing on this block either, because the timeline gives the split shell
+no main panel.
+
## Links
- 📚 [Documentation](https://www.objectui.org/docs/plugins/plugin-timeline)
diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx
index 22640e491e..7d24315684 100644
--- a/packages/plugin-timeline/src/ObjectTimeline.tsx
+++ b/packages/plugin-timeline/src/ObjectTimeline.tsx
@@ -7,7 +7,7 @@
*/
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
-import type { DataSource, TimelineSchema, ListViewTimelineConfig } from '@object-ui/types';
+import type { DataSource, TimelineSchema, ListViewTimelineConfig, ViewNavigationConfig } from '@object-ui/types';
import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react';
import { NavigationOverlay } from '@object-ui/components';
import { extractRecords, buildExpandFields, convertSortToQueryParams, createFieldColorResolver, recordDisplayValueAt, toDisplayDate } from '@object-ui/core';
@@ -171,6 +171,41 @@ export interface ObjectTimelineProps {
* has always declared on it, and that this renderer now actually reads.
*/
timeline?: ListViewTimelineConfig;
+ /**
+ * Entry-click navigation — what a click on an entry opens (an overlay
+ * mode, the record page in a new tab, or nothing), with the overlay `size`
+ * and the `openNewTab` / `preventNavigation` flags. Handed to
+ * `useNavigationOverlay` below, which the composed `onItemClick` fires.
+ *
+ * DECLARED by objectui#8654, the timeline arm of the objectui#8652
+ * maintainer ruling, verbatim 「B」: the platform half is
+ * `@objectstack/spec` 17.5.0, whose `ComponentPropsMap['object-timeline']`
+ * row declares `navigation: NavigationConfigSchema.optional()` for this
+ * block standalone, with no enclosing view. So this member follows the
+ * installed spec rather than leading it. Before it, the read went through
+ * an `as any` cast, and without the cast it would have compiled only
+ * through `BaseSchema`'s `[key: string]: any`.
+ *
+ * Declared HERE, beside `objectName`, `timeline`, `filter` and `sort`,
+ * rather than on `TimelineSchema` in `@object-ui/types`, and that is the
+ * spec's own split: `TimelineSchema` is the presentational rail, whose
+ * renderer never reads this key, while the spec declares it on the
+ * object-bound `object-timeline` row only. That row's other members live
+ * on this half for the same reason. The zod face of the row is
+ * `ObjectTimelineBlockSchema`'s `properties` bag, which reads the spec row
+ * by reference and so judges this key already.
+ *
+ * Same spec type as `ObjectKanbanSchema.navigation` and
+ * `ObjectCalendarSchema.navigation` — aligned with `@objectstack/spec`
+ * `NavigationConfig` rather than restated, so the vocabulary cannot fork.
+ *
+ * ⚠️ Unlike the board and the calendar, this renderer supplies NO default
+ * for an ABSENT key: the hook then resolves the spec's `page`, and a click
+ * opens nothing on a timeline no parent view navigates for. A parent's
+ * `onRowClick` / `onItemClick` outranks the whole key. The members are
+ * pinned in `__tests__/timelineNavigationMembers-8654.test.tsx`.
+ */
+ navigation?: ViewNavigationConfig;
/**
* Query filter for the object fetch, in any shape `toFilterNode` accepts
* (spec `ViewFilterRule[]`, ObjectQL AST nodes, or a MongoDB-style object).
@@ -750,8 +785,13 @@ export const ObjectTimeline: React.FC = ({
enabled: !!schema.objectName && !!dataSource,
});
+ // `navigation` is a declared member of this component's schema since
+ // objectui#8654 (see `ObjectTimelineProps`), so it is read with no cast.
+ // ⛔ No `?? { mode: 'drawer' }` here: an absent key resolving to the hook's
+ // `page` is the behaviour the published description states and the member
+ // pins hold. Changing it is objectui#11293's question, not a read-site edit.
const navigation = useNavigationOverlay({
- navigation: (schema as any).navigation,
+ navigation: schema.navigation,
objectName: schema.objectName,
onRowClick: onRowClick ?? onItemClick,
});
diff --git a/packages/plugin-timeline/src/__tests__/object-timeline-navigation-8654.test.tsx b/packages/plugin-timeline/src/__tests__/object-timeline-navigation-8654.test.tsx
new file mode 100644
index 0000000000..1887ff268d
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/object-timeline-navigation-8654.test.tsx
@@ -0,0 +1,274 @@
+/**
+ * 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#8654 — `navigation` is declared on `object-timeline`, BY REFERENCE
+ * to the spec: the timeline arm of the objectui#8652 ruling.
+ *
+ * ## The ruling this executes
+ *
+ * Maintainer ruling on objectui#8652, verbatim 「B」: declare `navigation` on
+ * the platform element schema first, then mirror it here. `@objectstack/spec`
+ * 17.5.0 declares it on the `object-timeline` row as well as on the board's and
+ * the calendar's, for the block standalone, with no enclosing view. objectui#8652
+ * mirrored the board and the calendar; this card mirrors the timeline.
+ *
+ * ## Where each face lives, and what this card changed on it
+ *
+ * - The VALIDATOR face is `ObjectTimelineBlockSchema` in `@object-ui/types/zod`,
+ * whose `properties` bag IS the spec row by reference (objectui#10859 batch
+ * 3). It judged `navigation` before this card; the rows below pin the
+ * value-level verdicts that reading gives, ⛔ no edit was made there.
+ * - The READING face is the schema `ObjectTimelineProps` declares. Its
+ * object-bound half (`objectName`, `timeline`, `filter`, `sort`, …) is where
+ * this card declares the member, typed `ViewNavigationConfig`, the spec's
+ * `NavigationConfig`. ⛔ Not on `TimelineSchema`: that is the
+ * presentational rail, its renderer never reads the key, and the spec has
+ * no `timeline` component row to declare it on.
+ * - The DESIGNER face is the registration's `inputs`, on both tags this one
+ * renderer is published under (`object-timeline`, `view:timeline`).
+ *
+ * The member pins (what each member DOES on a click) are in
+ * `timelineNavigationMembers-8654.test.tsx`.
+ */
+import React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { ComponentPropsMap } from '@objectstack/spec/ui';
+import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
+import { safeValidateSchema, StrictAnyComponentSchema } from '@object-ui/types/zod';
+import type { ViewNavigationConfig } from '@object-ui/types';
+import type { ObjectTimelineProps } from '../ObjectTimeline';
+// Module scope, not a hook: this import IS the registration (AGENTS.md's
+// test-discipline section).
+import '../index';
+
+afterEach(cleanup);
+
+const OBJECT = 'campaign';
+const TIMELINE = { startDateField: 'start_date', titleField: 'name' };
+
+/** The slice of a zod `safeParse` result these rows read — no zod internals. */
+type ParseResult = {
+ success: boolean;
+ error?: { issues: Array<{ path: PropertyKey[]; code: string }> };
+ data?: unknown;
+};
+type Parser = { safeParse: (doc: unknown) => ParseResult };
+
+/** Issues as `path|code`, so a red run names what broke. */
+const issuesOf = (result: ParseResult): string[] =>
+ result.success ? [] : (result.error?.issues ?? []).map((i) => `${i.path.map(String).join('.')}|${i.code}`);
+
+/** The installed spec's row, read on every run rather than restated here. */
+const specRow = (): Parser => (ComponentPropsMap as unknown as Record)['object-timeline'];
+
+/** A spec-shaped authored document: the props in the `properties` bag. */
+const authored = (navigation: unknown) => ({
+ type: 'object-timeline',
+ properties: { objectName: OBJECT, timeline: TIMELINE, navigation },
+});
+
+/** Both validator faces this package publishes, each judging the same document. */
+const FACES = [
+ ['safeValidateSchema', (doc: unknown) => safeValidateSchema(doc) as unknown as ParseResult],
+ ['StrictAnyComponentSchema', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc) as unknown as ParseResult],
+] as const;
+
+describe('the installed spec declares `navigation` on `object-timeline` — the premise, read live (objectui#8654)', () => {
+ it('a valid block is accepted, and a bogus key beside it is refused, on the same row', () => {
+ expect(specRow(), 'object-timeline is not in the installed ComponentPropsMap').toBeDefined();
+ expect(issuesOf(specRow().safeParse({ objectName: OBJECT, navigation: { mode: 'drawer' } }))).toEqual([]);
+ expect(issuesOf(specRow().safeParse({ objectName: OBJECT, zzqxNoSuchKey: 1 }))).toEqual(['|unrecognized_keys']);
+ });
+
+ it('the spec refuses a bad `mode` at `navigation.mode`', () => {
+ expect(issuesOf(specRow().safeParse({ navigation: { mode: 'not-a-mode' } }))).toEqual(['navigation.mode|invalid_value']);
+ });
+});
+
+describe.each(FACES)('`%s` judges the authored `properties.navigation` by the spec row (objectui#8654)', (_face, parse) => {
+ it('a valid block parses, and so does every mode the spec declares', () => {
+ for (const mode of ['page', 'drawer', 'modal', 'split', 'popover', 'new_window', 'none'] as const) {
+ expect(issuesOf(parse(authored({ mode }))), mode).toEqual([]);
+ }
+ expect(issuesOf(parse(authored({ mode: 'modal', size: 'lg' })))).toEqual([]);
+ expect(issuesOf(parse(authored({ mode: 'page', openNewTab: true })))).toEqual([]);
+ expect(issuesOf(parse(authored({ mode: 'none', preventNavigation: true })))).toEqual([]);
+ expect(issuesOf(parse(authored({ mode: 'drawer', width: '720px' })))).toEqual([]);
+ });
+
+ it('a bad `mode` is refused at `properties.navigation.mode` — where the spec refuses it', () => {
+ // The lit control is the row above: the documents differ in this one value.
+ expect(issuesOf(parse(authored({ mode: 'not-a-mode' })))).toEqual(['properties.navigation.mode|invalid_value']);
+ });
+
+ it('a bogus member INSIDE the block is refused — the block is the spec\'s strict object', () => {
+ expect(issuesOf(parse(authored({ mode: 'drawer', zzqxNoSuchMember: true })))).toEqual([
+ 'properties.navigation|unrecognized_keys',
+ ]);
+ });
+
+ it('the retired `view` member is refused, as the spec retired it in 17.5.0', () => {
+ expect(issuesOf(parse(authored({ mode: 'page', view: 'summary' })))).not.toEqual([]);
+ expect(issuesOf(specRow().safeParse({ navigation: { mode: 'page', view: 'summary' } }))).not.toEqual([]);
+ });
+
+ it('the parse writes no default into the document', () => {
+ // The spec defaults `mode` to `'page'`; read through the import boundary,
+ // the bag adds nothing, so what reaches the renderer is what was written.
+ const absent = parse({ type: 'object-timeline', properties: { objectName: OBJECT } });
+ expect(absent.success).toBe(true);
+ const absentBag = (absent.data as { properties: Record }).properties;
+ expect(Object.prototype.hasOwnProperty.call(absentBag, 'navigation')).toBe(false);
+ const noMode = parse(authored({ size: 'lg' }));
+ expect(noMode.success).toBe(true);
+ expect((noMode.data as { properties: { navigation: unknown } }).properties.navigation).toEqual({ size: 'lg' });
+ });
+});
+
+/* ── Type-level: the reading face declares the member, by the spec's type ─── */
+
+type Equal =
+ (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false;
+type Expect = T;
+
+/**
+ * `Equal`, not `extends`: an undeclared member reads `any` through
+ * `BaseSchema`'s index signature, and `any` passes a one-way check on both
+ * sides — which is precisely the before-state this card removes.
+ */
+export type assertionTimelineNavigationIsTheSpecType =
+ Expect>;
+/** The helper can FAIL — synthetic control. */
+export type assertionEqualCanFail = Expect, false>>;
+
+/**
+ * The reading face's own failing instrument: with the member declared,
+ * `'not-a-mode'` is not a `NavigationMode` and the expected error exists;
+ * delete the member and the index signature admits the value, the error
+ * disappears, and `tsc` reddens with TS2578 (an unused `@ts-expect-error`).
+ */
+export const BAD_TIMELINE_MODE_IS_REFUSED_AT_COMPILE_TIME: ObjectTimelineProps['schema'] = {
+ type: 'timeline',
+ objectName: OBJECT,
+ // @ts-expect-error — `mode` is the spec's closed `NavigationMode` vocabulary.
+ navigation: { mode: 'not-a-mode' },
+};
+
+/** The valid block annotates with no cast. */
+export const TIMELINE_WITH_NAVIGATION: ObjectTimelineProps['schema'] = {
+ type: 'timeline',
+ objectName: OBJECT,
+ navigation: { mode: 'modal', size: 'lg' },
+};
+
+/* ── The designer face: both registrations publish it ───────────────────── */
+
+/** The two tags this one renderer is published under. */
+const TIMELINE_TAGS = [
+ { tagLabel: 'object-timeline', type: 'object-timeline', namespace: 'plugin-timeline' },
+ { tagLabel: 'view:timeline', type: 'timeline', namespace: 'view' },
+] as const;
+
+const declaredInputs = (type: string, namespace?: string): Array<{ name: string; type: string }> =>
+ ((ComponentRegistry.getConfig(type, namespace) as { inputs?: Array<{ name: string; type: string }> } | undefined)?.inputs ?? []);
+
+/** A manifest built from the live registry, the way `gen-manifest.ts` builds it. */
+const liveManifest = () =>
+ manifestFromConfigs(
+ ComponentRegistry.getKnownTypes().map((type) => {
+ const meta = ComponentRegistry.getMeta(type);
+ return { type, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs };
+ }) as unknown as Parameters[0],
+ );
+
+/** Messages of one diagnostic code that a one-node document draws for `props`. */
+const diagnosticsOf = (type: string, props: Record, code: string): string[] =>
+ validateTree({ type, objectName: OBJECT, ...props } as never, liveManifest())
+ .diagnostics.filter((d) => d.code === code)
+ .map((d) => d.message);
+
+describe.each(TIMELINE_TAGS)('$tagLabel publishes `navigation` (objectui#8654)', ({ type, namespace }) => {
+ it('the registration declares it as an object', () => {
+ const inputs = declaredInputs(type, namespace);
+ const names = inputs.map((i) => i.name);
+ // Non-vacuity: an empty read (wrong type/namespace) fails both lines.
+ expect(names, `${type} inputs`).toContain('objectName');
+ expect(names, `${type} inputs`).toContain('navigation');
+ expect(inputs.find((i) => i.name === 'navigation')?.type).toBe('object');
+ });
+
+ it('the html tier accepts the block, and refuses a non-object value by kind', () => {
+ expect(diagnosticsOf(type, { navigation: { mode: 'drawer' } }, 'unknown-prop')).toEqual([]);
+ expect(diagnosticsOf(type, { navigation: { mode: 'drawer' } }, 'type-mismatch')).toEqual([]);
+ expect(diagnosticsOf(type, { navigation: 'drawer' }, 'type-mismatch')).toHaveLength(1);
+ });
+
+ it('control: a genuinely unknown prop is still reported', () => {
+ expect(diagnosticsOf(type, { zzqxNoSuchProp: { mode: 'drawer' } }, 'unknown-prop')).toEqual([
+ `<${type}> has no prop "zzqxNoSuchProp"`,
+ ]);
+ });
+});
+
+/* ── End to end: the authored key reaches the click, on both tags ──────── */
+
+const RECORDS = [{ id: '1', name: 'Launch', start_date: '2024-03-01', owner: 'Grace Hopper' }];
+
+function makeDataSource() {
+ return {
+ find: vi.fn().mockResolvedValue({ data: RECORDS }),
+ findOne: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ getObjectSchema: vi.fn().mockResolvedValue({
+ name: OBJECT,
+ fields: { name: { type: 'text' }, start_date: { type: 'date' }, owner: { type: 'text' } },
+ }),
+ };
+}
+
+/** Mount one node through the real `SchemaRenderer` and the real registry, then click its entry. */
+async function mountAndClick(node: Record) {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(await screen.findByText('Launch'));
+}
+
+const NODES = [
+ {
+ label: 'object-timeline, the spec-shaped `properties` bag',
+ withNav: authored({ mode: 'drawer' }),
+ without: { type: 'object-timeline', properties: { objectName: OBJECT, timeline: TIMELINE } },
+ },
+ {
+ label: 'view:timeline (the bare `timeline` key), flat',
+ withNav: { type: 'timeline', objectName: OBJECT, timeline: TIMELINE, navigation: { mode: 'drawer' } },
+ without: { type: 'timeline', objectName: OBJECT, timeline: TIMELINE },
+ },
+] as const;
+
+describe.each(NODES)('$label — the authored block decides the click (objectui#8654)', ({ withNav, without }) => {
+ it('`navigation: { mode: "drawer" }` opens the entry\'s record in a drawer', async () => {
+ await mountAndClick(withNav);
+ await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
+ expect(screen.getByText('Grace Hopper')).toBeTruthy();
+ });
+
+ it('control: the same node without the key opens nothing', async () => {
+ await mountAndClick(without);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
+ expect(screen.queryByText('Grace Hopper')).toBeNull();
+ });
+});
diff --git a/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx b/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx
new file mode 100644
index 0000000000..ecd991d4f3
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/timelineNavigationMembers-8654.test.tsx
@@ -0,0 +1,250 @@
+/**
+ * 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.
+ */
+
+/**
+ * The MEMBERS of `object-timeline.navigation`, pinned at what the TIMELINE
+ * reads (objectui#8654).
+ *
+ * objectui#8654 declares `navigation` on this block — on the schema
+ * `ObjectTimelineProps` reads and in the registration `inputs` of both tags
+ * (`object-timeline` and `view:timeline`) — as the timeline arm of the
+ * maintainer's objectui#8652 ruling 「B」, whose platform half is
+ * `@objectstack/spec` 17.5.0 declaring the key on this element. An
+ * `object`-armed input owes a member pin (objectui#8212), and this file is it:
+ * every member is driven through a real entry click on the real
+ * `ObjectTimeline`, with the real `useNavigationOverlay` and the real overlay
+ * shell, and each row asserts what the click DID. Modelled on the board's
+ * `kanbanNavigationMembers-8652`.
+ *
+ * ⚠️ The gate that will read this file does not read it yet:
+ * `registry-inputs-spec-parity.test.ts` books `object-timeline` as UNJUDGED,
+ * owed to objectui#11168 slice 3, which loads the lazily registered block. Its
+ * member-pin check refuses an entry for a block it does not judge, so the
+ * `MEMBER_PINS` line pointing here is slice 3's to add.
+ *
+ * What the rows establish:
+ *
+ * - **`drawer`, `modal` and `popover` open the entry's record** in that
+ * overlay. `drawer` is the LIT CONTROL every "nothing happened" row below
+ * leans on.
+ * - **`none` and `preventNavigation` open nothing**, and the flag outranks an
+ * overlay mode.
+ * - **`new_window` and `openNewTab` open the record page in a new tab**,
+ * built from the block's own object name; `openNewTab` outranks `page`.
+ * - **`size` and `width` are ONE decision**: `width` (deprecated) wins over a
+ * `size` beside it, a bucket name resolves through the size table, and
+ * `auto` lands where a drawer with no size does.
+ * - **A parent view's click handler outranks the whole key.**
+ *
+ * ⚠️ And the values the timeline does NOT honour on its own, pinned because
+ * the registration's description states them and a description is a claim
+ * about this renderer. Unlike the board and the calendar, this renderer
+ * supplies no drawer default, so an ABSENT key opens nothing; `page`, and a
+ * block written without `mode` (the spec's `page` default), open nothing on a
+ * timeline no parent view navigates for (objectui#11293); and `split` opens
+ * nothing, because the timeline hands the split shell no main panel. A
+ * timeline that learns any of these reddens its row, and the fix is to rewrite
+ * that description with it, ⛔ not to relax the row.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import {
+ assertNoOtherNetworkEscape,
+ installRecordSecurityExplainDouble,
+} from '@object-ui/test-support';
+
+import { ObjectTimeline, type ObjectTimelineProps } from '../ObjectTimeline';
+
+/** The block's own object name, which the new-tab URL is built from. */
+const OBJECT = 'duly_event';
+
+/**
+ * One record. `owner` is drawn by no part of the rail (which shows the title
+ * and the date), only by the overlay that lists the clicked record's fields —
+ * so its text on screen means "the entry's record opened".
+ */
+const RECORDS = [{ id: '1', name: 'Kickoff', start_date: '2024-01-05', owner: 'Ada Lovelace' }];
+const OPENED_RECORD_TEXT = 'Ada Lovelace';
+
+type Navigation = ObjectTimelineProps['schema']['navigation'];
+
+/**
+ * Mount one timeline with an authored `navigation` (or none), click its one
+ * entry, and hand back the `window.open` spy. The schema is typed at the
+ * published props: `navigation` is a declared member now, so no cast is needed.
+ */
+async function clickEntry(
+ navigation: Navigation,
+ props: Pick = {},
+) {
+ const open = vi.fn();
+ vi.stubGlobal('open', open);
+ const schema = {
+ type: 'timeline',
+ objectName: OBJECT,
+ timeline: { startDateField: 'start_date', titleField: 'name' },
+ ...(navigation ? { navigation } : {}),
+ } satisfies ObjectTimelineProps['schema'];
+ // `data` is read off the PROPS (pre-fetched records), so the timeline issues
+ // no query and the click is the only thing under test.
+ const extra = { data: RECORDS } as Record;
+ render();
+ fireEvent.click(await screen.findByText('Kickoff'));
+ return { open };
+}
+
+/** Give a click every chance to have done something before asserting it did not. */
+const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
+
+const dialog = () => document.querySelector('[role="dialog"]') as HTMLElement | null;
+const openedRecord = () => screen.queryByText(OPENED_RECORD_TEXT);
+
+/** Nothing opened: no overlay, no record, no tab. */
+async function expectNothingOpened(open: ReturnType, why: string) {
+ await settle();
+ expect(dialog(), `${why}: an overlay opened`).toBeNull();
+ expect(openedRecord(), `${why}: the record was drawn`).toBeNull();
+ expect(open, `${why}: a tab was opened`).not.toHaveBeenCalled();
+}
+
+/**
+ * The resolved overlay width, read off the `--ov-w` custom property the shared
+ * shell publishes (it caps the panel with `sm:max-w-[var(--ov-w,42rem)]`), so
+ * the custom property IS the resolved value.
+ */
+function panelWidth(): string {
+ const panel = dialog();
+ expect(panel, 'overlay panel').not.toBeNull();
+ return panel!.style.getPropertyValue('--ov-w').trim();
+}
+
+/** What the shell makes of an authored width: the authored value as a FLOOR. */
+const withFloor = (css: string) => `max(${css}, min(60vw, 880px))`;
+
+beforeEach(() => {
+ // A drag-resized width persisted in localStorage would mask every width row.
+ try { window.localStorage.clear(); } catch { /* private mode */ }
+ installRecordSecurityExplainDouble(vi);
+});
+
+afterEach(() => {
+ assertNoOtherNetworkEscape(expect);
+ // Unmount BEFORE restoring the real globals (objectui#7439).
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('object-timeline `navigation` members decide the entry click (objectui#8654)', () => {
+ it('LIT CONTROL: `mode: "drawer"` opens the entry\'s record in a drawer', async () => {
+ // First, because every "nothing happened" row below would be vacuous
+ // against a timeline whose entries are not clickable in this harness.
+ const { open } = await clickEntry({ mode: 'drawer' });
+ await waitFor(() => expect(dialog()).not.toBeNull());
+ expect(openedRecord()).not.toBeNull();
+ expect(open).not.toHaveBeenCalled();
+ });
+
+ it.each(['modal', 'popover'] as const)('`mode: "%s"` opens the entry\'s record in that overlay', async (mode) => {
+ const { open } = await clickEntry({ mode });
+ await waitFor(() => expect(dialog()).not.toBeNull());
+ expect(openedRecord()).not.toBeNull();
+ expect(open).not.toHaveBeenCalled();
+ });
+
+ it('`mode: "none"` opens nothing', async () => {
+ const { open } = await clickEntry({ mode: 'none' });
+ await expectNothingOpened(open, 'none');
+ });
+
+ it('`preventNavigation: true` OUTRANKS an overlay mode', async () => {
+ // The same block without the flag is the LIT CONTROL above.
+ const { open } = await clickEntry({ mode: 'drawer', preventNavigation: true });
+ await expectNothingOpened(open, 'preventNavigation');
+ });
+
+ it('`mode: "new_window"` opens the record page in a new tab, and no overlay', async () => {
+ const { open } = await clickEntry({ mode: 'new_window' });
+ await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
+ expect(open).toHaveBeenCalledWith(`/${OBJECT}/record/1`, '_blank');
+ expect(dialog()).toBeNull();
+ });
+
+ it('`openNewTab: true` OUTRANKS `mode: "page"`', async () => {
+ const { open } = await clickEntry({ mode: 'page', openNewTab: true });
+ await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
+ expect(open).toHaveBeenCalledWith(`/${OBJECT}/record/1`, '_blank');
+ });
+
+ it('…and an overlay mode too: `openNewTab: true` beside `drawer` opens a tab and no drawer', async () => {
+ const { open } = await clickEntry({ mode: 'drawer', openNewTab: true });
+ await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
+ expect(open).toHaveBeenCalledWith(`/${OBJECT}/record/1`, '_blank');
+ expect(dialog()).toBeNull();
+ });
+
+ it('`size` reaches the overlay through the bucket table', async () => {
+ await clickEntry({ mode: 'drawer', size: 'lg' });
+ await waitFor(() => expect(dialog()).not.toBeNull());
+ expect(panelWidth()).toBe(withFloor('min(92vw, 960px)'));
+ });
+
+ it('a deprecated `width` WINS over `size` authored beside it', async () => {
+ await clickEntry({ mode: 'drawer', size: 'sm', width: '720px' });
+ await waitFor(() => expect(dialog()).not.toBeNull());
+ expect(panelWidth()).toBe(withFloor('720px'));
+ });
+
+ it('`size: "auto"` resolves to NEITHER member and lands where a drawer with no size does', async () => {
+ await clickEntry({ mode: 'drawer' });
+ await waitFor(() => expect(dialog()).not.toBeNull());
+ const unsized = panelWidth();
+ cleanup();
+ await clickEntry({ mode: 'drawer', size: 'auto' });
+ await waitFor(() => expect(dialog()).not.toBeNull());
+ expect(panelWidth()).toBe(unsized);
+ // Non-vacuity: the unsized width is not the `lg` bucket's.
+ expect(unsized).not.toBe(withFloor('min(92vw, 960px)'));
+ });
+
+ it('a parent view\'s click handler OUTRANKS the whole key, overlay mode included', async () => {
+ // `ObjectTimeline` hands `onRowClick ?? onItemClick` to the hook, which
+ // calls it and returns — the timeline's own overlay never opens.
+ const onRowClick = vi.fn();
+ const { open } = await clickEntry({ mode: 'drawer' }, { onRowClick });
+ await waitFor(() => expect(onRowClick).toHaveBeenCalledTimes(1));
+ expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: '1' });
+ await expectNothingOpened(open, 'parent handler');
+ });
+});
+
+describe('the registration description\'s warnings are TRUE (objectui#8654)', () => {
+ it('with the key ABSENT a click opens nothing — this renderer supplies no drawer default', async () => {
+ const { open } = await clickEntry(undefined);
+ await expectNothingOpened(open, 'absent key — rewrite the `navigation` description');
+ });
+
+ it('`mode: "page"` opens NOTHING on a timeline no parent view navigates for', async () => {
+ const { open } = await clickEntry({ mode: 'page' });
+ await expectNothingOpened(open, 'page — rewrite the `navigation` description');
+ });
+
+ it('a block WITHOUT `mode` resolves to `page` — so it opens nothing either', async () => {
+ // The trap the description warns about: `size` alone, written to widen an
+ // overlay, silences the click, because the absent `mode` is the spec's
+ // `page`. Compare the LIT CONTROL.
+ const { open } = await clickEntry({ size: 'lg' });
+ await expectNothingOpened(open, 'mode-less block — rewrite the `navigation` description');
+ });
+
+ it('`mode: "split"` opens NOTHING — the timeline hands the split shell no main panel', async () => {
+ const { open } = await clickEntry({ mode: 'split' });
+ await expectNothingOpened(open, 'split — rewrite the `navigation` description');
+ });
+});
diff --git a/packages/plugin-timeline/src/index.tsx b/packages/plugin-timeline/src/index.tsx
index 2ff760dd64..087af17934 100644
--- a/packages/plugin-timeline/src/index.tsx
+++ b/packages/plugin-timeline/src/index.tsx
@@ -366,6 +366,25 @@ export const ObjectTimelineRenderer: React.FC = elementDataSourceBlock(({ s
const TIMELINE_FILTER_DESCRIPTION = 'Base query filter in the rule-array form `[{ field, operator, value }, ...]`, narrowing the records the timeline fetches. Context tokens such as `{current_user_id}` are resolved first, then the filter is lowered to `$filter` on the query. Authored `items` or `data` suppress that query, so the key then narrows nothing. The MongoDB-style record form is not this key’s shape.';
const TIMELINE_SORT_DESCRIPTION = 'Entry order in `[{ field, order }]` form, ordering the records the timeline fetches. Lowered to `$orderby` on the same query.';
+// ## `navigation` — objectui#8654 (the timeline arm of objectui#8652's ruling 「B」)
+//
+// `@objectstack/spec` 17.5.0 declares it on the `object-timeline` row, for this
+// block standalone — the platform half of the ruling. This row is the objectui
+// half, together with the `navigation` member of `ObjectTimelineProps`'s schema
+// in `./ObjectTimeline`. It is declared on both tags for the reason `filter` and
+// `sort` are above: `view:timeline` is the same renderer under a second tag,
+// the precedent the calendar's shared input list follows for this key.
+//
+// The description states what `ObjectTimeline` does with each member, measured
+// on the real component, and it differs from the board's and the calendar's in
+// two places: this renderer supplies no drawer default, so an ABSENT key opens
+// nothing, and it hands the split shell no main panel, so `split` opens nothing
+// either. `page`, and a block without `mode`, open nothing as on the siblings
+// (objectui#11293). Members pinned in
+// `__tests__/timelineNavigationMembers-8654.test.tsx`; a renderer that changes
+// any of these reddens those rows, and the fix is to rewrite this description.
+const TIMELINE_NAVIGATION_DESCRIPTION = 'What an entry click opens — the `{ mode, size, openNewTab, preventNavigation }` block a list view declares. Write it with `mode`: with the key ABSENT, or with a block that omits `mode` (it takes the spec’s `page` default), a click opens nothing on a timeline no parent view navigates for. `drawer`, `modal` and `popover` open the entry’s record in that overlay; `new_window` opens the record page in a new tab; `page`, `none` and `split` open nothing here, because the timeline hands the split shell no main panel. `openNewTab: true` opens the record page in a new tab and outranks the mode, `preventNavigation: true` opens nothing, and `size` sets the overlay width. A click handler from a parent view outranks the whole key.';
+
ComponentRegistry.register('object-timeline', ObjectTimelineRenderer, {
namespace: 'plugin-timeline',
label: 'Object Timeline',
@@ -375,6 +394,7 @@ ComponentRegistry.register('object-timeline', ObjectTimelineRenderer, {
{ name: 'variant', type: 'enum', enum: ['vertical', 'horizontal', 'gantt'] },
{ name: 'filter', type: 'array', description: TIMELINE_FILTER_DESCRIPTION },
{ name: 'sort', type: 'array', description: TIMELINE_SORT_DESCRIPTION },
+ { name: 'navigation', type: 'object', description: TIMELINE_NAVIGATION_DESCRIPTION },
]
});
@@ -387,5 +407,6 @@ ComponentRegistry.register('timeline', ObjectTimelineRenderer, {
{ name: 'variant', type: 'enum', enum: ['vertical', 'horizontal', 'gantt'] },
{ name: 'filter', type: 'array', description: TIMELINE_FILTER_DESCRIPTION },
{ name: 'sort', type: 'array', description: TIMELINE_SORT_DESCRIPTION },
+ { name: 'navigation', type: 'object', description: TIMELINE_NAVIGATION_DESCRIPTION },
]
});