Skip to content

Commit 72877bc

Browse files
os-zhuangclaude
andauthored
feat(app-shell): the environment list offers only "Set up your production environment"; the development-environment door is gone (#29778)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HPLuovszPmhavRYXixiYVakP)_ Part of #29770. This PR implements the whole card. The card is closed by hand when this PR lands: the executing branch is a project-thread branch, not `claude/issue-<n>-*`, and the closing-target guard only accepts a claim that names the latter shape. ## What changes Card B of the objectstack-ai/cloud#3487 ruling D ([ruling of record](objectstack-ai/cloud#3487 (comment))): each organization has exactly one environment, its production environment, and the development type is retired. All changes are under `ui/packages/app-shell` and `ui/packages/i18n`. - **Toolbar (`EnvironmentListToolbar`).** "Set up your production environment" is the only creation path. `decideEnvironmentCta` now answers `setup_production` or `production_exists`. In `production_exists` the `create_environment` action is withheld: no "Add development environment" button, no plan-locked "Development environments · Team plan and up" mark (`DevelopmentLockedMark` is deleted), no dialog, no POST. Other toolbar actions render as before. The `?runAction=create_environment` deep link is consumed and runs nothing in that state, the same handling the retired plan-locked state had. The loading skeleton and the no-signal fallback (metadata label + dialog safety net) are unchanged. - **Dialog (`entitlementDialogFromError`).** Maps `PRODUCTION_ENV_LIMIT` only. The `DEV_ENV_PLAN_LOCKED` / `DEV_ENV_LIMIT` branches and `planPhrase` are deleted. Those codes and cloud card A's new `DEV_ENV_RETIRED` take the ordinary error path and show the server's own message. - **State.** `EnvironmentEntitlementsState.canCreateDevelopmentEnv` and `EnvironmentEntitlementsSummary.development` are removed. None of these types is exported from `@object-ui/app-shell`. - **i18n.** Removed from all ten locale bundles: `planTier.teamAndUp` (and the `planTier` namespace), `environment.addDevelopment`, `environment.developmentLocked`, `environment.entitlement.{planLockedTitle, planLockedBody, limitTitle, limitBody, limitBodyWithCount, freePlan, namedPlan, upgradeCta}`. The ADR-0060 live list `product-specific.allowlist.json` shrinks by the same keys. It accepts deletions only, and this PR only deletes. ## Why The control plane is closing its development doors in parallel (cloud card A, objectstack-ai/cloud#3501, `DEV_ENV_RETIRED`). Without this change, a Console showing "Add development environment" would only produce refusals. Cloud's next framework pin carries this PR, and cloud card D (objectstack-ai/cloud#3503) waits for that pin. **Acceptance notes.** - An organization that already owns its environment, in the **no-signal** state (summary endpoint and row derivation both failed), still sees the metadata-labelled create button, as before. A POST there gets the control plane's answer. If that answer is `PRODUCTION_ENV_LIMIT`, the limit dialog opens. If it is `DEV_ENV_RETIRED`, a toast shows the server's localized sentence. Which code cloud returns for a typeless create once production exists is cloud card A's call. - `classifyEnvironmentType` in `useEnvironmentEntitlements` still mirrors the server's two types for the row fallback. It only decides `hasProductionEnv`, and archived rows are filtered out. Cloud card D retires the type end to end. ## Verification - New pins: `EnvironmentListToolbar.test.tsx` › "no development-environment door renders (cloud#3487 D)" renders nine entitlement states (loading, no signal, no production / production × summary / rows × manager / member × free / team) in `en` and `zh`. Each asserts that no text matches `/development/i` (`开发环境` in zh) and that the retired locked-mark test id is absent. `entitlements.test.ts` and `useConsoleActionRuntime.test.tsx` pin `DEV_ENV_PLAN_LOCKED` / `DEV_ENV_LIMIT` / `DEV_ENV_RETIRED` to the ordinary error path, with no dialog. - Production path unchanged: the setup-production label, primary variant, deep-link `autoTrigger` and skeleton pins are kept. The overflow deep-link pin (#4162) now runs on `setup_production`, using fillers with `order: -1` so the create action still overflows. - Local run at `ebe16a4c` (objectstack-ai/objectstack): `pnpm --dir ui exec vitest run packages/app-shell/src/environment/ packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts packages/i18n/` → 106 files passed; 1634 tests passed, 13 skipped. `eslint --quiet` on the touched files is clean. The whole UI suite and type check ran in CI on the same head: all 16 Test UI shards, Type Check UI and Lint UI passed. Clause-②: no Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BuhJziidmx7KHut5GG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d86e4a commit 72877bc

26 files changed

Lines changed: 383 additions & 797 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): the environment list offers only "Set up your production environment"; the development-environment door is gone
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Each organization now has exactly one environment, its production environment: the development environment type is retired (objectstack-ai/cloud#3487, ruling D). The Console's `sys_environment` list follows.
12+
13+
- **Toolbar.** "Set up your production environment" is the only creation path. Once the organization owns its production environment, the `create_environment` action is withheld: no "Add development environment" button, no "Development environments · Team plan and up" locked mark, no dialog and no POST. The list's other toolbar actions render as before, and the `?runAction=create_environment` deep link is consumed and runs nothing in that state. The loading skeleton and the no-signal fallback (the action's metadata label) are unchanged.
14+
- **Dialog.** `entitlementDialogFromError` maps `PRODUCTION_ENV_LIMIT` only. `DEV_ENV_PLAN_LOCKED`, `DEV_ENV_LIMIT` and the control plane's new `DEV_ENV_RETIRED` take the ordinary error path and show the server's own message.
15+
- **State.** `EnvironmentEntitlementsState` drops `canCreateDevelopmentEnv`, and the summary type drops `development`; `EnvironmentCtaKind` is now `'setup_production' | 'production_exists'`. None of these is exported from `@object-ui/app-shell`.
16+
- **i18n.** Removed from every locale bundle: `planTier.teamAndUp` (the namespace goes with it), `environment.addDevelopment`, `environment.developmentLocked`, and `environment.entitlement.{planLockedTitle, planLockedBody, limitTitle, limitBody, limitBodyWithCount, freePlan, namedPlan, upgradeCta}`. A host that read one of these keys through `t()` now gets the key back; there is no replacement, because nothing is left to label.

