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
5 changes: 4 additions & 1 deletion packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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];

Expand Down
3 changes: 2 additions & 1 deletion packages/app-shell/src/hooks/useNavigationSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ============================================================================
Expand Down Expand Up @@ -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],
);

Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();
const collectNavObjects = (items: any[]) => {
for (const item of items || []) {
Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/layout/AppSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion packages/app-shell/src/preview/UnpublishedAppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 () => {
Expand Down
40 changes: 40 additions & 0 deletions packages/app-shell/src/utils/__tests__/appRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions packages/app-shell/src/utils/appRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* ADR-0048 (option A) — the `/apps/<segment>` 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<string, unknown>;

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<T extends AppLike>(
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);
}
1 change: 1 addition & 0 deletions packages/app-shell/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/app-shell/src/views/SearchResultsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();

Expand Down
Loading