Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/nav-canonical-metadata-routes-3660.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/console': patch
'@object-ui/app-shell': patch
---

Point the last four navigation producers at the canonical metadata-admin routes instead of the deprecated `component/metadata` alias, removing a redirect hop from each (objectui#3660).

The System hub's "Metadata" and "Datasources" cards aimed at `…/component/metadata/directory` and `…/component/metadata/resource?type=datasource`, and the `sys-datasources` entry in both `AppSidebar.systemFallbackNavigation` and `UnifiedSidebar.homeNavigation` spelled the latter too. app-shell declares those spellings as legacy *aliases*, not pages: their route element is `LegacyMetadataRedirect`, which immediately navigates on to `…/metadata` and `…/metadata/datasource`. Every click on any of the four therefore paid a redundant hop plus a re-render to reach a destination the navigation could name directly. All four now name it.

The landing pages are unchanged, byte for byte — the new URLs are exactly what the alias hop was already computing (`datasource` percent-encodes to itself, and neither producer carried a query or hash beyond the `?type=` the alias itself consumed). Only the intermediate hop is gone.

The alias routes stay declared in both `AppContent` branches, untouched: bookmarks and external links still arrive on them and are still forwarded. This completes objectui#3639, which corrected the console host's two redirects and enumerated these four as the remainder.
16 changes: 14 additions & 2 deletions apps/console/src/pages/system/SystemHubPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export function SystemHubPage() {
// Metadata: single entry point to the server-driven metadata-admin engine.
// Per-type cards were removed when the engine started auto-listing every
// type registered with the framework (`/api/v1/meta`).
//
// The two metadata cards below name the engine's CANONICAL routes —
// `…/metadata` (directory) and `…/metadata/:type` (one type's list), declared
// by `DefaultAppContent` in `@object-ui/app-shell`. NOT the older
// `…/component/metadata/{directory,resource?type=}` spelling they used to
// carry (objectui#3660): app-shell declares that as a legacy *alias* whose
// route element is `LegacyMetadataRedirect`, i.e. a bare `<Navigate>` onto
// precisely the targets below. Aiming a card at it bought a redundant hop and
// a re-render on every click. The alias routes themselves stay declared —
// bookmarks and external links still land on them — this only stops the hub
// feeding its own traffic through them (same disposition as objectui#3639,
// which corrected the console host's two redirects).
const metadataTypeCards: HubCard[] = [
{
title: 'Applications',
Expand All @@ -115,15 +127,15 @@ export function SystemHubPage() {
title: 'Metadata',
description: 'Browse and edit every metadata type the platform exposes',
icon: Database,
href: `${basePath}/component/metadata/directory`,
href: `${basePath}/metadata`,
countLabel: '',
count: null,
},
{
title: 'Datasources',
description: 'Connect external databases and sync their tables in as objects',
icon: Boxes,
href: `${basePath}/component/metadata/resource?type=datasource`,
href: `${basePath}/metadata/datasource`,
countLabel: '',
count: null,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The System hub's two metadata cards land on the CANONICAL metadata-admin
* routes in one hop, never on the deprecated alias (objectui#3660).
*
* ## What was wrong
*
* The "Metadata" card aimed at `…/component/metadata/directory` and the
* "Datasources" card at `…/component/metadata/resource?type=datasource`.
* app-shell declares both spellings as legacy *aliases*, not pages: their route
* element is `LegacyMetadataRedirect`, which immediately `<Navigate>`s onto
* `…/metadata` and `…/metadata/datasource` respectively. So every click on
* either card paid a redundant hop plus a re-render to reach a destination the
* hub could name directly.
*
* These are the third and fourth of the six producers enumerated while fixing
* objectui#3639; that issue's PR corrected the console host's two redirects
* (`ObjectRedirect` / `MetadataRedirect`) but the two cards here were outside
* its declared file surface. The remaining two producers are the `sys-datasources`
* entries in app-shell's two sidebars, pinned by that package's
* `layout/__tests__/systemNavDatasourcesHop.test.tsx`.
*
* ## What these tests measure
*
* `ChainRecorder` records every distinct location the router settles on, so the
* assertion is about the *chain*, not only its endpoint. Endpoint-only
* assertions cannot tell a one-hop click from a two-hop one — both finish at
* `…/metadata/datasource` — and one hop versus two is the whole of this issue.
* The technique is lifted from `__tests__/AppContent.legacyRedirects.test.tsx`
* (objectui#3639), deliberately, so both halves of the same defect are measured
* the same way.
*
* The route table below declares the canonical routes AND the alias routes as
* terminal probes. That is what makes the direction falsifiable: restore either
* card's old href and the alias probe renders, the canonical probe does not, and
* the recorded chain ends on `component/metadata`.
*
* `SystemHubPage` is the real component, mounted at the real `system` path, so
* the hrefs measured are the ones its own render path emits — the card list is
* not transcribed here. A transcribed copy is exactly how the alias spelling
* survived this long.
*
* ## Scope
*
* This measures where the hub AIMS. The alias routes themselves are untouched
* and stay reachable for bookmarks and external links (app-shell's
* `console/__tests__/AppContent.noAppComponentRoutes.test.tsx` drives the real
* alias end to end); nothing here asks for their removal.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route, useLocation, useParams } from 'react-router-dom';

// The hub's only two data dependencies. `@object-ui/components` stays REAL so
// the cards, their click handlers and their test ids are the ones the hub
// actually renders.
vi.mock('@object-ui/app-shell', () => ({
useAdapter: () => ({ find: async () => ({ data: [] }) }),
}));
vi.mock('@object-ui/auth', () => ({
useIsWorkspaceAdmin: () => true,
}));

import { SystemHubPage } from '../SystemHubPage';

/**
* Records every distinct location the router settles on. `<Navigate replace>`
* re-renders the tree once per hop, so a two-hop chain shows up as three
* entries and a one-hop chain as two.
*/
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 <div data-testid={id}>{JSON.stringify(params)}</div>;
}

/**
* The destinations both spellings compete for. `metadata/*` mirrors app-shell's
* canonical metadata-admin routes; `component/metadata/*` mirrors the legacy
* aliases it declares alongside them. Both are terminal here — this file asks
* *which one the hub aims at*, not what the alias does afterwards.
*/
function renderHub(): string[] {
const chain: string[] = [];
render(
<MemoryRouter initialEntries={['/apps/setup/system']}>
<ChainRecorder sink={chain} />
<Routes>
<Route path="/apps/:appName">
<Route path="system" element={<SystemHubPage />} />
<Route path="metadata" element={<Probe id="canonical-directory" />} />
<Route path="metadata/:type" element={<Probe id="canonical-list" />} />
<Route path="component/metadata/directory" element={<Probe id="alias-directory" />} />
<Route path="component/metadata/resource" element={<Probe id="alias-resource" />} />
<Route path="component/metadata/resource/*" element={<Probe id="alias-resource" />} />
</Route>
<Route path="*" element={<Probe id="unmatched" />} />
</Routes>
</MemoryRouter>,
);
return chain;
}

/** No alias route may appear anywhere in the chain, not merely at its end. */
function expectNoAliasAnywhere(chain: string[]) {
expect(chain.some((entry) => entry.includes('component/metadata'))).toBe(false);
expect(screen.queryByTestId('alias-resource')).not.toBeInTheDocument();
expect(screen.queryByTestId('alias-directory')).not.toBeInTheDocument();
}

/**
* Click a hub card and let the counts effect settle first. `fetchCounts` runs
* from a `useEffect` and setStates; clicking before it resolves produces act()
* noise unrelated to anything measured here.
*/
async function clickCard(testId: string) {
const card = await screen.findByTestId(testId);
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
await userEvent.click(card);
}

describe('System hub metadata cards → canonical metadata routes (objectui#3660)', () => {
it('the "Metadata" card reaches the metadata directory in ONE hop', async () => {
const chain = renderHub();

await clickCard('hub-card-metadata');

// The whole point: two entries, i.e. a single navigation. Before the fix
// this ended on `component/metadata/directory`, whose route element is a
// second `<Navigate>` onto exactly the URL asserted here.
expect(chain).toEqual(['/apps/setup/system', '/apps/setup/metadata']);
expect(screen.getByTestId('canonical-directory')).toBeInTheDocument();
expectNoAliasAnywhere(chain);
});

it('the "Datasources" card reaches metadata/datasource in ONE hop', async () => {
const chain = renderHub();

await clickCard('hub-card-datasources');

expect(chain).toEqual(['/apps/setup/system', '/apps/setup/metadata/datasource']);
expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"datasource"');
expectNoAliasAnywhere(chain);
});

it('MEASUREMENT: both endpoints are byte-identical to what the alias hop produced', async () => {
// Equivalence, not improvement. `LegacyMetadataRedirect`'s directory arm
// builds `${appBase}/metadata` + search + hash, and its resource arm builds
// `${appBase}/metadata/${encodeURIComponent(type)}` + path tail + hash.
// Neither card carried a query or a hash beyond the `?type=` the alias
// itself consumed, and `datasource` percent-encodes to itself, so the old
// two-hop chain landed on precisely these two URLs. Pinned so a future
// reader can see the equality was measured rather than assumed.
const chain = renderHub();

await clickCard('hub-card-datasources');

expect(chain[chain.length - 1]).toBe('/apps/setup/metadata/datasource');
expect(chain).toHaveLength(2);
});

it('CONTROL: the sibling "Applications" card is unchanged and still hub-scoped', async () => {
// Without this the suite would also pass on a hub that had lost its cards
// entirely, or on one where every href had been rewritten to `/metadata`.
const chain = renderHub();

await clickCard('hub-card-applications');

expect(chain).toEqual(['/apps/setup/system', '/apps/setup/system/apps']);
});
});
23 changes: 13 additions & 10 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,16 +646,19 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
branch below. They are NOT a second copy of the page: both render
`LegacyMetadataRedirect`, which forwards onto the canonical
`metadata/:type…` routes declared just above. Declaring them here
is what makes the zero-app console's own fallback navigation work:
`sys-datasources` points straight at
`…/component/metadata/resource?type=datasource`, and `sys-objects`
arrives via the host's `system/metadata/:type` → same alias
rewrite. Both pass `isMetadataRoute` (a `metadata` path segment —
a substring test until #3638) and so land in THIS branch, which declared no
`component/…` route at all — every one of them rendered a blank
screen. Kept as a mirror rather than re-pointed navigation because
the alias already has exactly one canonical destination; adding a
zero-app-only spelling would create a second. */}
is what stopped the zero-app console rendering a blank screen: the
fallback navigation then aimed `sys-datasources` at an alias, and
carried `sys-objects` onto one via the host's
`system/metadata/:type` rewrite. Both pass `isMetadataRoute` (a
`metadata` path segment — a substring test until #3638) and so land
in THIS branch, which declared no `component/…` route at all —
every one of them rendered a blank screen. #3610 mirrored the
routes rather than re-point that navigation, because inventing a
zero-app-only spelling would have given the alias a second
canonical destination. #3660 re-pointed it anyway — at the shared
`metadata/:type` routes above, so no second spelling was created —
which leaves these two serving bookmarks and external links, the
arrivals that can never be re-pointed. */}
<Route path="component/metadata/directory" element={<LegacyMetadataRedirect mode="directory" />} />
<Route path="component/metadata/resource/*" element={<LegacyMetadataRedirect mode="resource" />} />
{extraRoutesNoApp}
Expand Down
11 changes: 10 additions & 1 deletion packages/app-shell/src/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,16 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
}
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' },
{ id: 'sys-datasources', label: t('layout.systemNav.datasources', { defaultValue: 'Datasources' }), type: 'url' as const, url: '/apps/setup/component/metadata/resource?type=datasource', 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.
// That alias is not a page: its route element is `LegacyMetadataRedirect`,
// which `<Navigate>`s onto exactly the URL spelled here, so every click
// paid a redundant hop plus a re-render. The alias route stays declared in
// BOTH `AppContent` branches (bookmarks and external links still arrive on
// it, and #3610 added it to the zero-app branch precisely because this
// entry fed it) — we simply stop aiming our own navigation at it.
{ id: 'sys-datasources', label: t('layout.systemNav.datasources', { defaultValue: 'Datasources' }), type: 'url' as const, url: '/apps/setup/metadata/datasource', icon: 'database' },
{ id: 'sys-users', label: t('layout.systemNav.users', { defaultValue: 'Users' }), type: 'url' as const, url: '/apps/setup/system/users', icon: 'users' },
{ id: 'sys-orgs', label: t('layout.systemNav.organizations', { defaultValue: 'Organizations' }), type: 'url' as const, url: '/apps/setup/system/organizations', icon: 'building-2' },
{ id: 'sys-roles', label: t('layout.systemNav.roles', { defaultValue: 'Roles' }), type: 'url' as const, url: '/apps/setup/system/roles', icon: 'shield' },
Expand Down
8 changes: 7 additions & 1 deletion packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,13 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
{ 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' },
{ id: 'sys-datasources', label: t('layout.systemNav.datasources', { defaultValue: 'Datasources' }), type: 'url' as const, url: '/apps/setup/component/metadata/resource?type=datasource', 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
// alias renders `LegacyMetadataRedirect`, a bare `<Navigate>` onto this
// very URL, so pointing here removes a hop without moving the landing
// page. The alias route itself is untouched.
{ id: 'sys-datasources', label: t('layout.systemNav.datasources', { defaultValue: 'Datasources' }), type: 'url' as const, url: '/apps/setup/metadata/datasource', icon: 'database' },
{ id: 'sys-users', label: t('layout.systemNav.users', { defaultValue: 'Users' }), type: 'url' as const, url: '/apps/setup/system/users', icon: 'users' },
{ id: 'sys-orgs', label: t('layout.systemNav.organizations', { defaultValue: 'Organizations' }), type: 'url' as const, url: '/apps/setup/system/organizations', icon: 'building-2' },
{ id: 'sys-roles', label: t('layout.systemNav.roles', { defaultValue: 'Roles' }), type: 'url' as const, url: '/apps/setup/system/roles', icon: 'shield' },
Expand Down
Loading
Loading