From f17d4f26e3179d8bad6f8e478db4979bf5d0be11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 03:40:31 +0000 Subject: [PATCH 1/2] feat(approvals): register approvals:inbox and de-hardcode Home's approvals link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the Approvals Inbox an addressable identity — `approvals:inbox` in the component registry — and stop Home's "pending approvals" card from sending every user into the setup app (objectstack#7231). - apps/console: new registerApprovalsComponents module, imported by main.tsx alongside the developer/studio/account registrations. The standalone `system/approvals` route stays: notification and email deep links carry it. - app-shell: HomePage resolves the approvals target from the app the user last had open, re-checked against the live active-app list, then their first available app; `setup` only as the last resort. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L9U1G2piXmYrhYQX96XUyv --- .../approvals-inbox-component-ref-os7231.md | 13 ++ .../approvalsInboxComponentRef.test.tsx | 145 +++++++++++++++ apps/console/src/main.tsx | 3 + .../src/registerApprovalsComponents.tsx | 67 +++++++ .../app-shell/src/console/home/HomePage.tsx | 38 +++- .../HomePage.approvalsTarget.test.tsx | 170 ++++++++++++++++++ 6 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 .changeset/approvals-inbox-component-ref-os7231.md create mode 100644 apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx create mode 100644 apps/console/src/registerApprovalsComponents.tsx create mode 100644 packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx diff --git a/.changeset/approvals-inbox-component-ref-os7231.md b/.changeset/approvals-inbox-component-ref-os7231.md new file mode 100644 index 0000000000..a9e57532a0 --- /dev/null +++ b/.changeset/approvals-inbox-component-ref-os7231.md @@ -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. diff --git a/apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx b/apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx new file mode 100644 index 0000000000..3ddba40afc --- /dev/null +++ b/apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx @@ -0,0 +1,145 @@ +// 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, and by a module the app actually loads — + * a registration nobody imports is a `Component not registered` empty + * state at runtime, and nothing else in the build would say so; + * 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=` — 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. + * + * ## 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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => 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 ( +
+ {appName ?? '(none)'} + {search.get('request') ?? '(none)'} +
+ ); + }, + }; +}); + +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( + + + + } /> + + } + /> + + , + ); +} + +describe('approvals:inbox component ref (objectstack#7231)', () => { + it('is registered, and by a module the console actually loads', async () => { + expect(getAppComponent(REF)).toBeDefined(); + expect(getAppComponent(REF)?.source).toBe('@object-ui/console'); + + // The import above proves the registration runs when this module is + // loaded; only `main.tsx` proves it is loaded in the app. Same guard the + // other `register*Components` modules rely on implicitly — asserted here + // because a silent omission degrades to an empty state, not a build error. + const { readFileSync } = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const here = path.dirname(fileURLToPath(import.meta.url)); + const main = readFileSync(path.resolve(here, '../main.tsx'), 'utf8'); + expect(main).toMatch(/import\s+['"]\.\/registerApprovalsComponents['"]/); + }); + + 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=` 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'); + }); +}); diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx index 0f3489019e..3ecfbd4f02 100644 --- a/apps/console/src/main.tsx +++ b/apps/console/src/main.tsx @@ -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 diff --git a/apps/console/src/registerApprovalsComponents.tsx b/apps/console/src/registerApprovalsComponents.tsx new file mode 100644 index 0000000000..4c8c5c1bfd --- /dev/null +++ b/apps/console/src/registerApprovalsComponents.tsx @@ -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//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=`, 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=` 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 ( +
+ {t('common.loading', { defaultValue: 'Loading...' })} +
+ ); +} + +registerAppComponent({ + ref: 'approvals:inbox', + label: 'Approvals Inbox', + source: '@object-ui/console', + component: (props: any) => ( + }> + + + ), +}); diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx index 6e77aa48a4..7b5115e5ff 100644 --- a/packages/app-shell/src/console/home/HomePage.tsx +++ b/packages/app-shell/src/console/home/HomePage.tsx @@ -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'; @@ -254,6 +255,11 @@ 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. @@ -261,6 +267,34 @@ export function HomePage() { 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 as any[], 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); @@ -426,7 +460,7 @@ export function HomePage() { 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} /> diff --git a/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx b/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx new file mode 100644 index 0000000000..2b500eb81c --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Home "pending approvals" card — where it actually lands (objectstack#7231). + * + * ## The defect + * + * `HomePage` hardcoded `navigate('/apps/setup/system/approvals')`. The path is + * not wrong about the PAGE — `system/approvals` is declared in the host's route + * fragment and mounted as both `extraRoutes` and `extraRoutesNoApp`, so + * `/apps/{ANY_APP}/system/approvals` resolves — it is wrong about the APP. A + * business user who has approvals to act on but no access to the `setup` app + * follows the one entry point Home offers them straight into the shell's "App + * not available" guard. + * + * `InboxPopover` (the bell in the app chrome) already got this right: + * `currentAppName ?? params.appName`, falling back to `setup` only when neither + * names an app. This file pins the same resolution for Home. + * + * ## Why Home cannot simply copy the popover + * + * Home renders at `/home`, OUTSIDE the `/apps/:appName/*` router, so the second + * half of the popover's expression (`params.appName`) is structurally + * unavailable — there is no app segment in the URL to read. `currentAppName` is + * the only signal, it is published by `ConsoleLayout` on app mount, and it is + * therefore *stale by construction*: on a cold landing straight at `/home` it is + * `undefined`, and after visiting an app it survives that app being deactivated. + * Both of those are cases below, and both are why the resolution re-checks the + * remembered name against the LIVE active-app list instead of trusting it. + * + * ## Scope of the stubs + * + * This file measures ONE navigation target. Every hook `HomePage` calls for + * unrelated surfaces (AI CTAs, drafts banner, recents, favorites) is stubbed at + * its module boundary; `HomeRail` — which owns the card and its testid — stays + * real, because "the click reaches the right URL" is not assertable through a + * stubbed button. `useNavigate` is mocked rather than driven through a + * `MemoryRouter` so the assertion reads the emitted target directly, matching + * `CloudOnboardingNext.test.tsx` in this same directory. + */ + +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 userEvent from '@testing-library/user-event'; + +const navigateMock = vi.fn(); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }), +})); + +vi.mock('@object-ui/auth', () => ({ + useAuth: () => ({ user: { id: 'u1', name: 'Ada', email: 'ada@example.com' } }), + useIsWorkspaceAdmin: () => false, +})); + +vi.mock('@object-ui/plugin-chatbot', () => ({ + useAgents: () => ({ agents: [] }), + isAskAgent: () => false, + agentHasCapability: () => false, +})); + +// --- the app list + the remembered app: the two inputs under test ----------- +let appsFixture: any[] = []; +let currentAppNameFixture: string | undefined; + +vi.mock('../../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: appsFixture, loading: false }), +})); + +vi.mock('../../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ currentAppName: currentAppNameFixture }), +})); + +// --- unrelated surfaces ---------------------------------------------------- +vi.mock('../../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) })); +vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) })); +vi.mock('../../../hooks/useHomeInbox', () => ({ + useHomeInbox: () => ({ pendingApprovalsCount: 3, notifications: [], activities: [] }), +})); +vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' })); +vi.mock('../../../views/metadata-admin/useMetadata', () => ({ + useMetadataClient: () => ({ listDrafts: async () => [] }), +})); +vi.mock('../../../preview/usePublishAllDrafts', () => ({ + usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }), +})); +vi.mock('../../../runtime-config', () => ({ + getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }), +})); + +import { HomePage } from '../HomePage'; + +const app = (name: string, extra: Record = {}) => ({ name, label: name, ...extra }); + +async function clickApprovals() { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('home-action-approvals')); + expect(navigateMock).toHaveBeenCalledTimes(1); + return navigateMock.mock.calls[0][0] as string; +} + +describe('HomePage pending-approvals target (objectstack#7231)', () => { + beforeEach(() => { + navigateMock.mockReset(); + appsFixture = []; + currentAppNameFixture = undefined; + }); + + it('lands in the app the user last had open, not the hardcoded setup app', async () => { + appsFixture = [app('setup'), app('crm')]; + currentAppNameFixture = 'crm'; + + expect(await clickApprovals()).toBe('/apps/crm/system/approvals'); + }); + + it('addresses the app by its package segment when it has one', async () => { + // `appRouteSegment` prefers `_packageId` over `name` — the URL builder the + // rest of Home already uses, so the approvals link cannot disagree with the + // app tiles right above it about how the same app is spelled. + appsFixture = [app('crm', { _packageId: 'acme-crm' })]; + currentAppNameFixture = 'acme-crm'; + + expect(await clickApprovals()).toBe('/apps/acme-crm/system/approvals'); + }); + + it('falls back to the first available app on a cold landing at /home', async () => { + // No app was mounted this session, so `currentAppName` is undefined. The + // old code sent this user to `setup`; a non-admin has no such app. + appsFixture = [app('crm'), app('hr')]; + currentAppNameFixture = undefined; + + const target = await clickApprovals(); + expect(target).toBe('/apps/crm/system/approvals'); + expect(target).not.toContain('/apps/setup/'); + }); + + it('does not resurrect a remembered app that is no longer active', async () => { + // `currentAppName` outlives the app it names — it is plain React state, not + // derived from the live list — so a link built from it unchecked can point + // at an app the metadata no longer serves. + appsFixture = [app('hr')]; + currentAppNameFixture = 'crm'; + + expect(await clickApprovals()).toBe('/apps/hr/system/approvals'); + }); + + it('PRECONDITION: a zero-app workspace renders no approvals card at all', async () => { + // Pins why the `setup` last resort in the resolution is NOT the zero-app + // branch: `HomePage` returns the welcome empty state before the action + // center exists. If this ever goes red, the last resort becomes reachable + // and needs a case of its own rather than the comment it carries today. + appsFixture = []; + currentAppNameFixture = undefined; + + render(); + expect(screen.queryByTestId('home-action-approvals')).toBeNull(); + }); +}); From 90987a48a20ebefab00523291ea93c006964e202 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 03:58:54 +0000 Subject: [PATCH 2/2] test(approvals): keep the console ref test inside the app's type surface `apps/console`'s tsconfig ships no `@types/node`, so the "does main.tsx side-effect-import the registration" guard could not be typechecked there. Dropped rather than widening the app's `types` for a test; the docblock now says so instead of implying coverage that is not present. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L9U1G2piXmYrhYQX96XUyv --- .../approvalsInboxComponentRef.test.tsx | 27 ++++++++----------- .../app-shell/src/console/home/HomePage.tsx | 2 +- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx b/apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx index 3ddba40afc..7790f93625 100644 --- a/apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx +++ b/apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx @@ -11,9 +11,9 @@ * file pins the key's three load-bearing properties, in the order a nav item * exercises them: * - * 1. the key is registered at all, and by a module the app actually loads — - * a registration nobody imports is a `Component not registered` empty - * state at runtime, and nothing else in the build would say so; + * 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 @@ -30,6 +30,13 @@ * 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 @@ -43,7 +50,6 @@ 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 } from 'react-router-dom'; @@ -105,20 +111,9 @@ function renderAt(url: string, routePath: string) { } describe('approvals:inbox component ref (objectstack#7231)', () => { - it('is registered, and by a module the console actually loads', async () => { + it('is registered by the module `main.tsx` side-effect-imports', () => { expect(getAppComponent(REF)).toBeDefined(); expect(getAppComponent(REF)?.source).toBe('@object-ui/console'); - - // The import above proves the registration runs when this module is - // loaded; only `main.tsx` proves it is loaded in the app. Same guard the - // other `register*Components` modules rely on implicitly — asserted here - // because a silent omission degrades to an empty state, not a build error. - const { readFileSync } = await import('node:fs'); - const path = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const here = path.dirname(fileURLToPath(import.meta.url)); - const main = readFileSync(path.resolve(here, '../main.tsx'), 'utf8'); - expect(main).toMatch(/import\s+['"]\.\/registerApprovalsComponents['"]/); }); it('addresses the `component/approvals/inbox` URL segments', () => { diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx index 7b5115e5ff..a73a97aed0 100644 --- a/packages/app-shell/src/console/home/HomePage.tsx +++ b/packages/app-shell/src/console/home/HomePage.tsx @@ -291,7 +291,7 @@ export function HomePage() { * last resort rather than a broken link. */ const approvalsAppSegment = - appRouteSegment(matchAppBySegment(activeApps as any[], currentAppName)) ?? + appRouteSegment(matchAppBySegment(activeApps, currentAppName)) ?? appRouteSegment(activeApps[0]) ?? 'setup';