From bd611adfbcf8147d361adabf03e4c061d02458ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:21:36 +0000 Subject: [PATCH 1/6] feat(spec): a report nav item without reportName opens the app's report index Claude-Session: https://claude.ai/code/session_01WGufzhEW5k2a4Vd5KSupfu Co-Authored-By: Claude --- packages/spec/src/ui/app.test.ts | 29 +++++++++++++++++++++++------ packages/spec/src/ui/app.zod.ts | 20 ++++++++++++++++++-- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 004a3d9e6a6..130193802d4 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -886,12 +886,29 @@ describe('ReportNavItemSchema', () => { expect(item.reportName).toBe('pipeline_overview'); }); - it('should reject report nav item without reportName', () => { - expect(() => ReportNavItemSchema.parse({ - id: 'nav_bad_report', - label: 'Bad Report', - type: 'report', - })).toThrow(); + // A `report` item without `reportName` is the app's report index entry: it + // parses on the variant, through the union and inside an app, and names no + // report afterwards. + it('accepts a report item without reportName as the app report index entry', () => { + const bare = { id: 'nav_reports', type: 'report' as const }; + expect(ReportNavItemSchema.safeParse(bare).success).toBe(true); + const item = NavigationItemSchema.parse(bare); + expect(item.type).toBe('report'); + expect('reportName' in item).toBe(false); + const app = AppSchema.safeParse({ name: 'crm', label: 'CRM', navigation: [bare] }); + expect(app.success).toBe(true); + }); + + it('still refuses an unknown key on the index entry, and still teaches reportName on other types', () => { + const misspelt = ReportNavItemSchema.safeParse({ id: 'nav_reports', type: 'report', report: 'pipeline' }); + expect(misspelt.success).toBe(false); + const elsewhere = NavigationItemSchema.safeParse({ id: 'nav_x', type: 'page', pageName: 'p', reportName: 'r' }); + expect(elsewhere.success).toBe(false); + expect(JSON.stringify(elsewhere.error?.issues)).toContain("type: 'report' (with reportName)"); + }); + + it('describes what an absent reportName opens', () => { + expect(ReportNavItemSchema.shape.reportName.description).toContain('report index'); }); }); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index c4b408799b2..657505d6a5a 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -669,12 +669,28 @@ export const UrlNavItemSchema = lazySchema(() => strictObject(navItemSurface('ur /** * 5. Report Navigation Item - * Navigates to a specific report. + * + * ONE variant with two targets, chosen by whether `reportName` is written: + * + * - `{ type: 'report', reportName }` opens that report (`report/NAME`). + * - `{ type: 'report' }` opens the app's report index (`reports`): the list of + * the reports the app ships that the member may open, each linking to its + * own route. + * + * The index entry is the author's to place. The console draws an index entry + * only where the navigation declares one, as it draws every other entry; an + * app whose navigation declares none shows no index entry. */ export const ReportNavItemSchema = lazySchema(() => strictObject(navItemSurface('report'), { ...BaseNavItemSchema.shape, type: z.literal('report'), - reportName: z.string().describe('Target report name'), + reportName: z + .string() + .optional() + .describe( + 'Target report name. Omit it and the entry opens the app\'s report index (the reports the app ships ' + + 'that the member may open) instead of one report.', + ), })); /** From 01ed76af40140f3e30a56f91d2225e56ab37b121 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:32:34 +0000 Subject: [PATCH 2/6] feat(console): draw the report index only where the app's navigation declares it The sidebar stops injecting its All reports row; a report nav entry with no reportName is the app's report index, routed to /reports and named by the console. The Studio shows that entry as the report index and keeps it on save. The showcase and todo examples, which ship reports, declare the entry. Claude-Session: https://claude.ai/code/session_01WGufzhEW5k2a4Vd5KSupfu Co-Authored-By: Claude --- .../27525-report-nav-index-app-shell.md | 14 ++++ .changeset/27525-report-nav-index-i18n.md | 10 +++ .changeset/27525-report-nav-index-layout.md | 10 +++ .changeset/27525-report-nav-index-spec.md | 10 +++ .changeset/27525-report-nav-index-types.md | 10 +++ content/docs/references/ui/app.mdx | 10 +-- examples/app-showcase/src/ui/apps/index.ts | 5 ++ examples/app-todo/src/apps/todo.app.ts | 3 + .../app-shell/src/chrome/CommandPalette.tsx | 10 ++- .../app-shell/src/hooks/useNavTargetLabel.ts | 11 ++- .../app-shell/src/layout/UnifiedSidebar.tsx | 32 ++----- ...nifiedSidebar.favoritesMenu-12427.test.tsx | 5 +- ...dSidebar.iconModeLabelFocus-12795.test.tsx | 4 +- ...edSidebar.reportIndexEntry-27525.test.tsx} | 84 ++++++++++++++----- .../__tests__/AppReportIndex-13020.test.tsx | 48 +++++++++-- .../inspectors/AppNavInspector.tsx | 10 +++ .../metadata-admin/previews/AppNavCanvas.tsx | 12 ++- .../metadata-admin/previews/AppPreview.tsx | 6 +- .../studio-design/StudioDesignSurface.tsx | 19 ++++- ui/packages/i18n/src/namespaces/studio/en.ts | 2 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + ui/packages/layout/src/NavigationRenderer.tsx | 18 +++- ui/packages/types/src/app.ts | 2 +- ui/packages/types/src/zod/app.zod.ts | 2 +- 24 files changed, 255 insertions(+), 84 deletions(-) create mode 100644 .changeset/27525-report-nav-index-app-shell.md create mode 100644 .changeset/27525-report-nav-index-i18n.md create mode 100644 .changeset/27525-report-nav-index-layout.md create mode 100644 .changeset/27525-report-nav-index-spec.md create mode 100644 .changeset/27525-report-nav-index-types.md rename ui/packages/app-shell/src/layout/__tests__/{UnifiedSidebar.reportIndexLink-13020.test.tsx => UnifiedSidebar.reportIndexEntry-27525.test.tsx} (57%) diff --git a/.changeset/27525-report-nav-index-app-shell.md b/.changeset/27525-report-nav-index-app-shell.md new file mode 100644 index 00000000000..d5b344686ad --- /dev/null +++ b/.changeset/27525-report-nav-index-app-shell.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': minor +--- + +feat(console): the sidebar draws the report index only where the app's navigation declares it + +Clause-②: yes (widening) +Metadata-protocol: added + +The console no longer injects an "All reports" row at the foot of every app's sidebar. The app's report index (`/apps/APP/reports`) is an entry its navigation declares, a `report` item with no `reportName`, drawn and highlighted like any other entry; an app whose navigation declares none shows no index entry. With no `label`, the entry shows the console's own name for the index ("All reports"). The ⌘K palette offers the declared entry, and keeps finding every report the member may open from the reports the app ships, entry or not. + +In the Studio, a `report` entry with no report chosen reads as the report index (「报表索引」 / "Report index") instead of an unbound entry, and a save keeps it. + +**Migration.** An app that relied on the injected row adds one entry to its navigation where the index belongs: `{ id: 'nav_reports', type: 'report' }`. diff --git a/.changeset/27525-report-nav-index-i18n.md b/.changeset/27525-report-nav-index-i18n.md new file mode 100644 index 00000000000..f1e82dc0f9b --- /dev/null +++ b/.changeset/27525-report-nav-index-i18n.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': patch +--- + +feat(i18n): the Studio's navigation inspector names the report index face + +Clause-②: yes (widening) +Metadata-protocol: added + +New `studio` strings, en and zh: `engine.inspector.appNav.reportIndex` (the empty face of a `report` entry's report picker) and `engine.inspector.appNav.reportIndexHint`. diff --git a/.changeset/27525-report-nav-index-layout.md b/.changeset/27525-report-nav-index-layout.md new file mode 100644 index 00000000000..4188a4a358a --- /dev/null +++ b/.changeset/27525-report-nav-index-layout.md @@ -0,0 +1,10 @@ +--- +'@object-ui/layout': minor +--- + +feat(layout): a `report` navigation entry with no `reportName` resolves to the app's report index + +Clause-②: yes (widening) +Metadata-protocol: added + +`resolveHref` answers `BASE/reports` for a `report` entry that names no report (it answered `#`), so the entry links, and is the active row, on the report index route. A label-less one asks the host's `NavTargetLabelResolver` for the new `{ kind: 'reportIndex' }` target, else shows its `id`. diff --git a/.changeset/27525-report-nav-index-spec.md b/.changeset/27525-report-nav-index-spec.md new file mode 100644 index 00000000000..68a8634d3dd --- /dev/null +++ b/.changeset/27525-report-nav-index-spec.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a `report` navigation item without `reportName` opens the app's report index + +Clause-②: yes (widening) +Metadata-protocol: added + +`ReportNavItemSchema.reportName` is optional. `{ type: 'report', reportName }` opens that report, as before; `{ type: 'report' }` with no `reportName` opens the app's report index (`/apps/APP/reports`), the list of the reports the app ships that the member may open. Before, an item with no `reportName` was refused (`navigation.N.reportName: expected string, received undefined`). Nothing that parsed before parses differently, and the stack's cross-check of a named `reportName` against `reports` is unchanged. diff --git a/.changeset/27525-report-nav-index-types.md b/.changeset/27525-report-nav-index-types.md new file mode 100644 index 00000000000..a93c13211d3 --- /dev/null +++ b/.changeset/27525-report-nav-index-types.md @@ -0,0 +1,10 @@ +--- +'@object-ui/types': patch +--- + +docs(types): `NavigationItem.reportName` says that an omitted name opens the app's report index + +Clause-②: yes (widening) +Metadata-protocol: added + +The description of the mirror's optional `reportName` now states what its absence means on a `report` entry. No shape changes. diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 0c6db4aceb2..94cd0aa4ba3 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -194,7 +194,7 @@ const result = ActionNavItemSchema.parse(data); | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'report'` | ✅ | | -| **reportName** | `string` | ✅ | Target report name | +| **reportName** | `string` | optional | Target report name. Omit it and the entry opens the app's report index (the reports the app ships that the member may open) instead of one report. | ### Nested Shape: `App.navigation[number][type='action']` @@ -588,7 +588,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'report'` | ✅ | | -| **reportName** | `string` | ✅ | Target report name | +| **reportName** | `string` | optional | Target report name. Omit it and the entry opens the app's report index (the reports the app ships that the member may open) instead of one report. | ### Nested Shape: `NavigationArea.navigation[number][type='action']` @@ -787,7 +787,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'report'` | ✅ | | -| **reportName** | `string` | ✅ | Target report name | +| **reportName** | `string` | optional | Target report name. Omit it and the entry opens the app's report index (the reports the app ships that the member may open) instead of one report. | ### Nested Shape: `NavigationContribution.items[number][type='action']` @@ -1039,7 +1039,7 @@ This schema accepts one of the following structures: | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'report'` | ✅ | | -| **reportName** | `string` | ✅ | Target report name | +| **reportName** | `string` | optional | Target report name. Omit it and the entry opens the app's report index (the reports the app ships that the member may open) instead of one report. | ### Nested Shape: `NavigationItem[type='report'].dataSource` @@ -1294,7 +1294,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'report'` | ✅ | | -| **reportName** | `string` | ✅ | Target report name | +| **reportName** | `string` | optional | Target report name. Omit it and the entry opens the app's report index (the reports the app ships that the member may open) instead of one report. | ### Nested Shape: `ReportNavItem.dataSource` diff --git a/examples/app-showcase/src/ui/apps/index.ts b/examples/app-showcase/src/ui/apps/index.ts index 3b64b2ac413..31a7aad577f 100644 --- a/examples/app-showcase/src/ui/apps/index.ts +++ b/examples/app-showcase/src/ui/apps/index.ts @@ -103,6 +103,11 @@ export const ShowcaseApp = App.create({ { id: 'nav_report_chart', type: 'report', reportName: 'showcase_hours_by_status_chart', label: 'Hours by Status (Chart)', icon: 'bar-chart-3' }, { id: 'nav_report_matrix', type: 'report', reportName: 'showcase_status_priority_matrix', label: 'Status × Priority', icon: 'grid-3x3' }, { id: 'nav_report_joined', type: 'report', reportName: 'showcase_task_overview', label: 'Task Overview', icon: 'layers' }, + // A `report` entry with no `reportName` is the app's report index + // (objectstack#27525): the console injects no index row of its own, so + // the app declares it. No `label`: it shows the console's own name for + // that page. + { id: 'nav_report_index', type: 'report', icon: 'bar-chart-3' }, ], }, { diff --git a/examples/app-todo/src/apps/todo.app.ts b/examples/app-todo/src/apps/todo.app.ts index d96d2df1a25..b63e886a0c9 100644 --- a/examples/app-todo/src/apps/todo.app.ts +++ b/examples/app-todo/src/apps/todo.app.ts @@ -33,6 +33,9 @@ export const TodoApp = App.create({ icon: 'chart-bar', children: [ { id: 'nav_dashboard', type: 'dashboard', dashboardName: 'task_dashboard', label: 'Dashboard', icon: 'layout-dashboard' }, + // The app's report index: a `report` entry with no `reportName`. The + // console draws it only because the app declares it. + { id: 'nav_reports', type: 'report', icon: 'bar-chart-3' }, ] }, ] diff --git a/ui/packages/app-shell/src/chrome/CommandPalette.tsx b/ui/packages/app-shell/src/chrome/CommandPalette.tsx index 3f9c1bdac1e..93b95746b23 100644 --- a/ui/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/ui/packages/app-shell/src/chrome/CommandPalette.tsx @@ -245,9 +245,13 @@ function CommandPaletteBody(props: CommandPaletteProps & { gates: NavMemberGates // objectui#13020 — the app's reports beyond its navigation: the ones it // ships that this member may open, the list the app's report index shows. // Read only while the palette is open inside an app; a report a navigation - // entry already names is offered through that entry, once. + // entry already names is offered through that entry, once. A `report` entry + // with no `reportName` is the app's report index (objectstack#27525): it is + // offered as itself, names no report, and leaves every report to this list. const navReportNames = new Set( - navItems.filter((item) => item.type === 'report').map((item) => item.reportName as string), + navItems + .filter((item) => item.type === 'report' && typeof item.reportName === 'string' && item.reportName !== '') + .map((item) => item.reportName as string), ); const appReports = useAppReportEntries(activeApp, open && !inStudio, gates).entries.filter( (entry) => !navReportNames.has(entry.name) && matches(entry.label, entry.name), @@ -553,7 +557,7 @@ function CommandPaletteBody(props: CommandPaletteProps & { gates: NavMemberGates .map(item => ( leaveTo(resolveHref(item, baseUrl, templateContext).href)} > diff --git a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts index 12608265c40..3ab84540819 100644 --- a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -45,6 +45,11 @@ * A target with no label answers `undefined` — the renderer then falls through * the spec's ladder and, at the bottom, to the target's machine name. * + * The app's report index (`reportIndex`, a `report` entry with no + * `reportName`, objectstack#27525) is named by no metadata record: it answers + * the console's own wording for that page, `sidebar.allReports`, the text the + * console's injected index row showed before navigation declared it. + * * ## A designer's pending drafts — the last rung (objectui#11862) * * A draft-only object or dashboard is in no published list, so the metadata @@ -135,7 +140,7 @@ function listViewsOf(def: LabelledRecord | undefined): Record( @@ -159,10 +164,12 @@ export function useNavTargetLabel(): NavTargetLabelResolver { metadataText(def?.label, language) ?? draftText(drafts, 'dashboard', target.dashboardName, language); return nonEmpty(dashboardLabel({ name: target.dashboardName, label: text ?? '' })); } + case 'reportIndex': + return nonEmpty(t('sidebar.allReports', { defaultValue: 'All reports' })); default: return undefined; } }, - [metadata, objectLabel, viewLabel, dashboardLabel, language, drafts], + [metadata, objectLabel, viewLabel, dashboardLabel, language, t, drafts], ); } diff --git a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx index 56d93b8a84e..0c4da082195 100644 --- a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -36,7 +36,6 @@ import { ChevronRight, LayoutGrid, Layers, - BarChart3, } from 'lucide-react'; import { NavigationRenderer, hasVisibleNavigationItems, type NavItemActionContext } from '@object-ui/layout'; import type { NavigationArea, NavigationItem } from '@object-ui/types'; @@ -592,32 +591,11 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { templateContext={{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }} /> - {/* The app's report index (objectui#13020): every report the app - ships that the member may open, its navigation's or not, so no - report is left with only a typed URL. A static entry: the - sidebar reads no report list for it (objectui#12980), and the - index says so when there are none. Not in Studio, whose - navigation is its own design surface. */} - {!isStudioApp && ( - - - - - - - - {t('sidebar.allReports', { defaultValue: 'All reports' })} - - - - - - - )} + {/* The app's report index is an entry its navigation declares, + `{ type: 'report' }` with no `reportName` (objectstack#27525): + the renderer above draws it, and the sidebar injects no index + row of its own. An app whose navigation declares none shows + none. */} {/* Recent Items */} {recentItems.length > 0 && ( diff --git a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx index ba66a358d6c..6e6231d7901 100644 --- a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.favoritesMenu-12427.test.tsx @@ -193,12 +193,9 @@ const favoritesSection = () => (screen.queryByText('Favorites')?.closest('[data-sidebar="group"]') as HTMLElement | null | undefined) ?? null; const favoriteLabels = () => within(favoritesSection()!).getAllByRole('link').map((a) => a.textContent); /** The rows of the app's menu: every row outside the Favorites section. */ -// The navigation's rows: not the Favorites section's, and not the built-in -// report index entry (objectui#13020), which is no navigation entry and -// carries no ⋯ menu. const menuRows = () => [...document.querySelectorAll('[data-sidebar="menu-item"]')].filter( - (row) => !favoritesSection()?.contains(row) && !row.querySelector('[data-testid="sidebar-report-index"]'), + (row) => !favoritesSection()?.contains(row), ); async function openMenu(label: string, scope: HTMLElement = document.body): Promise { diff --git a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx index 7a435b34dd3..d6c578a7647 100644 --- a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.iconModeLabelFocus-12795.test.tsx @@ -255,7 +255,7 @@ describe('UnifiedSidebar — a hidden group label is no focus stop in icon mode expect(peer).toHaveAttribute('data-collapsible', 'icon'); const walk = await tabWalk(screen.getByRole('link', { name: 'Page Authoring' }), sidebarOf(container)); - expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'My Work', 'Tasks', 'All reports']); + expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'My Work', 'Tasks']); expect(walk.filter((el) => el.closest('[data-sidebar="group-label"]'))).toEqual([]); expect(screen.queryByRole('button', { name: 'Workspace' })).not.toBeInTheDocument(); @@ -282,7 +282,7 @@ describe('UnifiedSidebar — a hidden group label is no focus stop in icon mode for (const label of labelsIn(container)) expectShown(label); const walk = await tabWalk(screen.getByRole('link', { name: 'Page Authoring' }), sidebarOf(container)); - expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'Workspace', 'My Work', 'Tasks', 'All reports']); + expect(walk.map((el) => el.textContent?.trim())).toEqual(['Page Authoring', 'Workspace', 'My Work', 'Tasks']); const user = userEvent.setup(); const workspace = screen.getByRole('button', { name: 'Workspace' }); diff --git a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.reportIndexLink-13020.test.tsx b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.reportIndexEntry-27525.test.tsx similarity index 57% rename from ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.reportIndexLink-13020.test.tsx rename to ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.reportIndexEntry-27525.test.tsx index 1a7372862a3..5c841a65f7f 100644 --- a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.reportIndexLink-13020.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.reportIndexEntry-27525.test.tsx @@ -1,14 +1,22 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * UnifiedSidebar — every app's sidebar links its report index - * (objectui#13020), so a report the app's navigation does not name still has - * a UI path, with nothing for the app to author. + * UnifiedSidebar — the app's report index is an entry its navigation declares + * (objectstack#27525, superseding objectui#13020's injected row). The + * maintainer's model, 「加上才有」: the entry exists only where the author adds + * it, `{ type: 'report' }` with no `reportName`. * - * Pinned: the entry links `/apps/APP/reports`, it is drawn for an app whose - * navigation names no report at all, and drawing it reads no report list - * (the sidebar is chrome on every route, objectui#12980). CONTROL: the app's - * own navigation entries are drawn as before. + * Pinned: + * (a) an app whose navigation declares no index entry gets no injected row: + * no link to `/apps/APP/reports`, no "All reports" text; + * (b) a declared index entry is drawn by the navigation renderer and routed to + * `/apps/APP/reports`; with no `label` it is named by the console's own + * wording for that page ("All reports"), with a `label` as authored; + * (c) it is the active row on the index route; + * (d) drawing it reads no report list (the sidebar is chrome on every route, + * objectui#12980). + * CONTROL: a named `report` entry still routes to its own `report/NAME` page, + * and the app's other entries are drawn as before. * * Harness: the provider / chrome mocks of `UnifiedSidebar.objectReadGate-12109.test.tsx`; * `MetadataProvider`, `@object-ui/layout` and `@object-ui/components` stay real. @@ -82,13 +90,14 @@ import { SidebarProvider } from '@object-ui/components'; import { MetadataProvider } from '../../providers/MetadataProvider'; import { UnifiedSidebar } from '../UnifiedSidebar'; -const NAVIGATION: NavigationItem[] = [ +const BASE_NAVIGATION: NavigationItem[] = [ { id: 'nav_account', type: 'object', objectName: 'crm_account', label: 'Accounts' }, + { id: 'nav_pipeline', type: 'report', reportName: 'crm_pipeline', label: 'Pipeline' }, { id: 'nav_help', type: 'url', url: '/help', label: 'Help' }, ]; -const CRM_APP = { name: 'crm', label: 'CRM', active: true, navigation: NAVIGATION }; -function renderSidebar() { +function renderSidebar(navigation: NavigationItem[] = BASE_NAVIGATION, at = '/apps/crm') { + const app = { name: 'crm', label: 'CRM', active: true, navigation }; const asked: string[] = []; const adapter = { clearCache: vi.fn(), @@ -103,45 +112,76 @@ function renderSidebar() { meta: { getItems: (type: string) => { asked.push(type); - return Promise.resolve({ type, items: type === 'app' ? [CRM_APP] : [] }); + return Promise.resolve({ type, items: type === 'app' ? [app] : [] }); }, getItem: () => Promise.resolve({ item: null }), }, }), } as unknown as Parameters[0]['adapter']; - render( + const view = render( - + , ); - return asked; + return { asked, container: view.container }; } +const INDEX_ENTRY: NavigationItem = { id: 'nav_reports', type: 'report' }; + beforeEach(() => { localStorage.clear(); sessionStorage.clear(); }); -describe('UnifiedSidebar — the report index entry (objectui#13020)', () => { - it('links the app\'s report index, though the navigation names no report', async () => { - renderSidebar(); - await waitFor(() => expect(screen.getByRole('link', { name: 'All reports' })).toHaveAttribute('href', '/apps/crm/reports')); +describe('UnifiedSidebar — the report index is a declared entry (objectstack#27525)', () => { + it('(a) injects no index row on an app whose navigation declares none', async () => { + const { container } = renderSidebar(); + await waitFor(() => expect(screen.getByRole('link', { name: 'Accounts' })).toBeInTheDocument()); + expect(screen.queryByRole('link', { name: 'All reports' })).not.toBeInTheDocument(); + expect(container.querySelector('a[href="/apps/crm/reports"]')).toBeNull(); + expect(container.querySelector('[data-testid="sidebar-report-index"]')).toBeNull(); + }); + + it('(b) draws a declared index entry, routed to the report index, named by the console', async () => { + renderSidebar([...BASE_NAVIGATION, INDEX_ENTRY]); + await waitFor(() => + expect(screen.getByRole('link', { name: 'All reports' })).toHaveAttribute('href', '/apps/crm/reports'), + ); + }); + + it('(b) a labelled index entry shows its own label, routed the same', async () => { + renderSidebar([...BASE_NAVIGATION, { ...INDEX_ENTRY, label: 'Reports' }]); + await waitFor(() => + expect(screen.getByRole('link', { name: 'Reports' })).toHaveAttribute('href', '/apps/crm/reports'), + ); + expect(screen.queryByRole('link', { name: 'All reports' })).not.toBeInTheDocument(); + }); + + it('(c) the declared entry is the active row on the index route', async () => { + renderSidebar([...BASE_NAVIGATION, INDEX_ENTRY], '/apps/crm/reports'); + await waitFor(() => + expect(screen.getByRole('link', { name: 'All reports' })).toHaveAttribute('data-active', 'true'), + ); + expect(screen.getByRole('link', { name: 'Pipeline' })).not.toHaveAttribute('data-active', 'true'); }); - it('drawing it reads no report list', async () => { - const asked = renderSidebar(); + it('(d) drawing it reads no report list', async () => { + const { asked } = renderSidebar([...BASE_NAVIGATION, INDEX_ENTRY]); await waitFor(() => expect(screen.getByRole('link', { name: 'All reports' })).toBeInTheDocument()); await new Promise((resolve) => setTimeout(resolve, 50)); expect(asked).not.toContain('report'); }); - it('CONTROL: the app\'s own entries are drawn as before', async () => { + it('CONTROL: a named report entry routes to its own page, and the other entries are drawn as before', async () => { renderSidebar(); - await waitFor(() => expect(screen.getByRole('link', { name: 'Accounts' })).toHaveAttribute('href', '/apps/crm/crm_account')); + await waitFor(() => + expect(screen.getByRole('link', { name: 'Pipeline' })).toHaveAttribute('href', '/apps/crm/report/crm_pipeline'), + ); + expect(screen.getByRole('link', { name: 'Accounts' })).toHaveAttribute('href', '/apps/crm/crm_account'); expect(screen.getByRole('link', { name: 'Help' })).toBeInTheDocument(); }); }); diff --git a/ui/packages/app-shell/src/views/__tests__/AppReportIndex-13020.test.tsx b/ui/packages/app-shell/src/views/__tests__/AppReportIndex-13020.test.tsx index cf89afffe91..4fecd53c747 100644 --- a/ui/packages/app-shell/src/views/__tests__/AppReportIndex-13020.test.tsx +++ b/ui/packages/app-shell/src/views/__tests__/AppReportIndex-13020.test.tsx @@ -95,8 +95,18 @@ const ANSWER: MePermissionsResponse = { fields: {}, }; -function adapterFor(reports: unknown[], asked: string[]) { - const items: Record = { app: [CRM_APP], report: reports, dataset: DATASETS }; +// objectstack#27525 — the app the card's HotCRM follow-up describes: one +// report index entry instead of one entry per report. +const INDEX_ONLY_APP = { + ...CRM_APP, + navigation: [ + { id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts' }, + { id: 'nav_reports', type: 'report' }, + ], +}; + +function adapterFor(reports: unknown[], asked: string[], app: unknown = CRM_APP) { + const items: Record = { app: [app], report: reports, dataset: DATASETS }; return { clearCache: vi.fn(), // objectui#13044 — the real MetadataProvider reads an object by name through @@ -121,14 +131,14 @@ function adapterFor(reports: unknown[], asked: string[]) { } as unknown as Parameters[0]['adapter']; } -function shell(children: React.ReactNode, url: string, reports: unknown[] = REPORTS) { +function shell(children: React.ReactNode, url: string, reports: unknown[] = REPORTS, app: any = CRM_APP) { const asked: string[] = []; const fetcher = vi.fn(async () => ({ ok: true, status: 200, json: async () => ANSWER }) as unknown as Response); render( - - + + {children} @@ -148,17 +158,19 @@ function renderIndex(reports: unknown[] = REPORTS) { ); } -function renderPalette(url: string) { +function renderPalette(url: string, app: any = CRM_APP) { return shell( {}} />} + element={ {}} />} /> , url, + REPORTS, + app, ); } @@ -241,3 +253,25 @@ describe('⌘K offers the app\'s reports beyond its navigation (objectui#13020)' expect(asked).not.toContain('dataset'); }); }); + +describe('⌘K under a navigation that declares only the report index (objectstack#27525)', () => { + it('finds every report the member may open with no entry naming one, and not one on an unreadable object', async () => { + renderPalette('/apps/crm?palette=1', INDEX_ONLY_APP); + await waitFor(() => expect(paletteReports()).toContain('Win Rate')); + expect(paletteReports()).toContain('Pipeline'); + expect(paletteReports()).not.toContain('Case Aging'); + expect(paletteReports()).not.toContain('Mixed Blocks'); + expect(paletteReports()).not.toContain('Headcount'); + }); + + it('offers the index entry itself, routed to the report index', async () => { + renderPalette('/apps/crm?palette=1', INDEX_ONLY_APP); + await waitFor(() => expect(paletteReports()).toContain('Win Rate')); + const index = Array.from(document.querySelectorAll('[cmdk-item][data-value^="report "]')).find( + (el) => (el.textContent ?? '').trim() === 'All reports', + ); + expect(index).toBeDefined(); + // `undefined` never leaks into the matched value of an entry naming no report. + expect(index?.getAttribute('data-value')).not.toContain('undefined'); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx index 21f9cdfc0f2..dd7896542f0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx @@ -479,6 +479,16 @@ export function AppNavInspector({ selection, draft, name, onPatch, onClearSelect ) )} + {/* objectstack#27525 — a `report` item with no `reportName` is the + app's report index, not an unbound item. */} + {navType === 'report' && !node.reportName && ( +

