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
24 changes: 24 additions & 0 deletions .changeset/10919-cloud-plan-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@object-ui/app-shell': minor
'@object-ui/types': minor
'@object-ui/i18n': patch
'@object-ui/cli': patch
---

New SDUI widget `cloud:plan-status`: a "Current plan" badge for one plan card on the Cloud pricing page, shown when that card's plan is the organization's plan (objectui#10919).

**Clause-②: yes** — the accept set of `AnyComponentSchema`, and so of `safeValidateSchema` and `objectui validate`, widens by one `type` literal, `cloud:plan-status`, and `@object-ui/types/zod` exports one new schema, `CloudPlanStatusSchema`. Nothing that parsed before is refused now.

**Why.** The pricing page is static metadata, and nothing in a page's expression scope carries the organization's plan, so the page could not tell which of its cards the organization is already on. The plan is available only from the org-scoped `GET /cloud/environment-entitlements` summary.

**What changed, in observable terms.**

- `@object-ui/app-shell` registers `cloud:plan-status`. A page places one node on each plan card and names that card's plan code in `properties.plan`: `{ "type": "cloud:plan-status", "properties": { "plan": "free" } }`. The widget reads the summary through the hook the environment list already uses, and renders the badge when the summary's `plan` equals `properties.plan`. The comparison is exact, so `Free` does not match `free`.
- The widget renders nothing on every other card, while the summary loads, when the request fails, and when the body is not the `{ success, data }` envelope. It never guesses a plan.
- The node's `className` and `responsiveStyles` reach the badge. Each node reads the summary itself, so a page with three cards makes three requests.
- The widget is registered under one key, `cloud:plan-status`. There is no bare `plan-status` fallback and no `app-shell:`-prefixed twin.
- `@object-ui/types/zod` exports `CloudPlanStatusSchema`, a member of `AnyComponentSchema`. `properties` is required and must be exactly `{ plan }`, with `plan` a non-empty string. A missing bag, a missing or empty `plan`, and any other key in the bag are each refused at that path.
- `body` and `children` are refused by name on this node, because the widget reads neither.
- `plan` is not an enum: the plan catalog belongs to the control plane, and ObjectUI does not list its codes.
- `@object-ui/i18n` adds `cloudPlanStatus.current` ("Current plan") to all ten locale packs.
- `@object-ui/cli`: `objectui check` knows `cloud:plan-status` as a registered type.
2 changes: 2 additions & 0 deletions packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"./dist/console/connect/ConnectAgentWidget.js",
"./dist/console/diagnostics/CloudAiModelStatus.js",
"./dist/console/home/CloudOnboardingNext.js",
"./dist/console/home/CloudPlanStatus.js",
"./dist/console/marketplace/InstalledListWidget.js",
"./dist/services/builtinComponents.js",
"./dist/views/global-notifications-renderer.js",
Expand All @@ -24,6 +25,7 @@
"./src/console/connect/ConnectAgentWidget.tsx",
"./src/console/diagnostics/CloudAiModelStatus.tsx",
"./src/console/home/CloudOnboardingNext.tsx",
"./src/console/home/CloudPlanStatus.tsx",
"./src/console/marketplace/InstalledListWidget.tsx",
"./src/services/builtinComponents.tsx",
"./src/views/global-notifications-renderer.tsx",
Expand Down
96 changes: 96 additions & 0 deletions packages/app-shell/src/console/home/CloudPlanStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* CloudPlanStatus — the "current plan" marker for one plan card on the Cloud
* control plane's pricing page, registered as the SDUI widget
* `cloud:plan-status` (objectui#10919).
*
* The pricing page is static metadata: nothing in its expression scope carries
* the organization's plan, so a page cannot tell which of its cards is the one
* the organization is already on. The plan is only available from the
* org-scoped `GET /cloud/environment-entitlements` summary (`data.plan`). This
* widget reads that summary and answers one question for the card it is placed
* on: is this card's plan the organization's plan?
*
* • the summary names this card's plan → a localized "Current plan" badge;
* • the summary names another plan → nothing;
* • loading, failed, or no summary → nothing. The plan is never guessed:
* only the authoritative summary (`source: 'summary'`) can mark a card, and
* the row-derived fallback carries no plan at all.
*
* ## One node per card, and the page names the card
*
* The plan code a card stands for is the page's, not this widget's: the plan
* catalog belongs to the control plane, so the page passes its own code in
* `properties.plan` (the `properties` bag is the spelling every page component
* is authored in) and the widget compares it VERBATIM with `data.plan`. No
* code is listed, normalized or defaulted here.
*
* ## The summary is read through `useEnvironmentEntitlements`
*
* The same hook the environment list uses — one reading of the endpoint and its
* strict `{ success, data }` envelope, not a third copy of the fetch. It is
* enabled unconditionally here because this widget exists only to read it.
*/

import { useState } from 'react';
import { Badge, cn } from '@object-ui/components';
import { createAuthenticatedFetch } from '@object-ui/auth';
import { ComponentRegistry } from '@object-ui/core';
import { useObjectTranslation } from '@object-ui/i18n';
import { useAdapter } from '../../providers/AdapterProvider.js';
import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js';

export interface CloudPlanStatusProps {
properties?: {
/**
* The plan code of the card this node sits on, exactly as the control
* plane's entitlements summary spells it (for example `free`).
*/
plan?: string;
};
/** Carries the scope class of the node's `responsiveStyles`. */
className?: string;
}

export function CloudPlanStatus({ properties, className }: CloudPlanStatusProps) {
const { t } = useObjectTranslation();
const dataSource = useAdapter();
// `useState`, not `useMemo`: the hook keys its fetch effect on this function,
// so its identity has to be one React guarantees to keep (AGENTS.md #10).
const [authFetch] = useState(() => createAuthenticatedFetch());
const entitlements = useEnvironmentEntitlements({
enabled: true,
dataSource,
authFetch,
apiBase: (import.meta.env.VITE_SERVER_URL as string | undefined) || '',
});

const plan = properties?.plan;
const isCurrent =
typeof plan === 'string'
&& plan !== ''
&& entitlements?.source === 'summary'
&& entitlements.plan === plan;
if (!isCurrent) return null;

return (
<Badge variant="secondary" className={cn(className)} data-plan-status="current">
{t('cloudPlanStatus.current')}
</Badge>
);
}

// SDUI registration — the Cloud pricing page places one node per plan card.
// Registered under the `cloud` namespace with no bare fallback, so the one key
// is `cloud:plan-status`: the spelling the page authors, and the one literal
// its `@object-ui/types` arm claims (`CloudPlanStatusSchema`).
ComponentRegistry.register('plan-status', (props: CloudPlanStatusProps) => (
<CloudPlanStatus {...props} />
), {
namespace: 'cloud',
skipFallback: true,
label: 'Cloud Plan Status',
category: 'plugin',
inputs: [],
});
169 changes: 169 additions & 0 deletions packages/app-shell/src/console/home/__tests__/CloudPlanStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* CloudPlanStatus (`cloud:plan-status`, objectui#10919) marks ONE plan card on
* the Cloud pricing page: a "Current plan" badge when the organization's plan,
* read from the authoritative entitlements summary, equals the card's
* `properties.plan` — and nothing in every other state, so a plan is never
* guessed.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import React from 'react';

let fetchImpl: (url: string, init?: RequestInit) => Promise<unknown>;
const fetchedUrls: string[] = [];

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ activeOrganization: { id: 'org_1' } }),
createAuthenticatedFetch: () => (url: string, init?: RequestInit) => {
fetchedUrls.push(url);
return fetchImpl(url, init);
},
}));

