Skip to content

Commit 02da91f

Browse files
os-zhuangclaude
andauthored
feat(console): resolve post-login landing from app metadata, not a hardcode (#2027)
Replace CloudAwareRootRedirect's hardcoded PREFERRED_APPS=['cloud_control'] with RootLandingRedirect, resolving the landing from app metadata: the isDefault app (its homePageId picks the page) → else the single visible app → else /home. This gives isDefault routing semantics (was a display-only badge) and removes the cloud-specific bake-in, so any product declares its landing in source rather than forking the Console. Pure resolveLandingPath() extracted + unit-tested (7 cases). Back-compat: no isDefault app + ≥2 visible apps still lands on /home; cloud is unaffected (cloud_control already isDefault). The landing is a build-time product decision in metadata, not a runtime Settings-UI preference. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 48be214 commit 02da91f

5 files changed

Lines changed: 140 additions & 41 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@object-ui/console': minor
3+
---
4+
5+
feat(console): resolve the post-login landing from app metadata, not a hardcode
6+
7+
The root route (`/`) previously redirected via a hardcoded
8+
`PREFERRED_APPS = ['cloud_control']` in `CloudAwareRootRedirect` — baking one
9+
product's policy (cloud) into the shared Console, with no supported way for a
10+
deployment to opt out of the `/home` launcher or land somewhere custom without
11+
forking the SPA.
12+
13+
`CloudAwareRootRedirect` is replaced by `RootLandingRedirect`, which resolves the
14+
landing purely from app metadata (`resolveLandingPath`, unit-tested):
15+
16+
1. the app marked `isDefault: true``/apps/<it>` (its own `homePageId` then
17+
selects the landing page within it);
18+
2. else the single visible app (`active !== false && hidden !== true`) → that app;
19+
3. else `/home` — the multi-app workspace launcher (legacy default).
20+
21+
This gives `isDefault` **routing semantics** (it was a display-only badge) — a
22+
back-compat-relevant contract change. Back-compat: a deployment with no
23+
`isDefault` app and ≥2 visible apps still lands on `/home`, exactly as before;
24+
cloud is unaffected (`cloud_control` is already `isDefault: true`) and the
25+
cloud-specific hardcode is removed. The landing is now a build-time product
26+
decision a developer declares in metadata, not a runtime Settings-UI preference.

apps/console/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
} from '@object-ui/app-shell';
4040

4141
import { AppContent } from './AppContent';
42-
import { CloudAwareRootRedirect } from './components/CloudAwareRootRedirect';
42+
import { RootLandingRedirect } from './components/RootLandingRedirect';
4343
import { FormPage } from './components/FormPage';
4444
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
4545
import SharedRecordPage from './pages/SharedRecordPage';
@@ -292,7 +292,7 @@ export function App() {
292292
<AppContent />
293293
</ProtectedRoute>
294294
} />
295-
<Route path="/" element={<ConnectedShell><CloudAwareRootRedirect /></ConnectedShell>} />
295+
<Route path="/" element={<ConnectedShell><RootLandingRedirect /></ConnectedShell>} />
296296
<Route path="*" element={<Navigate to="/" replace />} />
297297
</Routes>
298298
</ConsoleShell>

