@object-ui/core 15.0.0 → 16.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,252 @@
1
1
  # @object-ui/core
2
2
 
3
+ ## 16.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1c8935a: feat(app-shell): render ActionParamDialog params through the shared form field-widget renderer (ADR-0059, #2700)
8
+
9
+ `ActionParamDialog` no longer hand-rolls a per-type ternary chain (select /
10
+ lookup / textarea / number / boolean, everything else → text input). Every
11
+ declared action param now renders through the same `fieldWidgetMap` the object
12
+ form uses, so a param of ANY form-supported field type — `file`, `image`,
13
+ `richtext`, `markdown`, `color`, `address`, `code`, `date`, … — gets its real
14
+ widget, lazily loaded behind `Suspense`. Subsumes the single `file` branch ask
15
+ in #2698: `type: 'file'` params render the real `FileField` upload control via
16
+ the ambient `UploadProvider`, honoring `multiple`/`accept`/`maxSize`.
17
+
18
+ - `@object-ui/fields`: new exports `resolveFormWidgetType(type)` (widget-key
19
+ resolution incl. spec aliases, text fallback) and `getLazyFieldWidget(type)`
20
+ (per-type-cached `React.lazy` over the form's own widget loaders).
21
+ - `@object-ui/core`: `ActionParamDef` gains `accept`/`maxSize`; `multiple` is
22
+ now general widget config (was lookup-only).
23
+ - `@object-ui/app-shell`: new pure `paramToField()` adapter (param → field
24
+ shape) with a drift test pinning param support ⊇ form support (`FORM_FIELD_TYPES`),
25
+ mirroring the FieldEditWidget parity guard; `resolveActionParams()` inherits
26
+ `multiple`/`accept`/`maxSize` from the referenced field for every type.
27
+ `required` validation, `visible` CEL gating, helpText, error styling, and
28
+ value shapes for previously-supported types are unchanged.
29
+
30
+ - 2e7d7f0: feat(evaluator): route `{ dialect: 'cel' }` component/action predicates to the canonical CEL engine (#2661)
31
+
32
+ Component and action `visible` / `disabled` / `hidden` predicates were evaluated
33
+ by the home-grown JS `ExpressionEvaluator`, while field rules
34
+ (`visibleWhen`/`readonlyWhen`/`requiredWhen`, via `fieldRules.ts`) and row/list
35
+ conditionals (via `evalRowPredicate`) already delegate to the canonical
36
+ `@objectstack/formula` engine. That split meant a `{ dialect: 'cel' }` predicate
37
+ in a renderer/action surface was executed as **JavaScript** — CEL-only forms
38
+ (`x in list`, `has()`, typed `==`, the `today()`/`daysFromNow()` catalog) behaved
39
+ differently from, or faulted against, the server's enforcement.
40
+
41
+ This converges the remaining tier onto the same engine:
42
+
43
+ - **`@object-ui/core`** — `ExpressionEvaluator.evaluateCondition` now detects a
44
+ `{ dialect: 'cel', source }` envelope and evaluates it on `@objectstack/formula`
45
+ (via `evalFieldPredicate`), binding the `record` namespace plus the whole
46
+ context bag as top-level scope (`record.*`, `features.*`, `user.*`, `app.*`).
47
+ Fail-soft to visible/enabled to match the legacy default; `throwOnError`
48
+ callers still fail closed on a _faulting_ predicate (a genuine `false` never
49
+ throws). This fixes every `SchemaRenderer` visibility/disabled read at once.
50
+ - **`@object-ui/react`** — `toPredicateInput` preserves a CEL envelope instead of
51
+ collapsing it to a `${source}` string, and `useCondition` accepts and forwards
52
+ the envelope (keyed on a stable `(dialect, source)` so it doesn't re-evaluate
53
+ each render). Action buttons (`action-icon`/`group`/`bar`/`button`) therefore
54
+ evaluate CEL `visible`/`enabled`/`disabled` on the canonical engine.
55
+
56
+ **Back-compat:** bare strings and `${…}` templates stay on the legacy JS path
57
+ (deprecation window); only an explicit `{ dialect: 'cel' }` envelope is rerouted.
58
+ `{ dialect: 'template' }` is unaffected.
59
+
60
+ Together with the `^15.1.1` alignment (#2662), a renderer CEL predicate now
61
+ reaches the identical verdict as the server — including the framework's
62
+ `dateField == today()` equality fix (objectstack-ai/framework#3205) once it
63
+ lands in a published 15.x. The broader home-grown-vs-canonical divergence
64
+ motivation is #2661.
65
+
66
+ - 31b77d4: **Add the explicit `engine-owned` lifecycle bucket (tracks framework ADR-0103 addendum / #3343).** The framework split the overloaded `managedBy: 'system'` bucket by promoting the engine-owned case to its own enum value; this mirrors it in the UI type + runtime + badge.
67
+
68
+ - **`@object-ui/types`** — `ManagedByBucket` union and `MANAGED_BY_BUCKETS` gain `'engine-owned'` (canonical order: `platform, config, system, engine-owned, append-only, better-auth`). The union stays closed, so every consumer that missed the new value is a compile error.
69
+ - **`@object-ui/core`** — `resolveCrudAffordances` gains the `engine-owned` default row (identical all-locked matrix as `system`/`append-only`), so `isObjectInlineEditable` / the grid + form gates treat it as read-only automatically.
70
+ - **`@object-ui/app-shell`** — the `ManagedByBadge` renders `engine-owned` with the same read-only "System-managed" copy as a locked `system` object (reuses the existing `managedByBadge.system` i18n key — zero translation churn; the distinction is at the schema level, not the user-facing string), and `resolveManagedByEmptyState` reuses the `system` engine-owned empty state.
71
+
72
+ Behaviour-preserving: `engine-owned` resolves to the same locked affordances `system` did by default, so nothing about how a locked object renders changes — the value just makes the schema self-documenting. New unit coverage for the bucket in `resolveCrudAffordances` / `isObjectInlineEditable` / `MANAGED_BY_BUCKETS` / the empty-state helper.
73
+
74
+ - 62b9ab5: feat(data): unify master-detail saves behind `DataSource.batchTransaction`, isolate the non-atomic fallback in the adapter (#2679)
75
+
76
+ Master-detail saves (`MasterDetailForm`, `LineItemsPanel`) now always persist
77
+ through `dataSource.batchTransaction(operations)` — one ordered cross-object
78
+ operation list, with `{ $ref: <op index> }` linking a child to a parent created
79
+ in the same batch. The form no longer contains any client-side orchestration or
80
+ best-effort compensation-delete; that atomicity anti-pattern is gone from the UI
81
+ layer (framework #1604 / framework ADR-0034 item 4).
82
+
83
+ - **`@object-ui/types`** — `batchTransaction?` is now a first-class (optional)
84
+ method on the `DataSource` contract, typed via `BatchTransactionOperation` /
85
+ `BatchRef`. Replaces the previous `(dataSource as any).batchTransaction`
86
+ method-sniffing.
87
+ - **`@object-ui/core`** — new `emulateBatchTransaction(dataSource, operations)`
88
+ (sequential writes, `$ref` resolution, best-effort reverse-order compensation)
89
+ and `runBatchTransaction(dataSource, operations)` (prefers the adapter's method,
90
+ emulates otherwise). `ApiDataSource` / `ValueDataSource` implement
91
+ `batchTransaction` via the emulation.
92
+ - **`@object-ui/data-objectstack`** — `ObjectStackAdapter.batchTransaction` uses
93
+ the server's atomic `POST /api/v1/batch`, prefers the typed
94
+ `client.data.batchTransaction` SDK method when the installed client exposes it,
95
+ and degrades to the client-side emulation ONLY when the endpoint is missing
96
+ (404/405) or the runtime can't do transactions (501). Real errors (400/401/403/
97
+ 409/500) still surface. This is the isolated, tested home of the non-atomic
98
+ fallback.
99
+ - **`@object-ui/plugin-form`** — removed `applyDetail` / `createMany` /
100
+ `ApplyDetailResult` from `masterDetailTx.ts`; `MasterDetailForm` and
101
+ `LineItemsPanel` build ops and call `runBatchTransaction`. `LineItemsPanel`
102
+ saves are now atomic on a capable backend, with the rollup folded into the same
103
+ batch.
104
+
105
+ No behavior change on a current ObjectStack backend (it has `/api/v1/batch`);
106
+ older/limited backends keep a working — now clearly non-atomic — save path.
107
+
108
+ - 1629313: feat(fields): RadioField per-option `visibleWhen` cascading + `dependsOn` gating; single-source the option resolver
109
+
110
+ Brings `RadioField` to parity with `SelectField` / `MultiSelectField` for ADR-0058
111
+ cascading & role-gated options, and collapses the three copies of the
112
+ gate-then-filter logic onto one shared resolver.
113
+
114
+ - **`@object-ui/core`**: new pure `resolveCascadingOptions(rawOptions, record, dependsOn, scope)`
115
+ → `{ options, gated, dependsOnFields }` — the single source of truth for
116
+ `dependsOn` gating + per-option `visibleWhen` filtering.
117
+ - **`@object-ui/fields`**: `RadioField` now narrows its offered radios against
118
+ the live record + `current_user`, gates behind a "select the parent first"
119
+ hint while a `dependsOn` field is empty, and clears a value no longer offered
120
+ (scalar cascade clear). The `useCascadingOptions` hook is refactored to a thin
121
+ React wrapper over `resolveCascadingOptions`.
122
+ - **`@object-ui/components`**: the form renderer's inline option pre-filter and
123
+ cross-field cascade-clear effect now call `resolveCascadingOptions` instead of
124
+ re-deriving gating/filtering, so they can't drift from the widgets (no
125
+ behavior change).
126
+ - Tests: `RadioField.cascade.test.tsx` mirrors the select cascade tests; core
127
+ gains `resolveCascadingOptions` unit coverage.
128
+
129
+ - 2331ac9: feat(report): drill a date-bucket cell into its time range, not a superset (#1752)
130
+
131
+ Clicking a report/dashboard cell grouped by a `dateGranularity` date dimension
132
+ ("2026-Q2") used to drill into a **superset** — the date dimension was skipped,
133
+ so the record list spanned every time bucket. It now scopes to the clicked
134
+ bucket's half-open range, consuming the framework's new `drillRanges` sidecar.
135
+
136
+ - **`@object-ui/core`** — `buildDatasetDrillFilter` accepts the per-row
137
+ `drillRanges` and emits an ObjectQL range operator object
138
+ (`{ [field]: { $gte, $lt } }`) alongside the equality dims.
139
+ - **`@object-ui/plugin-report` / `@object-ui/plugin-dashboard`** — the report
140
+ renderer and dashboard widget forward `drillRanges`, and a **date-only**
141
+ report (no equality drill dim) is now drillable via the range alone.
142
+ - **`@object-ui/app-shell`** — the "Open in list →" escape hatch
143
+ (`useOpenRecordList`) now targets the ADR-0055 **bare data surface**
144
+ (`/:object/data`, "the URL is the view" — no baked-in view filter to
145
+ over-narrow the drill) and serializes a range to the
146
+ `filter[field][gte|lt]` operator contract. `ObjectDataPage` parses those
147
+ operators (equality shorthand unchanged), renders a range as a single chip,
148
+ and removes both bounds together. A new `drillUrlFilters` module owns the
149
+ write/read serialization so both sides can't drift (round-trip tested).
150
+
151
+ Companion to the framework analytics change (objectstack-ai/framework#3256).
152
+
153
+ ### Patch Changes
154
+
155
+ - 8b8b744: chore(deps): align `@objectstack/formula` / `lint` / `client` to `^15.1.1`
156
+
157
+ These three were still pinned to `^14.6.0` while `@objectstack/spec` was already
158
+ `^15.1.1` — a version skew from the v15 upgrade (formula/lint/client publish in
159
+ lockstep with spec, and their own 15.0.0 entries are pure dependency bumps, so
160
+ this is alignment, not a behavioral migration).
161
+
162
+ Practical effect: the client-side field-rule evaluation
163
+ (`visibleWhen`/`readonlyWhen`/`requiredWhen` via `fieldRules.ts`, which delegates
164
+ to `@objectstack/formula`'s `ExpressionEngine`) now tracks the 15.x engine — and
165
+ will pick up the framework's `dateField == today()` equality fix
166
+ (objectstack-ai/framework#3205) automatically at the next 15.x release via the
167
+ caret range. Renderer/action `visible`/`disabled` predicates are unaffected (they
168
+ use the home-grown JS evaluator — tracked separately in #2661).
169
+
170
+ - 7cf4051: chore(deps): align every `@objectstack/*` dependency to `^16.0.0-rc.0`
171
+
172
+ Bumps `@objectstack/spec` / `client` / `formula` / `lint` from `^15.1.1` to the
173
+ `16.0.0-rc.0` pre-release across the workspace (root + `apps/console` +
174
+ `apps/site` + all consuming packages). ObjectUI's own packages are already on
175
+ major 16, so this closes the 15↔16 skew between ObjectUI and the `@objectstack`
176
+ contract libraries (which publish in lockstep with `spec`).
177
+
178
+ This is a dependency alignment, not a behavioral migration: the full workspace
179
+ build (43/43) and the `@objectstack`-consuming package test suites
180
+ (`core` / `app-shell` / `data-objectstack` / `plugin-form` / `types`) are green
181
+ against `16.0.0-rc.0` with no source changes required.
182
+
183
+ Practical effect: `@objectstack/client@16.0.0-rc.0` now ships
184
+ `data.batchTransaction` (framework #3271), so `ObjectStackAdapter`'s feature
185
+ detect (`typeof client.data.batchTransaction === 'function'`) routes
186
+ master-detail cross-object saves through the typed SDK method instead of the
187
+ raw `fetch('/api/v1/batch')` fallback — realizing the "verify SDK path" half of
188
+ #2694. The raw-fetch branch stays as a defensive fallback (removal tracked in
189
+ #2694).
190
+
191
+ - 6d4fbe6: **Consolidate the `managedBy` lifecycle-bucket logic into one shared source of truth (follows framework ADR-0103).** The bucket taxonomy was hand-mirrored in several places — `crudAffordances.ts`, `ManagedByBadge.tsx` (its own `Bucket` union + `isWriteOptedIn` + the writable-system derivation), and `plugin-detail`'s `record-details.tsx` (`NON_EDITABLE_BUCKETS`, duplicated because it can't depend on app-shell) — a drift risk, and the object-schema `managedBy` type was open-ended (`(string & {})`) so unknown buckets slipped through and silently defaulted to fully-editable.
192
+
193
+ - **`@object-ui/types`** now owns the closed `ManagedByBucket` union (+ `MANAGED_BY_BUCKETS`), and `ObjectSchema.managedBy` is tightened from `'platform' | 'better-auth' | (string & {})` to that union — unknown buckets are now a type error at authoring time.
194
+ - **`@object-ui/core`** now owns the React-free runtime logic — `resolveCrudAffordances`, `isWriteOptedIn`, `isSystemWritable`, `isObjectInlineEditable` — reachable by every UI package including `plugin-detail` (which could not import app-shell).
195
+ - **`app-shell/utils/crudAffordances.ts`** is now a thin re-export of `@object-ui/core` (existing imports keep working); `ManagedByBadge` consumes the shared `isSystemWritable`; `plugin-detail` `record-details.tsx` replaces its hand-mirrored `NON_EDITABLE_BUCKETS` with `isObjectInlineEditable`.
196
+
197
+ Behavior-preserving — all existing affordance/edit-gate tests stay green; the shared module adds direct unit coverage (including the previously-untested `isSystemWritable` derivation). Translated copy (badge variants, empty-state messages) stays in app-shell.
198
+
199
+ - 0a3710b: **Finish the `managedBy` / `userActions` de-dup — one parser for the override shape (completes objectui#2712, framework#3343).** #2712 consolidated the bucket _union_ + affordance _set_ mirrors but left four surfaces still parsing the `userActions.{create,edit,delete}` override shape by hand. They now all route through the shared `@object-ui/core` policy, so no package re-implements the boolean / #2614-object-form parse locally.
200
+
201
+ - **`@object-ui/core`** promotes the internal `normalizeOverride` to the exported **`normalizeUserAction(v, base)`** (the one parser) and adds **`userActionPredicates(v)`** for per-record CEL predicate extraction.
202
+ - **`app-shell/utils/managedByEmptyState.ts`** — the writable-`system` create check and its local `EmptyStateUserActions` interface are replaced by `resolveCrudAffordances({ managedBy, userActions }).create`.
203
+ - **`plugin-grid/rowCrudAffordances.ts`** — the local `isOptedOut` / `predicatesOf` helpers (and duplicated `RowCrudUserAction` / `RowCrudPredicates` types) fold into `normalizeUserAction`; the historical type names stay re-exported for compat.
204
+ - **`plugin-detail/RelatedList.tsx`** — its inline `predicatesOf` fold into `userActionPredicates`.
205
+ - **`plugin-form/ObjectForm.tsx`** — the hand-rolled `managedBy !== 'platform'` blanket lock + `userActions` unlock is replaced by the resolved affordance for the current mode (`edit` / `create`), the **same** `resolveCrudAffordances` contract the detail (`isObjectInlineEditable`) and grid surfaces use.
206
+
207
+ Behavior-preserving for `platform` / `system` / `append-only` / `better-auth`, with one deliberate alignment: an admin-editable **`config`**-bucket object (e.g. `sys_webhook`, `sys_permission_set`) is now editable in `ObjectForm` — it was previously over-locked as "non-`platform`", while detail/grid already treated it as editable (`config` resolves `edit: true`). New unit coverage for the shared parser and the config / create-mode form gate; all existing affordance/edit-gate tests stay green.
208
+
209
+ - eee4ded: feat(fields): render `select` + `multiple` through the multi-value chip picker; restore fields/core lint gates
210
+
211
+ - **Multi-value select** — a `select` field/param declared `multiple: true`
212
+ now renders the multi-value chip picker (the `multiselect` widget) and stores
213
+ a `string[]`, instead of collapsing to a single-value dropdown that could
214
+ hold only one value. The delegation lives inside `SelectField`, so the object
215
+ form, the inline grid editor, and the app-shell `ActionParamDialog` all
216
+ inherit it from the one `select` widget with no per-surface drift. Single
217
+ selects keep the cascading dropdown (multi + per-option `visibleWhen`
218
+ cascading is not a combination in use today).
219
+ - **`autonumber` mapping is unchanged** here; this change is orthogonal.
220
+ - **Lint gates restored** — fixed the pre-existing baseline lint errors that
221
+ had left the `@object-ui/fields` and `@object-ui/core` package lints red (so
222
+ the gate could not catch new violations): `react-hooks/rules-of-hooks` in
223
+ `ImageField` / `TextAreaField` / `index.tsx` (hooks hoisted above early
224
+ returns; the `useFieldTranslate` hook no longer wrapped in try/catch), plus
225
+ `no-useless-assignment` / `no-useless-escape` / `no-control-regex` /
226
+ `prefer-const` / `preserve-caught-error` in the core evaluator and utils. No
227
+ behavior change from the lint fixes.
228
+
229
+ - Updated dependencies [7cf4051]
230
+ - Updated dependencies [94d4876]
231
+ - Updated dependencies [2b17339]
232
+ - Updated dependencies [31b77d4]
233
+ - Updated dependencies [6d4fbe6]
234
+ - Updated dependencies [62b9ab5]
235
+ - Updated dependencies [29c6040]
236
+ - Updated dependencies [faebac3]
237
+ - Updated dependencies [199fa83]
238
+ - @object-ui/types@16.1.0
239
+
240
+ ## 16.0.0
241
+
242
+ ### Patch Changes
243
+
244
+ - Updated dependencies [210806a]
245
+ - Updated dependencies [b4ef588]
246
+ - Updated dependencies [5534535]
247
+ - Updated dependencies [9b8f978]
248
+ - @object-ui/types@16.0.0
249
+
3
250
  ## 15.0.0
4
251
 
5
252
  ### Patch Changes
@@ -258,6 +258,12 @@ export interface ActionParamDef {
258
258
  * gated on `features.phoneNumber`). Absent = always visible.
259
259
  */
260
260
  visible?: string;
261
+ /** Accepted upload types (MIME types / extensions) for `file`/`image` params. */
262
+ accept?: string[];
263
+ /** Max upload size in bytes for `file`/`image` params. */
264
+ maxSize?: number;
265
+ /** Allow multiple values (file/image/lookup/user params → array value). */
266
+ multiple?: boolean;
261
267
  /** Object name the lookup picker queries (`reference_to` on the field). */
262
268
  referenceTo?: string;
263
269
  /** Field on the referenced record used as the human label (default `name`). */
@@ -266,8 +272,6 @@ export interface ActionParamDef {
266
272
  idField?: string;
267
273
  /** Optional secondary line shown under the label in the picker. */
268
274
  descriptionField?: string;
269
- /** Allow multi-select (renders chip list). */
270
- multiple?: boolean;
271
275
  /** Template (e.g. `{first_name} {last_name}`) used to build the label. */
272
276
  titleFormat?: string;
273
277
  /** Column definitions surfaced in the enterprise `RecordPickerDialog`. */
@@ -8,7 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'api'` ViewData mode.
9
9
  * Makes raw HTTP requests using the HttpRequest configs from ViewData.
10
10
  */
11
- import type { DataSource, QueryParams, QueryResult, HttpRequest, AggregateParams, AggregateResult } from '@object-ui/types';
11
+ import type { DataSource, BatchTransactionOperation, QueryParams, QueryResult, HttpRequest, AggregateParams, AggregateResult } from '@object-ui/types';
12
12
  export interface ApiDataSourceConfig {
13
13
  /** HttpRequest config for read operations (find, findOne) */
14
14
  read?: HttpRequest;
@@ -54,6 +54,16 @@ export declare class ApiDataSource<T = any> implements DataSource<T> {
54
54
  delete(_resource: string, id: string | number, opts?: {
55
55
  ifMatch?: string;
56
56
  }): Promise<boolean>;
57
+ /**
58
+ * Client-side (non-atomic) cross-object batch — a generic REST endpoint has
59
+ * no transactional batch primitive, so this delegates to the shared
60
+ * emulation (sequential writes + `$ref` resolution + best-effort
61
+ * compensation). Adapters fronting a server that DOES offer an atomic batch
62
+ * should override this to issue a single request.
63
+ */
64
+ batchTransaction(operations: BatchTransactionOperation[]): Promise<{
65
+ results: any[];
66
+ }>;
57
67
  getObjectSchema(_objectName: string): Promise<any>;
58
68
  getView(_objectName: string, _viewId: string): Promise<any | null>;
59
69
  getApp(_appId: string): Promise<any | null>;
@@ -8,6 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'api'` ViewData mode.
9
9
  * Makes raw HTTP requests using the HttpRequest configs from ViewData.
10
10
  */
11
+ import { emulateBatchTransaction } from './batchTransaction.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
13
14
  // ---------------------------------------------------------------------------
@@ -241,6 +242,16 @@ export class ApiDataSource {
241
242
  return false;
242
243
  }
243
244
  }
245
+ /**
246
+ * Client-side (non-atomic) cross-object batch — a generic REST endpoint has
247
+ * no transactional batch primitive, so this delegates to the shared
248
+ * emulation (sequential writes + `$ref` resolution + best-effort
249
+ * compensation). Adapters fronting a server that DOES offer an atomic batch
250
+ * should override this to issue a single request.
251
+ */
252
+ batchTransaction(operations) {
253
+ return emulateBatchTransaction(this, operations);
254
+ }
244
255
  async getObjectSchema(_objectName) {
245
256
  // Generic API endpoints typically don't expose schema metadata.
246
257
  // Return a minimal stub so schema-dependent components don't crash.
@@ -8,7 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'value'` ViewData mode.
9
9
  * Operates entirely on an in-memory array — no network requests.
10
10
  */
11
- import type { DataSource, MutationEvent, QueryParams, QueryResult, AggregateParams, AggregateResult } from '@object-ui/types';
11
+ import type { DataSource, BatchTransactionOperation, MutationEvent, QueryParams, QueryResult, AggregateParams, AggregateResult } from '@object-ui/types';
12
12
  export interface ValueDataSourceConfig<T = any> {
13
13
  /** The static data array */
14
14
  items: T[];
@@ -48,6 +48,14 @@ export declare class ValueDataSource<T = any> implements DataSource<T> {
48
48
  update(_resource: string, id: string | number, data: Partial<T>): Promise<T>;
49
49
  delete(_resource: string, id: string | number): Promise<boolean>;
50
50
  bulk(_resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]>;
51
+ /**
52
+ * Client-side (non-atomic) cross-object batch — this in-memory adapter has
53
+ * no server transaction, so it delegates to the shared emulation. Per-op
54
+ * MutationEvents come from the `create`/`update`/`delete` primitives above.
55
+ */
56
+ batchTransaction(operations: BatchTransactionOperation[]): Promise<{
57
+ results: any[];
58
+ }>;
51
59
  getObjectSchema(_objectName: string): Promise<any>;
52
60
  getView(_objectName: string, _viewId: string): Promise<any | null>;
53
61
  getApp(_appId: string): Promise<any | null>;
@@ -8,6 +8,7 @@
8
8
  * A DataSource adapter for the `provider: 'value'` ViewData mode.
9
9
  * Operates entirely on an in-memory array — no network requests.
10
10
  */
11
+ import { emulateBatchTransaction } from './batchTransaction.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Helpers
13
14
  // ---------------------------------------------------------------------------
@@ -341,6 +342,14 @@ export class ValueDataSource {
341
342
  }
342
343
  return results;
343
344
  }
345
+ /**
346
+ * Client-side (non-atomic) cross-object batch — this in-memory adapter has
347
+ * no server transaction, so it delegates to the shared emulation. Per-op
348
+ * MutationEvents come from the `create`/`update`/`delete` primitives above.
349
+ */
350
+ batchTransaction(operations) {
351
+ return emulateBatchTransaction(this, operations);
352
+ }
344
353
  async getObjectSchema(_objectName) {
345
354
  // Infer a minimal schema from the first item
346
355
  if (this.items.length === 0)
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ObjectUI — batchTransaction emulation
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
+ * Client-side emulation of {@link DataSource.batchTransaction} for adapters
9
+ * that lack a server-side transactional batch endpoint. This is the single,
10
+ * tested home for the non-atomic fallback (ObjectStack objectui #2679 /
11
+ * framework #1604 / ADR-0034 item 4): components call `runBatchTransaction`
12
+ * unconditionally and never orchestrate cross-object writes themselves.
13
+ *
14
+ * The emulation runs operations sequentially — NOT the old `bulk('create')`
15
+ * grouping — because sequential execution is what makes `{ $ref: i }`
16
+ * resolution and ordered semantics correct (a child create must observe the
17
+ * parent's freshly-minted id). Line-item counts are small, so the extra
18
+ * round-trips are acceptable.
19
+ */
20
+ import type { BatchTransactionOperation, DataSource } from '@object-ui/types';
21
+ /**
22
+ * Emulate an atomic batch over a plain DataSource by executing its `create`/
23
+ * `update`/`delete` primitives in order.
24
+ *
25
+ * NOT atomic: on failure it best-effort compensates by deleting the records
26
+ * it created (reverse order — children before parent), then rethrows the
27
+ * original error. Updates and deletes that already ran cannot be undone; a
28
+ * compensation delete that itself fails is swallowed (`Promise.allSettled`).
29
+ * This is the documented reason a server-backed `batchTransaction` is
30
+ * strictly better — a true transaction simply never commits.
31
+ *
32
+ * MutationEvents are deliberately NOT emitted here: the underlying
33
+ * `create`/`update`/`delete` primitives already emit per-op on adapters that
34
+ * support `onMutation`, so emitting here too would double-fire.
35
+ */
36
+ export declare function emulateBatchTransaction<T = any>(dataSource: DataSource<T>, operations: BatchTransactionOperation[]): Promise<{
37
+ results: any[];
38
+ }>;
39
+ /**
40
+ * Persist an ordered cross-object batch, preferring the adapter's native
41
+ * (potentially atomic) `batchTransaction` and falling back to
42
+ * {@link emulateBatchTransaction} when the adapter does not implement it.
43
+ *
44
+ * This is the single entry point UI components should call — they stay
45
+ * ignorant of whether the underlying save is truly atomic.
46
+ */
47
+ export declare function runBatchTransaction<T = any>(dataSource: DataSource<T>, operations: BatchTransactionOperation[]): Promise<{
48
+ results: any[];
49
+ }>;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * ObjectUI — batchTransaction emulation
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
+ * Client-side emulation of {@link DataSource.batchTransaction} for adapters
9
+ * that lack a server-side transactional batch endpoint. This is the single,
10
+ * tested home for the non-atomic fallback (ObjectStack objectui #2679 /
11
+ * framework #1604 / ADR-0034 item 4): components call `runBatchTransaction`
12
+ * unconditionally and never orchestrate cross-object writes themselves.
13
+ *
14
+ * The emulation runs operations sequentially — NOT the old `bulk('create')`
15
+ * grouping — because sequential execution is what makes `{ $ref: i }`
16
+ * resolution and ordered semantics correct (a child create must observe the
17
+ * parent's freshly-minted id). Line-item counts are small, so the extra
18
+ * round-trips are acceptable.
19
+ */
20
+ /** Best-effort id extraction across the record shapes adapters return. */
21
+ function idOf(rec) {
22
+ if (rec == null)
23
+ return undefined;
24
+ return rec.id ?? rec._id ?? rec.recordId;
25
+ }
26
+ /**
27
+ * Replace top-level `{ $ref: i }` values in an op payload with the id produced
28
+ * by operation `i`. Only backward references are valid — a forward or
29
+ * unresolved ref throws (the builders only ever emit `{ $ref: 0 }` pointing at
30
+ * an already-executed parent, so this is a guard, not a normal path).
31
+ */
32
+ function resolveRefs(data, results) {
33
+ const out = {};
34
+ for (const [key, value] of Object.entries(data)) {
35
+ if (value &&
36
+ typeof value === 'object' &&
37
+ !Array.isArray(value) &&
38
+ typeof value.$ref === 'number') {
39
+ const refIndex = value.$ref;
40
+ const id = idOf(results[refIndex]);
41
+ if (id === undefined) {
42
+ throw new Error(`batchTransaction: field "${key}" references operation ${refIndex}, ` +
43
+ `which has not produced an id (forward or invalid $ref)`);
44
+ }
45
+ out[key] = id;
46
+ }
47
+ else {
48
+ out[key] = value;
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ /**
54
+ * Emulate an atomic batch over a plain DataSource by executing its `create`/
55
+ * `update`/`delete` primitives in order.
56
+ *
57
+ * NOT atomic: on failure it best-effort compensates by deleting the records
58
+ * it created (reverse order — children before parent), then rethrows the
59
+ * original error. Updates and deletes that already ran cannot be undone; a
60
+ * compensation delete that itself fails is swallowed (`Promise.allSettled`).
61
+ * This is the documented reason a server-backed `batchTransaction` is
62
+ * strictly better — a true transaction simply never commits.
63
+ *
64
+ * MutationEvents are deliberately NOT emitted here: the underlying
65
+ * `create`/`update`/`delete` primitives already emit per-op on adapters that
66
+ * support `onMutation`, so emitting here too would double-fire.
67
+ */
68
+ export async function emulateBatchTransaction(dataSource, operations) {
69
+ const results = [];
70
+ const created = [];
71
+ try {
72
+ for (const op of operations) {
73
+ const action = op.action ?? 'create';
74
+ if (action === 'create') {
75
+ const record = await dataSource.create(op.object, resolveRefs(op.data ?? {}, results));
76
+ results.push(record);
77
+ const id = idOf(record);
78
+ if (id)
79
+ created.push({ object: op.object, id });
80
+ }
81
+ else if (action === 'update') {
82
+ if (op.id == null) {
83
+ throw new Error(`batchTransaction: update on "${op.object}" requires an id`);
84
+ }
85
+ results.push(await dataSource.update(op.object, op.id, resolveRefs(op.data ?? {}, results)));
86
+ }
87
+ else {
88
+ if (op.id == null) {
89
+ throw new Error(`batchTransaction: delete on "${op.object}" requires an id`);
90
+ }
91
+ results.push(await dataSource.delete(op.object, op.id));
92
+ }
93
+ }
94
+ return { results };
95
+ }
96
+ catch (err) {
97
+ // Best-effort compensation: undo the creates we made, newest first, so a
98
+ // child is removed before the parent it points at. Failures are ignored —
99
+ // there is nothing more we can do client-side.
100
+ if (created.length > 0) {
101
+ await Promise.allSettled([...created].reverse().map((c) => dataSource.delete(c.object, c.id)));
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+ /**
107
+ * Persist an ordered cross-object batch, preferring the adapter's native
108
+ * (potentially atomic) `batchTransaction` and falling back to
109
+ * {@link emulateBatchTransaction} when the adapter does not implement it.
110
+ *
111
+ * This is the single entry point UI components should call — they stay
112
+ * ignorant of whether the underlying save is truly atomic.
113
+ */
114
+ export function runBatchTransaction(dataSource, operations) {
115
+ return typeof dataSource.batchTransaction === 'function'
116
+ ? dataSource.batchTransaction(operations)
117
+ : emulateBatchTransaction(dataSource, operations);
118
+ }
@@ -8,3 +8,4 @@
8
8
  export { ApiDataSource, type ApiDataSourceConfig } from './ApiDataSource.js';
9
9
  export { ValueDataSource, type ValueDataSourceConfig } from './ValueDataSource.js';
10
10
  export { resolveDataSource, type ResolveDataSourceOptions } from './resolveDataSource.js';
11
+ export { emulateBatchTransaction, runBatchTransaction } from './batchTransaction.js';
@@ -11,3 +11,4 @@
11
11
  export { ApiDataSource } from './ApiDataSource.js';
12
12
  export { ValueDataSource } from './ValueDataSource.js';
13
13
  export { resolveDataSource } from './resolveDataSource.js';
14
+ export { emulateBatchTransaction, runBatchTransaction } from './batchTransaction.js';
@@ -86,6 +86,16 @@ export declare class ExpressionEvaluator {
86
86
  dialect?: string;
87
87
  source?: string;
88
88
  }, options?: EvaluationOptions): boolean;
89
+ /**
90
+ * Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula`
91
+ * engine (via `evalFieldPredicate`), binding this evaluator's context: the
92
+ * `record` key as the `record` namespace and the whole context bag as top-level
93
+ * scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to
94
+ * `true` (visible/enabled — the legacy default) unless the caller opted into
95
+ * `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL
96
+ * syntax) throws; a genuine `false` never throws.
97
+ */
98
+ private evaluateCelCondition;
89
99
  /**
90
100
  * Update the context with new data
91
101
  */
@@ -17,6 +17,7 @@
17
17
  import { ExpressionContext } from './ExpressionContext.js';
18
18
  import { ExpressionCache } from './ExpressionCache.js';
19
19
  import { FormulaFunctions } from './FormulaFunctions.js';
20
+ import { evalFieldPredicate } from './fieldRules.js';
20
21
  /**
21
22
  * Expression evaluator for dynamic UI expressions
22
23
  */
@@ -141,6 +142,10 @@ export class ExpressionEvaluator {
141
142
  return compiled.fn(...varValues);
142
143
  }
143
144
  catch (error) {
145
+ // The original error's message is inlined below. We can't pass it as the
146
+ // `Error` `cause` option because this package targets ES2020, whose lib
147
+ // types the 1-arg `Error` constructor only; hence the scoped disable.
148
+ // eslint-disable-next-line preserve-caught-error
144
149
  throw new Error(`Failed to evaluate expression "${expression}": ${error.message}`);
145
150
  }
146
151
  }
@@ -177,6 +182,18 @@ export class ExpressionEvaluator {
177
182
  if (typeof condition === 'boolean') {
178
183
  return condition;
179
184
  }
185
+ // #2661 — a CEL-dialect envelope routes to the canonical `@objectstack/formula`
186
+ // engine (the one `fieldRules` / list conditionals already use), NOT the legacy
187
+ // JS evaluator below. This makes a component / action `visible` / `disabled`
188
+ // predicate reach the SAME verdict as server enforcement — including CEL-only
189
+ // behavior like `record.due_date == today()` (framework#3205). Bare strings and
190
+ // `${…}` templates stay on the legacy path (back-compat deprecation window);
191
+ // only an explicit `{ dialect: 'cel' }` envelope is rerouted.
192
+ if (condition && typeof condition === 'object'
193
+ && condition.dialect === 'cel'
194
+ && typeof condition.source === 'string') {
195
+ return this.evaluateCelCondition(condition.source, options);
196
+ }
180
197
  // Unwrap Expression envelope (see `evaluate` for rationale).
181
198
  if (condition && typeof condition === 'object' && typeof condition.source === 'string') {
182
199
  condition = condition.source;
@@ -215,6 +232,37 @@ export class ExpressionEvaluator {
215
232
  return true;
216
233
  }
217
234
  }
235
+ /**
236
+ * Evaluate a `{ dialect: 'cel' }` predicate on the canonical `@objectstack/formula`
237
+ * engine (via `evalFieldPredicate`), binding this evaluator's context: the
238
+ * `record` key as the `record` namespace and the whole context bag as top-level
239
+ * scope so `record.*`, `features.*`, `user.*`, `app.*` all resolve. Fail-soft to
240
+ * `true` (visible/enabled — the legacy default) unless the caller opted into
241
+ * `throwOnError`, in which case a *faulting* predicate (bad field / non-CEL
242
+ * syntax) throws; a genuine `false` never throws.
243
+ */
244
+ evaluateCelCondition(source, options) {
245
+ if (!source.trim())
246
+ return true; // no predicate → visible/enabled
247
+ const bag = this.context.toObject();
248
+ const rec = bag.record;
249
+ const record = (rec && typeof rec === 'object' && !Array.isArray(rec))
250
+ ? rec
251
+ : bag;
252
+ if (!options.throwOnError) {
253
+ // Fast path: one evaluation, fail-soft to visible/enabled (legacy parity).
254
+ return evalFieldPredicate(source, record, true, undefined, bag);
255
+ }
256
+ // Fail-closed callers need to tell a genuine `false` from a fault. The
257
+ // canonical helper fails soft to the fallback, so a value that tracks the
258
+ // fallback in BOTH runs means the predicate faulted — then we throw.
259
+ const asTrue = evalFieldPredicate(source, record, true, undefined, bag);
260
+ const asFalse = evalFieldPredicate(source, record, false, undefined, bag);
261
+ if (asTrue !== asFalse) {
262
+ throw new Error(`CEL predicate failed to evaluate: ${source}`);
263
+ }
264
+ return asTrue;
265
+ }
218
266
  /**
219
267
  * Update the context with new data
220
268
  */
@@ -298,7 +298,10 @@ export class SafeExpressionParser {
298
298
  this.skipWhitespace();
299
299
  // eslint-disable-next-line no-constant-condition
300
300
  while (true) {
301
- let op = null;
301
+ // Every branch below either assigns `op` or `break`s out of the loop, so
302
+ // `op` is always set by the time the switch reads it (no null initializer
303
+ // needed).
304
+ let op;
302
305
  if (this.peek() === '=' && this.peek(1) === '=' && this.peek(2) === '=') {
303
306
  op = '===';
304
307
  this.pos += 3;
@@ -648,7 +651,7 @@ export class SafeExpressionParser {
648
651
  // Optional exponent: e+5, E-3
649
652
  if (/[eE]/.test(this.source[this.pos] ?? '')) {
650
653
  this.pos++; // consume 'e' or 'E'
651
- if (/[+\-]/.test(this.source[this.pos] ?? ''))
654
+ if (/[+-]/.test(this.source[this.pos] ?? ''))
652
655
  this.pos++; // optional sign
653
656
  let expDigits = 0;
654
657
  while (this.pos < this.source.length && /\d/.test(this.source[this.pos])) {
@@ -68,3 +68,25 @@ export declare function resolveVisibleOptions<T extends OptionLike>(options: rea
68
68
  * no longer offered). An empty value is always "valid" (nothing to clear).
69
69
  */
70
70
  export declare function isValueStillOffered(value: unknown, visibleOptions: readonly OptionLike[]): boolean;
71
+ /** The resolved state of a cascading / role-gated option list. */
72
+ export interface CascadingOptions<T extends OptionLike> {
73
+ /** Offered options after `visibleWhen` filtering. Empty while gated. */
74
+ options: T[];
75
+ /** True when a declared `dependsOn` field is empty — the list is gated. */
76
+ gated: boolean;
77
+ /** Normalized `dependsOn` field names (drives the "select the parent first" hint). */
78
+ dependsOnFields: string[];
79
+ }
80
+ /**
81
+ * Resolve an option field's offered set in one shot: normalize `dependsOn`,
82
+ * decide whether the list is gated (a controlling field is still empty), and —
83
+ * unless gated — filter by each option's `visibleWhen`. The single source of
84
+ * truth behind the option widgets' `useCascadingOptions` hook and the form
85
+ * renderer's inline pre-filter / cascade-clear, so gating and filtering can
86
+ * never drift between them (#2715).
87
+ *
88
+ * `gated` is true only when there IS a `dependsOn` and one of its fields is
89
+ * empty; a field with no `dependsOn` is never gated (its options are filtered
90
+ * purely by `visibleWhen`). Callers format their own hint from `dependsOnFields`.
91
+ */
92
+ export declare function resolveCascadingOptions<T extends OptionLike>(rawOptions: readonly T[] | undefined | null, record: Record<string, unknown>, dependsOn: DependsOnInput, scope?: Record<string, unknown>): CascadingOptions<T>;
@@ -78,3 +78,21 @@ export function isValueStillOffered(value, visibleOptions) {
78
78
  }
79
79
  return visibleOptions.some((o) => o.value === value);
80
80
  }
81
+ /**
82
+ * Resolve an option field's offered set in one shot: normalize `dependsOn`,
83
+ * decide whether the list is gated (a controlling field is still empty), and —
84
+ * unless gated — filter by each option's `visibleWhen`. The single source of
85
+ * truth behind the option widgets' `useCascadingOptions` hook and the form
86
+ * renderer's inline pre-filter / cascade-clear, so gating and filtering can
87
+ * never drift between them (#2715).
88
+ *
89
+ * `gated` is true only when there IS a `dependsOn` and one of its fields is
90
+ * empty; a field with no `dependsOn` is never gated (its options are filtered
91
+ * purely by `visibleWhen`). Callers format their own hint from `dependsOnFields`.
92
+ */
93
+ export function resolveCascadingOptions(rawOptions, record, dependsOn, scope) {
94
+ const dependsOnFields = resolveDependsOnFields(dependsOn);
95
+ const gated = dependsOnFields.length > 0 && isOptionGroupGated(dependsOn, record);
96
+ const options = gated ? [] : resolveVisibleOptions(rawOptions, record, scope);
97
+ return { options, gated, dependsOnFields };
98
+ }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export * from './registry/WidgetRegistry.js';
14
14
  export * from './validation/index.js';
15
15
  export * from './builder/schema-builder.js';
16
16
  export * from './utils/filter-converter.js';
17
+ export * from './utils/managedBy.js';
17
18
  export * from './utils/extract-records.js';
18
19
  export * from './utils/expand-fields.js';
19
20
  export * from './evaluator/index.js';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export * from './registry/WidgetRegistry.js';
13
13
  export * from './validation/index.js';
14
14
  export * from './builder/schema-builder.js';
15
15
  export * from './utils/filter-converter.js';
16
+ export * from './utils/managedBy.js';
16
17
  export * from './utils/extract-records.js';
17
18
  export * from './utils/expand-fields.js';
18
19
  export * from './evaluator/index.js';
@@ -69,6 +69,16 @@ export declare function buildDatasetFieldHelpers(fields: DatasetResultField[] |
69
69
  measureField: (name: string) => DatasetResultField | undefined;
70
70
  headerLabel: (name: string) => string;
71
71
  };
72
+ /**
73
+ * A half-open date-range drill scope for one time-bucketed dimension (#1752):
74
+ * the object FIELD to filter and its inclusive `gte` / exclusive `lt` bounds
75
+ * (the server's `drillRanges` sidecar entry).
76
+ */
77
+ export interface DatasetDrillRange {
78
+ field: string;
79
+ gte: unknown;
80
+ lt: unknown;
81
+ }
72
82
  /**
73
83
  * Build the record-list filter for a drilled dataset bucket (ADR-0021 D2).
74
84
  *
@@ -79,7 +89,14 @@ export declare function buildDatasetFieldHelpers(fields: DatasetResultField[] |
79
89
  * "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
80
90
  * list stays within the same slice the aggregate was computed over.
81
91
  *
92
+ * A time-bucketed date dimension (#1752) drills by RANGE, not equality — a
93
+ * humanized bucket ("2026-Q2") can't be exact-matched, so the server sends a
94
+ * half-open `[gte, lt)` per date dim in `rawRanges` instead of a raw value.
95
+ * Each becomes an ObjectQL range operator object (`{ $gte, $lt }`) so the drill
96
+ * scopes the list to the clicked time bucket instead of every bucket (which the
97
+ * old date-dim skip degraded to — a superset).
98
+ *
82
99
  * Shared by the dashboard `DatasetWidget` and the report renderer so a drill
83
100
  * filters identically (and correctly, including lookups) on both surfaces.
84
101
  */
85
- export declare function buildDatasetDrillFilter(rawRow: Record<string, unknown> | undefined, drillDims: string[], dimensionFields: Record<string, string>, runtimeFilter?: Record<string, unknown>): Record<string, unknown>;
102
+ export declare function buildDatasetDrillFilter(rawRow: Record<string, unknown> | undefined, drillDims: string[], dimensionFields: Record<string, string>, runtimeFilter?: Record<string, unknown>, rawRanges?: Record<string, DatasetDrillRange>): Record<string, unknown>;
@@ -104,14 +104,27 @@ export function buildDatasetFieldHelpers(fields, object, fieldLabel) {
104
104
  * "is empty" filter). The render-time `runtimeFilter` is ANDed in so the drilled
105
105
  * list stays within the same slice the aggregate was computed over.
106
106
  *
107
+ * A time-bucketed date dimension (#1752) drills by RANGE, not equality — a
108
+ * humanized bucket ("2026-Q2") can't be exact-matched, so the server sends a
109
+ * half-open `[gte, lt)` per date dim in `rawRanges` instead of a raw value.
110
+ * Each becomes an ObjectQL range operator object (`{ $gte, $lt }`) so the drill
111
+ * scopes the list to the clicked time bucket instead of every bucket (which the
112
+ * old date-dim skip degraded to — a superset).
113
+ *
107
114
  * Shared by the dashboard `DatasetWidget` and the report renderer so a drill
108
115
  * filters identically (and correctly, including lookups) on both surfaces.
109
116
  */
110
- export function buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, runtimeFilter) {
117
+ export function buildDatasetDrillFilter(rawRow, drillDims, dimensionFields, runtimeFilter, rawRanges) {
111
118
  const drillFilter = {};
112
119
  for (const d of drillDims) {
113
120
  const raw = rawRow?.[d];
114
121
  drillFilter[dimensionFields[d]] = raw === '' || raw === undefined ? null : raw;
115
122
  }
123
+ if (rawRanges) {
124
+ for (const r of Object.values(rawRanges)) {
125
+ if (r && r.field)
126
+ drillFilter[r.field] = { $gte: r.gte, $lt: r.lt };
127
+ }
128
+ }
116
129
  return runtimeFilter ? { ...runtimeFilter, ...drillFilter } : drillFilter;
117
130
  }
@@ -8,7 +8,10 @@
8
8
  * translated object label (e.g. 合同) → object API name → 'export'.
9
9
  */
10
10
  /** Windows-reserved + control characters that cannot appear in a filename. */
11
- const ILLEGAL_FILENAME_CHARS = /[\\\/:*?"<>|\u0000-\u001f]+/g;
11
+ // The control-char range in the class is matched deliberately (illegal in
12
+ // filenames, must be stripped), so no-control-regex is intentionally disabled.
13
+ // eslint-disable-next-line no-control-regex
14
+ const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|\u0000-\u001f]+/g;
12
15
  /** Longest base we emit — keeps the full name comfortably under OS limits. */
13
16
  const MAX_BASE_LENGTH = 80;
14
17
  /**
@@ -0,0 +1,101 @@
1
+ /**
2
+ * managedBy — the single source of truth for interpreting an object's
3
+ * lifecycle bucket (`ObjectSchema.managedBy`) into CRUD affordances.
4
+ *
5
+ * UI-side mirror of the framework's `resolveCrudAffordances()`
6
+ * (`@objectstack/spec/data/object.zod.ts`, ADR-0103). Lives in `@object-ui/core`
7
+ * — React-free and reachable by every UI package (app-shell, plugin-detail,
8
+ * plugin-form, plugin-grid) — so the bucket logic is defined ONCE instead of
9
+ * hand-mirrored in each. Components ask "should I show New / Import / Edit /
10
+ * Delete / Export?" and get an answer that tracks the object's lifecycle without
11
+ * special-casing `sys_*` names.
12
+ *
13
+ * The bucket UNION type lives in `@object-ui/types` (`ManagedByBucket`) so the
14
+ * schema field can reference it; this module re-exports it for convenience.
15
+ * Only the affordance BOOLEANS live here — all translated copy (badge variants,
16
+ * empty-state messages) stays in `@object-ui/app-shell`.
17
+ */
18
+ export type { ManagedByBucket } from '@object-ui/types';
19
+ export interface CrudAffordances {
20
+ /** Generic "New" button for single-record creation. */
21
+ create: boolean;
22
+ /** CSV bulk-import wizard. */
23
+ import: boolean;
24
+ /** Inline + form editing of existing rows. */
25
+ edit: boolean;
26
+ /** Row-level + bulk delete. */
27
+ delete: boolean;
28
+ /** CSV / clipboard export. */
29
+ exportCsv: boolean;
30
+ /**
31
+ * Per-record CEL predicates for the built-in row Edit/Delete actions,
32
+ * present only when `userActions.edit` / `delete` used the object form
33
+ * (objectui#2614). Carried through as authored for row renderers to evaluate;
34
+ * they never affect the object-level booleans above.
35
+ */
36
+ editPredicates?: RowCrudPredicates;
37
+ deletePredicates?: RowCrudPredicates;
38
+ }
39
+ /** Per-record predicates from the #2614 object form of a userActions flag. */
40
+ export interface RowCrudPredicates {
41
+ visibleWhen?: unknown;
42
+ disabledWhen?: unknown;
43
+ }
44
+ /** `edit`/`delete` accept a bare boolean or the #2614 object form. */
45
+ export type UserActionOverride = boolean | {
46
+ enabled?: boolean;
47
+ visibleWhen?: unknown;
48
+ disabledWhen?: unknown;
49
+ };
50
+ export interface UserActionsOverride {
51
+ create?: boolean;
52
+ import?: boolean;
53
+ edit?: UserActionOverride;
54
+ delete?: UserActionOverride;
55
+ exportCsv?: boolean;
56
+ }
57
+ export interface SchemaLike {
58
+ managedBy?: string | null;
59
+ userActions?: UserActionsOverride | null;
60
+ }
61
+ /**
62
+ * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
63
+ * the given bucket default, surfacing any per-record predicates alongside.
64
+ *
65
+ * THE single parser for the `userActions.{edit,delete}` override shape — every
66
+ * UI package (grid row affordances, related-list row predicates, this module's
67
+ * own `resolveCrudAffordances`) routes through here instead of re-implementing
68
+ * the boolean/object-form parse locally. `base` is the bucket default an
69
+ * omitted `enabled` falls back to (callers that only gate on an explicit
70
+ * opt-out pass `true`; predicate extraction is independent of `base`).
71
+ */
72
+ export declare function normalizeUserAction(v: UserActionOverride | undefined | null, base: boolean): {
73
+ enabled: boolean;
74
+ predicates?: RowCrudPredicates;
75
+ };
76
+ /**
77
+ * Extract the per-record CEL predicates (#2614 object form) from a
78
+ * `userActions.edit` / `delete` flag, or `undefined` for a bare boolean /
79
+ * predicate-less flag. Independent of the object-level enabled verdict — row
80
+ * renderers use this to gate a built-in action per record. Thin wrapper over
81
+ * {@link normalizeUserAction} so the parse lives in exactly one place.
82
+ */
83
+ export declare function userActionPredicates(v: UserActionOverride | undefined | null): RowCrudPredicates | undefined;
84
+ /** Resolve the effective CRUD affordances for an object schema. */
85
+ export declare function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances;
86
+ /** True only when a `userActions` flag (bare boolean or #2614 object form) opts the write in. */
87
+ export declare function isWriteOptedIn(v: UserActionOverride | undefined | null): boolean;
88
+ /**
89
+ * ADR-0103 — a `system`-bucket object that opened ANY write via `userActions`
90
+ * is admin/user-writable DATA, not an engine-owned monitoring surface. Used to
91
+ * pick the writable-system badge/empty-state copy.
92
+ */
93
+ export declare function isSystemWritable(obj: SchemaLike | null | undefined): boolean;
94
+ /**
95
+ * Whether an object's rows may be edited in place (detail inline-edit / form
96
+ * fields) — the resolved `edit` affordance. Engine-owned `system` /
97
+ * `append-only` / `better-auth` objects resolve to `false` unless they opened
98
+ * `userActions.edit`. (Per-record predicates gate row action buttons, not
99
+ * object-level editability, so only the resolved boolean matters here.)
100
+ */
101
+ export declare function isObjectInlineEditable(obj: SchemaLike | null | undefined): boolean;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * managedBy — the single source of truth for interpreting an object's
3
+ * lifecycle bucket (`ObjectSchema.managedBy`) into CRUD affordances.
4
+ *
5
+ * UI-side mirror of the framework's `resolveCrudAffordances()`
6
+ * (`@objectstack/spec/data/object.zod.ts`, ADR-0103). Lives in `@object-ui/core`
7
+ * — React-free and reachable by every UI package (app-shell, plugin-detail,
8
+ * plugin-form, plugin-grid) — so the bucket logic is defined ONCE instead of
9
+ * hand-mirrored in each. Components ask "should I show New / Import / Edit /
10
+ * Delete / Export?" and get an answer that tracks the object's lifecycle without
11
+ * special-casing `sys_*` names.
12
+ *
13
+ * The bucket UNION type lives in `@object-ui/types` (`ManagedByBucket`) so the
14
+ * schema field can reference it; this module re-exports it for convenience.
15
+ * Only the affordance BOOLEANS live here — all translated copy (badge variants,
16
+ * empty-state messages) stays in `@object-ui/app-shell`.
17
+ */
18
+ const DEFAULTS = {
19
+ platform: { create: true, import: true, edit: true, delete: true, exportCsv: true },
20
+ config: { create: true, import: false, edit: true, delete: true, exportCsv: true },
21
+ system: { create: false, import: false, edit: false, delete: false, exportCsv: true },
22
+ 'engine-owned': { create: false, import: false, edit: false, delete: false, exportCsv: true },
23
+ 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true },
24
+ 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true },
25
+ };
26
+ /**
27
+ * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
28
+ * the given bucket default, surfacing any per-record predicates alongside.
29
+ *
30
+ * THE single parser for the `userActions.{edit,delete}` override shape — every
31
+ * UI package (grid row affordances, related-list row predicates, this module's
32
+ * own `resolveCrudAffordances`) routes through here instead of re-implementing
33
+ * the boolean/object-form parse locally. `base` is the bucket default an
34
+ * omitted `enabled` falls back to (callers that only gate on an explicit
35
+ * opt-out pass `true`; predicate extraction is independent of `base`).
36
+ */
37
+ export function normalizeUserAction(v, base) {
38
+ if (v == null)
39
+ return { enabled: base };
40
+ if (typeof v === 'boolean')
41
+ return { enabled: v };
42
+ const enabled = v.enabled ?? base;
43
+ if (v.visibleWhen == null && v.disabledWhen == null)
44
+ return { enabled };
45
+ const predicates = {};
46
+ if (v.visibleWhen != null)
47
+ predicates.visibleWhen = v.visibleWhen;
48
+ if (v.disabledWhen != null)
49
+ predicates.disabledWhen = v.disabledWhen;
50
+ return { enabled, predicates };
51
+ }
52
+ /**
53
+ * Extract the per-record CEL predicates (#2614 object form) from a
54
+ * `userActions.edit` / `delete` flag, or `undefined` for a bare boolean /
55
+ * predicate-less flag. Independent of the object-level enabled verdict — row
56
+ * renderers use this to gate a built-in action per record. Thin wrapper over
57
+ * {@link normalizeUserAction} so the parse lives in exactly one place.
58
+ */
59
+ export function userActionPredicates(v) {
60
+ return normalizeUserAction(v, false).predicates;
61
+ }
62
+ /** Resolve the effective CRUD affordances for an object schema. */
63
+ export function resolveCrudAffordances(obj) {
64
+ const bucket = obj?.managedBy ?? 'platform';
65
+ const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
66
+ const o = obj?.userActions ?? {};
67
+ const edit = normalizeUserAction(o.edit, base.edit);
68
+ const del = normalizeUserAction(o.delete, base.delete);
69
+ const out = {
70
+ create: o.create ?? base.create,
71
+ import: o.import ?? base.import,
72
+ edit: edit.enabled,
73
+ delete: del.enabled,
74
+ exportCsv: o.exportCsv ?? base.exportCsv,
75
+ };
76
+ if (edit.predicates)
77
+ out.editPredicates = edit.predicates;
78
+ if (del.predicates)
79
+ out.deletePredicates = del.predicates;
80
+ return out;
81
+ }
82
+ /** True only when a `userActions` flag (bare boolean or #2614 object form) opts the write in. */
83
+ export function isWriteOptedIn(v) {
84
+ return v === true || (typeof v === 'object' && v !== null && v.enabled === true);
85
+ }
86
+ /**
87
+ * ADR-0103 — a `system`-bucket object that opened ANY write via `userActions`
88
+ * is admin/user-writable DATA, not an engine-owned monitoring surface. Used to
89
+ * pick the writable-system badge/empty-state copy.
90
+ */
91
+ export function isSystemWritable(obj) {
92
+ if (obj?.managedBy !== 'system')
93
+ return false;
94
+ const o = obj?.userActions ?? {};
95
+ return o.create === true || isWriteOptedIn(o.edit) || isWriteOptedIn(o.delete);
96
+ }
97
+ /**
98
+ * Whether an object's rows may be edited in place (detail inline-edit / form
99
+ * fields) — the resolved `edit` affordance. Engine-owned `system` /
100
+ * `append-only` / `better-auth` objects resolve to `false` unless they opened
101
+ * `userActions.edit`. (Per-record predicates gate row action buttons, not
102
+ * object-level editability, so only the resolved boolean matters here.)
103
+ */
104
+ export function isObjectInlineEditable(obj) {
105
+ return resolveCrudAffordances(obj).edit;
106
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/core",
3
- "version": "15.0.0",
3
+ "version": "16.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -25,11 +25,11 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "@objectstack/formula": "^14.6.0",
29
- "@objectstack/spec": "^14.6.0",
28
+ "@objectstack/formula": "^16.0.0-rc.0",
29
+ "@objectstack/spec": "^16.0.0-rc.0",
30
30
  "lodash": "^4.18.1",
31
31
  "zod": "^4.4.3",
32
- "@object-ui/types": "15.0.0"
32
+ "@object-ui/types": "16.1.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^6.0.3",