@object-ui/core 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +1224 -0
  2. package/README.md +2 -12
  3. package/dist/actions/ActionEngine.d.ts +42 -25
  4. package/dist/actions/ActionEngine.js +66 -52
  5. package/dist/actions/ActionRunner.d.ts +199 -21
  6. package/dist/actions/ActionRunner.js +332 -16
  7. package/dist/actions/actionKeys.d.ts +96 -20
  8. package/dist/actions/actionKeys.js +157 -18
  9. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  10. package/dist/data-scope/ViewDataProvider.js +83 -5
  11. package/dist/data-scope/element-data-source.d.ts +113 -0
  12. package/dist/data-scope/element-data-source.js +216 -0
  13. package/dist/data-scope/index.d.ts +2 -1
  14. package/dist/data-scope/index.js +1 -0
  15. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  16. package/dist/evaluator/declaredPredicate.js +152 -0
  17. package/dist/evaluator/fieldRules.d.ts +16 -0
  18. package/dist/evaluator/fieldRules.js +51 -16
  19. package/dist/evaluator/index.d.ts +1 -0
  20. package/dist/evaluator/index.js +1 -0
  21. package/dist/evaluator/listConditional.d.ts +5 -2
  22. package/dist/evaluator/listConditional.js +58 -16
  23. package/dist/evaluator/predicateInput.d.ts +51 -3
  24. package/dist/evaluator/predicateInput.js +62 -5
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.js +14 -1
  27. package/dist/protocols/DndProtocol.d.ts +42 -1
  28. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  29. package/dist/protocols/SharingProtocol.d.ts +11 -1
  30. package/dist/registry/Registry.d.ts +24 -0
  31. package/dist/theme/ThemeEngine.d.ts +0 -8
  32. package/dist/theme/ThemeEngine.js +22 -103
  33. package/dist/theme/index.d.ts +1 -1
  34. package/dist/theme/index.js +7 -1
  35. package/dist/types/index.d.ts +42 -8
  36. package/dist/utils/chart-series.d.ts +258 -5
  37. package/dist/utils/chart-series.js +423 -20
  38. package/dist/utils/compare-to.d.ts +40 -13
  39. package/dist/utils/compare-to.js +4 -32
  40. package/dist/utils/dashboard-filters.d.ts +54 -8
  41. package/dist/utils/dashboard-filters.js +261 -12
  42. package/dist/utils/dataset-format.d.ts +73 -14
  43. package/dist/utils/dataset-format.js +130 -9
  44. package/dist/utils/dataset-pivot.d.ts +28 -7
  45. package/dist/utils/dataset-pivot.js +27 -6
  46. package/dist/utils/dom-props.d.ts +137 -0
  47. package/dist/utils/dom-props.js +153 -0
  48. package/dist/utils/normalize-list-view.d.ts +26 -6
  49. package/dist/utils/normalize-list-view.js +30 -8
  50. package/dist/utils/number-display.d.ts +151 -0
  51. package/dist/utils/number-display.js +71 -0
  52. package/dist/utils/resolve-view-id.d.ts +31 -0
  53. package/dist/utils/resolve-view-id.js +45 -0
  54. package/dist/utils/sort-query.d.ts +55 -0
  55. package/dist/utils/sort-query.js +35 -0
  56. package/package.json +5 -5
  57. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  58. package/dist/utils/merge-views-into-objects.js +0 -39
package/README.md CHANGED
@@ -141,21 +141,11 @@ This allows the core types and logic to be used in:
141
141
 
142
142
  ## API Reference
143
143
 
