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
26 changes: 26 additions & 0 deletions .changeset/no-apps-create-first-app-cta-3573.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': patch
---

The no-apps empty state's "Create Your First App" CTA now opens the app-creation
flow instead of silently bouncing the user back to the landing page. It called
`navigate('/create-app')` — an ABSOLUTE path, so it resolved against the HOST's
root route tree, which declares no `/create-app`; the reference host's trailing
`<Route path="*">` therefore replaced it with `/`. The `create-app` route is
declared by `AppContent` itself, inside the `/apps/:appName/*` subtree (both the
no-active-app branch and the with-app router), so the CTA now builds the
app-scoped `/apps/<segment>/create-app` — the platform's canonical app URL
(ADR-0048) and the same target the sidebar's add-app entry already links to. On
a fresh zero-app deployment this was the first screen's only route into app
creation, and it read as a button that does nothing (#3573).

A plain relative `navigate('create-app')` is deliberately NOT the fix, and the
new routing test pins why: under the installed react-router 7,
`getResolveToMatches` resolves a relative target against the LEAF match's full
`pathname` with the splat INCLUDED (in v6 this was the `v7_relativeSplatPath`
future flag; v7 hardcodes it). The empty state renders across a whole URL family
— `/apps/setup` and any deeper `/apps/setup/<segment>` — so the relative form is
right only at the shallowest of them and builds
`/apps/setup/<segment>/create-app` elsewhere, which matches no route and renders
a blank screen instead of the bounce. The sibling "System Settings" CTA is
unchanged.
19 changes: 18 additions & 1 deletion packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,24 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
{t('empty.noAppsConfiguredDescription')}
</EmptyDescription>
<div className="mt-4 flex flex-col sm:flex-row items-center gap-3">
<Button onClick={() => navigate('/create-app')} data-testid="create-first-app-btn">
{/* #3573 — target the APP-SCOPED route, not the host root. `create-app`
is declared by THIS component (the no-active-app branch just below,
and the with-app router further down), i.e. inside the
`/apps/:appName/*` subtree — never at the root. The former absolute
`/create-app` resolved against the HOST's root route tree, which
declares no such path, so the host's trailing catch-all silently
bounced the user back to the landing page: a dead first-screen CTA.
A plain relative `navigate('create-app')` does NOT fix it either —
react-router 7 resolves a relative `to` against the LEAF match's
FULL pathname, splat INCLUDED (`getResolveToMatches`), so from
`/apps/setup/<anything>` it builds `/apps/setup/<anything>/create-app`,
which matches no route and renders a blank screen. `/apps/<segment>`
is the platform's canonical app URL (ADR-0048) and is what every
other navigation in this file — and AppSidebar's own add-app entry —
builds, so build it here too. (This branch is only reachable under
`/apps/setup…`: it requires `isSetupRoute`, the one pseudo-route the
guard above does not exclude — so `appName` is always present here.) */}
<Button onClick={() => navigate(`/apps/${appName}/create-app`)} data-testid="create-first-app-btn">
{t('empty.createFirstApp')}
</Button>
<Button variant="outline" onClick={() => navigate('/apps/setup')} data-testid="go-to-settings-btn">
Expand Down
226 changes: 226 additions & 0 deletions packages/app-shell/src/console/__tests__/AppContent.noAppsCta.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* No-apps empty state — "Create Your First App" must reach the DECLARED
* create-app route (objectui#3573).
*
* ## Measured URL shapes (why the entries below are the real-world ones)
*
* `AppContent` is mounted by the reference host at ONE place only —
* `apps/console/src/App.tsx`: `<Route path="/apps/:appName/*" />`. The empty
* state's precondition (`AppContent.tsx`, the `!activeApp && !isCreateAppRoute
* && !isSystemRoute && !isMetadataRoute` guard) can therefore only be reached
* when the `:appName` segment did NOT resolve to an app AND the URL is one of
* the built-in pseudo-routes (otherwise `requestedAppMissing` short-circuits to
* the "App not available" screen instead). Subtracting the three pseudo-routes
* the guard itself excludes leaves exactly one family:
*
* /apps/setup and /apps/setup/<anything not
* system|metadata|create-app>
*
* which is precisely where the sidebar's no-active-app system navigation and
* the empty state's own `go-to-settings-btn` send a zero-app user
* (`layout/AppSidebar.tsx` systemFallbackNavigation → `/apps/setup`).
*
* `/` is NOT such a URL: with zero apps `RootLandingRedirect` resolves to
* `/home` and `AppContent` never mounts. So the empty state always renders
* INSIDE the `/apps/:appName/*` subtree — which is what makes a relative
* navigation well-founded, and is pinned by the last test here.
*
* ## What the fix is (and why the obvious relative form is NOT it)
*
* The CTA used to call `navigate('/create-app')` — absolute, so it resolved
* against the ROOT route tree, which declares no such path; the host's trailing
* `<Route path="*">` bounced the user to `/`. The `create-app` route is
* declared by `AppContent` itself (both the no-active-app branch and the
* with-app branch), i.e. INSIDE the `/apps/:appName/*` subtree.
*
* MEASURED, against the installed react-router 7.18: a plain relative
* `navigate('create-app')` does NOT fix this. `getResolveToMatches` gives the
* LEAF match's FULL `pathname` — splat INCLUDED — as the resolution base (in
* v6 this was the `v7_relativeSplatPath` future flag; v7 hardcodes it). So the
* relative form is depth-dependent: right at `/apps/setup`, but from
* `/apps/setup/sys_inbox_message` it builds
* `/apps/setup/sys_inbox_message/create-app`, which matches no route inside the
* no-active-app `<Routes>` and renders a BLANK screen. The two CTA tests below
* cover both depths precisely so that trap stays pinned.
*
* The fix therefore builds the app-scoped URL `/apps/<segment>/create-app` —
* the platform's canonical app URL contract (ADR-0048, `utils/appRoute.ts`),
* the same target `layout/AppSidebar.tsx`'s add-app entry already builds, and
* the same base every other navigation in `AppContent.tsx` uses.
*
* NOTE ON SCOPE: `@object-ui/plugin-designer` is stubbed below — this file
* measures ROUTING (which route matches, which URL results), not the app
* designer's internals.
*/

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

// ---------------------------------------------------------------------------
// Mocks — providers and the lazily-imported designer pages. Everything that
// takes part in the ROUTING decision (AppContent's own guards, its nested
// <Routes>, react-router's relative resolution) stays real.
// ---------------------------------------------------------------------------

// The CTA's target sits behind `React.lazy(() => import('@object-ui/plugin-designer'))`.
// Stubbing it keeps the assertion off the transform pipeline entirely (AGENTS.md
// §测试纪律: never let an unbounded module load race a bounded `findBy` window).
vi.mock('@object-ui/plugin-designer', () => ({
CreateAppPage: () => <div data-testid="create-app-page">create app</div>,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

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

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: null,
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useIsWorkspaceAdmin: () => false,
}));

const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
};
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));

