@object-ui/i18n 17.5.0 → 17.6.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,639 @@
1
1
  # @object-ui/i18n
2
2
 
3
+ ## 17.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - deb157a: Retire the field designer's `Indexed` toggle — the ObjectStack spec has no
8
+ field-level index flag
9
+
10
+ `indexed` was never a `FieldSchema` key. The field-level flag built no index
11
+ (objectstack#2377 removed it) and, since objectstack#4001 replaced silent
12
+ drops with loud rejection, `FieldSchema.safeParse` refuses it by name. Ticking
13
+ `Indexed` in Studio therefore made `PUT /api/v1/meta/object/:name` fail with
14
+ `422 INVALID_METADATA`, and — because the key was stored — every later save of
15
+ that object stayed blocked until the author found and cleared the toggle.
16
+
17
+ Both field designers stop offering the control and stop authoring the key
18
+ (`ObjectFieldInspector`'s Advanced section; `FieldDesigner`'s advanced
19
+ section, `MetadataFieldsPage`, `MetadataService`, `metadataConverters`), the
20
+ `designer.field.indexed` / `appDesigner.fieldDesigner.indexed` labels retire
21
+ with it across all ten locale packs, and `DesignerFieldDefinition.indexed` is
22
+ removed from `@object-ui/types`.
23
+
24
+ Drafts and objects that already carry the key are un-poisoned on load rather
25
+ than migrated, so an edit-and-save round-trip of previously blocked metadata
26
+ now succeeds. The strip is keyed to the retired key alone — every other
27
+ unknown key on a field definition still survives the round-trip.
28
+
29
+ Declare indexes on the object instead: `indexes: [{ name, fields, unique }]`.
30
+ - 9c60144: **Breaking (published API):** `NavigationRenderer` no longer accepts `resolveGroupLabel` or `resolveItemLabel`. If your build just broke on one of these props, delete the prop — it never did anything.
31
+
32
+ Both were id-keyed label resolvers on `NavigationRendererProps` (`@object-ui/layout`), typically wired to `useObjectLabel().navGroupLabel` from `@object-ui/i18n` to translate sidebar group and leaf labels from a client translation pack keyed `{ns}.apps.{appName}.navigation.{nodeId}.label`.
33
+
34
+ **They could never fire.** The renderer guards convention-based resolution with `isCustomized` — a case-insensitive "the authored label differs from this branch's comparison target" test. On the `object` / `dashboard` branches the target is the object or dashboard name, and the test is meaningful: it protects an author's custom label (`Projects`) from being overwritten by an `objects.project.label` translation. On the two id-keyed branches the target was the nav node's own **`id`** — `grp_workspace`, `group_sales` — while the label was its text — `Workspace`, `Sales`. Those never compare equal, so the guard was true for every real navigation entry and the resolver beneath it was unreachable. The only node that could have reached it is one whose label is literally its own id.
35
+
36
+ Nothing regresses when they go, because nothing was using them to begin with: **app-navigation localization is owned solely by the server-side `/meta` boundary.** `translateApp` (`@objectstack/spec`, `src/system/i18n-resolver.ts`) rewrites every navigation node's `label` by id, and `@objectstack/rest` applies it before the metadata reaches the client — so nav labels arrive already localized and the client-side path was never the one answering. One owner, not two.
37
+
38
+ **If you wired these hooks to localize navigation, your labels were never being localized by them.** Move the translation to the server side: add it to the app's i18n bundle that `translateApp` reads, keyed by navigation node id. A client-side pack keyed `apps.*.navigation.*.label` changes nothing in the sidebar.
39
+
40
+ Also in this change:
41
+
42
+ - `useObjectLabel().navGroupLabel` (`@object-ui/i18n`) is **kept**, but its docstring no longer promises `"Sales" → "销售"` for sidebar groups — that promise was false, and a live docstring describing an unreachable path is how an agent or a developer ends up wiring a translation pack and receiving silent non-localization. It is now documented for what it is: a plain reader for `{ns}.apps.{appName}.navigation.{groupId}.label` with no first-party caller, pointing at the server boundary for anything nav-related.
43
+ - `UnifiedSidebar` (`@object-ui/app-shell`) drops the two prop wirings, including a `studio` carve-out that existed only to stop `resolveItemLabel` from re-translating an already-translated "Package management" label.
44
+ - The `object` / `dashboard` / `viewName` branches — `resolveObjectLabel`, `resolveDashboardLabel`, `resolveViewLabel` — are untouched and keep both their resolvers and the `isCustomized` guard.
45
+ - b8ce7dc: Show the current organization in the console top bar for users with exactly one
46
+ membership.
47
+
48
+ `WorkspaceSwitcher` is a multi-membership affordance — with one organization
49
+ there is nothing to switch to, so it renders nothing — and it was also the only
50
+ place the console ever displayed the organization name. On a deployment whose
51
+ tenancy posture puts an organization wall in force (`isolated` or `group`), that
52
+ left a single-membership business user with no indication anywhere of which
53
+ organization they were looking at, while every list on the page was silently
54
+ scoped to it. Found in a downstream multi-tenant acceptance run.
55
+
56
+ The new `CurrentOrganizationIndicator` renders the active organization name
57
+ read-only — no trigger, no menu, no click target — in exactly the case the
58
+ switcher declines. The switcher's own visibility rule is unchanged, and
59
+ deployments without an organization wall (`single` posture, or a server that
60
+ reports no posture at all) render nothing new: the top bar stays
61
+ organization-silent where organizations are not a scope the user is inside.
62
+
63
+ Adds one translated string, `organization.current.label`, in all ten packs.
64
+
65
+ ### Patch Changes
66
+
67
+ - 69251bf: `AddressField` is translatable, shows no US example placeholders, and formats its readonly line in the reader's address order.
68
+
69
+ The five sub-labels ("Street Address", "City", "State / Province", "ZIP /
70
+ Postal Code", "Country") were English string literals with no i18n key. On a
71
+ non-English console every address field showed five English words in the middle
72
+ of an otherwise fully translated form, and an app had no way to reach them: the
73
+ parts are not fields on the object (`billing_address` is a single `address`
74
+ column), so a translation bundle had nothing to key on, there is no `subLabels`
75
+ property to declare, and the widget cannot be replaced from metadata. They now
76
+ resolve through `fields.address.street` / `.city` / `.state` / `.postalCode` /
77
+ `.country`, added to all ten locale packs. The `en` values are byte-identical to
78
+ the literals they replace, and `FIELD_DEFAULTS` carries the same five, so
79
+ English and provider-less rendering are unchanged.
80
+
81
+ The five input placeholders (`123 Main St`, `San Francisco`, `CA`, `94102`,
82
+ `United States`) are **removed** rather than keyed. They were untranslated and
83
+ US-specific — a zh/ja/ar user was shown an American address as the example of
84
+ what to type — and the right example is a function of the address's country,
85
+ not the reader's language, which no channel in the stored value can supply
86
+ today. Each box keeps the visible label that names it.
87
+
88
+ The readonly line's part order now follows the reader's display locale
89
+ (`useDisplayLocale()`): `zh`, `ja` and `ko` read largest-first (`Country, ZIP
90
+ State, City, Street`), every other locale keeps the unchanged small-to-large
91
+ order (`Street, City, State ZIP, Country`). The display cell renderer takes the
92
+ same locale through the same shared `formatAddress`, so a stored address reads
93
+ identically in a readonly form and in a grid cell.
94
+ - 57e668f: An admin override of an approval now looks like one — before the click, and in the timeline afterwards.
95
+
96
+ A platform/tenant admin who holds **no slot** in a request's pending-approver
97
+ slate saw the exact same filled **Approve** / **Reject** / **Reassign** a
98
+ designated approver sees: no distinct styling, no warning, nothing. One unmarked
99
+ click takes framework#3424's privileged branch, which is *authoritative* — it
100
+ finalises the node even under `per_group` / `unanimous` / `quorum`, silently
101
+ bypassing every co-sign group that has not voted. The measured consequence in a
102
+ real app project was a PM who clicked Approve *"to see if it was real"* and
103
+ finalised a stage for an approver who never acted, then filed it as "countersign
104
+ is broken". The backend was working as designed; the console gave the admin no
105
+ way to see that.
106
+
107
+ Two halves, both client-side:
108
+
109
+ - **Affordance.** A viewer the server reports as `can_act: false` **and**
110
+ `can_override: true` now gets a warning-styled action labelled as an override
111
+ (`Override Approve`), and the dialog it opens **names the pending approvers
112
+ being bypassed**. The wording is the fix, not decoration — a warning that does
113
+ not say what is about to be bypassed would not have stopped that click.
114
+ - **Timeline.** `sys_approval_action.via_override` has been written since
115
+ framework#4466 and sent on the wire ever since, but **no console surface read
116
+ it** — an override rendered byte-for-byte like an ordinary approval in both the
117
+ record page's approvals panel and the Approval Center. Override rows are now
118
+ marked with an `Admin override` chip and a distinct timeline dot. This is
119
+ framework#4466's own *Expected* ("surfaced in the timeline"), which never
120
+ landed.
121
+
122
+ Nothing here relaxes anything. Who may act or override is unchanged, the request
123
+ the console sends is byte-identical, and no audit record is altered — this only
124
+ renders one that was already being written, and adds friction in front of a
125
+ privileged path.
126
+
127
+ Two details worth knowing, because both are load-bearing:
128
+
129
+ - The warning rides the **param dialog's** title and description rather than a
130
+ chained `confirmText`. These decision actions collect params, so the param
131
+ dialog is already the confirm — nothing is POSTed until its own Confirm — and
132
+ putting a second dialog in front of it produces a first prompt that reads as
133
+ "the action ran" (framework#7278, maintainer ruling 2026-08-10). One condition,
134
+ one wording, one dialog.
135
+ - The notice travels as its own dispatch key, **not** folded into the action's
136
+ `description`. The runtime resolves `description` through
137
+ `_actions.<name>.description`, preferring a bundle hit over the passed literal,
138
+ and `plugin-approvals` ships exactly such an entry for `approval_reject` — so a
139
+ warning routed that way would have been silently replaced by the ordinary
140
+ reject copy in every locale carrying the bundle. A safety notice a translation
141
+ can delete is not a safety notice.
142
+
143
+ Which actions get the treatment is read from each action's **own declared
144
+ `visible` gate** (does it OR in `can_override`?), not from a hard-coded name
145
+ list, so a future decision action still ships as metadata alone — and
146
+ `approval_recall`, gated on `is_submitter`, is never relabelled. Every new string
147
+ goes through the `approvalsInbox.*` i18n path in all ten locale packs.
148
+ - 516663d: RecordAttachmentsPanel no longer offers a Retry for an api-disabled `sys_attachment` read.
149
+
150
+ `OBJECT_API_DISABLED` (404, `enable.apiEnabled: false`) and its sibling
151
+ `OBJECT_API_METHOD_NOT_ALLOWED` (405, the operation is absent from
152
+ `enable.apiMethods`) are pure functions of the object's metadata — no user, no
153
+ session, no request body — so every retry of every persona re-fetches the
154
+ identical refusal. Before this change both landed in `RecordAttachmentsPanel`'s
155
+ `unavailable` state and offered a Retry that was guaranteed to change nothing,
156
+ the same wrong advice `ListView`'s error panel already stops giving for list
157
+ reads.
158
+
159
+ The panel gains a fifth status, `api-unavailable`: no Retry button, and honest
160
+ copy ("The attachments list is not available on this object.", new
161
+ `detail.attachmentsApiUnavailable` key in all ten locale packs) instead of
162
+ "We couldn't load the attachments for this record." The pre-existing `denied`
163
+ (authorization) and `unavailable` (network/5xx/expired-session) states and
164
+ their affordances are unchanged.
165
+
166
+ `ListView.classifyLoadError` — the classifier that already separated this case
167
+ into its own `api-disabled` kind for list views — is lifted out of
168
+ `packages/plugin-list/src/ListView.tsx`'s module scope into
169
+ `@object-ui/react` (`classifyLoadError`, `LoadErrorKind`), so both surfaces
170
+ consume one classification instead of `RecordAttachmentsPanel` re-deriving it.
171
+ `ListView`'s own behavior is unchanged — it now imports the function it
172
+ previously defined locally. The classifier delegates its api-disabled check to
173
+ `isApiAccessDeniedError` (`@object-ui/data-objectstack`), removing a second,
174
+ independently-maintained copy of the same code list.
175
+ - 41ac1b7: RecordAttachmentsPanel distinguishes a DENIED attachment list from an empty one.
176
+
177
+ A `sys_attachment` list read refused for authorization reasons (HTTP 403 /
178
+ `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security denial) was swallowed
179
+ into the panel's empty state, so a member denied the parent record was told
180
+ "No attachments yet. Upload a file to get started." about a record holding
181
+ 2095+ attachments — and was offered an Upload the server would refuse.
182
+
183
+ The panel now classifies that refusal with the same `isPermissionError`
184
+ predicate the kanban, calendar and form surfaces branch on, renders a distinct
185
+ denied state ("You don't have access to these attachments.", new
186
+ `detail.attachmentsAccessDenied` key in all ten locale packs), and withdraws
187
+ the Upload affordance. The denied state renders the translated sentence and
188
+ nothing sourced from the error — no status code, no server text, no row count.
189
+ The empty state is now reserved for a genuine 200-with-zero-rows; non-authz
190
+ failures keep their pre-existing handling.
191
+ - 1eaf0a1: RecordAttachmentsPanel distinguishes an UNLOADED attachment list from an empty one.
192
+
193
+ A `sys_attachment` list read that failed for any non-authorization reason — a
194
+ network failure (server unreachable, DNS, aborted request), a 5xx, or a 401 /
195
+ `AUTH_REQUIRED` (an expired session is authentication, not authorization, so the
196
+ denied predicate deliberately does not claim it) — was swallowed into the
197
+ panel's empty state. All three rendered "No attachments yet. Upload a file to
198
+ get started.": an affirmative claim about the record's contents, made by a panel
199
+ that never got an answer, over a record that may hold thousands of files.
200
+
201
+ The panel now carries the same four-way status vocabulary its siblings use —
202
+ `loading` / `loaded` / `denied` / `unavailable` — and every state that means
203
+ "the panel does not know" is answered before `rows.length === 0` is allowed to
204
+ mean "the record holds nothing". A failed read renders a distinct unavailable
205
+ state ("We couldn't load the attachments for this record.", new
206
+ `detail.attachmentsLoadFailed` and `detail.retryLoadAttachments` keys in all ten
207
+ locale packs) with a **retry** — unlike the denied state, an outage and a lapsed
208
+ session are both things a second attempt can fix — and withdraws the Upload
209
+ affordance, because offering an upload against a list the panel could not reach
210
+ is the same over-assertion as the empty state it replaces. Like the denied
211
+ state, it renders the translated sentence and nothing sourced from the error: no
212
+ status code, no server message, no host.
213
+
214
+ The empty state is now reserved for a genuine 200-with-zero-rows, and the
215
+ denied state (403 / `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security
216
+ denial) is unchanged. The "table not provisioned on an older stack" case is
217
+ unaffected: the ObjectStack adapter degrades a bare 404 to `{ data: [], total: 0 }`,
218
+ so it resolves through the success path and still renders the empty state.
219
+
220
+ This restores a house rule that had already landed twice as a bug fix —
221
+ `HomeActionCenter` may only say "You're all caught up" once the inbox has
222
+ answered, and an unloadable app list is UNKNOWN rather than "no default app".
223
+ - 2e82ab2: The config panel footer translates: `ConfigPanelRenderer`'s Save / Discard labels come from the locale pack.
224
+
225
+ `saveLabel` and `discardLabel` carried the English literals `'Save'` and
226
+ `'Discard'` as parameter defaults, and no caller in the repo passes either prop,
227
+ so the sticky footer that appears the moment a config draft is dirty stayed
228
+ English in every locale — inside panels whose every other string had already
229
+ been routed through `t()`. The fix is in the renderer rather than per-caller:
230
+ the footer is the renderer's own chrome, so a caller-side fix would translate
231
+ one panel's footer and leave the next host's English.
232
+
233
+ Both labels now resolve through `createSafeTranslation` — the mechanism this
234
+ package already uses for its built-in copy in `form.tsx`,
235
+ `fullscreen-editor.tsx`, `data-table.tsx` and friends. An explicitly passed
236
+ `saveLabel` / `discardLabel` still wins, unchanged and untranslated.
237
+
238
+ `common.save` is reused rather than twinned: it already ships `Save` in all ten
239
+ packs and is what the console's other save buttons read. `common.discard` is
240
+ new, because the packs carried no shared spelling of the word — the three that
241
+ existed are each scoped to one surface (`form.discard`,
242
+ `console.settingsView.discard`, `console.objectView.discard`) and the last of
243
+ them diverges from the other two in zh/ko/fr. Its ten values are the majority
244
+ spelling, byte-identical to `form.discard` and `console.settingsView.discard`.
245
+
246
+ Both English defaults are byte-identical to the literals they replace, so a
247
+ host that mounts no `I18nProvider` renders exactly what it did before.
248
+ - ef0d150: The dashboard config sidebar translates: `WidgetConfigPanel` and `DashboardConfigPanel` are wired through `t()`.
249
+
250
+ Both panels build a `ConfigPanelSchema` — breadcrumb, section titles, field
251
+ labels, placeholders, help text and option labels — and neither imported a
252
+ translation hook at all, so all 61 of their user-visible strings were English
253
+ literals. Both are exported from the package barrel and mounted by
254
+ `DashboardWithConfig` as the dashboard editing sidebar, so a user on any
255
+ non-English console opened a panel that stayed English inside chrome that had
256
+ translated around it.
257
+
258
+ They now resolve through a new `dashboard.config.*` namespace — 75 keys, added
259
+ to all ten locale packs. The namespace sits beside `dashboard.trend.*` and
260
+ `dashboard.filters.*`, which is where this package's other translated surfaces
261
+ already read from, and the panels reach it through
262
+ `useConfigPanelTranslation`, a `createSafeTranslation` hook whose
263
+ `CONFIG_PANEL_DEFAULT_TRANSLATIONS` map carries the English defaults for hosts
264
+ that mount no `I18nProvider`.
265
+
266
+ The keys are authored fresh against the wording the panels actually ship rather
267
+ than restored from the retired `configPanel.*` block: that vocabulary had no
268
+ reader, was never validated against a shipped label, and covered 16 of the 61
269
+ strings. Where the two name the same word the translations are reused.
270
+
271
+ Every `en` pack value and every built-in default is byte-identical to the
272
+ literal it replaces, so English rendering and provider-less rendering are
273
+ unchanged — asserted row by row, in both directions, against a frozen table of
274
+ the pre-change literals.
275
+ - f34226e: Delete two dead i18n namespaces — `configPanel.*` (16 keys) and `renderer.*` (13 keys) — from all ten locale packs.
276
+
277
+ 290 translated strings (29 keys x 10 packs) with **no reader anywhere in the
278
+ repo**. Measured with `node scripts/check-i18n-dead-keys.mjs`, the reverse sweep
279
+ from objectui#4658: it subtracts the referenced key set (the AST walk that
280
+ `check-i18n-call-site-keys.mjs` already uses, plus plural suffixes, plus
281
+ `returnObjects` branches, plus every dynamic template head) from the pack key
282
+ set, then re-checks each survivor with a fixed-string grep over the whole repo.
283
+ Both namespaces scored a clean sweep — `configPanel.*` 16/16 CONFIRMED and
284
+ `renderer.*` 13/13 CONFIRMED, zero NEEDS-REVIEW, and a fixed-string grep for all
285
+ 29 keys returns nothing at all outside `packages/i18n/src/locales/`.
286
+
287
+ `configPanel.*` is a dashboard-widget config-panel vocabulary (`layout`,
288
+ `columns`, `gap`, `rowHeight`, `refreshInterval`, `appearance`, `theme`,
289
+ `general`/`advanced`, …). The two components that surface exactly that
290
+ vocabulary — `packages/plugin-dashboard/src/WidgetConfigPanel.tsx` and
291
+ `DashboardConfigPanel.tsx` — import no translation hook at all and hardcode the
292
+ same words in English (`title: 'Layout'`, `label: 'Columns'`, `label: 'Gap'`,
293
+ `label: 'Show description'`, `label: 'Theme'`). The two config panels that do
294
+ use i18n read other namespaces: `ViewConfigPanel.tsx` reads
295
+ `console.objectView.*`, `ReportConfigPanel.tsx` reads `common.*` and
296
+ `report.editor.*`. No component reads `configPanel.*`.
297
+
298
+ `renderer.*` is SchemaRenderer placeholder/status vocabulary (`noPageSchema`,
299
+ `noFormSchema`, `noDashboardSchema`, `pageRendering`, `dashboardRendering`,
300
+ `formRenderingMode`, …). It is dead in the stronger sense: the English strings
301
+ have no hardcoded twin either — `No page schema provided`, `No form schema
302
+ provided`, `No dashboard schema provided`, `Page rendering`, `Dashboard
303
+ rendering` and `Form rendering in` each return zero hits repo-wide outside the
304
+ packs, and `packages/react/src/SchemaRenderer.tsx` imports no translation hook.
305
+ The messages themselves are gone from the product, not merely un-translated.
306
+
307
+ No behaviour change is possible: a key with no reader cannot be read. No test
308
+ fixture pinned any of the 29 keys, and neither i18n baseline JSON names one, so
309
+ nothing else had to move.
310
+
311
+ Part of #4730.
312
+ - 564b605: Delete two dead i18n namespaces — `workflow.*` (58 keys) and `publicForm.demo.*` (36 keys) — from all ten locale packs.
313
+
314
+ 940 translated strings (94 keys x 10 packs) with **no reader anywhere in the
315
+ repo**. Measured with `node scripts/check-i18n-dead-keys.mjs`, the reverse sweep
316
+ from objectui#4658: it subtracts the referenced key set (the AST walk that
317
+ `check-i18n-call-site-keys.mjs` already uses, plus plural suffixes, plus
318
+ `returnObjects` branches, plus every dynamic template head) from the pack key
319
+ set, then re-checks each survivor with a fixed-string grep over the whole repo.
320
+ `workflow.*` scored 54/58 CONFIRMED with the other 4 appearing only in two i18n
321
+ test fixtures — mentions, not consumers; `publicForm.demo.*` scored 36/36 with
322
+ zero textual footprint anywhere outside the packs.
323
+
324
+ `workflow.*` is a complete BPMN/workflow-designer vocabulary (`userTask`,
325
+ `serviceTask`, `parallelGateway`, `boundaryEvent`, `importBpmn`/`exportBpmn`,
326
+ `undo`/`redo`, …). Its one plausible consumer,
327
+ `packages/plugin-designer/src/ProcessDesigner.tsx`, hardcodes English
328
+ (`{ label: 'User Task', value: 'user-task' }`) and imports no translation hook
329
+ at all, while six sibling components in the same package do use one — the
330
+ vocabulary was never wired up. `publicForm.demo.*` is demo content (contact and
331
+ support form titles, field labels, industry/issue-type/priority options) for a
332
+ public-form showcase page that does not exist in this repo.
333
+
334
+ No behaviour changes: a key with no reader cannot be read. The `publicForm.*`
335
+ parent namespace and every other namespace are untouched.
336
+ - a1609a6: Console list filters: a `between` range is submitted only when both bounds are filled, and six operator labels stop rendering as raw i18n keys.
337
+
338
+ Two defects in the list-view filter panel (objectstack#8815), both in the Console
339
+ render layer, with no workaround available downstream.
340
+
341
+ **A half-filled range no longer refuses the whole view.** Picking a date column
342
+ and 「介于」 draws two inputs — that part landed in objectui#3958 — but typing
343
+ only one bound produced `["2024-01-01", ""]`, and both write paths read "is this
344
+ row filled in?" with one shape-blind predicate (`null` / `''` / empty array).
345
+ An array of length 2 passed it, so the empty bound went to the server, which
346
+ refuses the query outright (`400 INVALID_FILTER`): the list showed
347
+ 「该视图的查询被拒绝」 and the filters the user had already applied stopped
348
+ applying too. The saved-view fold persisted the same half-range, so the refusal
349
+ came back on every later read of that view, for every user of it.
350
+
351
+ The spec cannot intercept this — `ViewFilterRuleSchema` accepts
352
+ `["2024-01-01", ""]` because it counts the two slots rather than what is in
353
+ them, while refusing a scalar or a one-element array. Authoring validation is
354
+ therefore green on exactly the shape that fails at query time, which makes not
355
+ emitting it the producer's job. `@object-ui/components` now exports
356
+ `isFilterValueComplete(operator, value)` — arity-aware, so a `pair` row needs
357
+ both bounds — and the two consumers that had each kept a copy of the old
358
+ predicate (`plugin-list`'s `convertFilterGroupToAST`, `app-shell`'s
359
+ `foldFilterGroupToSpecRules`) read it instead. A half-filled range is now
360
+ dropped exactly as a half-typed `equals` row already was: no filter, rather than
361
+ a filter the server will reject. Bounds of `0` and `false` stay real bounds.
362
+
363
+ **Six operator labels are translated in all ten locale packs.**
364
+ `startsWith`, `endsWith`, `isNull`, `isNotNull`, `exists` and `notExists` were
365
+ missing from every pack, so i18next resolved them to the raw key and the dropdown
366
+ showed `filterBuilder.operators.isNull` beside translated entries. The
367
+ component's own defaults table could not cover it: that table serves only the
368
+ no-provider path, and the Console mounts a provider. The report named four —
369
+ a `date` column's bucket offers the four nullness operators; a `text` column
370
+ showed all six.
371
+
372
+ Because the label key is built dynamically (`t(\`filterBuilder.operators.${op}\`)`),
373
+ no existing gate could see the gap: the call-site checker classifies a template
374
+ key as `missing-prefix` and only asks whether the prefix resolves, and
375
+ cross-pack parity is satisfied when all ten packs are missing a key together.
376
+ A new parity test pins the packs against `FILTER_BUILDER_OPERATORS` in both
377
+ directions, so an operator added to the dropdown now fails loudly until every
378
+ pack labels it.
379
+ - 37f6844: FilterConditionField can author the spec's `$icontains` — case-insensitive contains is reachable from the filter UI.
380
+
381
+ `@objectstack/spec`'s `FieldOperatorsSchema` gained `$icontains` between
382
+ `17.0.0-rc.2` and `rc.5`, and every driver and evaluation face the platform
383
+ ships now executes it. `FilterConditionField` had no builder operator that could
384
+ author it, so the capability was unreachable from the sharing-rule criteria
385
+ builder and sat in that widget's parity test as an explicit `KNOWN_UNREACHABLE`
386
+ entry.
387
+
388
+ The FilterBuilder gains a `containsCaseInsensitive` operator ("Contains (ignore
389
+ case)", translated in all ten locale packs). `condToMongo` emits
390
+ `{ field: { $icontains: value } }` and `kvToCondition` reads it back, so a saved
391
+ criteria reopens in the visual builder instead of falling into the raw-JSON
392
+ editor. Today's `contains` is unchanged and still emits the case-SENSITIVE
393
+ `$contains`; whether it should have been case-insensitive all along is a product
394
+ question that stays open, and stored filter views keep meaning what they meant.
395
+
396
+ The fold is ASCII-only by contract — `café` does not match `CAFÉ`.
397
+
398
+ The new operator is **opt-in per consumer**: `FilterBuilder` takes an
399
+ `extraOperators` prop, and only `FilterConditionField` passes it. The one
400
+ dropdown feeds three at-rest dialects and only the MongoDB-style criteria this
401
+ widget writes can carry the operator — the spec's `VIEW_FILTER_OPERATORS` (saved
402
+ views) and `VALID_AST_OPERATORS` (the live grid's filter AST) have no
403
+ case-insensitive contains, so offering it there would author a filter those
404
+ paths cannot execute. Every other FilterBuilder is unchanged.
405
+ - 2b50261: `FilterBuilder` gives the set and range operators an input that matches the value shape the spec accepts, and stops minting the shape it refuses.
406
+
407
+ Three independent paths let one filter row end up with `operator: 'in'` and a
408
+ SCALAR `value` — the shape `ViewFilterRuleSchema` refuses at save time since
409
+ objectstack#6227, and the shape the query path answered `400 INVALID_FILTER` on
410
+ before that (objectstack#5869):
411
+
412
+ - Changing the operator dropdown wrote `{ operator }` alone, so the seed `''`
413
+ (or whatever the previous family had produced) survived the switch into
414
+ `in` / `not_in` / `between`. The operator and the shape of its value are one
415
+ edit, so they are now made together: switching families re-shapes the value —
416
+ a typed scalar becomes a one-element list, an empty one becomes `[]`, a range
417
+ keeps its first bound and leaves the second open, and a list collapsing to a
418
+ scalar keeps its first entry.
419
+ - A plain text or number column has no static `options`, so `in` fell through to
420
+ the single-value input and the user could only ever type a scalar into it.
421
+ Those columns now get a token input (type, Enter or comma commits, `×` or
422
+ Backspace removes) that always emits an array; `between` gets its two bounds
423
+ instead of one box. The lookup picker's no-DataSource fallback, which also
424
+ handed back a scalar while `multiple`, emits a list too.
425
+ - The multi-value families were decided from a local `["in", "notIn"]` literal,
426
+ already one spelling adrift: `notIn` is an alias and the canonical member is
427
+ `not_in`, so a stored view read back in canonical form got the single-value
428
+ input for a set operator. The families are now read from `@objectstack/spec`'s
429
+ exported `VIEW_FILTER_LIST_VALUE_OPERATORS` / `VIEW_FILTER_PAIR_VALUE_OPERATORS`
430
+ and folded through `normalizeFilterOperator`, so both spellings of one operator
431
+ get one answer and a family the spec widens is picked up without an edit here.
432
+
433
+ `foldFilterGroupToSpecRules` is unchanged and needed no change: it normalizes the
434
+ operator and carries `value` through verbatim, so the shape that reaches storage
435
+ is the producer's to get right. An untouched `in` row arrives as `[]`, which the
436
+ fold's existing incomplete-row rule already drops.
437
+
438
+ Four locale keys are added to all ten packs for the new inputs
439
+ (`filterBuilder.addValue` / `.removeValue` / `.rangeStart` / `.rangeEnd`).
440
+ - af5e292: Emit explicit file extensions on relative import specifiers, so the published
441
+ entries can be imported by Node's own ESM resolver.
442
+
443
+ `@object-ui/react`'s built entry re-exported through extensionless relative
444
+ specifiers (`export * from './SchemaRenderer'`). Node does not extension-search
445
+ relative specifiers, so `import('@object-ui/react')` under plain Node — an SSR
446
+ host, or any consumer without a bundler — failed with `ERR_MODULE_NOT_FOUND`.
447
+ Bundled consumers were never affected and are unchanged by this.
448
+
449
+ `@object-ui/types`, `@object-ui/core` and `@object-ui/i18n` carried the same
450
+ emission; `@object-ui/react`'s entry stayed unloadable until they were fixed
451
+ too, because evaluation crosses into them. No exported API changed.
452
+ - bea374e: A KPI card's sub-caption now translates from its own convention key
453
+
454
+ objectui#4032 item 4. The metric card renders two authored strings, and they
455
+ are two different authored fields:
456
+
457
+ | authored field | rendered as | bundle key |
458
+ |-----------------------|-------------------------|-------------------------------------------|
459
+ | `widget.description` | the shared card header | `dashboards.<d>.widgets.<id>.description` |
460
+ | `options.description` | the KPI sub-caption | `dashboards.<d>.widgets.<id>.subCaption` |
461
+
462
+ Only the first resolved. The metric dispatch spread `...options` straight
463
+ through, so the sub-caption reached `MetricWidget` as the raw authored English
464
+ and a `zh` dashboard showed a translated header above an untranslated caption.
465
+
466
+ They get two keys, not one — the objectstack#5428 item-4 ruling (2026-08-06):
467
+ "两个作者字段两个 key". That is why PR #4358 landed items 1-3 and deliberately
468
+ stopped here: at the time `@objectstack/spec` accepted no segment for the
469
+ sub-caption and the only key it would take was `description`, the shared key the
470
+ ruling forbids. objectstack#8056 added `subCaption` to the widget translation
471
+ node, and it ships in `@objectstack/spec@17.0.0` — the version this repo pins.
472
+
473
+ The server half already existed: `translateDashboard` overlays `subCaption` onto
474
+ `options.description` on the `/meta` path, so a served document was already
475
+ correct. This is the client half — the same key path, for the app bundles
476
+ objectui loads into `I18nProvider` itself.
477
+
478
+ - `@object-ui/i18n` gains `widgetSubCaption(dashboardName, widgetId, fallback?)`,
479
+ mirroring `widgetDescription` limb for limb rather than re-implementing
480
+ namespace discovery inside the plugin.
481
+ - `DashboardRenderer`'s `tWidgetSubCaption` composes the two channels in the
482
+ order `tWidgetTitle` already fixed — the authored value is collapsed to the
483
+ active language first (an inline per-locale map, the `pickLocalized` seam),
484
+ and the plain string that falls out is offered to the bundle as its fallback —
485
+ so a bundle entry always wins over an inline map, and neither channel is
486
+ replaced by the other. The resolved value is assigned after the `...options`
487
+ spread in both the `object-metric` and static-value branches.
488
+
489
+ The separation is pinned in both directions, because a shared key is exactly
490
+ what a later tidy-up would reach for: the `description` key never reaches
491
+ `options.description`, and `subCaption` never reaches `widget.description`. On a
492
+ `kpi` / `gauge` / `bullet` widget both are on screen at once, so one shared key
493
+ would make a single translation entry overwrite the other field's text.
494
+
495
+ Untranslated dashboards are unchanged: with no bundle entry the resolver hands
496
+ back exactly what the spread would have, and with nothing authored and nothing
497
+ translated it answers `undefined` rather than `''`, so a card that has no
498
+ sub-caption grows no caption row.
499
+ - d109a4d: The `organizations.*` picker locale family (avatar menu / console picker —
500
+ `mine`, `title`, `heading`, `subtitle`, `searchPlaceholder`, `new`, `current`,
501
+ `emptyTitle`, `emptyDescription`, `noMatches`) is now internally consistent in
502
+ all eight non-en/zh packs (`ar`, `de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`).
503
+
504
+ `en` and `zh` were renamed from "organization" to "workspace" terminology in
505
+ full; the other eight packs only had `create` follow, leaving each pack mixing
506
+ both nouns in one dropdown (e.g. `de.ts` read `create: "Workspace erstellen"`
507
+ directly beside `mine: "Meine Organisationen"`). Since PR #4638 restored the
508
+ avatar menu's "My Workspaces" entry, both lines render two apart in the same
509
+ menu. Each pack now uses the workspace term its own `create` key already
510
+ committed to (`Arbeitsbereich`-style German phrasing → `Workspace`, French
511
+ `espace de travail`, Japanese `ワークスペース`, Korean `워크스페이스`, Spanish/
512
+ Portuguese `espacio de trabajo` / `workspace`, Russian `рабочее пространство`,
513
+ Arabic `مساحة عمل`), with grammatical agreement (gender, definiteness,
514
+ particles) adjusted per key.
515
+
516
+ The sibling `organization.*` (singular) namespace — organization
517
+ **management**, a deliberately distinct surface per the comment separating the
518
+ two blocks in `en.ts` — is untouched in every pack.
519
+ - ad13d63: Removed five locale keys that had no call site in any package, app, example,
520
+ or e2e test: `console.objectView.striped`, `console.objectView.bordered`,
521
+ `console.objectView.virtualScroll`, `appDesigner.stripedRows`, and
522
+ `appDesigner.bordered`. Deleted from all ten packs (`en` plus the nine
523
+ translations) to keep `all-locales-key-parity.test.ts` green.
524
+
525
+ The `console.objectView.*` three describe grid options ObjectStack retired
526
+ upstream (objectstack#7176, 2026-08-10) and objectui stopped forwarding
527
+ (objectui#4649); the `appDesigner.*` pair is textually identical in name only
528
+ and was independently dead — no `AppCreationWizard.tsx` control, no fallback
529
+ entry in `useDesignerTranslation.ts`'s `DESIGNER_DEFAULT_TRANSLATIONS`, and no
530
+ mention anywhere in the repo outside the locale packs themselves.
531
+
532
+ No runtime behavior changes: nothing rendered these labels before this patch.
533
+ - a9e17b4: `auth.forgotPassword.successDescription`'s address hole is now spelled with
534
+ single braces (`{email}`) instead of i18next's double braces (`{{email}}`),
535
+ in all ten locale packs.
536
+
537
+ This is a spelling-only change — rendered output is byte-identical in every
538
+ language, because the hole was never filled by i18next in the first place:
539
+ `ForgotPasswordForm` substitutes the address itself once the user submits
540
+ the form (the label renders before the address exists, so `t()` cannot do
541
+ it). `{{email}}` and a genuinely unfilled i18next hole were indistinguishable
542
+ at the call site, and passing `email` as an interpolation argument — the
543
+ natural "fix" for what looks like a missing argument — would let i18next
544
+ consume the hole and cause the address to be appended a second time
545
+ (objectui#4135).
546
+
547
+ Converging on `{x}` for every hole a component fills downstream of `t()`
548
+ (the convention `resendOtpCountdownText`'s `{seconds}` already used) puts
549
+ this hole outside i18next's `{{…}}` syntax entirely, so the ambiguity is
550
+ gone by construction rather than fenced by an exemption. Accordingly,
551
+ `scripts/check-i18n-call-site-keys.mjs`'s `EXTERNALLY_INTERPOLATED_HOLES`
552
+ registry entry for this key is retired — the gate needs no exemption for a
553
+ hole i18next was never going to touch.
554
+
555
+ `ForgotPasswordForm.tsx`'s replacement marker and its own built-in default
556
+ label move to the same spelling in the same change, as does the inline
557
+ `defaultValue` at `apps/console/src/pages/auth/ForgotPasswordPage.tsx`'s
558
+ call site.
559
+ - 8871c14: Gate Home's metadata-authoring CTAs on the `manage_metadata` capability the
560
+ server reports, with a visible localized reason.
561
+
562
+ "Build an app" and "Start with a template" were gated on `useIsWorkspaceAdmin()`
563
+ — a ROLE check. On a multi-tenant deployment that deliberately withholds
564
+ metadata authoring from tenants, a workspace owner is an admin by role yet holds
565
+ no `manage_metadata`, so the most prominent CTA on their home page led into
566
+ `/studio`, a filled-in new-package dialog, and a capability refusal at submit.
567
+
568
+ Both cover cards are now disabled, with the reason shown on screen and in the
569
+ tooltip, whenever the session lacks the capability. The marketplace shortcut in
570
+ the apps strip is withheld with them (it targets the same route, so leaving it
571
+ live would have made the gate cosmetic), as is the "Build with AI" hero CTA
572
+ (its output is draft metadata that cannot be published without the capability).
573
+ On a workspace with no apps yet, the admin empty state explains the posture
574
+ instead of directing the owner to build their first application.
575
+
576
+ The gate consumes the answer `GET /api/v1/auth/me/permissions` already returns,
577
+ surfaced through the permissions provider — no permission logic is re-derived in
578
+ the client. Unknown capabilities fail OPEN: a backend that reports no
579
+ `systemPermissions` at all is indistinguishable from one reporting an empty set,
580
+ and the server enforces regardless, so nothing is withheld on missing client
581
+ data. New key `home.build.noCapability` in all ten locale packs.
582
+ - 21e4585: A write-warning toast now words each strip reason on its own instead of calling everything that is not `readonly_when` "read-only".
583
+
584
+ `emitWriteWarning` picked its sentence with a two-way conditional on
585
+ `reason === 'readonly_when'`. The other arm was never "readonly" — it was
586
+ "everything that is not `readonly_when`", so any reason the server-side write
587
+ path gained afterwards was announced to the user as a read-only lock, pointing
588
+ them at a permission problem that does not exist. That is the same defect
589
+ objectui#3484 / framework#3794 fixed for `readonly_when` itself, one reason
590
+ later.
591
+
592
+ The reason is now resolved through a table declared
593
+ `Record<DroppedFieldsEvent['reason'], …>`, mirroring the framework side of the
594
+ same seam (`service-automation`'s `DROPPED_REASON_LABEL`) and the shape the
595
+ spec's own `DroppedFieldsEventSchema` comment asks consumers for. The next reason
596
+ added upstream is a `type-check` failure here, unworded, where the conditional
597
+ compiled forever — which is what re-exporting THE spec type rather than a
598
+ hand-widened `string` was for all along.
599
+
600
+ Not a latent fix: the pinned spec (`@objectstack/spec` 17.0.0-rc.6) already
601
+ carries `primary_key` (objectstack#6437), the strip that keeps a payload `id` the
602
+ update dispatch ruled is not an identifier out of the targeted rows' primary key.
603
+ Until now a user who triggered it was told the field was read-only. It gets its
604
+ own wording in all ten locale packs: "The record's identifier cannot be changed
605
+ by a save, so it did not take effect".
606
+
607
+ One more line was added for a reason ahead of this bundle's pin — the adapter
608
+ reads `reason` structurally off the wire without checking it against the enum, so
609
+ a server newer than the bundle can deliver a value no table can have an arm for.
610
+ It names the fields and claims nothing about the cause, rather than throwing
611
+ inside a listener invoked as `void emitWriteWarning(...)` (which would cost the
612
+ user the whole toast, including the reasons that did resolve) or reusing a
613
+ wording that would state a cause we do not know.
614
+ - Updated dependencies [2533ec5]
615
+ - Updated dependencies [bbe8b86]
616
+ - Updated dependencies [8477be5]
617
+ - Updated dependencies [279fb13]
618
+ - Updated dependencies [ad07b65]
619
+ - Updated dependencies [41f498b]
620
+ - Updated dependencies [e1d4251]
621
+ - Updated dependencies [ac600e5]
622
+ - Updated dependencies [c1ef923]
623
+ - Updated dependencies [af5e292]
624
+ - Updated dependencies [167ec42]
625
+ - Updated dependencies [b1119ec]
626
+ - Updated dependencies [9f23d2b]
627
+ - Updated dependencies [af025ee]
628
+ - Updated dependencies [31676be]
629
+ - Updated dependencies [9ce096f]
630
+ - Updated dependencies [e05db88]
631
+ - Updated dependencies [5ffcc14]
632
+ - Updated dependencies [d971e51]
633
+ - Updated dependencies [d2ce342]
634
+ - Updated dependencies [dfc6975]
635
+ - @object-ui/core@17.6.0
636
+
3
637
  ## 17.5.0
4
638
 
5
639
  ### Minor Changes