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
33 changes: 33 additions & 0 deletions .changeset/21464-component-props-navigation-typed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/spec': minor
---

feat(spec)!: `navigation` on an `object-map`, `object-gantt` or `object-tree` page block takes the list view's navigation block instead of any value, and every remaining `z.unknown()` member of `ComponentPropsMap` is enumerated with its recorded reason (#21464)

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-object-map-gantt-tree-navigation-typed -->

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.

**`@objectstack/spec`**

- **`navigation` is typed on three rows.** `ComponentPropsMap['object-map']`, `['object-gantt']` and `['object-tree']` declared `navigation` as `z.unknown()`, although each renderer hands it to the console's shared navigation hook, which reads `navigation.mode` and falls back to `page` when it finds none. Any value passed, and an off-shape one was answered with a silent default: `navigation: 42` and a bare mode string such as `'drawer'` both opened the record page, whatever they named. Each row now takes the list view's `NavigationConfigSchema` by reference, the same block `object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take: `{ mode?, size?, openNewTab?, preventNavigation? }`, with `mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none`.
- **`ObjectMapProps`, `ObjectGanttProps` and `ObjectTreeProps`** (and their `…Parsed` twins) carry `NavigationConfig` on `navigation` instead of `unknown`.
- **No other member changes.** Every other `z.unknown()` member across `ComponentPropsMap` (107 of them) is now listed, with its recorded reason, by a test that fails on a new one until it carries one. The reasons are composition slots, the action blocks' runner-forwarded members, record rows and field values, members of schemas another file owns, a value shown as-is, a deliberately open bag, and a row no renderer draws. The list also holds 28 members a renderer reads with a fixed shape. 27 of them are typed in later changes, and one, `object-kanban`'s `conditionalFormatting`, waits for a ruling, because the console's own kanban fixtures author two rule dialects the list view's schema refuses.

## FROM → TO

| you wrote on an `object-map` / `object-gantt` / `object-tree` | write instead |
|:--|:--|
| `navigation: 'drawer'` | `navigation: { mode: 'drawer' }` |
| `navigation: { mode: 'tab' }` | a mode the hook knows: `page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none` |
| `navigation: { mode: 'drawer', target: '_blank' }` | `navigation: { mode: 'new_window' }`, or `openNewTab: true` beside a `page` mode |

The one-line fix: write `navigation` as the block a list view declares, `{ mode, size?, openNewTab?, preventNavigation? }`. No conversion is registered, because an off-shape value has no rewrite that both keeps what the block shows today (the record page) and honours what the author wrote; the D3 entry `ui-object-map-gantt-tree-navigation-typed` carries that judgment.

## Who is affected, measured

- **objectstack.** Measured on this branch after merging `origin/main` `100c394f6f`, over the 5 files per row that name `object-map` / `object-gantt` / `object-tree` in the examples, `packages/apps`, `@objectstack/platform-objects`, the plugins and services, the spec sources, the documentation and the published skills: no block of the three authors `navigation`. The one file that co-mentions a row and a `navigation:` key writes app navigation arrays, not this member. The control: the same census finds `objectName` in 4 of the 5 files per row.
- **objectui.** Measured at the `.objectui-sha` pin, over the 88 / 98 / 59 files that name `object-map` / `object-gantt` / `object-tree` (the control: `objectName` in 55 / 70 / 40 of them): every authored `navigation` is `{ mode }` with one of the seven modes, some with `size: 'lg'` or `openNewTab`, and each of those parses on all three rows. The non-object values are probes that expect a refusal: `navigation: 'anything'` in objectui's mirror tests, which assert that both faces answer alike, and a `navigation: 'drawer'` under a `@ts-expect-error`. Neither authors anything.
- **Deployed metadata** was not measured.
39 changes: 36 additions & 3 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ Sort field and direction pair
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` |
| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored |
| **navigation** | `any` | optional | Task-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema; renderer default `drawer` |
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Task-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values. The renderer's own default is `{ mode: 'drawer' }` when the key is absent |
| **label** | `string \| Record<string, string>` | optional | Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object's own label |
| **skipWeekends** | `boolean` | optional | Measure duration and auto-schedule math in WORKING days, skipping Saturdays and Sundays |
| **holidays** | `string[]` | optional | Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math |
Expand Down Expand Up @@ -679,6 +679,17 @@ Sort field and direction pair
| **interactions** | `{ move?: boolean; resize?: boolean; progress?: boolean; link?: boolean }` | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow |
| **timeSegments** | `{ dayStart?: string; bands: object[]; showMidnight?: boolean }` | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted |

### Nested Shape: `ObjectGanttProps.navigation`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | |
| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). |
| **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely |
| **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) |
| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. |
| **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. |


---

Expand Down Expand Up @@ -940,7 +951,7 @@ View filter rule
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` |
| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map field config, the author face — the same block `ListViewSchema.map` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored |
| **mapStyle** | `string` | optional | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record |
| **navigation** | `any` | optional | Marker-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema |
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Marker-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values |
| **enableClustering** | `boolean` | optional | Group nearby markers into clusters. Absent, the renderer clusters only above 100 markers |

### Nested Shape: `ObjectMapProps.data[provider='object']`
Expand Down Expand Up @@ -1005,6 +1016,17 @@ Sort field and direction pair
| **center** | `[number, number]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records |
| **style** | `string` | optional | Map style URL — the MapLibre style document the renderer loads in place of the public demo tiles. The component-level `mapStyle` is read FIRST and wins when both are present; this is NOT the inline CSS `style` record a component node carries |

### Nested Shape: `ObjectMapProps.navigation`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | |
| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). |
| **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely |
| **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) |
| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. |
| **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. |


---

Expand Down Expand Up @@ -1164,7 +1186,7 @@ Sort field and direction pair
| **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config |
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted |
| **navigation** | `any` | optional | Row-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema |
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values |

### Nested Shape: `ObjectTreeProps.data[provider='object']`

Expand Down Expand Up @@ -1215,6 +1237,17 @@ View filter rule
| **fields** | `string[]` | optional | Additional fields rendered as flat columns alongside the label |
| **defaultExpandedDepth** | `integer` | optional | Initial expansion depth (0 = roots only; omit = expand all) |

### Nested Shape: `ObjectTreeProps.navigation`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | |
| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). |
| **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely |
| **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) |
| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. |
| **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. |


---

Expand Down
Loading
Loading