Skip to content

Commit 25be818

Browse files
os-zhuangclaude
andcommitted
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 — 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 <noreply@anthropic.com>
1 parent 554ff92 commit 25be818

9 files changed

Lines changed: 423 additions & 8 deletions

File tree

.changeset/inline-action-schema.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `element:button` declares the action it executes — `InlineActionSchema` (objectui#2997)
6+
7+
`element:button`'s renderer reads `properties.action` and dispatches it through
8+
the `ActionRunner`; that prop is the only thing making a standalone-page button
9+
interactive. `ElementButtonPropsSchema` declared `label`, `variant`, `size`,
10+
`icon`, `iconPosition`, `disabled`, `aria` — and no `action`.
11+
12+
It is being authored anyway. cloud's `service-tenant` pages carry five
13+
declaration sites across the billing and pricing funnel (`pricing`, `welcome`,
14+
`billing-cancel` ×2, `billing-success`), each `{ type: 'navigation', to: … }`,
15+
each cast `as any` to get past the type system.
16+
17+
**An undeclared prop is stripped, not ignored.**
18+
`ElementButtonPropsSchema.safeParse({ label, action })` returned `success: true`
19+
with no `action` in `data` — a non-strict `z.object` drops unknown keys. That is
20+
harmless only because page block `properties` are still
21+
`z.record(z.string(), z.unknown())`, so this schema never runs on a real page
22+
save. The moment anyone tightens `properties` to validate against
23+
`ComponentPropsMap` — an obvious hardening — every one of those buttons loses its
24+
action at save, with a green parse. Declaring the prop is what defuses it.
25+
26+
**`InlineActionSchema` is derived, not a new dialect.** `ActionSchema` is
27+
`z.object(…).refine(…).refine(…)`, so `.pick()` is unavailable on the exported
28+
schema — the object half is now a factory both schemas build from, which keeps
29+
`lazySchema`'s deferral (the fields are constructed on first use of whichever
30+
schema is touched, not at module load). The inline schema `.pick()`s the twelve
31+
fields `element:button` actually forwards to the runner, so their `describe()`
32+
text, the `ActionType` vocabulary and the `target`-required refinement are shared
33+
rather than restated.
34+
35+
`name` and `label` are optional, which is the substantive difference:
36+
`ActionSchema` requires both because a registry entry needs an identity and a
37+
menu label, and an inline action has neither — the button already has its own
38+
`label`, and requiring `action.label` too would mean writing it twice. Everything
39+
that only means something for a registered action — `objectName`, `locations`,
40+
`order`, `ai`, `requiredPermissions`, `visible`/`disabled`, `resultDialog` — is
41+
excluded, as are `icon`/`variant` (the button has its own) and `body` (a page
42+
button running an inline sandboxed script is a separate decision). A declared
43+
field no renderer reads is the failure this schema exists to stop, so widen it
44+
when a renderer widens, not before.
45+
46+
**`navigation` and `to` become normalizing aliases, not new members.**
47+
`normalizeInlineAction` folds `type: 'navigation'``'url'` and `to``target`
48+
on parse, the `VIEW_FILTER_OPERATOR_ALIASES` pattern. So cloud's existing pages
49+
keep validating unchanged while parse output is always canonical, and the aliases
50+
get a `retiredKey` tombstone once the producers are migrated. `url` is also the
51+
*better* target: the runner's `url` path has `${param.X}` / `${ctx.X}`
52+
interpolation, `apiBase` promotion for `/api/…` paths and popup-blocker-safe
53+
`openIn`, none of which its `navigation` path has.
54+
55+
Deliberately **not** done: promoting `navigation` into `ActionType` as a bare
56+
member, which was considered and declined in #4070. It would name the type while
57+
leaving the prop undeclared and `to` homeless — half a fix, and a permanent
58+
synonym in a vocabulary whose members cannot be removed later.
59+
60+
Nothing is narrowed. `ui/InlineAction` and `ui/ElementButtonProps:action` are
61+
additions to every generated surface; no accepted value stops being accepted, so
62+
no stored page metadata changes meaning.
63+
64+
Verified: 16 new tests — the derivation is asserted from both directions
65+
(the inline field set is exactly the documented twelve; every one of them
66+
round-trips through `ActionSchema`; every registry-only concern is absent; every
67+
`ActionType` member is inline-authorable), and the fold is pinned against the
68+
literal shape cloud writes. Full `@objectstack/spec` suite **7038 tests across
69+
271 files**, `tsc --noEmit`, and all twelve `check:*` gates, clean.

