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
29 changes: 29 additions & 0 deletions .changeset/28519-action-cancel-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/spec': minor
---

feat(spec): an action's confirm and param dialogs can name their dismiss button — `cancelText`

Clause-②: yes (widening)
Metadata-protocol: added

`ActionSchema` gains one optional key, `cancelText`: the label of the dismiss button on the dialog an action opens before it runs. The button says "Cancel" today for every action. That reads badly when the action's own verb is "Cancel …" or "Close …": a "Cancel Invitation" confirm whose two buttons read "Cancel" and "Cancel Invitation" leaves the user unable to tell which one keeps the invitation. With the key, the action names what dismissing keeps. An action that does not declare it parses exactly as before.

```ts
defineAction({
name: 'cancel_invitation',
label: 'Cancel Invitation',
objectName: 'sys_invitation',
type: 'api',
target: '/api/v1/auth/organization/cancel-invitation',
confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.',
cancelText: 'Keep invitation',
});
```

- **An `I18nLabel`.** A plain string or an inline locale map (`{ en, 'zh-CN' }`). A blank string is refused at parse: `''` or whitespace only. Omit the key to keep "Cancel". `cancelLabel` (the same button's spelling on the console's `form` and `alert-dialog` renderers) is refused with a rename to `cancelText`, on the action and in a translation bundle.
- **Either pre-run dialog.** It labels the dismiss button of the confirm dialog a param-less action with `confirmText` opens, and of the param dialog an action with a non-empty `params` opens, beside that dialog's `submitText`. An action that declares neither opens no dialog, so `cancelText` there is refused at parse, naming the key.
- **Translated like `submitText`.** The slot is `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an action with no `objectName`). `translateAction` overlays it for an action that opens either dialog, and never gives a button to an action that opens none.
- **The name.** `cancelText` is the protocol's existing word for a dismiss button's copy: the `object-form` component's `cancelText`, beside its `submitText`.

Nothing draws the key yet. The console's confirm and param dialogs read it in a follow-up change. Until then the liveness ledger marks it `planned` with `authorWarn`, so `os validate` warns an author who declares it that the button still says "Cancel".
8 changes: 8 additions & 0 deletions .changeset/28519-action-keys-cancel-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/core': patch
---

`SPEC_ACTION_KEYS` lists `cancelText`, the key `@objectstack/spec` adds to `ActionSchema` for the dismiss button label of an action's confirm and param dialogs. An action that carries it is not counted as having an unknown key, and the inventory pin stays green.

Clause-②: no
Metadata-protocol: none
8 changes: 8 additions & 0 deletions .changeset/28519-lint-cancel-text-translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@objectstack/lint': patch
---

`validateTranslationReferences` judges an action translation's new `cancelText` key (`@objectstack/spec`'s dismiss label of an action's confirm and param dialogs). Under an action that declares neither `confirmText` nor `params`, at `objects.OBJECT._actions.ACTION.cancelText` or `globalActions.ACTION.cancelText`, it reports `translation-target-unknown` at `error`: such an action opens no dialog, so nothing reads the label. Under an action that declares either the key is accepted.

Clause-②: no
Metadata-protocol: none
21 changes: 21 additions & 0 deletions content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ interface Action {
body?: HookBody; // Inline logic for type: 'script'
params?: ActionParam[]; // Inputs collected before execution
submitText?: I18nLabel; // The param dialog's submit button verb (default "Confirm")
cancelText?: I18nLabel; // The confirm or param dialog's dismiss button label (default "Cancel")
method?: 'POST' | 'PATCH' | 'PUT' | 'DELETE'; // for type: 'api'

// UX
Expand Down Expand Up @@ -539,6 +540,26 @@ params:
- `submitLabel` and `confirmLabel` (the button's spelling on a view's `bulkActionDefs`) are refused with a rename to `submitText`.
- The console does not draw it yet: until its param dialog reads the key, the button says **Confirm**, and `os validate` warns an author who declares it.

### Naming the dismiss button: `cancelText`

The dialog an action opens before it runs, its confirm dialog or its param dialog, has a dismiss button that says **Cancel**. That reads badly when the action's own verb is "Cancel …" or "Close …": a **Cancel Invitation** confirm whose two buttons read **Cancel** and **Cancel Invitation** leaves the user unable to tell which one keeps the invitation. `cancelText` names what dismissing keeps.

```yaml
name: cancel_invitation
label: Cancel Invitation
objectName: sys_invitation
type: api
target: /api/v1/auth/organization/cancel-invitation
confirmText: Cancel this invitation? The recipient will no longer be able to accept it.
cancelText: Keep invitation
```

- It is an `I18nLabel`: a plain string, translated at `objects.<object>._actions.<action>.cancelText` (`globalActions.<action>.cancelText` for an action with no `objectName`), or an inline locale map.
- **Either pre-run dialog.** It labels the dismiss button of the confirm dialog a param-less action with `confirmText` opens, and of the param dialog an action with a non-empty `params` opens (beside that dialog's `submitText`). An action that declares neither opens no dialog, so `cancelText` there is refused at parse, and a translation of it under such an action is reported by `os validate` (`translation-target-unknown`).
- A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep **Cancel**.
- `cancelLabel` (the button's spelling on the console's `form` and `alert-dialog` renderers) is refused with a rename to `cancelText`.
- The console does not draw it yet: until its confirm and param dialogs read the key, the button says **Cancel**, and `os validate` warns an author who declares it.

## AI Tool Exposure (ADR-0011)

An action can be exposed as an AI-callable tool. Exposure is **opt-in and default off** — set `ai.exposed: true`, which then **requires** `ai.description` (≥ 40 chars, the LLM-facing contract).
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ const result = ApiMethod.parse(data);
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['<name>']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. |
| **submitText** | `string \| Record<string, string>` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. |
| **cancelText** | `string \| Record<string, string>` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel" (declared ahead of its console reader: no renderer shows it yet). Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ const result = MetadataBulkResultSchema.parse(data);
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['<name>']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. |
| **submitText** | `string \| Record<string, string>` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. |
| **cancelText** | `string \| Record<string, string>` | optional | Label of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares `confirmText`, or the param dialog of an action with a non-empty `params` — in place of the default "Cancel" (declared ahead of its console reader: no renderer shows it yet). Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.cancelText` (`globalActions.ACTION.cancelText` for an object-less action). Only on an action that opens one of those dialogs: with neither `confirmText` nor a non-empty `params` no dialog opens, so it is refused. The dismiss twin of `submitText`, as on `object-form`. A blank label is refused. |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. |
| **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record<string, string>` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
Expand Down
Loading
Loading