Skip to content

Commit 33b4995

Browse files
os-zhuangclaude
andauthored
fix(app-shell,components): welcome CTA deep-links into the environment create dialog (#2631)
Staging E2E (2026-07-17): the welcome hero's "Create your environment" navigated to the environments list, where the user had to find and click a SECOND create button — an extra hop on the very first thing a new user does (#844). - action:button: client-side `autoTrigger` flag — runs the action once on mount through the exact same execute path as a click (param dialog, confirm, entitlement gate all apply). Not persisted metadata; only client- composed schemas set it. - EnvironmentListToolbar: consume `?runAction=create_environment` once entitlements resolve — setup_production / add_development mark the create action autoTrigger; upgrade-locked orgs open the upgrade prompt (the honest answer to "create" there). Param is stripped on consumption so refresh / back don't re-open the dialog. Router-free (location + replaceState) so non-Router hosts and tests keep working. - CloudOnboardingNext: the create CTA navigates with the runAction param. - i18n: the toolbar's state-aware label overrides were hard-coded English in a zh console — now {en,zh} via the same pick() pattern as the widget. Closes #844. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 45c6fb4 commit 33b4995

6 files changed

Lines changed: 146 additions & 9 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
Welcome-page "Create your environment" deep-links straight into the create
7+
dialog (#844): `action:button` gains a client-side `autoTrigger` flag (runs
8+
the action once on mount — same execute path as a click, so param dialogs /
9+
confirms / entitlement gates still apply), and the environments list consumes
10+
`?runAction=create_environment` to mark its create action once entitlements
11+
resolve (upgrade-locked orgs get the upgrade prompt instead; the param is
12+
stripped after consumption so refresh/back don't re-open). Also localizes the
13+
EnvironmentListToolbar's state-aware label overrides ({en,zh}) — they were
14+
hard-coded English inside a zh console.

packages/app-shell/src/console/home/CloudOnboardingNext.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,12 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
161161
<div className="flex flex-col items-center gap-3" data-onboarding={state.phase}>
162162
<div className="flex flex-wrap items-center justify-center gap-3">
163163
{showCreatePrimary ? (
164-
<Button size="lg" onClick={() => navigate(envsRoute)}>
164+
// Deep-link straight INTO the create dialog (#844): the environments
165+
// list consumes `runAction=create_environment` and auto-opens its
166+
// create action once entitlements resolve — "Create your environment"
167+
// used to be a plain navigation that left the user hunting for a
168+
// second create button on the list page.
169+
<Button size="lg" onClick={() => navigate(`${envsRoute}?runAction=create_environment`)}>
165170
<Plus className="mr-2 h-4 w-4" />
166171
{pick({ en: 'Create your environment', zh: '创建你的环境' })}
167172
</Button>

packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ describe('CloudOnboardingNext', () => {
5353
expect(screen.queryByText('Open Production')).toBeNull();
5454

5555
fireEvent.click(create);
56-
expect(navigateMock).toHaveBeenCalledWith('/apps/cloud_control/sys_environment');
56+
// Deep-links into the create dialog (#844): the environments list consumes
57+
// `runAction=create_environment` and auto-opens its create action.
58+
expect(navigateMock).toHaveBeenCalledWith(
59+
'/apps/cloud_control/sys_environment?runAction=create_environment',
60+
);
5761
});
5862

5963
it('shows "Open Production" once the org has a production env', async () => {

packages/app-shell/src/environment/EnvironmentListToolbar.tsx

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* so only the label/variant changes — no duplicate POST logic here.
2222
*/
2323

24+
import { useEffect, useRef, useState } from 'react';
2425
import { SchemaRenderer } from '@object-ui/react';
2526
import { Button } from '@object-ui/components';
2627
import { Plus } from 'lucide-react';
@@ -33,6 +34,51 @@ import {
3334

3435
const CREATE_ACTION = 'create_environment';
3536

37+
/** Resolve an inline {en,zh} label against the document locale. */
38+
function pick(label: { en: string; zh: string }): string {
39+
const lang =
40+
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
41+
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
42+
}
43+
44+
/**
45+
* Deep-link support (#844): `?runAction=create_environment` on the
46+
* environments route auto-opens the create dialog once entitlements have
47+
* resolved — the welcome page's "Create your environment" CTA uses it so the
48+
* user doesn't land on the list and have to find the create button again.
49+
* The param is consumed exactly once (stripped from the URL on consumption)
50+
* so refresh / back don't re-open the dialog.
51+
*/
52+
function useAutoRunCreate(ctaKind: string | null): boolean {
53+
// Deliberately router-free (window.location + history.replaceState): the
54+
// toolbar also renders in tests/hosts without a Router, and the param is a
55+
// one-shot signal, not navigation state.
56+
const [requested] = useState<boolean>(() => {
57+
try {
58+
return new URLSearchParams(window.location.search).get('runAction') === CREATE_ACTION;
59+
} catch {
60+
return false;
61+
}
62+
});
63+
const consumed = useRef(false);
64+
// Only meaningful once the entitlement state has resolved: `upgrade_...`
65+
// routes to the upgrade dialog instead, and while loading we must not
66+
// trigger an action whose meaning (prod vs dev create) isn't known yet.
67+
const shouldRun = requested && !consumed.current && ctaKind != null;
68+
useEffect(() => {
69+
if (!shouldRun) return;
70+
consumed.current = true;
71+
try {
72+
const url = new URL(window.location.href);
73+
url.searchParams.delete('runAction');
74+
window.history.replaceState(window.history.state, '', url);
75+
} catch {
76+
/* URL cleanup is cosmetic — never fail the trigger over it */
77+
}
78+
}, [shouldRun]);
79+
return shouldRun;
80+
}
81+
3682
interface Props {
3783
/** Toolbar actions already localized by the caller (ObjectView). */
3884
actions: any[];
@@ -44,9 +90,20 @@ interface Props {
4490

4591
export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Props) {
4692
const toolbarActions = (actions || []).filter((a: any) => a?.locations?.includes('list_toolbar'));
47-
if (toolbarActions.length === 0) return null;
48-
4993
const ctaKind = entitlements?.ready ? decideEnvironmentCta(entitlements) : null;
94+
const autoRunCreate = useAutoRunCreate(toolbarActions.length > 0 ? ctaKind : null);
95+
96+
// Deep-linked "create" while in the upgrade state opens the SAME upgrade
97+
// prompt — the honest answer to "create" here. In an effect, not render:
98+
// onUpgrade sets parent state.
99+
useEffect(() => {
100+
if (autoRunCreate && ctaKind === 'upgrade_for_development' && entitlements) {
101+
onUpgrade(upgradeDialogSpec(entitlements));
102+
}
103+
// eslint-disable-next-line react-hooks/exhaustive-deps
104+
}, [autoRunCreate, ctaKind]);
105+
106+
if (toolbarActions.length === 0) return null;
50107

51108
// Upgrade state: a free-plan org clicking "create" must NOT POST-then-403.
52109
// Render a primary button that opens the upgrade prompt, plus any other
@@ -67,21 +124,33 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
67124
data-testid="environment-add-upgrade"
68125
>
69126
<Plus className="h-4 w-4" />
70-
<span>Add environment</span>
127+
<span>{pick({ en: 'Add environment', zh: '新建环境' })}</span>
71128
</Button>
72129
</>
73130
);
74131
}
75132

76133
// setup_production / add_development / loading: render the bar, overriding only
77134
// the create action's label (and promoting production setup to a primary CTA).
135+
// Labels are locale-aware — the metadata label is already localized by the
136+
// caller, but these state-aware overrides used to be hard-coded English,
137+
// which flashed an English button in a zh console (#844).
78138
const renderedActions = toolbarActions.map((a: any) => {
79139
if (a?.name !== CREATE_ACTION || ctaKind == null) return a;
80140
if (ctaKind === 'setup_production') {
81-
return { ...a, label: 'Set up your production environment', variant: 'primary' };
141+
return {
142+
...a,
143+
label: pick({ en: 'Set up your production environment', zh: '创建你的生产环境' }),
144+
variant: 'primary',
145+
...(autoRunCreate ? { autoTrigger: true } : {}),
146+
};
82147
}
83148
// add_development
84-
return { ...a, label: 'Add development environment' };
149+
return {
150+
...a,
151+
label: pick({ en: 'Add development environment', zh: '新建开发环境' }),
152+
...(autoRunCreate ? { autoTrigger: true } : {}),
153+
};
85154
});
86155

87156
return (

packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ vi.mock('@object-ui/react', async (importActual) => ({
1414
SchemaRenderer: ({ schema }: any) => (
1515
<div data-testid="action-bar">
1616
{(schema.actions || []).map((a: any) => (
17-
<button key={a.name} data-variant={a.variant}>{a.label}</button>
17+
<button key={a.name} data-variant={a.variant} data-autotrigger={a.autoTrigger ? 'true' : undefined}>{a.label}</button>
1818
))}
1919
</div>
2020
),
@@ -62,3 +62,31 @@ describe('EnvironmentListToolbar', () => {
6262
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
6363
});
6464
});
65+
66+
describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#844)', () => {
67+
const withRunActionParam = () => {
68+
const url = new URL(window.location.href);
69+
url.searchParams.set('runAction', 'create_environment');
70+
window.history.replaceState(null, '', url);
71+
};
72+
73+
it('marks the create action autoTrigger once entitlements resolve, then strips the param', async () => {
74+
withRunActionParam();
75+
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
76+
const btn = screen.getByText('Set up your production environment');
77+
// The SchemaRenderer stub surfaces autoTrigger as data-autotrigger.
78+
expect(btn.getAttribute('data-autotrigger')).toBe('true');
79+
// Param is consumed exactly once — stripped from the URL.
80+
await vi.waitFor(() => {
81+
expect(new URL(window.location.href).searchParams.get('runAction')).toBeNull();
82+
});
83+
});
84+
85+
it('upgrade state: deep link opens the upgrade prompt instead of a create POST', async () => {
86+
withRunActionParam();
87+
const onUpgrade = vi.fn();
88+
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
89+
await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1));
90+
expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' });
91+
});
92+
});

packages/components/src/renderers/action/action-button.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* - Variant / size / className overrides from schema
1818
*/
1919

20-
import React, { forwardRef, useCallback, useState } from 'react';
20+
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
2121
import { ComponentRegistry } from '@object-ui/core';
2222
import type { ActionSchema } from '@object-ui/types';
2323
import { useAction } from '@object-ui/react';
@@ -129,6 +129,23 @@ const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
129129
}
130130
}, [schema, execute, loading, localContext]);
131131

132+
// Client-side auto-trigger (#844): a caller (e.g. a welcome-page CTA that
133+
// deep-links into "create") can mark an action `autoTrigger: true` to run
134+
// it once as soon as the button mounts — the exact same execute path as a
135+
// click, so param dialogs / confirms / entitlement gates all still apply.
136+
// NOT persisted metadata: the flag only exists on client-composed schemas.
137+
// The ref guards re-fires across re-renders; the flag flipping true later
138+
// (state-dependent toolbars) still triggers exactly once.
139+
const autoTriggered = useRef(false);
140+
const autoTrigger = (schema as any).autoTrigger === true;
141+
useEffect(() => {
142+
if (!autoTrigger || autoTriggered.current) return;
143+
autoTriggered.current = true;
144+
void handleClick();
145+
// handleClick identity changes with schema/context churn; the ref makes
146+
// this once-only regardless, so it's safe to depend on it.
147+
}, [autoTrigger, handleClick]);
148+
132149
if (schema.visible && !isVisible) return null;
133150

134151
return (

0 commit comments

Comments
 (0)