From dff8c7ae3350c81f44b397525bffefade81d2504 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:19:12 +0800 Subject: [PATCH] fix(app-shell,components): welcome CTA deep-links into the environment create dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude Fable 5 --- .changeset/welcome-cta-autocreate.md | 14 ++++ .../src/console/home/CloudOnboardingNext.tsx | 7 +- .../__tests__/CloudOnboardingNext.test.tsx | 6 +- .../environment/EnvironmentListToolbar.tsx | 79 +++++++++++++++++-- .../__tests__/EnvironmentListToolbar.test.tsx | 30 ++++++- .../src/renderers/action/action-button.tsx | 19 ++++- 6 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 .changeset/welcome-cta-autocreate.md diff --git a/.changeset/welcome-cta-autocreate.md b/.changeset/welcome-cta-autocreate.md new file mode 100644 index 000000000..ed4cef79e --- /dev/null +++ b/.changeset/welcome-cta-autocreate.md @@ -0,0 +1,14 @@ +--- +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +Welcome-page "Create your environment" deep-links straight into the create +dialog (#844): `action:button` gains a client-side `autoTrigger` flag (runs +the action once on mount — same execute path as a click, so param dialogs / +confirms / entitlement gates still apply), and the environments list consumes +`?runAction=create_environment` to mark its create action once entitlements +resolve (upgrade-locked orgs get the upgrade prompt instead; the param is +stripped after consumption so refresh/back don't re-open). Also localizes the +EnvironmentListToolbar's state-aware label overrides ({en,zh}) — they were +hard-coded English inside a zh console. diff --git a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx index fd685052a..02797d663 100644 --- a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx +++ b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx @@ -161,7 +161,12 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
{showCreatePrimary ? ( - diff --git a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx index 09848b401..fa4ba8628 100644 --- a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx @@ -53,7 +53,11 @@ describe('CloudOnboardingNext', () => { expect(screen.queryByText('Open Production')).toBeNull(); fireEvent.click(create); - expect(navigateMock).toHaveBeenCalledWith('/apps/cloud_control/sys_environment'); + // Deep-links into the create dialog (#844): the environments list consumes + // `runAction=create_environment` and auto-opens its create action. + expect(navigateMock).toHaveBeenCalledWith( + '/apps/cloud_control/sys_environment?runAction=create_environment', + ); }); it('shows "Open Production" once the org has a production env', async () => { diff --git a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx index 96cc4e5f3..1cf27a1fb 100644 --- a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx +++ b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx @@ -21,6 +21,7 @@ * so only the label/variant changes — no duplicate POST logic here. */ +import { useEffect, useRef, useState } from 'react'; import { SchemaRenderer } from '@object-ui/react'; import { Button } from '@object-ui/components'; import { Plus } from 'lucide-react'; @@ -33,6 +34,51 @@ import { const CREATE_ACTION = 'create_environment'; +/** Resolve an inline {en,zh} label against the document locale. */ +function pick(label: { en: string; zh: string }): string { + const lang = + (typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en'; + return lang.toLowerCase().startsWith('zh') ? label.zh : label.en; +} + +/** + * Deep-link support (#844): `?runAction=create_environment` on the + * environments route auto-opens the create dialog once entitlements have + * resolved — the welcome page's "Create your environment" CTA uses it so the + * user doesn't land on the list and have to find the create button again. + * The param is consumed exactly once (stripped from the URL on consumption) + * so refresh / back don't re-open the dialog. + */ +function useAutoRunCreate(ctaKind: string | null): boolean { + // Deliberately router-free (window.location + history.replaceState): the + // toolbar also renders in tests/hosts without a Router, and the param is a + // one-shot signal, not navigation state. + const [requested] = useState(() => { + try { + return new URLSearchParams(window.location.search).get('runAction') === CREATE_ACTION; + } catch { + return false; + } + }); + const consumed = useRef(false); + // Only meaningful once the entitlement state has resolved: `upgrade_...` + // routes to the upgrade dialog instead, and while loading we must not + // trigger an action whose meaning (prod vs dev create) isn't known yet. + const shouldRun = requested && !consumed.current && ctaKind != null; + useEffect(() => { + if (!shouldRun) return; + consumed.current = true; + try { + const url = new URL(window.location.href); + url.searchParams.delete('runAction'); + window.history.replaceState(window.history.state, '', url); + } catch { + /* URL cleanup is cosmetic — never fail the trigger over it */ + } + }, [shouldRun]); + return shouldRun; +} + interface Props { /** Toolbar actions already localized by the caller (ObjectView). */ actions: any[]; @@ -44,9 +90,20 @@ interface Props { export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Props) { const toolbarActions = (actions || []).filter((a: any) => a?.locations?.includes('list_toolbar')); - if (toolbarActions.length === 0) return null; - const ctaKind = entitlements?.ready ? decideEnvironmentCta(entitlements) : null; + const autoRunCreate = useAutoRunCreate(toolbarActions.length > 0 ? ctaKind : null); + + // Deep-linked "create" while in the upgrade state opens the SAME upgrade + // prompt — the honest answer to "create" here. In an effect, not render: + // onUpgrade sets parent state. + useEffect(() => { + if (autoRunCreate && ctaKind === 'upgrade_for_development' && entitlements) { + onUpgrade(upgradeDialogSpec(entitlements)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoRunCreate, ctaKind]); + + if (toolbarActions.length === 0) return null; // Upgrade state: a free-plan org clicking "create" must NOT POST-then-403. // Render a primary button that opens the upgrade prompt, plus any other @@ -67,7 +124,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro data-testid="environment-add-upgrade" > - Add environment + {pick({ en: 'Add environment', zh: '新建环境' })} ); @@ -75,13 +132,25 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro // setup_production / add_development / loading: render the bar, overriding only // the create action's label (and promoting production setup to a primary CTA). + // Labels are locale-aware — the metadata label is already localized by the + // caller, but these state-aware overrides used to be hard-coded English, + // which flashed an English button in a zh console (#844). const renderedActions = toolbarActions.map((a: any) => { if (a?.name !== CREATE_ACTION || ctaKind == null) return a; if (ctaKind === 'setup_production') { - return { ...a, label: 'Set up your production environment', variant: 'primary' }; + return { + ...a, + label: pick({ en: 'Set up your production environment', zh: '创建你的生产环境' }), + variant: 'primary', + ...(autoRunCreate ? { autoTrigger: true } : {}), + }; } // add_development - return { ...a, label: 'Add development environment' }; + return { + ...a, + label: pick({ en: 'Add development environment', zh: '新建开发环境' }), + ...(autoRunCreate ? { autoTrigger: true } : {}), + }; }); return ( diff --git a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx index 8a78aec8c..e17d1c210 100644 --- a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx +++ b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx @@ -14,7 +14,7 @@ vi.mock('@object-ui/react', async (importActual) => ({ SchemaRenderer: ({ schema }: any) => (
{(schema.actions || []).map((a: any) => ( - + ))}
), @@ -62,3 +62,31 @@ describe('EnvironmentListToolbar', () => { expect(screen.queryByTestId('environment-add-upgrade')).toBeNull(); }); }); + +describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#844)', () => { + const withRunActionParam = () => { + const url = new URL(window.location.href); + url.searchParams.set('runAction', 'create_environment'); + window.history.replaceState(null, '', url); + }; + + it('marks the create action autoTrigger once entitlements resolve, then strips the param', async () => { + withRunActionParam(); + render(); + const btn = screen.getByText('Set up your production environment'); + // The SchemaRenderer stub surfaces autoTrigger as data-autotrigger. + expect(btn.getAttribute('data-autotrigger')).toBe('true'); + // Param is consumed exactly once — stripped from the URL. + await vi.waitFor(() => { + expect(new URL(window.location.href).searchParams.get('runAction')).toBeNull(); + }); + }); + + it('upgrade state: deep link opens the upgrade prompt instead of a create POST', async () => { + withRunActionParam(); + const onUpgrade = vi.fn(); + render(); + await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1)); + expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' }); + }); +}); diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index 302b127e5..3e49281b3 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -17,7 +17,7 @@ * - Variant / size / className overrides from schema */ -import React, { forwardRef, useCallback, useState } from 'react'; +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ActionSchema } from '@object-ui/types'; import { useAction } from '@object-ui/react'; @@ -129,6 +129,23 @@ const ActionButtonRenderer = forwardRef( } }, [schema, execute, loading, localContext]); + // Client-side auto-trigger (#844): a caller (e.g. a welcome-page CTA that + // deep-links into "create") can mark an action `autoTrigger: true` to run + // it once as soon as the button mounts — the exact same execute path as a + // click, so param dialogs / confirms / entitlement gates all still apply. + // NOT persisted metadata: the flag only exists on client-composed schemas. + // The ref guards re-fires across re-renders; the flag flipping true later + // (state-dependent toolbars) still triggers exactly once. + const autoTriggered = useRef(false); + const autoTrigger = (schema as any).autoTrigger === true; + useEffect(() => { + if (!autoTrigger || autoTriggered.current) return; + autoTriggered.current = true; + void handleClick(); + // handleClick identity changes with schema/context churn; the ref makes + // this once-only regardless, so it's safe to depend on it. + }, [autoTrigger, handleClick]); + if (schema.visible && !isVisible) return null; return (