@object-ui/plugin-list 16.1.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,693 @@
1
1
  # @object-ui/plugin-list
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d21794c: fix(list,i18n): a 400 from the server no longer reads as "check your connection"
8
+
9
+ `classifyLoadError` was written because a 403 rendered the same
10
+ "check your connection and try again" panel as a genuine outage — its own doc
11
+ comment says users "were told to debug their network when the server had
12
+ (correctly) denied them access." It made that distinction for 401 and 403 and
13
+ then sent **everything else**, including 4xx, to the network branch.
14
+
15
+ A **400** is the server saying it understood the request and will never accept
16
+ it. Retrying resends the identical bad request, so "check your connection and
17
+ try again" is advice that cannot work — the same mistake the function exists to
18
+ prevent, one status code over.
19
+
20
+ This became reachable from ordinary stored metadata with
21
+ objectstack-ai/objectstack#4121: a `$filter` array that is not a filter AST is
22
+ now rejected at the protocol with `400 INVALID_FILTER`, where it previously
23
+ reached a driver (and, for a lone `['and']`, silently returned every row). A
24
+ view saved with such a filter now answers 400 on every load.
25
+
26
+ Adds a fourth classification, `rejected`, for `status === 400` and for the
27
+ server's 400-class codes (`INVALID_FILTER`, `UNSUPPORTED_QUERY_PARAM`,
28
+ `INVALID_QUERY`). Its copy points at the filter rather than the network, and
29
+ says who can fix it when the view is saved that way. 403/401 keep priority, so a
30
+ permission denial can never read as a bad request — pinned by a test.
31
+
32
+ The two new strings are added to **all ten locale packs**, not just `en`: the
33
+ neighbouring panels are translated, and `fallbackLng: 'en'` would have rendered
34
+ this one in English beside them. The full-parity gate
35
+ (`all-locales-key-parity.test.ts`) caught the pack I missed.
36
+
37
+ Verified: 5 new tests — numeric status, error code without a status, a status
38
+ embedded in the message text, and the 403/401 ordering guard. Reverting the
39
+ branch fails four of them. `plugin-list` + `i18n`: **403 tests across 29 files**,
40
+ green.
41
+
42
+ - c4db402: refactor(views): ListView's `aria` and `sharing` are the spec sub-shapes (#2890 scope A step 5)
43
+
44
+ Last rename batch in the ListView vocabulary migration.
45
+
46
+ **`aria`** is now the spec's `AriaPropsSchema`: `label` → `ariaLabel`,
47
+ `describedBy` → `ariaDescribedBy`, folded at the ListView boundary like every
48
+ other legacy key. Two things fall out of adopting the spec shape:
49
+
50
+ - `role` becomes authorable. The list region hardcoded `role="region"`; it now
51
+ reads `aria.role` and falls back to `region`.
52
+ - `aria.live` stays as a documented local extension — it has no spec
53
+ counterpart, and dropping it would silently disable a shipped capability.
54
+ Promote it rather than growing that extension.
55
+
56
+ **`sharing`** is now the spec's `ViewSharingSchema` (`{ type, lockedBy }`),
57
+ imported by reference — the local four-key object is gone. The legacy pair folds
58
+ in: `visibility` collapses onto the two ownership kinds the spec models (only
59
+ `private` is `personal`; `team` / `organization` / `public` are all
60
+ `collaborative`), and a bare `enabled: true` maps to `personal`, which is the
61
+ badge the user already saw (the old title fell back to `'private'`).
62
+
63
+ _Visible change_: the share badge's tooltip shows the spec ownership type, so a
64
+ view authored with `visibility: 'team'` reads "Sharing: collaborative" instead
65
+ of "Sharing: team". The four-value audience has no spec home and nothing but
66
+ that tooltip consumed it; keeping a second audience enum alive would re-open the
67
+ fork this issue closes.
68
+
69
+ Also fixes the **spec bridge**, which was doing the opposite of its job: given a
70
+ spec-shaped `sharing`, `transformListView` _downgraded_ it — inventing a legacy
71
+ `visibility` audience and an `enabled` flag that the renderer then had to fold
72
+ back. Both sides speak `ViewSharing` now, so it passes through.
73
+
74
+ `conditionalFormatting` and `exportOptions` are deliberately **not** folded.
75
+ Both objectui shapes are supersets carrying capability the spec cannot express —
76
+ the `{ field, operator, value }` rule form, and `maxRecords` / `includeHeaders`
77
+ / `fileNamePrefix`. Folding them onto the narrower spec shapes would delete
78
+ working features; they want promotion upstream, not a rename.
79
+
80
+ - 5319bf1: feat(views): the list toolbar speaks one vocabulary — `userActions` (#2890 scope A step 3)
81
+
82
+ The seven bare `show*` toolbar flags fold into the spec's `userActions`, and the
83
+ renderer reads nothing else. `showDescription` folds into
84
+ `appearance.showDescription` at the same boundary.
85
+
86
+ | legacy | canonical |
87
+ | :-------------------------------------------------------- | :-------------------------------------------------------- |
88
+ | `showSearch` / `showSort` / `showFilters` / `showDensity` | `userActions.search` / `.sort` / `.filter` / `.rowHeight` |
89
+ | `showGroup` / `showHideFields` / `showColor` | `userActions.group` / `.hideFields` / `.rowColor` |
90
+ | `showDescription` | `appearance.showDescription` |
91
+
92
+ **The last three are new keys, and they close a capability hole rather than just
93
+ renaming one.** `@objectstack/spec`'s `UserActionsConfigSchema` documents itself
94
+ as "which interactive actions are available to users in the view toolbar — each
95
+ boolean toggles the corresponding toolbar element on/off", and already carries
96
+ `rowHeight` (objectui's `showDensity` under its spec name). Grouping, column
97
+ visibility and row coloring are the same kind of toggle: the spec models all
98
+ three as _configuration_ (`grouping`, `hiddenFields`, `rowColor`) but has no
99
+ "may the user change it" switch for any of them.
100
+
101
+ The consequence was visible in the product. With no `userActions` key to read,
102
+ the two list surfaces **hardcoded opposite policies**: `InterfaceListPage` (the
103
+ author-curated interface page) pinned all three OFF, `ObjectDataPage` pinned two
104
+ ON — and an interface-page author could not turn grouping back on for end users
105
+ at all. Both surfaces now express their policy as `userActions` defaults, which
106
+ an author can override.
107
+
108
+ Until the keys land in `@objectstack/spec`, `@object-ui/types` carries them as a
109
+ documented `.extend()` on `UserActionsConfigSchema` (the same shape
110
+ `ListColumnSchema` uses while waiting on objectstack#3761); it collapses into a
111
+ plain re-export once they do. Note the spec schema is not `.strict()`, so before
112
+ this an author writing `userActions: { group: false }` had it **silently
113
+ stripped** — valid on parse, no effect at render.
114
+
115
+ Defaults are unchanged and deliberately asymmetric, matching what these flags
116
+ have always done: `search` / `sort` / `filter` / `rowHeight` / `group` are on
117
+ unless turned off; `hideFields` / `rowColor` are off unless turned on. Making
118
+ them uniform would grow two buttons on every existing view, so it is left as its
119
+ own product decision rather than smuggled into a vocabulary migration.
120
+
121
+ Also drops a dead relay in app-shell's `ObjectView`, which forwarded
122
+ `showDescription` onto the node although `ListView` has only ever read
123
+ `appearance.showDescription`.
124
+
125
+ - 24e0e0a: feat(components,grid,list): a column-header sort orders the whole list, not the page you can see — #3106
126
+
127
+ Clicking a column header under server pagination sorted **the current page**.
128
+ The user saw "sorted by this column" and got "these fifty rows are in order;
129
+ page 2 starts over". The sort was real — its scope was not the one the screen
130
+ implied — and it had no way out of `data-table` at all: the sort lived in two
131
+ `useState`s with no callback, so the layer that issues the request could not
132
+ see it even in principle.
133
+
134
+ `DataTable` gains `manualSorting` + a controlled `sort` + `onSortChange`. In
135
+ that mode it sorts nothing, reports what a header click asks for, and renders
136
+ `sort` as the indicator — keeping **no** sort state of its own, because a
137
+ private copy beside a controlled prop is the shape the defect had.
138
+
139
+ `ObjectGrid` turns that into a `$orderby` in both of its server modes (its own
140
+ fetch, and a parent-driven one), and `ListView` lands it in `currentSort` — the
141
+ same state the toolbar's sort builder writes. One sort, two controls: that is
142
+ what makes "does a header sort outrank the saved view's sort?" a non-question
143
+ rather than a precedence rule someone has to remember.
144
+
145
+ Three details that are decisions, not incidentals:
146
+
147
+ - **A header click replaces the order** instead of appending to it, so the
148
+ column under the cursor is the one the list is sorted by. Multi-key orders
149
+ still come from the sort builder, and the headers render them numbered.
150
+ - **It cannot ask for "no sort".** In client mode the third click clears, and
151
+ that is meaningful there — the rows return to the order they arrived in.
152
+ Across a server-paged collection there is no such order (objectstack#4363), so
153
+ a header offering it would hand the user a worse lie than the one being fixed.
154
+ Clearing stays with the sort builder, which can restore the view's default.
155
+ - **Relational columns render no sort affordance** under server sorting. A
156
+ `lookup` column shows a related record's name while `$orderby` can only order
157
+ by the stored id (objectstack#4256) — the same reason #3096 removed them from
158
+ the toolbar's sort picker. Client-side sorting keys off the rendered label, so
159
+ those headers stay live there.
160
+
161
+ Client-side tables are untouched: same three-state cycle, same local sort.
162
+
163
+ ### Patch Changes
164
+
165
+ - 95b7214: fix(list,grid,detail,tree,core): every column resolver reads one key (#3104 PR2)
166
+
167
+ PR1 (#3119) put a canonicalizing fold at ListView's ingestion boundary. This
168
+ converges the 22 read sites themselves onto `columnIdentity()` from
169
+ `@object-ui/core`, so a surface that is NOT downstream of that fold resolves
170
+ the same identity anyway.
171
+
172
+ That distinction is the user-visible part. A standalone `object-grid` node —
173
+ authored directly on a page, with no `list-view` above it — never passed
174
+ through `normalizeListViewSchema`. Its `getSelectFields` read `c.field` alone
175
+ while the `ensureId` probe one line above read `f?.name || f?.field`, so a
176
+ legacy `{ name: 'account' }` column reached `$select` as a literal `undefined`
177
+ hole: the server never returned the field and every cell in that column came
178
+ back empty. Same for `ObjectTree`, `RelatedList` and the `record:details` /
179
+ `record:related_list` renderers.
180
+
181
+ Converged:
182
+
183
+ | Surface | Was | Now |
184
+ | ---------------------------------------- | ---------------------------------------------- | ----------------------------------- |
185
+ | `ListView` ×9 + its 2 request builders | `name \|\| fieldName \|\| field` vs `f?.field` | `columnIdentity()` |
186
+ | `RelatedList` ×8 | `accessorKey \|\| field \|\| name` | `accessorKey \|\| columnIdentity()` |
187
+ | `ObjectGrid` | name-first probe vs `c.field` projection | `columnIdentity()` |
188
+ | `ObjectTree` | `name \|\| fieldName \|\| field \|\| key` | `columnIdentity() \|\| key` |
189
+ | `buildExpandFields` | `field ?? name ?? fieldName` | `columnIdentity()` |
190
+ | `record-details` / `record-related-list` | `field \|\| name (\|\| key)` | `columnIdentity() (\|\| key)` |
191
+
192
+ `accessorKey` keeps its precedence in `RelatedList` — it is TanStack Table's
193
+ column key, not ObjectStack metadata identity, and only the `field || name`
194
+ tail was converged. `key` stays a tail fallback in `ObjectTree` and
195
+ `record-related-list` for the same reason: it is a generic entry key.
196
+
197
+ Two incidental fixes that TypeScript surfaced once the resolver stopped
198
+ returning `any`: ListView's filter-field options and its hide-fields popover
199
+ both built entries keyed `undefined` for a column with no resolvable identity.
200
+ Those entries could never match a column; they are now dropped.
201
+
202
+ **Inventory re-triage.** PR1 recorded 24 family members. Two were mis-classified
203
+ and are reclassified here rather than converged — reading what they actually
204
+ feed shows they are not column reads at all:
205
+
206
+ - `ViewPreview.tsx` adapts a ViewItem **form** section to what `object-form`
207
+ selects by (`field` → `name`) — the #3090 two-layer join.
208
+ - `SchemaForm.tsx` renders an arbitrary metadata **array** into a popover
209
+ summary and guesses at a display key; the entries are validations, actions,
210
+ or whatever the JSON schema declares.
211
+
212
+ So the family was 22, and it is now **0**. The ratchet asserts that, asserts
213
+ each converged surface actually routes through the shared reader (a surface
214
+ that dropped identity resolution instead of converging it goes red), and pins
215
+ `accessorKey`'s precedence in `RelatedList`.
216
+
217
+ - 9eb932b: fix(console): three real-user console failures — 403 blamed on the network, ⌘K search capped at 8 objects, nav gating fields inert
218
+
219
+ 1. **List error panel classifies the failure** (`plugin-list`, `i18n`): a 403/401 from the data source used to render the same "check your connection" copy as a genuine outage, sending users to debug their network while the server was correctly denying access. The panel now classifies by `httpStatus`/`status`/`statusCode`, the `PERMISSION_DENIED`/`UNAUTHORIZED` error codes, or an `HTTP <status>` message prefix, and renders dedicated permission-denied / sign-in-required copy (all nine locales).
220
+
221
+ 2. **⌘K / full-page search scope is no longer truncated** (`react`): `maxObjectsQueried` caps the per-object fanout fallback, not the search scope — it used to slice the candidate pool itself, so the `objects` whitelist sent to the platform's `/api/v1/search` only ever named the first 8 nav objects. Which sidebar group came first decided which records were findable; everything later in the nav was unsearchable no matter what the user typed.
222
+
223
+ 3. **Nav gating fields finally gate** (`app-shell`): `evaluateVisibility` only evaluated `${…}` template strings, so the `{ dialect: 'cel', source }` envelopes the spec normalizes every authored `visible` predicate into fell through to a blanket "visible" — a constant-false predicate still rendered for everyone. It now delegates to `ExpressionEvaluator.evaluateCondition`, which routes CEL envelopes to the canonical `@objectstack/formula` engine. And the sidebars' `requiredPermissions` check treats a bare name as an ADR-0066 system capability (union of the user's permission-set `systemPermissions` from `/me/permissions`) — the same subset rule the server applies to `AppSchema.requiredPermissions` — instead of misreading it as `can(<name>, 'read')`, which had degraded `requiredPermissions` into a hide-from-everyone switch (admins included). The `object:action` form and the legacy object-read fallback keep working.
224
+
225
+ - 7f0252e: fix(list,data-objectstack,types): exporting a searched list no longer downloads the unsearched superset
226
+
227
+ The server-streamed export mirrored the view's `filter` and `sort`, and the
228
+ code comment claimed that made the file match the screen:
229
+
230
+ > Mirrors the active view's filter + sort so the exported file matches what the
231
+ > user sees.
232
+
233
+ It mirrored one half. There was no way to carry the term a user had typed into
234
+ the search box — `ExportDownloadRequest` had no field for one — so exporting
235
+ during a search produced **more rows than the list showed**, in a file that
236
+ looks authoritative, with nothing indicating the difference. The client-side
237
+ fallback was always correct (it serializes the already-searched `data`); only
238
+ the server path was wrong, and it is the one that handles xlsx.
239
+
240
+ Same family as a dropped filter (objectstack#3948, objectstack#4181): a
241
+ plausible answer that is quietly broader than the one asked for.
242
+
243
+ - `ExportDownloadRequest` gains `search` / `searchFields`.
244
+ - `ObjectStackAdapter.exportDownload` sends them as `search=` / `searchFields=`,
245
+ trimming the term and omitting both when it is blank (`searchFields` alone
246
+ means nothing).
247
+ - `ListView` passes the active `searchTerm` and the view's `searchableFields`,
248
+ and both are now in the export callback's dependency array — a stale closure
249
+ would export the wrong row set.
250
+
251
+ Requires a server with objectstack#4230. Older servers ignore unknown query
252
+ params on this route, so they keep today's behaviour rather than erroring.
253
+
254
+ **Also: the filter merge is no longer written twice.** The three filter sources
255
+ (view filter, filter-panel group, per-field user filters) were merged by
256
+ verbatim copies in the data fetch and in the export — two copies that must
257
+ agree, deciding respectively what the user _sees_ and what they _download_.
258
+ Both now call `buildEffectiveFilter`. This is a pure extraction: the copies did
259
+ agree, and the four parity tests added for it pass against the old code too.
260
+ They exist to keep it that way — the adapter's duplicated filter-shape check
261
+ had already drifted apart unnoticed (#3072).
262
+
263
+ - c4d7b20: fix(view,list,core): a view's filter no longer disappears, or arrives as a predicate on columns that don't exist
264
+
265
+ Sweeping the other `$filter` producers after #3078 turned up two live defects in
266
+ `ObjectView`, which fetches its own data for calendar / kanban / gallery /
267
+ timeline (grid delegates to `ObjectGrid`).
268
+
269
+ **1. An object filter was dropped, and only for non-grid views.**
270
+ `table.defaultFilters` is declared `Record<string, any>`, and the merge tested
271
+ `baseFilter.length > 0` — `undefined > 0` for an object. So the filter vanished
272
+ and the view returned **every record**. `ObjectGrid` assigns the same value
273
+ straight to `params.$filter`, so one view definition filtered correctly as a
274
+ grid and returned everything as a calendar.
275
+
276
+ **2. Rule objects were spread into the `and`, not wrapped.**
277
+ `['and', ...baseFilter, ...userFilter]` is only correct when the source is an
278
+ array of AST nodes. `activeView.filter` is a spec `ViewFilterRule[]`, so
279
+ spreading put bare rule objects where the AST expects nodes:
280
+
281
+ ```js
282
+ isFilterAST([
283
+ "and",
284
+ { field: "stage", operator: "eq", value: "won" },
285
+ ["owner", "=", "me"],
286
+ ]);
287
+ // false → 400 since objectstack#4121
288
+ parseFilterAST(same);
289
+ // {$and:[{field:'stage',operator:'eq',value:'won'}, {owner:'me'}]}
290
+ ```
291
+
292
+ That second line is a predicate over three columns named `field`, `operator`
293
+ and `value` — which don't exist.
294
+
295
+ > **Correction.** The first version of this note said the spread was "reachable
296
+ > whenever a view with a filter meets a user filter value". That was wrong for
297
+ > `ObjectView`: the branch required a non-empty user filter, and nothing ever
298
+ > wrote the state it was built from, so it could never run. The shape is
299
+ > genuinely broken — a live server answers it with a 400 — and the adapter-level
300
+ > defence added alongside is still warranted for any producer that emits it, but
301
+ > **this particular site was dead code, not a live defect.** Defect 1 above was
302
+ > live: it sat on the always-taken path. The dead machinery behind the wrong
303
+ > claim is removed in a follow-up.
304
+
305
+ New in `@object-ui/core`: `toFilterNode` normalizes one source (rule array / AST
306
+ / MongoDB object) and `mergeFilterNodes` combines sources as siblings under one
307
+ `and`. `ObjectView` and `ListView.buildEffectiveFilter` both use them, so the
308
+ three filter shapes are reconciled in one place instead of by hand at each
309
+ renderer.
310
+
311
+ `ObjectStackAdapter` also now translates a bare rule object sitting directly
312
+ under a logical node — the chokepoint defence for any producer still emitting
313
+ the spread shape. Only rule-_shaped_ objects are touched; a child with no
314
+ `field` is a genuine MongoDB condition and passes through untouched.
315
+
316
+ **Correcting a comment shipped in #3078.** `buildEffectiveFilter` documented the
317
+ dropped-object case as unreachable, "nothing in this repo produces one for a
318
+ list view". That was wrong: `ObjectView` passes `mergedFilters` straight into
319
+ that schema's `filter`, and its last fallback is `table.defaultFilters`. The
320
+ case is now handled rather than explained away.
321
+
322
+ Verified with 19 tests across the four packages; reverting each source file
323
+ fails the ones that cover it. Emitted filters are asserted against the spec's
324
+ own `isFilterAST` / `parseFilterAST`, including an executable pin on what the
325
+ old spread shape produced.
326
+
327
+ - 2d5d594: fix(list,detail): sorting a lookup column no longer orders by an invisible key — #3096
328
+
329
+ A relational column (`lookup` / `master_detail` / `user` / `tree`) never holds
330
+ the string its cell shows: it holds the `$expand`-ed record, or a raw foreign-key
331
+ id whose label was resolved separately. Every sort path took that raw value as
332
+ its key, so the column of names came back in an order with no relation to the
333
+ names — sorting looked broken, with nothing saying the key was something else.
334
+
335
+ The two halves are fixed differently, because they can order by different things:
336
+
337
+ - **Client-side sorts** (grid column headers, any `data-table`, a non-windowed
338
+ related list) now key off the label the cell renders, via the new
339
+ `getSortValue` / `compareSortValues` in `@object-ui/core` — which resolves an
340
+ expanded record through `getRecordDisplayName` (ADR-0079), so the sort key and
341
+ the lookup cell agree on which field names a record. This replaces two broken
342
+ comparators: `a[col] < b[col]` is always false between two objects (the
343
+ comparator collapsed to a constant and permuted the rows), and
344
+ `String(a[col])` is `"[object Object]"` (every row compared equal, so the sort
345
+ silently did nothing).
346
+ - **Server `$orderby` sorts** cannot be fixed here — the key is the stored id by
347
+ construction, and `objectstack#4256` settled that no relation join is coming.
348
+ So those entry points stop offering the illusion: the ListView toolbar sort
349
+ picker withholds relational fields and explains why (pointing at a formula
350
+ field as the supported way to sort by a related name), and a windowed related
351
+ list renders no sort button for them.
352
+
353
+ A relational field the view's CURRENT sort already uses stays listed, labelled
354
+ `(by ID)`, so view metadata authored or saved with such a sort round-trips
355
+ instead of rendering a blank row and losing the sort on the next edit.
356
+
357
+ ## 17.0.0
358
+
359
+ ### Minor Changes
360
+
361
+ - 2735de6: feat: render the server's effective API operation set (#3391 PR-4)
362
+
363
+ The frontend now consumes the per-object **effective API operation set** the
364
+ server resolves (from `/me/permissions` `apiOperations`, framework #3391) —
365
+ never the raw `apiMethods` — so Import/Export/New/Edit/Delete buttons match what
366
+ the server will actually admit, and a 405 import refusal shows a dedicated
367
+ message instead of silently falling back.
368
+
369
+ - **core** `resolveCrudAffordances(obj, effectiveApiOperations?)` — new optional
370
+ second argument intersects each affordance bit with its API operation
371
+ (create/import→create/import, edit→update, delete→delete, exportCsv→export).
372
+ Omitting it (old backend / no effective set) leaves affordances unchanged.
373
+ - **permissions** — `/me/permissions` response carries per-object
374
+ `apiOperations`; `PermissionContextValue.getObjectApiOperations(object)`
375
+ exposes it (undefined when absent → callers keep current behavior); `check()`
376
+ maps `import→allowCreate`, `export→allowRead`.
377
+ - **app-shell** `ObjectView` intersects its toolbar affordances with the object's
378
+ effective operations (Import); the platform-admin identity-import bypass is
379
+ unaffected.
380
+ - **plugin-list** `ListView` / **plugin-grid** `ObjectGrid` gate the Export
381
+ button (and export handler) on effective `export`; `plugin-grid` gains the
382
+ `@object-ui/permissions` workspace dependency.
383
+ - **plugin-grid** `ImportWizard` — a 405 / `OBJECT_API_METHOD_NOT_ALLOWED`
384
+ import refusal is detected by a new `isImportNotAllowed` predicate at every
385
+ catch site (async, sync, dry-run) and STOPS with a dedicated
386
+ `grid.import.notAllowed` message (10 locales + fallback dict) — it never falls
387
+ back to the sync/legacy path (which 405s too), distinct from the 404
388
+ route-absent fallback.
389
+
390
+ Backward-compatible: a missing effective set (unrestricted object, older
391
+ backend, or no permission provider) preserves the current default-allow
392
+ behavior everywhere.
393
+
394
+ - ba45145: feat: gate list row Edit/Delete and bulk delete on the server's effective operation set (#3720)
395
+
396
+ The **fourth** surface #3391 left open. The three earlier rounds — the toolbar
397
+ (objectui#2823), detail/form (#3546, objectui#2832 + #2876) and related lists
398
+ (#3546) — all route through `resolveCrudAffordances`. The main list's **row
399
+ CRUD** does not: it has its own resolver (`plugin-grid`'s
400
+ `resolveRowCrudAffordances`), so none of those rounds ever reached it.
401
+
402
+ Its gate was `operations ?? { update: !!onEdit, delete: !!onDelete }` — and
403
+ `ObjectView` wires `onEdit`/`onDelete` unconditionally while view JSON rarely
404
+ declares `operations`, so it was effectively always-on. A caller whose effective
405
+ set carried neither `update` nor `delete` still got the row kebab's Edit/Delete
406
+ **and** the bulk delete, the most destructive affordance on the list.
407
+
408
+ - **plugin-grid** `resolveRowCrudAffordances` now takes `managedBy` and
409
+ `effectiveApiOperations` and resolves the object verdict through the shared
410
+ `resolveCrudAffordances` policy — so the row gate is the SAME decision the
411
+ toolbar, record header, form and related lists make. It also returns
412
+ `objectCanDelete`, the object-level delete verdict that bulk delete gates on
413
+ (bulk rides `onBulkDelete`, a different callback from the row `onDelete`).
414
+ - **plugin-grid** `ObjectGrid` threads its existing `effectiveApiOps` — until
415
+ now fed only to Export — into the row gate, and applies the delete verdict to
416
+ bulk delete: the implicit `['delete']`, an author-declared
417
+ `bulkActions: ['delete']`, and any `bulkActionDefs` entry with
418
+ `operation: 'delete'`. A declared bulk action is a _wiring_ declaration, not a
419
+ permission grant. Custom action ids and non-delete operations pass through
420
+ untouched.
421
+ - **plugin-list** `ListView`'s own bulk bar (the non-grid views — kanban /
422
+ calendar / gallery; the grid path delegates to `ObjectGrid`) drops its
423
+ built-in `delete` under the same verdict.
424
+
425
+ Also closes the ADR-0103 gap on this chain: `rowCrudAffordances` documented the
426
+ bucket lock as "applied upstream via the view's `operations.*`", but the
427
+ all-open default meant it never was — an engine-owned `system` / `append-only` /
428
+ `better-auth` object leaked a generic row Edit/Delete that the engine rejects
429
+ (`assertEngineOwnedWriteAllowed`). Running the shared policy applies it, and a
430
+ `userActions` opt-in still re-opens it (e.g. `sys_user`'s `edit`).
431
+
432
+ Same semantics as the earlier rounds: **intersection, never union** — a server
433
+ grant cannot re-open what the bucket or `userActions` closed, and a
434
+ `userActions` opt-in cannot survive a server denial. A missing effective set
435
+ (unrestricted object, older backend, or no `PermissionProvider`) preserves the
436
+ current behavior.
437
+
438
+ - cd09a7b: refactor(views): ListView reads the spec-canonical `columns`, with legacy `fields` folded in one normalizer (#2890 scope A step 1)
439
+
440
+ `ListViewSchema` has been derived from `@objectstack/spec/ui` since #2231, but
441
+ the renderer still spoke objectui's own vocabulary for the same concepts. First
442
+ rename closed: **`fields` → `columns`**.
443
+
444
+ Legacy acceptance does not disappear — stored view metadata in user databases
445
+ carries `fields` — but it now lives in exactly one place instead of being
446
+ re-implemented per read-site:
447
+
448
+ - **New `normalizeListViewSchema` (`@object-ui/core`)** folds `fields` into
449
+ `columns` (canonical wins when both are present) and drops the legacy key, so
450
+ a read-site that was missed fails loudly instead of quietly taking the legacy
451
+ path. It also absorbs the `viewType` renderability default ListView applied
452
+ inline. Non-mutating, idempotent, and returns its input by reference when
453
+ there is nothing to fold, so ListView's downstream memos keep a stable
454
+ dependency identity.
455
+ - **`ListView` normalizes once at the component boundary**, before anything
456
+ reads the schema. This is what guarantees the fold runs: nothing on the render
457
+ path parses view metadata through zod (the zod schemas serve the CLI
458
+ validator, the VS Code extension and tests), so a `z.preprocess` on
459
+ `ListViewSchema` — spec-side or local — would never execute.
460
+ - **Producers emit `columns`**: `ObjectView`'s `renderListView` payload,
461
+ `ObjectDataPage`, `InterfaceListPage` and the `list-view` registry defaults
462
+ had been _downgrading_ already-canonical `columns` config back to `fields`.
463
+
464
+ Two latent inconsistencies go away with it: the filter builder's
465
+ objectDef-not-loaded fallback now resolves `ListColumn.field` (it read only
466
+ `name`/`fieldName`, so object-form columns produced unnamed filter entries), and
467
+ the column list no longer depends on which of the two keys a host happened to
468
+ emit.
469
+
470
+ `fields` stays declared on `ListViewSchema` and in the drift guard's sanctioned
471
+ set — it is still valid input, and `@objectstack/spec`'s `react-blocks.ts`
472
+ sanctions it as the React-tier `<ListView fields>` prop — but it is input-only.
473
+
474
+ - f1abf0e: fix(views): ListView reads the spec-canonical `filter`, so a view's base filter reaches every visualization (#2890 scope A step 4)
475
+
476
+ Third rename in the ListView vocabulary migration: **`filters` → `filter`**. Unlike
477
+ the first two this closes a live bug, because the fork was asymmetric.
478
+
479
+ `ListView` was the **only** surface in the repo reading `filters`. Every child
480
+ view — `ObjectGrid`, `ObjectGallery`, `ObjectKanban`, `ObjectCalendar`,
481
+ `ObjectGantt`, `ObjectMap`, `ObjectTree`, `ObjectChart` — reads `filter`, and
482
+ `ListView` handed them `filters`. Wherever a child fetches its own rows instead
483
+ of receiving `ListView`'s, the view's base filter was silently dropped:
484
+
485
+ - **a `chart` list view aggregated the whole object.** The chart branch built an
486
+ `object-chart` node with `filters:`; `ObjectChart` reads `schema.filter` and
487
+ never read `filters`, so a chart view with a base filter charted unfiltered
488
+ totals.
489
+ - the same applied to any of the other view components rendered standalone from
490
+ a list-view-shaped config.
491
+
492
+ Conversely, a **spec-authored** list view — one carrying `filter`, which is what
493
+ the spec says and what `runtime-metadata-persistence` and "Save as view" already
494
+ persist — rendered **unfiltered** in `ListView`, because nothing read that key.
495
+
496
+ The fold is a key rename only. Both keys carry an ObjectQL FilterNode array
497
+ everywhere in objectui; every consumer passes the value straight to `$filter`.
498
+ (The spec types `filter` as `ViewFilterRule[]` — `{field, operator, value}`
499
+ objects — so objectui's field is typed from the spec but used as something else.
500
+ That mismatch is real and left alone here: converting formats inside a
501
+ vocabulary fold would change what reaches the data source.)
502
+
503
+ Also collapses a duplicated computation in `app-shell`'s `ObjectView`, which
504
+ computed the same effective filter **twice** — once as `filter` for the child
505
+ views, once as `filters` for `ListView` — with the two copies subtly different
506
+ (only one fell back to `listSchema.filter`; only the other ran token
507
+ substitution over the URL filters). There is now one computation, keeping both
508
+ behaviors.
509
+
510
+ `filters` stays declared on `ListViewSchema` and in the drift guard's sanctioned
511
+ set — stored views carry it and it is still valid input — but it is input-only.
512
+
513
+ - f05b84e: refactor(views): ListView resolves density from the spec-canonical `rowHeight` (#2890 scope A step 2)
514
+
515
+ Second rename in the ListView vocabulary migration: **`densityMode` → `rowHeight`**,
516
+ folded in the same `normalizeListViewSchema` that step 1 introduced.
517
+
518
+ Unlike `fields`/`columns` this is not a pure alias — the two vocabularies are
519
+ different sizes. The spec has five row heights (`compact`/`short`/`medium`/
520
+ `tall`/`extra_tall`); ListView's toolbar offers three densities
521
+ (`compact`/`comfortable`/`spacious`). Both directions now live in one place as
522
+ `DENSITY_MODE_TO_ROW_HEIGHT` / `ROW_HEIGHT_TO_DENSITY_MODE`, chosen so a fold
523
+ followed by a read is a round trip (`spacious` → `tall` → `spacious`), with the
524
+ narrowing collapse (`short` → `compact`, `extra_tall` → `spacious`) stated once
525
+ instead of being re-derived per call site.
526
+
527
+ Two behavior fixes fall out of it:
528
+
529
+ - **Precedence is no longer inverted.** `ListView` read `densityMode` _first_, so
530
+ a view carrying both keys rendered the legacy value — backwards from every
531
+ other legacy/canonical pair in the schema. The canonical key now wins.
532
+ - **The toolbar stops re-seeding the legacy key.** `ObjectView`'s
533
+ `onDensityChange` persisted `densityMode` into stored view metadata on every
534
+ density toggle, so the legacy vocabulary kept regrowing underneath the
535
+ migration. It persists `rowHeight` now.
536
+
537
+ `densityMode` stays declared on `ListViewSchema` and in the drift guard's
538
+ sanctioned set — stored views carry it and it is still valid input — but it is
539
+ input-only.
540
+
541
+ ### Patch Changes
542
+
543
+ - ab46110: fix(list): show the real match total in the record-count status bar under server pagination
544
+
545
+ The Airtable-style record-count bar read `data.length`, but under server-side
546
+ pagination (#2212) `data` is only the current page window — so a 158-row result
547
+ paginated 100/page reported "100 条记录" on page 1 and "58 条记录" on page 2,
548
+ never the true total. There was no other place to see how many records the
549
+ query matched.
550
+
551
+ The bar now shows the server's grand total (`serverTotal`) when known, falling
552
+ back to `data.length` when the whole result set is in memory (non-paginated,
553
+ grouped and non-grid views are unchanged — `serverTotal` is null there, so the
554
+ count is identical to before). Browser-verified against the showcase contacts
555
+ list: the bar reads "158 条记录" and stays stable across pages, and switching to
556
+ grouped/other views correctly resets to the loaded count.
557
+
558
+ - 7d46648: fix(hooks): stop calling translation hooks inside try/catch (objectui#2879)
559
+
560
+ Eleven call sites wrapped a React hook in `try`/`catch` to make it
561
+ "provider-safe". `useObjectTranslation` and `useObjectLabel` already are — they
562
+ read context optionally and fall back to react-i18next's global instance, and
563
+ never throw. The `catch` bought nothing and cost correctness: a throw _after_
564
+ the hook ran desyncs hook order on the next render, because React matches hooks
565
+ positionally. objectui#2595/#2596 fixed exactly this in `@object-ui/i18n`'s
566
+ `createSafeTranslation`; nine plugin-local re-implementations kept their own
567
+ copy of the bug, and two more (`ObjectTimeline`, `ObjectView`) were found by the
568
+ new lint rule below — `ObjectView` had even suppressed
569
+ `react-hooks/rules-of-hooks` inline to keep it.
570
+
571
+ - Six exact re-implementations now delegate to `createSafeTranslation`:
572
+ `plugin-detail`, `plugin-timeline`, `plugin-list`, `plugin-calendar`,
573
+ `plugin-grid`'s `ObjectGrid`, `plugin-designer`.
574
+ - `components`' `data-table` also delegates; `createSafeTranslation` now
575
+ returns `language` alongside `t` so consumers that localize dates don't need
576
+ a second hook call. Purely additive.
577
+ - `plugin-gantt` and `plugin-grid`'s `ImportWizard` keep their local hooks —
578
+ they fall back _per key_, which a single-probe factory cannot express and
579
+ which their comments justify (a host dictionary that covers common keys but
580
+ lags on newer ones). Only the `try`/`catch` is removed.
581
+ - `ObjectTimeline` and `ObjectView` call the hook directly and probe the
582
+ returned value, mirroring `useSafeFieldLabel`.
583
+
584
+ Adds `object-ui/no-try-catch-around-hook` (error) so a twelfth copy fails CI.
585
+ It only matches `use*` names, accepts member calls solely on `React` (so
586
+ `vi.useRealTimers()` is not a hook), and resets its try-depth inside nested
587
+ functions (so `renderHook(() => useThing())` inside a `try` is fine) — both
588
+ false positives were real code in this repo and are pinned in the rule's tests.
589
+
590
+ `eslint-rules/**/*.test.js` matched no vitest project glob, so the local
591
+ plugin's specs had never run in CI. They are now included; all three pass.
592
+
593
+ `ObjectTimeline`'s test mock of `@object-ui/react` omitted `useObjectLabel` —
594
+ the removed `try`/`catch` had been silently absorbing that gap. The mock is now
595
+ complete.
596
+
597
+ - 8aae006: fix(views): the five per-view-type configs speak the spec vocabulary (#2231 phase 3)
598
+
599
+ `kanban`/`calendar`/`gantt`/`gallery`/`timeline` on `ListViewSchema` were the last
600
+ hand-written forks left after #2882 — and the fork was not cosmetic: objectui named
601
+ the same concepts differently from `@objectstack/spec/ui`, and several read-sites
602
+ only understood one of the two dialects. Two of those gaps were live bugs.
603
+
604
+ **Kanban lanes ignored the spec key.** `ListView` gated the Kanban tab on
605
+ `groupByField || groupField` but rendered lanes off `groupField` alone. A config
606
+ authored with the spec key — which is exactly what the product's own
607
+ `CreateViewDialog` emits — offered the tab and then grouped by whatever
608
+ `detectStatusField()` guessed. The spec's `columns` (the fields shown on each card)
609
+ was also spread onto the board verbatim, where `columns` means _lanes_, so
610
+ `ObjectKanban` built lanes with `undefined` id and title. `columns` now maps to
611
+ `cardFields` and the vocabulary keys are stripped from the passthrough.
612
+
613
+ **Timeline lost every spec key in app-shell.** `ObjectView`'s `timeline` branch was
614
+ a three-key whitelist while its `gallery`/`gantt` siblings had already been fixed to
615
+ spread-first, so a stored `timeline: { startDateField, endDateField, groupByField,
616
+ colorField, scale }` arrived with only `titleField` and an axis pinned to the
617
+ `'due_date'` fallback.
618
+
619
+ Also: `plugin-view`'s `ObjectView` now reads `gallery.coverField` and
620
+ `timeline.startDateField` (it only understood the legacy aliases), and the dead
621
+ `gallery.subtitleField` is removed — three producers computed it and `ObjectGallery`
622
+ never read it.
623
+
624
+ The schema side now derives from the spec configs (`.partial()`, since the product
625
+ authors partial configs and spec marks `columns`/`titleField`/`startDateField`
626
+ required). `gantt` needed no local schema at all. The pre-#2231 names
627
+ (`groupField`, `cardFields`, `imageField`, `dateField`) remain accepted as deprecated
628
+ aliases so stored views keep validating; the spec key wins wherever both appear.
629
+ `calendar.defaultView` stays local — it has no spec counterpart.
630
+
631
+ - Updated dependencies [7b21891]
632
+ - Updated dependencies [0b3be01]
633
+ - Updated dependencies [3c4d935]
634
+ - Updated dependencies [4b1ed7d]
635
+ - Updated dependencies [4b60d2d]
636
+ - Updated dependencies [952b978]
637
+ - Updated dependencies [de5e40c]
638
+ - Updated dependencies [1a03af6]
639
+ - Updated dependencies [3e886eb]
640
+ - Updated dependencies [cfc675e]
641
+ - Updated dependencies [20df08c]
642
+ - Updated dependencies [1767124]
643
+ - Updated dependencies [8ecf5a6]
644
+ - Updated dependencies [af705b9]
645
+ - Updated dependencies [0502a7c]
646
+ - Updated dependencies [7b35e4b]
647
+ - Updated dependencies [8fb1295]
648
+ - Updated dependencies [e16ed2d]
649
+ - Updated dependencies [c6fd752]
650
+ - Updated dependencies [f9bbddb]
651
+ - Updated dependencies [dfd3705]
652
+ - Updated dependencies [c77108c]
653
+ - Updated dependencies [2735de6]
654
+ - Updated dependencies [697cda4]
655
+ - Updated dependencies [c19ac11]
656
+ - Updated dependencies [6dee2cb]
657
+ - Updated dependencies [e05f052]
658
+ - Updated dependencies [0502a7c]
659
+ - Updated dependencies [faad45e]
660
+ - Updated dependencies [09c6a17]
661
+ - Updated dependencies [c7cff19]
662
+ - Updated dependencies [ba73a02]
663
+ - Updated dependencies [cd09a7b]
664
+ - Updated dependencies [f1abf0e]
665
+ - Updated dependencies [f05b84e]
666
+ - Updated dependencies [9b4b952]
667
+ - Updated dependencies [341bfb5]
668
+ - Updated dependencies [2f947e4]
669
+ - Updated dependencies [7d46648]
670
+ - Updated dependencies [9b53d72]
671
+ - Updated dependencies [bb4aa25]
672
+ - Updated dependencies [75f1cdf]
673
+ - Updated dependencies [662bdf9]
674
+ - Updated dependencies [059a052]
675
+ - Updated dependencies [53642d4]
676
+ - Updated dependencies [8aae006]
677
+ - Updated dependencies [c6cfdf1]
678
+ - Updated dependencies [d147a13]
679
+ - Updated dependencies [c6aaed8]
680
+ - Updated dependencies [263f885]
681
+ - Updated dependencies [dc334da]
682
+ - @object-ui/components@17.0.0
683
+ - @object-ui/i18n@17.0.0
684
+ - @object-ui/fields@17.0.0
685
+ - @object-ui/react@17.0.0
686
+ - @object-ui/types@17.0.0
687
+ - @object-ui/core@17.0.0
688
+ - @object-ui/permissions@17.0.0
689
+ - @object-ui/mobile@17.0.0
690
+
3
691
  ## 16.1.0
4
692
 
5
693
  ### Patch Changes