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
15 changes: 15 additions & 0 deletions .changeset/10520-orphaned-console-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/console': minor
---

Framework navigation can now reach the console pages that the retired System Hub card wall used to be the only link to (objectui#10520).

Navigation reaches a console page only through a `type: 'component'` item whose `componentRef` is a component-registry key, never through a console path. The console registers three new keys:

- `audit:log` renders the Audit Log page. It stays beside the `sys_audit_log` object view because its detail drawer pretty-prints the before and after JSON of a change, which the object's record page shows as raw text.
- `ai:approvals` renders the AI Approvals page, which lists the whole AI pending-action queue.
- `developer:integrations` renders the Integrations & APIs page, which shows the environment's REST base URL, the generated REST endpoints of each business object and a cURL sample that sends the `x-api-key` header.

The keys are additive. `/apps/:app/system/audit-log`, `/apps/:app/system/ai-approvals` and `/apps/:app/developer/integrations` still render their pages, so bookmarks and deep links keep working. The navigation entries that name the new keys belong to the framework, not to this package.

**Breaking:** the Developer Hub page and its route, `/apps/:app/developer`, are removed. The hub was a wall of four cards, and each card's destination is now a registry key: `developer:api-console`, `developer:flow-runs` and `developer:public-forms`, which the framework's Studio app already names in its Developer group, and `developer:integrations`. A bookmark to `/apps/:app/developer` no longer shows the hub. Inside an app it now reaches the generic object route, which answers that no object named `developer` exists. With no apps configured it lands where it did before this change. The four routes under `/apps/:app/developer/` are unchanged.
9 changes: 7 additions & 2 deletions apps/console/src/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ const AiPendingActionsPage = lazy(() => import('./pages/system/AiPendingActionsP
const AuditLogPage = lazy(() => import('./pages/system/AuditLogPage').then(m => ({ default: m.AuditLogPage })));
const SettingsHub = lazy(() => import('./pages/settings/SettingsHub').then(m => ({ default: m.SettingsHub })));
const SettingsView = lazy(() => import('./pages/settings/SettingsView').then(m => ({ default: m.SettingsView })));
const DeveloperHubPage = lazy(() => import('./pages/developer/DeveloperHubPage').then(m => ({ default: m.DeveloperHubPage })));
const ApiConsolePage = lazy(() => import('./pages/developer/ApiConsolePage').then(m => ({ default: m.ApiConsolePage })));
const FlowRunsPage = lazy(() => import('./pages/developer/FlowRunsPage').then(m => ({ default: m.FlowRunsPage })));
const PublicFormsPage = lazy(() => import('./pages/developer/PublicFormsPage').then(m => ({ default: m.PublicFormsPage })));
Expand Down Expand Up @@ -209,7 +208,13 @@ export const systemRoutes = (
<Route path="system/audit-log" element={<Suspense fallback={<LoadingScreen />}><AuditLogPage /></Suspense>} />
<Route path="system/settings" element={<Suspense fallback={<LoadingScreen />}><SettingsHub /></Suspense>} />
<Route path="system/settings/:namespace" element={<Suspense fallback={<LoadingScreen />}><SettingsView /></Suspense>} />
<Route path="developer" element={<Suspense fallback={<LoadingScreen />}><DeveloperHubPage /></Suspense>} />
{/* No bare `developer` route (objectui#10520): the Developer Hub card wall
retired once all four of its destinations were `developer:*` registry
keys that framework navigation can name. A `/developer` bookmark falls
through to app-shell's generic `:objectName` route instead of a blank
screen; what it lands on is measured in
`__tests__/AppContent.systemHubRoutes.test.tsx`. The sub-page routes
below stay for bookmarks and deep links. */}
<Route path="developer/api-console" element={<Suspense fallback={<LoadingScreen />}><ApiConsolePage /></Suspense>} />
<Route path="developer/flow-runs" element={<Suspense fallback={<LoadingScreen />}><FlowRunsPage /></Suspense>} />
<Route path="developer/public-forms" element={<Suspense fallback={<LoadingScreen />}><PublicFormsPage /></Suspense>} />
Expand Down
56 changes: 56 additions & 0 deletions apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ vi.mock('../pages/settings/SettingsHub', () => ({
SettingsHub: () => <div data-testid="settings-hub-page">settings hub</div>,
}));

/**
* One of the retired Developer Hub's destinations (objectui#10520), stubbed
* for the same reason: it sits behind `React.lazy`, and its real body fetches
* the object list.
*/
vi.mock('../pages/developer/IntegrationsPage', () => ({
IntegrationsPage: () => <div data-testid="integrations-page">integrations</div>,
}));

/** Echoes `:objectName` — this is the probe the whole fix is measured against. */
vi.mock('../../../../packages/app-shell/src/views/ObjectView', () => ({
ObjectView: () => {
Expand Down Expand Up @@ -439,3 +448,50 @@ describe('the bare system landing forwards onto the settings hub (objectui#3743)
expect(screen.queryByTestId('create-first-app-btn')).not.toBeInTheDocument();
});
});

describe('the retired Developer Hub URL (objectui#10520)', () => {
/**
* `/apps/:app/developer` rendered `DeveloperHubPage`, a card wall of four
* links whose only in-app link was a card on the System Hub. objectui#10520
* retired it once all four destinations were `developer:*` registry keys, and
* deliberately added no redirect in its place: the URL was never blank
* without it. With the host's `developer` route gone, the segment falls
* through to app-shell's generic `:objectName` route, i.e. the URL now means
* what `/apps/:app/<name>` means everywhere else. The `ObjectView` probe
* above echoes the name it was handed; the real `ObjectView` answers a name
* that is not an object with its own "object not found" empty state.
*
* Put the `developer` route (and its page) back and this pin goes red: the
* hub renders, and the `ObjectView` probe never does.
*/
it('with an active app, a /developer bookmark lands on the generic object route in place, never a blank screen', async () => {
renderConsoleAt('/apps/setup/developer');

expect(await screen.findByTestId('object-view')).toHaveTextContent('developer');
expect(chain).toEqual(['/apps/setup/developer']);
expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'setup');
expect(screen.queryByText('Page not found')).not.toBeInTheDocument();
});

it('MEASUREMENT: with zero apps, the bookmark reaches the no-apps empty state, as it did before the retirement', async () => {
// Not a consequence of this change: `isSystemRoute` keys on a `system`
// segment, so on a zero-app deployment `/developer` never reached the host
// fragment, and the hub route was unreachable there all along.
metadataApps = [];
renderConsoleAt('/apps/setup/developer');

expect(await screen.findByTestId('create-first-app-btn')).toBeInTheDocument();
expect(chain).toEqual(['/apps/setup/developer']);
});

it('the sub-page routes it linked to still resolve (bookmarks and deep links carry them)', async () => {
// One of the four, driven end to end: the rest share the same fragment and
// the same shape. It must NOT fall through to the object route the bare
// segment now reaches.
renderConsoleAt('/apps/setup/developer/integrations');

expect(await screen.findByTestId('integrations-page')).toBeInTheDocument();
expect(screen.queryByTestId('object-view')).not.toBeInTheDocument();
expect(screen.queryByTestId('record-detail-view')).not.toBeInTheDocument();
});
});
228 changes: 228 additions & 0 deletions apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `audit:log`, `ai:approvals` and `developer:integrations`: the
* component-registry keys for the console pages that lost their only in-app
* link when the System Hub card wall retired (objectui#10520).
*
* ## What this file is for
*
* Framework navigation reaches a console page only through a
* `type: 'component'` item whose `componentRef` is a registry key, never a
* console path. `AuditLogPage`, `AiPendingActionsPage` and `IntegrationsPage`
* had a route but no key, so no navigation entry could name them. This file
* pins, per key, the properties a nav item exercises, in the order it
* exercises them:
*
* 1. the key is registered at all, by importing the registration module the
* way `main.tsx` does (a side effect);
* 2. the key addresses the `component/<ns>/<name>` URL, built from the ref
* through the same helper the sidebar uses rather than spelled out;
* 3. that URL, rendered by app-shell's REAL `DefaultAppContent`, reaches the
* page. Its `component/:ns/:name/*` route is what serves `ComponentNavView`
* in the shipped console, so a key that is registered but spelled
* differently from the URL fails here, not only in a browser;
* 4. the key is ADDITIVE: the page's standalone route, from this host's REAL
* `systemRoutes` fragment (imported, never transcribed), still renders it,
* because bookmarks and deep links carry it.
*
* Plus the premise the Developer Hub's retirement rests on: all four of its
* cards' destinations are registered `developer:*` keys.
*
* ## Why the whole `DefaultAppContent`, and not `ComponentNavView` alone
*
* `ComponentNavView` is not on app-shell's barrel, and importing it by source
* path puts app-shell's file into this app's `tsc` program under this app's
* compiler options, which it is not written against. The route table that
* serves it IS exported, so the probe goes through that: the same lazy
* `ComponentNavView`, the same registry, the same URL grammar as production.
*
* ## Scope of the stubs
*
* The three pages are stubbed at the exact specifiers the registration modules
* and `AppContent` lazy-import, so the registry entries and the routes under
* test are the production ones, Suspense wrappers included. The shell around
* them is stubbed the way `AppContent.systemHubRoutes.test.tsx` stubs it, for
* the reasons given there.
*/

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

// AGENTS.md §测试纪律: the lazily-imported app-shell pages are stubbed so no
// unbounded `import()` races a bounded assertion window. The paths are relative
// to app-shell's OWN source tree (`@object-ui/app-shell` is aliased to
// `packages/app-shell/src` by `apps/console/vite.config.ts`).
vi.mock('../../../../packages/app-shell/src/views/metadata-admin', () => ({
MetadataDirectoryPage: () => <div data-testid="metadata-directory-page" />,
StudioHomePage: () => <div data-testid="studio-home-page" />,
MetadataResourceListPage: () => <div data-testid="metadata-resource-list-page" />,
MetadataResourceEditPage: () => <div data-testid="metadata-resource-edit-page" />,
MetadataResourceHistoryPage: () => <div data-testid="metadata-resource-history-page" />,
MetadataDiagnosticsPage: () => <div data-testid="metadata-diagnostics-page" />,
MetadataResourceRouter: () => <div data-testid="metadata-resource-router" />,
registerMetadataResource: () => {},
}));
vi.mock('../../../../packages/app-shell/src/console/marketplace/MarketplacePage', () => ({
MarketplacePage: () => <div data-testid="marketplace-page" />,
}));
vi.mock('../../../../packages/app-shell/src/console/marketplace/MarketplaceInstalledPage', () => ({
MarketplaceInstalledPage: () => <div data-testid="marketplace-installed-page" />,
}));
vi.mock('../../../../packages/app-shell/src/console/marketplace/MarketplacePackagePage', () => ({
MarketplacePackagePage: () => <div data-testid="marketplace-package-page" />,
}));
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));
vi.mock('../../../../packages/app-shell/src/layout/ConsoleLayout', () => ({
ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => (
<div data-testid="console-layout" data-active-app={activeAppName}>
{children}
</div>
),
}));
vi.mock('../../../../packages/app-shell/src/chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../../../packages/app-shell/src/chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../../../packages/app-shell/src/chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../../../packages/app-shell/src/views/ObjectView', () => ({
ObjectView: () => <div data-testid="object-view" />,
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
}),
useObjectLabel: () => ({
objectLabel: ({ label }: { label?: string }) => label,
}),
}));
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: null,
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));
const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
};
vi.mock('../../../../packages/app-shell/src/providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));
vi.mock('../../../../packages/app-shell/src/providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => ({
apps: [
{ name: 'setup', label: 'Setup', isDefault: true, navigation: [] },
{ name: 'studio', label: 'Studio', navigation: [] },
],
objects: [],
loading: false,
ensureType: undefined,
error: null,
refresh: vi.fn(async () => {}),
}),
}));

