diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 987e5cc73..1220d58ca 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -25,6 +25,7 @@ import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState'; import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider'; import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent'; import { resolveRecordFormTarget, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation'; +import { matchAppBySegment } from '../utils/appRoute'; import { resolveHref, type NavTemplateContext } from '@object-ui/layout'; import { ExpressionEvaluator } from '@object-ui/core'; @@ -172,7 +173,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const activeApps = apps.filter((a: any) => a.active !== false); const launcherApps = activeApps.filter((a: any) => a.hidden !== true); const activeApp = - apps.find((a: any) => a.name === appName) || + // ADR-0048 (A) — the route segment is the package id; resolve by it, + // falling back to the app name (legacy/alias URL). + matchAppBySegment(apps, appName) || launcherApps.find((a: any) => a.isDefault === true) || launcherApps[0]; diff --git a/packages/app-shell/src/hooks/useNavigationSync.ts b/packages/app-shell/src/hooks/useNavigationSync.ts index d4f4ffba5..1377f3ddb 100644 --- a/packages/app-shell/src/hooks/useNavigationSync.ts +++ b/packages/app-shell/src/hooks/useNavigationSync.ts @@ -15,6 +15,7 @@ import type { NavigationItem, AppSchema } from '@object-ui/types'; import { useObjectTranslation } from '@object-ui/i18n'; import { useAdapter } from '../providers/AdapterProvider'; import { useMetadata } from '../providers/MetadataProvider'; +import { matchAppBySegment } from '../utils/appRoute'; import { usePreviewDrafts } from '../preview/PreviewModeContext'; // ============================================================================ @@ -222,7 +223,7 @@ export function useNavigationSync(): UseNavigationSyncReturn { /** Find the current app schema from metadata by name. */ const findApp = useCallback( (appName: string): AppSchema | undefined => - apps.find((a: any) => a.name === appName) as AppSchema | undefined, + matchAppBySegment(apps, appName) as AppSchema | undefined, [apps], ); diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 915d5b6a2..59c483b6b 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -61,7 +61,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types'; import { useAuth, getUserInitials } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider'; -import { resolveI18nLabel, preferLocal } from '../utils'; +import { resolveI18nLabel, preferLocal, matchAppBySegment } from '../utils'; import { getIcon } from '../utils/getIcon'; import { useMobileViewSwitcher } from './MobileViewSwitcherContext'; import { useNavigationContext } from '../context/NavigationContext'; @@ -457,7 +457,8 @@ export function AppHeader({ // Filter objects to only those belonging to the current app via its navigation const appNameKey = activeAppName || currentAppName || appNameFromRoute; - const currentApp = (metadataApps || []).find((a: any) => a.name === appNameKey); + // ADR-0048 (A) — appNameKey may be a package id (route segment); match by it. + const currentApp = matchAppBySegment(metadataApps || [], appNameKey); const appNavObjectNames = new Set(); const collectNavObjects = (items: any[]) => { for (const item of items || []) { diff --git a/packages/app-shell/src/layout/AppSidebar.tsx b/packages/app-shell/src/layout/AppSidebar.tsx index ed8fbcffe..4ab7071cc 100644 --- a/packages/app-shell/src/layout/AppSidebar.tsx +++ b/packages/app-shell/src/layout/AppSidebar.tsx @@ -61,7 +61,7 @@ import { usePermissions } from '@object-ui/permissions'; import { useRecentItems } from '../hooks/useRecentItems'; import { useFavorites } from '../hooks/useFavorites'; import { useNavPins } from '../hooks/useNavPins'; -import { resolveI18nLabel } from '../utils'; +import { resolveI18nLabel, matchAppBySegment } from '../utils'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { useAppContextSelectors } from './ContextSelectors'; @@ -175,7 +175,8 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true); // The active-app lookup still spans ALL apps (incl. hidden) so a // direct /apps/account navigation keeps rendering the Account branding. - const activeApp = apps.find((a: any) => a.name === activeAppName && a.active !== false) || activeApps[0]; + // ADR-0048 (A) — route segment may be a package id; match by it (name fallback). + const activeApp = matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName) || activeApps[0]; // Extract branding information from spec const logo = activeApp?.branding?.logo; diff --git a/packages/app-shell/src/layout/AppSwitcher.tsx b/packages/app-shell/src/layout/AppSwitcher.tsx index 134c25fa7..a501b4043 100644 --- a/packages/app-shell/src/layout/AppSwitcher.tsx +++ b/packages/app-shell/src/layout/AppSwitcher.tsx @@ -17,7 +17,7 @@ import { } from '@object-ui/components'; import { ChevronDown, Check } from 'lucide-react'; import { useMetadata } from '../providers/MetadataProvider'; -import { resolveI18nLabel } from '../utils'; +import { resolveI18nLabel, matchAppBySegment } from '../utils'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { getIcon } from '../utils/getIcon'; @@ -36,7 +36,8 @@ export function AppSwitcher({ activeAppName, onAppChange }: AppSwitcherProps) { // (personal-settings-style surfaces like the Account app), not the // top-level App Switcher. const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true); - const activeApp = activeApps.find((a: any) => a.name === activeAppName) || apps.find((a: any) => a.name === activeAppName && a.active !== false) || activeApps[0]; + // ADR-0048 (A) — route segment may be a package id; match by it (name fallback). + const activeApp = matchAppBySegment(activeApps, activeAppName) || matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName) || activeApps[0]; if (!activeApp) return null; diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 469ffe5f0..582cfee42 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -47,7 +47,7 @@ import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth'; import { useRecentItems } from '../hooks/useRecentItems'; import { useFavorites } from '../hooks/useFavorites'; import { useNavPins } from '../hooks/useNavPins'; -import { resolveI18nLabel } from '../utils'; +import { resolveI18nLabel, matchAppBySegment } from '../utils'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; // useObjectLabel provides appLabel/appDescription for convention-based // i18n lookup — `{ns}.apps.{name}.label` resolves to the translated label @@ -183,7 +183,8 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { // Filter switcher to non-hidden apps; active-app lookup spans all so // direct navigation to /apps/account still renders. const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true); - const activeApp = apps.find((a: any) => a.name === (activeAppName || currentAppName) && a.active !== false) || activeApps[0]; + // ADR-0048 (A) — route segment may be a package id; match by it (name fallback). + const activeApp = matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName || currentAppName) || activeApps[0]; // Drag-reorder and pin persistence const { applyOrder, handleReorder } = useNavOrder(activeApp?.name || 'home'); diff --git a/packages/app-shell/src/preview/UnpublishedAppBar.tsx b/packages/app-shell/src/preview/UnpublishedAppBar.tsx index 8524c3e97..fd98e0bad 100644 --- a/packages/app-shell/src/preview/UnpublishedAppBar.tsx +++ b/packages/app-shell/src/preview/UnpublishedAppBar.tsx @@ -26,6 +26,7 @@ import { toast } from 'sonner'; import { Button } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; import { useMetadata } from '../providers/MetadataProvider'; +import { matchAppBySegment } from '../utils/appRoute'; import { usePreviewDrafts } from './PreviewModeContext'; export function UnpublishedAppBar() { @@ -40,7 +41,7 @@ export function UnpublishedAppBar() { if (preview) return null; const routeApp = appName ?? location.pathname.match(/\/apps\/([^/?#]+)/)?.[1]; if (!routeApp) return null; - const app = (apps ?? []).find((a: any) => a?.name === routeApp); + const app = matchAppBySegment(apps ?? [], routeApp); if (!app || (app as any).hidden !== true) return null; const publish = async () => { diff --git a/packages/app-shell/src/utils/__tests__/appRoute.test.ts b/packages/app-shell/src/utils/__tests__/appRoute.test.ts new file mode 100644 index 000000000..5734f1f33 --- /dev/null +++ b/packages/app-shell/src/utils/__tests__/appRoute.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { appRouteSegment, matchAppBySegment } from '../appRoute'; + +/** + * ADR-0048 (option A) — package-id route segment. + */ +describe('appRouteSegment', () => { + it('returns the package id when present', () => { + expect(appRouteSegment({ name: 'crm', _packageId: 'com.acme.crm' })).toBe('com.acme.crm'); + }); + it('falls back to the app name when no package id', () => { + expect(appRouteSegment({ name: 'crm' })).toBe('crm'); + }); + it('returns undefined for nullish input', () => { + expect(appRouteSegment(undefined)).toBeUndefined(); + expect(appRouteSegment(null)).toBeUndefined(); + }); +}); + +describe('matchAppBySegment', () => { + const crm = { name: 'crm', _packageId: 'com.acme.crm' }; + const hr = { name: 'crm', _packageId: 'com.beta.crm' }; // same display name, different vendor + const apps = [crm, hr]; + + it('matches by package id (disambiguates same-named apps)', () => { + expect(matchAppBySegment(apps, 'com.acme.crm')).toBe(crm); + expect(matchAppBySegment(apps, 'com.beta.crm')).toBe(hr); + }); + it('falls back to matching by name (legacy/alias URL)', () => { + expect(matchAppBySegment([{ name: 'sales' }], 'sales')).toEqual({ name: 'sales' }); + }); + it('prefers a package-id match over a name match', () => { + const byName = { name: 'com.acme.crm' }; // pathological: an app literally named like a pkg id + expect(matchAppBySegment([byName, crm], 'com.acme.crm')).toBe(crm); + }); + it('returns undefined for missing inputs', () => { + expect(matchAppBySegment(apps, undefined)).toBeUndefined(); + expect(matchAppBySegment(null, 'com.acme.crm')).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/utils/appRoute.ts b/packages/app-shell/src/utils/appRoute.ts new file mode 100644 index 000000000..d26faebb3 --- /dev/null +++ b/packages/app-shell/src/utils/appRoute.ts @@ -0,0 +1,30 @@ +/** + * ADR-0048 (option A) — the `/apps/` route is keyed on the **package + * id** (reverse-domain, globally unique), not the app's display name. This makes + * two vendors' same-named apps unambiguous and self-describing in the URL. + * + * - `appRouteSegment(app)` — the canonical route segment for linking TO an app + * (its package id; falls back to `name` for runtime/DB apps with no + * `_packageId`). + * - `matchAppBySegment(apps, seg)` — resolve a route segment back to its app, + * preferring the package id and falling back to the app name. The name + * fallback doubles as a per-tenant friendly alias and keeps legacy + * name-based URLs/bookmarks working. Callers keep their own + * default/first-app fallback around this match. + */ + +type AppLike = { name?: unknown; _packageId?: unknown } & Record; + +export function appRouteSegment(app: AppLike | null | undefined): string | undefined { + if (!app) return undefined; + const seg = (app._packageId as string | undefined) ?? (app.name as string | undefined); + return seg ?? undefined; +} + +export function matchAppBySegment( + apps: readonly T[] | null | undefined, + seg: string | null | undefined, +): T | undefined { + if (!apps || seg == null) return undefined; + return apps.find((a) => a?._packageId === seg) ?? apps.find((a) => a?.name === seg); +} diff --git a/packages/app-shell/src/utils/index.ts b/packages/app-shell/src/utils/index.ts index ac6dc5806..15741bca1 100644 --- a/packages/app-shell/src/utils/index.ts +++ b/packages/app-shell/src/utils/index.ts @@ -14,6 +14,7 @@ export { deriveRelatedLists } from './deriveRelatedLists'; export type { DerivedRelatedList } from './deriveRelatedLists'; export { preferLocal } from './preferLocal'; +export { appRouteSegment, matchAppBySegment } from './appRoute'; /** * Resolves an I18nLabel to a plain string. diff --git a/packages/app-shell/src/views/SearchResultsPage.tsx b/packages/app-shell/src/views/SearchResultsPage.tsx index 1e3989eed..c75ba4e59 100644 --- a/packages/app-shell/src/views/SearchResultsPage.tsx +++ b/packages/app-shell/src/views/SearchResultsPage.tsx @@ -25,6 +25,7 @@ import { } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/i18n'; import { useMetadata } from '../providers/MetadataProvider'; +import { matchAppBySegment } from '../utils/appRoute'; import { resolveHref } from '@object-ui/layout'; import { useAuth } from '@object-ui/auth'; @@ -72,7 +73,7 @@ export function SearchResultsPage() { const { apps: metadataApps } = useMetadata(); const apps = metadataApps || []; - const activeApp = apps.find((a: any) => a.name === appName) || apps[0]; + const activeApp = matchAppBySegment(apps, appName) || apps[0]; const baseUrl = `/apps/${appName}`; const { user } = useAuth();