Skip to content

Commit 2d785c8

Browse files
os-zhuangclaude
andauthored
feat(app-shell): first-run zero-org users land straight on the create-workspace form (#2165)
A brand-new user with no organizations was routed to the org picker (/organizations), whose empty "No organizations yet" state is pure friction — there is nothing to pick (one extra hop). Auto-open the existing CreateWorkspaceDialog when the org list is empty so they land directly on the "name your workspace" form. Users who already have orgs still see the picker; the `?create=1` entry is unchanged; the empty-state (with its own "New organization" button) remains the backdrop if the dialog is dismissed. The dialog already creates the org from the user's chosen name and eagerly provisions its production environment (born-with-env), so this completes the workspace-first onboarding: name your workspace -> org + Production env. It belongs in the platform (app-shell), reused by any multi-org ObjectOS app; cloud's onOrganizationCreated hook provisions the env (open mechanism, closed orchestration — ADR-0002/0024). Verified: new OrganizationsPage.test.tsx (zero-org auto-opens; multi-org does not; ?create=1 still opens) — 3/3 pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f824056 commit 2d785c8

2 files changed

Lines changed: 115 additions & 7 deletions

File tree

packages/app-shell/src/console/organizations/OrganizationsPage.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,21 +140,30 @@ export function OrganizationsPage() {
140140
}, [isOrganizationsLoading, orgList.length, manageMode, wantsCreate]);
141141

142142
// Open the create dialog when arriving via the header "Create workspace"
143-
// entry (`?create=1`). Guarded so closing the dialog doesn't re-open it.
143+
// entry (`?create=1`), OR for a brand-new user who has ZERO organizations:
144+
// they have nothing to pick, so land them straight on the "name your
145+
// workspace" form instead of an empty picker (one hop fewer on first run).
146+
// The empty-state (with its own "New organization" button) remains the
147+
// backdrop if they dismiss the dialog. Guarded so closing never re-opens it.
144148
const createOpenedRef = useRef(false);
145149
useEffect(() => {
146-
if (wantsCreate && !createOpenedRef.current) {
150+
if (isOrganizationsLoading) return;
151+
const firstRunNoOrg = orgList.length === 0 && canCreateOrg;
152+
if ((wantsCreate || firstRunNoOrg) && !createOpenedRef.current) {
147153
createOpenedRef.current = true;
148154
setIsCreateOpen(true);
149155
}
150-
}, [wantsCreate]);
156+
}, [wantsCreate, orgList.length, canCreateOrg, isOrganizationsLoading]);
151157

152-
// Show a spinner while we're either still loading, or about to auto-redirect
153-
// because there's only one org. This prevents the picker from briefly
154-
// flashing on screen for single-org users.
158+
// Show a spinner while we're either still loading, about to auto-redirect
159+
// because there's only one org, or about to auto-open the create dialog for a
160+
// brand-new zero-org user. This prevents the picker / empty-state from
161+
// briefly flashing on screen before the redirect or dialog.
155162
const willAutoSelect =
156163
!wantsCreate && !isOrganizationsLoading && orgList.length === 1;
157-
if (isOrganizationsLoading || willAutoSelect) {
164+
const willAutoCreate =
165+
!isOrganizationsLoading && orgList.length === 0 && canCreateOrg && !createOpenedRef.current;
166+
if (isOrganizationsLoading || willAutoSelect || willAutoCreate) {
158167
return (
159168
<div className="flex flex-1 items-center justify-center py-20">
160169
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* OrganizationsPage — zero-org first-run auto-opens the create-workspace form.
5+
*
6+
* A brand-new user with NO organizations has nothing to pick, so the page must
7+
* land them straight on the "name your workspace" dialog instead of an empty
8+
* picker (one hop fewer on first run). Users who already have organizations
9+
* still see the picker; the `?create=1` entry still opens the dialog.
10+
*/
11+
12+
import '@testing-library/jest-dom/vitest';
13+
import { describe, it, expect, vi, beforeEach } from 'vitest';
14+
import { render, screen, waitFor } from '@testing-library/react';
15+
16+
vi.mock('@object-ui/i18n', () => ({
17+
useObjectTranslation: () => ({
18+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
19+
}),
20+
}));
21+
22+
const navigate = vi.fn();
23+
let searchParams = new URLSearchParams();
24+
vi.mock('react-router-dom', () => ({
25+
useNavigate: () => navigate,
26+
useSearchParams: () => [searchParams],
27+
}));
28+
29+
let authState: Record<string, unknown>;
30+
vi.mock('@object-ui/auth', () => ({ useAuth: () => authState }));
31+
32+
vi.mock('../resolveHomeUrl', () => ({ resolveRootUrl: () => '/root' }));
33+
34+
// Stub the dialog so the test observes only whether it is opened.
35+
vi.mock('../CreateWorkspaceDialog', () => ({
36+
CreateWorkspaceDialog: ({ open }: { open: boolean }) =>
37+
open ? <div data-testid="create-workspace-dialog" /> : null,
38+
}));
39+
40+
// Passthrough UI primitives (spread props → children render).
41+
vi.mock('@object-ui/components', () => ({
42+
Avatar: (p: any) => <div {...p} />,
43+
AvatarImage: (p: any) => <img {...p} />,
44+
AvatarFallback: (p: any) => <div {...p} />,
45+
Button: (p: any) => <button {...p} />,
46+
Input: (p: any) => <input {...p} />,
47+
Empty: (p: any) => <div {...p} />,
48+
EmptyTitle: (p: any) => <div {...p} />,
49+
EmptyDescription: (p: any) => <div {...p} />,
50+
}));
51+
vi.mock('lucide-react', () => ({
52+
Plus: () => <span />,
53+
Search: () => <span />,
54+
Loader2: () => <span />,
55+
}));
56+
57+
import { OrganizationsPage } from '../OrganizationsPage';
58+
59+
beforeEach(() => {
60+
vi.clearAllMocks();
61+
searchParams = new URLSearchParams();
62+
authState = {
63+
organizations: [],
64+
activeOrganization: null,
65+
isOrganizationsLoading: false,
66+
switchOrganization: vi.fn(),
67+
getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true } }),
68+
};
69+
});
70+
71+
describe('OrganizationsPage — zero-org first run', () => {
72+
it('auto-opens the create-workspace dialog when the user has no organizations', async () => {
73+
render(<OrganizationsPage />);
74+
await waitFor(() =>
75+
expect(screen.getByTestId('create-workspace-dialog')).toBeInTheDocument(),
76+
);
77+
// Landed straight on the form — no picker card navigation happened.
78+
expect(navigate).not.toHaveBeenCalled();
79+
});
80+
81+
it('does NOT auto-open the dialog when the user already has organizations', async () => {
82+
authState.organizations = [
83+
{ id: 'o1', name: 'Alpha', slug: 'alpha' },
84+
{ id: 'o2', name: 'Beta', slug: 'beta' },
85+
];
86+
render(<OrganizationsPage />);
87+
await waitFor(() => expect((authState.getAuthConfig as any)).toHaveBeenCalled());
88+
expect(screen.queryByTestId('create-workspace-dialog')).toBeNull();
89+
});
90+
91+
it('still opens the dialog via the explicit ?create=1 entry (regression)', async () => {
92+
authState.organizations = [{ id: 'o1', name: 'Alpha', slug: 'alpha' }];
93+
searchParams = new URLSearchParams('create=1');
94+
render(<OrganizationsPage />);
95+
await waitFor(() =>
96+
expect(screen.getByTestId('create-workspace-dialog')).toBeInTheDocument(),
97+
);
98+
});
99+
});

0 commit comments

Comments
 (0)