From 25be81870b21176e10cf577c690c28ea0daa3b13 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:51:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(spec):=20element:button=20declares=20the?= =?UTF-8?q?=20action=20it=20executes=20=E2=80=94=20InlineActionSchema=20(o?= =?UTF-8?q?bjectui#2997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit element:button's renderer reads `properties.action` and dispatches it through the ActionRunner — it is the only thing making a standalone-page button interactive — and ElementButtonPropsSchema did not declare it. It is authored anyway: cloud's service-tenant pages carry five sites across the billing and pricing funnel, each `{type:'navigation', to: …}`, each cast `as any`. An undeclared prop is STRIPPED, not ignored — safeParse returned success with no `action` in `data`. Harmless only because page block `properties` are still z.record(z.string(), z.unknown()); the moment that is tightened to validate against ComponentPropsMap, every one of those buttons loses its action at save with a green parse. InlineActionSchema is derived, not a new dialect. ActionSchema is z.object(…).refine(…).refine(…), so `.pick()` is unavailable on the export; the object half is now a factory both build from, preserving lazySchema's deferral. The inline schema picks the twelve fields element:button actually forwards, sharing their describe() text, the ActionType vocabulary and the target-required refinement. `name`/`label` are optional — a registry entry needs an identity and a menu label, an inline action has neither (the button supplies its own label). Registry-only concerns are excluded, as are icon/variant (the button has its own) and body (a page button running a sandboxed script is a separate decision). `navigation` and `to` become normalizing aliases folding to `url`/`target`, the VIEW_FILTER_OPERATOR_ALIASES pattern: cloud's pages keep validating while output is always canonical, and the aliases can be tombstoned once producers migrate. `url` is also the better target — its runner path has ${param} interpolation, apiBase promotion and popup-safe openIn; the navigation path has none of those. NOT done: promoting `navigation` into ActionType bare (declined in #4070) — it names the type while leaving the prop undeclared and `to` homeless, and adds a permanent synonym to a vocabulary whose members cannot be removed. Nothing narrowed; all generated surfaces gain entries only. spec suite 7038/271 green, tsc clean, all twelve check:* gates pass. Refs objectstack-ai/objectui#2997, objectstack-ai/objectui#2945, #4070 Co-Authored-By: Claude Opus 5 --- .changeset/inline-action-schema.md | 69 ++++++++ content/docs/references/ui/action.mdx | 26 ++- content/docs/references/ui/component.mdx | 9 +- packages/spec/api-surface.json | 4 + packages/spec/authorable-surface.json | 13 ++ packages/spec/json-schema.manifest.json | 1 + packages/spec/src/ui/action.zod.ts | 109 +++++++++++- packages/spec/src/ui/component.zod.ts | 14 ++ packages/spec/src/ui/inline-action.test.ts | 186 +++++++++++++++++++++ 9 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 .changeset/inline-action-schema.md create mode 100644 packages/spec/src/ui/inline-action.test.ts diff --git a/.changeset/inline-action-schema.md b/.changeset/inline-action-schema.md new file mode 100644 index 0000000000..339f6bea68 --- /dev/null +++ b/.changeset/inline-action-schema.md @@ -0,0 +1,69 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `element:button` declares the action it executes — `InlineActionSchema` (objectui#2997) + +`element:button`'s renderer reads `properties.action` and dispatches it through +the `ActionRunner`; that prop is the only thing making a standalone-page button +interactive. `ElementButtonPropsSchema` declared `label`, `variant`, `size`, +`icon`, `iconPosition`, `disabled`, `aria` — and no `action`. + +It is being authored anyway. cloud's `service-tenant` pages carry five +declaration sites across the billing and pricing funnel (`pricing`, `welcome`, +`billing-cancel` ×2, `billing-success`), each `{ type: 'navigation', to: … }`, +each cast `as any` to get past the type system. + +**An undeclared prop is stripped, not ignored.** +`ElementButtonPropsSchema.safeParse({ label, action })` returned `success: true` +with no `action` in `data` — a non-strict `z.object` drops unknown keys. That is +harmless only because page block `properties` are still +`z.record(z.string(), z.unknown())`, so this schema never runs on a real page +save. The moment anyone tightens `properties` to validate against +`ComponentPropsMap` — an obvious hardening — every one of those buttons loses its +action at save, with a green parse. Declaring the prop is what defuses it. + +**`InlineActionSchema` is derived, not a new dialect.** `ActionSchema` is +`z.object(…).refine(…).refine(…)`, so `.pick()` is unavailable on the exported +schema — the object half is now a factory both schemas build from, which keeps +`lazySchema`'s deferral (the fields are constructed on first use of whichever +schema is touched, not at module load). The inline schema `.pick()`s the twelve +fields `element:button` actually forwards to the runner, so their `describe()` +text, the `ActionType` vocabulary and the `target`-required refinement are shared +rather than restated. + +`name` and `label` are optional, which is the substantive difference: +`ActionSchema` requires both because a registry entry needs an identity and a +menu label, and an inline action has neither — the button already has its own +`label`, and requiring `action.label` too would mean writing it twice. Everything +that only means something for a registered action — `objectName`, `locations`, +`order`, `ai`, `requiredPermissions`, `visible`/`disabled`, `resultDialog` — is +excluded, as are `icon`/`variant` (the button has its own) and `body` (a page +button running an inline sandboxed script is a separate decision). A declared +field no renderer reads is the failure this schema exists to stop, so widen it +when a renderer widens, not before. + +**`navigation` and `to` become normalizing aliases, not new members.** +`normalizeInlineAction` folds `type: 'navigation'` → `'url'` and `to` → `target` +on parse, the `VIEW_FILTER_OPERATOR_ALIASES` pattern. So cloud's existing pages +keep validating unchanged while parse output is always canonical, and the aliases +get a `retiredKey` tombstone once the producers are migrated. `url` is also the +*better* target: the runner's `url` path has `${param.X}` / `${ctx.X}` +interpolation, `apiBase` promotion for `/api/…` paths and popup-blocker-safe +`openIn`, none of which its `navigation` path has. + +Deliberately **not** done: promoting `navigation` into `ActionType` as a bare +member, which was considered and declined in #4070. It would name the type while +leaving the prop undeclared and `to` homeless — half a fix, and a permanent +synonym in a vocabulary whose members cannot be removed later. + +Nothing is narrowed. `ui/InlineAction` and `ui/ElementButtonProps:action` are +additions to every generated surface; no accepted value stops being accepted, so +no stored page metadata changes meaning. + +Verified: 16 new tests — the derivation is asserted from both directions +(the inline field set is exactly the documented twelve; every one of them +round-trips through `ActionSchema`; every registry-only concern is absent; every +`ActionType` member is inline-authorable), and the fold is pinned against the +literal shape cloud writes. Full `@objectstack/spec` suite **7038 tests across +271 files**, `tsc --noEmit`, and all twelve `check:*` gates, clean. diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 1e28fb4979..8aee98c97b 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -62,8 +62,8 @@ to the field name and is used as the request-body key). ## TypeScript Usage ```typescript -import { Action, ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui'; -import type { Action, ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui'; +import { Action, ActionAi, ActionLocation, ActionParam, ActionType, InlineAction } from '@objectstack/spec/ui'; +import type { Action, ActionAi, ActionLocation, ActionParam, ActionType, InlineAction } from '@objectstack/spec/ui'; // Validate data const result = Action.parse(data); @@ -189,3 +189,25 @@ const result = Action.parse(data); --- +## InlineAction + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Action functionality type | +| **name** | `string` | optional | Machine name (lowercase snake_case) | +| **label** | `string` | optional | Display label | +| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. | +| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | +| **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | +| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string; … }[]` | optional | Input parameters required from user | +| **confirmText** | `string` | optional | Confirmation message before execution | +| **successMessage** | `string` | optional | Success message to show after execution | +| **errorMessage** | `string` | optional | Error message to show when the action fails (overrides the raw error). | +| **refreshAfter** | `boolean` | optional | Refresh view after execution | +| **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | + + +--- + diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 3ee4f25a0a..6cb57065c6 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -44,11 +44,12 @@ const result = AIChatWindowProps.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string` | ✅ | Button display label | -| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | ✅ | Button visual variant | -| **size** | `Enum<'small' \| 'medium' \| 'large'>` | ✅ | Button size | +| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant | +| **size** | `Enum<'small' \| 'medium' \| 'large'>` | optional | Button size | | **icon** | `string` | optional | Icon name (Lucide icon) | -| **iconPosition** | `Enum<'left' \| 'right'>` | ✅ | Icon position relative to label | -| **disabled** | `boolean` | ✅ | Disable the button | +| **iconPosition** | `Enum<'left' \| 'right'>` | optional | Icon position relative to label | +| **disabled** | `boolean` | optional | Disable the button | +| **action** | `{ type?: Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>; name?: string; label?: string; target?: string; … }` | optional | Inline action executed on click | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 9707b1c325..ca15ab68d5 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3282,6 +3282,9 @@ "I18nLabelSchema (const)", "I18nObject (type)", "I18nObjectSchema (const)", + "InlineAction (type)", + "InlineActionInput (type)", + "InlineActionSchema (const)", "InterfacePageConfig (type)", "InterfacePageConfigSchema (const)", "JoinedReportBlock (type)", @@ -3499,6 +3502,7 @@ "expandViewContainerWithDiagnostics (function)", "isAggregatedViewContainer (function)", "normalizeFilterOperator (function)", + "normalizeInlineAction (function)", "pageForm (const)", "reportForm (const)", "reportSelectionOrder (function)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index a6f707d3c2..dab0c97b05 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7788,6 +7788,7 @@ "ui/DropZone:label", "ui/DropZone:maxItems", "ui/DropZone:role", + "ui/ElementButtonProps:action", "ui/ElementButtonProps:aria", "ui/ElementButtonProps:disabled", "ui/ElementButtonProps:icon", @@ -8001,6 +8002,18 @@ "ui/I18nObject:defaultValue", "ui/I18nObject:key", "ui/I18nObject:params", + "ui/InlineAction:confirmText", + "ui/InlineAction:errorMessage", + "ui/InlineAction:label", + "ui/InlineAction:method", + "ui/InlineAction:name", + "ui/InlineAction:openIn", + "ui/InlineAction:opensInNewTab", + "ui/InlineAction:params", + "ui/InlineAction:refreshAfter", + "ui/InlineAction:successMessage", + "ui/InlineAction:target", + "ui/InlineAction:type", "ui/InterfacePageConfig:addRecord", "ui/InterfacePageConfig:allowPrinting", "ui/InterfacePageConfig:appearance", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 5ac0cd0e7a..5893376d74 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1661,6 +1661,7 @@ "ui/HttpRequest", "ui/I18nLabel", "ui/I18nObject", + "ui/InlineAction", "ui/InterfacePageConfig", "ui/JoinedReportBlock", "ui/KanbanConfig", diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index ba62a4f649..e2768332e4 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -373,7 +373,20 @@ export const ActionAiSchema = z.object({ export type ActionAi = z.infer; -export const ActionSchema = lazySchema(() => z.object({ +/** + * The object half of {@link ActionSchema}, before its refinements. + * + * A factory rather than a schema so `lazySchema`'s deferral still holds — the + * fields are built on first use of whichever schema derives from them, not at + * module load. + * + * It exists because `.pick()` is a `ZodObject` method and `ActionSchema` is + * `z.object(…).refine(…).refine(…)`, so nothing can derive a subset from the + * exported schema. {@link InlineActionSchema} derives from this instead of + * restating a dozen field definitions and their `describe()` text, which is how + * a second action vocabulary would start. + */ +const actionObject = () => z.object({ /** Machine name of the action */ name: SnakeCaseIdentifierSchema.describe('Machine name (lowercase snake_case)'), @@ -677,7 +690,9 @@ export const ActionSchema = lazySchema(() => z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), -}).refine((data) => { +}); + +export const ActionSchema = lazySchema(() => actionObject().refine((data) => { // Require `target` for types that reference an external resource if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) { return false; @@ -751,6 +766,96 @@ export type Action = z.infer; export type ActionParam = z.infer; export type ActionInput = z.input; +/** + * Legacy spellings an inline action may carry, folded to canonical on parse. + * + * `navigation` is a `type` no spec enum ever declared, and `to` a `target` no + * spec schema ever declared — yet both are what cloud's tenant pages actually + * write on an `element:button` (`{ type: 'navigation', to: PRICING_ROUTE }`, + * five sites across the billing and pricing funnel). They reached a real + * dispatcher: objectui's `ActionRunner` has a `navigation` case reading + * `nav.to ?? nav.target`. + * + * `url` + `target` is the canonical pair, and it is also the *better* one — the + * runner's `url` path adds `${param.X}` / `${ctx.X}` interpolation, `apiBase` + * promotion for `/api/…` paths, and popup-blocker-safe `openIn` handling, none + * of which the `navigation` path has. So this is a bridge with an end state, not + * a second vocabulary: authored `navigation`/`to` keep validating, parse output + * is always `url`/`target`, and the aliases get a `retiredKey` tombstone once + * the producers are migrated. + * + * Exported so a producer can normalize before writing, rather than inventing its + * own fold. objectstack-ai/objectui#2997. + */ +export function normalizeInlineAction(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const v = value as Record; + const needsType = v.type === 'navigation'; + const needsTarget = v.target === undefined && typeof v.to === 'string'; + if (!needsType && !needsTarget && !('to' in v)) return value; + const out: Record = { ...v }; + if (needsType) out.type = 'url'; + if (needsTarget) out.target = v.to; + delete out.to; + return out; +} + +/** + * An action declared **inline** on a UI surface, rather than registered by name. + * + * The execution half of {@link ActionSchema} — `.pick()`ed from the same field + * definitions, so the `describe()` text, the operator vocabulary and the + * `target`-required rule are shared rather than restated — minus everything that + * only means something for a *registered* action: `objectName`, `locations`, + * `order`, `ai` exposure, `requiredPermissions`, `visible`/`disabled` + * predicates, `resultDialog`. + * + * `name` and `label` are optional here, which is the substantive difference. + * `ActionSchema` requires both because a registry entry needs an identity and a + * menu label; an inline action has neither — the host supplies the label (an + * `element:button` has its own `label` prop, and requiring `action.label` too + * would mean writing it twice). + * + * Scoped deliberately to what a host actually honours. `element:button`'s + * renderer forwards exactly these fields to the `ActionRunner`; `icon` and + * `variant` are excluded because the button has its own, and `body` because a + * page button running an inline sandboxed script is a separate decision. Widen + * this when a renderer widens, not before — a declared field no renderer reads + * is the failure this schema exists to stop. + */ +export const InlineActionSchema = lazySchema(() => z.preprocess( + normalizeInlineAction, + actionObject().pick({ + type: true, + name: true, + label: true, + target: true, + openIn: true, + method: true, + params: true, + confirmText: true, + successMessage: true, + errorMessage: true, + refreshAfter: true, + opensInNewTab: true, + }).partial({ + name: true, + label: true, + }).refine((data) => { + // The same rule ActionSchema's first refinement applies, for the same + // reason: a `url`/`flow`/`modal`/`api`/`form` action with no `target` has + // nothing to dispatch to and fails silently at click time. + if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) return false; + return true; + }, { + message: "Inline action 'target' is required when type is 'url', 'flow', 'modal', 'api', or 'form' (`to` is accepted as a legacy spelling).", + path: ['target'], + }), +)); + +export type InlineAction = z.infer; +export type InlineActionInput = z.input; + /** * Action Factory Helper */ diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 8233c5e550..edab5ffd24 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { ViewFilterRuleSchema } from './view.zod'; +import { InlineActionSchema } from './action.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; @@ -305,6 +306,19 @@ export const ElementButtonPropsSchema = lazySchema(() => z.object({ iconPosition: z.enum(['left', 'right']) .optional().default('left').describe('Icon position relative to label'), disabled: z.boolean().optional().default(false).describe('Disable the button'), + /** + * What the button does when clicked. Declared inline — a page button is not a + * registered object action, so `name` and `label` are optional (the button + * supplies its own label). + * + * Without this the button renders inert, which is why it was being authored + * regardless: cloud's tenant pages carry five of them across the billing and + * pricing funnel. Undeclared, it was **silently stripped** from + * `ElementButtonPropsSchema`'s parse output — harmless only because page block + * `properties` are still `z.record(z.string(), z.unknown())`, and a loaded gun + * the moment that is tightened. objectstack-ai/objectui#2997. + */ + action: InlineActionSchema.optional().describe('Inline action executed on click'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); diff --git a/packages/spec/src/ui/inline-action.test.ts b/packages/spec/src/ui/inline-action.test.ts new file mode 100644 index 0000000000..5f4908bc05 --- /dev/null +++ b/packages/spec/src/ui/inline-action.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `InlineActionSchema` — the action shape a UI surface declares inline + * (objectstack-ai/objectui#2997). + * + * Two things need pinning: + * + * 1. **It stays derived.** It is `.pick()`ed from the same field factory + * `ActionSchema` uses, so a field's `describe()` text, its vocabulary and the + * `target`-required rule cannot drift into a second dialect. A restated field + * list would fail silently — it keeps validating, just not the same thing. + * 2. **The legacy spellings keep parsing, and stop being emitted.** cloud's + * tenant pages write `{ type: 'navigation', to: … }`, which no spec enum or + * schema ever declared. They must validate, and parse output must always be + * the canonical `url` + `target`. + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { + ActionSchema, + InlineActionSchema, + ActionType, + normalizeInlineAction, +} from './action.zod'; +import { ElementButtonPropsSchema } from './component.zod'; + +/** The fields an inline action admits, in the order they are picked. */ +const INLINE_FIELDS = [ + 'type', 'name', 'label', 'target', 'openIn', 'method', 'params', + 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', 'opensInNewTab', +] as const; + +/** Reach the inner object through the preprocess → object → refine chain. */ +function inlineObject(): z.ZodObject> { + // z.preprocess(fn, inner) is a pipe: `.out` is the refined object, whose + // `.def.schema`… — walk defensively so a zod internals rename fails loudly + // here rather than making the assertions vacuous. + const pipe = InlineActionSchema as unknown as { def?: { out?: unknown } }; + let node = pipe.def?.out as { shape?: unknown; def?: { schema?: unknown } } | undefined; + for (let i = 0; i < 6 && node && !node.shape; i++) { + node = node.def?.schema as typeof node; + } + expect(node?.shape, 'could not reach the inline action object shape').toBeDefined(); + return node as unknown as z.ZodObject>; +} + +describe('InlineActionSchema is derived from ActionSchema, not restated', () => { + it('admits exactly the documented field set', () => { + expect(Object.keys(inlineObject().shape).sort()).toEqual([...INLINE_FIELDS].sort()); + }); + + it('admits only fields ActionSchema itself declares', () => { + // ActionSchema is z.object(…).refine(…), so its shape is not reachable + // through the exported schema — parse a body carrying every inline field + // instead. If ActionSchema dropped one, the round-trip would lose it. + const body = { + name: 'go_somewhere', + label: 'Go', + type: 'url' as const, + target: '/environments', + openIn: 'new-tab' as const, + method: 'POST' as const, + params: [], + confirmText: 'Sure?', + successMessage: 'Done', + errorMessage: 'Failed', + refreshAfter: true, + opensInNewTab: true, + }; + const parsed = ActionSchema.safeParse(body); + expect(parsed.success, JSON.stringify((parsed as { error?: unknown }).error)).toBe(true); + for (const key of INLINE_FIELDS) { + expect(parsed.data, `ActionSchema dropped "${key}"`).toHaveProperty(key); + } + }); + + it('excludes every registry-only concern', () => { + const shape = inlineObject().shape; + for (const key of ['objectName', 'locations', 'order', 'ai', 'requiredPermissions', + 'visible', 'disabled', 'resultDialog', 'body', 'icon', 'variant']) { + expect(shape, `"${key}" should not be inline-authorable`).not.toHaveProperty(key); + } + }); + + it('shares ActionType, so a new action type is inline-authorable for free', () => { + for (const type of ActionType.options) { + const needsTarget = type !== 'script'; + const body = needsTarget ? { type, target: '/x' } : { type, target: 'fn_name' }; + expect(InlineActionSchema.safeParse(body).success, `type "${type}" rejected`).toBe(true); + } + }); +}); + +describe('InlineActionSchema — identity is optional, unlike a registered action', () => { + it('parses without name or label', () => { + const r = InlineActionSchema.safeParse({ type: 'url', target: '/environments' }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + + it('is exactly what ActionSchema refuses — the two differ on purpose', () => { + const body = { type: 'url', target: '/environments' }; + expect(InlineActionSchema.safeParse(body).success).toBe(true); + const strict = ActionSchema.safeParse(body); + expect(strict.success).toBe(false); + expect((strict as { error: z.ZodError }).error.issues.map(i => i.path.join('.')).sort()) + .toEqual(['label', 'name']); + }); + + it('keeps the target-required rule it inherited', () => { + for (const type of ['url', 'flow', 'modal', 'api', 'form']) { + const r = InlineActionSchema.safeParse({ type }); + expect(r.success, `type "${type}" should require a target`).toBe(false); + expect((r as { error: z.ZodError }).error.issues.some(i => i.path.join('.') === 'target')).toBe(true); + } + }); +}); + +describe('normalizeInlineAction — the legacy spellings cloud actually writes', () => { + it('folds the exact shape in cloud service-tenant pages', () => { + // packages/service-tenant/src/pages/{pricing,welcome,billing-cancel,billing-success} + const r = InlineActionSchema.safeParse({ type: 'navigation', to: '/environments' }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect(r.data).toMatchObject({ type: 'url', target: '/environments' }); + }); + + it('never emits the legacy spellings back out', () => { + const out = InlineActionSchema.parse({ type: 'navigation', to: '/x' }) as Record; + expect(out.type).toBe('url'); + expect(out).not.toHaveProperty('to'); + }); + + it('leaves an explicit target alone when `to` is also present', () => { + const out = InlineActionSchema.parse({ type: 'url', target: '/wins', to: '/loses' }) as Record; + expect(out.target).toBe('/wins'); + expect(out).not.toHaveProperty('to'); + }); + + it('passes a canonical body through untouched, by identity', () => { + const canonical = { type: 'url', target: '/x' }; + expect(normalizeInlineAction(canonical)).toBe(canonical); + }); + + it('does not invent a type for a bare `to`', () => { + // `to` alone still means "navigate", so the default `script` type must not + // silently swallow it — the target-required rule has nothing to check, but + // the fold must still move `to` onto `target` rather than dropping it. + const out = normalizeInlineAction({ to: '/x' }) as Record; + expect(out.target).toBe('/x'); + expect(out).not.toHaveProperty('to'); + expect(out.type).toBeUndefined(); + }); + + it('is a no-op on non-objects', () => { + for (const v of [undefined, null, 'x', 7, [1, 2]]) { + expect(normalizeInlineAction(v)).toBe(v); + } + }); +}); + +describe('element:button declares its action', () => { + it('no longer strips it from the parse output', () => { + const r = ElementButtonPropsSchema.safeParse({ + label: 'Upgrade', + action: { type: 'navigation', to: '/apps/cloud_control/sys_environment' }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect(r.data).toHaveProperty('action'); + expect((r.data as { action: Record }).action) + .toMatchObject({ type: 'url', target: '/apps/cloud_control/sys_environment' }); + }); + + it('still renders inert without one — the prop is optional', () => { + const r = ElementButtonPropsSchema.safeParse({ label: 'Static' }); + expect(r.success).toBe(true); + expect(r.data).not.toHaveProperty('action'); + }); + + it('rejects an action whose type is not dispatchable', () => { + const r = ElementButtonPropsSchema.safeParse({ + label: 'Broken', + action: { type: 'teleport', target: '/x' }, + }); + expect(r.success).toBe(false); + }); +});