/** The zero-app deployment this whole screen exists for. */
const NO_APPS: unknown[] = [];
const refreshMetadata = vi.fn(async () => {});
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => ({
apps: NO_APPS,
objects: [],
loading: false,
// `undefined` — no bucket preloading to await, so the shell is ready on
// first render (mirrors a host that ships metadata eagerly).
ensureType: undefined,
error: null,
refresh: refreshMetadata,
}),
}));

const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }),
useGlobalUndo: () => {},
useMutationInvalidationBridge: () => {},
}));

import { AppContent } from '../AppContent';

/** Reports the live URL so a bounce is visible as a URL, not just a screen. */
function LocationProbe() {
const location = useLocation();
return <div data-testid="pathname">{location.pathname}</div>;
}

/**
* The reference host's route tree, reduced to the parts that decide this
* question: the `/apps/:appName/*` subtree, the landing route, and the
* trailing catch-all that used to swallow `/create-app`.
* Mirrors `apps/console/src/App.tsx`.
*/
function renderConsoleAt(initialUrl: string) {
return render(
<MemoryRouter initialEntries={[initialUrl]}>
<LocationProbe />
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
<Route path="/" element={<div data-testid="root-landing">landing</div>} />
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MemoryRouter>,
);
}

const pathname = () => screen.getByTestId('pathname').textContent;

describe('AppContent — no-apps empty state CTA (objectui#3573)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders the empty state at /apps/setup — the measured entry URL', async () => {
renderConsoleAt('/apps/setup');
expect(await screen.findByTestId('create-first-app-btn')).toBeInTheDocument();
});

it('renders it deeper in the same pseudo-route subtree too', async () => {
renderConsoleAt('/apps/setup/sys_inbox_message');
expect(await screen.findByTestId('create-first-app-btn')).toBeInTheDocument();
});

it('CTA opens the declared create-app route instead of bouncing to the landing page', async () => {
renderConsoleAt('/apps/setup');
fireEvent.click(await screen.findByTestId('create-first-app-btn'));

expect(await screen.findByTestId('create-app-page')).toBeInTheDocument();
expect(pathname()).toBe('/apps/setup/create-app');
// The regression this pins: the absolute `/create-app` matched no root
// route, so the host's `<Route path="*">` replaced it with `/`.
expect(screen.queryByTestId('root-landing')).not.toBeInTheDocument();
});

it('resolves from a deeper splat URL to the SAME create-app route', async () => {
// The depth trap: react-router 7 resolves a relative `to` against the leaf
// match's full pathname (splat included), so a relative `create-app` would
// build `/apps/setup/sys_inbox_message/create-app` here — matching nothing,
// rendering blank. The splat segment must NOT reach the target URL.
renderConsoleAt('/apps/setup/sys_inbox_message');
fireEvent.click(await screen.findByTestId('create-first-app-btn'));

expect(await screen.findByTestId('create-app-page')).toBeInTheDocument();
expect(pathname()).toBe('/apps/setup/create-app');
expect(screen.queryByTestId('root-landing')).not.toBeInTheDocument();
});

it('leaves the sibling go-to-settings CTA on its absolute /apps/setup target', async () => {
renderConsoleAt('/apps/setup/sys_inbox_message');
fireEvent.click(await screen.findByTestId('go-to-settings-btn'));

expect(pathname()).toBe('/apps/setup');
expect(screen.queryByTestId('root-landing')).not.toBeInTheDocument();
// NB: this asserts CURRENT behaviour, it does not bless it. `/apps/setup`
// bare IS this empty state's own URL (`isSystemRoute` needs a `/system`
// segment), so on a zero-app deployment this sibling CTA is a no-op loop —
// filed separately as #3590. Kept here only to prove the #3573 fix did not
// touch it; update this expectation together with #3590.
expect(await screen.findByTestId('create-first-app-btn')).toBeInTheDocument();
});

it('MEASUREMENT: a non-pseudo /apps/:appName URL never reaches this empty state', async () => {
// Evidence for the URL-family claim in the file header: an unresolved app
// segment that is not a pseudo-route takes the "App not available" branch,
// so `/apps/setup*` really is the only family the CTA has to work from.
renderConsoleAt('/apps/crm');
expect(await screen.findByTestId('app-not-available-retry')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByTestId('create-first-app-btn')).not.toBeInTheDocument();
});
});
});
Loading