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..31160d0d9ef --- /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. 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 new file mode 100644 index 00000000000..eae3e2cc7f9 --- /dev/null +++ b/.changeset/27525-report-nav-index-cli.md @@ -0,0 +1,10 @@ +--- +'@objectstack/cli': minor +--- + +docs(cli): `os explain app` says a `report` navigation item may omit `reportName` to open the app's report index + +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)", 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/.changeset/27525-report-nav-index-i18n.md b/.changeset/27525-report-nav-index-i18n.md new file mode 100644 index 00000000000..5be62de0491 --- /dev/null +++ b/.changeset/27525-report-nav-index-i18n.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': minor +--- + +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..6a3d1197b7a --- /dev/null +++ b/.changeset/27525-report-nav-index-types.md @@ -0,0 +1,10 @@ +--- +'@object-ui/types': minor +--- + +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/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/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/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` 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.', + ), })); /** 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.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 21f9cdfc0f2..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 /> @@ -479,6 +487,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/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)}> 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/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.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 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 9cac5831f18..62c97561dda 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//'),