content/docs/references/ui/action.mdx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ to the field name and is used as the request-body key).
6262
## TypeScript Usage
6363

6464
```typescript
65-
import { Action, ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui';
66-
import type { Action, ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui';
65+
import { Action, ActionAi, ActionLocation, ActionParam, ActionType, InlineAction } from '@objectstack/spec/ui';
66+
import type { Action, ActionAi, ActionLocation, ActionParam, ActionType, InlineAction } from '@objectstack/spec/ui';
6767

6868
// Validate data
6969
const result = Action.parse(data);
@@ -189,3 +189,25 @@ const result = Action.parse(data);
189189

190190
---
191191

192+
## InlineAction
193+
194+
### Properties
195+
196+
| Property | Type | Required | Description |
197+
| :--- | :--- | :--- | :--- |
198+
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Action functionality type |
199+
| **name** | `string` | optional | Machine name (lowercase snake_case) |
200+
| **label** | `string` | optional | Display label |
201+
| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. |
202+
| **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). |
203+
| **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. |
204+
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string; … }[]` | optional | Input parameters required from user |
205+
| **confirmText** | `string` | optional | Confirmation message before execution |
206+
| **successMessage** | `string` | optional | Success message to show after execution |
207+
| **errorMessage** | `string` | optional | Error message to show when the action fails (overrides the raw error). |
208+
| **refreshAfter** | `boolean` | optional | Refresh view after execution |
209+
| **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. |
210+
211+
212+
---
213+

content/docs/references/ui/component.mdx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,12 @@ const result = AIChatWindowProps.parse(data);
4444
| Property | Type | Required | Description |
4545
| :--- | :--- | :--- | :--- |
4646
| **label** | `string` || Button display label |
47-
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | | Button visual variant |
48-
| **size** | `Enum<'small' \| 'medium' \| 'large'>` | | Button size |
47+
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant |
48+
| **size** | `Enum<'small' \| 'medium' \| 'large'>` | optional | Button size |
4949
| **icon** | `string` | optional | Icon name (Lucide icon) |
50-
| **iconPosition** | `Enum<'left' \| 'right'>` || Icon position relative to label |
51-
| **disabled** | `boolean` || Disable the button |
50+
| **iconPosition** | `Enum<'left' \| 'right'>` | optional | Icon position relative to label |
51+
| **disabled** | `boolean` | optional | Disable the button |
52+
| **action** | `{ type?: Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>; name?: string; label?: string; target?: string; … }` | optional | Inline action executed on click |
5253
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
5354

5455

packages/spec/api-surface.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3282,6 +3282,9 @@
32823282
"I18nLabelSchema (const)",
32833283
"I18nObject (type)",
32843284
"I18nObjectSchema (const)",
3285+
"InlineAction (type)",
3286+
"InlineActionInput (type)",
3287+
"InlineActionSchema (const)",
32853288
"InterfacePageConfig (type)",
32863289
"InterfacePageConfigSchema (const)",
32873290
"JoinedReportBlock (type)",
@@ -3499,6 +3502,7 @@
34993502
"expandViewContainerWithDiagnostics (function)",
35003503
"isAggregatedViewContainer (function)",
35013504
"normalizeFilterOperator (function)",
3505+
"normalizeInlineAction (function)",
35023506
"pageForm (const)",
35033507
"reportForm (const)",
35043508
"reportSelectionOrder (function)",

