From b653e9f8436dacb34bd4880d787820bfb245667c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 11:19:32 +0000 Subject: [PATCH] fix(app-shell): point the three sys-objects producers at the canonical metadata route (#3739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AppSidebar.systemFallbackNavigation`, `UnifiedSidebar.homeNavigation` and `console/home/QuickActions` all aimed at `/apps/setup/system/metadata/object`. That spelling is not a page: `apps/console`'s host fragment declares `system/metadata/:metadataType` with `MetadataRedirect` as its element, a bare Navigate onto `/apps/setup/metadata/object` — the engine's real route. Every click paid a redundant hop plus a re-render. Same defect #3660 fixed for `sys-datasources`, declared one line below `sys-objects` in both sidebar arrays; it was missed there because the two entries reach their aliases through different route tables (app-shell's `component/metadata/resource` vs the host's `system/metadata/:type` rewrite). Endpoints are byte-identical to what the alias hop computed; only the intermediate hop is gone. The alias routes are untouched and stay reachable for bookmarks and external links. Pins: `systemNavObjectsHop.test.tsx` measures the redirect chain for both sidebars (one entry = direct arrival) with a CONTROL case proving the alias still forwards; `systemNavSettingsTarget.test.tsx` and `QuickActions.settingsTarget.test.tsx` pin the URL each of the three producers emits. #3611's "the sibling card is unchanged" anchor in the QuickActions suite is rewritten rather than extended — that premise is what this issue falsified. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../nav-sys-objects-canonical-route-3739.md | 13 + .../src/console/home/QuickActions.tsx | 9 +- .../QuickActions.settingsTarget.test.tsx | 37 ++- packages/app-shell/src/layout/AppSidebar.tsx | 19 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 14 +- .../__tests__/systemNavObjectsHop.test.tsx | 275 ++++++++++++++++++ .../systemNavSettingsTarget.test.tsx | 42 ++- 7 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 .changeset/nav-sys-objects-canonical-route-3739.md create mode 100644 packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx diff --git a/.changeset/nav-sys-objects-canonical-route-3739.md b/.changeset/nav-sys-objects-canonical-route-3739.md new file mode 100644 index 0000000000..0f95492737 --- /dev/null +++ b/.changeset/nav-sys-objects-canonical-route-3739.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +Point the `sys-objects` navigation entries at the canonical metadata-admin route instead of the `system/metadata/object` alias, removing a redirect hop from each click (objectui#3739). + +`AppSidebar.systemFallbackNavigation`, `UnifiedSidebar.homeNavigation` and `console/home/QuickActions` all spelled this target `/apps/setup/system/metadata/object`. That is not a page: `apps/console`'s host fragment declares `system/metadata/:metadataType` with `MetadataRedirect` as its element, which immediately navigates on to `/apps/setup/metadata/object` — the engine's real route (`metadata/:type`, `MetadataResourceListPage`). Every click therefore paid a redundant hop plus a re-render to reach a destination the navigation could name directly. All three now name it. + +This is the same defect objectui#3660 fixed for `sys-datasources`, declared on the line immediately below `sys-objects` in both sidebar arrays. It was missed there because the two entries reached their aliases through different route tables — `sys-datasources` through app-shell's own `component/metadata/resource` alias, `sys-objects` through the host's `system/metadata/:type` rewrite. + +The landing page is unchanged, byte for byte: the new URL is exactly what the alias hop was already computing (`object` percent-encodes to itself, and no producer carried a query or hash). Only the intermediate hop is gone. Of the three producers, the two sidebars are live; `QuickActions` has no JSX call site today, so its change is a guard against the dead link returning with the component. + +The alias routes stay declared and untouched: bookmarks and external links still arrive on them and are still forwarded. diff --git a/packages/app-shell/src/console/home/QuickActions.tsx b/packages/app-shell/src/console/home/QuickActions.tsx index 67bbb49aca..370a9519f3 100644 --- a/packages/app-shell/src/console/home/QuickActions.tsx +++ b/packages/app-shell/src/console/home/QuickActions.tsx @@ -36,7 +36,14 @@ export function QuickActions() { label: t('home.quickActions.manageObjects', { defaultValue: 'Manage Objects' }), description: t('home.quickActions.manageObjectsDesc', { defaultValue: 'Configure data models' }), icon: Database, - href: '/apps/setup/system/metadata/object', + // #3739 — the metadata-admin engine's CANONICAL route, not the legacy + // `…/system/metadata/object` alias this card used to carry. That alias is + // not a page: `apps/console`'s host fragment serves it with + // `MetadataRedirect`, a bare `` onto the URL below, so every + // click paid a redundant hop plus a re-render. Same defect and same + // remedy as #3660's `sys-datasources` entry in both sidebars. The alias + // routes stay for bookmarks and external links. + href: '/apps/setup/metadata/object', iconBg: 'bg-gradient-to-br from-violet-500/15 to-purple-500/10 ring-violet-500/20', iconText: 'text-violet-600 dark:text-violet-400', hoverBorder: 'hover:border-violet-500/40', diff --git a/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx b/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx index 7f1799f513..63734b78d5 100644 --- a/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx @@ -24,6 +24,25 @@ * Configured" empty state's own URL on a zero-app deployment. The card's * sibling ("Manage Objects") already spelled `/apps/setup/system/...`, which is * what made this one the odd entry out. + * + * ## The "Manage Objects" card (objectui#3739) + * + * That sibling's `/apps/setup/system/...` prefix is what made it the anchor for + * #3611 — and it was the wrong URL for a different reason, unnoticed at the + * time. `…/system/metadata/object` is not a page: `apps/console`'s host fragment + * serves it with `MetadataRedirect`, a bare `` onto + * `/apps/setup/metadata/object`, so the card bought a redundant hop plus a + * re-render. It is the third of the three producers #3739 re-points; the other + * two are the `sys-objects` entries in both sidebars + * (`layout/__tests__/systemNavSettingsTarget.test.tsx`). + * + * So the third case below is REWRITTEN, not extended: it used to assert this + * card as the untouched consistency anchor, and that premise is what #3739 + * falsified. It now pins the canonical target — the same discipline as the + * sidebar suites, which replace the old shape rather than keeping the bug and + * the fix side by side. Whether that URL resolves in one hop is a property of + * the URL rather than of this producer, and is measured in + * `layout/__tests__/systemNavObjectsHop.test.tsx`. */ import '@testing-library/jest-dom/vitest'; @@ -60,6 +79,9 @@ function renderQuickActions() { const SYSTEM_HUB = '/apps/setup/system'; +/** Where the metadata-admin engine really serves the object list (objectui#3739). */ +const OBJECTS_TARGET = '/apps/setup/metadata/object'; + describe('QuickActions system-settings card (objectui#3611, dormant)', () => { it('DORMANCY PRECONDITION: nothing renders this component, so the fix is a guard, not a user-visible change', async () => { // Recorded as an assertion rather than prose so it goes red the day the @@ -99,12 +121,23 @@ describe('QuickActions system-settings card (objectui#3611, dormant)', () => { expect(screen.getByTestId('landing')).toHaveTextContent(SYSTEM_HUB); }); - it('REGRESSION: the sibling card that was already hub-scoped is unchanged', async () => { + it('the Manage Objects card targets the canonical metadata route, not the system alias (objectui#3739)', async () => { + // Replaces #3611's "the sibling is unchanged" anchor. That assertion was + // true of the URL and wrong about it: `…/system/metadata/object` is an alias + // the host only forwards, so pinning it froze the extra hop in place. const user = userEvent.setup(); renderQuickActions(); await user.click(screen.getByTestId('quick-action-manage-objects')); - expect(screen.getByTestId('landing')).toHaveTextContent(`${SYSTEM_HUB}/metadata/object`); + expect(screen.getByTestId('landing')).toHaveTextContent(OBJECTS_TARGET); + // `toHaveTextContent` matches on substring, and neither spelling contains + // the other (`/apps/setup/system/metadata/object` has the extra segment in + // the MIDDLE), so the assertion above already separates them. This second + // one adds nothing to the logic and is kept for the diff: a revert then + // fails naming the alias, instead of reporting two similar-looking paths. + expect(screen.getByTestId('landing')).not.toHaveTextContent( + `${SYSTEM_HUB}/metadata/object`, + ); }); }); diff --git a/packages/app-shell/src/layout/AppSidebar.tsx b/packages/app-shell/src/layout/AppSidebar.tsx index f0e5d76743..f7046d0455 100644 --- a/packages/app-shell/src/layout/AppSidebar.tsx +++ b/packages/app-shell/src/layout/AppSidebar.tsx @@ -316,7 +316,12 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri // `/apps/setup` renders `AppContent`'s "No Apps Configured" empty state (its // `isSystemRoute` guard needs a `/system` segment), so the cluster's head entry // was a dead link in the one situation the cluster exists for. Every sibling - // below already spells `/apps/setup/system/...`. + // below already spelled `/apps/setup/system/...` — the two metadata-admin + // entries excepted, and only since: they name the engine's canonical + // `/apps/setup/metadata/:type` routes (#3660, #3739), because for THOSE two + // the `system/...` spelling is a redirect rather than a page. Consistency of + // prefix is not the invariant here; naming the route that actually renders + // is. const systemFallbackNavigation: NavigationItem[] = React.useMemo(() => { const items: NavigationItem[] = [ { id: 'sys-settings', label: t('layout.systemNav.systemSettings', { defaultValue: 'System Settings' }), type: 'url' as const, url: '/apps/setup/system', icon: 'settings' }, @@ -326,7 +331,17 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri items.push({ id: 'sys-marketplace', label: t('layout.systemNav.appMarketplace', { defaultValue: 'App Marketplace' }), type: 'url' as const, url: '/apps/setup/system/marketplace', icon: 'store' }); } items.push( - { id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url' as const, url: '/apps/setup/system/metadata/object', icon: 'database' }, + // #3739 — `sys-objects` names the metadata-admin engine's CANONICAL route + // `/apps/setup/metadata/object`, not the legacy + // `…/system/metadata/object` alias it used to carry. That alias is not a + // page either: `apps/console`'s host fragment declares it as + // `MetadataRedirect`, which ``s onto exactly the URL spelled + // here, so every click paid a redundant hop plus a re-render — the same + // defect #3660 fixed one line below, on the entry immediately after this + // one, and the same remedy. The alias routes stay declared in the host + // (bookmarks and external links still arrive on them) — we simply stop + // aiming our own navigation at them. + { id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url' as const, url: '/apps/setup/metadata/object', icon: 'database' }, // #3660 — `sys-datasources` names the metadata-admin engine's CANONICAL // route `/apps/setup/metadata/datasource`, not the legacy // `…/component/metadata/resource?type=datasource` alias it used to carry. diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 9da813e8ef..50afa55901 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -327,12 +327,22 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { // `/system` form resolves in BOTH branches (`extraRoutesNoApp` with no // active app, `extraRoutes` once one exists), so an app-bearing deployment // now reaches the hub here instead of whatever app `/apps/setup` fell back - // to. Every sibling below already spells `/apps/setup/system/...`. + // to. Every sibling below already spelled `/apps/setup/system/...` — the + // two metadata-admin entries excepted, and only since: they name the + // engine's canonical `/apps/setup/metadata/:type` routes (#3660, #3739), + // because for THOSE two the `system/...` spelling is a redirect rather + // than a page. const adminItems: NavigationItem[] = [ { id: 'sys-settings', label: t('layout.systemNav.systemSettings', { defaultValue: 'System Settings' }), type: 'url' as const, url: '/apps/setup/system', icon: 'settings' }, { id: 'sys-apps', label: t('layout.systemNav.applications', { defaultValue: 'Applications' }), type: 'url' as const, url: '/apps/setup/system/apps', icon: 'layout-grid' }, { id: 'sys-marketplace', label: t('layout.systemNav.appMarketplace', { defaultValue: 'App Marketplace' }), type: 'url' as const, url: '/apps/setup/system/marketplace', icon: 'store' }, - { id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url' as const, url: '/apps/setup/system/metadata/object', icon: 'database' }, + // #3739 — canonical `…/metadata/object`, not the legacy + // `…/system/metadata/object` alias. See the twin entry in + // `AppSidebar.systemFallbackNavigation` for the full note: the alias is + // served by `apps/console`'s `MetadataRedirect`, a bare `` + // onto this very URL, so pointing here removes a hop without moving the + // landing page. The alias routes themselves are untouched. + { id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url' as const, url: '/apps/setup/metadata/object', icon: 'database' }, // #3660 — canonical `…/metadata/datasource`, not the legacy // `…/component/metadata/resource?type=datasource` alias. See the twin // entry in `AppSidebar.systemFallbackNavigation` for the full note: the diff --git a/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx new file mode 100644 index 0000000000..684d10afff --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `sys-objects` entry both sidebars carry arrives at the canonical + * metadata-admin route with ZERO redirects (objectui#3739). + * + * ## What was wrong + * + * `AppSidebar.systemFallbackNavigation` and `UnifiedSidebar.homeNavigation` each + * hold their own literal for this entry, and both spelled it + * `/apps/setup/system/metadata/object`. That is not a page: `apps/console`'s host + * fragment declares `system/metadata/:metadataType` with `MetadataRedirect` as + * its element, which immediately ``s onto + * `/apps/setup/metadata/object`. Every click therefore paid a redundant hop plus + * a re-render to reach a URL the nav item could name itself. + * + * This is the same defect objectui#3660 fixed for `sys-datasources` — declared on + * the line immediately BELOW this entry in both arrays — and it was missed there, + * because the two entries reach their aliases through different route tables: + * `sys-datasources` went through app-shell's own `component/metadata/resource` + * alias, `sys-objects` through the host's `system/metadata/:type` rewrite. Same + * cost, different producer of the hop, so the grep that found one did not find + * the other. The third producer of this URL, `console/home/QuickActions`, is + * pinned in `console/home/__tests__/QuickActions.settingsTarget.test.tsx`. + * + * ## What this file measures, and how it differs from the sibling pins + * + * `systemNavSettingsTarget.test.tsx` asserts the URL each entry CARRIES — a + * string equality on an `href`. This file asks the next question: what that URL + * costs to resolve. `ChainRecorder` records every distinct location the router + * settles on, so a direct arrival is a one-entry chain and an alias arrival is + * two. Endpoint-only assertions cannot tell those apart — both finish at + * `/apps/setup/metadata/object`, which is exactly why the detour survived #3660 + * and #3611 (the latter even pinned the alias spelling as its consistency + * anchor). `systemNavDatasourcesHop.test.tsx` is this file's twin for the entry + * one line below; the two are kept separate because the alias route being + * mirrored, and the file it is transcribed from, differ. + * + * The href is READ OUT of a real sidebar render rather than typed in here, so the + * producer under test is the component's own literal. Both sidebars are driven, + * because both hold a copy. + * + * ## The alias mirror below, and what rests on its fidelity + * + * `AliasMetadataRedirect` mirrors `MetadataRedirect` from + * `apps/console/src/AppContent.tsx` — transcribed, not imported, because + * `apps/console` is a different Vitest project (the same reason, and the same + * transcription, as `console/__tests__/AppContent.noAppComponentRoutes.test.tsx` + * and `AppContent.pseudoRouteSegments.test.tsx`; keep the regex and the target + * construction identical to the original). + * + * Nothing this file ASSERTS depends on that mirror being faithful: the green + * expectation is `chain` equals `[canonical URL]`, which holds iff the sidebar's + * URL matches a canonical route directly. The mirror only shapes what the FAILURE + * looks like — with it, restoring either literal produces a two-entry chain whose + * first entry is the alias, i.e. the extra hop printed literally in the diff + * rather than merely implied. The real host route's own behaviour is pinned + * against the real route table in `apps/console`'s + * `__tests__/AppContent.legacyRedirects.test.tsx`, and end-to-end through + * app-shell's route tree in `console/__tests__/AppContent.noAppComponentRoutes.test.tsx`. + * + * ## Scope + * + * Where the sidebars AIM. The alias routes are untouched and stay reachable for + * bookmarks and external links; nothing here asks for their removal, and the + * CONTROL case below pins that they still work. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; + +// --------------------------------------------------------------------------- +// Mocks — providers and console-only chrome, matching the sibling sidebar +// suites (`systemNavDatasourcesHop.test.tsx`, `systemNavSettingsTarget.test.tsx`). +// `react-router-dom` stays REAL: the chain measurement below IS the router. +// --------------------------------------------------------------------------- + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback, + dashboardLabel: ({ label }: { label?: string }) => label, + navGroupLabel: (_a: string, _g: string, fallback?: string) => fallback, + }), +})); + +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: null, signOut: vi.fn(), isAuthEnabled: false, activeOrganization: null }), + useIsWorkspaceAdmin: () => true, + getUserInitials: () => 'U', +})); + +vi.mock('@object-ui/permissions', () => ({ + usePermissions: () => ({ can: () => true, hasCapabilities: () => true }), +})); + +/** The zero-app deployment `systemFallbackNavigation` exists for. */ +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: [], objects: [] }), +})); + +vi.mock('../../providers/ExpressionProvider', () => ({ + useExpressionContext: () => ({ evaluator: null }), + evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false', +})); + +vi.mock('../../utils', () => ({ + resolveI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => + apps.find((a) => a?.name === segment), + appRouteSegment: (app: { name?: string }) => app?.name, +})); + +vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null })); +vi.mock('@object-ui/components', async (importOriginal) => ({ + ...(await importOriginal>()), + getLazyIcon: () => () => null, +})); + +vi.mock('../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) })); +vi.mock('../../hooks/useFavorites', () => ({ + useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }), +})); +vi.mock('../../hooks/useNavPins', () => ({ + useNavPins: () => ({ togglePin: vi.fn(), applyPins: (items: unknown) => items }), +})); +vi.mock('../../hooks/useNavActionDispatch', () => ({ + useNavActionDispatch: () => vi.fn(), +})); +vi.mock('../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ context: 'home', currentAppName: undefined }), +})); +vi.mock('../ContextSelectors', () => ({ + useAppContextSelectors: () => ({ contextValues: {}, element: null }), + contextSelectorQueryKey: (id: string) => (id === 'active_package' ? 'package' : id), + STUDIO_PACKAGE_SELECTOR_ID: 'active_package', +})); +vi.mock('../LocalizedSidebarTrigger', () => ({ + LocalizedSidebarTrigger: () => null, +})); + +import { SidebarProvider } from '@object-ui/components'; +import { AppSidebar } from '../AppSidebar'; +import { UnifiedSidebar } from '../UnifiedSidebar'; + +/** Where the metadata-admin engine really serves the object list. */ +const CANONICAL = '/apps/setup/metadata/object'; + +/** The legacy spelling both entries used to carry — an alias, not a page. */ +const ALIAS = '/apps/setup/system/metadata/object'; + +/** + * Records every distinct location the router settles on: one entry means the URL + * matched a real route on arrival, two means it was forwarded once. + */ +function ChainRecorder({ sink }: { sink: string[] }) { + const location = useLocation(); + const here = `${location.pathname}${location.search}`; + if (sink[sink.length - 1] !== here) sink.push(here); + return null; +} + +/** Terminal probe: reports which route matched and with which params. */ +function Probe({ id }: { id: string }) { + const params = useParams(); + return
{JSON.stringify(params)}
; +} + +/** + * Mirror of `MetadataRedirect` from `apps/console/src/AppContent.tsx` — the + * `system/metadata/*` legs of its `systemRoutes` fragment. Present so a restored + * alias literal shows up as a real extra hop rather than as a dead end; see this + * file's header on what does and does not rest on its fidelity. + */ +function AliasMetadataRedirect() { + const { metadataType, itemName } = useParams<{ metadataType?: string; itemName?: string }>(); + const location = useLocation(); + const prefix = location.pathname.replace(/\/(system\/)?metadata(\/.*)?$/, ''); + const base = `${prefix}/metadata`; + const target = !metadataType + ? base + : itemName + ? `${base}/${encodeURIComponent(metadataType)}/${itemName}` + : `${base}/${encodeURIComponent(metadataType)}`; + return ; +} + +/** Renders a sidebar, reads the entry's href, then unmounts it. */ +function objectsHrefFrom(ui: React.ReactElement, at: string): string { + const view = render( + + {ui} + , + ); + const href = screen.getByRole('link', { name: 'Object Manager' }).getAttribute('href'); + view.unmount(); + expect(href).toBeTruthy(); + return href as string; +} + +/** Drops the emitted URL into a router that knows both spellings. */ +function chainFor(url: string): string[] { + const chain: string[] = []; + render( + + + + + } /> + } /> + } /> + } /> + + } /> + + , + ); + return chain; +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe('sidebar sys-objects reaches the canonical route directly (objectui#3739)', () => { + it('AppSidebar: the zero-app fallback cluster arrives with NO redirect', () => { + const href = objectsHrefFrom( + {}} />, + '/apps/setup', + ); + + // Precondition: with zero apps this really is the fallback cluster, so the + // href just read is the one `systemFallbackNavigation` declares. + expect(href).toBe(CANONICAL); + + const chain = chainFor(href); + + // One entry = matched on arrival. Before the fix this was two, the alias + // first. + expect(chain).toEqual([CANONICAL]); + expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"'); + expect(chain.some((entry) => entry.includes('system/metadata'))).toBe(false); + }); + + it('UnifiedSidebar: the /home Administration cluster arrives with NO redirect', () => { + const href = objectsHrefFrom(, '/home'); + + expect(href).toBe(CANONICAL); + + const chain = chainFor(href); + + expect(chain).toEqual([CANONICAL]); + expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"'); + expect(chain.some((entry) => entry.includes('system/metadata'))).toBe(false); + }); + + it('CONTROL: the alias still resolves, and doing so costs the hop the entries used to pay', () => { + // The alias is deliberately KEPT (bookmarks, external links), so its + // continued reachability is part of the contract, not collateral. This also + // proves the two assertions above are not vacuous: the probe table really + // does forward this spelling, so a sidebar that still emitted it would be + // measured at two entries rather than silently falling to `unmatched`. + const chain = chainFor(ALIAS); + + expect(chain).toEqual([ALIAS, CANONICAL]); + expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"'); + }); +}); diff --git a/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx b/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx index dc132a897f..579694f46d 100644 --- a/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx +++ b/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx @@ -56,6 +56,23 @@ * as above: replace the old shape, do not keep it alongside). The AppSidebar * test below gained the matching assertion at the same time — that sidebar * carries its own copy of the literal and had none. + * + * ## The `Object Manager` entry (objectui#3739) + * + * #3660 re-pointed `sys-datasources` and stopped there; `sys-objects`, the entry + * declared on the line immediately ABOVE it in both sidebars, kept spelling + * `/apps/setup/system/metadata/object`. That is a legacy alias too — served by + * `apps/console`'s `MetadataRedirect`, which ``s onto + * `/apps/setup/metadata/object` — so the same redundant hop survived on the + * neighbouring click target. Both literals now name the destination, and the + * expectation here is REWRITTEN rather than kept alongside, exactly as the + * `Datasources` note above describes. + * + * Note what this file does NOT claim: that the URL resolves in one hop. That is + * a property of the URL, not of the producer, and is measured once per family in + * `systemNavDatasourcesHop.test.tsx` / `systemNavObjectsHop.test.tsx`. Here the + * assertion is string equality on the href each sidebar emits — which is the + * half that can drift per copy, since each sidebar holds its own literal. */ import '@testing-library/jest-dom/vitest'; @@ -172,6 +189,15 @@ const SYSTEM_HUB = '/apps/setup/system'; */ const DATASOURCES_TARGET = '/apps/setup/metadata/datasource'; +/** + * `Object Manager` names the same engine's canonical route (objectui#3739). This + * entry used to read `${SYSTEM_HUB}/metadata/object` — an alias served by + * `apps/console`'s `MetadataRedirect`, a bare `` onto the URL below. + * Replaced here rather than kept alongside, for the reason spelled out in this + * file's header: the repo pins the fix, not both the bug and the fix. + */ +const OBJECTS_TARGET = '/apps/setup/metadata/object'; + /** * Every entry of the `/home` Administration cluster, in declaration order. * Asserted whole rather than by sample: the defect was that the group's @@ -182,7 +208,7 @@ const ADMINISTRATION_ENTRIES: ReadonlyArray = [ ['System Settings', SYSTEM_HUB], ['Applications', `${SYSTEM_HUB}/apps`], ['App Marketplace', `${SYSTEM_HUB}/marketplace`], - ['Object Manager', `${SYSTEM_HUB}/metadata/object`], + ['Object Manager', OBJECTS_TARGET], ['Datasources', DATASOURCES_TARGET], ['Users', `${SYSTEM_HUB}/users`], ['Organizations', `${SYSTEM_HUB}/organizations`], @@ -245,6 +271,20 @@ describe('sidebar system-settings target (objectui#3590)', () => { 'href', DATASOURCES_TARGET, ); + + // objectui#3739 — `sys-objects`, the twin one line above `sys-datasources` + // in this same array. Pinned in both sidebars for the same reason: two + // independent literals, either of which can drift back to the alias alone. + expect(screen.getByRole('link', { name: 'Object Manager' })).toHaveAttribute( + 'href', + OBJECTS_TARGET, + ); + // The alias spelling by name, so a revert is named in the diff rather than + // showing up as an unexplained string difference. + expect(screen.getByRole('link', { name: 'Object Manager' })).not.toHaveAttribute( + 'href', + `${SYSTEM_HUB}/metadata/object`, + ); }); it('UnifiedSidebar: /home renders the Administration cluster as a GROUP, with all nine entries reachable (objectui#3609)', () => {