From f896d84d7d94a5468bf6a98f7e05191162abf9d7 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 10 Aug 2026 10:49:12 +0000 Subject: [PATCH] feat(spec,cli): `description` is authorable on an action (#7367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer half already shipped and was unreachable. objectui's ActionParamDialog renders an action description as the dialog's DialogDescription, two handlers feed it via actionDescription(...), and useObjectLabel already resolves objects.{o}._actions.{a}.description with a globalActions.{a}.description fallback — but ActionSchema (strictObject) refused the key and the translation shape refused the bundle slot, so no producer could reach any of it. Three surfaces move together so the key is never declared without being extractable: - ActionSchema: optional `description`, I18nLabel-shaped exactly as `label`. - actionTranslationSchema: the matching slot at both addresses. - os i18n extract: emits it beside label/confirmText/successMessage/params, seeded only when declared (pushOptional, not pushDerived — the dialog has its own generic fallback, so an undeclared description is not a gap). Mechanism for the maintainer's 2026-08-10 ruling on #7278: an action with params carries its confirm question here instead of in confirmText, so one decision is one dialog. InlineActionSchema deliberately does NOT gain the key — that shape forwards only what a host renderer honours. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 --- .changeset/action-description-authorable.md | 48 ++++ content/docs/references/data/object.mdx | 2 +- .../references/kernel/metadata-plugin.mdx | 2 +- .../docs/references/system/translation.mdx | 6 +- content/docs/references/ui/action.mdx | 1 + packages/cli/src/utils/i18n-extract.ts | 10 +- .../i18n-extract-action-description.test.ts | 200 +++++++++++++++ packages/spec/authorable-surface/ui.json | 1 + packages/spec/liveness/action.json | 6 + packages/spec/src/system/translation.zod.ts | 7 + .../spec/src/ui/action-description.test.ts | 232 ++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 60 ++++- 12 files changed, 568 insertions(+), 7 deletions(-) create mode 100644 .changeset/action-description-authorable.md create mode 100644 packages/cli/test/i18n-extract-action-description.test.ts create mode 100644 packages/spec/src/ui/action-description.test.ts diff --git a/.changeset/action-description-authorable.md b/.changeset/action-description-authorable.md new file mode 100644 index 0000000000..f7cfbce665 --- /dev/null +++ b/.changeset/action-description-authorable.md @@ -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. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8d1100fd70..b610660cc5 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -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 <= 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; objectName?: string; icon?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | +| **actions** | `{ name: string; label: string \| Record; description?: string \| Record; 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. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index fd6c36030e..829d7e90be 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -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; 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; description?: string \| Record; 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` diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index e819a8b25a..617b2732a4 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -85,7 +85,7 @@ Translation data for a single object | **description** | `string` | optional | Translated object description | | **fields** | `Record }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | -| **_actions** | `Record; … }>` | optional | Action translations keyed by action name | +| **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -143,7 +143,7 @@ Translation data for objects, apps, and UI messages | **objects** | `Record; … }>` | optional | Object translations keyed by object name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | -| **globalActions** | `Record; … }>` | optional | Global action translations keyed by action name | +| **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | | **pages** | `Record` | optional | Page translations keyed by page name | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -196,7 +196,7 @@ One locale of translations — the `translation` metadata type | **objects** | `Record; … }>` | optional | Object translations keyed by object name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | -| **globalActions** | `Record; … }>` | optional | Global action translations keyed by action name | +| **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | | **pages** | `Record` | optional | Page translations keyed by page name | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 29417d06f5..77bc1e2ca3 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -62,6 +62,7 @@ const result = ActionSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | +| **description** | `string \| Record` | 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 | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index a231d636de..f6475f733e 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -35,13 +35,14 @@ * objects.._views..description * objects.._views..emptyState.title / .message * objects.._actions..label + * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage * objects.._actions..params..label / .helpText / .placeholder * objects.._actions..params..options. * objects.._actions..resultDialog.title / .description / .acknowledge * objects.._actions..resultDialog.fields. - * globalActions..label / .confirmText / .successMessage + * globalActions..label / .description / .confirmText / .successMessage * globalActions..params..* / .resultDialog.* (same shape as object actions) * apps..label / .description * apps..navigation..label @@ -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); @@ -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); diff --git a/packages/cli/test/i18n-extract-action-description.test.ts b/packages/cli/test/i18n-extract-action-description.test.ts new file mode 100644 index 0000000000..01ee8342db --- /dev/null +++ b/packages/cli/test/i18n-extract-action-description.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#7367 — the extractor half of `action.description`. +// +// Splitting the extractor out of the schema change would open a +// declared-but-not-extracted window: the key parses, objectui renders it, and +// `os i18n extract` scaffolds no translation slot for it — so a zh-CN +// workspace silently shows the English dialog line with no diagnostic, which is +// exactly the leak objectstack#3370 closed for action labels. +// +// The last test walks ONE fixture action through all three surfaces — +// `ActionSchema` accepts it, the extractor emits its key, the translation +// schema accepts the bundle written at that key — because each half passing on +// its own does not prove the addresses agree. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +// Subpath entrypoints — the root barrel re-exports only a curated set, and +// neither of these two is in it. +import { ActionSchema } from '@objectstack/spec/ui'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +const paths = (config: any) => collectExpectedEntries(config).map((e) => e.path.join('.')); + +describe('the extractor emits action-level `description` (#7367)', () => { + it('emits it for a TOP-LEVEL object action that declares one', () => { + const keys = paths({ + actions: [ + { + name: 'approval_reject', + objectName: 'sys_approval_request', + label: 'Reject', + description: 'Reject this request? Say why — the requester sees it.', + params: [{ name: 'reason', label: 'Reason' }], + }, + ], + }); + + expect(keys).toContain('objects.sys_approval_request._actions.approval_reject.description'); + }); + + it('emits it for a GLOBAL action that declares one', () => { + const keys = paths({ + actions: [{ name: 'rotate_client_secret', label: 'Rotate', description: 'Rotate the secret?' }], + }); + + expect(keys).toContain('globalActions.rotate_client_secret.description'); + }); + + it('emits it for an action declared INLINE on the object — the objectstack#3370 walker', () => { + const keys = paths({ + objects: [ + { + name: 'sys_approval_request', + label: 'Approval Request', + actions: [{ name: 'approval_reject', label: 'Reject', description: 'Reject this request?' }], + }, + ], + }); + + expect(keys).toContain('objects.sys_approval_request._actions.approval_reject.description'); + }); + + it('seeds NOTHING for an action that declares none — exactly as `confirmText` does not', () => { + // Measured, not presumed: `pushOptional` on an absent value still records a + // SEED-LESS entry (path, no `inline`/`sourceValue`). That is deliberate — + // it is how the coverage gate spots a bundle authoring a key the metadata + // never writes — and it is the same thing `confirmText` and + // `successMessage` have always done. So the contract to pin is not "the + // path is absent" but "nothing is seeded and nothing is gated": the dialog + // falls back to its own generic `actionDialog.description`, so an + // undeclared description is not a translation gap. `pushDerived` (what + // `label` uses) WOULD invent an English source string here and then gate + // every locale on translating it. + const config = { + objects: [ + { + name: 'sys_approval_request', + label: 'Approval Request', + actions: [{ name: 'approval_approve', label: 'Approve' }], + }, + ], + }; + const entries = collectExpectedEntries(config); + const at = (key: string) => entries.find((e) => e.path.join('.') === key); + const root = 'objects.sys_approval_request._actions.approval_approve'; + + // Present as a path, carrying no source text — byte-for-byte the shape of + // the undeclared `confirmText` beside it. + expect(at(`${root}.description`)).toBeDefined(); + expect(at(`${root}.description`)?.inline).toBeUndefined(); + expect(at(`${root}.description`)?.sourceValue).toBeUndefined(); + expect(at(`${root}.description`)?.sourceValue).toBe(at(`${root}.confirmText`)?.sourceValue); + + // `label` is the contrast: derived, so it DOES carry a seed. + expect(at(`${root}.label`)?.sourceValue).toBe('Approve'); + + // Nothing seeded into a bundle, nothing gated as a gap. + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects.sys_approval_request._actions.approval_approve) + .not.toHaveProperty('description'); + expect(computeI18nCoverage({ ...config, translations: [{ 'zh-CN': {} }] }).issues.map((i) => i.key)) + .not.toContain(`${root}.description`); + }); + + it('carries the declared text into the emitted bundle, attributed to the action source', () => { + const config = { + objects: [ + { + name: 'sys_approval_request', + label: 'Approval Request', + actions: [{ name: 'approval_reject', label: 'Reject', description: 'Reject this request?' }], + }, + ], + }; + const entry = collectExpectedEntries(config).find( + (e) => e.path.join('.') === 'objects.sys_approval_request._actions.approval_reject.description', + ); + + expect(entry?.source).toBe('action'); + expect(entry?.inline).toBe('Reject this request?'); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects.sys_approval_request._actions.approval_reject.description) + .toBe('Reject this request?'); + }); + + it('gates an untranslated description, and goes quiet once it is translated', () => { + // The extractable surface and the `os lint` gated surface share one walker; + // this is that contract applied to the new key. + const config: any = { + objects: [ + { + name: 'sys_approval_request', + label: 'Approval Request', + actions: [{ name: 'approval_reject', label: 'Reject', description: 'Reject this request?' }], + }, + ], + translations: [ + { 'zh-CN': { objects: { sys_approval_request: { label: '审批请求', _actions: { approval_reject: { label: '拒绝' } } } } } }, + ], + }; + + const gaps = computeI18nCoverage(config).issues.map((i) => i.key); + expect(gaps).toContain('objects.sys_approval_request._actions.approval_reject.description'); + + config.translations[0]['zh-CN'].objects.sys_approval_request._actions.approval_reject.description = + '拒绝该请求?'; + const after = computeI18nCoverage(config).issues.filter((i) => i.source !== 'metadataForm'); + expect(after).toEqual([]); + }); +}); + +describe('one fixture action, all three surfaces (#7367 end-to-end)', () => { + const authored = { + name: 'approval_reject', + objectName: 'sys_approval_request', + label: 'Reject', + // `type` defaults to `script`, which requires a dispatch target. + target: 'rejectApproval', + // The #7278 shape: the confirm question lives here, so the param dialog is + // the ONE dialog — no `confirmText` stacked in front of it. + description: 'Reject this request? The requester sees your reason.', + params: [{ name: 'reason', label: 'Reason', type: 'textarea' as const, required: true }], + }; + + it('parses as metadata, extracts to a key, and that key round-trips a bundle', () => { + // 1. The producer accepts it. + const parsed = ActionSchema.safeParse(authored); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.description) + .toBe('Reject this request? The requester sees your reason.'); + expect(parsed.success && parsed.data.confirmText).toBeUndefined(); + + // 2. The extractor emits the address objectui's resolver reads + // (`objects.{o}._actions.{a}.description`). + const key = 'objects.sys_approval_request._actions.approval_reject.description'; + const { bundles } = extractTranslations({ actions: [authored] }, { + defaultLocale: 'en', + locales: ['zh-CN'], + fill: 'empty', + }); + const bundle: any = bundles['zh-CN']; + expect(bundle.objects.sys_approval_request._actions.approval_reject) + .toHaveProperty('description'); + + // 3. A translator fills that exact address and the translation schema takes it. + bundle.objects.sys_approval_request._actions.approval_reject.description = '拒绝该请求?申请人会看到你的理由。'; + const translated = ObjectTranslationDataSchema.safeParse( + bundle.objects.sys_approval_request, + ); + expect(translated.success).toBe(true); + expect(translated.success && translated.data._actions?.approval_reject?.description) + .toBe('拒绝该请求?申请人会看到你的理由。'); + + // The three surfaces agree on ONE address. + expect(paths({ actions: [authored] })).toContain(key); + }); +}); diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 59aae859e0..62d3cb2f34 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -21,6 +21,7 @@ "ui/Action:bulkEnabled [RETIRED]", "ui/Action:component", "ui/Action:confirmText", + "ui/Action:description", "ui/Action:disabled", "ui/Action:errorMessage", "ui/Action:execute [RETIRED]", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 2b93b2f191..ee4a817cb3 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -10,6 +10,12 @@ "status": "live", "note": "display." }, + "description": { + "status": "live", + "verifiedAt": "2026-08-10", + "evidence": "objectui @5e52495 packages/app-shell/src/views/ActionParamDialog.tsx:215 renders it as the dialog's DialogDescription; objectui @5e52495 packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:206 and objectui packages/app-shell/src/views/RecordDetailView.tsx:586 both feed it as `description: actionDescription(objForI18n, action?.name, action?.description)`; objectui packages/i18n/src/useObjectLabel.ts:463 resolves objects.{o}._actions.{a}.description then globalActions.{a}.description then this literal; framework: packages/cli/src/utils/i18n-extract.ts emits the key and packages/spec/src/system/translation.zod.ts declares the translation slot.", + "note": "Added #7367 — the RENDERER half predates the key by design: the consumer chain above shipped while `ActionSchema` (strictObject) refused `description`, so no producer could reach it (the mirror image of declared-but-unenforced). Live from the moment this key exists: the dialog reads `action.description` directly, no second input required. Mechanism for the 2026-08-10 ruling on #7278 — an action with `params` carries its confirm question here instead of in `confirmText`, so one decision is one dialog." + }, "objectName": { "status": "live", "evidence": "packages/services/service-ai/src/tools/action-tools.ts:535", diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 32a8013d9e..e517c20880 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -101,6 +101,13 @@ const actionTranslationSchema = (surface: string) => strictObject({ }, }, { label: z.string().optional().describe('Translated action label'), + // The address `useObjectLabel.actionDescription` already resolves — + // `objects.{object}._actions.{action}.description`, falling back to + // `globalActions.{action}.description` (objectui + // packages/i18n/src/useObjectLabel.ts:463). Declared here with #7367's + // `ActionSchema.description`: a bundle could not carry the string the + // resolver was already looking for. + description: z.string().optional().describe('Translated action description — the explanatory line under the title in the action\'s param dialog'), confirmText: z.string().optional().describe('Translated confirmation prompt'), successMessage: z.string().optional().describe('Translated success toast/message'), params: z.record(z.string(), strictObject({ diff --git a/packages/spec/src/ui/action-description.test.ts b/packages/spec/src/ui/action-description.test.ts new file mode 100644 index 0000000000..a63a4067a1 --- /dev/null +++ b/packages/spec/src/ui/action-description.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#7367 — `description` on the action contract. +// +// The renderer half predates the key. objectui's param dialog has rendered an +// action description since before this schema could carry one +// (`ActionParamDialog.tsx:215` → `DialogDescription`, fed by +// `actionDescription(objectName, actionName, action.description)` from +// `useConsoleActionRuntime.tsx:206` and `RecordDetailView.tsx:586`, resolved +// through `objects.{o}._actions.{a}.description` in `useObjectLabel.ts:463`). +// What was missing was the PRODUCER: `ActionSchema` is a `strictObject` and +// refused `description` outright, and `actionTranslationSchema` refused the +// matching bundle key — so the resolver was looking for a string nothing could +// author. The mirror image of declared-but-unenforced. +// +// These pins hold the key open in the exact shape `label` has, and hold the +// three things this change must NOT do: relax the strict shape, widen the +// INLINE action surface ahead of its renderer, or re-point the action PARAM's +// `description` → `helpText` alias, which is a different surface and still +// correct. + +import { describe, it, expect } from 'vitest'; +import { ActionSchema, ActionParamSchema, InlineActionSchema } from './action.zod'; +import { ObjectTranslationDataSchema, TranslationDataSchema } from '../system/translation.zod'; + +/** + * Minimum legal registered action — identity, menu label, and something to + * dispatch to. `type` defaults to `script`, whose refinement requires an inline + * `body` or a registered bundle function `target`; leaving both off fails for a + * reason that has nothing to do with `description` and would make every pin + * below unreadable. + */ +const base = { name: 'approval_reject', label: 'Reject', target: 'rejectApproval' } as const; + +describe('ActionSchema.description (#7367)', () => { + it('accepts a plain string, matching `label`\'s I18nLabel contract', () => { + const result = ActionSchema.safeParse({ + ...base, + description: 'Reject this request? Say why — the requester sees it.', + params: [{ name: 'reason', label: 'Reason', type: 'textarea', required: true }], + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.description).toBe( + 'Reject this request? Say why — the requester sees it.', + ); + }); + + it('accepts the localized-map form, matching `label`\'s I18nLabel contract', () => { + const localized = { en: 'Reject this request?', 'zh-CN': '拒绝该请求?' }; + const result = ActionSchema.safeParse({ ...base, description: localized }); + + expect(result.success).toBe(true); + expect(result.success && result.data.description).toEqual(localized); + }); + + it('is optional — an action that declares none still parses', () => { + const result = ActionSchema.safeParse(base); + + expect(result.success).toBe(true); + expect(result.success && result.data.description).toBeUndefined(); + }); + + it('takes exactly the shapes `label` takes, and refuses the ones it refuses', () => { + // The contract claim is "I18nLabel-shaped exactly as `label` is", so it is + // asserted against `label` rather than restated — a divergence in either + // direction (a form only one of them accepts) fails here. + for (const value of [ + 'A string', + { en: 'A map', 'zh-CN': '一个映射' }, + 42, + null, + ['an array'], + { en: 7 }, + ] as unknown[]) { + const asLabel = ActionSchema.safeParse({ ...base, label: value }).success; + const asDescription = ActionSchema.safeParse({ ...base, description: value }).success; + + expect({ value, asLabel, asDescription }).toEqual({ value, asLabel, asDescription: asLabel }); + } + }); + + it('does not relax the strict shape — an unknown sibling is still refused, by code and message', () => { + const result = ActionSchema.safeParse({ + ...base, + description: 'Legal now.', + descriptionText: 'Still not a key.', + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + // Asserted as the SUGGESTION arrow, not as `toContain('description')` — + // the latter is satisfied by the substring inside `descriptionText` and + // would stay green with the key removed. The arrow can only be produced + // when `description` is a DECLARED key to resolve the near-miss against. + expect(issue?.message).toContain('Unrecognized key(s) on this action: `descriptionText`'); + expect(issue?.message).toContain('Did you mean `descriptionText` → `description`?'); + }); + + it('leaves `ai.description` alone — the LLM contract and the dialog line are different keys', () => { + const both = ActionSchema.safeParse({ + ...base, + description: 'Reject this request?', + ai: { + exposed: true, + description: 'Reject a pending approval request on behalf of the current user, with a reason.', + }, + }); + + expect(both.success).toBe(true); + expect(both.success && both.data.description).toBe('Reject this request?'); + expect(both.success && both.data.ai?.description).toContain('Reject a pending approval request'); + + // The ≥40-char LLM contract is unaffected: a top-level `description` does + // not satisfy `ai.exposed`. + const missingAi = ActionSchema.safeParse({ + ...base, + description: 'Reject this request?', + ai: { exposed: true }, + }); + expect(missingAi.success).toBe(false); + expect(missingAi.success ? undefined : missingAi.error.issues[0]?.message) + .toContain('ai.description is required'); + }); +}); + +describe('the surfaces #7367 deliberately does NOT widen', () => { + it('an action PARAM still routes `description` to `helpText`', () => { + // `ACTION_PARAM_KEY_ALIASES.description = 'helpText'` is a PARAM-surface + // entry and stays correct: the param's help line is `helpText`, and the key + // this PR legalises lives one level up on the action. If the alias were + // ever dropped as "stale now that description is legal", this goes red. + const result = ActionParamSchema.safeParse({ + name: 'reason', + label: 'Reason', + type: 'textarea', + description: 'Shown under the input', + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('helpText'); + }); + + it('an INLINE action still refuses it — the pick widens when a renderer widens, not before', () => { + // `InlineActionSchema` forwards exactly what `element:button`'s renderer + // honours. That renderer has no description slot, so picking the key here + // would declare a field no renderer reads — the failure that schema exists + // to stop (`bodyExtra` is the one knowing exception, ruled in #5777). + const result = InlineActionSchema.safeParse({ + type: 'url', + target: '/docs', + label: 'Docs', + description: 'Not honoured by element:button', + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('description'); + }); +}); + +describe('actionTranslationSchema.description (#7367)', () => { + it('accepts the key at the object-scoped address the resolver walks first', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { + approval_reject: { label: '拒绝', description: '拒绝该请求?请说明原因。' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data._actions?.approval_reject?.description) + .toBe('拒绝该请求?请说明原因。'); + }); + + it('accepts it at the globalActions address the resolver falls back to', () => { + const result = TranslationDataSchema.safeParse({ + globalActions: { + approval_reject: { label: '拒绝', description: '拒绝该请求?' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.globalActions?.approval_reject?.description) + .toBe('拒绝该请求?'); + }); + + it('is a flat string here — the bundle is already per-locale, so no nested map', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { approval_reject: { description: { en: 'Nope', 'zh-CN': '不' } } }, + }); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0]?.code).toBe('invalid_type'); + }); + + it('does not relax the strict translation shape — an unknown sibling is still refused', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { + approval_reject: { description: '拒绝该请求?', descriptionText: '不是键' }, + }, + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('Unrecognized key(s) on this object action translation: `descriptionText`'); + // Same reasoning as the ActionSchema pin: the arrow, not the substring. + expect(issue?.message).toContain('Did you mean `descriptionText` → `description`?'); + }); + + it('keeps the action description and the result-dialog description distinct', () => { + // `resultDialog.description` is a different string on a different dialog + // (the post-success reveal). Both must be carryable at once. + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { + approval_reject: { + description: '拒绝该请求?', + resultDialog: { title: '已拒绝', description: '该请求已被拒绝。' }, + }, + }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data._actions?.approval_reject?.description).toBe('拒绝该请求?'); + expect(result.success && result.data._actions?.approval_reject?.resultDialog?.description) + .toBe('该请求已被拒绝。'); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index cc915d2e2c..57c53dbf3b 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -809,6 +809,35 @@ const actionObject = () => strictObject({ /** Display label */ label: I18nLabelSchema.describe('Display label'), + /** + * Explanatory line shown in the action's PARAM DIALOG, under the title. + * + * The renderer half already exists and predates this key: objectui's + * `ActionParamDialog` renders it as the dialog's `DialogDescription` + * (`objectui packages/app-shell/src/views/ActionParamDialog.tsx:215`, falling + * back to the generic `actionDialog.description` string), fed by + * `actionDescription(objectName, actionName, action.description)` from two + * independent handlers — `useConsoleActionRuntime.tsx:206` and + * `RecordDetailView.tsx:586`. The resolver + * (`objectui packages/i18n/src/useObjectLabel.ts:463`) reads + * `objects.{object}._actions.{action}.description`, falls back to + * `globalActions.{action}.description`, then to this literal. Until #7367 no + * producer could reach any of it: this shape refused the key. + * + * **Use it for the question the dialog is asking.** An action that collects + * params and ALSO sets `confirmText` shows the user two dialogs for one + * decision — the confirm, then the param prompt. The maintainer's 2026-08-10 + * ruling on #7278 is to carry the confirm question here instead: one + * condition, one wording, one dialog, nothing sent until its own Confirm. + * `confirmText` stays correct for a param-LESS action, where the confirm IS + * the only dialog. + * + * **Not `ai.description`.** That one is the LLM-facing tool contract + * (≥40 chars, required when `ai.exposed`); this one is human-facing dialog + * copy and is never sent to a model. + */ + description: I18nLabelSchema.optional().describe('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`.'), + /** Target object this action belongs to (optional, snake_case) */ objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object this action belongs to. When set, the action is auto-merged into the object\'s actions array by defineStack().'), @@ -944,6 +973,32 @@ const actionObject = () => strictObject({ * bare "expected array, received object" an author cannot act on. Sources * still carrying the object form are rewritten at load by the * `inline-action-api-params-to-body-extra` conversion (ADR-0087 D2). + * + * **The api prescription is not universal, which #6828 measured and the + * maintainer's 2026-08-10 ruling closed.** On a `type:'url'` action the + * object form meant a THIRD thing again — objectui's `ActionRunner` read a + * non-array `params` as the `${param.X}` interpolation scope for `target`, + * and `params.newTab` as a legacy new-tab flag. Sending that author to + * `bodyExtra` is a wrong instruction: an api request-body key is not an + * interpolation scope (the same asymmetry is why the conversion above guards + * on `type === 'api'` — rewriting a url action's object `params` would be + * lossy, and ADR-0087 D2 requires losslessness). The ruling **retired** the + * url meaning rather than giving it a key: the scope is already expressible + * as `target`-string interpolation, and the flag is already {@link openIn}. + * So the refusal below prescribes per action type — `bodyExtra` for `api`, + * the sanctioned url spellings for `url` — and nothing new enters the + * vocabulary. A future authorable interpolation-scope key needs a spec + * proposal that demonstrates pull, not a third arm of this one. + * + * The branch is stated IN THE TEXT rather than selected at runtime because + * zod cannot see a sibling from a property-level error map: the map receives + * only `{ code, expected, input, inst, path }` for the offending value, and + * an object-level `.check()`/`.superRefine()` — which would see `type` — is + * skipped once a property has already failed (probed on zod 4.4.3). Reading + * `type` here would mean restructuring `ActionSchema` behind a + * `z.preprocess`, which erases `z.input` (the authoring + * type `defineAction` publishes) — a far larger change than the guidance + * defect warrants, and one that moves surfaces this issue must not move. */ params: z.array(ActionParamSchema, { error: (iss) => ( @@ -951,8 +1006,11 @@ const actionObject = () => strictObject({ && iss.input !== null && typeof iss.input === 'object' && !Array.isArray(iss.input) - ? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not the request payload. " + ? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not a values map. " + "For a `type:'api'` action's static request body — including `{{page.}}` tokens — use `bodyExtra: { … }` instead (#5777). " + + "For a `type:'url'` action there is nowhere to move it to, by decision: put static values straight into the `target` string " + + "(`${param.X}` interpolates a value collected by the params dialog, `${ctx.X}` one from the action context), and open a new tab with " + + "`openIn: 'new-tab'`. The url-side readings of an object `params` — a static `${param.X}` scope, and `params.newTab` — are RETIRED, not renamed (#6828). " + 'Expected an array of ActionParam, received an object.' : undefined ),