packages/spec/authorable-surface.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7788,6 +7788,7 @@
77887788
"ui/DropZone:label",
77897789
"ui/DropZone:maxItems",
77907790
"ui/DropZone:role",
7791+
"ui/ElementButtonProps:action",
77917792
"ui/ElementButtonProps:aria",
77927793
"ui/ElementButtonProps:disabled",
77937794
"ui/ElementButtonProps:icon",
@@ -8001,6 +8002,18 @@
80018002
"ui/I18nObject:defaultValue",
80028003
"ui/I18nObject:key",
80038004
"ui/I18nObject:params",
8005+
"ui/InlineAction:confirmText",
8006+
"ui/InlineAction:errorMessage",
8007+
"ui/InlineAction:label",
8008+
"ui/InlineAction:method",
8009+
"ui/InlineAction:name",
8010+
"ui/InlineAction:openIn",
8011+
"ui/InlineAction:opensInNewTab",
8012+
"ui/InlineAction:params",
8013+
"ui/InlineAction:refreshAfter",
8014+
"ui/InlineAction:successMessage",
8015+
"ui/InlineAction:target",
8016+
"ui/InlineAction:type",
80048017
"ui/InterfacePageConfig:addRecord",
80058018
"ui/InterfacePageConfig:allowPrinting",
80068019
"ui/InterfacePageConfig:appearance",

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,7 @@
16611661
"ui/HttpRequest",
16621662
"ui/I18nLabel",
16631663
"ui/I18nObject",
1664+
"ui/InlineAction",
16641665
"ui/InterfacePageConfig",
16651666
"ui/JoinedReportBlock",
16661667
"ui/KanbanConfig",

