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
14 changes: 14 additions & 0 deletions .changeset/welcome-cta-autocreate.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/app-shell/src/console/home/CloudOnboardingNext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
<div className="flex flex-col items-center gap-3" data-onboarding={state.phase}>
<div className="flex flex-wrap items-center justify-center gap-3">
{showCreatePrimary ? (
<Button size="lg" onClick={() => navigate(envsRoute)}>
// Deep-link straight INTO the create dialog (#844): the environments
// list consumes `runAction=create_environment` and auto-opens its
// create action once entitlements resolve — "Create your environment"
// used to be a plain navigation that left the user hunting for a
// second create button on the list page.
<Button size="lg" onClick={() => navigate(`${envsRoute}?runAction=create_environment`)}>
<Plus className="mr-2 h-4 w-4" />
{pick({ en: 'Create your environment', zh: '创建你的环境' })}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
79 changes: 74 additions & 5 deletions packages/app-shell/src/environment/EnvironmentListToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<boolean>(() => {
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[];
Expand All @@ -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
Expand All @@ -67,21 +124,33 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
data-testid="environment-add-upgrade"
>
<Plus className="h-4 w-4" />
<span>Add environment</span>
<span>{pick({ en: 'Add environment', zh: '新建环境' })}</span>
</Button>
</>
);
}

// 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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ vi.mock('@object-ui/react', async (importActual) => ({
SchemaRenderer: ({ schema }: any) => (
<div data-testid="action-bar">
{(schema.actions || []).map((a: any) => (
<button key={a.name} data-variant={a.variant}>{a.label}</button>
<button key={a.name} data-variant={a.variant} data-autotrigger={a.autoTrigger ? 'true' : undefined}>{a.label}</button>
))}
</div>
),
Expand Down Expand Up @@ -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(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
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(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1));
expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' });
});
});
19 changes: 18 additions & 1 deletion packages/components/src/renderers/action/action-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -129,6 +129,23 @@ const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
}
}, [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 (
Expand Down
Loading