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
48 changes: 48 additions & 0 deletions .changeset/action-description-authorable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": minor
"@objectstack/cli": patch
---

feat(spec,cli): `description` is authorable on an action (#7367)

An action may now declare a top-level `description`, I18nLabel-shaped exactly as
`label` is (plain string or `{ en, 'zh-CN', … }` map). It is the explanatory line
the param dialog shows under the title.

**This closes a producer gap, not a renderer gap.** The consumer half already
shipped and has been unreachable: objectui's `ActionParamDialog` renders the
string as the dialog's `DialogDescription`, two independent handlers feed it as
`actionDescription(objectName, actionName, action.description)`, and the
resolver already walks `objects.{object}._actions.{action}.description` with a
`globalActions.{action}.description` fallback. Nothing could author any of it —
`ActionSchema` is a `strictObject` and refused the key outright, and the
translation shape refused the matching bundle key. The mirror image of
declared-but-unenforced: machinery with no way in.

Three surfaces move together, so the key is never declared without being
extractable:

- **`ActionSchema`** — optional `description`.
- **Action translations** (`objects.{o}._actions.{a}` and `globalActions.{a}`) —
the matching `description` slot, so a bundle can carry the translated string
at the address the resolver already reads.
- **`os i18n extract`** — emits the key beside `label` / `confirmText` /
`successMessage` / `params`. It is seeded only when the action declares one;
an action without a description is not a translation gap, because the dialog
falls back to its own generic string.

**What to write in it.** An action that collects `params` and also sets
`confirmText` shows two dialogs for one decision — the confirm, then the param
prompt. Per the maintainer's 2026-08-10 ruling, carry the confirm question in
`description` instead: one condition, one wording, one dialog, nothing sent until
that dialog's own Confirm. `confirmText` remains correct for a param-less action,
where the confirm is the only dialog.

`description` is not `ai.description`. That one is the LLM-facing tool contract
(≥40 chars, required when `ai.exposed`) and is unchanged; this one is
human-facing dialog copy and is never sent to a model.

Additive and optional: every existing action, bundle and extract keeps parsing
unchanged. Inline actions (`InlineActionSchema`) deliberately do not gain the
key — that shape forwards only what a host renderer honours, and widens when a
renderer widens.
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const result = ApiMethod.parse(data);
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
| **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be &lt;= sharingModel in openness. |
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
| **actions** | `{ name: string; label: string \| Record<string, string>; objectName?: string; icon?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ const result = MetadataBulkResultSchema.parse(data);
| **executionPinned** | `boolean` | optional | Transaction rows reference a specific version_hash; history GC is disabled and getByHash() MUST resolve old hashes (ADR-0009) |
| **loadOrder** | `integer` | optional | Loading priority (lower = earlier) |
| **domain** | `Enum<'data' \| 'ui' \| 'automation' \| 'system' \| 'security' \| 'ai'>` | ✅ | Protocol domain |
| **actions** | `{ name: string; label: string \| Record<string, string>; objectName?: string; icon?: string; … }[]` | optional | Declarative type-level actions (e.g. datasource "Test connection"), reusing ActionSchema; merged with plugin-registered actions when emitted |
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Declarative type-level actions (e.g. datasource "Test connection"), reusing ActionSchema; merged with plugin-registered actions when emitted |

### Allowed Values: `MetadataTypeRegistryEntry.type`

Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Translation data for a single object
| **description** | `string` | optional | Translated object description |
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
| **_actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, object>; … }>` | optional | Action translations keyed by action name |
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |

Expand Down Expand Up @@ -143,7 +143,7 @@ Translation data for objects, apps, and UI messages
| **objects** | `Record<string, { label?: string; pluralLabel?: string; description?: string; fields?: Record<string, object>; … }>` | optional | Object translations keyed by object name |
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, object> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, object>; … }>` | optional | Global action translations keyed by action name |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down Expand Up @@ -196,7 +196,7 @@ One locale of translations — the `translation` metadata type
| **objects** | `Record<string, { label?: string; pluralLabel?: string; description?: string; fields?: Record<string, object>; … }>` | optional | Object translations keyed by object name |
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, object> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, object>; … }>` | optional | Global action translations keyed by action name |
| **globalActions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object> }>` | optional | Dashboard translations keyed by dashboard name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const result = ActionSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Machine name (lowercase snake_case) |
| **label** | `string \| Record<string, string>` | ✅ | Display label |
| **description** | `string \| Record<string, string>` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two — #7278). Not the LLM-facing `ai.description`. |
| **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). |
| **icon** | `string` | optional | Icon name |
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible |
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@
* objects.<name>._views.<view>.description
* objects.<name>._views.<view>.emptyState.title / .message
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.description
* objects.<name>._actions.<action>.confirmText
* objects.<name>._actions.<action>.successMessage
* objects.<name>._actions.<action>.params.<param>.label / .helpText / .placeholder
* objects.<name>._actions.<action>.params.<param>.options.<value>
* objects.<name>._actions.<action>.resultDialog.title / .description / .acknowledge
* objects.<name>._actions.<action>.resultDialog.fields.<path>
* globalActions.<action>.label / .confirmText / .successMessage
* globalActions.<action>.label / .description / .confirmText / .successMessage
* globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions)
* apps.<app>.label / .description
* apps.<app>.navigation.<id>.label
Expand Down Expand Up @@ -687,6 +688,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
const aname = action.name as string;
const aroot = ['objects', objectName, '_actions', aname];
pushDerived(out, [...aroot, 'label'], action.label ?? aname, inlineText(action.label), 'action', { objectName });
pushOptional(out, [...aroot, 'description'], action.description, 'action', { objectName });
pushOptional(out, [...aroot, 'confirmText'], action.confirmText, 'action', { objectName });
pushOptional(out, [...aroot, 'successMessage'], action.successMessage, 'action', { objectName });
pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
Expand Down Expand Up @@ -760,6 +762,12 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
: ['globalActions', action.name];
const kind: ExpectedEntry['source'] = objectName ? 'action' : 'globalAction';
pushDerived(out, [...root, 'label'], action.label ?? action.name, inlineText(action.label), kind, { objectName });
// `description` is OPTIONAL-not-derived, exactly like confirmText: the
// param dialog falls back to its own generic `actionDialog.description`
// string when the action declares none, so an undeclared description is
// not an i18n gap to seed (`pushDerived` would invent an English source
// string nothing authored). #7367.
pushOptional(out, [...root, 'description'], action.description, kind, { objectName });
pushOptional(out, [...root, 'confirmText'], action.confirmText, kind, { objectName });
pushOptional(out, [...root, 'successMessage'], action.successMessage, kind, { objectName });
pushActionParams(out, root, action, kind, objectName);
Expand Down
Loading
Loading