apps/console/src/components/CloudAwareRootRedirect.tsx

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { resolveLandingPath } from './RootLandingRedirect';
5+
6+
describe('resolveLandingPath', () => {
7+
it('routes to the App marked isDefault (isDefault now ROUTES, not just badges)', () => {
8+
expect(
9+
resolveLandingPath([
10+
{ name: 'crm' },
11+
{ name: 'cloud_control', isDefault: true },
12+
{ name: 'setup' },
13+
]),
14+
).toBe('/apps/cloud_control');
15+
});
16+
17+
it('prefers isDefault over the single-visible-app rule', () => {
18+
expect(
19+
resolveLandingPath([{ name: 'a', isDefault: true }, { name: 'b' }, { name: 'c' }]),
20+
).toBe('/apps/a');
21+
});
22+
23+
it('lands directly in the single visible App when none is isDefault', () => {
24+
expect(resolveLandingPath([{ name: 'only_app' }])).toBe('/apps/only_app');
25+
});
26+
27+
it('ignores hidden/inactive Apps when counting "single visible"', () => {
28+
expect(
29+
resolveLandingPath([
30+
{ name: 'main' },
31+
{ name: 'secret', hidden: true },
32+
{ name: 'off', active: false },
33+
]),
34+
).toBe('/apps/main');
35+
});
36+
37+
it('falls back to /home for a multi-app deployment with no isDefault (legacy behavior)', () => {
38+
expect(resolveLandingPath([{ name: 'a' }, { name: 'b' }])).toBe('/home');
39+
});
40+
41+
it('falls back to /home when there are no apps', () => {
42+
expect(resolveLandingPath([])).toBe('/home');
43+
expect(resolveLandingPath(null)).toBe('/home');
44+
expect(resolveLandingPath(undefined)).toBe('/home');
45+
});
46+
47+
it('ignores entries without a name', () => {
48+
expect(resolveLandingPath([{ isDefault: true }, { name: 'real' }])).toBe('/apps/real');
49+
});
50+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* RootLandingRedirect — element for `<Route path="/" />`.
5+
*
6+
* Resolves the post-login landing from APP METADATA, so any product built on
7+
* the framework (cloud's control plane, an ISV's product, a per-project
8+
* runtime) declares its landing in source rather than forking the Console. This
9+
* replaces the previous hardcoded `PREFERRED_APPS = ['cloud_control']` redirect,
10+
* which baked one product's policy into the shared bundle.
11+
*
12+
* The landing is a build/dev-time PRODUCT decision, declared in metadata — not a
13+
* runtime, per-tenant, Settings-UI preference. Resolution order (see
14+
* {@link resolveLandingPath}):
15+
* 1. the App marked `isDefault: true` → `/apps/<it>` — and that App's own
16+
* `homePageId` then selects the landing page within it;
17+
* 2. else the single visible App (`active !== false && hidden !== true`)
18+
* → `/apps/<it>` (a one-app deployment shouldn't show a one-tile launcher);
19+
* 3. else `/home` — the multi-app workspace launcher (the legacy default).
20+
*
21+
* NOTE: this gives `isDefault` ROUTING semantics; it was previously a
22+
* display-only badge. Back-compat: a deployment with no `isDefault` App and ≥2
23+
* visible Apps still lands on `/home`, exactly as before. (A deploy-time ops
24+
* override is intentionally kept server-side, not read here — the metadata
25+
* declaration is the source of truth.)
26+
*/
27+
28+
import { Navigate } from 'react-router-dom';
29+
import { useMetadata, LoadingFallback } from '@object-ui/app-shell';
30+
31+
/** Minimal shape this resolver needs off each App metadata record. */
32+
interface LandingApp {
33+
name?: string;
34+
isDefault?: boolean;
35+
active?: boolean;
36+
hidden?: boolean;
37+
}
38+
39+
/**
40+
* The path `/` should redirect to, resolved purely from the App list. Extracted
41+
* from the component so the policy is unit-testable without a router/render.
42+
*/
43+
export function resolveLandingPath(apps: readonly LandingApp[] | null | undefined): string {
44+
const list = (apps ?? []).filter((a): a is LandingApp & { name: string } => Boolean(a?.name));
45+
46+
// 1. The App the product declared as default (isDefault now ROUTES, not just badges).
47+
const defaultApp = list.find((a) => a.isDefault === true);
48+
if (defaultApp) return `/apps/${defaultApp.name}`;
49+
50+
// 2. A single-app deployment lands straight in that App (no one-tile launcher).
51+
const visible = list.filter((a) => a.active !== false && a.hidden !== true);
52+
if (visible.length === 1) return `/apps/${visible[0].name}`;
53+
54+
// 3. Multi-app default: the workspace launcher.
55+
return '/home';
56+
}
57+
58+
export function RootLandingRedirect() {
59+
const { apps, loading } = useMetadata();
60+
if (loading) return <LoadingFallback />;
61+
return <Navigate to={resolveLandingPath(apps as LandingApp[] | undefined)} replace />;
62+
}

0 commit comments

Comments
 (0)