‎ui/packages/app-shell/src/__tests__/product-specific.allowlist.json‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,19 +85,9 @@
8585
"seeded"
8686
],
8787
"environment": [
88-
"addDevelopment",
89-
"developmentLocked",
9088
"entitlement.contactSalesCta",
91-
"entitlement.freePlan",
92-
"entitlement.limitBody",
93-
"entitlement.limitBodyWithCount",
94-
"entitlement.limitTitle",
95-
"entitlement.namedPlan",
96-
"entitlement.planLockedBody",
97-
"entitlement.planLockedTitle",
9889
"entitlement.productionLimitBody",
9990
"entitlement.productionLimitTitle",
100-
"entitlement.upgradeCta",
10191
"setUpProduction"
10292
],
10393
"organizations": [
@@ -114,9 +104,6 @@
114104
"subtitle",
115105
"title"
116106
],
117-
"planTier": [
118-
"teamAndUp"
119-
],
120107
"workspace": [
121108
"createButton",
122109
"createDescription",

‎ui/packages/app-shell/src/environment/EnvironmentEntitlementDialog.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/**
2-
* EnvironmentEntitlementDialog — a friendly upgrade / limit dialog shown instead
3-
* of a raw red error toast when an environment-create is gated by plan or
4-
* capacity (DEV_ENV_PLAN_LOCKED / DEV_ENV_LIMIT / PRODUCTION_ENV_LIMIT).
2+
* EnvironmentEntitlementDialog — a friendly limit dialog shown instead of a raw
3+
* red error toast when an environment-create is refused because the org
4+
* already owns its one production environment (PRODUCTION_ENV_LIMIT).
55
*
66
* Driven by a single {@link EntitlementDialogSpec}, opened from one place: the
77
* action runtime's apiHandler, when the create POST comes back with an
8-
* entitlement 403. The env-list toolbar opens no dialog: its plan-locked state
9-
* draws a locked mark that links to the upgrade page instead (objectui#12190).
8+
* entitlement 403. The env-list toolbar opens no dialog: once the production
9+
* environment exists it draws no create affordance at all.
1010
*
1111
* The CTA renders as an anchor (not an SPA navigation) so a control-plane URL
1212
* lands on the control plane's own page regardless of the console's router.

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

Lines changed: 36 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,22 @@
55
* The cloud serves ONE `create_environment` action; born-with-env makes its
66
* meaning depend on org state (which the action metadata can't express). This
77
* component reads the resolved entitlement state and renders the right
8-
* affordance:
9-
* • no production env → "Set up your production environment" (primary);
10-
* the create POST provisions the org's one
11-
* production env — the historical-data path that
12-
* must never error.
13-
* • has prod + dev allowed → "Add development environment" (the create POST
14-
* makes a dev env).
15-
* • has prod + dev NOT allowed → for a viewer the summary sends an `upgradeUrl`
16-
* to, an outline, LOCKED mark naming the tier
17-
* development environments start at, linking
18-
* to that URL; for a viewer it sends none, no
19-
* mark (objectui#12983). Either way no button,
20-
* no dialog, no POST (objectui#12190).
21-
* • still resolving → a SKELETON where the create button will land —
22-
* no label at all (see below).
23-
* • settled with no signal → the action's metadata label (neutral), with the
24-
* apiHandler entitlement dialog as the safety net.
8+
* affordance. Each organization has exactly one environment, its production
9+
* environment (objectstack-ai/cloud#3487, ruling D: the development type is
10+
* retired), so creating the production environment is the only creation path:
11+
* • no production env → "Set up your production environment" (primary);
12+
* the create POST provisions the org's one
13+
* production env — the historical-data path that
14+
* must never error.
15+
* • has production env → no create affordance at all: nothing is left to
16+
* create, so no button, no dialog, no POST.
17+
* • still resolving → a SKELETON where the create button will land —
18+
* no label at all (see below).
19+
* • settled with no signal → the action's metadata label (neutral), with the
20+
* apiHandler entitlement dialog as the safety net.
2521
*
26-
* Create flows reuse the standard `action:bar` runner (name modal → apiHandler),
27-
* so only the label/variant changes — no duplicate POST logic here.
22+
* The create flow reuses the standard `action:bar` runner (name modal →
23+
* apiHandler), so only the label/variant changes — no duplicate POST logic here.
2824
*
2925
* ## Why a skeleton while resolving (cloud#1049 → objectui#3482)
3026
*
@@ -44,13 +40,12 @@
4440
* There is no longer one string with two owners — there are two DIFFERENT strings
4541
* for two different states, and each has exactly one owner:
4642
*
47-
* • Every RESOLVED state → THIS package. `environment.setUpProduction` /
48-
* `environment.addDevelopment` / `environment.developmentLocked` (with
49-
* `planTier.teamAndUp`) in `packages/i18n/src/locales/*.ts` are the sole
50-
* source of truth. The cloud
51-
* action metadata's `_actions.create_environment.label` translation CANNOT
52-
* reach the button here — `decideEnvironmentCta()` is total over `ready`
53-
* state, so a resolved state always overrides the label.
43+
* • Every RESOLVED state → THIS package. `environment.setUpProduction` in
44+
* `packages/i18n/src/locales/*.ts` is the sole source of truth, and the
45+
* production-exists state draws no button at all. The cloud action
46+
* metadata's `_actions.create_environment.label` translation CANNOT reach
47+
* the button here — `decideEnvironmentCta()` is total over `ready` state,
48+
* so a resolved state always overrides the label.
5449
* • Still resolving → nothing (skeleton).
5550
* • Settled with NO usable signal (`ready: false`, `source: 'unknown'` — the
5651
* summary endpoint was unusable AND the row-derivation threw) → the metadata
@@ -64,11 +59,9 @@
6459
import { SchemaRenderer, useCapabilityGate } from '@object-ui/react';
6560
import { actionRendersAt } from '@object-ui/types';
6661
import type { ActionBarSchema } from '@object-ui/types';
67-
import { Button, Skeleton } from '@object-ui/components';
68-
import { Lock } from 'lucide-react';
62+
import { Skeleton } from '@object-ui/components';
6963
import { useObjectTranslation } from '@object-ui/i18n';
7064
import { useNavRunAction } from '../hooks/useNavRunAction.js';
71-
import { resolveCtaHref } from './EnvironmentEntitlementDialog.js';
7265
import { decideEnvironmentCta, type EnvironmentEntitlementsState } from './entitlements.js';
7366

7467
const CREATE_ACTION = 'create_environment';
@@ -95,13 +88,11 @@ const CREATE_ACTION = 'create_environment';
9588
* lost, it is unrecoverable: the URL no longer carries it, so a reload cannot
9689
* retry. Hence both conjuncts below: the create action must actually be on the
9790
* bar (`hasCreateAction`), AND entitlements must have resolved — while loading
98-
* we must not trigger an action whose meaning (prod vs dev create) isn't known
99-
* yet. In the `upgrade_for_development` state arming consumes the param and
100-
* runs NOTHING (objectui#12190: no dialog): the server has already answered
101-
* "create" there (no development environment on this plan), so the intent is
102-
* spent on that answer rather than lost. The answer is drawn as the locked
103-
* mark for a viewer who can upgrade, and as no create affordance at all for
104-
* one who cannot (objectui#12983). When either conjunct
91+
* we must not trigger an action whose meaning (whether there is anything left
92+
* to create) isn't known yet. In the `production_exists` state arming consumes
93+
* the param and runs NOTHING (no dialog): the org already owns its one
94+
* environment, so the answer to "create" is that nothing is left to create, and
95+
* the intent is spent on that answer rather than lost. When either conjunct
10596
* fails, the honest degradation is to leave the URL alone: the next mount that
10697
* CAN act on the deep link still does.
10798
*
@@ -131,50 +122,6 @@ function OtherActionsBar({ actions }: { actions: any[] }) {
131122
return <SchemaRenderer schema={bar} />;
132123
}
133124

134-
const DEVELOPMENT_LOCKED_MARK = 'environment-development-locked';
135-
136-
/**
137-
* The plan-locked stand-in for the create action (objectui#12190): an outline
138-
* mark with a lock icon that names the tier development environments start at
139-
* ("Development environments · Team plan and up"; the zh value of the tier
140-
* label follows cloud's own zh bundle). The maintainer's rule is that no primary
141-
* button opens a paywall dialog (objectstack-ai/cloud#2775), so this is never a
142-
* primary button and opens nothing: it is a plain link to the upgrade page.
143-
*
144-
* The destination is the entitlement summary's `upgradeUrl`, verbatim, resolved
145-
* the way the entitlement dialog resolves its CTA (`resolveCtaHref`: a
146-
* control-plane-relative path is prefixed with the control-plane origin). The
147-
* URL is the control plane's to name and none is composed here (objectui#10437).
148-
*
149-
* The mark exists only as that link, so it is drawn only for a viewer the
150-
* summary sends an `upgradeUrl` to (objectui#12983, ruling B on
151-
* objectstack-ai/cloud#2917: the upsell reaches only whoever can upgrade). The
152-
* control plane trims `upgradeUrl` by role (cloud's `resolveSummaryCanManage`:
153-
* it is sent only to a viewer who manages the organization, such as its owner
154-
* or an admin), so its presence is the server's own answer to "can this viewer
155-
* upgrade?", and no viewer flag is read or added for it. A viewer it sends none
156-
* (a plain member) cannot upgrade, so no mark is drawn for them.
157-
*/
158-
function DevelopmentLockedMark({ upgradeUrl }: { upgradeUrl: string }) {
159-
const { t } = useObjectTranslation();
160-
const { href, external } = resolveCtaHref(
161-
upgradeUrl,
162-
(import.meta.env.VITE_SERVER_URL as string | undefined) || '',
163-
);
164-
return (
165-
<Button asChild size="sm" variant="outline" className="shadow-none gap-1.5 sm:gap-2 h-8 sm:h-9 text-muted-foreground">
166-
<a
167-
href={href}
168-
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
169-
data-testid={DEVELOPMENT_LOCKED_MARK}
170-
>
171-
<Lock className="h-4 w-4" aria-hidden="true" />
172-
<span>{t('environment.developmentLocked', { tier: t('planTier.teamAndUp') })}</span>
173-
</a>
174-
</Button>
175-
);
176-
}
177-
178125
interface Props {
179126
/** Toolbar actions already localized by the caller (ObjectView). */
180127
actions: any[];
@@ -235,30 +182,15 @@ export function EnvironmentListToolbar({ actions, entitlements }: Props) {
235182
);
236183
}
237184

238-
// Plan-locked state: a free-plan org must NOT POST-then-403, and no primary
239-
// button may open a paywall dialog (objectui#12190). The create action gives
240-
// way to the locked mark, drawn only for a viewer who can upgrade
241-
// (objectui#12983); any other (non-create) toolbar actions still render
242-
// through the normal bar, for every viewer.
243-
if (ctaKind === 'upgrade_for_development') {
244-
const upgradeUrl = entitlements.upgradeUrl;
245-
return (
246-
<>
247-
<OtherActionsBar actions={otherActions(toolbarActions)} />
248-
{/*
249-
The mark stands in for the create action. With no create action on
250-
the bar there is nothing to stand in for, so it must not appear either
251-
(#4123): same one-list rule as the arming predicate above. With no
252-
`upgradeUrl` the server has said this viewer cannot upgrade, and the
253-
upsell is not drawn (objectui#12983; see `DevelopmentLockedMark`).
254-
*/}
255-
{hasCreateAction && upgradeUrl ? <DevelopmentLockedMark upgradeUrl={upgradeUrl} /> : null}
256-
</>
257-
);
185+
// The org already owns its one environment: the create action is withheld,
186+
// so it can neither POST-then-403 nor open a dialog. Any other (non-create)
187+
// toolbar actions still render through the normal bar.
188+
if (ctaKind === 'production_exists') {
189+
return <OtherActionsBar actions={otherActions(toolbarActions)} />;
258190
}
259191

260-
// setup_production / add_development: render the bar, overriding only the
261-
// create action's label (and promoting production setup to a primary CTA).
192+
// setup_production: render the bar, overriding only the create action's
193+
// label and promoting it to a primary CTA.
262194
// Labels are locale-aware — the metadata label is already localized by the
263195
// caller, but these state-aware overrides used to be hard-coded English,
264196
// which flashed an English button in a zh console (#844). They now resolve
@@ -270,18 +202,10 @@ export function EnvironmentListToolbar({ actions, entitlements }: Props) {
270202
// the "who owns this button's text" note at the top of the file.
271203
const renderedActions = toolbarActions.map((a: any) => {
272204
if (a?.name !== CREATE_ACTION || ctaKind == null) return a;
273-
if (ctaKind === 'setup_production') {
274-
return {
275-
...a,
276-
label: t('environment.setUpProduction'),
277-
variant: 'primary',
278-
...(autoRunCreate ? { autoTrigger: true } : {}),
279-
};
280-
}
281-
// add_development
282205
return {
283206
...a,
284-
label: t('environment.addDevelopment'),
207+
label: t('environment.setUpProduction'),
208+
variant: 'primary',
285209
...(autoRunCreate ? { autoTrigger: true } : {}),
286210
};
287211
});

‎ui/packages/app-shell/src/environment/__tests__/EnvironmentEntitlementDialog.test.tsx‎

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* EnvironmentEntitlementDialog renders the friendly upgrade/limit dialog and a
3-
* CTA that lands on the (control-plane-resolved) upgrade URL.
2+
* EnvironmentEntitlementDialog renders the friendly limit dialog and a CTA that
3+
* lands on the (control-plane-resolved) URL the server named.
44
*/
55

66
import { describe, it, expect, vi } from 'vitest';
@@ -33,34 +33,32 @@ describe('EnvironmentEntitlementDialog', () => {
3333
state={{
3434
open: true,
3535
spec: {
36-
code: 'DEV_ENV_PLAN_LOCKED',
37-
title: 'Development environments are a paid feature',
38-
message: 'Upgrade to add them.',
39-
cta: { label: 'Upgrade plan', url: '/settings/billing' },
36+
code: 'PRODUCTION_ENV_LIMIT',
37+
title: 'You already have your production environment',
38+
message: 'Each organization includes exactly one production environment.',
39+
cta: { label: 'Contact sales', url: '/contact-sales' },
4040
},
4141
}}
4242
/>,
4343
);
44-
expect(await screen.findByText('Development environments are a paid feature')).toBeTruthy();
44+
expect(await screen.findByText('You already have your production environment')).toBeTruthy();
4545
const cta = screen.getByTestId('entitlement-cta-primary');
46-
expect(cta.getAttribute('href')).toBe('/settings/billing');
46+
expect(cta.getAttribute('href')).toBe('/contact-sales');
4747
fireEvent.click(cta);
4848
expect(onOpenChange).toHaveBeenCalledWith(false);
4949
});
5050

5151
it('renders nothing when closed', () => {
5252
render(<EnvironmentEntitlementDialog apiBase="" onOpenChange={vi.fn()} state={{ open: false }} />);
53-
expect(screen.queryByText('Development environments are a paid feature')).toBeNull();
53+
expect(screen.queryByText('You already have your production environment')).toBeNull();
5454
});
5555
});
5656