/**
* The probes stand in for the three pages at the exact specifiers
* `registerSystemComponents`, `registerDeveloperComponents` and `AppContent`
* lazy-import. Each echoes `:appName`, which `IntegrationsPage` builds its
* "Open API Console" link from.
*/
function probe(testId: string) {
return function Probe() {
const { appName } = useParams<{ appName?: string }>();
return <div data-testid={testId}>{appName ?? '(none)'}</div>;
};
}
vi.mock('../pages/system/AuditLogPage', () => ({ AuditLogPage: probe('audit-log-probe') }));
vi.mock('../pages/system/AiPendingActionsPage', () => ({ AiPendingActionsPage: probe('ai-approvals-probe') }));
vi.mock('../pages/developer/IntegrationsPage', () => ({ IntegrationsPage: probe('integrations-probe') }));

import { DefaultAppContent, getAppComponent, componentRefToUrlSegments } from '@object-ui/app-shell';
import { systemRoutes } from '../AppContent';

// Side-effect imports: these are the modules under test, imported the way
// `main.tsx` imports them.
import '../registerSystemComponents';
import '../registerDeveloperComponents';

/** The `component/...` path the sidebar builds for a `componentRef` nav item. */
const componentPath = (ref: string) => `component/${componentRefToUrlSegments(ref).join('/')}`;