packages/spec/src/ui/action.zod.ts

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,20 @@ export const ActionAiSchema = z.object({
373373

374374
export type ActionAi = z.infer<typeof ActionAiSchema>;
375375

376-
export const ActionSchema = lazySchema(() => z.object({
376+
/**
377+
* The object half of {@link ActionSchema}, before its refinements.
378+
*
379+
* A factory rather than a schema so `lazySchema`'s deferral still holds — the
380+
* fields are built on first use of whichever schema derives from them, not at
381+
* module load.
382+
*
383+
* It exists because `.pick()` is a `ZodObject` method and `ActionSchema` is
384+
* `z.object(…).refine(…).refine(…)`, so nothing can derive a subset from the
385+
* exported schema. {@link InlineActionSchema} derives from this instead of
386+
* restating a dozen field definitions and their `describe()` text, which is how
387+
* a second action vocabulary would start.
388+
*/
389+
const actionObject = () => z.object({
377390
/** Machine name of the action */
378391
name: SnakeCaseIdentifierSchema.describe('Machine name (lowercase snake_case)'),
379392

@@ -677,7 +690,9 @@ export const ActionSchema = lazySchema(() => z.object({
677690

678691
/** ARIA accessibility attributes */
679692
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
680-
}).refine((data) => {
693+
});
694+
695+
export const ActionSchema = lazySchema(() => actionObject().refine((data) => {
681696
// Require `target` for types that reference an external resource
682697
if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) {
683698
return false;
@@ -751,6 +766,96 @@ export type Action = z.infer<typeof ActionSchema>;
751766
export type ActionParam = z.infer<typeof ActionParamSchema>;
752767
export type ActionInput = z.input<typeof ActionSchema>;
753768

769+
/**
770+
* Legacy spellings an inline action may carry, folded to canonical on parse.
771+
*
772+
* `navigation` is a `type` no spec enum ever declared, and `to` a `target` no
773+
* spec schema ever declared — yet both are what cloud's tenant pages actually
774+
* write on an `element:button` (`{ type: 'navigation', to: PRICING_ROUTE }`,
775+
* five sites across the billing and pricing funnel). They reached a real
776+
* dispatcher: objectui's `ActionRunner` has a `navigation` case reading
777+
* `nav.to ?? nav.target`.
778+
*
779+
* `url` + `target` is the canonical pair, and it is also the *better* one — the
780+
* runner's `url` path adds `${param.X}` / `${ctx.X}` interpolation, `apiBase`
781+
* promotion for `/api/…` paths, and popup-blocker-safe `openIn` handling, none
782+
* of which the `navigation` path has. So this is a bridge with an end state, not
783+
* a second vocabulary: authored `navigation`/`to` keep validating, parse output
784+
* is always `url`/`target`, and the aliases get a `retiredKey` tombstone once
785+
* the producers are migrated.
786+
*
787+
* Exported so a producer can normalize before writing, rather than inventing its
788+
* own fold. objectstack-ai/objectui#2997.
789+
*/
790+
export function normalizeInlineAction(value: unknown): unknown {
791+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
792+
const v = value as Record<string, unknown>;
793+
const needsType = v.type === 'navigation';
794+
const needsTarget = v.target === undefined && typeof v.to === 'string';
795+
if (!needsType && !needsTarget && !('to' in v)) return value;
796+
const out: Record<string, unknown> = { ...v };
797+
if (needsType) out.type = 'url';
798+
if (needsTarget) out.target = v.to;
799+
delete out.to;
800+
return out;
801+
}
802+
803+
/**
804+
* An action declared **inline** on a UI surface, rather than registered by name.
805+
*
806+
* The execution half of {@link ActionSchema} — `.pick()`ed from the same field
807+
* definitions, so the `describe()` text, the operator vocabulary and the
808+
* `target`-required rule are shared rather than restated — minus everything that
809+
* only means something for a *registered* action: `objectName`, `locations`,
810+
* `order`, `ai` exposure, `requiredPermissions`, `visible`/`disabled`
811+
* predicates, `resultDialog`.
812+
*
813+
* `name` and `label` are optional here, which is the substantive difference.
814+
* `ActionSchema` requires both because a registry entry needs an identity and a
815+
* menu label; an inline action has neither — the host supplies the label (an
816+
* `element:button` has its own `label` prop, and requiring `action.label` too
817+
* would mean writing it twice).
818+
*
819+
* Scoped deliberately to what a host actually honours. `element:button`'s
820+
* renderer forwards exactly these fields to the `ActionRunner`; `icon` and
821+
* `variant` are excluded because the button has its own, and `body` because a
822+
* page button running an inline sandboxed script is a separate decision. Widen
823+
* this when a renderer widens, not before — a declared field no renderer reads
824+
* is the failure this schema exists to stop.
825+
*/
826+
export const InlineActionSchema = lazySchema(() => z.preprocess(
827+
normalizeInlineAction,
828+
actionObject().pick({
829+
type: true,
830+
name: true,
831+
label: true,
832+
target: true,
833+
openIn: true,
834+
method: true,
835+
params: true,
836+
confirmText: true,
837+
successMessage: true,
838+
errorMessage: true,
839+
refreshAfter: true,
840+
opensInNewTab: true,
841+
}).partial({
842+
name: true,
843+
label: true,
844+
}).refine((data) => {
845+
// The same rule ActionSchema's first refinement applies, for the same
846+
// reason: a `url`/`flow`/`modal`/`api`/`form` action with no `target` has
847+
// nothing to dispatch to and fails silently at click time.
848+
if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) return false;
849+
return true;
850+
}, {
851+
message: "Inline action 'target' is required when type is 'url', 'flow', 'modal', 'api', or 'form' (`to` is accepted as a legacy spelling).",
852+
path: ['target'],
853+
}),
854+
));
855+
856+
export type InlineAction = z.infer<typeof InlineActionSchema>;
857+
export type InlineActionInput = z.input<typeof InlineActionSchema>;
858+
754859
/**
755860
* Action Factory Helper
756861
*/

packages/spec/src/ui/component.zod.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { FilterConditionSchema } from '../data/filter.zod';
55
import { ViewFilterRuleSchema } from './view.zod';
6+
import { InlineActionSchema } from './action.zod';
67
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
78
import { FeedItemType, FeedFilterMode } from '../data/feed.zod';
89

@@ -305,6 +306,19 @@ export const ElementButtonPropsSchema = lazySchema(() => z.object({
305306
iconPosition: z.enum(['left', 'right'])
306307
.optional().default('left').describe('Icon position relative to label'),
307308
disabled: z.boolean().optional().default(false).describe('Disable the button'),
309+
/**
310+
* What the button does when clicked. Declared inline — a page button is not a
311+
* registered object action, so `name` and `label` are optional (the button
312+
* supplies its own label).
313+
*
314+
* Without this the button renders inert, which is why it was being authored
315+
* regardless: cloud's tenant pages carry five of them across the billing and
316+
* pricing funnel. Undeclared, it was **silently stripped** from
317+
* `ElementButtonPropsSchema`'s parse output — harmless only because page block
318+
* `properties` are still `z.record(z.string(), z.unknown())`, and a loaded gun
319+
* the moment that is tightened. objectstack-ai/objectui#2997.
320+
*/
321+
action: InlineActionSchema.optional().describe('Inline action executed on click'),
308322
/** ARIA accessibility */
309323
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
310324
}));

0 commit comments

Comments
 (0)