@object-ui/plugin-detail 16.0.0 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,755 @@
1
1
  # @object-ui/plugin-detail
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1cf0de7: fix(detail): finish the approval-lock story, and warn on silently stripped fields (framework#3794)
8
+
9
+ The Console reported record writability wrong in both directions during an
10
+ approval, so a user had nothing to go on: what they _could_ edit said "locked",
11
+ and what they _couldn't_ said "updated successfully".
12
+
13
+ **The lock band told the truth; the Edit button did not.** objectui#2902 split
14
+ the band into "in approval · editable" vs locked, but the header **Edit** CTA
15
+ still keyed off nothing at all — on a genuinely locked record it stayed live, so
16
+ the user opened the form, filled a screen, and got `RECORD_LOCKED` back on Save.
17
+ It is now `disabled` on a locked record: visible-but-off, with the band beside it
18
+ saying why. This is the LOCK, not the mere presence of an approval — a
19
+ `lockRecord: false` node keeps Edit live, which is the point of that setting.
20
+
21
+ **And the band could still re-lock itself.** `DetailView` OR-ed the record's own
22
+ `approval_status` mirror into `isLocked` unconditionally. That mirror is written
23
+ on submit by any flow configuring an `approvalStatusField`, _regardless of_
24
+ `lockRecord` — so on a `lockRecord: false` node the host correctly resolved "not
25
+ locked" from the request's `lock_record` while the mirror dragged the band back
26
+ to "Locked for approval", with the pencils live and saves landing underneath it.
27
+ The host is now authoritative whenever it threads `approvalPending`; the mirror
28
+ is consulted only for bare/legacy `DetailView` hosts that thread nothing, where
29
+ it still reads as locked (no node granularity — the safe direction).
30
+
31
+ Recall's tooltip no longer promises to unlock a record the node never locked
32
+ (`detail.cancelApprovalTooltipUnlocked`).
33
+
34
+ **Silently stripped fields now surface on the record form's save path.** The
35
+ adapter emitted a write-warning for `create`/`update` responses carrying
36
+ `droppedFields`, but not for `batchTransaction` — which is how the record form
37
+ saves a master-detail record, i.e. the one surface where a user actually edits a
38
+ `readonlyWhen`-locked field. `batchTransaction` now emits one warning per event,
39
+ resolving each back to its operation via the response's `index`.
40
+
41
+ The toast itself was hardcoded English and called every strip "read-only". It is
42
+ now localized (`detail.writeStripped*`, ten locales) and worded by reason:
43
+ `readonly_when` says the field is not editable _in this record's current state_,
44
+ which is what actually happened — the field is editable in other states and the
45
+ form rendered it as an ordinary input, so "read-only" sent the user hunting for a
46
+ permission problem that does not exist.
47
+
48
+ **And it stopped crying wolf.** `createObjectStackUserStateAdapter` hand-stamped
49
+ the server-managed `updated_at` on every recents/favorites write, which the
50
+ server strips and reports — so the console popped "Some fields were not saved"
51
+ about a field no user ever touched, on page loads, drowning the signal the toast
52
+ exists for. It no longer sends the column; the server stamps it anyway.
53
+
54
+ - 390c071: feat(record): declare inputs for the seven configurable record:\* blocks, and curate six
55
+
56
+ Seven `record:*` blocks shipped with renderers that read props but declared no
57
+ `inputs`. That combination is the worst of both: the renderer honours
58
+ `limit`, `severity`, `location` …, while every authoring surface — the designer
59
+ panel, the AI vocabulary, the generated manifest — reports the block takes no
60
+ configuration. objectui#3013 recorded them as deliberately uncurated for
61
+ exactly that reason.
62
+
63
+ The declarations mirror what each renderer actually reads:
64
+
65
+ | block | inputs |
66
+ | -------------------------------------- | ---------------------------------------------------------------------------- |
67
+ | `record:activity` | 11 — from `RecordActivityComponentProps` |
68
+ | `record:chatter` / `record:discussion` | 5 — from `RecordChatterComponentProps` |
69
+ | `record:alert` | 8 — severity, title, body, visible, icon, action, dismissible, dismissKey |
70
+ | `record:quick_actions` | 7 — actionNames, requiredPermissions, location, align, inline, variant, size |
71
+ | `record:history` | 3 — limit, emptyText, unknownUserText |
72
+ | `record:reference_rail` | 1 — hideEmpty |
73
+
74
+ `inputs` describe what an AUTHOR writes, which is a subset of what the renderer
75
+ reads. `entries`, `loading` and resolved `actions` are injected by the host
76
+ shell off RecordContext; declaring them would invite a model to hand-write the
77
+ data the page is supposed to fetch. `aria` is omitted for the reason it is
78
+ omitted on `record:details` — an accessibility escape hatch, not a layout
79
+ choice. `location` takes its enum from the spec's `ACTION_LOCATIONS` rather
80
+ than restating it, per objectui#3019.
81
+
82
+ Six of the seven are now in `PUBLIC_BLOCKS`: configurable and absent from the
83
+ contract is the state objectui#3006 was about. The contract goes 36 → 42 tags,
84
+ all resolving.
85
+
86
+ `record:chatter` stays out — it is the same renderer as `record:discussion`
87
+ under a Salesforce-familiar name, kept for schemas already in the wild. Two
88
+ spellings of one block is ambiguity an authoring model cannot resolve, so the
89
+ vocabulary carries the spec's name. A test compares the two input lists, so the
90
+ day they diverge the exclusion stops being justified and fails.
91
+
92
+ A companion assertion requires every curated `record:*` tag to declare inputs.
93
+ A curated tag with none reads as "takes no configuration" when the renderer in
94
+ fact reads props — the same gap objectui#3006 opened, pointed the other way.
95
+
96
+ - bac266c: fix(detail): a related list has one sorting semantics instead of two — #3106
97
+
98
+ A related list carried two. Its own sort-button row (opt-in via `sortable`) went
99
+ out as a server `$orderby` over the whole child collection; the `data-table` it
100
+ embeds took `sortable`'s default of `true` and sorted the rows it was holding —
101
+ which, in windowed mode, is **one page**.
102
+
103
+ Turning `sortable` on put both in the same card, with nothing saying they meant
104
+ different things. Leaving it off — the default — was worse: the page-local sort
105
+ was then the _only_ one the user could reach, and it looked exactly like the
106
+ list being sorted.
107
+
108
+ The table's column headers now drive this list's sort in both modes, so there is
109
+ one order behind them:
110
+
111
+ - **Windowed**: the header sort becomes the server `$orderby` and resets to page
112
+ one, the same path the buttons took.
113
+ - **Client mode**: this list keeps sorting in memory, where its key is the label
114
+ resolved through its own id → name map (#3096) — a key the embedded table
115
+ cannot see, so its sort was the worse of the two even when both were possible.
116
+
117
+ The button row survives only where there are no headers to click: a `list`
118
+ (`data-list`) related list, or a caller-supplied `schema` whose contents we
119
+ cannot assume. `sortable`'s documentation now says that is what it controls.
120
+
121
+ Relational columns keep #3096's rule, moved to the header: no sort affordance
122
+ while the sort is a server `$orderby` (the key would be the stored foreign-key
123
+ id while the cell shows a name), live in client mode where the key is the label.
124
+
125
+ ### Patch Changes
126
+
127
+ - fc0272a: fix(actions): apply the ADR-0066 D4 capability gate on every action surface (framework#3923)
128
+
129
+ An action declaring `requiredPermissions` is supposed to be one declaration with
130
+ two enforcement surfaces: 403 on the server, hidden button in the UI. The UI half
131
+ only ever ran inside `ActionEngine.getActionsForLocation` — and the surfaces
132
+ `record_header`, `record_more`, `list_item` and `list_toolbar` actually render on
133
+ do not go through the engine. They filter their own action lists. So a button
134
+ declaring a capability nobody holds rendered, live and clickable, on the record
135
+ header, in every grid row menu, and on the list toolbar. For a `type: 'api'`
136
+ action pointed at a self-authored endpoint, nothing else was checking either: the
137
+ platform's action route (which is where the 403 comes from) never sees that
138
+ request.
139
+
140
+ `page:header`, `action:bar` (business _and_ `systemActions`) and the grid's
141
+ `RowActionMenu` now apply the same gate, via a shared `useCapabilityGate()` so
142
+ the surfaces cannot drift apart. The rule is the engine's, unchanged: hide unless
143
+ the caller holds **all** declared capabilities; an empty held set is "holds
144
+ nothing" and gates; **unknown** — no action runtime, no resolved capabilities —
145
+ fails OPEN, because the server is the authority and hiding a permitted user's
146
+ button on missing client data is the worse failure.
147
+
148
+ The record surface was also feeding the gate nothing to work with.
149
+ `RecordDetailView` mounts its own `<ActionProvider>`, which shadows the shell's
150
+ for every action on that page, and seeded it with identity only — no
151
+ `systemPermissions`. Since unknown fails open, that alone un-gated every
152
+ `record_header` / `record_more` / `record_section` action on the one page those
153
+ locations exist on. It now forwards the caller's resolved capabilities (and only
154
+ once they have actually resolved, so a standalone embed without a
155
+ `PermissionProvider` keeps failing open rather than hiding everything).
156
+
157
+ `useRecordEditable`'s record-level explain probe went out on a bare
158
+ `fetch(..., { credentials: 'include' })`. A bearer-token session carries its
159
+ credential in the `Authorization` header, not a cookie, so the probe came back
160
+ 401 on a perfectly valid admin session and the verdict silently failed open —
161
+ the hook was inert in exactly the deployments it was written for. It now rides
162
+ the host's authenticated fetch (`SchemaRendererProvider`'s `apiFetch`), falling
163
+ back to the global one for standalone embeds.
164
+
165
+ - c785740: fix(detail): record Attachments become their own tab (with count badge) and their copy is translated — objectstack#4358
166
+
167
+ Two defects on `enable.files: true` record detail pages:
168
+
169
+ 1. **Buried placement.** `RecordDetailView` appended `RecordAttachmentsPanel`
170
+ AFTER the schema-rendered page tree, whose synthesized default embeds
171
+ `record:discussion` as the last main component — so the panel always
172
+ landed below an ever-growing feed timeline, undiscoverable without
173
+ scrolling to the very bottom, with no metadata knob to move it.
174
+
175
+ `buildDefaultTabs` now emits a peer **Attachments** tab (a new
176
+ `record:attachments` node rendered by an app-shell registration wrapping
177
+ the existing panel via RecordContext) between Related and
178
+ Activity/History. `PageTabsRenderer` derives the tab's count badge from a
179
+ `sys_attachment` probe scoped to `(parent_object, parent_id)`, riding the
180
+ same RelatedCountStore cache/invalidation bus as related-list badges — so
181
+ uploads and deletes update the badge live. A `hideAttachments` synthesizer
182
+ option suppresses the tab; RecordDetailView keeps its legacy bottom append
183
+ only as the fallback for authored pages without the node
184
+ (`hasExplicitAttachments`).
185
+
186
+ 2. **Untranslated copy.** The panel's eleven `detail.*` keys (`attachments`,
187
+ `uploadAttachment`, `loadingAttachments`, `noAttachments`,
188
+ `downloadAttachment`, `deleteAttachment`, and the five
189
+ `attachment*Denied/Required` friendly errors) existed only as inline
190
+ English `defaultValue`s — no locale bundle carried them, so non-English
191
+ consoles always showed English. All ten locales now define them; the tab
192
+ label rides the existing well-known-label dictionary (→ 附件 etc.).
193
+
194
+ - 95b7214: fix(list,grid,detail,tree,core): every column resolver reads one key (#3104 PR2)
195
+
196
+ PR1 (#3119) put a canonicalizing fold at ListView's ingestion boundary. This
197
+ converges the 22 read sites themselves onto `columnIdentity()` from
198
+ `@object-ui/core`, so a surface that is NOT downstream of that fold resolves
199
+ the same identity anyway.
200
+
201
+ That distinction is the user-visible part. A standalone `object-grid` node —
202
+ authored directly on a page, with no `list-view` above it — never passed
203
+ through `normalizeListViewSchema`. Its `getSelectFields` read `c.field` alone
204
+ while the `ensureId` probe one line above read `f?.name || f?.field`, so a
205
+ legacy `{ name: 'account' }` column reached `$select` as a literal `undefined`
206
+ hole: the server never returned the field and every cell in that column came
207
+ back empty. Same for `ObjectTree`, `RelatedList` and the `record:details` /
208
+ `record:related_list` renderers.
209
+
210
+ Converged:
211
+
212
+ | Surface | Was | Now |
213
+ | ---------------------------------------- | ---------------------------------------------- | ----------------------------------- |
214
+ | `ListView` ×9 + its 2 request builders | `name \|\| fieldName \|\| field` vs `f?.field` | `columnIdentity()` |
215
+ | `RelatedList` ×8 | `accessorKey \|\| field \|\| name` | `accessorKey \|\| columnIdentity()` |
216
+ | `ObjectGrid` | name-first probe vs `c.field` projection | `columnIdentity()` |
217
+ | `ObjectTree` | `name \|\| fieldName \|\| field \|\| key` | `columnIdentity() \|\| key` |
218
+ | `buildExpandFields` | `field ?? name ?? fieldName` | `columnIdentity()` |
219
+ | `record-details` / `record-related-list` | `field \|\| name (\|\| key)` | `columnIdentity() (\|\| key)` |
220
+
221
+ `accessorKey` keeps its precedence in `RelatedList` — it is TanStack Table's
222
+ column key, not ObjectStack metadata identity, and only the `field || name`
223
+ tail was converged. `key` stays a tail fallback in `ObjectTree` and
224
+ `record-related-list` for the same reason: it is a generic entry key.
225
+
226
+ Two incidental fixes that TypeScript surfaced once the resolver stopped
227
+ returning `any`: ListView's filter-field options and its hide-fields popover
228
+ both built entries keyed `undefined` for a column with no resolvable identity.
229
+ Those entries could never match a column; they are now dropped.
230
+
231
+ **Inventory re-triage.** PR1 recorded 24 family members. Two were mis-classified
232
+ and are reclassified here rather than converged — reading what they actually
233
+ feed shows they are not column reads at all:
234
+
235
+ - `ViewPreview.tsx` adapts a ViewItem **form** section to what `object-form`
236
+ selects by (`field` → `name`) — the #3090 two-layer join.
237
+ - `SchemaForm.tsx` renders an arbitrary metadata **array** into a popover
238
+ summary and guesses at a display key; the entries are validations, actions,
239
+ or whatever the JSON schema declares.
240
+
241
+ So the family was 22, and it is now **0**. The ratchet asserts that, asserts
242
+ each converged surface actually routes through the shared reader (a surface
243
+ that dropped identity resolution instead of converging it goes red), and pins
244
+ `accessorKey`'s precedence in `RelatedList`.
245
+
246
+ - 2baa13f: fix(record): register the record:\* blocks under one key, prefixed once
247
+
248
+ Eleven blocks in plugin-detail were registered as
249
+ `register('record:x', …, { namespace: 'record' })` — an already-prefixed name
250
+ handed to a registry that prefixes it again. Each landed at
251
+ `record:record:x`, and the key authors actually resolved, `record:x`, was the
252
+ un-namespaced _fallback_ rather than the intended registration. The registry
253
+ carried 23 keys for 12 components.
254
+
255
+ Nothing failed, which is why it survived: `getPublicConfigs()` rewrites `type`
256
+ to the curated tag, so the doubled name never reached the contract, the
257
+ manifest, or the JSX surface. It was visible only when enumerating the registry
258
+ directly — which is what objectui#3013's reverse check does.
259
+
260
+ Registering the bare name is what makes `namespace` correct, and
261
+ `skipFallback: true` is what keeps the fallback from claiming that bare name
262
+ globally. Without it these would take over `details`, `path`, `history`,
263
+ `alert` … as top-level tags; `alert` is the live case, owned by `ui:`. Every
264
+ block stays reachable exactly as `record:<name>`, and 23 keys become 12.
265
+
266
+ `record:line_items` needed no change — it was the one already registered this
267
+ way, which is what made objectui#3006's near-miss possible in the first place.
268
+
269
+ Two console assertions hold the shape: no key carries a doubled prefix, and no
270
+ `record:*` block owns the bare spelling of its own name.
271
+
272
+ - 2d5d594: fix(list,detail): sorting a lookup column no longer orders by an invisible key — #3096
273
+
274
+ A relational column (`lookup` / `master_detail` / `user` / `tree`) never holds
275
+ the string its cell shows: it holds the `$expand`-ed record, or a raw foreign-key
276
+ id whose label was resolved separately. Every sort path took that raw value as
277
+ its key, so the column of names came back in an order with no relation to the
278
+ names — sorting looked broken, with nothing saying the key was something else.
279
+
280
+ The two halves are fixed differently, because they can order by different things:
281
+
282
+ - **Client-side sorts** (grid column headers, any `data-table`, a non-windowed
283
+ related list) now key off the label the cell renders, via the new
284
+ `getSortValue` / `compareSortValues` in `@object-ui/core` — which resolves an
285
+ expanded record through `getRecordDisplayName` (ADR-0079), so the sort key and
286
+ the lookup cell agree on which field names a record. This replaces two broken
287
+ comparators: `a[col] < b[col]` is always false between two objects (the
288
+ comparator collapsed to a constant and permuted the rows), and
289
+ `String(a[col])` is `"[object Object]"` (every row compared equal, so the sort
290
+ silently did nothing).
291
+ - **Server `$orderby` sorts** cannot be fixed here — the key is the stored id by
292
+ construction, and `objectstack#4256` settled that no relation join is coming.
293
+ So those entry points stop offering the illusion: the ListView toolbar sort
294
+ picker withholds relational fields and explains why (pointing at a formula
295
+ field as the supported way to sort by a related name), and a windowed related
296
+ list renders no sort button for them.
297
+
298
+ A relational field the view's CURRENT sort already uses stays listed, labelled
299
+ `(by ID)`, so view metadata authored or saved with such a sort round-trips
300
+ instead of rendering a blank row and losing the sort on the next edit.
301
+
302
+ - Updated dependencies [1cf0de7]
303
+ - Updated dependencies [752e18f]
304
+ - Updated dependencies [c785740]
305
+ - Updated dependencies [d61efd1]
306
+ - Updated dependencies [9eb932b]
307
+ - Updated dependencies [3cb9646]
308
+ - Updated dependencies [d21794c]
309
+ - Updated dependencies [b5b97e2]
310
+ - Updated dependencies [2d5d594]
311
+ - Updated dependencies [f8a95e5]
312
+ - @object-ui/i18n@17.1.0
313
+
314
+ ## 17.0.0
315
+
316
+ ### Minor Changes
317
+
318
+ - 952b978: fix(detail): the approval band honors the node's `lockRecord` instead of assuming every approval locks (#2902)
319
+
320
+ A record detail page treated "a pending approval request exists" as "this
321
+ record is locked". An approval node declares `lockRecord` (default `true`), and
322
+ on `lockRecord: false` the server keeps accepting writes for the whole time
323
+ that node waits — so the console was asserting a lock the backend did not
324
+ enforce.
325
+
326
+ The label was the smaller half of it. The same conflated signal fed `canEdit`,
327
+ so the record-level inline-edit session was suppressed too: no pencils,
328
+ `enter()` a no-op. On a single-approver step — a department head or plant
329
+ manager, exactly the case `lockRecord: false` exists for, where the approver is
330
+ meant to fill in the missing detail before deciding — the capability was
331
+ unreachable from the UI. And a flow chaining nodes with different policies drew
332
+ one identical band for "edit freely" and "the server will reject your save with
333
+ `RECORD_LOCKED`", so the two states were indistinguishable until Save failed.
334
+
335
+ Approval state is now two signals:
336
+
337
+ - **`approvalPending`** — an approval is running. Drives the band and the recall
338
+ button, both meaningful whether or not the record is editable.
339
+ - **`locked`** — that approval also forbids edits, from the pending node's
340
+ `lock_record` (framework#3814, read off the same `node_config_json` snapshot
341
+ the server's record-lock hook reads).
342
+
343
+ The band renders two states: amber lock + "Locked for approval", or sky clock +
344
+ "In approval · editable", each with its own tooltip. Recall moved out of the
345
+ locked branch — an editable pending approval is just as recallable. Inline
346
+ editing stays live in the editable state.
347
+
348
+ `InlineEditProvider` takes a new optional `approvalPending` prop, defaulting to
349
+ `locked`, so a host that threads only `locked` renders exactly as before. The
350
+ record's `approval_status` field remains the fallback for backends with no
351
+ approvals API; it carries no node granularity, so it still reads as locked — as
352
+ does a pending request from a backend too old to report the policy.
353
+
354
+ New `detail.approvalPendingEditable` / `detail.approvalPendingTooltip` keys are
355
+ translated in all ten locales.
356
+
357
+ - f9bbddb: feat: gate detail/form edit & delete on the server's effective operation set (#3546)
358
+
359
+ PR-4 (#3391) wired the **list/toolbar** surface (ObjectView Import, ListView /
360
+ ObjectGrid Export) to the server-resolved effective API operation set
361
+ (`/me/permissions` `apiOperations`, intersected via
362
+ `resolveCrudAffordances(obj, effectiveApiOperations?)`). The **detail / form**
363
+ surfaces still gated edit/delete on the bucket + `userActions` alone. This
364
+ extends the same intersection to them, so the record page and its forms never
365
+ offer an operation the server would 405.
366
+
367
+ - **core** `isObjectInlineEditable(obj, effectiveApiOperations?)` gains the same
368
+ optional second argument as `resolveCrudAffordances` — inline-edit is now
369
+ additionally ANDed with the server allowing `update`.
370
+ - **app-shell** `RecordDetailView` threads the object's effective operations into
371
+ the synthesized Edit/Delete header actions and the record-body inline-edit
372
+ gate (`canEdit`); `RelatedRecordActionsBridge` intersects each **child**
373
+ object's Create/Edit/Delete handlers with that child's own effective set.
374
+ - **plugin-detail** `record:details` ANDs its inline-edit affordance with the
375
+ object's effective `update`.
376
+ - **plugin-form** `ObjectForm`'s blanket managed-object field lock also engages
377
+ when the server denies `update` (edit mode) / `create` (create mode).
378
+
379
+ Backward-compatible: a missing effective set (unrestricted object, older
380
+ backend, or no `PermissionProvider`) leaves the resolved affordance untouched —
381
+ the bucket/`userActions` decision wins, exactly as today. Layers on top of the
382
+ existing per-object `check('edit')` / `check('delete')` permission gates
383
+ (intersection, never union).
384
+
385
+ ### Patch Changes
386
+
387
+ - 7b21891: fix(action): honor the spec `disabled` predicate on every action-rendering surface (#1885 follow-through)
388
+
389
+ The spec Action field is `disabled` (boolean | CEL — disabled when TRUE); the
390
+ schema has no `enabled` key. #1885 wired it in `action:button` only. Browser
391
+ dogfooding against the showcase found FIVE more surfaces where a spec-authored
392
+ `disabled` silently did nothing:
393
+
394
+ - **components** — the `action:group` leaves (inline + dropdown), `action:icon`
395
+ and `action:menu` still read the legacy non-spec `enabled`. They now consume
396
+ `disabled` as the primary control (evaluated in the same scope as `visible`),
397
+ with `enabled` kept as a deprecated fallback.
398
+ - **app-shell** — `DeclaredActionsBar` (server-declared action bar) read
399
+ neither; it gains `disabled` (no legacy fallback: declared actions are
400
+ spec-shaped and never carried `enabled`).
401
+ - **plugin-detail** — `record:quick_actions` HAD a `disabled` implementation,
402
+ but its `typeof === 'string'` split dropped the `{dialect:'cel', source}`
403
+ envelope the server compiles authored CEL into (#2661 routes envelopes to the
404
+ canonical formula engine), so the predicate never fired on real metadata. It
405
+ now feeds `toPredicateInput`'s result to `useCondition` whole, like every
406
+ other surface.
407
+
408
+ Pinned by new `DropdownActionItem` tests (disabled-when-TRUE, false-stays-
409
+ clickable, disabled-wins-over-enabled, boolean literal) and browser-verified
410
+ end-to-end against the showcase `showcase_archive_task` specimen: greyed on an
411
+ in-progress task, clickable on a done one (with `visible` hiding Mark Done on
412
+ the same screen — the hide-vs-grey contrast).
413
+
414
+ - faad45e: fix(fields): render `image` fields consistently and add click-to-zoom (#2836)
415
+
416
+ An `image` field rendered differently — and wrongly — on three surfaces:
417
+
418
+ - **Edit form showed broken thumbnails.** A record read back its `image` value
419
+ as a bare `sys_file` id (the reference form), but `readFileValue` returned an
420
+ id with no URL — the comment assumed the read path expands it, which the
421
+ edit-form data path does not. The result was `<img src="">`. `file-value` now
422
+ derives the stable download URL (`/api/v1/storage/files/:id`, which
423
+ 302-redirects to a signed URL and works directly as `<img src>`) for a bare
424
+ id or an id-only object, so every widget and cell renderer resolves one.
425
+ - **Inline edit leaked the raw storage URL.** `InlineFieldInput` had no branch
426
+ for file-backed types and fell through to a plain text input showing
427
+ `/api/v1/storage/files/…`. It now renders the same upload widgets the form
428
+ uses (`image`/`avatar`/`signature`/`file`/`video`/`audio`).
429
+ - **Hard-coded English.** `ImageField`'s upload/crop/remove/alt strings now go
430
+ through `t('fields.image.*')` (en + zh added).
431
+
432
+ Also adds an `ImageLightbox` — click a read-only thumbnail (detail or list cell)
433
+ to open a full-screen preview; multiple images get prev/next navigation, a
434
+ position counter and arrow-key support, a single image just the image. In a
435
+ grid cell the click is `stopPropagation`-guarded so enlarging doesn't also open
436
+ the row.
437
+
438
+ - 7d46648: fix(hooks): stop calling translation hooks inside try/catch (objectui#2879)
439
+
440
+ Eleven call sites wrapped a React hook in `try`/`catch` to make it
441
+ "provider-safe". `useObjectTranslation` and `useObjectLabel` already are — they
442
+ read context optionally and fall back to react-i18next's global instance, and
443
+ never throw. The `catch` bought nothing and cost correctness: a throw _after_
444
+ the hook ran desyncs hook order on the next render, because React matches hooks
445
+ positionally. objectui#2595/#2596 fixed exactly this in `@object-ui/i18n`'s
446
+ `createSafeTranslation`; nine plugin-local re-implementations kept their own
447
+ copy of the bug, and two more (`ObjectTimeline`, `ObjectView`) were found by the
448
+ new lint rule below — `ObjectView` had even suppressed
449
+ `react-hooks/rules-of-hooks` inline to keep it.
450
+
451
+ - Six exact re-implementations now delegate to `createSafeTranslation`:
452
+ `plugin-detail`, `plugin-timeline`, `plugin-list`, `plugin-calendar`,
453
+ `plugin-grid`'s `ObjectGrid`, `plugin-designer`.
454
+ - `components`' `data-table` also delegates; `createSafeTranslation` now
455
+ returns `language` alongside `t` so consumers that localize dates don't need
456
+ a second hook call. Purely additive.
457
+ - `plugin-gantt` and `plugin-grid`'s `ImportWizard` keep their local hooks —
458
+ they fall back _per key_, which a single-probe factory cannot express and
459
+ which their comments justify (a host dictionary that covers common keys but
460
+ lags on newer ones). Only the `try`/`catch` is removed.
461
+ - `ObjectTimeline` and `ObjectView` call the hook directly and probe the
462
+ returned value, mirroring `useSafeFieldLabel`.
463
+
464
+ Adds `object-ui/no-try-catch-around-hook` (error) so a twelfth copy fails CI.
465
+ It only matches `use*` names, accepts member calls solely on `React` (so
466
+ `vi.useRealTimers()` is not a hook), and resets its try-depth inside nested
467
+ functions (so `renderHook(() => useThing())` inside a `try` is fine) — both
468
+ false positives were real code in this repo and are pinned in the rule's tests.
469
+
470
+ `eslint-rules/**/*.test.js` matched no vitest project glob, so the local
471
+ plugin's specs had never run in CI. They are now included; all three pass.
472
+
473
+ `ObjectTimeline`'s test mock of `@object-ui/react` omitted `useObjectLabel` —
474
+ the removed `try`/`catch` had been silently absorbing that gap. The mock is now
475
+ complete.
476
+
477
+ - 53642d4: fix(core,fields): a string `$orderby` is a clause, not a character array — and localize the sharing-rule widgets (objectstack#3821)
478
+
479
+ **The recipient picker listed nothing, ever.** `QueryParams['$orderby']` was
480
+ typed as `Record | string[] | SortObject[]`, so `queryParamsToRecord` sent any
481
+ non-array value through `Object.entries`. Handed the clause string `'name asc'`
482
+ — which callers do build by hand — it walked the string index by index and
483
+ emitted `$orderby=0 n,1 a,2 m,3 e,4 ,5 a,6 s,7 c`. The server sorted by columns
484
+ that don't exist and every row was filtered out, so
485
+ `sys_sharing_rule.recipient_id` rendered "No matches" for every recipient type
486
+ and no sharing rule could be created from the Console. `ObjectGrid` builds the
487
+ same shape from a schema-level `sort` in three places, so grids with a string
488
+ sort silently showed an empty table.
489
+
490
+ A string `$orderby` is now passed through verbatim (the server's OData
491
+ normalizer has always parsed `'name asc'`), and the type admits `string`.
492
+ `RecipientPickerField` additionally switched to the structured
493
+ `{ name: 'asc' }` form so it can't regress this way against any data source.
494
+
495
+ **The three sharing-rule authoring widgets never had translations.**
496
+ `ObjectRefField`, `RecipientPickerField` and `FilterConditionField` hardcoded
497
+ their English copy — a Chinese Console showed "Select an object", "Select a
498
+ user", "Search…", "No matches", "Edit as JSON". They now go through
499
+ `useFieldTranslation` like every other widget, with keys added under `fields.*`
500
+ in all ten locales.
501
+
502
+ The recipient placeholder was the interesting one: it read
503
+ `` `Select a ${recipientType.replace(/_/g,' ')}` ``, interpolating the enum
504
+ value into an English sentence — a shape no locale can translate. It is now a
505
+ per-type key (`fields.recipient.selectUser`, `…selectBusinessUnit`, …), so
506
+ "选择业务单元" and "Select a business unit" no longer have to share a structure.
507
+
508
+ **Editing a rule silently dropped its recipient.** The picker resets the stored
509
+ id when `recipient_type` changes, because an id valid for a user is meaningless
510
+ for a team. It treated the edit form's `'' → 'user'` hydration as such a change:
511
+ opening any saved rule blanked the recipient, and saving persisted the blank.
512
+ Only a non-empty predecessor now counts as a type switch.
513
+
514
+ **Building a filter submitted the surrounding form.** None of `FilterBuilder`'s
515
+ controls declared `type="button"`, and a bare `<button>` inside a `<form>`
516
+ defaults to `type="submit"`. Adding, removing or clearing a condition therefore
517
+ submitted the sharing-rule dialog — firing validation mid-edit, and on an
518
+ already-valid form saving the record before the admin was done.
519
+
520
+ **A rejected write showed the user raw server diagnostics.** The form rendered
521
+ `error.message` verbatim, so a sharing / RLS denial reached the dialog and the
522
+ toast as `FORBIDDEN: insufficient privileges to update showcase_private_note
523
+ pi-TgoJ4_DM55Fqz` — untranslated, and leaking the object's machine name and the
524
+ record id to whoever hit it. Permission failures now render localized copy
525
+ (`form.noPermissionToSave`, added in all ten locales), with the server text kept
526
+ on the console for debugging; other failures still show the server's message,
527
+ which is the useful part, and fall back to `form.submitFailed` when there is
528
+ none — replacing the previously hardcoded English "An error occurred during
529
+ submission".
530
+
531
+ **The detail header offered "Edit" on records the user may only read.** Object
532
+ permissions can't express "this one record is read-only" — a read-only sharing
533
+ grant sits inside an object the user may otherwise edit — so the header showed
534
+ the primary Edit CTA, opened the form, and let the user retype a field before
535
+ the server rejected the save. `DetailView` now gates Edit / Delete on the
536
+ object-level check AND on the explain engine's record-grained verdict
537
+ (`POST /api/v1/security/explain` with a `recordId`, ADR-0090 D6 / ADR-0095 C2 —
538
+ the same pipeline the enforcement middleware runs, so button and server cannot
539
+ disagree). Explaining oneself needs no special permission. The probe is one
540
+ cached request per record, skipped entirely when the object-level check already
541
+ says no, and **fails open** on every uncertainty — an unanswered hint must never
542
+ be the reason a permitted user cannot act; the server stays the authority
543
+ (ADR-0057 D10).
544
+
545
+ **A long option rendered straight past the combobox border.** `Combobox`'s
546
+ trigger pinned itself to the component's `w-[200px]` default while the fields
547
+ around it ran the full form column, and the selected label was a bare text child
548
+ of a flex button — flex items need `truncate` AND `min-w-0` to clip, and it had
549
+ neither. So "成员 (showcase_project_membership)" in the object picker overflowed
550
+ the control and collided with the field beside it. The label now truncates, the
551
+ trigger can shrink, the dropdown matches the trigger's width instead of a
552
+ hardcoded 200px (a widened combobox used to clip its own options), and the two
553
+ sharing-rule pickers ask for `w-full` so they line up with every other input.
554
+
555
+ Hardens `evaluatePermission` while there: a role config carrying only
556
+ `fieldPermissions` (no `actions`) made `check()` throw a TypeError that
557
+ propagated out of the render. A permission check must not be able to crash a
558
+ view.
559
+
560
+ Browser-verified against the framework showcase Console in Chinese: object /
561
+ criteria / recipient copy is fully localized, the recipient dropdown lists real
562
+ users, business units and positions, a saved rule reopens with its recipient and
563
+ criteria intact, editing the filter no longer submits, and a rule created
564
+ end-to-end stores a real record id rather than free text. The criteria authored
565
+ in the builder is honored by the evaluator: `{"pinned":true}` on an owner-private
566
+ object granted the recipient exactly the matching records and nothing else.
567
+
568
+ - Updated dependencies [7b21891]
569
+ - Updated dependencies [0b3be01]
570
+ - Updated dependencies [3c4d935]
571
+ - Updated dependencies [4b1ed7d]
572
+ - Updated dependencies [4b60d2d]
573
+ - Updated dependencies [952b978]
574
+ - Updated dependencies [de5e40c]
575
+ - Updated dependencies [1a03af6]
576
+ - Updated dependencies [3e886eb]
577
+ - Updated dependencies [cfc675e]
578
+ - Updated dependencies [20df08c]
579
+ - Updated dependencies [1767124]
580
+ - Updated dependencies [8ecf5a6]
581
+ - Updated dependencies [af705b9]
582
+ - Updated dependencies [0502a7c]
583
+ - Updated dependencies [7b35e4b]
584
+ - Updated dependencies [8fb1295]
585
+ - Updated dependencies [e16ed2d]
586
+ - Updated dependencies [c6fd752]
587
+ - Updated dependencies [f9bbddb]
588
+ - Updated dependencies [dfd3705]
589
+ - Updated dependencies [c77108c]
590
+ - Updated dependencies [2735de6]
591
+ - Updated dependencies [697cda4]
592
+ - Updated dependencies [c19ac11]
593
+ - Updated dependencies [6dee2cb]
594
+ - Updated dependencies [e05f052]
595
+ - Updated dependencies [0502a7c]
596
+ - Updated dependencies [faad45e]
597
+ - Updated dependencies [09c6a17]
598
+ - Updated dependencies [c7cff19]
599
+ - Updated dependencies [ba73a02]
600
+ - Updated dependencies [cd09a7b]
601
+ - Updated dependencies [f1abf0e]
602
+ - Updated dependencies [f05b84e]
603
+ - Updated dependencies [9b4b952]
604
+ - Updated dependencies [341bfb5]
605
+ - Updated dependencies [2f947e4]
606
+ - Updated dependencies [7d46648]
607
+ - Updated dependencies [9b53d72]
608
+ - Updated dependencies [bb4aa25]
609
+ - Updated dependencies [75f1cdf]
610
+ - Updated dependencies [662bdf9]
611
+ - Updated dependencies [059a052]
612
+ - Updated dependencies [53642d4]
613
+ - Updated dependencies [8aae006]
614
+ - Updated dependencies [c6cfdf1]
615
+ - Updated dependencies [d147a13]
616
+ - Updated dependencies [c6aaed8]
617
+ - Updated dependencies [263f885]
618
+ - Updated dependencies [dc334da]
619
+ - @object-ui/components@17.0.0
620
+ - @object-ui/i18n@17.0.0
621
+ - @object-ui/fields@17.0.0
622
+ - @object-ui/react@17.0.0
623
+ - @object-ui/types@17.0.0
624
+ - @object-ui/core@17.0.0
625
+ - @object-ui/permissions@17.0.0
626
+
627
+ ## 16.1.0
628
+
629
+ ### Minor Changes
630
+
631
+ - faebac3: Related lists paginate by default and fetch server-side windows (#2711).
632
+
633
+ `record:related_list` now applies the spec default `limit` of 5 when a node
634
+ doesn't declare one, so detail-page related lists render pages with
635
+ Previous/Next controls instead of dumping every child row. On the auto-fetch
636
+ path RelatedList requests one page at a time (`$top`/`$skip`), reads the
637
+ collection size from `QueryResult.total` (`hasMore` fallback), sends user
638
+ column sorts as a server `$orderby`, and seeds the initial order from the
639
+ node's `sort` prop (new `defaultSort` prop on RelatedList). Caller-provided
640
+ `data` keeps the historical client-side slicing. Behavior change: lists that
641
+ previously rendered all rows now show 5 per page — declare a larger `limit`
642
+ on the `record:related_list` node to widen the window.
643
+
644
+ ### Patch Changes
645
+
646
+ - 7cf4051: chore(deps): align every `@objectstack/*` dependency to `^16.0.0-rc.0`
647
+
648
+ Bumps `@objectstack/spec` / `client` / `formula` / `lint` from `^15.1.1` to the
649
+ `16.0.0-rc.0` pre-release across the workspace (root + `apps/console` +
650
+ `apps/site` + all consuming packages). ObjectUI's own packages are already on
651
+ major 16, so this closes the 15↔16 skew between ObjectUI and the `@objectstack`
652
+ contract libraries (which publish in lockstep with `spec`).
653
+
654
+ This is a dependency alignment, not a behavioral migration: the full workspace
655
+ build (43/43) and the `@objectstack`-consuming package test suites
656
+ (`core` / `app-shell` / `data-objectstack` / `plugin-form` / `types`) are green
657
+ against `16.0.0-rc.0` with no source changes required.
658
+
659
+ Practical effect: `@objectstack/client@16.0.0-rc.0` now ships
660
+ `data.batchTransaction` (framework #3271), so `ObjectStackAdapter`'s feature
661
+ detect (`typeof client.data.batchTransaction === 'function'`) routes
662
+ master-detail cross-object saves through the typed SDK method instead of the
663
+ raw `fetch('/api/v1/batch')` fallback — realizing the "verify SDK path" half of
664
+ #2694. The raw-fetch branch stays as a defensive fallback (removal tracked in
665
+ #2694).
666
+
667
+ - 53513a4: fix(plugin-detail): #2688 — record surfaces without a caller title no longer floor to `Record #<id>`, and the meta footer never prints a raw audit user id
668
+
669
+ - `DetailView` header: after every declared-identity step misses and no
670
+ `schema.title` was provided, probe name-ish record keys (`name`, `title`,
671
+ `*_name`, …) before falling to the `Record #<id>` floor. Fixes records whose
672
+ name lives in a field the type-aware derivation deliberately skips (e.g. an
673
+ `autonumber` `name`) opened from surfaces like the gantt row drawer.
674
+ - `RecordMetaFooter`: `created_by` / `updated_by` are always user references on
675
+ ObjectStack — when the fetched schema omits the audit system fields, default
676
+ the reference target to `sys_user` so the footer renders a resolved user name
677
+ (or the muted placeholder) instead of the raw opaque id.
678
+
679
+ - f329ec5: chore(lint): clear the baseline lint errors in plugin-detail (objectui#2713 Wave 3)
680
+
681
+ Wave 3 of the #2713 lint-gate restoration. `@object-ui/plugin-detail` was red at
682
+ baseline on `main`; cleared every **error** (no behavior change; warnings out of
683
+ scope). All nine are `react-hooks` errors — the record renderers called hooks
684
+ after conditional early returns, which is a real fragility (React throws when the
685
+ guard toggles between renders), so each is restructured so hooks run
686
+ unconditionally while the rendered output stays identical:
687
+
688
+ - **`record-reference-rail`** — hoisted `useState` above the empty-entries early
689
+ return (no dependency on it).
690
+ - **`record-related-list`** — moved the `!objectName` placeholder return below
691
+ the four hooks (`usePermissions` / `useFieldPermissions` / `useRelatedRecordActions`
692
+ / `useMemo`); those hooks are pure context/memo reads, safe with an empty
693
+ object name. The object-level read gate ordering is unchanged (covered by
694
+ `RecordRelatedListRenderer.readgate.test`).
695
+ - **`record-quick-actions`** — moved the `requiredPermissions` gate below
696
+ `useActionEngine` (a pure `useContext`/`useMemo` hook).
697
+ - **`record-highlights`** — `useId` + `useRegisterHighlightFields` now run
698
+ unconditionally; the permission gate is enforced after them. Because
699
+ `useRegisterHighlightFields` has a register effect, it is passed `[]` when the
700
+ gate denies — equivalent to not registering, so no body field is ever hidden
701
+ for highlights that aren't rendered.
702
+ - **`RelatedList`** `SectionIcon` (`react-hooks/static-components`) —
703
+ `resolveIconComponent` is a stable registry lookup, not a component created
704
+ during render → justified scoped disable.
705
+
706
+ - 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.
707
+
708
+ - **`@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.
709
+ - **`@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).
710
+ - **`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`.
711
+
712
+ 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.
713
+
714
+ - 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.
715
+
716
+ - **`@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.
717
+ - **`app-shell/utils/managedByEmptyState.ts`** — the writable-`system` create check and its local `EmptyStateUserActions` interface are replaced by `resolveCrudAffordances({ managedBy, userActions }).create`.
718
+ - **`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.
719
+ - **`plugin-detail/RelatedList.tsx`** — its inline `predicatesOf` fold into `userActionPredicates`.
720
+ - **`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.
721
+
722
+ 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.
723
+
724
+ - 3b2e4d9: fix(list): route remaining system-field groupings through the shared classifier
725
+
726
+ Follow-up to the `owner_id` default-column fix: consolidate the display-oriented
727
+ system-field exclusions onto the shared `isSystemManagedField` /
728
+ `SYSTEM_MANAGED_FIELD_NAMES` (from `@object-ui/types`) so the framework-injected
729
+ `owner_id` is treated consistently across the grid, record picker, and detail
730
+ drawer.
731
+
732
+ - `ObjectGrid` record-detail drawer: the business-fields vs. muted meta-section
733
+ split now uses the shared classifier, so `owner_id` (and other injected system
734
+ fields) land in the meta section instead of the business body.
735
+ - `deriveLookupColumns` (record picker): drops its local name set for the shared
736
+ classifier — now flag-aware (`field.system`), not just name-based.
737
+ - `RecordDetailDrawer`: its default `systemFields` set is derived from the shared
738
+ `SYSTEM_MANAGED_FIELD_NAMES`; the `systemFields` prop override is preserved.
739
+
740
+ `deriveRelatedLists`' narrow "audit FK on every object" set and plugin-detail's
741
+ inline-edit "never editable" set are intentionally left distinct — different
742
+ semantics (the latter deliberately keeps `owner_id` editable).
743
+
744
+ - Updated dependencies [0318118]
745
+ - Updated dependencies [af1b0db]
746
+ - Updated dependencies [1100a8b]
747
+ - Updated dependencies [7abe4cd]
748
+ - Updated dependencies [ebe6494]
749
+ - Updated dependencies [f80aaf2]
750
+ - Updated dependencies [29c6040]
751
+ - @object-ui/i18n@16.1.0
752
+
3
753
  ## 16.0.0
4
754
 
5
755
  ### Minor Changes