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