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
13 changes: 13 additions & 0 deletions .changeset/approvals-inbox-component-ref-os7231.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

Register `approvals:inbox` as a component ref, and stop sending Home's "pending approvals" card into the setup app (objectstack#7231).

The Approvals Inbox had no addressable identity: nothing in any app's navigation metadata pointed at it, and every entry to it was a hardcoded path. `HomePage`'s action-center card spelled `/apps/setup/system/approvals`. That path is not wrong about the page — `system/approvals` is mounted as both `extraRoutes` and `extraRoutesNoApp`, so `/apps/{any app}/system/approvals` has always resolved — it is wrong about the app. A business user with approvals waiting but no access to `setup` followed the only entry Home offers them into the shell's "App not available" guard.

Two changes, one additive and one corrective:

- `approvals:inbox` now resolves in the component registry to the Approvals Inbox page, so a `{ type: 'component', componentRef: 'approvals:inbox' }` nav item renders the full inbox at `/apps/{app}/component/approvals/inbox` — tabs, drawer, decision actions and record deep links all scoped to `{app}`. Both mount paths are relative routes under `/apps/:appName/*`, so the page reads the same `:appName` and the same `?request={id}` deep link either way. The standalone `system/approvals` route is untouched and stays the target of server notification and email links; the registry key is purely additive indirection, so the approval surface can be rebuilt later behind the same key without any navigation metadata changing.
- The Home card now navigates within the app the user last had open, re-checked against their live active-app list so a remembered app that has since been deactivated is not resurrected as a dead link, falling back to their first available app. `setup` survives only as the last resort for an app carrying no addressable segment at all — the zero-app workspace never reaches this producer, because Home returns its welcome empty state before the action center exists.
140 changes: 140 additions & 0 deletions apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `approvals:inbox` — the component-registry key for the Approvals Inbox
* (objectstack#7231).
*
* ## What this file is for
*
* objectstack#7213 converges the three coexisting "待我审批" entries at the
* METADATA level: app navigation names a registry key, not a literal URL. This
* file pins the key's three load-bearing properties, in the order a nav item
* exercises them:
*
* 1. the key is registered at all, by importing the registration module the
* way `main.tsx` does — a side effect, next to the developer / studio /
* account registrations;
* 2. `approvals:inbox` addresses `component/approvals/inbox`, so the URL the
* sidebar builds and the key the framework's metadata declares cannot
* drift apart. The URLs below are BUILT from the ref through the same
* helper `AppContent` uses, rather than spelled out, so a change to
* either one moves both;
* 3. the page mounted through that route still sees `:appName` and
* `?request=<id>` — the two router inputs `ApprovalsInboxPage` reads
* (`useParams().appName` for record deep links, `useSearchParams()` for
* the notification drawer opener).
*
* Property 3 is the one worth measuring rather than assuming: the standalone
* `system/approvals` route and the `component/:ns/:name/*` route are different
* paths and only look interchangeable. They are interchangeable HERE because
* both are relative routes under `/apps/:appName/*` (`App.tsx`), which is what
* the last case asserts by driving the same probe down both.
*
* What this file deliberately does NOT assert is that `main.tsx` performs that
* side-effect import — the one remaining way the key could be missing at
* runtime. `apps/console`'s tsconfig ships no `@types/node`, so reading the
* source back is not typecheckable here, and reaching for `vite/client` to
* make it so would widen the app's type surface for a test. The import sits in
* `main.tsx` beside its three siblings; a reviewer sees it in one line of diff.
*
* ## Scope of the stub
*
* `ApprovalsInboxPage` itself is stubbed at its module boundary. Its internals
* (tabs, drawer, decision actions) are objectui#2762's subject and are being
* rebuilt wholesale by objectui#2763; what belongs to THIS change is only what
* the routing hands the page. The stub is a probe that echoes exactly those two
* inputs, and it is mocked at the same specifier the registration lazy-imports,
* so the entry under test is the real registered one — Suspense wrapper
* included — not a re-creation of it.
*/

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

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
language: 'en',
}),
}));

