@object-ui/core 16.0.0 → 17.1.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 (56) hide show
  1. package/CHANGELOG.md +1589 -1
  2. package/dist/actions/ActionRunner.d.ts +88 -16
  3. package/dist/actions/ActionRunner.js +164 -81
  4. package/dist/actions/actionKeys.d.ts +118 -0
  5. package/dist/actions/actionKeys.js +243 -0
  6. package/dist/actions/index.d.ts +1 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/adapters/ApiDataSource.d.ts +11 -1
  9. package/dist/adapters/ApiDataSource.js +21 -1
  10. package/dist/adapters/ValueDataSource.d.ts +10 -2
  11. package/dist/adapters/ValueDataSource.js +9 -0
  12. package/dist/adapters/batchTransaction.d.ts +49 -0
  13. package/dist/adapters/batchTransaction.js +118 -0
  14. package/dist/adapters/index.d.ts +1 -0
  15. package/dist/adapters/index.js +1 -0
  16. package/dist/evaluator/ExpressionEvaluator.d.ts +10 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +48 -0
  18. package/dist/evaluator/SafeExpressionParser.js +5 -2
  19. package/dist/evaluator/fieldRules.d.ts +0 -2
  20. package/dist/evaluator/fieldRules.js +3 -2
  21. package/dist/evaluator/optionLint.js +5 -2
  22. package/dist/evaluator/optionRules.d.ts +35 -6
  23. package/dist/evaluator/optionRules.js +18 -23
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.js +8 -0
  26. package/dist/registry/Registry.d.ts +81 -2
  27. package/dist/registry/Registry.js +102 -2
  28. package/dist/registry/public-blocks.js +52 -1
  29. package/dist/theme/ThemeEngine.js +0 -12
  30. package/dist/utils/chart-series.d.ts +28 -0
  31. package/dist/utils/chart-series.js +55 -0
  32. package/dist/utils/column-identity.d.ts +110 -0
  33. package/dist/utils/column-identity.js +210 -0
  34. package/dist/utils/dashboard-filters.d.ts +2 -2
  35. package/dist/utils/dataset-format.d.ts +18 -1
  36. package/dist/utils/dataset-format.js +14 -1
  37. package/dist/utils/expand-fields.js +8 -8
  38. package/dist/utils/export-filename.js +4 -1
  39. package/dist/utils/filter-converter.d.ts +47 -0
  40. package/dist/utils/filter-converter.js +94 -7
  41. package/dist/utils/filter-tokens.d.ts +101 -0
  42. package/dist/utils/filter-tokens.js +173 -0
  43. package/dist/utils/managedBy.d.ts +119 -0
  44. package/dist/utils/managedBy.js +147 -0
  45. package/dist/utils/normalize-list-view.d.ts +99 -0
  46. package/dist/utils/normalize-list-view.js +255 -0
  47. package/dist/utils/record-title.js +0 -0
  48. package/dist/utils/sort-values.d.ts +52 -0
  49. package/dist/utils/sort-values.js +125 -0
  50. package/dist/validation/index.d.ts +9 -1
  51. package/dist/validation/index.js +9 -1
  52. package/dist/validation/validators/index.d.ts +7 -1
  53. package/dist/validation/validators/index.js +7 -1
  54. package/dist/validation/validators/object-validation-engine.d.ts +151 -31
  55. package/dist/validation/validators/object-validation-engine.js +275 -142
  56. package/package.json +4 -4
@@ -5,14 +5,6 @@
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
- /**
9
- * @object-ui/core - Action Runner
10
- *
11
- * Executes actions defined in ActionSchema and EventHandler.
12
- * Supports all spec v2.0.1 action types: script, url, modal, flow, api.
13
- * Features: conditional execution, confirmation, toast notifications,
14
- * redirect handling, action chaining, custom handler registration.
15
- */
16
8
  import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
17
9
  import { type UndoableOperation } from './UndoManager';
