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