// The probe stands in for the page at the exact specifier
// `registerApprovalsComponents` lazy-imports, so the registry entry exercised
// below is the production one.
vi.mock('../pages/system/ApprovalsInboxPage', async () => {
const { useParams, useSearchParams } = await import('react-router-dom');
return {
ApprovalsInboxPage: () => {
const { appName } = useParams<{ appName?: string }>();
const [search] = useSearchParams();
return (
<div>
<span data-testid="probe-app">{appName ?? '(none)'}</span>
<span data-testid="probe-request">{search.get('request') ?? '(none)'}</span>
</div>
);
},
};
});

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

// Side-effect import: this is the module under test.
import '../registerApprovalsComponents';

const REF = 'approvals:inbox';

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

function renderAt(url: string, routePath: string) {
const entry = getAppComponent(REF);
if (!entry) throw new Error(`${REF} is not registered`);
const Registered = entry.component;
render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route
path="/apps/:appName/*"
element={
<Routes>
<Route path={routePath} element={<Registered />} />
</Routes>
}
/>
</Routes>
</MemoryRouter>,
);
}

describe('approvals:inbox component ref (objectstack#7231)', () => {
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/approvals/inbox` URL segments', () => {
expect(componentRefToUrlSegments(REF)).toEqual(['approvals', 'inbox']);
expect(componentPath(REF)).toBe('component/approvals/inbox');
});

it('mounted via the component route, the page reads the app segment and the deep link', async () => {
renderAt(`/apps/crm/${componentPath(REF)}?request=req_42`, `${componentPath(REF)}/*`);

expect(await screen.findByTestId('probe-app')).toHaveTextContent('crm');
expect(screen.getByTestId('probe-request')).toHaveTextContent('req_42');
});

it('the standalone system/approvals route keeps handing the page the same inputs', async () => {
// The component ref is ADDITIVE. Server notifications and email links carry
// `/system/approvals?request=<id>` and `InboxPopover` app-prefixes them, so
// this route staying equivalent is a shipping constraint, not a nicety.
renderAt('/apps/crm/system/approvals?request=req_42', 'system/approvals');

expect(await screen.findByTestId('probe-app')).toHaveTextContent('crm');
expect(screen.getByTestId('probe-request')).toHaveTextContent('req_42');
});
});
3 changes: 3 additions & 0 deletions apps/console/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import './registerStudioComponents';
// Register `account:*` component refs (My Profile, etc.).
import './registerAccountComponents';

// Register `approvals:*` component refs (the Approvals Inbox entry).
import './registerApprovalsComponents';

// (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
67 changes: 67 additions & 0 deletions apps/console/src/registerApprovalsComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Approvals component registrations.
*
* Binds the `approvals:*` registry keys to the lazy-loaded console pages that
* already implement these surfaces, so app metadata can point a nav item at
* the Approvals Inbox declaratively:
*
* ```ts
* { id: 'nav_approvals', type: 'component', componentRef: 'approvals:inbox' }
* ```
*
* URL shape resolved by `ComponentNavView`:
* approvals:inbox → /apps/<app>/component/approvals/inbox
*
* ## Why a registry key rather than a `type: 'url'` nav item
*
* objectstack#7213 converges the three coexisting "待我审批" entries at the
* METADATA level: the framework-side navigation points at this ref, not at a
* literal path. objectui#2763 (approved, scheduled after v17) will rebuild the
* approval surface on the standard SDUI renderers and delete this bespoke page;
* when it does, only this file changes — every app's nav metadata keeps naming
* `approvals:inbox` and nothing downstream churns.
*
* ## The standalone route stays
*
* `system/approvals` (declared in `AppContent.tsx`'s `systemRoutes` fragment,
* mounted as both `extraRoutes` and `extraRoutesNoApp`) is NOT replaced by this
* registration — server notification and email deep links carry
* `/system/approvals?request=<id>`, and `InboxPopover` app-prefixes them. This
* component ref is purely additive.
*
* Both mount paths sit under `/apps/:appName/*`, so `useParams().appName` — the
* app segment `ApprovalsInboxPage` builds its record deep links from — resolves
* identically either way, and `?request=<id>` reaches the page's
* `useSearchParams()` drawer opener on both. Pinned in
* `__tests__/approvalsInboxComponentRef.test.tsx`.
*/

import { lazy, Suspense } from 'react';
import { registerAppComponent } from '@object-ui/app-shell';
import { useObjectTranslation } from '@object-ui/i18n';

const ApprovalsInboxPage = lazy(() =>
import('./pages/system/ApprovalsInboxPage').then((m) => ({ default: m.ApprovalsInboxPage })),
);

function ApprovalsFallback() {
const { t } = useObjectTranslation();
return (
<div className="p-6 text-sm text-muted-foreground">
{t('common.loading', { defaultValue: 'Loading...' })}
</div>
);
}

registerAppComponent({
ref: 'approvals:inbox',
label: 'Approvals Inbox',
source: '@object-ui/console',
component: (props: any) => (
<Suspense fallback={<ApprovalsFallback />}>
<ApprovalsInboxPage {...props} />
</Suspense>
),
});
38 changes: 36 additions & 2 deletions packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-cha
import { HomeAppsStrip } from './HomeAppsStrip';
import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail';
import { useHomeInbox } from '../../hooks/useHomeInbox';
import { appRouteSegment } from '../../utils';
import { useNavigationContext } from '../../context/NavigationContext';
import { appRouteSegment, matchAppBySegment } from '../../utils';
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
import { useMetadataClient } from '../../views/metadata-admin/useMetadata';
Expand Down Expand Up @@ -254,13 +255,46 @@ export function HomePage() {
const { user } = useAuth();
const isAdmin = useIsWorkspaceAdmin();
const { pendingApprovalsCount, notifications, activities } = useHomeInbox();
// Home renders OUTSIDE the `/apps/:appName/*` router, so there is no
// `params.appName` to read — `currentAppName` (published by ConsoleLayout on
// every app mount) is the only "which app is the user in" signal available
// here, and it is undefined on a cold landing straight at `/home`.
const { currentAppName } = useNavigationContext();
// AI CTA gating, per agent: "Build with AI" only when a build agent is
// deployed (cloud / AI Studio); "Ask AI" only when a data agent is; neither
// when AI isn't enabled. Community builds typically land in the ask-only state.
const { askAvailable, buildAvailable } = useHomeAiAvailability();

const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true);

/**
* Which app hosts the Approvals Inbox we link to (objectstack#7231).
*
* `system/approvals` is mounted in the host's route fragment as BOTH
* `extraRoutes` and `extraRoutesNoApp`, so `/apps/{ANY_APP}/system/approvals`
* resolves — the page was never bound to `setup`. The hardcoded
* `/apps/setup/...` this replaces was therefore not "where the page lives",
* it was a dead end for every business user without access to the setup app.
*
* Resolution order, each step falling through only when the previous one
* cannot name an app the user can actually open:
* 1. the app they last had open, if it is still one of their active apps
* (`matchAppBySegment` re-checks it against the live list, so an app
* that was deactivated or hidden since is not resurrected as a link);
* 2. their first active app — arbitrary but reachable, and on a business
* user's workspace that is a business app rather than `setup`;
* 3. `setup`. NOT the zero-app case: `activeApps.length === 0` returns the
* welcome empty state below, which renders no action center, so this
* producer never runs there. What is left for step 3 is the degenerate
* app carrying neither `_packageId` nor `name` — nothing addressable to
* build a URL from, so the historical target is the least-surprising
* last resort rather than a broken link.
*/
const approvalsAppSegment =
appRouteSegment(matchAppBySegment(activeApps, currentAppName)) ??
appRouteSegment(activeApps[0]) ??
'setup';

const recentApps = recentItems
.filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page' || item.type === 'record')
.slice(0, 6);
Expand Down Expand Up @@ -426,7 +460,7 @@ export function HomePage() {
<HomeActionCenter
pendingApprovalsCount={pendingApprovalsCount}
notifications={notifications}
onOpenApprovals={() => navigate('/apps/setup/system/approvals')}
onOpenApprovals={() => navigate(`/apps/${approvalsAppSegment}/system/approvals`)}
onOpenNotification={(n) => navigate(n.actionUrl || '/apps/setup/sys_inbox_message?view=mine')}
t={t}
/>
Expand Down
Loading
Loading