import { I18nProvider } from '@object-ui/i18n';
import { ComponentRegistry } from '@object-ui/core';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
import { CloudPlanStatus } from '../CloudPlanStatus';

/**
* The badge text resolves from the locale packs, so a real i18n context is
* needed — without one `t()` returns the raw key and a text assertion would
* pass against nothing. The adapter is the row-derived fallback's data source;
* its rows name a production env, so the fallback resolves `derived` for real
* rather than failing.
*/
const adapter = { find: vi.fn().mockResolvedValue([{ id: 'env_1', environment_type: 'production' }]) };

const renderInShell = (ui: React.ReactElement) =>
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<AdapterCtx.Provider value={adapter as never}>{ui}</AdapterCtx.Provider>
</I18nProvider>,
);

/** The control plane's `{ success, data }` envelope around a summary naming `plan`. */
function summary(plan: string) {
return {
ok: true,
status: 200,
json: async () => ({ success: true, data: { plan, hasProductionEnv: true } }),
};
}

afterEach(() => {
cleanup();
fetchedUrls.length = 0;
adapter.find.mockClear();
});

describe('CloudPlanStatus (objectui#10919)', () => {
it('marks the card whose plan is the organization\'s plan', async () => {
fetchImpl = async () => summary('free');
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);

expect(await screen.findByText('Current plan')).toBeTruthy();
expect(container.querySelector('[data-plan-status="current"]')).toBeTruthy();
});

it('reads the org-scoped summary endpoint', async () => {
fetchImpl = async () => summary('free');
renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);

await screen.findByText('Current plan');
expect(fetchedUrls).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']);
});

it('renders nothing on a card whose plan is not the organization\'s', async () => {
fetchImpl = async () => summary('team');
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);

// Wait until the summary was actually read, so the empty render is a
// verdict on it and not the loading state.
await waitFor(() => expect(fetchedUrls).toHaveLength(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});

it('compares the plan code verbatim — no case folding', async () => {
fetchImpl = async () => summary('free');
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'Free' }} />);

await waitFor(() => expect(fetchedUrls).toHaveLength(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});

it('renders nothing while the summary is loading', () => {
fetchImpl = () => new Promise(() => {});
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);

expect(container.innerHTML).toBe('');
});

it('renders nothing when the summary fails — the row-derived fallback carries no plan', async () => {
fetchImpl = async () => ({ ok: false, status: 500, json: async () => null });
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);

// The hook fell through to the rows, which resolve (source `derived`) but
// name no plan — so the card is left unmarked instead of guessed.
await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});