function renderConsoleAt(url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route
path="/apps/:appName/*"
element={<DefaultAppContent extraRoutes={systemRoutes} extraRoutesNoApp={systemRoutes} />}
/>
</Routes>
</MemoryRouter>,
);
}

describe.each([
{ ref: 'audit:log', segments: ['audit', 'log'], probeId: 'audit-log-probe', app: 'setup', route: 'system/audit-log' },
{ ref: 'ai:approvals', segments: ['ai', 'approvals'], probeId: 'ai-approvals-probe', app: 'setup', route: 'system/ai-approvals' },
{
ref: 'developer:integrations',
segments: ['developer', 'integrations'],
probeId: 'integrations-probe',
app: 'studio',
route: 'developer/integrations',
},
])('$ref component ref (objectui#10520)', ({ ref, segments, probeId, app, route }) => {
it('is registered by the module `main.tsx` side-effect-imports', () => {
expect(getAppComponent(ref)).toBeDefined();
expect(getAppComponent(ref)?.source).toBe('@object-ui/console');
});

it(`addresses the component/${segments.join('/')} URL segments`, () => {
expect(componentRefToUrlSegments(ref)).toEqual(segments);
expect(componentPath(ref)).toBe(`component/${segments.join('/')}`);
});

it('the component URL reaches the page through the shipped route table', async () => {
renderConsoleAt(`/apps/${app}/${componentPath(ref)}`);

expect(await screen.findByTestId(probeId)).toHaveTextContent(app);
expect(screen.queryByText('Component not registered')).not.toBeInTheDocument();
});

it(`the standalone ${route} route keeps rendering the page (the key is additive)`, async () => {
renderConsoleAt(`/apps/${app}/${route}`);

expect(await screen.findByTestId(probeId)).toHaveTextContent(app);
});
});

describe('the retired Developer Hub (objectui#10520)', () => {
it('every one of its four destinations is a registered developer:* key', () => {
// The hub's four cards, by the key a navigation entry would name. This is
// the premise its retirement rests on: were any of them unkeyed, the hub
// would still be that page's only way in.
for (const ref of [
'developer:integrations',
'developer:api-console',
'developer:flow-runs',
'developer:public-forms',
]) {
expect(getAppComponent(ref), ref).toBeDefined();
}
});
});
3 changes: 3 additions & 0 deletions apps/console/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ import './registerAccountComponents';
// Register `approvals:*` component refs (the Approvals Inbox entry).
import './registerApprovalsComponents';

// Register `audit:log` and `ai:approvals` component refs (objectui#10520).
import './registerSystemComponents';

// (Per-type metadata-admin override for `object` was removed: the
// `object` type now uses the same generic ResourceListPage as every
// other metadata type for visual consistency. The visual ObjectManager
Expand Down
Loading
Loading