diff --git a/.changeset/19049-nav-item-label-optional-inherited.md b/.changeset/19049-nav-item-label-optional-inherited.md new file mode 100644 index 00000000000..8ce88e53919 --- /dev/null +++ b/.changeset/19049-nav-item-label-optional-inherited.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +spec(ui): a navigation entry may omit `label` — it then inherits its target's CURRENT label at render time (#19049) + +Clause-②: yes (widening) + +`BaseNavItemSchema.label` is `.optional()`. An `app.navigation` entry written without a `label` now parses, and the semantic it parses into is declared on the key itself: **absent means the entry inherits, at render time, the current label of whatever it opens** — the view's label when it names a view and that view is labelled, else the object's / dashboard's label. A label the author *did* write renders verbatim and is never overwritten. + +This executes the maintainer's cloud#2021 ruling (「2021 可以接受有些修改刷新才生效」) as letter **A** on objectui#9868: sync by render-time inheritance, no stored state. The spec moves first because the console reads its navigation contract from here — until now an unnamed entry was not *representable*, so the promise "an unnamed entry shows its target's name" had nowhere to be declared. + +- **Accept-set widening only, on eight branches at once.** `BaseNavItemSchema` is spread (`...BaseNavItemSchema.shape`) into the `object`, `dashboard`, `page`, `url`, `report`, `action`, `component` and `group` nav-item declarations, so the one-line relaxation reaches all eight. The ninth branch, `separator`, spreads nothing and has never carried a `label`. Nothing that parsed before stops parsing: a present `label` is accepted exactly as before, and every other key on the item is untouched. +- **Nothing is stored for the absent case.** There is no new member and no `inherited` flag — the parse adds no key the author did not write. That is the whole point of resolving at render: a target renamed after the entry was authored shows its new name on the next render, where a label materialised at authoring time would be a stale snapshot. Consumers must resolve an absent `label` at render, not at ingest. +- **The rule this relaxes still holds.** *Every real destination must have identity and text* — identity is the target, text is inherited at render. That sentence is recorded in the key's `describe`, so it ships to the reference page and to any tool reading the JSON Schema. +- **The three sibling `label` declarations in this file are unchanged and still required**: `NavigationArea.label`, `AppContextSelector.label` and `App.label`. Each names a container the author is creating rather than a target it could inherit from, so there is nothing for an absent label to resolve against. The ruling covers navigation entries only. + +Downstream, in order: objectui#9868 relaxes its own `packages/types` validator to match, resolves the absent label in the nav renderer, and stops writing `label || pageName` for an unnamed entry; then cloud#2021 stops materialising an inherited label in `apply_blueprint`. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index b083c1c39c7..0d105645747 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -92,7 +92,7 @@ const result = AppDefinitionResponseSchema.parse(data); | **isDefault** | `boolean` | optional (default: `false`) | Is default app | | **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead (navigation only — never an access gate) | | **_unpublished** | `boolean` | optional | Machine-managed publish gate (ADR-0045 §3) — true = unpublished, externally unobservable. Written by AI materialization, cleared by publish-drafts. Never authored. | -| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | +| **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | | **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index eef23c9ff69..c79c4cc219a 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -624,7 +624,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | | **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level. Naming a group the target app does not declare is not refused: the items are appended at the app top level anyway and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by `os build` and `os validate` at compile time. | | **priority** | `integer` | optional (default: `200`) | Merge priority within the target group — lower applied first (matches object extender priority) | -| **items** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | +| **items** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | ### Nested Shape: `PackageInstallBody[option 2].engine` diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 5884e3215e7..d0a10138d15 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -107,7 +107,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | | **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level. Naming a group the target app does not declare is not refused: the items are appended at the app top level anyway and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by `os build` and `os validate` at compile time. | | **priority** | `integer` | optional (default: `200`) | Merge priority within the target group — lower applied first (matches object extender priority) | -| **items** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | +| **items** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | ### Nested Shape: `Manifest.engine` diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 7ed82333c35..c0fce9e72cd 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -28,7 +28,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -66,7 +66,7 @@ const result = ActionNavItemSchema.parse(data); | **isDefault** | `boolean` | optional (default: `false`) | Is default app | | **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead (navigation only — never an access gate) | | **_unpublished** | `boolean` | optional | Machine-managed publish gate (ADR-0045 §3) — true = unpublished, externally unobservable. Written by AI materialization, cleared by publish-drafts. Never authored. | -| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | +| **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | | **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -101,7 +101,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -117,14 +117,14 @@ const result = ActionNavItemSchema.parse(data); | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | | **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | -| **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | +| **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | ### Nested Shape: `App.navigation[number][type='dashboard']` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -141,7 +141,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -159,7 +159,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -177,7 +177,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -194,7 +194,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -211,7 +211,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -237,7 +237,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -248,7 +248,7 @@ const result = ActionNavItemSchema.parse(data); | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | | **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | -| **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Child navigation items | +| **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Child navigation items | ### Nested Shape: `App.areas[number]` @@ -258,7 +258,7 @@ const result = ActionNavItemSchema.parse(data); | **label** | `string \| Record` | ✅ | Area display label | | **icon** | `string` | optional | Area icon name | | **description** | `string \| Record` | optional | Area description | -| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items within this area | +| **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items within this area | ### Nested Shape: `App.contextSelectors[number]` @@ -328,7 +328,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -351,7 +351,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -373,7 +373,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -398,14 +398,14 @@ const result = ActionNavItemSchema.parse(data); | **label** | `string \| Record` | ✅ | Area display label | | **icon** | `string` | optional | Area icon name | | **description** | `string \| Record` | optional | Area description | -| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items within this area | +| **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items within this area | ### Nested Shape: `NavigationArea.navigation[number][type='object']` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -421,14 +421,14 @@ const result = ActionNavItemSchema.parse(data); | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | | **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | -| **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | +| **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | ### Nested Shape: `NavigationArea.navigation[number][type='dashboard']` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -445,7 +445,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -463,7 +463,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -481,7 +481,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -498,7 +498,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -515,7 +515,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -541,7 +541,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -552,7 +552,7 @@ const result = ActionNavItemSchema.parse(data); | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | | **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | -| **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Child navigation items | +| **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Child navigation items | --- @@ -568,14 +568,14 @@ A navigation contribution: a package injecting nav items into an app it does not | **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | | **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level. Naming a group the target app does not declare is not refused: the items are appended at the app top level anyway and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by `os build` and `os validate` at compile time. | | **priority** | `integer` | optional (default: `200`) | Merge priority within the target group — lower applied first (matches object extender priority) | -| **items** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | +| **items** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | ### Nested Shape: `NavigationContribution.items[number][type='object']` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -591,14 +591,14 @@ A navigation contribution: a package injecting nav items into an app it does not | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | | **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | -| **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | +| **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | ### Nested Shape: `NavigationContribution.items[number][type='dashboard']` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -615,7 +615,7 @@ A navigation contribution: a package injecting nav items into an app it does not | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -633,7 +633,7 @@ A navigation contribution: a package injecting nav items into an app it does not | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -651,7 +651,7 @@ A navigation contribution: a package injecting nav items into an app it does not | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -668,7 +668,7 @@ A navigation contribution: a package injecting nav items into an app it does not | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -685,7 +685,7 @@ A navigation contribution: a package injecting nav items into an app it does not | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -711,7 +711,7 @@ A navigation contribution: a package injecting nav items into an app it does not | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -722,7 +722,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | | **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | -| **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Child navigation items | +| **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Child navigation items | --- @@ -742,7 +742,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -771,7 +771,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -794,7 +794,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -818,7 +818,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -842,7 +842,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -865,7 +865,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -895,7 +895,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -933,7 +933,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -958,7 +958,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -985,7 +985,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -1008,7 +1008,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | @@ -1030,7 +1030,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique identifier for this navigation item (lowercase snake_case) | -| **label** | `string \| Record` | ✅ | Display proper label | +| **label** | `string \| Record` | optional | Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case. | | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | diff --git a/packages/spec/api-surface-declarations/ui.txt b/packages/spec/api-surface-declarations/ui.txt index 1b6f1460f7d..5b9bd1de254 100644 --- a/packages/spec/api-surface-declarations/ui.txt +++ b/packages/spec/api-surface-declarations/ui.txt @@ -230,7 +230,7 @@ declare const ActionNavItemSchema: z.ZodObject<{ params: z.ZodOptional>; }, z.core.$strict>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -242,7 +242,7 @@ declare const ActionNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -2761,7 +2761,7 @@ declare const ComponentNavItemSchema: z.ZodObject<{ componentRef: z.ZodString; params: z.ZodOptional>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -2773,7 +2773,7 @@ declare const ComponentNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -5358,7 +5358,7 @@ declare const DashboardNavItemSchema: z.ZodObject<{ type: z.ZodLiteral<"dashboard">; dashboardName: z.ZodString; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -5370,7 +5370,7 @@ declare const DashboardNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -8764,7 +8764,7 @@ declare const GroupNavItemSchema: z.ZodObject<{ type: z.ZodLiteral<"group">; expanded: z.ZodDefault; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -8776,7 +8776,7 @@ declare const GroupNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -12225,7 +12225,7 @@ declare const ObjectNavItemSchema: z.ZodObject<{ filters: z.ZodOptional>; runAction: z.ZodOptional; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -12237,7 +12237,7 @@ declare const ObjectNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -12924,7 +12924,7 @@ declare const PageNavItemSchema: z.ZodObject<{ pageName: z.ZodString; params: z.ZodOptional>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -12936,7 +12936,7 @@ declare const PageNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -19230,7 +19230,7 @@ declare const ReportNavItemSchema: z.ZodObject<{ type: z.ZodLiteral<"report">; reportName: z.ZodString; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -19242,7 +19242,7 @@ declare const ReportNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; @@ -19683,7 +19683,7 @@ declare const UrlNavItemSchema: z.ZodObject<{ _blank: "_blank"; }>>; id: z.ZodString; - label: z.ZodUnion & { + label: z.ZodOptional & { key?: never; defaultValue?: never; }, Record & { @@ -19695,7 +19695,7 @@ declare const UrlNavItemSchema: z.ZodObject<{ }, Record & { key?: never; defaultValue?: never; - }>>]>; + }>>]>>; icon: z.ZodOptional; order: z.ZodOptional; badge: z.ZodOptional>; diff --git a/packages/spec/src/ui/app-nav-label-optional-inherited.pin.test.ts b/packages/spec/src/ui/app-nav-label-optional-inherited.pin.test.ts new file mode 100644 index 00000000000..29e10cc0648 --- /dev/null +++ b/packages/spec/src/ui/app-nav-label-optional-inherited.pin.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A navigation entry's `label` is OPTIONAL, and its absence is INHERITANCE — + * not a hole and not a stored value. + * + * ## The two directions, and why each needs its own pin + * + * The maintainer's ruling (cloud#2021, executed as objectui#9868 letter A) + * bought exactly one thing: an `app.navigation` entry may omit `label`, and the + * shell then renders the CURRENT label of whatever the entry opens — the view's + * label when it names a labelled view, else the object's / dashboard's label. + * Nothing is stored for that case: no `inherited` flag, no materialised copy. + * That is what makes a renamed target show its new name on the next render + * instead of a snapshot taken when the entry was authored. + * + * Both halves fail silently if only one is pinned: + * + * - **ABSENT parses.** A `label` that drifts back to required does not look + * like a spec regression downstream; it looks like objectui's nav writer + * producing invalid metadata, one repo over, after the relaxation it was + * sequenced behind has already shipped. + * - **PRESENT is verbatim.** The inheritance is a RENDER-TIME resolution, so + * the tempting implementation — fill the label in during parse — would keep + * direction one green while destroying the ruling: an entry whose target is + * later renamed would then be frozen at whatever the parse wrote, and an + * author's own wording would be overwritten by their target's. This half + * asserts the parse ADDS nothing: no key appears that the author did not + * write, and a label the author did write comes back byte-for-byte. + * + * ## Why the three sibling `label` sites are pinned here too + * + * `app.zod.ts` carries four `label: I18nLabelSchema` declarations. The ruling + * covers ONE of them — the nav-item base, which is spread into eight branch + * declarations, so relaxing it there moves all eight at once. The Area, the + * context-selector Dropdown and the App label are a different surface and were + * never ruled on: each names a container the author is creating, not a target + * it could inherit from, so there is nothing for an absent label to resolve + * against. They are asserted here because "make the nav label optional" is an + * edit one character away from "make every label in this file optional", and + * that over-reach would leave every other gate in the repo green. + */ + +import { describe, it, expect } from 'vitest'; +import { + NavigationItemSchema, + ObjectNavItemSchema, + DashboardNavItemSchema, + PageNavItemSchema, + UrlNavItemSchema, + ReportNavItemSchema, + ActionNavItemSchema, + ComponentNavItemSchema, + GroupNavItemSchema, + NavigationAreaSchema, + AppContextSelectorSchema, + AppSchema, +} from './app.zod'; + +/** + * The eight branch declarations that spread `...BaseNavItemSchema.shape`, with + * the rest of the payload each one needs to be otherwise-valid. The ninth nav + * branch, `separator`, spreads nothing and has never had a `label` — it is + * absent from this table on purpose, and its own guidance table already + * prescribes `group` for a titled divider. + */ +const SPREAD_BRANCHES = [ + ['object', ObjectNavItemSchema, { id: 'nav_a', type: 'object', objectName: 'sys_user' }], + ['dashboard', DashboardNavItemSchema, { id: 'nav_b', type: 'dashboard', dashboardName: 'sales' }], + ['page', PageNavItemSchema, { id: 'nav_c', type: 'page', pageName: 'welcome' }], + ['url', UrlNavItemSchema, { id: 'nav_d', type: 'url', url: 'https://example.com' }], + ['report', ReportNavItemSchema, { id: 'nav_e', type: 'report', reportName: 'quarterly' }], + ['action', ActionNavItemSchema, { id: 'nav_f', type: 'action', actionDef: { actionName: 'do_it' } }], + ['component', ComponentNavItemSchema, { id: 'nav_g', type: 'component', componentRef: 'metadata:resource' }], + ['group', GroupNavItemSchema, { id: 'nav_h', type: 'group' }], +] as const; + +describe('nav item label — ABSENT parses and inherits at render time', () => { + it('accepts an object nav item with no label through the union', () => { + const result = NavigationItemSchema.safeParse({ + id: 'nav_users', + type: 'object', + objectName: 'sys_user', + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ id: 'nav_users', type: 'object', objectName: 'sys_user' }); + }); + + it.each(SPREAD_BRANCHES)('accepts a %s nav item with no label (branch schema)', (_kind, schema, payload) => { + const result = schema.safeParse(payload); + + expect(result.success).toBe(true); + // The relaxation propagates through the spread — it is not a union-level + // tolerance that the branch itself would still refuse. + expect(result.error).toBeUndefined(); + }); + + it('accepts a label-less entry nested under a group', () => { + const result = NavigationItemSchema.safeParse({ + id: 'nav_sales', + label: 'Sales', + type: 'group', + children: [{ id: 'nav_accounts', type: 'object', objectName: 'account' }], + }); + + expect(result.success).toBe(true); + }); + + it('is a WIDENING only — an unknown key is still refused on a label-less entry', () => { + const result = NavigationItemSchema.safeParse({ + id: 'nav_users', + type: 'object', + objectName: 'sys_user', + labl: 'Users', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues.some((issue) => issue.code === 'unrecognized_keys')).toBe(true); + }); +}); + +describe('nav item label — PRESENT is verbatim, and ABSENT materialises nothing', () => { + it('keeps a string label byte-for-byte', () => { + const result = NavigationItemSchema.safeParse({ + id: 'nav_users', + label: 'Team directory', + type: 'object', + objectName: 'sys_user', + }); + + expect(result.success).toBe(true); + expect((result.data as { label?: unknown }).label).toBe('Team directory'); + }); + + it('keeps an inline locale map byte-for-byte', () => { + const label = { en: 'Users', 'zh-CN': '用户' }; + const result = NavigationItemSchema.safeParse({ + id: 'nav_users', + label, + type: 'object', + objectName: 'sys_user', + }); + + expect(result.success).toBe(true); + expect((result.data as { label?: unknown }).label).toEqual(label); + }); + + it('writes no label — and no inherited-marker key — when the author wrote none', () => { + const result = NavigationItemSchema.safeParse({ + id: 'nav_users', + type: 'object', + objectName: 'sys_user', + }); + + expect(result.success).toBe(true); + // Resolution is the renderer's job at render time. A parse that filled this + // in would freeze the entry at the target's name as of authoring. + expect(Object.keys(result.data as object)).not.toContain('label'); + expect(Object.keys(result.data as object)).toEqual(['id', 'type', 'objectName']); + }); +}); + +describe('the three sibling label sites this ruling does NOT cover stay required', () => { + it('NavigationArea.label is still required', () => { + const result = NavigationAreaSchema.safeParse({ id: 'area_sales', navigation: [] }); + + expect(result.success).toBe(false); + expect(result.error?.issues.some((issue) => issue.path.join('.') === 'label')).toBe(true); + // Control: the same payload WITH a label parses, so the leg above is a + // verdict about `label` and not about the rest of the packet. + expect(NavigationAreaSchema.safeParse({ id: 'area_sales', label: 'Sales', navigation: [] }).success).toBe(true); + }); + + it('AppContextSelector.label is still required', () => { + const payload = { id: 'active_package', optionsSource: { endpoint: '/api/v1/packages' } }; + const result = AppContextSelectorSchema.safeParse(payload); + + expect(result.success).toBe(false); + expect(result.error?.issues.some((issue) => issue.path.join('.') === 'label')).toBe(true); + expect(AppContextSelectorSchema.safeParse({ ...payload, label: 'Package' }).success).toBe(true); + }); + + it('App.label is still required', () => { + const result = AppSchema.safeParse({ name: 'crm' }); + + expect(result.success).toBe(false); + expect(result.error?.issues.some((issue) => issue.path.join('.') === 'label')).toBe(true); + expect(AppSchema.safeParse({ name: 'crm', label: 'CRM' }).success).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index cce4e40b185..12ca79f0a8d 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -296,8 +296,27 @@ const BaseNavItemSchema = z.object({ /** Unique identifier for the item */ id: SnakeCaseIdentifierSchema.describe('Unique identifier for this navigation item (lowercase snake_case)'), - /** Display label */ - label: I18nLabelSchema.describe('Display proper label'), + /** + * Display label — OPTIONAL since the cloud#2021 / objectui#9868 letter-A + * ruling, and optional in the ACCEPT sense only: an entry still shows text. + * + * Absent ⇒ the entry inherits, **at render time**, the CURRENT label of what + * it opens — the view's label when it names a view and that view is labelled, + * else the object's / dashboard's label. Present ⇒ rendered verbatim, never + * overwritten. Nothing is stored for the absent case: there is no `inherited` + * flag and no materialised copy, which is what makes a renamed target show + * its new name on the next render rather than a stale snapshot. + * + * The rule this relaxes — *every real destination must have identity and + * text* — still holds: identity is the target, text is inherited at render. + */ + label: I18nLabelSchema.optional().describe( + 'Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render ' + + "time — the view's label when it names a view and that view is labelled, else the object's / dashboard's " + + 'label; a present label renders verbatim and is never overwritten. Every real destination must have identity ' + + 'and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is ' + + 'materialised for the absent case.', + ), /** Icon name (Lucide) */ icon: z.string().optional().describe('Icon name'),