5757
// objectui#10437 — the dialog renders the spec the builder produces, so these
5858
// pins go through the REAL builder: a spec hand-written without a `cta` would
59-
// pass whether or not the builder still synthesizes a default URL. (The
60-
// proactive builder is gone since objectui#12190; the plan-locked toolbar draws
61-
// a mark, not this dialog.)
62-
describe('EnvironmentEntitlementDialog — no server URL, no upgrade CTA (objectui#10437)', () => {
63-
const SERVER_UPGRADE_URL = 'https://cloud.example.com/_console/apps/cloud_control/page/pricing';
59+
// pass whether or not the builder still synthesizes a default URL.
60+
describe('EnvironmentEntitlementDialog — no server URL, no CTA (objectui#10437)', () => {
61+
const SERVER_CONTACT_URL = 'https://cloud.example.com/contact-sales';
6462

6563
const renderSpec = (spec: EntitlementDialogSpec | null) =>
6664
render(
@@ -71,18 +69,18 @@ describe('EnvironmentEntitlementDialog — no server URL, no upgrade CTA (object
7169
/>,
7270
);
7371

74-
it('a 403 without upgrade_url opens the dialog with no upgrade anchor', async () => {
75-
renderSpec(entitlementDialogFromError({ error: { code: 'DEV_ENV_PLAN_LOCKED', details: { plan: 'free' } } }));
76-
expect(await screen.findByText('Development environments are a paid feature')).toBeTruthy();
72+
it('a 403 without contact_url opens the dialog with no anchor', async () => {
73+
renderSpec(entitlementDialogFromError({ error: { code: 'PRODUCTION_ENV_LIMIT', details: { current: 1, limit: 1 } } }));
74+
expect(await screen.findByText('You already have your production environment')).toBeTruthy();
7775
expect(screen.queryByTestId('entitlement-cta-primary')).toBeNull();
7876
expect(screen.queryByRole('link')).toBeNull();
7977
});
8078

81-
it('control: a server-supplied upgrade_url renders the anchor to exactly that URL', async () => {
79+
it('control: a server-supplied contact_url renders the anchor to exactly that URL', async () => {
8280
renderSpec(
83-
entitlementDialogFromError({ error: { code: 'DEV_ENV_PLAN_LOCKED', details: { upgrade_url: SERVER_UPGRADE_URL } } }),
81+
entitlementDialogFromError({ error: { code: 'PRODUCTION_ENV_LIMIT', details: { contact_url: SERVER_CONTACT_URL } } }),
8482
);
85-
expect(await screen.findByText('Development environments are a paid feature')).toBeTruthy();
86-
expect(screen.getByTestId('entitlement-cta-primary').getAttribute('href')).toBe(SERVER_UPGRADE_URL);
83+
expect(await screen.findByText('You already have your production environment')).toBeTruthy();
84+
expect(screen.getByTestId('entitlement-cta-primary').getAttribute('href')).toBe(SERVER_CONTACT_URL);
8785
});
8886
});

0 commit comments

Comments
 (0)