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
69 changes: 69 additions & 0 deletions .changeset/inline-action-schema.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 24 additions & 2 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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. |


---

9 changes: 5 additions & 4 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,9 @@
"I18nLabelSchema (const)",
"I18nObject (type)",
"I18nObjectSchema (const)",
"InlineAction (type)",
"InlineActionInput (type)",
"InlineActionSchema (const)",
"InterfacePageConfig (type)",
"InterfacePageConfigSchema (const)",
"JoinedReportBlock (type)",
Expand Down Expand Up @@ -3499,6 +3502,7 @@
"expandViewContainerWithDiagnostics (function)",
"isAggregatedViewContainer (function)",
"normalizeFilterOperator (function)",
"normalizeInlineAction (function)",
"pageForm (const)",
"reportForm (const)",
"reportSelectionOrder (function)",
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,7 @@
"ui/HttpRequest",
"ui/I18nLabel",
"ui/I18nObject",
"ui/InlineAction",
"ui/InterfacePageConfig",
"ui/JoinedReportBlock",
"ui/KanbanConfig",
Expand Down
109 changes: 107 additions & 2 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,20 @@ export const ActionAiSchema = z.object({

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

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)'),

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -751,6 +766,96 @@ export type Action = z.infer<typeof ActionSchema>;
export type ActionParam = z.infer<typeof ActionParamSchema>;
export type ActionInput = z.input<typeof ActionSchema>;

/**
* 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<string, unknown>;
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<string, unknown> = { ...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<typeof InlineActionSchema>;
export type InlineActionInput = z.input<typeof InlineActionSchema>;

/**
* Action Factory Helper
*/
Expand Down
14 changes: 14 additions & 0 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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'),
}));
Expand Down
Loading
Loading