@object-ui/core 6.2.3 → 7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,226 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 7.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 08c47da: feat(dashboard): dataset chart widgets paint select/lookup dimensions in their option colors
8
+
9
+ A dashboard `DatasetWidget` chart grouped by a select/lookup dimension (e.g.
10
+ project `health`) painted its categories from the generic `--chart-1..5`
11
+ palette — the same gap the chart view (`object-chart`) had before #1932. It now
12
+ resolves the dimension field's option colors (using the dataset's base `object`
13
+ - dimension→field map the query already returns) and threads them to the
14
+ renderer as a per-category `categoryColors` map, so health green/red/yellow
15
+ paints semantically.
16
+
17
+ The value/label→color resolution is extracted into a shared `buildOptionColorMap`
18
+ (`@object-ui/core`) now used by both `DatasetWidget` and `ObjectChart`.
19
+
20
+ - Updated dependencies [677f7ed]
21
+ - Updated dependencies [a71be60]
22
+ - Updated dependencies [cb03bc3]
23
+ - @object-ui/types@7.1.0
24
+
25
+ ## 7.0.0
26
+
27
+ ### Minor Changes
28
+
29
+ - f7f325d: feat: action progress state + Undo affordance
30
+
31
+ - **core**: `ActionResult.undo` (an `UndoableOperation`) and `ActionDef.undoable`.
32
+ On success the `ActionRunner` pushes the operation onto the global UndoManager
33
+ and the success toast carries an "Undo" affordance (`ToastHandler` gains an
34
+ `undo` option).
35
+ - **app-shell**: the console action runtime mounts `useGlobalUndo` (Ctrl+Z /
36
+ Ctrl+Shift+Z) and renders the toast's "Undo" button; its `apiHandler` resolves
37
+ the row id from the list row record and, for `undoable` actions, captures the
38
+ changed fields' prior values so the update can be reverted.
39
+ - **plugin-detail**: record-header quick-action buttons show a spinner + disable
40
+ while the action runs (a visible progress state for slow/flow actions).
41
+
42
+ - c12986e: Add resultDialog + target interpolation for one-shot action reveals
43
+
44
+ Some platform actions return values the user MUST copy now because the
45
+ server will not surface them again — 2FA TOTP URI + backup codes, freshly
46
+ minted OAuth client_secret, regenerated recovery codes. Previously these
47
+ had to ship as bespoke pages in `apps/account` because actions only
48
+ emitted a fire-and-forget toast.
49
+
50
+ **`@object-ui/core` — ActionRunner**
51
+
52
+ - New `ActionDef.resultDialog: ResultDialogSpec` field. When set on a
53
+ successful action, the runner suppresses the `successMessage` toast and
54
+ awaits the registered `ResultDialogHandler` instead. Missing handler is
55
+ non-fatal (logs a warning); rejected handler is treated as acknowledged.
56
+ - New `setResultDialogHandler(handler)` setter.
57
+ - New types: `ResultDialogSpec`, `ResultDialogFieldSpec`,
58
+ `ResultDialogHandler`.
59
+ - `executeUrl` and `executeAPI` now run `${param.X}` and `${ctx.X}`
60
+ interpolation against `target` before fetching / navigating. Values are
61
+ `encodeURIComponent`'d, missing keys resolve to empty string. `ctx`
62
+ exposes `origin`, `user`, `org`, `recordId` by default; consumers can
63
+ inject more via `context.ctx`.
64
+
65
+ **`@object-ui/react`**
66
+
67
+ - `ActionProvider` and `useActionRunner` both gained an `onResultDialog`
68
+ option that wires straight through to the runner.
69
+
70
+ **`@object-ui/app-shell`**
71
+
72
+ - New `ActionResultDialog` component — promise-based, blocks click-outside
73
+ and Escape (the user MUST click acknowledge), renders five field
74
+ formats: `qrcode` (client-side via the `qrcode` package — never sent
75
+ off-device, so 2FA URIs stay secret), `code-list`, `secret`, `text`,
76
+ `json`. Falls back to `json` when a value's shape doesn't match its
77
+ declared format.
78
+ - `ObjectView` and `RecordDetailView` install the handler and mount the
79
+ dialog automatically, so any action with `resultDialog` declared in
80
+ metadata now works without code changes.
81
+ - New dependency: `qrcode@^1.5.x` for client-side QR rendering.
82
+
83
+ Pairs with the framework-side `Action.resultDialog` schema added in
84
+ `@objectstack/spec` and the `sys_two_factor` / `sys_oauth_application` /
85
+ `sys_account` updates in `@objectstack/platform-objects`.
86
+
87
+ - 053c948: feat(app-shell): zero-roundtrip `newTabUrl` fast path for `opensInNewTab` actions
88
+
89
+ Actions that declare `newTabUrl` (a path template with a `{recordId}` placeholder
90
+ whose target endpoint performs all auth/authz itself) now drive the pre-opened
91
+ popup straight to that URL on click, skipping the action POST entirely — applied
92
+ to both server-action paths (list rows via `useConsoleActionRuntime`, record
93
+ header via `RecordDetailView`). The popup paints the existing spinner page until
94
+ the (possibly slow) endpoint commits its redirect; the URL is resolved absolute
95
+ because `about:blank` gives a bare-relative href no reliable base. The
96
+ popup-blocked toast fallback is unchanged. Removes one full round trip of
97
+ white-screen latency from every such Open click.
98
+
99
+ - ddbe4a2: B2 step 3: client-side field-level conditional rules (`visibleWhen` / `readonlyWhen` / `requiredWhen`). The form renderer now evaluates these CEL predicates reactively against the live record and gates each field's visibility, read-only state, and required-ness accordingly. Evaluation delegates to the canonical `@objectstack/formula` `ExpressionEngine` — the _same_ dialect the server enforces (`requiredWhen` in the rule-validator, `readonlyWhen` in `stripReadonlyWhenFields`) — so the UX and the persisted verdict always agree. New core helpers `evalFieldPredicate` / `resolveFieldRuleState` (zero-React, fail-open). `FormField` gains `visibleWhen` / `readonlyWhen` / `requiredWhen` (+ deprecated `conditionalRequired` alias), and `ObjectForm` carries them through from object metadata.
100
+ - d54346c: feat: action/flow completion messaging
101
+
102
+ - **core**: `ActionResult.silent` — a handler sets it when the action only
103
+ HANDED OFF to a follow-up UI (rather than completing), so `ActionRunner`
104
+ skips the automatic success toast. Fixes the misleading "Action completed
105
+ successfully" toast that fired the moment a `flow` action opened its wizard.
106
+ - **app-shell**: both flow handlers now return `silent: true` when the flow
107
+ pauses at a screen (the wizard only opened — it hasn't completed). `FlowRunner`
108
+ renders the flow's declared `successMessage` / `errorMessage` (from the
109
+ terminal `AutomationResult`) instead of a generic "Done" / the raw error.
110
+
111
+ - 2270239: feat: scoped style-object rendering (ADR-0065)
112
+
113
+ A metadata node may carry `responsiveStyles` (per-breakpoint CSS-property maps);
114
+ `SchemaRenderer` compiles it to **id-scoped CSS** injected as a `<style>` tag and
115
+ appends a scope class to the node. Build-independent (arbitrary values + design
116
+ tokens pass through verbatim — no Tailwind JIT), collision-free (per-node scope,
117
+ unlayered so it beats base utilities), responsive-correct (model breakpoint maps
118
+ → generated `@media`, never `md:` variant classes). Adds `compileScopedStyles`/
119
+ `scopeClassFor`/`hasResponsiveStyles` to `@object-ui/core` and an SDUI design-token
120
+ palette (`--space-*`, `--surface`, `--brand`, …) to the theme. Mirrors Builder.io.
121
+
122
+ ### Patch Changes
123
+
124
+ - 5976ba3: fix(core): evaluate bare CEL predicates in `evaluateCondition`
125
+
126
+ `ExpressionEvaluator.evaluateCondition` delegated to `evaluate`, which only
127
+ processes `${...}` templates and returns any other string verbatim. A bare
128
+ predicate such as `record.status == "converted"` (the shape `objectstack build`
129
+ emits for `disabled`/`visible`/`condition`) was therefore returned as a
130
+ non-empty string and coerced to `true` — so every bare-expression predicate was
131
+ silently always-truthy.
132
+
133
+ The most visible symptom: a param-collecting `api` action invoked from the
134
+ record header (e.g. CRM "Reassign Lead") was treated as permanently `disabled`,
135
+ so `ActionRunner.execute` bailed before opening the param dialog. The renderer
136
+ (`page:header`) was unaffected because it evaluates via `evaluateExpression`
137
+ directly.
138
+
139
+ `evaluateCondition` now treats a non-`${}` condition as a single expression
140
+ (via `evaluateExpression`), keeps the `${...}` template path, and preserves the
141
+ "empty/undefined ⇒ visible/enabled" and "unparseable ⇒ default visible/enabled"
142
+ fallbacks. Also hardens `ActionRunner`'s `disabled` gate to evaluate the
143
+ boolean/string/envelope form rather than treating any object as truthy, and
144
+ unifies the grid row-action predicate scope so `record.*` and bare-field
145
+ predicates resolve identically on every surface.
146
+
147
+ - eaccefd: fix(actions): warn when an action is hidden by a throwing `visible` predicate
148
+
149
+ `ActionEngine.getActionsForLocation` is fail-closed: a `visible` predicate that
150
+ throws hides the action. The most common cause is an authoring bug — a BARE
151
+ field reference (`done` instead of `record.done`), which is undeclared in the
152
+ `{ record, recordId, objectName, user }` eval scope. Hiding it silently made
153
+ that bug invisible (a long debugging hunt). The catch now emits a one-time
154
+ `console.warn` naming the action + predicate + error, with the `record.<field>`
155
+ tip. Deduped per predicate so re-renders don't spam.
156
+
157
+ - 71d7ce0: fix(actions): handle `type: 'form'` in ActionRunner
158
+
159
+ A `form` action had no `case` in `ActionRunner`'s execution switch, so it fell
160
+ through to `executeActionSchema` and silently no-opped — clicking a Log-Time /
161
+ "open form" action did nothing. Add `executeForm`, which opens the FormView as a
162
+ routed page (`/forms/:name`, per the action spec) via the navigation handler,
163
+ forwarding the current record id as `?recordId=` for hosts that support it.
164
+ Covered by ActionRunner unit tests.
165
+
166
+ - 2d47e94: B2 follow-ups (A): field conditional rules in inline grids + submit-time enforcement.
167
+
168
+ - **Grids**: a line-item column's `readonlyWhen` / `requiredWhen` CEL rule is now honored per row — `deriveMasterDetail` carries the props onto the `GridColumn` and `GridField` evaluates them against each row via `resolveFieldRuleState` (a `readonlyWhen`-TRUE cell locks; a `requiredWhen`-TRUE empty cell flags inline-invalid). Rules are row-scoped (`record.*`); the core helpers gained an optional `scope` (and `GridField` a `contextRecord` prop) so a future header-driven lock can bind `parent.*` — that wiring is deferred (it needs the master-detail header's re-renders isolated).
169
+ - **Submit enforcement**: `requiredWhen` already drove react-hook-form's `required` rule, so submit is blocked with a field error when the predicate is TRUE and the value is empty. Added a reactive cleanup so a stale _required_ error clears when the predicate flips FALSE (and all errors clear when a field is hidden by `visibleWhen`).
170
+
171
+ - c3749eb: feat(dashboard): dataset chart widgets drill through to records
172
+
173
+ Dataset-bound **chart** widgets (bar/line/pie/area/donut/funnel/…) are now
174
+ click-drillable, matching table/pivot. Clicking a segment maps it back to its
175
+ dataset row and opens the same governed drill drawer (raw group keys preserved),
176
+ so a chart-only dashboard is no longer an exploration dead-end. This closes the
177
+ "object-backed chart drills but dataset chart doesn't" inconsistency and aligns
178
+ with mainstream BI (click a chart → see records).
179
+
180
+ - `@object-ui/core`: `findChartSeriesRow` — inverse of `buildChartSeries`,
181
+ maps a clicked `{category, series}` back to the source dataset row index
182
+ (matches both dims when a 2nd dimension is pivoted into series).
183
+ - `ObjectChart`: optional `onSegmentClick` lets a host own the chart click
184
+ (and suppress the widget's own object-drill).
185
+ - `DatasetWidget`: lifts the drill machinery to cover both table/pivot and
186
+ chart, and wires the chart's segment click to the precise dataset drill.
187
+
188
+ - 1394e34: feat(chart): visualise the second dataset dimension as grouped series
189
+
190
+ A dataset chart with two dimensions (e.g. `['status','priority']`) previously
191
+ only rendered the first dimension — the second was invisible (repeated x-axis
192
+ labels, no grouping). New shared `buildChartSeries` helper (`@object-ui/core`)
193
+ pivots the second dimension into one series per value; `ObjectChart`
194
+ (plugin-charts) and `DatasetWidget` (plugin-dashboard) both use it, so
195
+ multi-dimension charts render consistently as grouped/coloured bars.
196
+
197
+ Refs objectstack-ai/objectui#1759, objectstack-ai/framework#1890
198
+
199
+ - 7c239fd: Add `ComponentRegistry.unregister(type, namespace?)` — the inverse of
200
+ `register()`. Clears the namespaced key and the bare-name fallback (when it
201
+ still resolves to that registration) plus any matching lazy stub, and notifies
202
+ subscribers only when something was removed. Lets callers (and tests) restore
203
+ prior registry state cleanly.
204
+ - 8d1195d: Fix `type: 'url'` actions so they actually reach the backend in split-origin dev setups, and so reveal-once result dialogs render.
205
+
206
+ - `ActionRunner.executeUrl`: when context provides `apiBase`, relative `/api/...`, `/_auth/...`, and `/_account/...` URLs are now promoted to absolute (`${apiBase}${path}`) before navigation. Same-origin API paths (with or without `apiBase`) trigger a full-page `window.location.href` rather than React-Router push — this is required for server-side OAuth redirect dances (e.g. better-auth `/sign-in/social`) that React Router would otherwise swallow into the SPA's fallback route.
207
+ - `ActionRunner.buildInterpolationContext`: surfaces `ctx.apiBase` for action targets that want to template it explicitly.
208
+ - `ObjectView`: passes `apiBase: import.meta.env.VITE_SERVER_URL` into the toolbar `ActionProvider` context so the above resolves.
209
+ - `action-button` and `action-menu` renderers now forward `resultDialog` when invoking the runner. Previously this field was silently dropped by an explicit whitelist, breaking every "show once, then hide" flow (2FA QR/backup codes, OAuth client_secret, regenerated tokens).
210
+
211
+ - Updated dependencies [ddbe4a2]
212
+ - Updated dependencies [9049bbe]
213
+ - Updated dependencies [cb2fdb1]
214
+ - Updated dependencies [6cfa330]
215
+ - Updated dependencies [ad8ade6]
216
+ - Updated dependencies [3870c20]
217
+ - Updated dependencies [b88c560]
218
+ - Updated dependencies [d16566f]
219
+ - Updated dependencies [300d755]
220
+ - Updated dependencies [4eb9cb6]
221
+ - Updated dependencies [858ad94]
222
+ - @object-ui/types@7.0.0
223
+
3
224
  ## 6.2.3
4
225
 
5
226
  ### Patch Changes
@@ -16,8 +16,16 @@
16
16
  * ActionSchema[] → ActionEngine → ActionRunner → ActionResult
17
17
  */
18
18
  import { ActionRunner, type ActionDef, type ActionContext, type ActionResult } from './ActionRunner';
19
- /** Action location types (from @objectstack/spec) */
20
- export type ActionLocation = 'list_toolbar' | 'list_item' | 'record_header' | 'record_more' | 'record_related' | 'global_nav';
19
+ /**
20
+ * Action location types — re-exported from `@objectstack/spec/ui` so the
21
+ * platform has one source of truth (`ACTION_LOCATIONS`). Do NOT redeclare
22
+ * this enum locally; add new values in
23
+ * `framework/packages/spec/src/ui/action.zod.ts` and they propagate here
24
+ * automatically.
25
+ */
26
+ export type { ActionLocation } from '@object-ui/types';
27
+ export { ACTION_LOCATIONS } from '@object-ui/types';
28
+ import type { ActionLocation } from '@object-ui/types';
21
29
  /** Registered action with metadata */
22
30
  export interface RegisteredAction {
23
31
  action: ActionDef;
@@ -51,7 +59,16 @@ export declare class ActionEngine {
51
59
  private shortcuts;
52
60
  private normalizedShortcutMap;
53
61
  private runner;
54
- constructor(context?: ActionContext);
62
+ /**
63
+ * @param contextOrRunner Either an initial `ActionContext` (constructs a
64
+ * new local `ActionRunner`) or an existing `ActionRunner` to share.
65
+ * Sharing is how nested consumers (e.g. `record:quick_actions` inside
66
+ * an `<ActionProvider>`) inherit the provider's confirm/param/modal/
67
+ * toast/navigate handlers without redeclaring them — without this,
68
+ * any action declaring `params: [...]` or `confirmText` would silently
69
+ * no-op in the nested engine.
70
+ */
71
+ constructor(contextOrRunner?: ActionContext | ActionRunner);
55
72
  /** Get the underlying ActionRunner for handler configuration */
56
73
  getRunner(): ActionRunner;
57
74
  /** Register a single action */
@@ -67,7 +84,28 @@ export declare class ActionEngine {
67
84
  unregisterAction(name: string): void;
68
85
  /** Add an event-to-action mapping */
69
86
  addMapping(mapping: ActionMapping): void;
70
- /** Get actions available at a specific location, sorted by priority */
87
+ /**
88
+ * Get actions available at a specific location, sorted by priority.
89
+ *
90
+ * Filtering applied (in order):
91
+ * 1. `locations.includes(location)` — location/region match
92
+ * 2. `action.visible` — evaluated against the runner's current context
93
+ * ({ record, recordId, objectName, user, … }). Missing or `true`
94
+ * passes; any other value is coerced to boolean. Evaluator errors
95
+ * hide the action (fail-closed) rather than throwing — this matches
96
+ * the contract used by every individual action renderer
97
+ * (`action-button`, `action-menu`, `action-bar`, …) so the same
98
+ * action behaves identically whether surfaced via the engine or
99
+ * consumed standalone.
100
+ *
101
+ * Predicate normalization matches `@object-ui/react`'s `toPredicateInput`:
102
+ * raw strings (`'record.foo == true'`) are wrapped as `${...}` so they
103
+ * evaluate as expressions, not as truthy string literals. Spec-style
104
+ * `{ dialect, source }` envelopes are unwrapped to their `source`.
105
+ * Without this, every per-action renderer (which already calls
106
+ * `toPredicateInput`) and the engine path would disagree on the same
107
+ * `visible:` predicate.
108
+ */
71
109
  getActionsForLocation(location: ActionLocation): ActionDef[];
72
110
  /** Get actions that support bulk operations */
73
111
  getBulkActions(): ActionDef[];
@@ -16,12 +16,43 @@
16
16
  * ActionSchema[] → ActionEngine → ActionRunner → ActionResult
17
17
  */
18
18
  import { ActionRunner } from './ActionRunner';
19
+ export { ACTION_LOCATIONS } from '@object-ui/types';
19
20
  /** Normalize a shortcut string for comparison (lowercase, trimmed, sorted parts) */
20
21
  function normalizeShortcut(keys) {
21
22
  return keys.toLowerCase().split('+').map(k => k.trim()).sort().join('+');
22
23
  }
24
+ /** De-dupe key set so a broken predicate warns once, not every re-render. */
25
+ const _warnedPredicates = new Set();
26
+ /**
27
+ * Surface an action whose `visible` predicate threw (and was therefore
28
+ * fail-closed hidden). Almost always an authoring bug — typically a bare field
29
+ * reference that should be `record.<field>`. Warned once per predicate.
30
+ */
31
+ function warnHiddenPredicate(name, raw, err) {
32
+ const src = typeof raw === 'string'
33
+ ? raw
34
+ : (raw && typeof raw === 'object' && typeof raw.source === 'string' ? raw.source : String(raw));
35
+ const key = `${String(name)}::${src}`;
36
+ if (_warnedPredicates.has(key))
37
+ return;
38
+ _warnedPredicates.add(key);
39
+ const msg = err instanceof Error ? err.message : String(err);
40
+ // eslint-disable-next-line no-console
41
+ console.warn(`[ActionEngine] action "${String(name)}" hidden: its \`visible\` predicate threw — ${msg}. ` +
42
+ `Predicate: ${src}. Action predicates evaluate against { record, recordId, objectName, user, … }; ` +
43
+ `use \`record.<field>\` (not a bare field).`);
44
+ }
23
45
  export class ActionEngine {
24
- constructor(context = {}) {
46
+ /**
47
+ * @param contextOrRunner Either an initial `ActionContext` (constructs a
48
+ * new local `ActionRunner`) or an existing `ActionRunner` to share.
49
+ * Sharing is how nested consumers (e.g. `record:quick_actions` inside
50
+ * an `<ActionProvider>`) inherit the provider's confirm/param/modal/
51
+ * toast/navigate handlers without redeclaring them — without this,
52
+ * any action declaring `params: [...]` or `confirmText` would silently
53
+ * no-op in the nested engine.
54
+ */
55
+ constructor(contextOrRunner = {}) {
25
56
  Object.defineProperty(this, "actions", {
26
57
  enumerable: true,
27
58
  configurable: true,
@@ -52,7 +83,10 @@ export class ActionEngine {
52
83
  writable: true,
53
84
  value: void 0
54
85
  });
55
- this.runner = new ActionRunner(context);
86
+ this.runner =
87
+ contextOrRunner instanceof ActionRunner
88
+ ? contextOrRunner
89
+ : new ActionRunner(contextOrRunner);
56
90
  }
57
91
  /** Get the underlying ActionRunner for handler configuration */
58
92
  getRunner() {
@@ -100,10 +134,87 @@ export class ActionEngine {
100
134
  addMapping(mapping) {
101
135
  this.mappings.push(mapping);
102
136
  }
103
- /** Get actions available at a specific location, sorted by priority */
137
+ /**
138
+ * Get actions available at a specific location, sorted by priority.
139
+ *
140
+ * Filtering applied (in order):
141
+ * 1. `locations.includes(location)` — location/region match
142
+ * 2. `action.visible` — evaluated against the runner's current context
143
+ * ({ record, recordId, objectName, user, … }). Missing or `true`
144
+ * passes; any other value is coerced to boolean. Evaluator errors
145
+ * hide the action (fail-closed) rather than throwing — this matches
146
+ * the contract used by every individual action renderer
147
+ * (`action-button`, `action-menu`, `action-bar`, …) so the same
148
+ * action behaves identically whether surfaced via the engine or
149
+ * consumed standalone.
150
+ *
151
+ * Predicate normalization matches `@object-ui/react`'s `toPredicateInput`:
152
+ * raw strings (`'record.foo == true'`) are wrapped as `${...}` so they
153
+ * evaluate as expressions, not as truthy string literals. Spec-style
154
+ * `{ dialect, source }` envelopes are unwrapped to their `source`.
155
+ * Without this, every per-action renderer (which already calls
156
+ * `toPredicateInput`) and the engine path would disagree on the same
157
+ * `visible:` predicate.
158
+ */
104
159
  getActionsForLocation(location) {
160
+ const evaluator = this.runner.getEvaluator();
105
161
  return Array.from(this.actions.values())
106
162
  .filter(ra => ra.locations.includes(location))
163
+ .filter(ra => {
164
+ // [ADR-0066 D4] Capability gate (UI half — the server enforces the
165
+ // source of truth). Hide an action whose `requiredPermissions` are not
166
+ // ALL held by the caller's `systemPermissions`. Fail-OPEN when
167
+ // systemPermissions is unknown (undefined): the server still 403s, and
168
+ // hiding on missing data is a worse regression than showing a button
169
+ // that errors clearly. Mirrors the App/nav requiredPermissions gate.
170
+ const required = ra.action.requiredPermissions;
171
+ if (!Array.isArray(required) || required.length === 0)
172
+ return true;
173
+ const held = this.runner.getContext()?.user?.systemPermissions;
174
+ if (!Array.isArray(held))
175
+ return true;
176
+ return required.every((p) => held.includes(p));
177
+ })
178
+ .filter(ra => {
179
+ const raw = ra.action.visible;
180
+ if (raw == null || raw === '' || raw === true)
181
+ return true;
182
+ if (raw === false)
183
+ return false;
184
+ let expr;
185
+ if (typeof raw === 'string') {
186
+ expr = `\${${raw}}`;
187
+ }
188
+ else if (typeof raw === 'object' && typeof raw.source === 'string') {
189
+ const src = raw.source;
190
+ if (!src)
191
+ return true;
192
+ expr = `\${${src}}`;
193
+ }
194
+ else {
195
+ expr = Boolean(raw);
196
+ }
197
+ try {
198
+ // `throwOnError: true` is required for fail-closed semantics: the
199
+ // evaluator's default behavior swallows expression errors and
200
+ // returns the raw template string, which `Boolean(string)` then
201
+ // coerces to `true` — silently leaking actions whose preconditions
202
+ // failed (e.g. `ctx` undefined). Surface the error so our catch
203
+ // below can hide the action.
204
+ return evaluator.evaluateCondition(expr, { throwOnError: true });
205
+ }
206
+ catch (err) {
207
+ // Fail-closed: hide the action. But a *thrown* predicate is almost
208
+ // always an authoring bug, not a real precondition — most often a
209
+ // BARE field reference (`done` instead of `record.done`), which is
210
+ // an undeclared variable in the `{ record, recordId, objectName,
211
+ // user }` eval scope. Silently hiding it makes that bug invisible
212
+ // (the #2183 hunt). Warn once per predicate so it is diagnosable
213
+ // without spamming re-renders.
214
+ warnHiddenPredicate(ra.action.name, raw, err);
215
+ return false;
216
+ }
217
+ })
107
218
  .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100))
108
219
  .map(ra => ra.action);
109
220
  }
@@ -14,6 +14,7 @@
14
14
  * redirect handling, action chaining, custom handler registration.
15
15
  */
16
16
  import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
17
+ import { type UndoableOperation } from './UndoManager';
17
18
  export interface ActionResult {
18
19
  success: boolean;
19
20
  data?: any;
@@ -23,6 +24,20 @@ export interface ActionResult {
23
24
  redirect?: string;
24
25
  /** Modal schema to render (for type: 'modal') */
25
26
  modal?: any;
27
+ /**
28
+ * Suppress the automatic success toast for this result. A handler sets this
29
+ * when the action only HANDED OFF to a follow-up UI rather than completing —
30
+ * e.g. a `flow` action that paused at a screen and opened the flow-runner. The
31
+ * action hasn't "completed yet", so a "success" toast on open would be
32
+ * misleading; the follow-up surface owns its own completion messaging.
33
+ */
34
+ silent?: boolean;
35
+ /**
36
+ * An undoable operation captured by the handler (e.g. an `undoable` update
37
+ * action's prior field values). When present, the runner pushes it onto the
38
+ * global UndoManager and the success toast offers an "Undo" affordance.
39
+ */
40
+ undo?: UndoableOperation;
26
41
  }
27
42
  export interface ActionContext {
28
43
  data?: Record<string, any>;
@@ -102,10 +117,18 @@ export interface ActionDef {
102
117
  errorMessage?: string;
103
118
  /** Whether to refresh data after execution (from UIActionSchema) */
104
119
  refreshAfter?: boolean;
120
+ /** Single-record update actions: offer an Undo affordance on success. */
121
+ undoable?: boolean;
105
122
  /** Params object (for custom handlers) */
106
123
  params?: Record<string, any>;
107
124
  /** ActionParam definitions to collect from user before execution (from spec ActionSchema.params) */
108
125
  actionParams?: ActionParamDef[];
126
+ /**
127
+ * Result dialog spec — when present and the action succeeds, the runner
128
+ * suppresses the successMessage toast and asks the consumer to open a
129
+ * reveal dialog rendering `result.data` (see ResultDialogSpec).
130
+ */
131
+ resultDialog?: ResultDialogSpec;
109
132
  /** Script/expression to execute (for type: 'script') */
110
133
  execute?: string;
111
134
  /** Target URL or identifier (for type: 'url', 'modal', 'flow') */
@@ -124,6 +147,12 @@ export interface ActionDef {
124
147
  * handler can drive it to a returned `redirectUrl` after an awaited fetch
125
148
  * without tripping popup blockers. Used by actions like `sso_as_owner`. */
126
149
  opensInNewTab?: boolean;
150
+ /** Zero-roundtrip new-tab target: a path template (`{recordId}` placeholder)
151
+ * the handler navigates the pre-opened tab to IMMEDIATELY on click, skipping
152
+ * the action POST entirely. Only valid with `opensInNewTab`; the endpoint
153
+ * must perform all auth/authz itself (e.g. the cloud `/sso-open` endpoint,
154
+ * which re-runs every check the POST half would have done). */
155
+ newTabUrl?: string;
127
156
  /** Any additional properties */
128
157
  [key: string]: any;
129
158
  }
@@ -143,6 +172,11 @@ export type ConfirmationHandler = (message: string, options?: {
143
172
  export type ToastHandler = (message: string, options?: {
144
173
  type?: 'success' | 'error' | 'info' | 'warning';
145
174
  duration?: number;
175
+ /** When set, the toast offers an "Undo" affordance (the runner has already
176
+ * pushed the operation onto the global UndoManager). */
177
+ undo?: {
178
+ label?: string;
179
+ };
146
180
  }) => void;
147
181
  /**
148
182
  * Modal handler — consumers provide to render modal dialogs.
@@ -162,6 +196,35 @@ export type NavigationHandler = (url: string, options?: {
162
196
  * Returns collected values, or null if cancelled.
163
197
  */
164
198
  export type ParamCollectionHandler = (params: ActionParamDef[], action?: ActionDef) => Promise<Record<string, any> | null>;
199
+ /**
200
+ * Result dialog spec — declarative description of how to render a
201
+ * one-shot reveal of an action's API response. Mirrors
202
+ * `Action.resultDialog` in @objectstack/spec.
203
+ *
204
+ * When set on an action and the action succeeds, the runner suppresses
205
+ * the success toast and awaits a ResultDialogHandler instead. Used for
206
+ * values the user must copy NOW (2FA secret, OAuth client_secret, backup
207
+ * codes).
208
+ */
209
+ export interface ResultDialogFieldSpec {
210
+ path: string;
211
+ label?: string;
212
+ format?: 'qrcode' | 'code-list' | 'secret' | 'text' | 'json';
213
+ }
214
+ export interface ResultDialogSpec {
215
+ title?: string;
216
+ description?: string;
217
+ acknowledge?: string;
218
+ format?: 'qrcode' | 'code-list' | 'secret' | 'text' | 'json';
219
+ fields?: ResultDialogFieldSpec[];
220
+ }
221
+ /**
222
+ * Result dialog handler — consumers provide to render the post-success
223
+ * reveal dialog. Returns a promise that resolves when the user
224
+ * acknowledges. Errors / rejections are treated as acknowledge for
225
+ * cleanup purposes (the action already succeeded).
226
+ */
227
+ export type ResultDialogHandler = (spec: ResultDialogSpec, data: unknown, action?: ActionDef) => Promise<void>;
165
228
  /**
166
229
  * ActionParam definition accepted by the runner.
167
230
  * Compatible with @objectstack/spec ActionParam.
@@ -210,6 +273,7 @@ export declare class ActionRunner {
210
273
  private modalHandler;
211
274
  private navigationHandler;
212
275
  private paramCollectionHandler;
276
+ private resultDialogHandler;
213
277
  constructor(context?: ActionContext);
214
278
  /**
215
279
  * Set a custom confirmation handler (e.g., Shadcn AlertDialog).
@@ -232,6 +296,12 @@ export declare class ActionRunner {
232
296
  * ActionParam values before action execution.
233
297
  */
234
298
  setParamCollectionHandler(handler: ParamCollectionHandler): void;
299
+ /**
300
+ * Set a result dialog handler — shows a one-shot reveal dialog after
301
+ * a successful action whose spec declares `resultDialog`. Used for
302
+ * values the user must copy now (2FA codes, OAuth client_secret).
303
+ */
304
+ setResultDialogHandler(handler: ResultDialogHandler): void;
235
305
  registerHandler(actionName: string, handler: ActionHandler): void;
236
306
  unregisterHandler(actionName: string): void;
237
307
  /**
@@ -275,6 +345,14 @@ export declare class ActionRunner {
275
345
  /**
276
346
  * Execute navigation action
277
347
  */
348
+ /**
349
+ * `form` action — open a FormView as a routed page (`/forms/:name`, per the
350
+ * spec). `target` is the FormView name. Without this case the action fell
351
+ * through to `executeActionSchema` and silently no-opped (the "Log Time does
352
+ * nothing" report). The current record id is forwarded as `?recordId=` for
353
+ * hosts that support it; the form route ignores unknown query params.
354
+ */
355
+ private executeForm;
278
356
  private executeNavigation;
279
357
  /**
280
358
  * Execute API action — supports both simple string endpoint and complex ApiConfig.
@@ -284,6 +362,34 @@ export declare class ActionRunner {
284
362
  * Validate URL to prevent javascript: or data: protocol injection.
285
363
  */
286
364
  private isValidUrl;
365
+ /**
366
+ * Substitute `${param.X}` and `${ctx.X}` tokens in an action target.
367
+ *
368
+ * Used by executeUrl and executeAPI so metadata can produce dynamic
369
+ * URLs without coupling to the wider ExpressionEvaluator surface
370
+ * (which would also evaluate operators, member access, etc. — overkill
371
+ * and unsafe for URL-position values).
372
+ *
373
+ * Every substituted value is `encodeURIComponent`'d, since the only
374
+ * legitimate use case so far (better-auth `/sign-in/social?provider=…`)
375
+ * places interpolated values in URL query positions. Callers MUST
376
+ * therefore put tokens after a `?` or `&` — using `${param.x}` inside a
377
+ * path segment will produce percent-encoded slashes, which is the
378
+ * correct behaviour for opaque IDs but would break a literal path
379
+ * fragment.
380
+ *
381
+ * `ctx.origin`, `ctx.user.*`, `ctx.org.*`, `ctx.recordId` are surfaced
382
+ * implicitly from the runner's ActionContext + window. Consumers can
383
+ * extend by stuffing extra keys under `context.ctx = {...}` before
384
+ * calling `runner.execute()`.
385
+ */
386
+ private interpolateTarget;
387
+ /**
388
+ * Assemble the `ctx` namespace exposed to target interpolation.
389
+ * Built-in keys: origin, user, org, recordId. Anything the consumer
390
+ * placed under context.ctx is merged on top.
391
+ */
392
+ private buildInterpolationContext;
287
393
  updateContext(newContext: Partial<ActionContext>): void;
288
394
  getContext(): ActionContext;
289
395
  /**