it('renders nothing for a bare (un-enveloped) body that names the plan', async () => {
// A bare body is a producer contract violation (cloud#1046): it yields no
// summary, so even a `plan` that matches must not mark the card.
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ plan: 'free' }) });
const { container } = renderInShell(<CloudPlanStatus properties={{ plan: 'free' }} />);

await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});

it('renders nothing when the node names no plan', async () => {
fetchImpl = async () => summary('free');
const { container } = renderInShell(<CloudPlanStatus />);

await waitFor(() => expect(fetchedUrls).toHaveLength(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});
});

describe('`cloud:plan-status` registration (objectui#10919)', () => {
it('is registered under the one key the page authors, with no second spelling', () => {
expect(ComponentRegistry.has('cloud:plan-status')).toBe(true);
// `skipFallback: true` — no bare `plan-status` fallback …
expect(ComponentRegistry.has('plan-status')).toBe(false);
// … and no `app-shell:`-prefixed twin.
expect(ComponentRegistry.has('app-shell:cloud:plan-status')).toBe(false);
});

it('renders from a page node through SchemaRenderer, reading `properties.plan`', async () => {
fetchImpl = async () => summary('team');
const { container } = renderInShell(
<SchemaRenderer
schema={{
type: 'cloud:plan-status',
id: 'plan_team_current',
responsiveStyles: { large: { alignSelf: 'flex-start' } },
properties: { plan: 'team' },
} as never}
/>,
);

expect(await screen.findByText('Current plan')).toBeTruthy();
// The node's `responsiveStyles` scope class reaches the badge through
// `className`, so the page can place it inside its card.
const scope = container.querySelector('style[data-os-scope]')?.getAttribute('data-os-scope');
expect(scope).toBeTruthy();
const badge = container.querySelector('[data-plan-status="current"]');
expect(badge?.classList.contains(scope as string)).toBe(true);
});
});
2 changes: 2 additions & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ import './console/marketplace/InstalledListWidget.js';
import './console/connect/ConnectAgentWidget.js';
// SDUI widget for the Cloud Welcome page's state-aware onboarding next-step.
import './console/home/CloudOnboardingNext.js';
// SDUI widget for the Cloud pricing page's "current plan" marker (objectui#10919).
import './console/home/CloudPlanStatus.js';
// SDUI widget: read-only admin diagnostic for the env's effective AI model
// (cloud#797) — fetches GET /api/v1/ai/effective-model.
import './console/diagnostics/CloudAiModelStatus.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet
expect(refusedAtType(key), key).toBe(false);
}
});

it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => {
// One registry key (`skipFallback: true`, so no bare `plan-status`), armed in
// `@object-ui/types/zod` in the same change, so the pin above did not move.
expect(NAMESPACED_KEYS).toContain('cloud:plan-status');
expect(BARE_KEYS).not.toContain('plan-status');
expect(refusedAtType('cloud:plan-status')).toBe(false);
});
});

/* ── End to end: the README document through `objectui validate` ─────────── */
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/known-schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
'cloud-connection:panel',
'cloud:ai-model-status',
'cloud:onboarding-next',
'cloud:plan-status',
'code',
'code-editor',
'collapsible',
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2952,6 +2952,9 @@ const ar = {
openProduction: "فتح بيئة الإنتاج",
manageEnvironments: "إدارة البيئات",
},
cloudPlanStatus: {
current: "الخطة الحالية",
},
ai: {
nlQuery: {
placeholder: "اطرح سؤالًا حول بياناتك…",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2922,6 +2922,9 @@ const de = {
openProduction: "Produktion öffnen",
manageEnvironments: "Umgebungen verwalten",
},
cloudPlanStatus: {
current: "Aktueller Tarif",
},
ai: {
nlQuery: {
placeholder: "Stellen Sie eine Frage zu Ihren Daten…",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3397,6 +3397,10 @@ const en = {
openProduction: 'Open Production',
manageEnvironments: 'Manage environments',
},
// `cloud:plan-status` — the Cloud pricing page's current-plan marker (objectui#10919).
cloudPlanStatus: {
current: 'Current plan',
},
// `@object-ui/plugin-ai` — the `nl-query`, `ai-form-assist` and
// `ai-recommendations` components (objectui#10232). The `*One` rows are this
// repo's two-key plural convention (see `search.itemsAvailableOne`): the
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2926,6 +2926,9 @@ const es = {
openProduction: "Abrir producción",
manageEnvironments: "Gestionar entornos",
},
cloudPlanStatus: {
current: "Plan actual",
},
ai: {
nlQuery: {
placeholder: "Haga una pregunta sobre sus datos…",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2924,6 +2924,9 @@ const fr = {
openProduction: "Ouvrir la production",
manageEnvironments: "Gérer les environnements",
},
cloudPlanStatus: {
current: "Offre actuelle",
},
ai: {
nlQuery: {
placeholder: "Posez une question sur vos données…",
Expand Down
Loading
Loading