+ {t('engine.inspector.appNav.reportIndex', locale)} + {' · '} + {t('engine.inspector.appNav.reportIndexHint', locale)} +

+ )} + {previewHref && (
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx index 445106608ab..bc8f786cbcd 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx @@ -211,16 +211,26 @@ function navTarget(it: RawNav): string | undefined { return undefined; } +/** + * The target-bearing branches whose target key the spec makes optional, with + * a meaning for its absence: a `report` entry with no `reportName` opens the + * app's report index (objectstack#27525). Such an entry names a target, so a + * save sends it. + */ +const NAV_TARGET_OPTIONAL: ReadonlySet = new Set(['report']); + /** * Whether a save leaves this entry out (objectui#11776): it has no `type` (the * placeholder an unbind leaves behind), or its `type` is a target-bearing * branch and none of that branch's target keys holds a string — the entry the * spec refuses for want of a target ("navigation.N.objectName — expected * string, received undefined"). A key that holds a string is a target the - * spec takes, so the save sends it and the server judges it. + * spec takes, so the save sends it and the server judges it. A branch in + * {@link NAV_TARGET_OPTIONAL} is never left out for want of its key. */ function namesNoTarget(it: RawNav): boolean { if (typeof it.type !== 'string') return true; + if (NAV_TARGET_OPTIONAL.has(it.type)) return false; const reads = NAV_TARGET_READS.get(it.type); return !!reads && !reads.some((read) => typeof read(it) === 'string'); } diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx index ade7868a2e6..9380ab18fc6 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx @@ -99,7 +99,9 @@ interface NavItem { /** * The target a nav item names, read from the key its own branch declares. - * Returns undefined for branches that name nothing (`group`, `separator`). + * Returns undefined for branches that name nothing (`group`, `separator`). A + * `report` entry with no `reportName` names the app's report index, answered + * as its route segment `reports` (objectstack#27525). */ function navTarget(it: Record, kind?: NavKind): string | undefined { switch (kind) { @@ -110,7 +112,7 @@ function navTarget(it: Record, kind?: NavKind): string | undefi case 'page': return typeof it.pageName === 'string' ? it.pageName : undefined; case 'report': - return typeof it.reportName === 'string' ? it.reportName : undefined; + return typeof it.reportName === 'string' && it.reportName !== '' ? it.reportName : 'reports'; case 'url': return typeof it.url === 'string' ? it.url : undefined; case 'component': diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 112c2439faa..342c7df8444 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -3332,11 +3332,12 @@ export function StudioNavItemInspector({ const visibleShown = typed ? typed.text : (heldVisible ?? ''); const visibleHeldBack = !!typed && typed.errors > 0 && typed.text !== heldVisible; // Whether the entry names a target for its type yet (a group and a - // separator name none, and are never unbound). + // separator name none, and are never unbound). A `report` entry is never + // unbound: with no `reportName` it opens the app's report index + // (objectstack#27525). const unbound = (kind === 'page' && !str(node.pageName)) || (kind === 'dashboard' && !str(node.dashboardName)) || - (kind === 'report' && !str(node.reportName)) || (kind === 'url' && !str(node.url)) || (kind === 'component' && !str(node.componentRef)) || (kind === 'action' && !str(actionDef?.actionName)) || @@ -3502,6 +3503,15 @@ export function StudioNavItemInspector({ options={optionsOf(targets.rows)} loading={targets.loading} onPick={(v) => setTarget(NAV_TARGET_KEY[kind], v)} + // objectstack#27525 — a `report` entry with no `reportName` is the + // app's report index: its empty face says so, and picking it + // removes the key. + {...(kind === 'report' + ? { + emptyLabel: t('engine.inspector.appNav.reportIndex', locale), + hint: str(node.reportName) ? undefined : t('engine.inspector.appNav.reportIndexHint', locale), + } + : {})} locale={locale} /> )} @@ -3904,6 +3914,7 @@ function NavTargetSelect({ loading, onPick, hint, + emptyLabel, locale, }: { label: string; @@ -3912,6 +3923,8 @@ function NavTargetSelect({ loading: boolean; onPick: (value: string) => void; hint?: string; + /** The face of the empty value, where an absent target means something; else "Choose…". */ + emptyLabel?: string; locale: string; }): React.ReactElement { const id = React.useId(); @@ -3924,7 +3937,7 @@ function NavTargetSelect({ diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index d299ff61b4f..58de6eba06c 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -695,6 +695,8 @@ const STUDIO_EN: Record = { 'engine.inspector.appNav.choose': '— Choose —', 'engine.inspector.appNav.noTargets': 'There is nothing of this type to link to yet.', 'engine.inspector.appNav.unboundHint': 'Until a target is chosen, this item stays here but is left out of the saved navigation.', + 'engine.inspector.appNav.reportIndex': 'Report index', + 'engine.inspector.appNav.reportIndexHint': 'With no report chosen, this item opens the app\'s report index: every report the app ships that the member may open.', 'engine.inspector.appNav.actionHint': 'Only actions not bound to an object can run from the menu.', 'engine.inspector.appNav.docPage': 'Doc page', 'engine.inspector.appNav.book': 'Book', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 558f5535d97..7f9b8af1db6 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -599,6 +599,8 @@ const STUDIO_ZH: Record = { 'engine.inspector.appNav.choose': '— 请选择 —', 'engine.inspector.appNav.noTargets': '还没有可链接的此类型条目。', 'engine.inspector.appNav.unboundHint': '选定目标之前,该导航项保留在此处,但不会写入保存的导航。', + 'engine.inspector.appNav.reportIndex': '报表索引', + 'engine.inspector.appNav.reportIndexHint': '未选定报表时,该导航项打开应用的报表索引:列出应用自带、且当前成员可以打开的全部报表。', 'engine.inspector.appNav.actionHint': '只有未绑定到对象的动作才能从菜单运行。', 'engine.inspector.appNav.docPage': '文档页', 'engine.inspector.appNav.book': '文档集', diff --git a/ui/packages/layout/src/NavigationRenderer.tsx b/ui/packages/layout/src/NavigationRenderer.tsx index 71eca7a7207..9f866973628 100644 --- a/ui/packages/layout/src/NavigationRenderer.tsx +++ b/ui/packages/layout/src/NavigationRenderer.tsx @@ -210,11 +210,16 @@ export type DocTargetChecker = (target: DocNavTarget) => boolean; * labelled, else the object's / dashboard's label. These are the three targets * that sentence names; `resolveNavItemLabel` walks them in that order and asks a * {@link NavTargetLabelResolver} about each one. + * + * `reportIndex` is the fourth: a `report` entry with no `reportName` opens the + * app's report index, which no metadata record names, so the host answers its + * own wording for that page (objectstack#27525). */ export type NavLabelTarget = | { kind: 'view'; objectName: string; viewName: string } | { kind: 'object'; objectName: string } - | { kind: 'dashboard'; dashboardName: string }; + | { kind: 'dashboard'; dashboardName: string } + | { kind: 'reportIndex' }; /** * Answers the CURRENT display label of a nav target from the host's metadata — @@ -569,7 +574,9 @@ function isKeyedLabel(label: unknown): label is KeyedI18nLabel { * `componentRef`, `actionDef.actionName`, a `doc` entry's `doc` else its * `book` — and an entry with no target of its * own (a `group`), or with its target missing, shows its `id`, which the - * validator requires of every entry. + * validator requires of every entry. A `report` entry with no `reportName` is + * the app's report index: it shows the host's `reportIndex` answer, else its + * `id`. */ function inheritedNavItemLabel( item: Exclude, @@ -598,7 +605,8 @@ function inheritedNavItemLabel( break; case 'report': if (item.reportName) return item.reportName; - break; + // No `reportName`: the app's report index (objectstack#27525). + return ask({ kind: 'reportIndex' }) ?? item.id; case 'url': if (item.url) return item.url; break; @@ -1000,7 +1008,9 @@ export function resolveHref( return { href: url, external: false }; } case 'report': - return { href: item.reportName ? `${basePath}/report/${item.reportName}` : '#', external: false }; + // No `reportName` is the app's report index (objectstack#27525), the + // `reports` route; a named report its own route. + return { href: item.reportName ? `${basePath}/report/${item.reportName}` : `${basePath}/reports`, external: false }; case 'url': return { href: item.url ?? '#', external: item.target === '_blank' }; case 'component': { diff --git a/ui/packages/types/src/app.ts b/ui/packages/types/src/app.ts index 5998a721410..8bee5ee3f14 100644 --- a/ui/packages/types/src/app.ts +++ b/ui/packages/types/src/app.ts @@ -212,7 +212,7 @@ export interface NavigationEntryItem { /** Target page name (for type: 'page') */ pageName?: string; - /** Target report name (for type: 'report') */ + /** Target report name (for type: 'report'); omitted, the entry opens the app's report index */ reportName?: string; /** Target URL (for type: 'url') */ diff --git a/ui/packages/types/src/zod/app.zod.ts b/ui/packages/types/src/zod/app.zod.ts index 8a347afee01..989b5093af3 100644 --- a/ui/packages/types/src/zod/app.zod.ts +++ b/ui/packages/types/src/zod/app.zod.ts @@ -285,7 +285,7 @@ const NavigationItemObject = z.looseObject({ runAction: z.string().optional().describe('Auto-run deep link (type: object) — name of an action on the target object that the list surface runs once on arrival. Not combinable with recordId (a record detail page has no list toolbar to auto-run); composes with viewName or filters. Encoded as the reserved ?runAction= search param.'), dashboardName: z.string().optional().describe('Target dashboard name (type: dashboard)'), pageName: z.string().optional().describe('Target page name (type: page)'), - reportName: z.string().optional().describe('Target report name (type: report)'), + reportName: z.string().optional().describe('Target report name (type: report); omitted, the entry opens the app\'s report index'), url: z.string().optional().describe('Target URL (type: url)'), target: z.enum(['_blank', '_self']).optional().describe('Link target (type: url)'), componentRef: z.string().optional().describe('Target component reference (type: component) — colon-joined ComponentRegistry key e.g. metadata:resource, routed to /component//'), From 0b6a152e69540e794402a0ed19e3aeb6f19f8f55 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:53:00 +0000 Subject: [PATCH 3/6] test(console): the Studio's report entry is the report index, kept on save Claude-Session: https://claude.ai/code/session_01WGufzhEW5k2a4Vd5KSupfu Co-Authored-By: Claude --- .../AppNavCanvas.navPayload-11776.test.tsx | 9 ++++--- ...oDesignSurface.navItemTypes-11790.test.tsx | 25 ++++++++++++++++++- ...oDesignSurface.sharedSelect-11865.test.tsx | 7 +++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.navPayload-11776.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.navPayload-11776.test.tsx index d05ce295314..165bb181298 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.navPayload-11776.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.navPayload-11776.test.tsx @@ -71,9 +71,12 @@ describe('navPayloadOf — what a nav save sends (objectui#11776)', () => { expect(navPayloadOf([entry])).toEqual([entry]); }); - it('only the separator and the group are taken bare; every other branch names a target', () => { + // objectstack#27525 — a bare `report` entry is the app's report index, so + // the spec takes it and the save sends it. + it('only the separator, the group and the report index are taken bare; every other branch names a target', () => { const takenBare = specNavTypes().filter((type) => NavigationItemSchema.safeParse(bare(type)).success); - expect(takenBare.sort()).toEqual(['group', 'separator']); + expect(takenBare.sort()).toEqual(['group', 'report', 'separator']); + expect(navPayloadOf([bare('report')])).toEqual([bare('report')]); }); it('a `doc` entry names its target by `doc` OR `book`', () => { @@ -134,7 +137,7 @@ describe('navPayloadOf — what a nav save sends (objectui#11776)', () => { const home = { id: 'nav_home', type: 'page', label: 'Home', pageName: 'home' }; const sep = { type: 'separator' }; const accounts = { id: 'nav_accounts', type: 'object', objectName: 'account' }; - const editor = [home, { id: 'nav_item_2', type: 'object' }, sep, accounts, { id: 'nav_item_5', type: 'report' }]; + const editor = [home, { id: 'nav_item_2', type: 'object' }, sep, accounts, { id: 'nav_item_5', type: 'dashboard' }]; const sent = navPayloadOf(editor); expect(sent).toEqual([home, sep, accounts]); // The entries sent ARE the editor's: nothing kept is copied or rewritten. diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx index 832777cf620..b1e42b9d168 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemTypes-11790.test.tsx @@ -309,7 +309,6 @@ describe('StudioNavItemInspector — the Type choice is the spec nav-item union const TARGET_CASES: Array<{ type: string; pickIn: string; value: string; written: Record }> = [ { type: 'page', pickIn: 'Page', value: 'home', written: { pageName: 'home' } }, { type: 'dashboard', pickIn: 'Dashboard', value: 'ops_board', written: { dashboardName: 'ops_board' } }, - { type: 'report', pickIn: 'Report', value: 'tasks_by_status', written: { reportName: 'tasks_by_status' } }, { type: 'action', pickIn: 'Action', value: 'sync_all', written: { actionDef: { actionName: 'sync_all' } } }, { type: 'component', pickIn: 'Component', value: 'acme:board', written: { componentRef: 'acme:board' } }, { type: 'doc', pickIn: 'Doc page', value: 'getting_started', written: { doc: 'getting_started' } }, @@ -332,6 +331,30 @@ describe('StudioNavItemInspector — each target-bearing type picks a target and expect(navPayloadOf([entry])).toEqual([entry]); }); + // objectstack#27525 — a `report` entry with no report chosen is the app's + // report index, not an unbound entry: it parses, a save sends it, and the + // picker's empty face says what it opens. + it('report: born as the report index, kept by a save; a picked report is written, and the index face removes it', async () => { + render(); + fireEvent.click(typeChoice('report')); + expect(entryNow()).toEqual({ id: 'nav_item_3', type: 'report' }); + expect(NavigationItemSchema.safeParse(entryNow()).success).toBe(true); + expect(navPayloadOf([entryNow()])).toEqual([entryNow()]); + expect(screen.queryByText(/left out of the saved navigation/)).toBeNull(); + expect(screen.getByText(/opens the app's report index/)).toBeInTheDocument(); + expect(await offered('Report', /tasks_by_status/)).toEqual(['Report index', 'Tasks by status (tasks_by_status)']); + + await pick('Report', 'tasks_by_status'); + expect(entryNow()).toEqual({ id: 'nav_item_3', type: 'report', reportName: 'tasks_by_status' }); + expect(NavigationItemSchema.safeParse(entryNow()).success).toBe(true); + expect(screen.queryByText(/opens the app's report index/)).toBeNull(); + + const listbox = await openPicker('Report'); + fireEvent.click(within(listbox).getByRole('option', { name: 'Report index' })); + expect(entryNow()).toEqual({ id: 'nav_item_3', type: 'report' }); + expect(navPayloadOf([entryNow()])).toEqual([entryNow()]); + }); + it('url: the address is typed, and the entry parses', async () => { render(); fireEvent.click(typeChoice('url')); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.sharedSelect-11865.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.sharedSelect-11865.test.tsx index 8eee86b08c9..316f1b8e8aa 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.sharedSelect-11865.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.sharedSelect-11865.test.tsx @@ -163,6 +163,7 @@ afterEach(() => { const OBJECT_PICKER = 'nav-link-object'; const CHOOSE_OBJECT = en('engine.studio.nav.chooseObject'); const CHOOSE = en('engine.inspector.appNav.choose'); +const REPORT_INDEX = en('engine.inspector.appNav.reportIndex'); const SAME_TAB = en('engine.inspector.appNav.urlTargetSelf'); const NEW_TAB = en('engine.inspector.appNav.urlTargetBlank'); @@ -212,6 +213,7 @@ const ENTRIES: Record> = { pageOutside: { id: 'nav_p', type: 'page', pageName: 'ghost_page' }, dashBound: { id: 'nav_d', type: 'dashboard', dashboardName: 'ops_board' }, reportBorn: { id: 'nav_item_3', type: 'report' }, + reportBound: { id: 'nav_r', type: 'report', reportName: 'tasks_by_status' }, actionBorn: { id: 'nav_item_3', type: 'action' }, actionBound: { id: 'nav_a', type: 'action', actionDef: { actionName: 'sync_all' } }, compBorn: { id: 'nav_item_3', type: 'component' }, @@ -273,7 +275,10 @@ const NAV_WRITES: ReadonlyArray Date: Thu, 29 Oct 2026 12:54:46 +0000 Subject: [PATCH 4/6] chore(changeset): grade every package the report index change moves minor Claude-Session: https://claude.ai/code/session_01WGufzhEW5k2a4Vd5KSupfu Co-Authored-By: Claude --- .changeset/27525-report-nav-index-i18n.md | 2 +- .changeset/27525-report-nav-index-types.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/27525-report-nav-index-i18n.md b/.changeset/27525-report-nav-index-i18n.md index f1e82dc0f9b..5be62de0491 100644 --- a/.changeset/27525-report-nav-index-i18n.md +++ b/.changeset/27525-report-nav-index-i18n.md @@ -1,5 +1,5 @@ --- -'@object-ui/i18n': patch +'@object-ui/i18n': minor --- feat(i18n): the Studio's navigation inspector names the report index face diff --git a/.changeset/27525-report-nav-index-types.md b/.changeset/27525-report-nav-index-types.md index a93c13211d3..6a3d1197b7a 100644 --- a/.changeset/27525-report-nav-index-types.md +++ b/.changeset/27525-report-nav-index-types.md @@ -1,5 +1,5 @@ --- -'@object-ui/types': patch +'@object-ui/types': minor --- docs(types): `NavigationItem.reportName` says that an omitted name opens the app's report index From 3406b52f48dbfc0e0fcfa1230df038c0c210ca9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:20:31 +0000 Subject: [PATCH 5/6] docs(cli, docs, qa): teach the report index nav entry; drop the injected "All reports" row from docs and the QA checklist (#27525) - `os explain app` names the bare `report` item as the report index target. - content/docs/ui/apps.mdx and reports.mdx document `{ type: 'report' }` with no `reportName`, and say the console draws no index entry of its own. - platform-core.nav-surfaces-render (r8) and builtin-apps-nav-render (r10): the console-added destination the walks named is gone; the index is now a served nav entry. - @objectstack/cli patch changeset. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhEW5k2a4Vd5KSupfu --- .changeset/27525-report-nav-index-cli.md | 10 ++++++++ content/docs/ui/apps.mdx | 18 +++++++++++++- content/docs/ui/reports.mdx | 5 ++++ .../areas/platform-core.json | 24 ++++++++++++++----- packages/cli/src/commands/explain.ts | 2 +- 5 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 .changeset/27525-report-nav-index-cli.md diff --git a/.changeset/27525-report-nav-index-cli.md b/.changeset/27525-report-nav-index-cli.md new file mode 100644 index 00000000000..5b341708dfb --- /dev/null +++ b/.changeset/27525-report-nav-index-cli.md @@ -0,0 +1,10 @@ +--- +'@objectstack/cli': patch +--- + +docs(cli): `os explain app` says a `report` navigation item may omit `reportName` to open the app's report index + +Clause-②: no +Metadata-protocol: none + +The `navigation` row of `os explain app` now ends its `report -> reportName` target with "(omit it on a report item to open the app's report index)", matching the widening `@objectstack/spec` ships in this release. Only the help text changes; the command's behaviour and output shape are the same. diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 20c7be01bd4..a72383cb3b9 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -170,6 +170,21 @@ Links to a report — a `report` metadata item, named by `reportName`: { id: 'nav_pipeline', type: 'report', label: 'Pipeline Report', reportName: 'sales_pipeline', icon: 'file-bar-chart' } ``` +Leave `reportName` out and the entry opens the app's **report index** instead +(`/apps//reports`): one page listing the reports the app ships that the +member may open, each linking to its own report. The console draws no index +entry of its own, so an app that wants one declares it, once, wherever it +belongs in the menu: + +```typescript +{ id: 'nav_reports', type: 'report', icon: 'bar-chart-3' } +``` + +With no `label`, the entry shows the console's own name for the index +("All reports"). The ⌘K palette finds every report the member may open from the +reports the app ships, so an app does not need one entry per report to make +them searchable. + ### Action Navigation Runs an action instead of navigating to a surface. The reference lives in a @@ -290,7 +305,8 @@ labelled view, else the object's or dashboard's label, else the target's machine name. A renamed object, view or dashboard therefore shows its new name on the next load with no navigation edit. Other entries fall back to their target's name (`pageName`, `reportName`, the `url`) and a group to its `id`, so give those a -`label`. A `label` you write renders verbatim; never write an empty one, omit the +`label`. A report index entry (a `report` entry with no `reportName`) shows the +console's own name for the index. A `label` you write renders verbatim; never write an empty one, omit the key instead. ## Designing the navigation diff --git a/content/docs/ui/reports.mdx b/content/docs/ui/reports.mdx index 977ccf8742a..8c867bfba45 100644 --- a/content/docs/ui/reports.mdx +++ b/content/docs/ui/reports.mdx @@ -300,6 +300,11 @@ stack does not define fails validation with `App '' navigation references r no reports at all, where the target may come from another package.) The entry resolves to `/report/` in the console. +A `report` entry **without** `reportName` opens the app's report index +(`/apps//reports`), which lists every report the app ships that the member may +open. The console adds no index entry by itself: declare `{ id: 'nav_reports', type: +'report' }` where the menu should carry it. + ## Permissions and protection The report schema carries exactly **one** access-shaped block, and it is not about diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 14ae4e7d266..193a90c7f09 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -304,7 +304,7 @@ "title": "Every showcase nav surface renders without page errors, and failures surface the error boundary — never a blank page", "since": "v15", "status": "active", - "revision": 7, + "revision": 8, "priority": "P0", "surface": "browser", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -317,7 +317,7 @@ "steps": [ "run the pinned suite: pnpm -C examples/app-showcase test:smoke e2e/showcase-smoke.spec.ts (the SURFACES array in e2e/showcase-smoke.spec.ts — all 49 showcase_app nav destinations at revision 5: pages, object lists, the three filter slices, the tabular view, dashboards, reports, the view-gallery pages). On a shared box pass SMOKE_API_URL=http://localhost:: the config signs in, drives every page and boots `serve --dev -p ` from that one URL, and reuses whatever already answers it — on the default port 3000 that can be somebody else's server. Where the browser cache holds a different chromium build than @playwright/test resolves (RUNNER.md Environment facts), pass OS_TEST_CHROMIUM_EXECUTABLE_PATH=/opt/pw-browsers/chromium. Name the spec: the config's testDir is ./e2e (examples/app-showcase/playwright.config.ts), so a bare `test:smoke` also collects bulk-capability-gate.spec.ts and detail-shapes.spec.ts beside the pin (QA run #25812), and their results are not this item's", "GET /api/v1/meta/app?id=showcase_app and extract every nav destination from the response (?id= matches the app NAME; the package-id spelling returns an empty items array and would yield zero destinations)", - "diff the served nav destinations against the SURFACES array; hand-walk any destination the suite does not cover (screenshot first, then DOM). Then hand-walk the destinations the console draws on its own, which are on NEITHER side of that diff: today the sidebar's static 'All reports' entry (…/reports, data-testid sidebar-report-index; ui/packages/app-shell/src/layout/UnifiedSidebar.tsx, objectui#13020), absent from meta/app and from SURFACES (QA run #25812 walked it as the 50th surface)", + "diff the served nav destinations against the SURFACES array; hand-walk any destination the suite does not cover (screenshot first, then DOM). The console draws no nav destination of its own: the report index (…/reports) renders only where an app's navigation declares a `report` entry with no `reportName` (showcase: nav_report_index in the Reports group), so it is served in meta/app and falls inside that diff like any other entry. #27525 retired the sidebar's injected 'All reports' row (objectui#13020) that QA run #25812 walked as the 50th surface", "on each hand-walked surface: wait for render, screenshot, then check for pageerror / empty
/ placeholder leaks ('no actions configured')", "chart surfaces (the chart:true entries in SURFACES — Delivery Operations, Chart Gallery, Command Center, Revenue Pulse, Hours by Status (Chart)): the suite now POLLS boundingBox against a bounded deadline (SURFACE_BUDGET_MS in the spec) instead of reading it once after a fixed wait, so a chart red here is a real finding and not the measurement window — #7569 measured the difference: 3 fresh loads each drew 5 real charts, and polling every 400 ms returned null ONLY at t = 1.5 s, the old fixed measurement instant and exactly the recharts ResponsiveContainer initial-layout window, then non-null on all 19 later samples. Read WHICH assertion failed: 'no chart SVG laid out' means the box stayed null for the whole budget; a width/height failure means the chart laid out but collapsed (#2616 D). If you hand-walk a chart surface outside the suite, poll the same way — never sleep-then-read-once", "probe the failure path: navigate to a nonexistent route under the app (/_console/apps/com.example.showcase/object_that_does_not_exist); screenshot what renders", @@ -331,9 +331,9 @@ "evidence": "test run output + the failing assertion's message for any chart red" }, { - "clause": "the smoke's coverage is CURRENT: every nav destination served in meta/app is either in the SURFACES array or hand-walked this run, and every destination the console adds on its own (steps[2]: the 'All reports' entry) is hand-walked — no surface silently outside the net", + "clause": "the smoke's coverage is CURRENT: every nav destination served in meta/app is either in the SURFACES array or hand-walked this run — no surface silently outside the net", "oracle": "api", - "verify": "diff of served nav destinations vs SURFACES + the hand-walk records for the remainder and for the console-added destinations. The run computes that diff itself: nothing in automated.ref asserts it — showcase-smoke.spec.ts drives the SURFACES it lists and never reads meta/app (QA run #25815) — so this clause has no automation and is never scored off the suite", + "verify": "diff of served nav destinations vs SURFACES + the hand-walk records for the remainder. The run computes that diff itself: nothing in automated.ref asserts it — showcase-smoke.spec.ts drives the SURFACES it lists and never reads meta/app (QA run #25815) — so this clause has no automation and is never scored off the suite", "evidence": "the diff + per-surface screenshots" }, { @@ -386,6 +386,12 @@ "date": "2026-10-26", "change": "steps[0], steps[2], acceptance[0].verify, acceptance[1] (clause, verify) and automated.ref corrected — three checklist-accuracy findings, one of QA run #25812 (priority:P0 at 6679644a, console b41ed7f68956) and two of QA run #25815 (release-18.2.0-tier1 at 6679644a); wave anchor #25807, card #25845. (1) #25812: the console-added 'All reports' sidebar entry (UnifiedSidebar's static report index, objectui#13020) is in neither meta/app nor SURFACES, so the served-vs-SURFACES diff cannot see it — steps[2] and acceptance[1] now hand-walk it; and step 1's bare `test:smoke` collects every spec under testDir ./e2e, two besides the pin — steps[0] names the spec. (2) #25815: acceptance[0] and negative[0], [1], [3] score only from a suite run against a live showcase with a built Console; otherwise not-run (browser). (3) #25815: nothing in the ref computes the served-nav vs SURFACES diff, so acceptance[1] has no automation; automated.ref now names the clauses the spec scores. Re-read at 9753d163", "ref": "#25812" + }, + { + "revision": 8, + "date": "2026-10-29", + "change": "steps[2], acceptance[1] (clause, verify) corrected: #27525 stops the console injecting the 'All reports' sidebar row into every app but Studio. The report index is now a navigation entry an app declares (a `report` item with no `reportName`); showcase declares nav_report_index, so it is served in meta/app and the served-vs-SURFACES diff sees it. The hand-walk of console-added destinations is dropped because there are none. Re-read at UnifiedSidebar.tsx and examples/app-showcase/src/ui/apps/index.ts on the #27525 branch", + "ref": "#27525" } ] }, @@ -572,7 +578,7 @@ "title": "The built-in apps a stock boot REGISTERS (Setup / Account) render every merged-nav destination; app-level and entry-level gates are enforced, not errored — and Studio's deliberate absence is asserted, never hunted", "since": "v15", "status": "active", - "revision": 9, + "revision": 10, "priority": "P0", "surface": "browser", "personas": [ @@ -601,7 +607,7 @@ "confirm the gated entries resolve to ABSENT-not-erroring for the admin: nav_organizations (requiresService org-scoping) is absent in single-org mode; nav_jwks Signing Keys is ABSENT for EVERY persona, admin included — the entry was removed in #7544 because sys_jwks declares apiEnabled:false, so its list answers OBJECT_API_DISABLED (404) for all callers and no permission gate could prune it; SSO Providers is absent unless OS_SSO_ENABLED (knownGap)", "sign in as the plain member: attempt to open Setup (app name setup) — capture the app-level refusal (App.requiredPermissions setup.access); open Account (declares no requiredPermissions) — capture it opening. Studio is not part of this comparison: it is absent for EVERY persona including the admin, so a member failing to reach it proves nothing about the gate (the same reasoning #7544 applied to nav_jwks)", "as the member, GET /api/v1/meta/app for a reachable app and confirm every manage_platform_settings-gated entry (API Keys, the manage_platform_settings settings URLs) is ABSENT from the member's payload — the server prunes, the client does not merely hide. JWKS is deliberately NOT in this list any more: it is absent for admins too, so finding it absent for a member proves nothing about the gate (#7544)", - "cross-check served-vs-rendered: diff each app's meta/app nav destinations against what actually rendered so nothing is silently outside the walk. The console adds one destination meta/app does not carry, to every app but Studio: the 'All reports' sidebar entry (…/reports, ui/packages/app-shell/src/layout/UnifiedSidebar.tsx); walk it in each app and do not read it as an unserved entry" + "cross-check served-vs-rendered: diff each app's meta/app nav destinations against what actually rendered so nothing is silently outside the walk. The console adds no destination meta/app does not carry: the report index (…/reports) renders only where an app's navigation declares a `report` entry with no `reportName`, and no built-in app declares one (#27525 retired the injected 'All reports' row), so an 'All reports' row in Setup or Account is a finding, not a console-added entry" ], "acceptance": [ { @@ -706,6 +712,12 @@ "date": "2026-10-28", "change": "fixtures.knownGaps[2] and steps[9] corrected. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26481, priority:P0, at 756220c4; wave anchor #25807, slice B): the marketplace page no longer reads 'HTTP 502 · fetch failed' but 'Failed to load marketplace — The marketplace catalog could not be reached.' (the wire is still 502 MARKETPLACE_PROXY_FAILED), and the console-added 'All reports' sidebar entry was walked in both apps though the item never named it. Re-read at base fd246c22: MarketplacePage.tsx (unreachable class for MARKETPLACE_PROXY_FAILED), ui/packages/i18n/src/locales/en.ts (marketplace load failed/unreachable), UnifiedSidebar.tsx (the static report index, not in Studio). No clause changed", "ref": "#25807" + }, + { + "revision": 10, + "date": "2026-10-29", + "change": "steps[9] corrected: #27525 stops the console injecting the 'All reports' sidebar row, so Setup and Account, which declare no report index entry, no longer render one; the step now reads such a row as a finding instead of a console-added destination. No clause changed", + "ref": "#27525" } ] }, diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index ff99879fa3d..3ae9396af87 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -223,7 +223,7 @@ export const SCHEMAS: Record = { ], optional: [ { name: 'description', type: 'string', description: 'App description' }, - { name: 'navigation', type: 'NavItem[]', description: 'Menu tree. Every item needs `id` (snake_case) + `label` + a discriminant `type`, plus that type\'s own target key: object -> `objectName`, dashboard -> `dashboardName`, page -> `pageName`, url -> `url`, report -> `reportName`.' }, + { name: 'navigation', type: 'NavItem[]', description: 'Menu tree. Every item needs `id` (snake_case) + `label` + a discriminant `type`, plus that type\'s own target key: object -> `objectName`, dashboard -> `dashboardName`, page -> `pageName`, url -> `url`, report -> `reportName` (omit it on a report item to open the app\'s report index).' }, // `theme` here described a key that is only an alias of `branding`; the // `themes` metadata surface itself was retired by commit 35ad101bc (ADR-0049) — // `app.branding` is the one colour surface. `logo` and `defaultRoute` From 19f4cf83abf7a7e827614166658cabbc9f9bceab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:00:25 +0000 Subject: [PATCH 6/6] fix(app-shell): metadata-admin's report picker reaches the report index by removing reportName (#27525) The navigation inspector's report combo offered reports only, so once a report was picked there was no way back to the index, and the only empty value the console reads as the index, `''`, is one defineStack refuses as an unknown report. The combo now leads with a "Report index" row (a new optional `emptyLabel` on InspectorComboField), and picking it removes the key, as the Studio inspector does. Pinned in AppNavInspector.reportIndexClear-27525.test.tsx. The @objectstack/cli changeset is graded minor with the PR-wide `Metadata-protocol: added` declaration. Claude-Session: https://claude.ai/code/session_01WGufzhEW5k2a4Vd5KSupfu Co-Authored-By: Claude --- .../27525-report-nav-index-app-shell.md | 2 +- .changeset/27525-report-nav-index-cli.md | 8 +- ...vInspector.reportIndexClear-27525.test.tsx | 117 ++++++++++++++++++ .../inspectors/AppNavInspector.tsx | 10 +- .../inspectors/InspectorComboField.tsx | 24 +++- 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.reportIndexClear-27525.test.tsx diff --git a/.changeset/27525-report-nav-index-app-shell.md b/.changeset/27525-report-nav-index-app-shell.md index d5b344686ad..31160d0d9ef 100644 --- a/.changeset/27525-report-nav-index-app-shell.md +++ b/.changeset/27525-report-nav-index-app-shell.md @@ -9,6 +9,6 @@ Metadata-protocol: added The console no longer injects an "All reports" row at the foot of every app's sidebar. The app's report index (`/apps/APP/reports`) is an entry its navigation declares, a `report` item with no `reportName`, drawn and highlighted like any other entry; an app whose navigation declares none shows no index entry. With no `label`, the entry shows the console's own name for the index ("All reports"). The ⌘K palette offers the declared entry, and keeps finding every report the member may open from the reports the app ships, entry or not. -In the Studio, a `report` entry with no report chosen reads as the report index (「报表索引」 / "Report index") instead of an unbound entry, and a save keeps it. +In the Studio, a `report` entry with no report chosen reads as the report index (「报表索引」 / "Report index") instead of an unbound entry, and a save keeps it. The metadata editor's navigation inspector matches it: the report picker leads with a "Report index" row, and picking it removes `reportName` from the entry rather than saving `reportName: ''`. **Migration.** An app that relied on the injected row adds one entry to its navigation where the index belongs: `{ id: 'nav_reports', type: 'report' }`. diff --git a/.changeset/27525-report-nav-index-cli.md b/.changeset/27525-report-nav-index-cli.md index 5b341708dfb..eae3e2cc7f9 100644 --- a/.changeset/27525-report-nav-index-cli.md +++ b/.changeset/27525-report-nav-index-cli.md @@ -1,10 +1,10 @@ --- -'@objectstack/cli': patch +'@objectstack/cli': minor --- docs(cli): `os explain app` says a `report` navigation item may omit `reportName` to open the app's report index -Clause-②: no -Metadata-protocol: none +Clause-②: yes (widening) +Metadata-protocol: added -The `navigation` row of `os explain app` now ends its `report -> reportName` target with "(omit it on a report item to open the app's report index)", matching the widening `@objectstack/spec` ships in this release. Only the help text changes; the command's behaviour and output shape are the same. +The `navigation` row of `os explain app` now ends its `report -> reportName` target with "(omit it on a report item to open the app's report index)", the widening `@objectstack/spec` ships in this release. Only the help text changes; the command's behaviour and output shape are the same. Graded `minor` because this PR declares `Metadata-protocol: added`, which sets the floor for every package it moves. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.reportIndexClear-27525.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.reportIndexClear-27525.test.tsx new file mode 100644 index 00000000000..1449a310560 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.reportIndexClear-27525.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27525 — metadata-admin's nav inspector reaches the report index + * the way the Studio inspector does: by REMOVING `reportName`, never by + * writing `''`. + * + * A `report` item with no `reportName` opens the app's report index. The + * target combo offered only reports, so an author could not get back to the + * index once a report was picked, and the one empty value the console reads + * as the index (`''`) is a value `defineStack` refuses as an unknown report. + * The combo now leads with a 「Report index」 row for a `report` item; picking + * it drops the key. + * + * - picking the index row writes the item with no `reportName` key; + * - the trigger names the index while the item has no `reportName`; + * - CONTROL: picking a report still writes its name; + * - CONTROL: another type's target combo offers no empty row. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MetadataCtx } from '@object-ui/react'; + +// ONE client for the whole file (see AppNavInspector.labelInherits-11196). +const client = vi.hoisted(() => ({ + list: async (type: string) => + type === 'report' ? [{ name: 'pipeline', label: 'Pipeline' }] : type === 'page' ? [{ name: 'home', label: 'Home' }] : [], +})); + +vi.mock('../useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => client, +})); + +import { AppNavInspector } from './AppNavInspector'; + +afterEach(cleanup); + +const METADATA = { + apps: [], + objects: [], + dashboards: [], + reports: [{ name: 'pipeline', label: 'Pipeline' }], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +function inspect(item: Record) { + const onPatch = vi.fn(); + render( + + {}} + locale="en-US" + readOnly={false} + /> + , + ); + return onPatch; +} + +/** The single nav item an `onPatch` call wrote. */ +function written(onPatch: ReturnType): Record { + expect(onPatch).toHaveBeenCalledTimes(1); + return (onPatch.mock.calls[0][0] as { navigation: Array> }).navigation[0]; +} + +const NAMED = { id: 'nav_pipeline', type: 'report', label: 'Pipeline', reportName: 'pipeline' }; +const INDEX = { id: 'nav_reports', type: 'report', label: 'Reports' }; + +describe('objectstack#27525 — AppNavInspector clears a report target by removing reportName', () => { + it("picking the Report index row removes the key, never writes ''", async () => { + const user = userEvent.setup(); + const onPatch = inspect(NAMED); + await user.click(screen.getByRole('combobox', { name: 'Report' })); + await user.click(await screen.findByRole('option', { name: /Report index/ })); + const item = written(onPatch); + expect(Object.prototype.hasOwnProperty.call(item, 'reportName')).toBe(false); + expect(item).toEqual({ id: 'nav_pipeline', type: 'report', label: 'Pipeline' }); + }); + + it('the trigger names the index while the item has no reportName', () => { + inspect(INDEX); + expect(screen.getByRole('combobox', { name: 'Report' }).textContent).toContain('Report index'); + }); + + it('CONTROL: picking a report writes its name', async () => { + const user = userEvent.setup(); + const onPatch = inspect(INDEX); + await user.click(screen.getByRole('combobox', { name: 'Report' })); + await user.click(await screen.findByRole('option', { name: /pipeline/ })); + expect(written(onPatch).reportName).toBe('pipeline'); + }); + + it("CONTROL: a page item's target combo offers no empty row", async () => { + const user = userEvent.setup(); + inspect({ id: 'nav_home', type: 'page', label: 'Home', pageName: 'home' }); + await user.click(screen.getByRole('combobox', { name: 'Page' })); + await screen.findByRole('option', { name: /home/ }); + expect(screen.queryByRole('option', { name: /Report index/ })).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx index dd7896542f0..86971bfca34 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx @@ -452,7 +452,15 @@ export function AppNavInspector({ selection, draft, name, onPatch, onClearSelect value={String((node as Record)[target.targetKey] ?? '')} options={targetOptions.options} loading={targetOptions.loading} - onCommit={(v) => patch({ [target.targetKey as string]: v })} + // objectstack#27525 — a `report` item's empty pick is the report + // index: the `reportName` key is REMOVED, as the Studio inspector + // does, never written as `''`. + emptyLabel={navType === 'report' ? t('engine.inspector.appNav.reportIndex', locale) : undefined} + onCommit={(v) => + v === '' && navType === 'report' + ? patch({}, [target.targetKey as string]) + : patch({ [target.targetKey as string]: v }) + } disabled={readOnly} mono /> diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/InspectorComboField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/InspectorComboField.tsx index d91de3633d4..4e502ed1b6a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/InspectorComboField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/InspectorComboField.tsx @@ -125,6 +125,15 @@ export type InspectorComboFieldProps = InspectorComboFieldNaming & { allowCustom?: boolean; /** Render the trigger value in a monospace font. */ mono?: boolean; + /** + * The face of the empty value, for a target whose absence means something + * (a `report` nav item with no `reportName` opens the report index, + * objectstack#27525). Given, the list leads with a row of this text that + * commits `''`, and the trigger shows it while the value is empty; the + * caller decides what `''` writes. Omitted, the combo offers no way back to + * empty, as before. + */ + emptyLabel?: string; /** Override the trigger label for the currently-selected custom value. */ className?: string; }; @@ -153,6 +162,7 @@ export function InspectorComboField({ loading, allowCustom = true, mono, + emptyLabel, className, }: InspectorComboFieldProps) { const [open, setOpen] = React.useState(false); @@ -177,7 +187,9 @@ export function InspectorComboField({ const triggerId = label ? autoId : id; const selected = options.find((o) => o.value === value); - const triggerText = selected ? selected.label : value || (loading ? t('engine.inspector.combo.loading', locale) : placeholder); + const triggerText = selected + ? selected.label + : value || (emptyLabel ?? (loading ? t('engine.inspector.combo.loading', locale) : placeholder)); // A chosen option with a label of its own shows that label in the body // font; only a raw value takes the caller's `mono` (objectui#12303). const selectedLabelled = !!selected && !!selected.label && selected.label !== selected.value; @@ -241,7 +253,15 @@ export function InspectorComboField({ - {!showCustom && filtered.length === 0 && {emptyText}} + {!showCustom && filtered.length === 0 && !emptyLabel && {emptyText}} + {emptyLabel && !trimmed && ( + + commit('')}> + + {emptyLabel} + + + )} {showCustom && ( commit(trimmed)}>