144
- See [full documentation](https://objectui.org/api/core) for detailed API reference.
145
-
146
- <!-- release-metadata:v3.3.0 -->
147
-
148
- ## Compatibility
149
-
150
- - **Node.js:** ≥ 18
151
- - **TypeScript:** ≥ 5.0 (strict mode)
152
- - **`@objectstack/spec`:** ^3.3.0
153
- - **`@objectstack/client`:** ^3.3.0
154
- - **Tailwind CSS:** ≥ 3.4 (for packages with UI)
144
+ See [full documentation](https://objectui.org/docs/api) for detailed API reference.
155
145
 
156
146
  ## Links
157
147
 
158
- - 📚 [Documentation](https://www.objectui.org/docs/core)
148
+ - 📚 [Documentation](https://www.objectui.org/docs/guide/architecture)
159
149
  - 📦 [npm package](https://www.npmjs.com/package/@object-ui/core)
160
150
  - 📝 [Changelog](./CHANGELOG.md)
161
151
  - 🐛 [Report an issue](https://github.com/objectstack-ai/objectui/issues)
@@ -8,9 +8,8 @@
8
8
  /**
9
9
  * @object-ui/core - Action Engine
10
10
  *
11
- * Declarative action dispatch engine. Manages action registration,
12
- * event-to-action mapping, location-based filtering, keyboard shortcuts,
13
- * and bulk operation support.
11
+ * Declarative action engine. Manages action registration, location-based
12
+ * filtering, keyboard shortcuts, and bulk operation support.
14
13
  *
15
14
  * Replaces callback-based patterns with a schema-driven pipeline:
16
15
  * ActionSchema[] → ActionEngine → ActionRunner → ActionResult
@@ -37,15 +36,6 @@ export interface RegisteredAction {
37
36
  /** Priority for ordering (lower = first) */
38
37
  priority?: number;
39
38
  }
40
- /** Event-to-action mapping */
41
- export interface ActionMapping {
42
- /** Event name (e.g., 'row:click', 'toolbar:save', 'keyboard:ctrl+s') */
43
- event: string;
44
- /** Action name to execute */
45
- actionName: string;
46
- /** Optional condition expression */
47
- condition?: string;
48
- }
49
39
  /** Keyboard shortcut handler */
50
40
  export interface ShortcutBinding {
51
41
  /** Key combination (e.g., 'ctrl+s', 'meta+k', 'shift+n') */
@@ -53,9 +43,35 @@ export interface ShortcutBinding {
53
43
  /** Action name to trigger */
54
44
  actionName: string;
55
45
  }
46
+ /**
47
+ * Registration + filtering + execution for declared actions.
48
+ *
49
+ * Entry points are by NAME (`executeAction`), by LOCATION
50
+ * (`getActionsForLocation`), by SHORTCUT (`handleShortcut`) and in BULK
51
+ * (`executeBulk`). There is deliberately no entry point by arbitrary EVENT
52
+ * name: this class used to carry a second, parallel one — `addMapping()`
53
+ * registered `{ event, actionName, condition }` triples into a private
54
+ * `mappings` array and `dispatch(event)` ran every triple matching an event
55
+ * string. It was retired under enforce-or-remove (objectui#3368) because it
56
+ * was a public export of `@object-ui/core` with zero production callers: in
57
+ * its whole lifetime nothing ever registered a mapping, so `dispatch()` had
58
+ * no reachable caller either, and its four call sites were all in this
59
+ * class's own test file.
60
+ *
61
+ * Its condition gate had drifted three ways from the `visible` contract
62
+ * `getActionsForLocation` below implements — it entered on a raw truthy check
63
+ * (so `condition: false` dispatched anyway), typed `condition` as `string`
64
+ * only (so a `{ dialect: 'cel', source }` envelope could not reach the
65
+ * canonical engine), and evaluated without `throwOnError` (so a throwing
66
+ * predicate failed OPEN, the opposite of `visible`'s fail-closed posture).
67
+ * All three were retired WITH the path rather than fixed on it: aligning the
68
+ * contract of an API nobody calls only widens behaviour nobody uses. If an
69
+ * event-keyed entry point is ever genuinely needed, it must be built on the
70
+ * shared predicate definitions (`hasDeclaredPredicate` + `toPredicateInput`)
71
+ * that the surviving gate below already uses, not on a fourth spelling.
72
+ */
56
73
  export declare class ActionEngine {
57
74
  private actions;
58
- private mappings;
59
75
  private shortcuts;
60
76
  private normalizedShortcutMap;
61
77
  private runner;
@@ -92,21 +108,24 @@ export declare class ActionEngine {
92
108
  registerActions(actions: ActionDef[]): void;
93
109
  /** Unregister an action by name */
94
110
  unregisterAction(name: string): void;
95
- /** Add an event-to-action mapping */
96
- addMapping(mapping: ActionMapping): void;
97
111
  /**
98
112
  * Get actions available at a specific location, sorted by priority.
99
113
  *
100
114
  * Filtering applied (in order):
101
115
  * 1. `locations.includes(location)` — location/region match
102
116
  * 2. `action.visible` — evaluated against the runner's current context
103
- * ({ record, recordId, objectName, user, … }). Missing or `true`
104
- * passes; any other value is coerced to boolean. Evaluator errors
105
- * hide the action (fail-closed) rather than throwing — this matches
106
- * the contract used by every individual action renderer
107
- * (`action-button`, `action-menu`, `action-bar`, …) so the same
108
- * action behaves identically whether surfaced via the engine or
109
- * consumed standalone.
117
+ * ({ record, recordId, objectName, user, … }) whenever a gate is
118
+ * DECLARED, which is asked through core's one definition
119
+ * `hasDeclaredPredicate` (objectui#3850's ruling, adopted here by
120
+ * objectui#3957). No gate — absent, `''`, blank predicate text in either
121
+ * spelling, an envelope with no evaluable `source`, or a value that is
122
+ * not a predicate at all — means visible. Evaluator errors hide the
123
+ * action (fail-closed) rather than throwing — this matches the contract
124
+ * used by every individual action renderer (`action-button`,
125
+ * `action-menu`, `action-bar`, …) so the same action behaves identically
126
+ * whether surfaced via the engine or consumed standalone, which is now
127
+ * true of the gate in front of the verdict as well as the verdict
128
+ * (objectui#3314: one value, one answer, whichever entry reads it).
110
129
  *
111
130
  * Predicate normalization goes through the shared `toPredicateInput`
112
131
  * (`@object-ui/core`'s canonical helper — semantically identical to
@@ -130,8 +149,6 @@ export declare class ActionEngine {
130
149
  getAction(name: string): ActionDef | undefined;
131
150
  /** Execute an action by name */
132
151
  executeAction(name: string, contextOverride?: Partial<ActionContext>): Promise<ActionResult>;
133
- /** Dispatch an event — finds mapped actions and executes them */
134
- dispatch(event: string, contextOverride?: Partial<ActionContext>): Promise<ActionResult[]>;
135
152
  /** Handle a keyboard shortcut event — returns true if handled */
136
153
  handleShortcut(keys: string, contextOverride?: Partial<ActionContext>): Promise<ActionResult | null>;
137
154
  /** Execute a bulk operation on multiple records */
@@ -146,6 +163,6 @@ export declare class ActionEngine {
146
163
  }>;
147
164
  /** Update the action context */
148
165
  updateContext(context: Partial<ActionContext>): void;
149
- /** Clear all registered actions and mappings */
166
+ /** Clear all registered actions and shortcuts */
150
167
  clear(): void;
151
168
  }
@@ -8,15 +8,15 @@
8
8
  /**
9
9
  * @object-ui/core - Action Engine
10
10
  *
11
- * Declarative action dispatch engine. Manages action registration,
12
- * event-to-action mapping, location-based filtering, keyboard shortcuts,
13
- * and bulk operation support.
11
+ * Declarative action engine. Manages action registration, location-based
12
+ * filtering, keyboard shortcuts, and bulk operation support.
14
13
  *
15
14
  * Replaces callback-based patterns with a schema-driven pipeline:
16
15
  * ActionSchema[] → ActionEngine → ActionRunner → ActionResult
17
16
  */
18
17
  import { ActionRunner } from './ActionRunner';
19
18
  import { toPredicateInput } from '../evaluator/predicateInput.js';
19
+ import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
20
20
  export { ACTION_LOCATIONS } from '@object-ui/types';
21
21
  /** Normalize a shortcut string for comparison (lowercase, trimmed, sorted parts) */
22
22
  function normalizeShortcut(keys) {
@@ -43,6 +43,33 @@ function warnHiddenPredicate(name, raw, err) {
43
43
  `Predicate: ${src}. Action predicates evaluate against { record, recordId, objectName, user, os.user, … }; ` +
44
44
  `use \`record.<field>\` (not a bare field).`);
45
45
  }
46
+ /**
47
+ * Registration + filtering + execution for declared actions.
48
+ *
49
+ * Entry points are by NAME (`executeAction`), by LOCATION
50
+ * (`getActionsForLocation`), by SHORTCUT (`handleShortcut`) and in BULK
51
+ * (`executeBulk`). There is deliberately no entry point by arbitrary EVENT
52
+ * name: this class used to carry a second, parallel one — `addMapping()`
53
+ * registered `{ event, actionName, condition }` triples into a private
54
+ * `mappings` array and `dispatch(event)` ran every triple matching an event
55
+ * string. It was retired under enforce-or-remove (objectui#3368) because it
56
+ * was a public export of `@object-ui/core` with zero production callers: in
57
+ * its whole lifetime nothing ever registered a mapping, so `dispatch()` had
58
+ * no reachable caller either, and its four call sites were all in this
59
+ * class's own test file.
60
+ *
61
+ * Its condition gate had drifted three ways from the `visible` contract
62
+ * `getActionsForLocation` below implements — it entered on a raw truthy check
63
+ * (so `condition: false` dispatched anyway), typed `condition` as `string`
64
+ * only (so a `{ dialect: 'cel', source }` envelope could not reach the
65
+ * canonical engine), and evaluated without `throwOnError` (so a throwing
66
+ * predicate failed OPEN, the opposite of `visible`'s fail-closed posture).
67
+ * All three were retired WITH the path rather than fixed on it: aligning the
68
+ * contract of an API nobody calls only widens behaviour nobody uses. If an
69
+ * event-keyed entry point is ever genuinely needed, it must be built on the
70
+ * shared predicate definitions (`hasDeclaredPredicate` + `toPredicateInput`)
71
+ * that the surviving gate below already uses, not on a fourth spelling.
72
+ */
46
73
  export class ActionEngine {
47
74
  /**
48
75
  * @param contextOrRunner Either an initial `ActionContext` (constructs a
@@ -60,12 +87,6 @@ export class ActionEngine {
60
87
  writable: true,
61
88
  value: new Map()
62
89
  });
63
- Object.defineProperty(this, "mappings", {
64
- enumerable: true,
65
- configurable: true,
66
- writable: true,
67
- value: []
68
- });
69
90
  Object.defineProperty(this, "shortcuts", {
70
91
  enumerable: true,
71
92
  configurable: true,
@@ -140,11 +161,6 @@ export class ActionEngine {
140
161
  }
141
162
  this.actions.delete(name);
142
163
  this.shortcuts = this.shortcuts.filter(s => s.actionName !== name);
143
- this.mappings = this.mappings.filter(m => m.actionName !== name);
144
- }
145
- /** Add an event-to-action mapping */
146
- addMapping(mapping) {
147
- this.mappings.push(mapping);
148
164
  }
149
165
  /**
150
166
  * Get actions available at a specific location, sorted by priority.
@@ -152,13 +168,18 @@ export class ActionEngine {
152
168
  * Filtering applied (in order):
153
169
  * 1. `locations.includes(location)` — location/region match
154
170
  * 2. `action.visible` — evaluated against the runner's current context
155
- * ({ record, recordId, objectName, user, … }). Missing or `true`
156
- * passes; any other value is coerced to boolean. Evaluator errors
157
- * hide the action (fail-closed) rather than throwing — this matches
158
- * the contract used by every individual action renderer
159
- * (`action-button`, `action-menu`, `action-bar`, …) so the same
160
- * action behaves identically whether surfaced via the engine or
161
- * consumed standalone.
171
+ * ({ record, recordId, objectName, user, … }) whenever a gate is
172
+ * DECLARED, which is asked through core's one definition
173
+ * `hasDeclaredPredicate` (objectui#3850's ruling, adopted here by
174
+ * objectui#3957). No gate — absent, `''`, blank predicate text in either
175
+ * spelling, an envelope with no evaluable `source`, or a value that is
176
+ * not a predicate at all — means visible. Evaluator errors hide the
177
+ * action (fail-closed) rather than throwing — this matches the contract
178
+ * used by every individual action renderer (`action-button`,
179
+ * `action-menu`, `action-bar`, …) so the same action behaves identically
180
+ * whether surfaced via the engine or consumed standalone, which is now
181
+ * true of the gate in front of the verdict as well as the verdict
182
+ * (objectui#3314: one value, one answer, whichever entry reads it).
162
183
  *
163
184
  * Predicate normalization goes through the shared `toPredicateInput`
164
185
  * (`@object-ui/core`'s canonical helper — semantically identical to
@@ -194,22 +215,36 @@ export class ActionEngine {
194
215
  })
195
216
  .filter(ra => {
196
217
  const raw = ra.action.visible;
197
- if (raw == null || raw === '' || raw === true)
218
+ // Ask "is a `visible` gate DECLARED?" through the repo's ONE definition
219
+ // (`evaluator/declaredPredicate.ts`, objectui#3850's ruling), which is
220
+ // what the renderers, `SchemaRenderer` and `ActionRunner`'s execution
221
+ // gates ask. This filter used to answer it with a range of its own —
222
+ // three empty spellings folded by hand (`null` / `''` / an envelope with
223
+ // an empty `source`) and everything else coerced with `Boolean(raw)` —
224
+ // and it was the LAST consumer to do so, so the same value got two
225
+ // answers depending on which entry read it: `visible: 0` / `NaN` hid the
226
+ // action here and showed it on the renderer face, and `visible: ' '`
227
+ // hid it here because the normalizer wraps a blank string into `'${ }'`
228
+ // whose verdict is falsy. That is the shape objectui#3314's invariant
229
+ // forbids, and it is fixed by deleting a range, not by adding one
230
+ // (objectui#3957). A value that is not a predicate, or a predicate that
231
+ // says nothing, must not be the reason an action is hidden from
232
+ // everyone — the same fail-open posture `ActionRunner` already committed
233
+ // to for `disabled` (`catch { isDisabled = false }`).
234
+ //
235
+ // Booleans need no branch of their own: they are DECLARED (`visible:
236
+ // false` is a verdict, objectui#3812) and `evaluateCondition`
237
+ // short-circuits them.
238
+ if (!hasDeclaredPredicate(raw))
198
239
  return true;
199
- if (raw === false)
200
- return false;
201
240
  // #3314 — one shared normalization, not two. Hand-rolling the envelope
202
241
  // unwrap here is what dropped `dialect: 'cel'` and demoted the
203
242
  // predicate to the legacy JS engine; `toPredicateInput` keeps the
204
243
  // envelope so `evaluateCondition` can route it to `@objectstack/formula`
205
244
  // (the engine the server enforces with), exactly as the renderers do.
245
+ // Declared ⇒ the normalizer left something to evaluate, so there is no
246
+ // `undefined` case left to handle here.
206
247
  const expr = toPredicateInput(raw);
207
- if (expr === undefined) {
208
- // Not an evaluable predicate: an envelope with an empty `source`
209
- // (→ nothing declared → visible), or a stray non-predicate value,
210
- // which keeps the historical `Boolean(raw)` coercion.
211
- return typeof raw === 'object' ? true : Boolean(raw);
212
- }
213
248
  try {
214
249
  // `throwOnError: true` is required for fail-closed semantics: the
215
250
  // evaluator's default behavior swallows expression errors and
@@ -262,26 +297,6 @@ export class ActionEngine {
262
297
  }
263
298
  return this.runner.execute(registered.action);
264
299
  }
265
- /** Dispatch an event — finds mapped actions and executes them */
266
- async dispatch(event, contextOverride) {
267
- const matchingMappings = this.mappings.filter(m => m.event === event);
268
- if (matchingMappings.length === 0) {
269
- return [];
270
- }
271
- const results = [];
272
- for (const mapping of matchingMappings) {
273
- // Check condition if present
274
- if (mapping.condition) {
275
- const evaluator = this.runner.getEvaluator();
276
- const shouldRun = evaluator.evaluateCondition(mapping.condition);
277
- if (!shouldRun)
278
- continue;
279
- }
280
- const result = await this.executeAction(mapping.actionName, contextOverride);
281
- results.push(result);
282
- }
283
- return results;
284
- }
285
300
  /** Handle a keyboard shortcut event — returns true if handled */
286
301
  async handleShortcut(keys, contextOverride) {
287
302
  const actionName = this.normalizedShortcutMap.get(normalizeShortcut(keys));
@@ -341,10 +356,9 @@ export class ActionEngine {
341
356
  updateContext(context) {
342
357
  this.runner.updateContext(context);
343
358
  }
344
- /** Clear all registered actions and mappings */
359
+ /** Clear all registered actions and shortcuts */
345
360
  clear() {
346
361
  this.actions.clear();
347
- this.mappings = [];
348
362
  this.shortcuts = [];
349
363
  this.normalizedShortcutMap.clear();
350
364
  }
@@ -5,7 +5,22 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import type { ActionInput as SpecActionInput } from '@objectstack/spec/ui';
8
+ /**
9
+ * @object-ui/core - Action Runner
10
+ *
11
+ * Executes actions defined in ActionSchema and EventHandler.
12
+ *
13
+ * Dispatches every `@objectstack/spec` `ActionType` — `script`, `url`, `modal`,
14
+ * `flow`, `api`, `form` — plus the one declared renderer-local alias,
15
+ * `navigation` (see `ObjectUiLocalActionType`). The table that does it is typed
16
+ * against that vocabulary, so this list cannot go stale the way the old comment
17
+ * had (it still claimed five types after `form` shipped).
18
+ *
19
+ * Features: conditional execution, confirmation, toast notifications,
20
+ * redirect handling, action chaining, custom handler registration.
21
+ */
22
+ import type { UIActionSchema } from '@object-ui/types';
23
+ import type { Action as SpecActionInput } from '@objectstack/spec/ui';
9
24
  import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
10
25
  import { type UndoableOperation } from './UndoManager';
11
26
  export interface ActionResult {
@@ -61,6 +76,30 @@ export interface ApiConfig {
61
76
  /**
62
77
  * Action definition accepted by the runner.
63
78
  * Compatible with both UIActionSchema (spec v2.0.1) and legacy crud.ts ActionSchema.
79
+ *
80
+ * ── CLOSED since objectstack#4075 step 3 ──────────────────────────────────────
81
+ *
82
+ * This interface used to end with `[key: string]: any`, so it accepted any key
83
+ * of any type: deleting `execute` from it produced ZERO compile errors, a typo
84
+ * (`targt`, `exectue`) type-checked, and a tombstoned key sailed through to a
85
+ * runner that then silently did nothing — the objectstack#2169 "Mark Done does
86
+ * nothing" shape. The index signature is gone. Every key this type accepts is
87
+ * now written down below, and `tsc` rejects the rest at any site that AUTHORS an
88
+ * action literal in code.
89
+ *
90
+ * What it does NOT reach, and what still covers that: stored `sys_metadata` rows
91
+ * are rehydrated UNPARSED (objectstack#3903), so authored metadata arrives at
92
+ * `runner.execute()` as a plain object the compiler never saw. The dev-mode
93
+ * `warnOnUnknownActionKeys` shim from step 1 therefore STAYS — it is not
94
+ * redundant with the closed type, it is the other half of the same surface, and
95
+ * the two halves catch disjoint populations (code-authored literals vs. stored
96
+ * rows).
97
+ *
98
+ * {@link ActionContext} keeps ITS index signature, deliberately. It is a runtime
99
+ * data bag whose keys are genuinely open (`data`, `record`, `pageVariables`,
100
+ * `user`, plus whatever a host passes); this is a declared metadata contract
101
+ * mirroring `@objectstack/spec`'s `ActionSchema`. Open key set on a data bag is
102
+ * correct; open key set on a contract is the bug above.
64
103
  */
65
104
  export interface ActionDef {
66
105
  /** Action type identifier — a `RunnableActionType` (the spec's six plus the
@@ -77,6 +116,24 @@ export interface ActionDef {
77
116
  name?: string;
78
117
  /** Display label */
79
118
  label?: string;
119
+ /**
120
+ * Human description of the action, shown as the param-collection dialog's
121
+ * subtitle (`useConsoleActionRuntime` reads it beside `label`).
122
+ *
123
+ * objectui vocabulary, not spec: `@objectstack/spec`'s `ActionSchema` has no
124
+ * `description`, so this derives from `@object-ui/types`' renderer view of an
125
+ * action (`UIActionSchema`, `ui-action.ts`) — the same "authorable for a
126
+ * declared surface" source `check:action-forward-parity` derives its owed set
127
+ * from, which is why that gate already requires all four action renderers to
128
+ * forward this key (objectui#4192: `action:menu`'s dialog titled itself
129
+ * "Action parameters" instead of naming the action).
130
+ *
131
+ * Promoted by step 3 (objectstack#4075): the key was authorable, forwarded
132
+ * and read, but `ActionDef` never declared it, so it reached its reader only
133
+ * through `[key: string]: any`. Deleting the index signature is what surfaced
134
+ * it — the two TS2353s the deletion produced repo-wide were both this key.
135
+ */
136
+ description?: UIActionSchema['description'];
80
137
  /** Confirmation text — shows a confirm dialog before executing */
81
138
  confirmText?: string;
82
139
  /** Structured confirmation (from crud.ts) */
@@ -86,10 +143,31 @@ export interface ActionDef {
86
143
  confirmText?: string;
87
144
  cancelText?: string;
88
145
  };
89
- /** Condition expression — if falsy, skip action */
90
- condition?: string;
91
- /** Disabled expression — if truthy, skip action */
92
- disabled?: string | boolean;
146
+ /**
147
+ * Condition predicate — the action executes only while it holds.
148
+ *
149
+ * A boolean, a bare CEL string, a `${…}` template, or the normalized
150
+ * `{ dialect, source }` envelope `objectstack build` emits. Declared and
151
+ * FALSE skips the action; not declared at all (absent / empty predicate)
152
+ * executes. `boolean` is spelled out here because the execution gate now
153
+ * honours `condition: false` as the verdict it plainly is (objectui#3872) —
154
+ * the same widening `disabled` already carries below.
155
+ */
156
+ condition?: string | boolean;
157
+ /**
158
+ * Disabled predicate — the control renders inert while it holds.
159
+ *
160
+ * Same three arms as {@link ActionDef.visible}, derived from the same spec
161
+ * field, because the 2026-08-06 maintainer ruling on objectstack#4075 gave
162
+ * both keys ONE shape: `boolean | string(CEL) | { dialect, source }` —
163
+ * "boolean = 条件的退化形字面量,string = CEL 简写,信封 = 完整形". The
164
+ * envelope arm arrived in `@objectstack/spec` 17.0.0-rc.6 (objectstack#5970,
165
+ * PR objectstack#6450); until then this was a hand-written `string | boolean`
166
+ * that could not describe the envelope, which is why `DeclaredActionsBar`
167
+ * read it through an `(action as any).disabled` cast. Derived, not restated,
168
+ * so the two keys cannot drift apart again.
169
+ */
170
+ disabled?: SpecActionInput['disabled'];
93
171
  /**
94
172
  * API endpoint (string URL or complex config).
95
173
  *
@@ -121,6 +199,19 @@ export interface ActionDef {
121
199
  * today (objectstack#4075 step 2).
122
200
  */
123
201
  navigate?: any;
202
+ /** `navigation` alias: the target URL. `executeNavigation` resolves
203
+ * `to || target || redirect`, so a flat spec-style `target` also works. */
204
+ to?: string;
205
+ /** `navigation` alias: force the external-URL treatment that the scheme
206
+ * heuristic would otherwise decide. */
207
+ external?: boolean;
208
+ /** `navigation` alias: open in a new tab. The first-class spec switch is
209
+ * `openIn: 'new-tab'`, which WINS over this one — prefer it in new metadata.
210
+ * Unrelated to the retired `params.newTab` read (objectui#4097). */
211
+ newTab?: boolean;
212
+ /** `navigation` alias: replace the current history entry instead of pushing
213
+ * a new one. No `ActionSchema` counterpart in any spelling. */
214
+ replace?: boolean;
124
215
  /** onClick callback (legacy) */
125
216
  onClick?: () => void | Promise<void>;
126
217
  /** Whether to reload data after success */
@@ -213,16 +304,23 @@ export interface ActionDef {
213
304
  /**
214
305
  * Visibility predicate, evaluated against the runner's context.
215
306
  *
216
- * The `boolean` arm is objectui's own, deliberately wider than the spec:
217
- * `ActionSchema.visible` admits only a CEL string or a `{ dialect, source }`
218
- * envelope, while `ActionEngine.getActionsForLocation` also honours a literal
219
- * `visible: true` / `false` (pinned by `ActionEngine.visibility.test.ts`).
220
- * Declared rather than left to the index signature so the tolerance is
221
- * auditable instead of invisible — but it IS a dialect, and objectstack#4075
222
- * step 3 has to decide whether the spec adopts the boolean or objectui drops
223
- * it. Do not widen this further.
224
- */
225
- visible?: SpecActionInput['visible'] | boolean;
307
+ * Three arms — `boolean | string(CEL) | { dialect, source }` — and all three
308
+ * now come from the spec. Step 2 had to write `SpecActionInput['visible'] |
309
+ * boolean` because the literal `visible: true` / `false` that
310
+ * `ActionEngine.getActionsForLocation` honours (pinned by
311
+ * `ActionEngine.visibility.test.ts`) was objectui tolerance the spec did not
312
+ * share, and it left the open question step 3 was blocked on: does the spec
313
+ * adopt the boolean, or does objectui drop it?
314
+ *
315
+ * The maintainer ruled ADOPT on 2026-08-06 (objectstack#4075): "统一形状,
316
+ * spec 采纳 —— `visible` / `disabled` 两键在 spec 侧统一收敛为
317
+ * `boolean | string(CEL) | {dialect, source}`(boolean = 条件的退化形字面量,
318
+ * string = CEL 简写,信封 = 完整形)". `@objectstack/spec` 17.0.0-rc.6 carries
319
+ * it (objectstack#5970, PR objectstack#6450), so the local `| boolean` is no
320
+ * longer a tolerance to declare — it is part of the derived type, and adding
321
+ * it back would restate a spec arm rather than widen anything.
322
+ */
323
+ visible?: SpecActionInput['visible'];
226
324
  /** System permissions the caller must ALL hold for the action to be offered
227
325
  * (ADR-0066 D4 UI half — the server enforces the source of truth). */
228
326
  requiredPermissions?: SpecActionInput['requiredPermissions'];
@@ -268,8 +366,6 @@ export interface ActionDef {
268
366
  * remains available to a HOST passing it explicitly.
269
367
  */
270
368
  bulkEnabled?: SpecActionInput['bulkEnabled'];
271
- /** Any additional properties */
272
- [key: string]: any;
273
369
  }
274
370
  /**
275
371
  * A handler registered with {@link ActionRunner} — the client-side action
@@ -351,6 +447,31 @@ export interface ResultDialogSpec {
351
447
  * cleanup purposes (the action already succeeded).
352
448
  */
353
449
  export type ResultDialogHandler = (spec: ResultDialogSpec, data: unknown, action?: ActionDef) => Promise<void>;
450
+ /**
451
+ * One entry of a param's option list, as the RESOLVED param carries it.
452
+ *
453
+ * `label` / `value` are the two keys this layer itself reads and rewrites (i18n
454
+ * translation of the label, value matching). Everything else an option declares
455
+ * rides along in the catch-all and reaches the widgets untouched: per-option
456
+ * `visibleWhen` (the CEL predicate `resolveVisibleOptions()` filters on —
457
+ * ADR-0058 / #2284), `color`, `icon`, `disabled`.
458
+ *
459
+ * The catch-all is deliberate rather than a closed key list (objectui#3559).
460
+ * A field's option vocabulary is owned by the field metadata (`@objectstack/spec`'s
461
+ * `SelectOptionSchema`) and read by the option widgets; a param's option list is
462
+ * only a CONDUIT between the two. When this type restated that vocabulary as
463
+ * `{ label, value }`, the resolver dutifully rebuilt every inherited option to
464
+ * match and a field's `visibleWhen` stopped narrowing the dialog's list — the
465
+ * same field metadata behaving two ways on two surfaces. Naming the two keys
466
+ * this layer uses and passing the rest through is what keeps that from
467
+ * recurring; it is NOT an invitation to author new resolved-only option keys
468
+ * (the spec's schema is the authoring gate, and it is `.strict()`).
469
+ */
470
+ export type ActionParamOption = {
471
+ label: string;
472
+ value: string;
473
+ [key: string]: unknown;
474
+ };
354
475
  /**
355
476
  * ActionParam definition accepted by the runner.
356
477
  * Compatible with @objectstack/spec ActionParam.
@@ -373,10 +494,7 @@ export interface ActionParamDef {
373
494
  label: string;
374
495
  type: string;
375
496
  required?: boolean;
376
- options?: Array<{
377
- label: string;
378
- value: string;
379
- }>;
497
+ options?: ActionParamOption[];
380
498
  defaultValue?: unknown;
381
499
  helpText?: string;
382
500
  placeholder?: string;
@@ -543,6 +661,37 @@ export declare class ActionRunner {
543
661
  * through to `executeActionSchema` and silently no-opped (the "Log Time does
544
662
  * nothing" report). The current record id is forwarded as `?recordId=` for
545
663
  * hosts that support it; the form route ignores unknown query params.
664
+ *
665
+ * ## An id never travels without its object (objectui#4292)
666
+ *
667
+ * `?recordId=` names an id and nothing else, so the consumer has to pick an
668
+ * object to resolve it against, and the only one it can pick is the FormView's
669
+ * own target object (`/forms/:name` does exactly that since objectui#4278).
670
+ * That is right whenever the action fired from a record OF that object, and
671
+ * nothing here used to check that it did: `target: 'showcase_task.edit'` on an
672
+ * Account record header is publishable metadata, and with per-table integer
673
+ * keys `GET /data/showcase_task/42` cheerfully answers for Account 42. Before
674
+ * #4278 the damage stopped at a duplicate insert; now that the route honours
675
+ * the param, the same mis-scoped action PATCHes a different object's record
676
+ * with no error anywhere. So the id is forwarded only when the two objects
677
+ * MATCH, and the object identity rides along as the consumer's second half of
678
+ * the check ({@link FORM_RECORD_OBJECT_PARAM}).
679
+ *
680
+ * The three outcomes, and why the third preserves rather than refuses:
681
+ *
682
+ * - **match** — forward `?recordId=&recordObject=`. The #4278 edit path,
683
+ * plus the identity the consumer re-checks against its own view metadata;
684
+ * - **mismatch** — forward NEITHER, i.e. the bare `/forms/:name`. A "log
685
+ * time" action fired from a Task at another object's create view is not a
686
+ * broken edit, it is a CREATE that happens to be launched from a record —
687
+ * which is exactly what this route did before #4278 taught it to read the
688
+ * param. Refusing here would break a shape that works;
689
+ * - **not comparable** — either end unknown ⇒ forward as before. A host that
690
+ * publishes no `objectName`, or an unqualified target name, gives this
691
+ * runner nothing to compare; tightening on absent information would turn
692
+ * working edit flows into creates on every such host. The guard only ever
693
+ * fires on information it actually has, and the consumer half applies the
694
+ * same rule to a missing `recordObject`.
546
695
  */
547
696
  private executeForm;
548
697
  /**
@@ -556,6 +705,35 @@ export declare class ActionRunner {
556
705
  * drift apart again. Prefer `type: 'url'` + `openIn` in new metadata.
557
706
  */
558
707
  private executeNavigation;
708
+ /**
709
+ * Assemble the request body for the runner's OWN `executeAPI` — the path taken
710
+ * when no host registered an `api` handler.
711
+ *
712
+ * `bodyExtra` is merged LAST, which is the spec's documented semantics for the
713
+ * key ("Static request-body fields for a `type:'api'` action, merged last
714
+ * (overrides user params)") and what the console's `apiHandler` already does on
715
+ * both of its branches. Before objectstack#6837 this path read `action.params`
716
+ * alone and never looked at `bodyExtra` at all, so an action whose payload lives
717
+ * entirely in `bodyExtra` — the shape the ADR-0087 conversion produces from an
718
+ * old object-form `params` page — POSTed an empty body here.
719
+ *
720
+ * `params` contributes only in its DEPRECATED non-array form (the compat window
721
+ * #5777 opened; see `warnOnDeprecatedObjectParams`). An ARRAY `params` is a
722
+ * parameter DEFINITION list, not a payload: it reaches this method unconsumed
723
+ * only when no `paramCollectionHandler` is mounted, and POSTing the definitions
724
+ * as the request body was never a payload any endpoint wanted. It now falls
725
+ * through to the context record like an absent `params` does.
726
+ *
727
+ * `bodyShape` then decides how that payload is WRAPPED, and the wrap covers the
728
+ * collected payload ONLY — `bodyExtra` stays at the top level. That is the
729
+ * spec's own wording for the key ("`{ wrap: 'data' }` nests the user-collected
730
+ * params under that key, while `recordIdParam` and other top-level keys stay
731
+ * flat") and what both console read-sites already do, so the key means one
732
+ * thing on every path that honours it. Until objectstack#6938 this path did not
733
+ * read it at all: the console `apiHandler` wrapped and the runner's own fallback
734
+ * did not, so the same action changed body shape with the host it ran under.
735
+ */
736
+ private buildApiRequestBody;
559
737
  /**
560
738
  * Execute API action — supports both simple string endpoint and complex ApiConfig.
561
739
  */