18
10
  export interface ActionResult {
@@ -70,7 +62,8 @@ export interface ApiConfig {
70
62
  * Compatible with both UIActionSchema (spec v2.0.1) and legacy crud.ts ActionSchema.
71
63
  */
72
64
  export interface ActionDef {
73
- /** Action type identifier: 'script' | 'url' | 'modal' | 'flow' | 'api' | 'navigation' | custom */
65
+ /** Action type identifier — a `RunnableActionType` (the spec's six plus the
66
+ * `navigation` alias of `url`) or a custom type with a registered handler. */
74
67
  type?: string;
75
68
  /** Legacy action type field */
76
69
  actionType?: string;
@@ -131,10 +124,33 @@ export interface ActionDef {
131
124
  * reveal dialog rendering `result.data` (see ResultDialogSpec).
132
125
  */
133
126
  resultDialog?: ResultDialogSpec;
134
- /** Script/expression to execute (for type: 'script') */
135
- execute?: string;
136
- /** Target URL or identifier (for type: 'url', 'modal', 'flow') */
127
+ /**
128
+ * The one handler slot: script name/expression for `type: 'script'`, URL for
129
+ * `'url'`, flow name for `'flow'`, modal/page for `'modal'`, endpoint for
130
+ * `'api'`, FormView name for `'form'`.
131
+ *
132
+ * The `execute` alias was REMOVED in @objectstack/spec 17 (#3855) and is not
133
+ * read here (#3856) — don't re-add it. Two handler slots is how one action ran
134
+ * one script server-side and a different one client-side (#3713).
135
+ */
137
136
  target?: string;
137
+ /**
138
+ * Action body (spec `ActionSchema.body` — `HookBodySchema`). Opaque here:
139
+ * bodies are a SERVER-side execution surface and this runner never
140
+ * interprets one.
141
+ *
142
+ * L2 (`language: 'js'`) is defined as a function body run inside an isolated
143
+ * VM enforcing declared capabilities, `timeoutMs` and `memoryMb`. A browser
144
+ * has no such isolate, so "enforcing" those client-side would be decoration.
145
+ * L1 (`language: 'expression'`) is formula-engine (CEL) source, a different
146
+ * dialect from this package's `${…}` ExpressionEvaluator — running it here
147
+ * would diverge silently rather than fail.
148
+ *
149
+ * Consumers dispatch bodies by registering a `script` handler that POSTs to
150
+ * `/api/v1/actions/{object}/{action}` (see app-shell's
151
+ * `useConsoleActionRuntime`); the server runs the body through its sandbox.
152
+ */
153
+ body?: unknown;
138
154
  /** For type: 'url' — where to open `target`. `'new-tab'` forces a new
139
155
  * browser tab/window, `'self'` forces same-page navigation. When omitted,
140
156
  * external URLs open in a new tab and relative URLs navigate in place.
@@ -258,6 +274,12 @@ export interface ActionParamDef {
258
274
  * gated on `features.phoneNumber`). Absent = always visible.
259
275
  */
260
276
  visible?: string;
277
+ /** Accepted upload types (MIME types / extensions) for `file`/`image` params. */
278
+ accept?: string[];
279
+ /** Max upload size in bytes for `file`/`image` params. */
280
+ maxSize?: number;
281
+ /** Allow multiple values (file/image/lookup/user params → array value). */
282
+ multiple?: boolean;
261
283
  /** Object name the lookup picker queries (`reference_to` on the field). */
262
284
  referenceTo?: string;
263
285
  /** Field on the referenced record used as the human label (default `name`). */
@@ -266,8 +288,6 @@ export interface ActionParamDef {
266
288
  idField?: string;
267
289
  /** Optional secondary line shown under the label in the picker. */
268
290
  descriptionField?: string;
269
- /** Allow multi-select (renders chip list). */
270
- multiple?: boolean;
271
291
  /** Template (e.g. `{first_name} {last_name}`) used to build the label. */
272
292
  titleFormat?: string;
273
293
  /** Column definitions surfaced in the enterprise `RecordPickerDialog`. */
@@ -290,6 +310,21 @@ export declare class ActionRunner {
290
310
  private navigationHandler;
291
311
  private paramCollectionHandler;
292
312
  private resultDialogHandler;
313
+ /**
314
+ * Built-in dispatch, one entry per runnable action type.
315
+ *
316
+ * A table rather than a `switch` because the type annotation is the guard:
317
+ * `Record<RunnableActionType, …>` stops compiling the moment
318
+ * `@objectstack/spec` adds an `ActionType` this runner has no executor for.
319
+ * That is the whole Tier-2 failure class for actions — a spec name that
320
+ * validates at save and then does nothing at run time (#2942) — converted
321
+ * into a build error. It also rejects the reverse: an executor keyed by a
322
+ * name that is neither in the spec nor a declared local alias.
323
+ *
324
+ * `navigation` is that declared alias (see `ObjectUiLocalActionType`); it
325
+ * shares `url`'s navigator.
326
+ */
327
+ private readonly builtinExecutors;
293
328
  constructor(context?: ActionContext);
294
329
  /**
295
330
  * Set a custom confirmation handler (e.g., Shadcn AlertDialog).
@@ -347,6 +382,21 @@ export declare class ActionRunner {
347
382
  * Uses navigationHandler for SPA routing, falls back to window.location.
348
383
  */
349
384
  private executeUrl;
385
+ /**
386
+ * The one navigator behind `type: 'url'` and its `navigation` alias.
387
+ *
388
+ * Both names mean "go to a location", and they had two implementations that
389
+ * drifted: only this one interpolated `${param.X}`, promoted `/api/…` to the
390
+ * `apiBase`, short-circuited a redirect-dance URL to a full-page load, and
391
+ * honoured `openIn`. A `navigation` action was quietly the weaker of the two
392
+ * (#2944 item 3). Sharing the body is what keeps the alias honest.
393
+ *
394
+ * `action` supplies the interpolation context (`params`) and `openIn`;
395
+ * `source` supplies the navigation modifiers, which live on the nested
396
+ * `action.navigate` block for the legacy shape and on the action itself
397
+ * otherwise.
398
+ */
399
+ private navigateTo;
350
400
  /**
351
401
  * Execute modal action — open a dialog.
352
402
  * Delegates to the registered modalHandler; returns modal schema if no handler.
@@ -357,10 +407,22 @@ export declare class ActionRunner {
357
407
  * Delegates to a registered 'flow' handler; otherwise returns not-implemented.
358
408
  */
359
409
  private executeFlow;
360
- private executeActionSchema;
361
410
  /**
362
- * Execute navigation action
411
+ * Terminal fallback for a dispatch the switch above did not recognize: no
412
+ * known `type`, no registered handler, no `navigate` / `api` / `endpoint` /
413
+ * `onClick`. Some schema-only shapes are still runnable here — an action
414
+ * whose whole payload is a `redirect`, or an explicit `reload` / `close`
415
+ * directive — so this stays a real execution path.
416
+ *
417
+ * What it must never do is report success for an action it did not run. It
418
+ * used to return `{ success: true, reload: true, close: true }` for a
419
+ * COMPLETELY EMPTY schema, which is how a legacy string row action —
420
+ * dispatched as `{ type: '<action name>' }`, the name landing in the `type`
421
+ * slot — reached the user as a green "Action completed successfully" toast
422
+ * having issued zero requests (objectui#2960). An unresolvable dispatch is a
423
+ * wiring bug; surface it instead of laundering it into a success.
363
424
  */
425
+ private executeActionSchema;
364
426
  /**
365
427
  * `form` action — open a FormView as a routed page (`/forms/:name`, per the
366
428
  * spec). `target` is the FormView name. Without this case the action fell
@@ -369,6 +431,16 @@ export declare class ActionRunner {
369
431
  * hosts that support it; the form route ignores unknown query params.
370
432
  */
371
433
  private executeForm;
434
+ /**
435
+ * `navigation` — objectui's alias of the spec's `url` (`ObjectUiLocalActionType`).
436
+ *
437
+ * Kept because `{ type: 'navigation', to: … }` is authored today
438
+ * (`element:button` CTAs) and because deleting the case would not fail loudly:
439
+ * the action would land in `executeActionSchema` and report success while
440
+ * navigating nowhere. All it does now is resolve the alias's own spelling of
441
+ * the target and hand off to the shared navigator, so the two names cannot
442
+ * drift apart again. Prefer `type: 'url'` + `openIn` in new metadata.
443
+ */
372
444
  private executeNavigation;
373
445
  /**
374
446
  * Execute API action — supports both simple string endpoint and complex ApiConfig.
@@ -5,16 +5,9 @@
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
- /**
9
- * @object-ui/core - Action Runner
10
- *
11
- * Executes actions defined in ActionSchema and EventHandler.
12
- * Supports all spec v2.0.1 action types: script, url, modal, flow, api.
13
- * Features: conditional execution, confirmation, toast notifications,
14
- * redirect handling, action chaining, custom handler registration.
15
- */
16
8
  import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
17
9
  import { globalUndoManager } from './UndoManager';
10
+ import { warnOnUnknownActionKeys } from './actionKeys';
18
11
  /**
19
12
  * Bind the server-CEL-parity `os.user` identity alias into an action context
20
13
  * (#2358 trap 1). `@objectstack/spec` documents the canonical CEL identity
@@ -96,6 +89,34 @@ export class ActionRunner {
96
89
  writable: true,
97
90
  value: void 0
98
91
  });
92
+ /**
93
+ * Built-in dispatch, one entry per runnable action type.
94
+ *
95
+ * A table rather than a `switch` because the type annotation is the guard:
96
+ * `Record<RunnableActionType, …>` stops compiling the moment
97
+ * `@objectstack/spec` adds an `ActionType` this runner has no executor for.
98
+ * That is the whole Tier-2 failure class for actions — a spec name that
99
+ * validates at save and then does nothing at run time (#2942) — converted
100
+ * into a build error. It also rejects the reverse: an executor keyed by a
101
+ * name that is neither in the spec nor a declared local alias.
102
+ *
103
+ * `navigation` is that declared alias (see `ObjectUiLocalActionType`); it
104
+ * shares `url`'s navigator.
105
+ */
106
+ Object.defineProperty(this, "builtinExecutors", {
107
+ enumerable: true,
108
+ configurable: true,
109
+ writable: true,
110
+ value: {
111
+ script: (action) => this.executeScript(action),
112
+ url: (action) => this.executeUrl(action),
113
+ modal: (action) => this.executeModal(action),
114
+ flow: (action) => this.executeFlow(action),
115
+ api: (action) => this.executeAPI(action),
116
+ form: (action) => this.executeForm(action),
117
+ navigation: (action) => this.executeNavigation(action),
118
+ }
119
+ });
99
120
  this.context = withIdentityAlias(context);
100
121
  this.evaluator = new ExpressionEvaluator(this.context);
101
122
  // Default confirmation: window.confirm (can be overridden)
@@ -165,6 +186,11 @@ export class ActionRunner {
165
186
  }
166
187
  async execute(action) {
167
188
  try {
189
+ // `ActionDef` accepts any key of any type, so a typo (`targt`) and a
190
+ // retired key (`execute`) both reach here having type-checked. Neither
191
+ // binds a handler, and binding no handler silently is the #2169 "Mark Done
192
+ // does nothing" shape. Dev-only, warn-once, changes nothing (#4075 step 1).
193
+ warnOnUnknownActionKeys(action);
168
194
  // Resolve the action type
169
195
  const actionType = action.type || action.actionType || action.name || '';
170
196
  // Conditional execution
@@ -237,45 +263,27 @@ export class ActionRunner {
237
263
  await this.handlePostExecution(action, result);
238
264
  return result;
239
265
  }
240
- // Built-in action execution by type
266
+ // Built-in action execution by type.
241
267
  let result;
242
- switch (actionType) {
243
- case 'script':
244
- result = await this.executeScript(action);
245
- break;
246
- case 'url':
247
- result = await this.executeUrl(action);
248
- break;
249
- case 'modal':
250
- result = await this.executeModal(action);
251
- break;
252
- case 'flow':
253
- result = await this.executeFlow(action);
254
- break;
255
- case 'api':
256
- result = await this.executeAPI(action);
257
- break;
258
- case 'form':
259
- result = await this.executeForm(action);
260
- break;
261
- case 'navigation':
262
- result = await this.executeNavigation(action);
263
- break;
264
- default:
265
- // Legacy fallback: check for navigate, api, or onClick
266
- if (action.navigate) {
267
- result = await this.executeNavigation(action);
268
- }
269
- else if (action.api || action.endpoint) {
270
- result = await this.executeAPI(action);
271
- }
272
- else if (action.onClick) {
273
- await action.onClick();
274
- result = { success: true };
275
- }
276
- else {
277
- result = await this.executeActionSchema(action);
278
- }
268
+ const builtin = actionType
269
+ ? this.builtinExecutors[actionType]
270
+ : undefined;
271
+ if (builtin) {
272
+ result = await builtin(action);
273
+ }
274
+ else if (action.navigate) {
275
+ // Legacy fallback: check for navigate, api, or onClick
276
+ result = await this.executeNavigation(action);
277
+ }
278
+ else if (action.api || action.endpoint) {
279
+ result = await this.executeAPI(action);
280
+ }
281
+ else if (action.onClick) {
282
+ await action.onClick();
283
+ result = { success: true };
284
+ }
285
+ else {
286
+ result = await this.executeActionSchema(action);
279
287
  }
280
288
  await this.handlePostExecution(action, result);
281
289
  return result;
@@ -424,8 +432,38 @@ export class ActionRunner {
424
432
  * Supports ${} template expressions referencing data, record, user context.
425
433
  */
426
434
  async executeScript(action) {
427
- const script = action.execute || action.target;
435
+ // `target` is the only handler slot. The `execute` alias was removed in
436
+ // @objectstack/spec 17 (#3855), which rejects an authored `execute` at parse
437
+ // with the rename prescription — so parsed metadata cannot carry it and a
438
+ // `target || execute` fallback could only ever evaluate to `target` (#3856).
439
+ const script = action.target;
428
440
  if (!script) {
441
+ // A spec `body` IS a script — this runner just cannot run one (see the
442
+ // `body` field docs). Saying "no script provided" would send the author
443
+ // hunting for a missing field they actually wrote, so name the real
444
+ // cause and the remedy instead. Checked before the retired `execute` key:
445
+ // a `body` action ignores `target`, so "rename it" would be wrong advice.
446
+ if (action.body != null) {
447
+ return {
448
+ success: false,
449
+ error: 'Action body must be executed server-side — this client runner does not interpret ' +
450
+ '`body` (sandboxed JS needs an isolated VM; expression bodies use the formula engine). ' +
451
+ 'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action}.',
452
+ };
453
+ }
454
+ // ActionDef is open-ended (`[key: string]: any`), so hand-authored
455
+ // metadata that never passed through the spec parser still compiles with
456
+ // the retired key. Carry the same prescription the spec's tombstone does,
457
+ // for the same reason: a bare "no script provided" reads as "you forgot a
458
+ // field" to an author who did write one.
459
+ if (typeof action.execute === 'string') {
460
+ return {
461
+ success: false,
462
+ error: '`execute` was removed in @objectstack/spec 17 — rename the key to `target`. ' +
463
+ 'The value (a script name or expression) is unchanged. ' +
464
+ 'Run `os migrate meta --from 16` to rewrite it automatically.',
465
+ };
466
+ }
429
467
  return { success: false, error: 'No script provided for script action' };
430
468
  }
431
469
  // Named script registry wins over the expression evaluator. This lets
@@ -464,6 +502,23 @@ export class ActionRunner {
464
502
  if (!rawUrl) {
465
503
  return { success: false, error: 'No URL provided for url action' };
466
504
  }
505
+ return this.navigateTo(action, rawUrl, action);
506
+ }
507
+ /**
508
+ * The one navigator behind `type: 'url'` and its `navigation` alias.
509
+ *
510
+ * Both names mean "go to a location", and they had two implementations that
511
+ * drifted: only this one interpolated `${param.X}`, promoted `/api/…` to the
512
+ * `apiBase`, short-circuited a redirect-dance URL to a full-page load, and
513
+ * honoured `openIn`. A `navigation` action was quietly the weaker of the two
514
+ * (#2944 item 3). Sharing the body is what keeps the alias honest.
515
+ *
516
+ * `action` supplies the interpolation context (`params`) and `openIn`;
517
+ * `source` supplies the navigation modifiers, which live on the nested
518
+ * `action.navigate` block for the legacy shape and on the action itself
519
+ * otherwise.
520
+ */
521
+ async navigateTo(action, rawUrl, source) {
467
522
  // Apply target ${param.X} / ${ctx.X} interpolation FIRST — the
468
523
  // ExpressionEvaluator that follows would otherwise see unresolved
469
524
  // `param.provider` and either error or substitute undefined. Doing it
@@ -487,7 +542,9 @@ export class ActionRunner {
487
542
  if (apiBase && /^\/(api|_auth|_account)\//.test(url)) {
488
543
  url = `${apiBase}${url}`;
489
544
  }
490
- const isExternal = url.startsWith('http://') || url.startsWith('https://');
545
+ // `source.external` is the `navigation` shape's explicit override; the
546
+ // scheme heuristic covers every other case.
547
+ const isExternal = Boolean(source.external) || url.startsWith('http://') || url.startsWith('https://');
491
548
  // Same-origin API endpoints (most commonly the auth provider's
492
549
  // `/api/v1/auth/sign-in/social` redirect dance) issue server-side
493
550
  // 302s that must be followed by the *browser*, not the SPA router.
@@ -505,13 +562,22 @@ export class ActionRunner {
505
562
  return { success: true };
506
563
  }
507
564
  // `openIn` is the first-class, declarative switch (ActionSchema.openIn);
508
- // it wins over the legacy `params.newTab` escape hatch and the
509
- // external-URL heuristic.
510
- const newTab = action.openIn === 'new-tab' ? true
511
- : action.openIn === 'self' ? false
512
- : (action.params?.newTab ?? isExternal);
565
+ // it wins over the legacy `navigate.newTab` / `params.newTab` escape
566
+ // hatches and the external-URL heuristic.
567
+ const openIn = source.openIn ?? action.openIn;
568
+ const newTab = openIn === 'new-tab' ? true
569
+ : openIn === 'self' ? false
570
+ : (source.newTab ?? action.params?.newTab ?? isExternal);
571
+ // History semantics, the one thing the `navigation` shape carries that
572
+ // `ActionSchema` has no field for. Omitted from the handler call when
573
+ // unset so hosts see the same options object they always did.
574
+ const replace = source.replace ?? action.replace;
513
575
  if (this.navigationHandler) {
514
- this.navigationHandler(url, { external: isExternal, newTab });
576
+ this.navigationHandler(url, {
577
+ external: isExternal,
578
+ newTab,
579
+ ...(replace === undefined ? {} : { replace: Boolean(replace) }),
580
+ });
515
581
  return { success: true };
516
582
  }
517
583
  if (newTab) {
@@ -556,9 +622,37 @@ export class ActionRunner {
556
622
  error: `Flow handler not registered. Cannot execute flow: ${flowName}`,
557
623
  };
558
624
  }
625
+ /**
626
+ * Terminal fallback for a dispatch the switch above did not recognize: no
627
+ * known `type`, no registered handler, no `navigate` / `api` / `endpoint` /
628
+ * `onClick`. Some schema-only shapes are still runnable here — an action
629
+ * whose whole payload is a `redirect`, or an explicit `reload` / `close`
630
+ * directive — so this stays a real execution path.
631
+ *
632
+ * What it must never do is report success for an action it did not run. It
633
+ * used to return `{ success: true, reload: true, close: true }` for a
634
+ * COMPLETELY EMPTY schema, which is how a legacy string row action —
635
+ * dispatched as `{ type: '<action name>' }`, the name landing in the `type`
636
+ * slot — reached the user as a green "Action completed successfully" toast
637
+ * having issued zero requests (objectui#2960). An unresolvable dispatch is a
638
+ * wiring bug; surface it instead of laundering it into a success.
639
+ */
559
640
  async executeActionSchema(action) {
641
+ const hasApi = !!(action.api || action.endpoint);
642
+ // `reload`/`close` default to true downstream, so only an EXPLICIT `true`
643
+ // counts as declared intent — otherwise every empty schema would look like
644
+ // it asked for a reload and slip back through this guard.
645
+ const declaresBehavior = hasApi || !!action.onClick || !!action.redirect
646
+ || action.reload === true || action.close === true;
647
+ if (!declaresBehavior) {
648
+ const label = action.name || action.type || action.actionType || 'action';
649
+ return {
650
+ success: false,
651
+ error: `Action "${label}" is not executable: no handler is registered under that name and it declares no api, endpoint, navigate, redirect or onClick.`,
652
+ };
653
+ }
560
654
  const result = { success: true };
561
- if (action.api || action.endpoint) {
655
+ if (hasApi) {
562
656
  const apiResult = await this.executeAPI(action);
563
657
  if (!apiResult.success)
564
658
  return apiResult;
@@ -574,9 +668,6 @@ export class ActionRunner {
574
668
  }
575
669
  return result;
576
670
  }
577
- /**
578
- * Execute navigation action
579
- */
580
671
  /**
581
672
  * `form` action — open a FormView as a routed page (`/forms/:name`, per the
582
673
  * spec). `target` is the FormView name. Without this case the action fell
@@ -599,31 +690,23 @@ export class ActionRunner {
599
690
  }
600
691
  return { success: true, redirect: to };
601
692
  }
693
+ /**
694
+ * `navigation` — objectui's alias of the spec's `url` (`ObjectUiLocalActionType`).
695
+ *
696
+ * Kept because `{ type: 'navigation', to: … }` is authored today
697
+ * (`element:button` CTAs) and because deleting the case would not fail loudly:
698
+ * the action would land in `executeActionSchema` and report success while
699
+ * navigating nowhere. All it does now is resolve the alias's own spelling of
700
+ * the target and hand off to the shared navigator, so the two names cannot
701
+ * drift apart again. Prefer `type: 'url'` + `openIn` in new metadata.
702
+ */
602
703
  async executeNavigation(action) {
603
- const nav = action.navigate || action;
604
- const to = this.evaluator.evaluate(nav.to || nav.target);
605
- if (!this.isValidUrl(to)) {
606
- return {
607
- success: false,
608
- error: 'Invalid URL scheme. Only http://, https://, and relative URLs are allowed.',
609
- };
610
- }
611
- const isExternal = nav.external || (typeof to === 'string' && (to.startsWith('http://') || to.startsWith('https://')));
612
- if (this.navigationHandler) {
613
- this.navigationHandler(to, {
614
- external: isExternal,
615
- newTab: nav.newTab ?? isExternal,
616
- replace: nav.replace,
617
- });
618
- return { success: true };
619
- }
620
- if (isExternal) {
621
- window.open(to, '_blank', 'noopener,noreferrer');
622
- }
623
- else {
624
- return { success: true, redirect: to };
704
+ const nav = (action.navigate || action);
705
+ const rawUrl = nav.to || nav.target || nav.redirect;
706
+ if (!rawUrl) {
707
+ return { success: false, error: 'No URL provided for navigation action' };
625
708
  }
626
- return { success: true };
709
+ return this.navigateTo(action, rawUrl, nav);
627
710
  }
628
711
  /**
629
712
  * Execute API action — supports both simple string endpoint and complex ApiConfig.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * What an action is allowed to carry — objectstack#4075 step 1.
10
+ *
11
+ * `ActionDef` ends with `[key: string]: any`, so it accepts any key of any type.
12
+ * Concretely (objectui#2990): deleting `ActionDef.execute` produced ZERO compile
13
+ * errors even though the field had just been removed, and stale metadata still
14
+ * authoring `execute: 'markDone'` type-checks today. The same deletion against
15
+ * `@object-ui/types`' `ActionSchema` — which has no index signature — correctly
16
+ * produced `TS2353` at the authoring site. One of the two readers can catch a
17
+ * retired key; the other is structurally incapable.
18
+ *
19
+ * That asymmetry is the issue. An open key set on a DECLARED METADATA CONTRACT
20
+ * is what lets a typo (`targt`, `exectue`) and a tombstoned key (`execute`) both
21
+ * sail through to a runner that then silently does nothing — the #2169 "Mark
22
+ * Done does nothing" shape.
23
+ *
24
+ * This module is step 1 of the staged narrowing: it makes the key set VISIBLE
25
+ * and warns on anything outside it, without changing a single type. Nothing
26
+ * breaks, and an invisible failure becomes an audible one. Steps 2 and 3 —
27
+ * promoting the legitimate keys to explicit optional fields, then removing the
28
+ * index signature — are what finally let `tsc` catch typos and retired keys.
29
+ *
30
+ * The lists are pinned by `__tests__/actionKeys.pin.test.ts`, which re-derives
31
+ * each one from its actual source. A hand-maintained list that drifts from the
32
+ * interface it mirrors would be the same "declared ≠ enforced" trap this whole
33
+ * thread is about.
34
+ *
35
+ * ── What the inventory found, i.e. step 2's worklist ─────────────────────────
36
+ * `ActionDef` declares 34 keys, the spec's `ActionSchema` declares 36, and they
37
+ * overlap on 17. The two halves of the difference are different problems:
38
+ *
39
+ * 18 keys the SPEC owns that `ActionDef` never declared — `ai`, `aria`,
40
+ * `bodyExtra`, `bodyShape`, `bulkEnabled`, `component`, `icon`, `locations`,
41
+ * `mode`, `objectName`, `order`, `recordIdField`, `recordIdParam`,
42
+ * `requiredPermissions`, `requiresFeature`, `shortcut`, `variant`, `visible`.
43
+ * These are authored today and reach readers through the index signature.
44
+ * `ActionEngine` reads two of them (`visible` at the location filter, `locations`
45
+ * at registration) through an `as any` cast — a cast that exists ONLY because the
46
+ * field is undeclared, and that goes away when step 2 promotes them.
47
+ *
48
+ * 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
49
+ * `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
50
+ * `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
51
+ * `actionParams`. Several are already marked legacy at their declaration. These
52
+ * are objectui's own dialect and need the #4115 treatment: kept and documented as
53
+ * deliberate, or retired — but not silently carried.
54
+ */
55
+ /**
56
+ * Every property `ActionDef` declares, in declaration order.
57
+ *
58
+ * Kept as data because TypeScript types do not survive to runtime — and
59
+ * `keyof ActionDef` cannot substitute for this list: the index signature widens
60
+ * it to `string | number`, which is precisely the problem being worked around.
61
+ */
62
+ export declare const ACTION_DEF_KEYS: readonly ["type", "actionType", "name", "label", "confirmText", "confirm", "condition", "disabled", "api", "endpoint", "method", "navigate", "onClick", "reload", "close", "redirect", "toast", "successMessage", "errorMessage", "refreshAfter", "undoable", "params", "actionParams", "resultDialog", "target", "body", "openIn", "modal", "chain", "chainMode", "onSuccess", "onFailure", "opensInNewTab", "newTabUrl"];
63
+ /**
64
+ * Every property `@objectstack/spec`'s `ActionSchema` declares.
65
+ *
66
+ * `ActionDef` mirrors that schema, so a key the spec owns is legitimate even
67
+ * when `ActionDef` has not declared it yet — several are read today only through
68
+ * an `as any` cast (`visible` and `locations` in `ActionEngine`), which is the
69
+ * cast this list exists to eventually retire.
70
+ *
71
+ * Pinned rather than read off the schema at runtime: the spec exports
72
+ * `ActionSchema` as a lazy proxy that does not forward `.shape`, so resolving it
73
+ * means walking zod internals (`_def.in` / `_def.innerType`). That is fine in a
74
+ * test and wrong in shipped code — `@object-ui/types` made the same call for
75
+ * `ActionComponent`. The pin test does the walk and fails the day this drifts.
76
+ */
77
+ export declare const SPEC_ACTION_KEYS: readonly ["ai", "aria", "body", "bodyExtra", "bodyShape", "bulkEnabled", "component", "confirmText", "disabled", "errorMessage", "execute", "icon", "label", "locations", "method", "mode", "name", "newTabUrl", "objectName", "openIn", "opensInNewTab", "order", "params", "recordIdField", "recordIdParam", "refreshAfter", "requiredPermissions", "requiresFeature", "resultDialog", "shortcut", "successMessage", "target", "type", "undoable", "variant", "visible"];
78
+ /**
79
+ * The `navigation` alias's own spelling of a target, read off the action itself
80
+ * when no nested `navigate` object is present (`ActionRunner.executeNavigation`).
81
+ *
82
+ * A declared objectui-side dialect, not spec vocabulary: `replace` is documented
83
+ * at its read site as "the one thing the `navigation` shape carries that
84
+ * `ActionSchema` has no field for". Listing it here is what keeps it from being
85
+ * silent dialect — the alternative is a warning that cries wolf on a shape the
86
+ * runner itself supports.
87
+ */
88
+ export declare const NAVIGATION_ALIAS_KEYS: readonly ["to", "external", "newTab", "replace"];
89
+ /**
90
+ * Keys the spec has TOMBSTONED: still present in `ActionSchema` so the parser can
91
+ * reject them BY NAME with a rename prescription, rather than fail with a bare
92
+ * "unrecognized key".
93
+ *
94
+ * Warned about separately from unknown keys, and more loudly: an unknown key is
95
+ * probably a typo, while a retired key is metadata that used to work. That
96
+ * distinction is why `executeScript` carries a runtime branch returning the
97
+ * rename prescription — a branch that exists solely to compensate for the index
98
+ * signature, and that can retire with it in step 3.
99
+ */
100
+ export declare const RETIRED_ACTION_KEYS: Readonly<Record<string, string>>;
101
+ /** Every key an action may legitimately carry today. */
102
+ export declare const KNOWN_ACTION_KEYS: ReadonlySet<string>;
103
+ /** Split an action's own keys into the two things worth saying out loud. */
104
+ export declare function classifyActionKeys(action: object | null | undefined): {
105
+ unknown: string[];
106
+ retired: string[];
107
+ };
108
+ /** Reset the warn-once memo. Exported for tests. */
109
+ export declare function resetActionKeyWarnings(): void;
110
+ /**
111
+ * Dev-mode only: name the keys an action carries that no reader will ever look
112
+ * at. Non-breaking by construction — it changes no types and rejects nothing.
113
+ *
114
+ * Silently binding no handler is the #2169 "Mark Done does nothing" shape, and
115
+ * the compiler cannot help while the index signature stands. Until it comes
116
+ * down, this is the audible half.
117
+ */
118
+ export declare function warnOnUnknownActionKeys(action: object | null | undefined, where?: string): void;