@object-ui/i18n 17.3.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 +1929 -1
- package/README.md +41 -12
- package/dist/i18n.d.ts +1 -1
- package/dist/i18n.js +1 -2
- package/dist/index.d.ts +23 -21
- package/dist/index.js +31 -21
- package/dist/locales/ar.d.ts +638 -369
- package/dist/locales/ar.js +744 -424
- package/dist/locales/de.d.ts +638 -369
- package/dist/locales/de.js +759 -446
- package/dist/locales/en.d.ts +673 -404
- package/dist/locales/en.js +899 -406
- package/dist/locales/es.d.ts +638 -369
- package/dist/locales/es.js +739 -427
- package/dist/locales/fr.d.ts +638 -369
- package/dist/locales/fr.js +738 -423
- package/dist/locales/index.d.ts +6476 -3786
- package/dist/locales/index.js +20 -20
- package/dist/locales/ja.d.ts +638 -369
- package/dist/locales/ja.js +732 -417
- package/dist/locales/ko.d.ts +638 -369
- package/dist/locales/ko.js +736 -424
- package/dist/locales/pt.d.ts +638 -369
- package/dist/locales/pt.js +737 -425
- package/dist/locales/ru.d.ts +638 -369
- package/dist/locales/ru.js +750 -424
- package/dist/locales/zh.d.ts +672 -403
- package/dist/locales/zh.js +720 -403
- package/dist/pickLocalized.d.ts +58 -0
- package/dist/pickLocalized.js +115 -7
- package/dist/provider.d.ts +84 -2
- package/dist/provider.js +305 -31
- package/dist/useDisplayLocale.d.ts +49 -0
- package/dist/useDisplayLocale.js +55 -0
- package/dist/useObjectLabel.d.ts +94 -14
- package/dist/useObjectLabel.js +134 -21
- package/dist/useSafeTranslation.js +51 -3
- package/dist/utils/index.d.ts +4 -3
- package/dist/utils/index.js +4 -3
- package/dist/utils/number-display.d.ts +32 -0
- package/dist/utils/number-display.js +32 -0
- package/dist/utils/spec-formatters.d.ts +39 -13
- package/dist/utils/spec-formatters.js +3 -3
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,1914 @@
|
|
|
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
|
+
|
|
637
|
+
## 17.5.0
|
|
638
|
+
|
|
639
|
+
### Minor Changes
|
|
640
|
+
|
|
641
|
+
- 0e67b53: `/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
|
|
642
|
+
|
|
643
|
+
Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted.
|
|
644
|
+
|
|
645
|
+
Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one.
|
|
646
|
+
|
|
647
|
+
One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a `<base href="/console/">` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test.
|
|
648
|
+
|
|
649
|
+
Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched.
|
|
650
|
+
|
|
651
|
+
- ae10a01: Console chrome reaches the bundle — the list switcher, the aggregate footer, the dialog a11y fallbacks and the whole Settings namespace screen stop being English on non-English consoles
|
|
652
|
+
|
|
653
|
+
Six strings on the two screens a user looks at most were hardcoded English literals rather than bundle lookups, so they stayed English on every non-English console with nothing an app could author to change them. They are not object, field, view or action labels — no key in `TranslationData` reaches them — while the console's own bundle already ships zh-CN, ja-JP, es-ES, de, fr, pt, ru, ko and ar and translates hundreds of neighbouring strings. Omissions from an otherwise complete bundle, not a missing capability.
|
|
654
|
+
|
|
655
|
+
**Two of the six needed no new keys at all, which is the more interesting half.** The list-view mode switcher named its nine visualizations from a private `VIEW_LABELS` table while `console.objectView.viewType*` — the same nine words — had been resolved through the bundle by the create-view picker for months; the switcher now reads those keys, so the picker's 「画廊」 and the switcher's 「画廊」 cannot drift apart in nine languages. The create/edit dialog's close button is the remainder of a fix that already landed: objectstack#5505 routed the `sr-only` close label through `common.close` for the two Shadcn-synced primitives, but `MobileDialogContent` is a hand-written wrapper outside that regeneration zone with its own close button, and it is exactly what `ModalForm` renders — so the dialog the report measured was the one place still announcing "Close" in English.
|
|
656
|
+
|
|
657
|
+
The aggregate footer is the one the original report singled out: the **number** was already locale-formatted and the **prefix** was a hardcoded `Avg: ` / `Sum: `. All eleven aggregation kinds now take their prefix from `grid.summary.*`, and the label/value join is its own key rather than a `': '` baked into the renderer — the separator is translatable content, so zh sets a fullwidth colon and fr the French space-before-colon. The numbers are untouched. The form dialog's `sr-only` description fallback joins the packs too; it is clipped, not visible, so the only way an app could displace it was to author a `description` and thereby put a visible subtitle on every dialog.
|
|
658
|
+
|
|
659
|
+
**The Settings namespace screen converts as one unit.** `SettingsView` routed zero framing copy through i18n — save/failure toasts, the env-lock and crypto refusals, the load-error card, the empty-route state, the navigation buttons, the unsaved-changes save bar — while its immediate sibling `SettingsHub`, in the same directory, resolved everything through `t('console.settingsHub.*')`. A zh-CN admin read correctly translated field labels sitting inside an English save bar, because `useSettingsLabel` translates a namespace's authored content but reaches none of the chrome around it. All of it now resolves through a `console.settingsView.*` namespace placed beside the hub's, including the crypto-refusal strings that objectui#4579 deliberately left in English rather than leave one translated string among a dozen literals.
|
|
660
|
+
|
|
661
|
+
The save-bar counter was an English plural rule executing in every locale (`change` plus an `s` when the count exceeds one). It is now a real i18next plural family — base key plus `_one` and `_other` in all ten packs — not the `(s)` spelling translated nine ways. The base key is the load-bearing part: i18next asks `Intl.PluralRules` for the one suffix a language needs and, finding no such slot, falls back to English, so without it Russian would read English at counts 2-20 and Arabic at 2-99. Russian and Arabic take the "noun: {count}" form their packs already use for this exact reason, and the counter is verified rendering in-language at 1, 2 and 5.
|
|
662
|
+
|
|
663
|
+
The Beta badge reuses the hub's existing key rather than minting a twin, and the refusal messages interpolate their subject through the bundle instead of concatenating a translated word onto an English prefix.
|
|
664
|
+
|
|
665
|
+
- 66fb4fa: The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
|
|
666
|
+
|
|
667
|
+
`LocaleSwitcher` built its items from a module-level `LANGUAGES` constant — exactly the ten codes `@object-ui/i18n` ships packs for — and never consulted the app, even though `GET /api/v1/i18n/locales` has been serving that list all along. It failed in both directions at once. An app shipping a locale outside those ten (`th`, a regional `pt-BR`) had no way to be selected from the console: the bundle could be complete and lint clean, and the menu simply had no entry for it. An app shipping only `en` and `zh` still listed all ten, so picking 日本語 handed the user the console's own chrome in Japanese with everything app-authored in the fallback language — a half-translated UI its author never opted into.
|
|
668
|
+
|
|
669
|
+
The menu is now the **intersection**: the app's own locale list ∩ what the renderer can actually resolve (built-in packs, `config.resources`, and — for an app that wires a dynamic `loadLanguage` loader, which is how the console gets its packs — the locales that loader can fetch). Both failure directions close in the same change: an app-shipped locale becomes offerable, and locales the app does not ship disappear. The endpoint is reached through a new `loadLocales` prop on `I18nProvider`, wired exactly like the existing `loadLanguage`: the app owns the transport, the provider owns what is done with the answer. An app that does not wire it keeps today's menu unchanged.
|
|
670
|
+
|
|
671
|
+
**The restore validation widened in lockstep, because otherwise this fix would have minted the next bug.** A restored language was validated against "the locales this provider can produce" — built-in packs plus `config.resources` — a bound that was correct only for as long as the menu offered exactly the built-in ten. The moment the menu grows to the app's real list, a locale the user can now pick is a locale that bound rejects, so a user-picked app locale would have been purged on the next page load. It now also accepts a locale a wired dynamic loader may be able to fetch, and the bound stays honest rather than absent: only well-formed BCP-47 tags qualify (`constructor`, `__proto__`, `en_US` are still rejected, as is any stored locale in an app with no loader), and the app's own locale list adjudicates the choice for real once it arrives — a locale the app has since dropped is reverted and purged rather than left locking the UI to a language with no translations.
|
|
672
|
+
|
|
673
|
+
Labels come from the built-in native names where they exist (`中文`, `日本語`, … are unchanged) and from `Intl.DisplayNames` for everything else, so an app locale is named in its own language rather than by its code. The endpoint's own `label` is deliberately not used for display: the server sets it to the code echoed back, which would have put `th` in the menu where `ไทย` belongs.
|
|
674
|
+
|
|
675
|
+
While the app's list is in flight the switcher renders nothing, following the sibling menus in the same folder — the ten never flash past on an app that only ships two. When there is no backend, the endpoint fails, or the app answers with nothing this renderer can produce, the built-in ten remain as the offline fallback, so the menu is never empty and never unusable.
|
|
676
|
+
|
|
677
|
+
- 92250d6: One home for the number-display policy — and a percent stops meaning two different things between a list cell and a dashboard measure
|
|
678
|
+
|
|
679
|
+
`formatDisplayNumber`, `shouldGroupDisplayNumber` and `DisplayNumberFormatOptions` move from `@object-ui/i18n` into `@object-ui/core`. `@object-ui/i18n` re-exports all three under the same names, so every existing import path keeps working unchanged and both spellings resolve to the same function object; nothing published was removed.
|
|
680
|
+
|
|
681
|
+
The move is what fixes the bug. `@object-ui/core`'s `formatMeasure` needed exactly this policy and could not import it — `core` is the React-free engine and is a runtime dependency of React-free consumers (the `object-ui` VS Code extension, `@object-ui/data-objectstack`), while `i18n` depends on `i18next`/`react-i18next` and peer-depends on React. So `formatMeasure` carried a parallel `Intl` implementation, recorded at both ends as deliberate duplication, and the two drifted in the one place a hand-built string and `Intl` disagree. A German session read `1.234,5 %` from a list cell and `1.234,5%` from a dashboard measure showing the same number. The function is pure, so the boundary was never a property of the code — only of where the code sat; moving it down removes the obstacle instead of working around it. `core` imports nothing from `i18n`, so the new edge adds no cycle.
|
|
682
|
+
|
|
683
|
+
**Behaviour change — a measure's percent sign now follows the locale.** `formatMeasure` appended a literal `%` in every locale; it now renders the locale's own percent convention, the same one the list-cell `formatPercent` has used since the fix to its own machine-locale defect. Measured to change output in de, fr, es, ru, sv, cs, fi (a no-break space appears before the sign), tr (the sign moves to the FRONT: `%1.234,5`) and ar (its own percent sign plus U+061C). English, Japanese and Chinese are byte-identical — their convention is a bare trailing sign — which is why this was invisible in an English session.
|
|
684
|
+
|
|
685
|
+
**No numeral moves, in any locale, at any magnitude.** The obvious route to the locale's convention is `Intl`'s `style: 'percent'`, but that style expects a fraction, so a value already in percentage points would have to be divided by 100 for `Intl` to multiply it straight back — and that round trip is lossy. Measured, it moves 27,581 of 1,200,013 ordinary-magnitude en-US forms at rounding ties (`0.175` at two decimals becomes `0.17%` instead of `0.18%`), plus `MAX_SAFE_INTEGER` and everything from 1e23 up, where `100,000,000,000,000,000,000,000%` becomes `99,999,999,999,999,990,000,000%`. The percentage points are formatted directly instead, through a new `style: 'percentPoints'` on `DisplayNumberFormatOptions`; that route was measured to produce a byte-identical percent affix to `style: 'percent'` across all 171 locale tags tested while moving none of those 1,200,013 forms. Callers holding a fraction keep using `style: 'percent'`, whose behaviour is unchanged — naming the two cases apart is what stops the next caller from reaching for the lossy one.
|
|
686
|
+
|
|
687
|
+
`@object-ui/i18n`'s entry declaration is byte-identical, but the declaration it points at now lives in `@object-ui/core` and the package gains that dependency, so it takes the same minor bump rather than a patch.
|
|
688
|
+
|
|
689
|
+
- ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
|
|
690
|
+
|
|
691
|
+
`common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
|
|
692
|
+
built its dialog placeholder by concatenating the key with three ASCII full
|
|
693
|
+
stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
|
|
694
|
+
the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
|
|
695
|
+
brought it under objectui#3878's glyph pin. That left `common.search` with zero
|
|
696
|
+
readers repo-wide while it still existed in all ten packs.
|
|
697
|
+
|
|
698
|
+
Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
|
|
699
|
+
no MDX or JSON reference, and the one dynamic template-literal reader of the
|
|
700
|
+
`common` namespace takes a two-member union parameter (`'openChat' |
|
|
701
|
+
'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
|
|
702
|
+
|
|
703
|
+
The dormant copy in `@object-ui/fields`' no-provider fallback table
|
|
704
|
+
(`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
|
|
705
|
+
module-local `Record<string, string>` read only when no `LocalizationProvider`
|
|
706
|
+
is mounted; it is not exported, so removing an entry no reader asks for changes
|
|
707
|
+
no rendered output and narrows no public type. Hence patch for that package,
|
|
708
|
+
while the pack change is a minor: deleting a key from `en` narrows the exported
|
|
709
|
+
`TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
|
|
710
|
+
`common.search` stops type-checking. Same grading, for the same reason, as
|
|
711
|
+
objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
|
|
712
|
+
break.
|
|
713
|
+
|
|
714
|
+
Retiring a key from `common` was the ruled decision on objectui#4392 rather than
|
|
715
|
+
keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
|
|
716
|
+
reader inherits an unreviewed contract, and a dormant key beside a live
|
|
717
|
+
`table.search` is where a second dialect gets started. The objectui#4328
|
|
718
|
+
dead-surface family has consistently chosen removal for zero-consumer surfaces.
|
|
719
|
+
|
|
720
|
+
The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
|
|
721
|
+
PR #4397) is a different key and is untouched.
|
|
722
|
+
|
|
723
|
+
A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
|
|
724
|
+
fails if the key returns to any pack, if any package reads or re-declares it, or
|
|
725
|
+
if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
|
|
726
|
+
runs call site to key, and none of them can see a key with no call site.
|
|
727
|
+
|
|
728
|
+
- fa51109: i18n: retire the orphaned `report.editor.*` namespace — 105 of its 106 keys, in all ten locale packs (~1050 translated strings)
|
|
729
|
+
|
|
730
|
+
The namespace labelled the hand-rolled report editor form. That form no longer
|
|
731
|
+
exists: `ReportConfigPanel`'s body is `ReportDefaultInspector`, a spec-driven
|
|
732
|
+
inspector whose labels come from the report spec's own metadata rather than from
|
|
733
|
+
a pack namespace. Until objectui#4137 the namespace had exactly one live reader,
|
|
734
|
+
and it was the objectui#4118 defect itself — the panel borrowing
|
|
735
|
+
`report.editor.title` (the label of the report's Title _field_) to name itself.
|
|
736
|
+
Moving that slot onto a purpose-built `report.editor.panelTitle` left the other
|
|
737
|
+
105 keys with no reader anywhere.
|
|
738
|
+
|
|
739
|
+
Re-verified before deleting, repo-wide and per key: no `t()` call site, no
|
|
740
|
+
dynamic `t()` template form, and no JSON or MDX reference reads any of the 105.
|
|
741
|
+
No user-visible string changes — these keys never rendered.
|
|
742
|
+
|
|
743
|
+
`report.editor.panelTitle` survives in all ten packs and is untouched; the
|
|
744
|
+
deletion sweeps around it. `report.editor` therefore remains a live namespace
|
|
745
|
+
holding exactly that one key.
|
|
746
|
+
|
|
747
|
+
This narrows the exported `TranslationKeys` type (`typeof en`), which is why it
|
|
748
|
+
is a minor rather than a patch: code indexing `TranslationKeys` at a retired key
|
|
749
|
+
stops type-checking. No runtime consumer existed to break.
|
|
750
|
+
|
|
751
|
+
A negative pin (`packages/i18n/src/__tests__/report-editor-retired-4145.test.ts`)
|
|
752
|
+
names all 105 retired keys and fails if any returns to any pack, since every
|
|
753
|
+
existing i18n gate runs call site to key and none of them can see a key with no
|
|
754
|
+
call site.
|
|
755
|
+
|
|
756
|
+
- 78fa331: console: seed the UI language from the tenant's server-side locale
|
|
757
|
+
|
|
758
|
+
`GET /auth/me/localization` has always been fetched on every boot, but its
|
|
759
|
+
`locale` only ever fed currency/date formatting — the UI language was decided
|
|
760
|
+
entirely client-side, so a tenant configured `zh-CN` still handed every new
|
|
761
|
+
device an English console until each user switched by hand.
|
|
762
|
+
|
|
763
|
+
The tenant locale now sits in the language precedence chain, between the user's
|
|
764
|
+
own choice and the browser's:
|
|
765
|
+
|
|
766
|
+
1. the user's explicit choice (`objectui-locale`)
|
|
767
|
+
2. the tenant's server locale, cached at `objectui-locale-seed`
|
|
768
|
+
3. the browser language
|
|
769
|
+
4. `en`
|
|
770
|
+
|
|
771
|
+
The server value is cached in a slot of its own and is never written into the
|
|
772
|
+
explicit-choice slot, so it can never masquerade as a preference the user
|
|
773
|
+
expressed: only a manual switch promotes a language to an explicit choice. A
|
|
774
|
+
cached seed applies synchronously at bootstrap, and the in-app fetch refreshes
|
|
775
|
+
that cache from every successful answer, so a tenant that changes its locale
|
|
776
|
+
reaches choice-less devices on their next boot without an old seed pinning
|
|
777
|
+
them. On a device's true first visit the fetch is raced against a ~500ms
|
|
778
|
+
timeout alongside the console's existing pre-mount round-trips and fails open
|
|
779
|
+
to the browser language; a seed that arrives after the bound is cached for the
|
|
780
|
+
next boot rather than re-languaging a live session. A tenant locale this build
|
|
781
|
+
ships no pack for falls through to the next tier instead of half-rendering.
|
|
782
|
+
|
|
783
|
+
No platform additions: no new endpoint, no client read/write API, and
|
|
784
|
+
`sys_user_preference` is untouched.
|
|
785
|
+
|
|
786
|
+
- 0082db8: The timeline's gantt bucket labels and its row-label default speak the session language
|
|
787
|
+
|
|
788
|
+
objectui#4513 routed every `Intl` call in the timeline renderer through `useDisplayLocale()`, so a Chinese session renders `2026年8月` on the month axis and `2026年8月11日` on item dates. Three sibling strings in the same renderer never went through `Intl` at all and stayed English on that same Chinese axis: the `week` header (`Week 1`), the `quarter` header (`Q3 2026`), and the gantt row-label column default (`Items`). The half-fixed state was the visible one — a Chinese date axis with English bucket labels beside it.
|
|
789
|
+
|
|
790
|
+
They are a translation concern rather than a locale-resolver one, and that distinction is the fix: a locale TAG formats a date, only a TRANSLATION spells a word. All three now resolve through the package's existing channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, the `createSafeTranslation` factory `ObjectTimeline` already uses for `timeline.bucket.*` — under three new keys carried by all ten locale packs: `timeline.scale.week`, `timeline.scale.quarter`, `timeline.gantt.rowLabel`.
|
|
791
|
+
|
|
792
|
+
The week number and the quarter/year ride the channel's own `{{hole}}` parameters rather than being concatenated, because the word order belongs to the translation: Chinese puts the year first (`2026年第3季度`), which no `Q${q} ${year}` template can produce at all. Only the row-label DEFAULT moved — an author who writes `rowLabel` still supplies their own string, and the `year` scale stays a bare `String(getFullYear())` with no vocabulary in it to translate.
|
|
793
|
+
|
|
794
|
+
English output is byte-identical to the retired literals: the `en` pack values are the same two templates the code used to interpolate by hand. `generateTimeScaleHeaders` is a pure exported function and cannot host a hook, so the translate fn is threaded in as an optional fifth parameter on the seam #4513 opened for `locale`, defaulting to the package's own defaults table — the same lookup the channel serves with no `I18nProvider` mounted. Existing three- and four-argument call sites are unaffected.
|
|
795
|
+
|
|
796
|
+
One consequence is worth stating because it looks like a bug and is not: dates and vocabulary resolve through different channels on purpose. `useDisplayLocale()` puts the tenant's regional default first (how this organization writes dates), while `t` follows the UI language (what this user reads). A tenant configured `en` whose user reads Chinese chrome therefore sees `Aug 2026` beside `第 1 周` — the same split `timeline.bucket.*` has always had.
|
|
797
|
+
|
|
798
|
+
### Patch Changes
|
|
799
|
+
|
|
800
|
+
- 932cbcd: An app you are not allowed to open now says so, instead of reporting that it may still be publishing
|
|
801
|
+
|
|
802
|
+
`GET /api/v1/meta/apps` is filtered per session server-side (`filterAppForUser`), so an app withheld by its `requiredPermissions` and an app that does not exist were byte-identical to the console: both simply absent from the list. With one fact and two conditions, `AppContent` rendered its only copy for an absent app — "This app is not available yet — it may still be publishing. Try again in a moment." — over a permanent authorization decision, under a Retry button that could never succeed.
|
|
803
|
+
|
|
804
|
+
That is not a cosmetic complaint. On a downstream acceptance round one role hit this screen while another opened the same app fine, and because the copy names a transient deployment state the finding was filed as a suspected platform defect and carried through two test batches before a clean-baseline investigation found the account was missing a permission-set binding. The gate had been working exactly as designed; the message is what sent everyone to the wrong place.
|
|
805
|
+
|
|
806
|
+
The maintainer ruling (2026-08-12) took the contract half first. objectstack#8013 made the BY-NAME route answer an explicit denial — `403` with the ADR-0112 catalog code `PERMISSION_DENIED` in the declared `{ success: false, error: { code, message } }` envelope — for an app that exists and whose `requiredPermissions` the session lacks, while the LIST route stays filtered exactly as before, with no `authorized: false` flag, so the enumeration surface is not widened past what a direct by-name probe already implies. Absence keeps answering `404 RESOURCE_NOT_FOUND`, and so do the two neighbouring refusals the same ruling deliberately left alone: an unpublished app (ADR-0045 §3 keeps it externally unobservable) and an app gated by an absent optional service (ADR-0057 D10 — nothing was denied to the caller).
|
|
807
|
+
|
|
808
|
+
This is the console half. When a requested app is missing from the list and the existing post-publish readiness re-check still cannot find it, the console asks the by-name route which of the two it is, through a new `ObjectStackAdapter.probeAppAccess(name)`. On the measured code it renders a plain authorization message with a way back to the launcher; on anything else — an absent app, an unreachable server, a host that injected a DataSource without the probe — today's publishing copy renders byte for byte, retry button included.
|
|
809
|
+
|
|
810
|
+
Two properties of that seam are load-bearing rather than incidental. It branches on the ADR-0112 **code**, never the status (objectui#4408): the two answers under test are both errors one status apart, and a status-reading implementation passes the happy path while going blind exactly where the defect lives. And only `denied` moves the copy: this bug exists because the console asserted a state it had not measured, so a probe that fails, times out or cannot be issued must leave the screen alone rather than guess in the other direction.
|
|
811
|
+
|
|
812
|
+
`probeAppAccess` is deliberately separate from `getApp` rather than a flag on it: `getApp` degrades every failure to `null` — the very conflation being undone — and memoises in the adapter's metadata cache, where a verdict about the CALLER would outlive the session it described. New public API on the adapter (`probeAppAccess`, `isAppPermissionDeniedError`, `APP_PERMISSION_DENIED_CODE`, `AppAccessVerdict`), purely additive; nothing existing changed shape. Three new `empty.*` keys ship in all ten locale packs.
|
|
813
|
+
|
|
814
|
+
- 734d186: The console's Applications page is localized — its own chrome only, never the
|
|
815
|
+
server's words (objectui#4307).
|
|
816
|
+
|
|
817
|
+
`AppManagementPage` was raw English end to end: headings, the search field, the
|
|
818
|
+
selection and bulk controls, the six per-row actions with their tooltip/ARIA
|
|
819
|
+
pairs, the status badges, and every toast. It was the last un-i18n'd system page,
|
|
820
|
+
and #4233 / PR #4300 had just given it four live mutations — so the gap became
|
|
821
|
+
user-visible on every non-English console at the moment operators started using
|
|
822
|
+
it. 45 keys land under `appManagement.*` in all ten packs, reached through
|
|
823
|
+
`useObjectTranslation` with the call site's `defaultValue` inline, which is the
|
|
824
|
+
convention the neighbouring system pages already follow.
|
|
825
|
+
|
|
826
|
+
The split that shapes this change is between the strings the PAGE authors and
|
|
827
|
+
the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on
|
|
828
|
+
`manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata
|
|
829
|
+
required` is the server's diagnosis of one specific request; there is no fixed
|
|
830
|
+
catalogue of those sentences to key against. Each failure toast is therefore a
|
|
831
|
+
keyed template with a `{{reason}}` hole, and what fills the hole is passed
|
|
832
|
+
through byte for byte, untranslated. The one part that IS the page's own — what
|
|
833
|
+
it says when the server sent no message at all — is keyed as
|
|
834
|
+
`appManagement.toast.unknownError`.
|
|
835
|
+
|
|
836
|
+
Two smaller things follow from doing the conversion properly rather than
|
|
837
|
+
mechanically. The per-failure entry of a bulk toast and the separator between
|
|
838
|
+
entries are keys, not literals, because bracket style and list punctuation are
|
|
839
|
+
locale properties (the same rule, and the same past defect, as
|
|
840
|
+
`validation.formInvalidJoiner`). And the row's controls now name an app through
|
|
841
|
+
the resolver the visible heading two lines away already used, with `t` passed:
|
|
842
|
+
an app carrying objectui's keyed label form previously rendered `Select [object
|
|
843
|
+
Object]` into its checkbox's ARIA label.
|
|
844
|
+
|
|
845
|
+
- 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
|
|
846
|
+
|
|
847
|
+
`buildChartSeries`' single-dimension branch passed rows through verbatim, so a row whose category VALUE is `null` reached recharts with a null category and drew no mark. The visible outcome was not an empty chart but a quietly wrong one: rows `[{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}]` drew exactly ONE bar — the dominant group, 51 of 53 events, dropped while the y-axis scale still accommodated it, so the chart understated its own data and the axis proved the data had been there. With every group null it drew axes, gridlines and an axis title with zero marks and no empty state, which is the shipped first-boot state of the built-in System Overview board's "Events by User" (every seeded `sys_audit_log` row is written with `user_id = NULL`).
|
|
848
|
+
|
|
849
|
+
The mapping lives in the shared series layer, so dashboard widgets and standalone `ObjectChart` get one answer rather than a per-chart patch in the recharts wrapper. It resolves the two-answers disagreement the card names as well: an empty result set keeps the designed empty state, a non-empty result always draws bars — the null bucket included.
|
|
850
|
+
|
|
851
|
+
`@object-ui/core` gains `NULL_CATEGORY_LABEL` and `ChartSeriesOptions`; `buildChartSeries` and `findChartSeriesRow` each take an optional trailing `options`. Both additive — every existing call site compiles and behaves identically, and a result with no null category is still returned by array identity. The two helpers are a pair on purpose: the caller matches a clicked segment against rows that still carry the raw `null`, so `findChartSeriesRow` reads the bucket label back to that row and the newly-visible bar keeps its drill-through instead of resolving to `-1`.
|
|
852
|
+
|
|
853
|
+
The label goes through the i18n channel (`chart.nullCategory`, en `(None)` / zh `(未指定)`, all ten packs), passed down by the renderer: `@object-ui/core` is React-free and cannot read the locale bundle, so it takes the resolved string the same way `dimensionOptionTranslator` takes a resolver. Its English constant is the floor for a provider-less host, not the mechanism.
|
|
854
|
+
|
|
855
|
+
`hasNoCategoryKey` (framework#4033) is untouched and now documented against this: a row that does not carry the category key AT ALL is a different defect — a dimension grouped by but never projected — and keeps its explanatory placeholder. The bucket deliberately never ADDS the key to such a row, which is what keeps that guard's signal alive. Key absent → the placeholder; key present with a null value → the bucket.
|
|
856
|
+
|
|
857
|
+
- f7c6430: The build-history panel tells an operator a 503 means "the commit store could not be reached — retry", instead of `commits HTTP 503`
|
|
858
|
+
|
|
859
|
+
`packages/app-shell/src/preview/commitHistory.ts` flattened every non-OK response to a bare status code (`commits HTTP {status}` for the read, `HTTP {status}` for the revert). Nothing was ever swallowed and no fictional "no history" was ever rendered — those fail-loud properties held, and still hold, which is why objectstack#5980's 503-ification (ADR-0110 D3) needed no follow-up here. What was lost is the meaning the backend already sends, on the one screen where it matters most: this is the rollback surface, read by an operator who is usually mid-incident. A 503 says the read/write did not happen and is worth retrying; a 404 says the store answered "no". They now read differently, and 404, 500 and 503 stay tellable apart.
|
|
860
|
+
|
|
861
|
+
Failures now throw a `CommitStoreError` carrying `status`, the ADR-0112 `code`, and a `retryable` flag, and the panel renders a sentence rather than a number. The revert half gets a deliberately different sentence: a write that could not reach the store may still have landed, and re-issuing it appends a _second_ revert commit to an append-only log, so the copy asks the operator to re-read the timeline before retrying rather than simply saying "try again".
|
|
862
|
+
|
|
863
|
+
Two details of the report this fixes were checked against the producer and came back different, and both are the reason the copy is authored client-side. The semantic code arrives at **`error.code`**, not `details.code` — `HttpDispatcher.errorFromThrown` parks it in `details` and `buildApiError`/`splitSemanticCode` lift it out and drop `details` (objectstack#3842) — so a consumer reading `details.code` would run a check that can only pass vacuously. And the envelope's own `message` for this class is _withheld_: `declaresServerFault` (objectstack#5811) is true for a 5xx carrying a string code, so the prose on the wire is the generic `Internal server error`. Rendering it would have been strictly worse than the bare status code it replaced. Classification therefore keys on the HTTP status first and treats the code as a second signal, which also means a 503 shed by a proxy with an HTML body still produces the retryable reading.
|
|
864
|
+
|
|
865
|
+
Adds `preview.history.loadFailedUnavailable` and `preview.history.revertUnavailable` to all ten locale packs.
|
|
866
|
+
|
|
867
|
+
- 92876f0: Doc comments no longer cite `@objectstack/spec` symbols the pinned spec has retired
|
|
868
|
+
|
|
869
|
+
Eight exported declarations carried a doc comment claiming alignment with a
|
|
870
|
+
`@objectstack/spec` symbol that `17.0.0-rc.6` does not export — four locale
|
|
871
|
+
formatting shapes in `@object-ui/i18n` (`SpecPluralRule`, `SpecDateFormat`,
|
|
872
|
+
`SpecNumberFormat`, `SpecLocaleConfig`) and four activity-feed shapes in
|
|
873
|
+
`@object-ui/types` (`FieldChangeEntry`, `Mention`, `Reaction`,
|
|
874
|
+
`RecordSubscription`). A citation that points at nothing is worse than a stale
|
|
875
|
+
one: the next reader cannot tell whether the protocol retired the symbol,
|
|
876
|
+
renamed it, or never had it.
|
|
877
|
+
|
|
878
|
+
Measuring all eight against the published registry answered that question, and
|
|
879
|
+
the answer was not "these names never existed". Every one was a real export the
|
|
880
|
+
protocol retired on purpose, and every local key set was faithful to the schema
|
|
881
|
+
it named. The feed four left `@objectstack/spec/data` in the `16.0.0` major,
|
|
882
|
+
when the feed surface was replaced by the data API over `sys_comment` /
|
|
883
|
+
`sys_activity`. The i18n four left `@objectstack/spec/ui` in `17.0.0-rc.6`
|
|
884
|
+
itself — they were still present in `rc.5` — retired under ADR-0049
|
|
885
|
+
enforce-or-remove because no authorable shape carried them and nothing ever
|
|
886
|
+
parsed them.
|
|
887
|
+
|
|
888
|
+
Each comment now records that provenance, including the version the symbol left
|
|
889
|
+
and what (if anything) replaced it, so the shapes read as declarations these
|
|
890
|
+
packages own rather than as a view onto a protocol type. Type shapes, runtime
|
|
891
|
+
behaviour and exports are unchanged — the published `.d.ts` files differ only in
|
|
892
|
+
comment text, which is why this is graded `patch`.
|
|
893
|
+
|
|
894
|
+
- 828549a: The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
|
|
895
|
+
|
|
896
|
+
`gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.
|
|
897
|
+
|
|
898
|
+
The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.
|
|
899
|
+
|
|
900
|
+
`gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.
|
|
901
|
+
|
|
902
|
+
Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
|
|
903
|
+
|
|
904
|
+
- e1ade8f: An illegal gantt dependency link now says why it was refused, instead of doing nothing
|
|
905
|
+
|
|
906
|
+
Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target _had_ been registered, so the drop itself was a no-op.
|
|
907
|
+
|
|
908
|
+
Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.
|
|
909
|
+
|
|
910
|
+
During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.
|
|
911
|
+
|
|
912
|
+
Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.
|
|
913
|
+
|
|
914
|
+
One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
|
|
915
|
+
|
|
916
|
+
- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
|
|
917
|
+
|
|
918
|
+
Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
|
|
919
|
+
|
|
920
|
+
**One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
|
|
921
|
+
|
|
922
|
+
The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
|
|
923
|
+
|
|
924
|
+
Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
|
|
925
|
+
|
|
926
|
+
**es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
|
|
927
|
+
|
|
928
|
+
**pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
|
|
929
|
+
|
|
930
|
+
No behavior, no keys added or removed, no placeholder changed.
|
|
931
|
+
|
|
932
|
+
- bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
|
|
933
|
+
|
|
934
|
+
**objectui#4375** — `ListView` and `LookupField` built their search placeholder as
|
|
935
|
+
`t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
|
|
936
|
+
in all ten locales on screens where objectui#3878 had converged everything else on
|
|
937
|
+
U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
|
|
938
|
+
was appended to right-to-left text). Both now read `table.search`, which is already
|
|
939
|
+
the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
|
|
940
|
+
`PeoplePicker` render it too — and is translated with the right ellipsis in all ten
|
|
941
|
+
packs. No new keys.
|
|
942
|
+
|
|
943
|
+
**objectui#4376** — `list.loading` served the English `Loading records…` in eight of
|
|
944
|
+
the nine translation packs (`zh` alone had translated it); `designer.undo` and
|
|
945
|
+
`designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
|
|
946
|
+
`ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
|
|
947
|
+
pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
|
|
948
|
+
inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
|
|
949
|
+
|
|
950
|
+
- 5cc847c: The console shows a standing impersonation banner, with an exit that fails loudly (#4467).
|
|
951
|
+
|
|
952
|
+
While `session.impersonatedBy` is present, `ConsoleShell` renders a banner naming BOTH
|
|
953
|
+
parties — the impersonated user, whose name every write is recorded under, and the
|
|
954
|
+
administrator who started it — plus a stop affordance. It derives from the session rather
|
|
955
|
+
than from client memory of the click, so it survives a full SPA reboot, a new tab and a
|
|
956
|
+
browser restart, and it cannot disagree with who the server thinks is acting. An ordinary
|
|
957
|
+
session renders `null` and its chrome is unchanged.
|
|
958
|
+
|
|
959
|
+
The exit calls `POST /auth/admin/stop-impersonating` over the same data lane and then
|
|
960
|
+
awaits a session refresh. The server restores the administrator from the `admin_session`
|
|
961
|
+
COOKIE, so a deployment that blocks cookies cannot exit this way — the banner says so and
|
|
962
|
+
stays up instead of appearing to succeed, which would leave the operator doing ordinary
|
|
963
|
+
work under someone else's identity.
|
|
964
|
+
|
|
965
|
+
Ten locale packs carry the banner's copy.
|
|
966
|
+
|
|
967
|
+
- fa21254: Kanban: a drop that makes fields required now collects them instead of dead-ending
|
|
968
|
+
|
|
969
|
+
Dragging a card into a column whose value flips a field's `requiredWhen` predicate to TRUE used to PATCH the column value alone. The engine refused the whole update — correctly, that is what the predicate declares — and the board had no way to finish the move: the only path to closing a won deal was to abandon the board and open the record form. HotCRM's opportunity pipeline is the reported case (`win_reason` is required when `stage == "closed_won"`), but the dead end belonged to every board whose target column carries a conditional requirement.
|
|
970
|
+
|
|
971
|
+
The board now evaluates the target column's predicates BEFORE writing anything. If the move would make fields required while they are still empty, it opens a small dialog collecting exactly those fields, then submits the column value and everything collected as ONE PATCH — never two writes, which would leave the record in the refused state if the second one failed. A drop that triggers no predicate is untouched, down to the PATCH body.
|
|
972
|
+
|
|
973
|
+
The verdict comes from `@object-ui/core`'s `resolveFieldRuleState` — the same evaluator the record form, the wizard and the line-item grid already resolve `visibleWhen`/`readonlyWhen`/`requiredWhen` with, delegating to `@objectstack/formula`'s CEL engine. The board's prompt and the server's enforcement therefore reach the identical verdict rather than drifting through a second hand-rolled predicate evaluator. Emptiness is core's `isMissingForRequired`, the presence contract the form and the server share, so a `false` boolean and a `0` count as answers and are not re-asked.
|
|
974
|
+
|
|
975
|
+
Every control in the dialog is `@object-ui/fields`' `FieldEditWidget`, the same widget the record form renders for that field type — a select edits as a select, a date as a date picker — so this adds no second set of field-rendering decisions.
|
|
976
|
+
|
|
977
|
+
Four kinds of field are deliberately NOT collected, and each falls through to the unchanged PATCH where the server's refusal (legible since objectstack#7525) speaks for itself: one that already has a value, one `visibleWhen` hides, one that is readonly, and one whose type has no edit widget at all. A dialog row with no control would be a worse dead end than the one being fixed.
|
|
978
|
+
|
|
979
|
+
Cancelling writes nothing and leaves the card in its original column; a combined PATCH that is still refused for some other reason surfaces the refusal and rolls back exactly as a plain rejected move does, rather than looping the dialog on an arbitrary server error.
|
|
980
|
+
|
|
981
|
+
`@object-ui/i18n` carries two new `kanban.*` strings for the dialog, translated across all ten packs. Its public type surface is unchanged — the `.d.ts` was measured identical before and after — hence the patch bump.
|
|
982
|
+
|
|
983
|
+
- 33c32bf: List sort: the picker stops borrowing the filter whitelist, and a header click is no longer a one-way door out of the view's declared sort
|
|
984
|
+
|
|
985
|
+
`filterableFields` was applied to the single field set both toolbar builders read, so a whitelist authored for _filtering_ silently became the _sort_ whitelist too. A view could declare a two-level default sort — `plan_start_date` then `name` — and get a sort panel that offered neither field and rendered both of its rows blank: the declared sort worked on load and could then be neither reproduced nor modified, and there was no way to express "sortable but not offered as a filter condition" short of widening the filter builder as collateral. The whitelist now narrows the filter builder alone, which is the contract it was written for; the sort picker starts from every field the view can name and applies its own sortability rules.
|
|
986
|
+
|
|
987
|
+
Those rules are about what the sort can honestly reach, so a second one joins the existing relational exclusion: a `formula` field is withheld. It has no materialised column, so ordering by one is refused by the server outright (objectstack `UNMATERIALIZED_SORT_TYPES`) — and it matters here precisely because the base set widened, since a formula field previously reached the picker only if someone had whitelisted it. The exclusion is `formula` alone and deliberately not the spec's `COMPUTED_VALUE_TYPES`: `summary` and `autonumber` are computed too, each gets a real maintained column, and both order correctly. Either rule keeps its existing escape hatch — a field the current sort already uses stays listed, which for a formula field is the only way to remove the offending row.
|
|
988
|
+
|
|
989
|
+
One consequence worth naming: the hint explaining the relational omission used to be gated by the same whitelist. A view whitelisting only `status` showed a near-empty sort picker and no word about why; the withheld relational field now reaches the rule that withholds it, so the explanation appears with it.
|
|
990
|
+
|
|
991
|
+
The second half is the way back. One column-header click replaces the whole sort array, so a view shipping a multi-level default lost it for the rest of the session — the declared `sort` behaved as an initial value only, recoverable just by reloading the page. The sort panel gains a **Reset to view default** control that restores the declared array whole: multi-level, in declared order, not merely cleared. It reads the view's declared sort through the same resolver the initial render already uses, so there is one answer to "what did this view declare". It is disabled while the active sort already matches that default, and absent entirely for a view that declares no sort — there is no default to return to, and clearing the sort under that label would be a second, differently-named way to do what removing the rows already does. The header click's own semantics are unchanged: it still replaces the array, it just no longer does so irreversibly.
|
|
992
|
+
|
|
993
|
+
- 6d641c9: Members & invitations tabs gate their affordances by org role instead of letting the server's 403 be the UI (#4475)
|
|
994
|
+
|
|
995
|
+
A user whose organization role is `member` opened the workspace members page and
|
|
996
|
+
was shown an enabled **Invite member** button plus a per-row **Member actions**
|
|
997
|
+
menu carrying **Remove member** — on every row, the workspace Owner's included.
|
|
998
|
+
Nothing was hidden or disabled; the action only failed after the user had
|
|
999
|
+
committed to it. The Settings tab of the same page already gated correctly; the
|
|
1000
|
+
members and invitations tabs never got the same treatment.
|
|
1001
|
+
|
|
1002
|
+
The affordances are now narrowed to the roles that can actually use them, keyed
|
|
1003
|
+
on the active member's role — the same source the role-change menu on this page
|
|
1004
|
+
already reads. Which roles those are is **measured against the routes that
|
|
1005
|
+
enforce them**, not assumed to be "owner":
|
|
1006
|
+
|
|
1007
|
+
| affordance | route | permission | roles |
|
|
1008
|
+
| ----------------- | --------------------------------- | ----------------------- | ----------------------------- |
|
|
1009
|
+
| Invite member | `/organization/invite-member` | `invitation:["create"]` | owner, admin, delegated_admin |
|
|
1010
|
+
| Remove member | `/organization/remove-member` | `member:["delete"]` | owner, admin |
|
|
1011
|
+
| Cancel invitation | `/organization/cancel-invitation` | `invitation:["cancel"]` | owner, admin |
|
|
1012
|
+
|
|
1013
|
+
Three different gates, because `delegated_admin` holds `invitation:["create"]`
|
|
1014
|
+
without `member:["delete"]` and deliberately without `cancel` — so it keeps the
|
|
1015
|
+
invite button and the copy-link action while losing remove and cancel. A single
|
|
1016
|
+
owner check could not express that.
|
|
1017
|
+
|
|
1018
|
+
An actor left with no row action at all gets no menu rather than a trigger that
|
|
1019
|
+
opens onto nothing, and the members page explains the absence where the Invite
|
|
1020
|
+
button used to sit, in the Settings tab's own voice. An unresolved role is
|
|
1021
|
+
treated as the least privileged, so nothing privileged is offered to a viewer
|
|
1022
|
+
whose membership could not be read.
|
|
1023
|
+
|
|
1024
|
+
Reading the pages is unaffected: the member list and the invitation ledger still
|
|
1025
|
+
render in full. Whether `org_member` should be able to read the invitation
|
|
1026
|
+
ledger at all is a separate, server-side question.
|
|
1027
|
+
|
|
1028
|
+
- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
|
|
1029
|
+
|
|
1030
|
+
Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
|
|
1031
|
+
|
|
1032
|
+
The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
|
|
1033
|
+
|
|
1034
|
+
`useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
|
|
1035
|
+
|
|
1036
|
+
Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
|
|
1037
|
+
|
|
1038
|
+
Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
|
|
1039
|
+
|
|
1040
|
+
`formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
|
|
1041
|
+
|
|
1042
|
+
- 58bebf6: Organization & invitation console: translate the English holdouts a zh session was left reading (#4474)
|
|
1043
|
+
|
|
1044
|
+
Three families of string, one sweep over `console/organizations/`:
|
|
1045
|
+
|
|
1046
|
+
- **Role names** now come from the single shared `ORG_ROLE_LABELS` map at every
|
|
1047
|
+
site. The role badges on the members and invitations pages were rendering the
|
|
1048
|
+
raw server identifier (`owner`) under a CSS `capitalize` that made it look like
|
|
1049
|
+
a label in English and left it untranslated everywhere else; the accept page
|
|
1050
|
+
did the same in its role row and inside its otherwise-translated sentence. The
|
|
1051
|
+
map's four `organization.roles.*` keys existed in no locale pack, so even the
|
|
1052
|
+
dropdown that did consult it fell through to English — all ten packs now carry
|
|
1053
|
+
them. An unrecognized role renders verbatim rather than blank.
|
|
1054
|
+
- **Server-echoed errors** are mapped by better-auth's stable `code`, never by
|
|
1055
|
+
matching its English text. `createAuthClient` was dropping that code for every
|
|
1056
|
+
`organization.*` call while preserving it for sign-in/sign-up, so the console
|
|
1057
|
+
had nothing to key on; all sixteen organization methods now go through the same
|
|
1058
|
+
`toAuthError` helper. Messages are unchanged — the code simply stops being
|
|
1059
|
+
thrown away. An unmapped code still shows the server's own sentence.
|
|
1060
|
+
- **Icon-only `aria-label`s** (member actions, copy invitation link, cancel
|
|
1061
|
+
invitation, and a fourth on the share-link copy button) are translated — for an
|
|
1062
|
+
icon-only control this is the only name a screen reader gets.
|
|
1063
|
+
|
|
1064
|
+
- 405e808: `pickLocalized` reads own properties only, and takes only string values, on every limb
|
|
1065
|
+
|
|
1066
|
+
The resolver read four of its six limbs — the exact tag, the base language, `default` and `en` — with a bare bracket access. Bare access walks the prototype chain, so a locale that happened to name an `Object.prototype` member resolved to that member and the function stringified it into the label: `pickLocalized({ en: 'Pricing' }, 'constructor')` returned `function Object() { [native code] }`, and the same held for `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`. Those same four limbs also skipped the `typeof === 'string'` filter the regional and last-resort limbs already applied, so a non-string value short-circuited the chain and rendered as `[object Object]`.
|
|
1067
|
+
|
|
1068
|
+
Both guards now apply uniformly. A guarded limb **misses** rather than aborting the resolution, so an unusable entry falls through to the next limb exactly as an absent one does — `pickLocalized({ en: 'Pricing' }, 'constructor')` is now `'Pricing'` (the `en` limb), and only a map with no usable entry at all resolves to `''`. An empty-string value is still a hit, because `''` is a label the author wrote.
|
|
1069
|
+
|
|
1070
|
+
No real language tag can observe this: no BCP-47 tag is an `Object.prototype` member, and the inline locale map is declared `z.record(<tag>, z.string())`, so every in-contract input resolves byte-identically to before. What it changes is agreement with the backend twin `resolveI18nLabel` (objectstack#6765), which shipped with exactly these two narrowings recorded as deliberate departures from this function because on a server the locale can arrive in an `Accept-Language` header. That recorded rule divergence is now zero; the only remaining difference is how each side spells a miss (`''` here for a text node, `undefined` there for a producer's fallback chain), which is pinned as an identity in the cross-resolver parity table.
|
|
1071
|
+
|
|
1072
|
+
- c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
|
|
1073
|
+
|
|
1074
|
+
The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
|
|
1075
|
+
|
|
1076
|
+
The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
|
|
1077
|
+
|
|
1078
|
+
`MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
|
|
1079
|
+
|
|
1080
|
+
The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
|
|
1081
|
+
|
|
1082
|
+
**What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
|
|
1083
|
+
|
|
1084
|
+
- d46f9b8: i18n: `createSafeTranslation`'s provider-less fallback now honours a call site's inline `defaultValue`
|
|
1085
|
+
|
|
1086
|
+
`fallbackT` looked its key up in the hook's hand-written `defaults` map and, on a miss, rendered the
|
|
1087
|
+
**raw key** to the user — then ran every option, `defaultValue` included, through the interpolation
|
|
1088
|
+
loop as if it were a `{{defaultValue}}` variable. So `t('perm.facet.none', { defaultValue: 'None' })`
|
|
1089
|
+
showed `perm.facet.none` on a host with no `I18nProvider`, which is a supported scenario (standalone
|
|
1090
|
+
embedding and tests are the whole reason this factory exists).
|
|
1091
|
+
|
|
1092
|
+
The lookup order is now `defaults[key]` -> a string `defaultValue` -> the key, matching i18next,
|
|
1093
|
+
which serves the provider path: the defaults map is the pack value's stand-in here, so it keeps the
|
|
1094
|
+
pack's winning position. `defaultValue` is also excluded from the interpolation loop as a reserved
|
|
1095
|
+
name — it selects the string, it does not fill holes in one. Non-string `defaultValue` is ignored.
|
|
1096
|
+
The provider path is untouched.
|
|
1097
|
+
|
|
1098
|
+
Measured over all 26 `createSafeTranslation` hooks in the repo: 27 keys reach a hook whose defaults
|
|
1099
|
+
map lacks them, 21 of those carrying an inline `defaultValue` that used to be dropped (16 keys in
|
|
1100
|
+
`plugin-detail` alone). Those 21 now render their English instead of a raw key on provider-less
|
|
1101
|
+
hosts; the other 6 pass no inline default and still need a map or pack entry.
|
|
1102
|
+
|
|
1103
|
+
- 2fea4d2: `detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
|
|
1104
|
+
|
|
1105
|
+
This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
|
|
1106
|
+
|
|
1107
|
+
The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
|
|
1108
|
+
|
|
1109
|
+
The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
|
|
1110
|
+
|
|
1111
|
+
Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
|
|
1112
|
+
|
|
1113
|
+
- 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
|
|
1114
|
+
|
|
1115
|
+
The Sort panel withholds columns that link to another record and explains why, and the last sentence of that explanation named the remedy: _add a formula field holding it_. A formula field is exactly what the platform will not order by. The server keeps `UNMATERIALIZED_SORT_TYPES = new Set(['formula'])` and, since objectstack#6994, a sort naming one is a hard `400 INVALID_SORT` — before that it degraded silently, returning every row with `asc` and `desc` byte-identical. So an author who read the hint, followed it, and built a formula field arrived at a refusal; and since #4243 withheld formula fields from this very picker, at a field the panel does not offer either. Two doors, opposite advice, for one problem.
|
|
1116
|
+
|
|
1117
|
+
The remedy sentence now names a **stored, denormalised field — written when the source changes** — and rules the formula field out in as many words: it is virtual, no column is stored for it, and the server refuses to sort by one. That is deliberately the server's own vocabulary rather than a third phrasing of the same fact: objectstack#6924 and objectstack#6994 settled on one wording across the refusal doors so an author refused twice is not sent two different ways, and this is the UI door of that same set. The first half of the hint — why relation columns are withheld at all — is unchanged.
|
|
1118
|
+
|
|
1119
|
+
All ten locale packs move together, as `check:i18n-drift` requires of any `en` edit. The same sentence also lives in `plugin-list`'s provider-less fallback table, which is what renders when the component is used outside an `I18nProvider`; it is updated to match `en` byte for byte, because a pack-only reword would have left the retired advice on exactly the surface this fixes.
|
|
1120
|
+
|
|
1121
|
+
- 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
|
|
1122
|
+
|
|
1123
|
+
A list pointed at an object whose `enable` block withholds the API rendered its ordinary
|
|
1124
|
+
empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
|
|
1125
|
+
records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
|
|
1126
|
+
`sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
|
|
1127
|
+
to nobody. That is also why the upstream defect objectstack#7544 survived review for its
|
|
1128
|
+
whole life: a merely unpopulated page invites nobody to click through.
|
|
1129
|
+
|
|
1130
|
+
The masking had two halves, in two packages, and neither package could see the other:
|
|
1131
|
+
|
|
1132
|
+
- **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
|
|
1133
|
+
**every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
|
|
1134
|
+
at the surface as a successful empty result, indistinguishable from a genuinely empty
|
|
1135
|
+
object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
|
|
1136
|
+
(404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
|
|
1137
|
+
would have pinned the object to "empty" for the rest of the session.
|
|
1138
|
+
- **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
|
|
1139
|
+
half was never swallowed, so it already reached this panel, but classified as `network`:
|
|
1140
|
+
_"check your connection and try again"_ for a condition no retry can change. It now says
|
|
1141
|
+
the object is not exposed through the API, that this is a setting on the object rather than
|
|
1142
|
+
a permission, and it offers **no Retry** button, because every retry re-fetches the
|
|
1143
|
+
identical refusal.
|
|
1144
|
+
|
|
1145
|
+
Both denials are pure functions of the object's metadata — no user, no permission, no
|
|
1146
|
+
context — so neither is transient or per-user, which is exactly the case where a silent empty
|
|
1147
|
+
state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
|
|
1148
|
+
missing collection, a missing record and a disabled object are all 404.
|
|
1149
|
+
|
|
1150
|
+
**A genuinely empty object still renders the ordinary empty state**, and a backend without an
|
|
1151
|
+
optional collection still degrades to empty — pinned in both directions, at the adapter, at
|
|
1152
|
+
the view, and once end-to-end over a real adapter and a real `ListView`.
|
|
1153
|
+
|
|
1154
|
+
Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
|
|
1155
|
+
branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
|
|
1156
|
+
It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
|
|
1157
|
+
branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
|
|
1158
|
+
|
|
1159
|
+
New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
|
|
1160
|
+
`en` pack and mirrored in the list defaults map.
|
|
1161
|
+
|
|
1162
|
+
## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
|
|
1163
|
+
|
|
1164
|
+
Two independent reasons, either of which is sufficient under this repo's precedent
|
|
1165
|
+
(objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
|
|
1166
|
+
|
|
1167
|
+
1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
|
|
1168
|
+
boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
|
|
1169
|
+
`['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
|
|
1170
|
+
public surface. Additive surface growth is minor.
|
|
1171
|
+
2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
|
|
1172
|
+
**REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
|
|
1173
|
+
`{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
|
|
1174
|
+
relied on those two codes arriving as a successful empty result now receives a rejected
|
|
1175
|
+
promise carrying `code` + `httpStatus`, and must handle it.
|
|
1176
|
+
|
|
1177
|
+
Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
|
|
1178
|
+
with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
|
|
1179
|
+
is scoped to the two denial codes named above and to nothing else.
|
|
1180
|
+
|
|
1181
|
+
Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
|
|
1182
|
+
`@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
|
|
1183
|
+
described in the body, which is what this note is.
|
|
1184
|
+
|
|
1185
|
+
- 31ab1ac: fix(print): `window.print()` produces a usable page, and the Print buttons say what they do
|
|
1186
|
+
|
|
1187
|
+
The list, report and dashboard Print controls were bare `window.print()` calls with no
|
|
1188
|
+
print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail,
|
|
1189
|
+
toasts — with the data table clipped to a single viewport. With no label to the contrary
|
|
1190
|
+
they were being accepted against "export to PDF" requirements, which they have never been.
|
|
1191
|
+
|
|
1192
|
+
- `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell
|
|
1193
|
+
chrome, prints the active content area full-width, releases the viewport-height flex chain
|
|
1194
|
+
so long tables paginate instead of clipping, repeats table headers on every sheet, and
|
|
1195
|
+
neutralises dark mode (which otherwise prints white-on-white). One sheet serves list,
|
|
1196
|
+
report and dashboard.
|
|
1197
|
+
- The list and report Print buttons carry a tooltip and accessible name stating that they
|
|
1198
|
+
open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`,
|
|
1199
|
+
translated in all ten locale packs).
|
|
1200
|
+
- The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" —
|
|
1201
|
+
it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by
|
|
1202
|
+
`dashboardActions.printDialogOpening`).
|
|
1203
|
+
|
|
1204
|
+
No control was removed and no headless detection was added. A real print/PDF primitive
|
|
1205
|
+
remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED).
|
|
1206
|
+
|
|
1207
|
+
- 06915b0: fix(i18n): every date branch threads the active locale, so a `zh` session no longer renders half its dates in English
|
|
1208
|
+
|
|
1209
|
+
Date rendering had two locale channels and only one followed the user's
|
|
1210
|
+
language, so the same row could read `逾期 6 天` in one column and `In 3 days`
|
|
1211
|
+
in the next, with a datetime column showing `8/11/2026 12:00 am`
|
|
1212
|
+
(objectui#4468).
|
|
1213
|
+
|
|
1214
|
+
The overdue phrase resolves through the translate fn (the active UI language),
|
|
1215
|
+
while every `Intl` branch took its tag from the raw tenant locale
|
|
1216
|
+
(`useLocalization().locale`) — which is `undefined` on any workspace that never
|
|
1217
|
+
configured one, and `undefined` makes `Intl` use the _machine's_ locale.
|
|
1218
|
+
`DateTimeCellRenderer` passed no tag at all.
|
|
1219
|
+
|
|
1220
|
+
Every date-formatting site in `@object-ui/fields` now resolves through the one
|
|
1221
|
+
existing channel, `useDisplayLocale()` (tenant regional default → active UI
|
|
1222
|
+
language → `en`): `DateCellRenderer` (relative past, relative future, near-today
|
|
1223
|
+
and the beyond-±7-days absolute fallback), `DateTimeCellRenderer`, the read-only
|
|
1224
|
+
`DateField` / `DateTimeField` / `FormulaField` faces, and the sub-grid's
|
|
1225
|
+
temporal cells. English output is unchanged, and the already-localized overdue
|
|
1226
|
+
wording is untouched.
|
|
1227
|
+
|
|
1228
|
+
No public signature changed. `@object-ui/i18n` carries a documentation
|
|
1229
|
+
correction only: `useDisplayLocale`'s docstring claimed `DateCellRenderer`
|
|
1230
|
+
already formatted from this channel, which was the very thing that was not true.
|
|
1231
|
+
|
|
1232
|
+
- ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
|
|
1233
|
+
|
|
1234
|
+
Two call sites asked for a key whose value was written for a different slot, so the rendered copy was wrong (objectui#4118, surfaced by objectui#3810's census).
|
|
1235
|
+
|
|
1236
|
+
`ReportConfigPanel` used `report.editor.title` for both its heading and the accessible name of its `role="complementary"` landmark. That key is the label of the report's Title _field_ — `report.editor.titlePlaceholder` ('e.g. Pipeline by Quarter') sits directly under it in the pack. So the panel was headed "Title", and a screen reader announced a complementary region named "Title", which says nothing about what the region is. A new `report.editor.panelTitle` ('Edit report' — what the call site's own dead fallback said before objectui#3810 aligned it to the pack) now names the panel, in all ten locale packs.
|
|
1237
|
+
|
|
1238
|
+
`ViewSettingsPopover`'s colour section used `list.color`. On the wide toolbar `ListView` already uses both keys correctly for the two slots of this one feature: the compact `Paintbrush` button is `list.color` ('Color') and the panel it opens is headed `list.rowColor` ('Row Color'). This popover is that same panel on the collapsed/`compactToolbar` surface, so it now takes `list.rowColor` — an existing key, no pack change.
|
|
1239
|
+
|
|
1240
|
+
No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
|
|
1241
|
+
|
|
1242
|
+
- Updated dependencies [ee66e2e]
|
|
1243
|
+
- Updated dependencies [ee26e65]
|
|
1244
|
+
- Updated dependencies [5900ac5]
|
|
1245
|
+
- Updated dependencies [3fc2971]
|
|
1246
|
+
- Updated dependencies [aca27fa]
|
|
1247
|
+
- Updated dependencies [dde7283]
|
|
1248
|
+
- Updated dependencies [f279deb]
|
|
1249
|
+
- Updated dependencies [eb7f586]
|
|
1250
|
+
- Updated dependencies [e901131]
|
|
1251
|
+
- Updated dependencies [d9d3463]
|
|
1252
|
+
- Updated dependencies [613b167]
|
|
1253
|
+
- Updated dependencies [abb0f81]
|
|
1254
|
+
- Updated dependencies [38ab505]
|
|
1255
|
+
- Updated dependencies [7e4f0e5]
|
|
1256
|
+
- Updated dependencies [92250d6]
|
|
1257
|
+
- Updated dependencies [c1d939f]
|
|
1258
|
+
- Updated dependencies [49ae9f4]
|
|
1259
|
+
- Updated dependencies [2459a3e]
|
|
1260
|
+
- Updated dependencies [d6aa172]
|
|
1261
|
+
- Updated dependencies [fe52a04]
|
|
1262
|
+
- Updated dependencies [bb68488]
|
|
1263
|
+
- Updated dependencies [9461dd3]
|
|
1264
|
+
- @object-ui/core@17.5.0
|
|
1265
|
+
|
|
1266
|
+
## 17.4.0
|
|
1267
|
+
|
|
1268
|
+
### Patch Changes
|
|
1269
|
+
|
|
1270
|
+
- 7864f03: Backfill the auth family's 54 missing locale keys — `auth` 26 + `oauth` 16 + `acceptInvitation` 12 (objectui#3546, slice three)
|
|
1271
|
+
|
|
1272
|
+
`scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured 54 keys that a
|
|
1273
|
+
`t()` call site asks for and that **no locale pack defined** — 54 distinct keys at
|
|
1274
|
+
54 call sites across the console's six auth pages. All 54 carried an inline
|
|
1275
|
+
`t(key, { defaultValue: 'English' })`, which is exactly the objectui#3517 class:
|
|
1276
|
+
English rendered correctly, and **all ten languages were stuck on it** for
|
|
1277
|
+
months. Nothing here rendered a raw key — slice one (PR #3583) held those sites.
|
|
1278
|
+
|
|
1279
|
+
What that meant on the page: a `zh` user reaching `/login` and switching to the
|
|
1280
|
+
phone/SMS branch got "Email or phone number", "Get code", "Resend in {seconds}s"
|
|
1281
|
+
and "Sign in with password instead" in English; the whole `/oauth/consent` screen
|
|
1282
|
+
— including the four scope sentences describing what a third-party client is
|
|
1283
|
+
about to be granted — was English-only; so was the `/accept-invitation` page and
|
|
1284
|
+
the device-authorization dead end.
|
|
1285
|
+
|
|
1286
|
+
- **`packages/i18n/src/locales/en.ts`** gains the 54 keys. `oauth.consent.*` and
|
|
1287
|
+
`acceptInvitation.*` are new top-level namespaces; the other 26 extend
|
|
1288
|
+
`auth.login`, `auth.forgotPassword`, `auth.device` and `auth.verifyEmail`.
|
|
1289
|
+
Every one of the 52 keys whose call site carries a **string** `defaultValue`
|
|
1290
|
+
gets that exact string, byte for byte (52/52, script-compared), so the pack
|
|
1291
|
+
path and the inline-default path cannot diverge. The two remaining keys —
|
|
1292
|
+
`oauth.consent.title` / `oauth.consent.request` — have **template**
|
|
1293
|
+
defaultValues, where byte identity is structurally impossible (JS `${…}` vs
|
|
1294
|
+
i18next `{{…}}`); both take the interpolation contract the call site actually
|
|
1295
|
+
declares in its options.
|
|
1296
|
+
- **The nine other packs** get real translations, each evidenced against a
|
|
1297
|
+
neighbour key in the same pack (fr's space before `?`/`:`, de's en dash, ru's
|
|
1298
|
+
ё, ar's verb-first placement so an RTL sentence does not open on a Latin
|
|
1299
|
+
client name, zh's full-width punctuation). The one string all ten packs share
|
|
1300
|
+
is `phonePlaceholder` — the E.164 example number, treated like the
|
|
1301
|
+
`name@example.com` the packs already keep untranslated.
|
|
1302
|
+
- **`scripts/i18n-call-site-key-baseline.json`** loses exactly those 54 entries
|
|
1303
|
+
(163 → 109). The file is a ratchet: an unfixed key missing from it fails the
|
|
1304
|
+
build, and a fixed key still listed fails it too.
|
|
1305
|
+
- **No component changed.** An AST sweep of all 122 call sites in these three
|
|
1306
|
+
namespaces found zero dead `t(key) || 'English'` fallbacks (the construct
|
|
1307
|
+
slice one had to delete from `ObjectView.tsx`, where i18next's key-as-value
|
|
1308
|
+
return made `||` unreachable).
|
|
1309
|
+
|
|
1310
|
+
Two holes here are **not** i18next's and must survive translation intact:
|
|
1311
|
+
`resendOtpCountdownText` carries `{seconds}` in single braces because
|
|
1312
|
+
`packages/auth/src/LoginForm.tsx` and `ForgotPasswordForm.tsx` substitute it with
|
|
1313
|
+
a literal `.replace()`, and `oauth.consent.request`'s `{{suffix}}` arrives
|
|
1314
|
+
pre-formatted from the page. Both are pinned in
|
|
1315
|
+
`packages/i18n/src/__tests__/auth-namespace-3546.test.tsx`, in both directions.
|
|
1316
|
+
|
|
1317
|
+
- f5f8744: Backfill the `console` namespace's 41 missing locale keys plus the `console.ai.group.` template family (objectui#3546, slice four)
|
|
1318
|
+
|
|
1319
|
+
`scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured **41 distinct
|
|
1320
|
+
keys at 47 call sites** under `console.*` that a `t()` call site asks for and
|
|
1321
|
+
that **no locale pack defined** — five of those keys have more than one site
|
|
1322
|
+
(`console.ai.dock.maximize` has three), which is why the denominator is measured
|
|
1323
|
+
and never counted by hand. All 47 carried an inline
|
|
1324
|
+
`t(key, { defaultValue: 'English' })`, so this is the objectui#3517 class:
|
|
1325
|
+
English rendered correctly, and **all ten languages were stuck on it**. Nothing
|
|
1326
|
+
here rendered a raw key — slice one (PR #3583) held those sites.
|
|
1327
|
+
|
|
1328
|
+
What that meant on the page: a `zh` user opening `/ai` got "Waiting for
|
|
1329
|
+
server…", "Still working…" and "Connection lost — reconnecting…" in the
|
|
1330
|
+
connection banner, all ten "Designing your app…" progress hints in English, and
|
|
1331
|
+
"Built" / "Not yet built" / "Published" / "Publish failed" on the plan card; the
|
|
1332
|
+
ChatDock's whole chrome (title, resize handle, collapse, "Open full page") was
|
|
1333
|
+
English including two `aria-label`s; the conversation sidebar's date headers read
|
|
1334
|
+
"Today" / "Yesterday" / "Previous 7 days"; a mistyped URL produced an English
|
|
1335
|
+
"Page not found"; and the `?` shortcuts dialog's AI group was English inside an
|
|
1336
|
+
otherwise translated table. Two of these strings ("Not yet built", "TODAY") are
|
|
1337
|
+
named in objectui#2458's mixed-language list.
|
|
1338
|
+
|
|
1339
|
+
- **`packages/i18n/src/locales/en.ts`** gains 46 keys: the 41 measured ones plus
|
|
1340
|
+
the five members of the `console.ai.group.` family. `console.notFound` is a new
|
|
1341
|
+
sub-namespace; the rest extend `console.shortcuts` and `console.ai` (with new
|
|
1342
|
+
`console.ai.dock`, `console.ai.designingPlanHint` and `console.ai.group`
|
|
1343
|
+
objects). Every one of the 41 measured keys takes its call site's inline
|
|
1344
|
+
`defaultValue` **byte for byte** (46/47 sites, script-compared), so the pack
|
|
1345
|
+
path and the inline-default path cannot diverge.
|
|
1346
|
+
|
|
1347
|
+
- The one site that cannot match is `ChatDock.tsx:563`, where a single key
|
|
1348
|
+
(`console.ai.dock.open`) carries two different English strings: the
|
|
1349
|
+
`aria-label` says `Open assistant` and the `title` says
|
|
1350
|
+
`Open assistant (⌘⇧I)`. A key can hold one value, so `en` takes the
|
|
1351
|
+
`aria-label` spelling — an accessible name must not carry a glyph run that
|
|
1352
|
+
screen readers announce as symbols, and `⌘` is a mac-only glyph that a
|
|
1353
|
+
_language_ pack cannot vary per platform. The tooltip therefore stops
|
|
1354
|
+
advertising the shortcut; recorded on objectui#3810 (whose class this
|
|
1355
|
+
divergence joins) rather than papered over.
|
|
1356
|
+
|
|
1357
|
+
- **`console.ai.group.` leaves the ratchet's `missingPrefixes` (4 → 3).** It is a
|
|
1358
|
+
template key — `` t(`console.ai.group.${group.key}`) `` in
|
|
1359
|
+
`ConversationsSidebar.tsx:277` — whose static head matched no `en` key, so every
|
|
1360
|
+
expansion missed. Its value surface is the **closed** `ConversationGroupKey`
|
|
1361
|
+
union, so the repair is an enumeration of all five members, not a wildcard; a
|
|
1362
|
+
test reads the component's own union and label map and fails if a sixth bucket
|
|
1363
|
+
is ever added without a key.
|
|
1364
|
+
|
|
1365
|
+
- **The nine other packs** get real translations, each evidenced against its own
|
|
1366
|
+
`console` neighbours: `zh` full-width punctuation and the pack's single-em-dash
|
|
1367
|
+
status-line style, `ja`/`ko` the pack's `AI ` spacing, `de` formal _Sie_ and
|
|
1368
|
+
its `Wird …` progressive, `fr` straight apostrophes, `es` _usted_ (as the
|
|
1369
|
+
`console.ai` neighbourhood already uses), `pt` the pack's `off-line` spelling,
|
|
1370
|
+
`ru` ё orthography, `ar` verb-first phrasing that never opens an RTL sentence
|
|
1371
|
+
with a Latin token. Where `en` repeats a string the packs already translate
|
|
1372
|
+
(`Go back`, `Publish failed`, `Try again`, `Back to home`, `Assistant`,
|
|
1373
|
+
`Today`, `Yesterday`), the existing neighbour's wording is reused rather than
|
|
1374
|
+
re-invented.
|
|
1375
|
+
|
|
1376
|
+
- **`scripts/i18n-call-site-key-baseline.json`** shrinks by exactly 42 entries
|
|
1377
|
+
(41 keys + 1 prefix family): 109 → 68 keys, 4 → 3 prefixes.
|
|
1378
|
+
|
|
1379
|
+
No component changed: an AST sweep of all 308 `console.*` call sites in the repo
|
|
1380
|
+
found zero dead `t(key) || 'English'` fallbacks among this slice's keys.
|
|
1381
|
+
|
|
1382
|
+
- 69becd2: `de` approvals inbox no longer shows two quote typographies on one screen
|
|
1383
|
+
|
|
1384
|
+
Three `approvalsInbox` values quoted the record title with ASCII straight quotes
|
|
1385
|
+
on **both** sides while their own sibling `approvalsInbox.approveOneTitle` used
|
|
1386
|
+
the correct German pair (U+201E low-9 opener, U+201C closer):
|
|
1387
|
+
|
|
1388
|
+
| key | before | after |
|
|
1389
|
+
| ------------------------------- | ----------------------- | ----------------------- |
|
|
1390
|
+
| `approvalsInbox.rejectOneTitle` | `"{{title}}" ablehnen?` | `„{{title}}“ ablehnen?` |
|
|
1391
|
+
| `approvalsInbox.inlineApproved` | `"{{title}}" genehmigt` | `„{{title}}“ genehmigt` |
|
|
1392
|
+
| `approvalsInbox.inlineRejected` | `"{{title}}" abgelehnt` | `„{{title}}“ abgelehnt` |
|
|
1393
|
+
|
|
1394
|
+
A German approver therefore met both typographies in a single screen and inside a
|
|
1395
|
+
single operation pair — German quotes on the approve confirmation, typewriter
|
|
1396
|
+
quotes on the reject confirmation and on both inline toasts. `„…“` is the pack's
|
|
1397
|
+
own majority (50 paired spans) and DUDEN R11, so the three values were the
|
|
1398
|
+
outlier. Keys and the `{{title}}` placeholder are unchanged; this is a
|
|
1399
|
+
value-domain typography fix, and `en` (ASCII on both sides by design) is
|
|
1400
|
+
untouched.
|
|
1401
|
+
|
|
1402
|
+
This is a **different defect shape** from objectui#3876, which paired a German
|
|
1403
|
+
opener with an ASCII closer. Because these three were ASCII on both sides they
|
|
1404
|
+
were self-consistent, so the pairing invariant objectui#3876 left behind could
|
|
1405
|
+
not see them — it scans forward from each `„`, and there was no `„` in them to
|
|
1406
|
+
scan from. The pack's straight-quote census being empty now lets that pin become
|
|
1407
|
+
strictly stronger: `de-quote-pairing-3876.test.ts` asserted an explicit
|
|
1408
|
+
three-key allowlist and now asserts that **no `de` value holds a U+0022 at all**,
|
|
1409
|
+
which covers both defect shapes and needs no per-key maintenance as new values
|
|
1410
|
+
land. The pack census moves from `„` 47 / `“` 47 / `”` 0 / `"` 6 to
|
|
1411
|
+
`„` 50 / `“` 50 / `”` 0 / `"` 0.
|
|
1412
|
+
|
|
1413
|
+
None of the three i18n gates could have caught this: `all-locales-key-parity`
|
|
1414
|
+
compares key sets and placeholder shapes, `check-i18n-call-site-keys.mjs` only
|
|
1415
|
+
asks whether a key resolves, and `check-i18n-en-drift.mjs` fires on `en` value
|
|
1416
|
+
changes — these values were wrong from the day they landed, so no drift event
|
|
1417
|
+
ever existed. All three are value-blind by design, which is why the invariant
|
|
1418
|
+
lives in a test.
|
|
1419
|
+
|
|
1420
|
+
- 5e52495: German pack: the 20 values that closed the German opening quote with an ASCII straight quote now close it with `“`
|
|
1421
|
+
|
|
1422
|
+
`packages/i18n/src/locales/de.ts` opened a quoted span with the German low-9
|
|
1423
|
+
quote `„` (U+201E) and closed it with the ASCII typewriter quote `"` (U+0022) in
|
|
1424
|
+
20 keys, so a German user read `Registerkarte „Alle Datensätze" anzeigen` — a
|
|
1425
|
+
mismatched pair. `search.resultsCount` showed it most plainly: the value ended
|
|
1426
|
+
`„{{query}}""`, the mismatched closer immediately followed by the TS string
|
|
1427
|
+
terminator.
|
|
1428
|
+
|
|
1429
|
+
This was never a pack convention. Measured on `main@2937bcf7d` before the fix,
|
|
1430
|
+
the same file already spelled 23 spans the correct German way, `„…“` — the two
|
|
1431
|
+
styles sat side by side, in sibling keys of the same namespace. Every mismatched
|
|
1432
|
+
closer is now `“` (U+201C), which is what German orthography (DUDEN R11) and the
|
|
1433
|
+
majority of the file already used. Affected surfaces: the four empty states
|
|
1434
|
+
(object / page / dashboard / report not found), the search results header, the
|
|
1435
|
+
lookup "create named" action, four `console.objectView` strings, the home
|
|
1436
|
+
getting-started hint, the six `navigationSync` toasts, the local marketplace
|
|
1437
|
+
install toast, and the preview not-ready title.
|
|
1438
|
+
|
|
1439
|
+
No other language pack and no `en` value changed — the counts are unchanged for
|
|
1440
|
+
every other locale, and the mismatch was measured only in `de`.
|
|
1441
|
+
|
|
1442
|
+
### Counted at landing, in two units
|
|
1443
|
+
|
|
1444
|
+
The card reported "20 values" and the triage re-scan reported "22 mismatched
|
|
1445
|
+
pairs"; both are correct about different units, and the difference is now
|
|
1446
|
+
recorded rather than left to the next reader. 20 keys carried a mismatch;
|
|
1447
|
+
22 mismatch occurrences lived in them, because `navigationSync.renamedPage` and
|
|
1448
|
+
`navigationSync.renamedDashboard` each quote two names in one sentence
|
|
1449
|
+
("Seite „alt“ in „neu“ umbenannt") and so contribute two each. Full-file counts
|
|
1450
|
+
before → after: `„` 45 → 45, `“` 25 → 47, `”` 2 → 2, `"` 28 → 6.
|
|
1451
|
+
|
|
1452
|
+
### The durable half, and why it is not the count equality the card proposed
|
|
1453
|
+
|
|
1454
|
+
Three i18n gates run over these packs and none can see a wrong quote:
|
|
1455
|
+
`all-locales-key-parity` compares key sets and placeholder shapes,
|
|
1456
|
+
`check-i18n-call-site-keys.mjs` only asks whether a key resolves, and
|
|
1457
|
+
`check-i18n-en-drift.mjs` fires on `en` **value changes** — these values were
|
|
1458
|
+
wrong from the day they landed, so no drift event ever existed. Without a
|
|
1459
|
+
value-domain assertion the next backfill copies the mismatch from a neighbour
|
|
1460
|
+
again, which is exactly how these 20 accumulated.
|
|
1461
|
+
|
|
1462
|
+
The card proposed asserting `count(„) === count(“)`. That is **false on the
|
|
1463
|
+
correctly fixed file** (45 vs 47) and would have sent the next reader hunting a
|
|
1464
|
+
bug that is not there: two values in this pack, `grid.import.savedMappingHint`
|
|
1465
|
+
and `grid.import.savedMappingPreviewNote`, are still untranslated English prose
|
|
1466
|
+
and quote in the English style `“…”`, so their two `“` are legitimate _openers_.
|
|
1467
|
+
The invariant that is pinned instead is the pairing itself — for every `„`, the
|
|
1468
|
+
first quote character that follows must be `“` — backed by the arithmetic
|
|
1469
|
+
identity `count(“) === count(„) + count(”)`, which stays true if those two values
|
|
1470
|
+
are later translated (47 === 47 + 0) and breaks the moment a mismatch returns.
|
|
1471
|
+
Both carry a presence guard so a broken import cannot make them pass by scanning
|
|
1472
|
+
nothing.
|
|
1473
|
+
|
|
1474
|
+
- b750823: `preview.draftBar` speaks one second person in `es` — the draft-preview banner no
|
|
1475
|
+
longer switches from tú to usted when a Spanish user publishes (#3844)
|
|
1476
|
+
|
|
1477
|
+
`DraftPreviewBar` renders two mutually exclusive sentences in the same strip of
|
|
1478
|
+
the same banner: `message` while there are unpublished changes, `messageClean`
|
|
1479
|
+
once there are none. In `es` the two disagreed on register — `message` was tú
|
|
1480
|
+
(`estás viendo`, `publiques`) while `messageClean` (`ve`) and `sampleDataBody`
|
|
1481
|
+
(`Está`, `su`, `Publíquela`) were usted. So a Spanish user who pressed Publish
|
|
1482
|
+
watched the banner change person: same component, same position, same session.
|
|
1483
|
+
|
|
1484
|
+
This is a third defect class in the value-domain blind spot behind #3582 and
|
|
1485
|
+
#3625, and no gate in the repo can see it. Both `es` values are correct
|
|
1486
|
+
translations of their `en` sentences — nothing is missing, nothing is stale, and
|
|
1487
|
+
nothing holds English. The inconsistency is _internal to one pack, on one UI
|
|
1488
|
+
surface_: `scripts/check-i18n-call-site-keys.mjs` only asks whether a key exists
|
|
1489
|
+
in `en`, `all-locales-key-parity` compares key sets and placeholder shapes and
|
|
1490
|
+
never reads values, and `scripts/check-i18n-en-drift.mjs` only fires when an `en`
|
|
1491
|
+
value moves — these two `en` values never moved.
|
|
1492
|
+
|
|
1493
|
+
`message` is the value that changes, because usted is what the pack already says
|
|
1494
|
+
everywhere around it: the `es` pack censuses 102 usted markers to 30 tú (tú being
|
|
1495
|
+
the marked exception, concentrated in the auth, report-editor and organizations
|
|
1496
|
+
neighbourhoods that #3546 slice two deliberately ruled informal); the other three
|
|
1497
|
+
strings of this same object were already usted; and #3546 slice five gave the 19
|
|
1498
|
+
new `preview.unpublishedBar.*` / `preview.history.*` keys usted on the strength
|
|
1499
|
+
of `home.pendingDrafts.published` ("¡Publicado! Sus cambios están activos."). Two
|
|
1500
|
+
smaller divergences inside the same sentence are closed with it, so the banner's
|
|
1501
|
+
two halves stop disagreeing about wording as well as person:
|
|
1502
|
+
|
|
1503
|
+
- **`—` instead of `:`** in `messageClean`, matching `en`, where both sentences
|
|
1504
|
+
open `Draft preview — `. That is the whole of `messageClean`'s diff.
|
|
1505
|
+
- **`activo` instead of `en producción`** for "live". The pack spells this concept
|
|
1506
|
+
`activo` in four neighbouring places including `publishCta` in this very object
|
|
1507
|
+
("Publicar para verlo activo"), and it reserves _producción_ for the actual
|
|
1508
|
+
production environment (`environment.entitlement.planLockedBody`), so the
|
|
1509
|
+
outlier was ambiguous as well as inconsistent.
|
|
1510
|
+
- **`Vista previa del borrador`** as the shared opening, `del` being the form
|
|
1511
|
+
`messageClean` already used.
|
|
1512
|
+
|
|
1513
|
+
No `en` value changes (the en-drift gate reports 0), no key is added or removed
|
|
1514
|
+
(so `all-locales-key-parity` is untouched by construction), and the nine other
|
|
1515
|
+
packs are not touched. The diff is two values in one file.
|
|
1516
|
+
|
|
1517
|
+
Re-voicing the whole `preview` namespace to tú was considered and rejected — a
|
|
1518
|
+
much larger change that would collide with the adjacent `marketplace.*` (9:0
|
|
1519
|
+
usted) and `console.ai.*`. `preview.empty.notReadyDescription` therefore stays tú
|
|
1520
|
+
here, since #3844's ruling is this banner only; it is filed as #3875 rather than
|
|
1521
|
+
waved through, because `PreviewDraftEmptyState` renders _underneath_
|
|
1522
|
+
`DraftPreviewBar` and so those two show usted and tú on screen at the same time.
|
|
1523
|
+
After this change the `preview` namespace is 23 usted to that 1 tú.
|
|
1524
|
+
|
|
1525
|
+
A gate that checks "one register per namespace" is deliberately **not** here:
|
|
1526
|
+
recognising usted vs tú needs real morphology, and token matching demonstrably
|
|
1527
|
+
cannot — in this pack the token `revisa` is usted in `console.ai.empty.build`
|
|
1528
|
+
(es.ts:1365, "…y usted revisa y publica.") and tú in
|
|
1529
|
+
`auth.forgotPassword.successTitle` (es.ts:1918, "Revisa tu correo electrónico"),
|
|
1530
|
+
the same eight letters in opposite registers. The neighbourhood boundary such a
|
|
1531
|
+
gate would police is human judgement anyway — #3546 slice two's "same rule,
|
|
1532
|
+
different answer". A new `draftBar-es-register-3844.test.ts` pins the four `es`
|
|
1533
|
+
values byte for byte instead, plus the `en` literals, so a future reword of
|
|
1534
|
+
either `en` sentence fails in the same PR that reworded it.
|
|
1535
|
+
|
|
1536
|
+
- ac2139c: `grid.import` saved-mapping copy is now translated in ko / de / fr / es / pt / ru / ar instead of served as English
|
|
1537
|
+
|
|
1538
|
+
Five keys — `grid.import.savedMapping`, `chooseSavedMapping`, `manualMapping`,
|
|
1539
|
+
`savedMappingHint` and `savedMappingPreviewNote` — were **byte-identical to `en`**
|
|
1540
|
+
in seven of the nine non-`en` packs, 35 values in all. Only zh and ja had been
|
|
1541
|
+
translated, and two of the five are multi-clause explanatory sentences rather
|
|
1542
|
+
than placeholders or proper nouns, so this was not a deliberately untranslated
|
|
1543
|
+
term: a German, French, Spanish, Portuguese, Russian, Korean or Arabic user
|
|
1544
|
+
picking a saved mapping in the import wizard read English.
|
|
1545
|
+
|
|
1546
|
+
None of the three i18n gates could see it. `all-locales-key-parity` compares key
|
|
1547
|
+
sets and placeholder shapes, so English passed perfectly; the call-site gate only
|
|
1548
|
+
asks whether a key resolves; `check-i18n-en-drift.mjs` fires on an `en` **value
|
|
1549
|
+
change** and these keys arrived in the packs already English, so no drift event
|
|
1550
|
+
ever existed. objectui#3920 also proposed a general "no non-`en` value may be
|
|
1551
|
+
byte-equal to `en`" gate; that is deliberately not part of this change, because
|
|
1552
|
+
values like `auth.*.emailPlaceholder` (`name@example.com`),
|
|
1553
|
+
`fields.image.counter` (`{{current}} / {{total}}`) and
|
|
1554
|
+
`grid.import.templateFileName` are byte-equal across packs correctly, and
|
|
1555
|
+
objectui#3880 records the other face of the family. The five keys are pinned by
|
|
1556
|
+
name instead.
|
|
1557
|
+
|
|
1558
|
+
Each translation reuses vocabulary its own pack already ships rather than
|
|
1559
|
+
inventing a second word for the same concept: `stepMapping` for "mapping"
|
|
1560
|
+
(Zuordnung / Correspondance / Asignación / Mapeamento / Сопоставление / تعيين /
|
|
1561
|
+
매핑), `legacyFallbackNotice` for "type coercion" (Typkonvertierung / conversion
|
|
1562
|
+
de type / conversión de tipos / conversão de tipos / приведение типов / تحويل
|
|
1563
|
+
الأنواع / 형 변환), `view.readonlyTooltip` for "read-only", and `validateHint`
|
|
1564
|
+
for "on the server". `es` follows the usted ruling objectui#3844 measured for
|
|
1565
|
+
this pack; `pt` is the pack's own pt-BR (mapeamento / salvo / no servidor);
|
|
1566
|
+
`ru` keeps its ё; `fr` keeps the straight apostrophe and the space before colon
|
|
1567
|
+
and semicolon; `ar` punctuates with U+060C and U+061B; `ko` uses the 은(는) /
|
|
1568
|
+
이(가) spelling its `fields.file.exceedsMaxSize` already uses for a placeholder
|
|
1569
|
+
whose particle cannot be resolved at authoring time.
|
|
1570
|
+
|
|
1571
|
+
The quote around `{{name}}` follows each pack's measured majority for wrapping a
|
|
1572
|
+
placeholder — de `„…“` (38 spans against 3 ASCII), fr `« … »` with an ASCII
|
|
1573
|
+
space (25 against 18), ru `«…»` (23 against 18), and ASCII `"…"` for ko (38:0),
|
|
1574
|
+
pt (39:0), es (22:19) and ar (27:15). The card cited
|
|
1575
|
+
`residue-namespaces-3546.test.tsx` as pinning "the rest ASCII", but that line
|
|
1576
|
+
pins the `empty.*` family only and its own comment scopes the rule to "the
|
|
1577
|
+
sibling value in the SAME pack"; `grid.import` has no other quoted span, so the
|
|
1578
|
+
census one ring out decides.
|
|
1579
|
+
|
|
1580
|
+
Germanising the two `de` sentences also closes the 45/47 gap
|
|
1581
|
+
`de-quote-pairing-3876.test.ts` documented: that pack's census moves from
|
|
1582
|
+
`„` 45 / `“` 47 / `”` 2 to `„` 47 / `“` 47 / `”` 0, exactly as that file's header
|
|
1583
|
+
predicted, and its `count(“) === count(„) + count(”)` identity still holds. The
|
|
1584
|
+
three literal counts in it were updated and the two keys are now pinned by name
|
|
1585
|
+
there, so `rdqKeys === []` cannot become an assertion that passes because nothing
|
|
1586
|
+
is produced.
|
|
1587
|
+
|
|
1588
|
+
`en`, `zh` and `ja` are untouched, and `grid.import.transform` — the sixth key in
|
|
1589
|
+
the same block, English in the same seven packs, and the third column header of
|
|
1590
|
+
the very table this hint sits above — is deliberately left alone, filed as
|
|
1591
|
+
objectui#3938 and pinned as still-English so the number cannot drift while that
|
|
1592
|
+
issue waits its turn.
|
|
1593
|
+
|
|
1594
|
+
- b14ab3a: `grid.import.transform` is now translated in ko / de / fr / es / pt / ru / ar instead of served as English
|
|
1595
|
+
|
|
1596
|
+
The third column header of the import wizard's saved-mapping summary was
|
|
1597
|
+
**byte-identical to `en`** — the literal string `Transform` — in seven of the
|
|
1598
|
+
nine non-`en` packs. zh (`转换`) and ja (`変換`) had translated it, which is what
|
|
1599
|
+
rules out the "deliberately untranslated term" reading; this is the sixth key of
|
|
1600
|
+
the same six-line block objectui#3920 / PR #3936 fixed the other five of, and it
|
|
1601
|
+
was left out of that card only to keep its 5-keys / 35-values census verifiable.
|
|
1602
|
+
|
|
1603
|
+
| pack | before | after | the pack's own anchor |
|
|
1604
|
+
| ---- | ----------- | ---------------- | ---------------------------------------------------------------------------------------- |
|
|
1605
|
+
| ko | `Transform` | `변환` | `savedMappingHint` "이름 변경 + 변환 + 형 변환" |
|
|
1606
|
+
| de | `Transform` | `Transformation` | `savedMappingHint` "Umbenennung + Transformationen + Typkonvertierung" |
|
|
1607
|
+
| fr | `Transform` | `Transformation` | `savedMappingHint` "le renommage + les transformations + la conversion de type" |
|
|
1608
|
+
| es | `Transform` | `Transformación` | `savedMappingHint` "el cambio de nombre + las transformaciones + la conversión de tipos" |
|
|
1609
|
+
| pt | `Transform` | `Transformação` | `savedMappingHint` "renomeação + transformações + conversão de tipos" |
|
|
1610
|
+
| ru | `Transform` | `Преобразование` | `savedMappingHint` "переименование + преобразования + приведение типов" |
|
|
1611
|
+
| ar | `Transform` | `التحويل` | `savedMappingHint` "إعادة التسمية + التحويلات + تحويل الأنواع" |
|
|
1612
|
+
|
|
1613
|
+
Each value is the **singular of the word the pack already uses** for `en`'s
|
|
1614
|
+
plural "transforms" in `grid.import.savedMappingHint` — the sentence
|
|
1615
|
+
`SavedMappingSummary` renders directly above this header. That is the anchor
|
|
1616
|
+
rather than `legacyFallbackNotice`'s term, because `en`'s own sentence lists
|
|
1617
|
+
"transforms" and "type coercion" as two different server-side operations and
|
|
1618
|
+
`legacyFallbackNotice` is about the second: reusing de "Typkonvertierung", ko
|
|
1619
|
+
"형 변환" or ar "تحويل الأنواع" for this header would name the wrong operation.
|
|
1620
|
+
|
|
1621
|
+
Why it was worth a card of its own rather than waiting for a general gate: this
|
|
1622
|
+
value is not a hidden string. It is the third `TableHead` of
|
|
1623
|
+
`SavedMappingSummary`, sitting in one row with `csvColumn` and `mapsTo`, which
|
|
1624
|
+
were always translated — so a German user read `Spalte` / `Zugeordnet zu` /
|
|
1625
|
+
`Transform`, two languages in a single header row, directly under the German
|
|
1626
|
+
hint PR #3936 had just landed. Before that PR the whole panel was English and
|
|
1627
|
+
therefore at least self-consistent.
|
|
1628
|
+
|
|
1629
|
+
The three i18n gates are value-blind here exactly as they were for
|
|
1630
|
+
objectui#3920: `all-locales-key-parity` compares key sets and placeholder shapes
|
|
1631
|
+
(the key was present with no placeholder, so English passed perfectly), the
|
|
1632
|
+
call-site gate only asks whether a key resolves, and `check-i18n-en-drift.mjs`
|
|
1633
|
+
fires on an `en` **value change** — this value entered the seven packs already
|
|
1634
|
+
English, so no drift event ever existed. `en`, zh and ja are untouched, and the
|
|
1635
|
+
pin PR #3936 left in `gridImportSavedMapping-i18n-3920.test.ts` asserting these
|
|
1636
|
+
seven were "still English" has been replaced by the translated pin it asked for.
|
|
1637
|
+
|
|
1638
|
+
- 8c60819: The inbox popover now spells out what the bell badge is made of
|
|
1639
|
+
|
|
1640
|
+
The bell badge is `unread notification topics + pending approvals`, clamped to
|
|
1641
|
+
"9+" above nine. As one number it is unexplainable: objectstack#7213 measured
|
|
1642
|
+
Home's "pending approvals" card saying 8 while the bell said "9+", and read that
|
|
1643
|
+
as the two counts disagreeing — they never did, the bell was simply carrying a
|
|
1644
|
+
second addend the user could not see.
|
|
1645
|
+
|
|
1646
|
+
The popover already tabs the two streams and puts a count pill on each tab, so
|
|
1647
|
+
the split was partly visible — but those pills clamp at "9+" too. A loaded
|
|
1648
|
+
console therefore showed three "9+"s that reconcile to nothing, which is why
|
|
1649
|
+
sectioning alone did not close this.
|
|
1650
|
+
|
|
1651
|
+
A breakdown line under the popover header now states the exact, unclamped
|
|
1652
|
+
addends beside the exact total — `15 total · 12 notifications + 3 pending
|
|
1653
|
+
approvals`. The approvals half is the same `pendingApprovalsCount` the Home card
|
|
1654
|
+
and the Approvals Inbox tab read, so the number a user reconciles against is
|
|
1655
|
+
literally the one they see elsewhere.
|
|
1656
|
+
|
|
1657
|
+
The badge formula, the counting APIs and the "9+" clamp on the badge itself are
|
|
1658
|
+
unchanged — this is a display fix. Three new keys
|
|
1659
|
+
(`notifications.badgeTotal` / `badgeNotifications` / `badgeApprovals`) land in
|
|
1660
|
+
all ten locale packs. They interpolate named placeholders (`{{total}}`,
|
|
1661
|
+
`{{unread}}`, `{{approvals}}`) rather than i18next's `{{count}}`, which would
|
|
1662
|
+
additionally drive plural-key resolution these packs carry no forms for.
|
|
1663
|
+
|
|
1664
|
+
- e64a52e: 回填 `perm` + `home` 两命名空间 14 个缺失语言 key,十个语言包补齐(#3546 切片六)
|
|
1665
|
+
|
|
1666
|
+
`scripts/check-i18n-call-site-keys.mjs` 实测:14 个不重复 key / 14 个调用点(1:1),分布在权限集
|
|
1667
|
+
记录的授权面摘要(`PermissionFacetLink`,ADR-0056 P1)与"一键发布全部草稿"的其余几条 toast
|
|
1668
|
+
(`usePublishAllDrafts`:ADR-0038 L3 探针健康、示例数据健康、ADR-0066 ⑨ 能力引用巡检)。
|
|
1669
|
+
|
|
1670
|
+
这 14 处都带内联 `defaultValue`,所以英文一直正常渲染,**十种语言都翻不了** —— 修的是这一半。
|
|
1671
|
+
英文字串一字未改:9 处静态 `defaultValue` 与 en 逐字节相同;另外 4 个计数标签(对象 / 字段规则 /
|
|
1672
|
+
RLS 策略 / 标签页规则)的 `defaultValue` 是带英文单复数分支的模板串,改用 i18next 复数族
|
|
1673
|
+
(`key` + `key_one`)后逐个计数渲染结果与模板串一致。
|
|
1674
|
+
|
|
1675
|
+
复数族刻意带**基础 key** 而不是只写 `_one`/`_other`:i18next 只按语言的 CLDR 类别取一个后缀,
|
|
1676
|
+
取不到就沿 fallback 链落到 `en`。ru(few 2-4、many 5-20)与 ar(two、few 3-10、many 11-99)
|
|
1677
|
+
恰好在用户最先遇到的计数上会因此显示英文;基础 key 让这些类别落回**本语言**。
|
|
1678
|
+
|
|
1679
|
+
- 844d17f: Backfill the `marketplace` and `preview` namespaces' 37 missing locale keys plus the `marketplace.disclosure.runtime.` template-key family (objectui#3546, slice five)
|
|
1680
|
+
|
|
1681
|
+
`scripts/check-i18n-call-site-keys.mjs` (objectui#3530) measured 37 keys that a
|
|
1682
|
+
`t()` call site asks for and that **no locale pack defined** — 37 distinct keys at
|
|
1683
|
+
37 call sites across five console components — plus one `missing-prefix` family
|
|
1684
|
+
whose static head matched no `en` key at all, so every expansion missed. All 37
|
|
1685
|
+
carried an inline `t(key, { defaultValue: 'English' })`, which is exactly the
|
|
1686
|
+
objectui#3517 class: English rendered correctly, and **all ten languages were
|
|
1687
|
+
stuck on it** for months. Nothing here rendered a raw key — slice one (PR #3583)
|
|
1688
|
+
held those sites.
|
|
1689
|
+
|
|
1690
|
+
What that meant on the page for a `zh` (or `ja`, `de`, `ar`, …) user: the
|
|
1691
|
+
marketplace's "Your organization" strip, its Install / Installing… / Installed
|
|
1692
|
+
buttons and the version-update affordances were English; the whole ADR-0025 PD4
|
|
1693
|
+
**pre-install permission disclosure** was English — "This package contains code",
|
|
1694
|
+
the trust-tier badge, "Reviewed & approved" / "Not yet reviewed" / "Signed", the
|
|
1695
|
+
four permission group labels (platform services, lifecycle hooks, network,
|
|
1696
|
+
filesystem) and the consent checkbox the user ticks to accept them; the ADR-0045
|
|
1697
|
+
unpublished-app banner and its publish toasts were English; and the entire
|
|
1698
|
+
ADR-0067 build-history sheet — title, description, the per-commit labels, the
|
|
1699
|
+
Revert button and both of its result toasts — was English.
|
|
1700
|
+
|
|
1701
|
+
`marketplace.disclosure.runtime.` is repaired as an **enumeration, not a
|
|
1702
|
+
wildcard**: its value surface is the closed trust-tier enum
|
|
1703
|
+
(`PluginRuntimeSchema` = `z.enum(['node', 'sandbox', 'worker'])`, ADR-0025 §3.6),
|
|
1704
|
+
so all three members are backfilled and the family leaves the ratchet's
|
|
1705
|
+
`missingPrefixes` (3 → 2). A test reads the component's own fallback map and
|
|
1706
|
+
fails if a fourth tier is ever added without a key — the job the prefix entry
|
|
1707
|
+
used to do.
|
|
1708
|
+
|
|
1709
|
+
Each `en` value is byte-identical to the inline `defaultValue` it replaces (36 of
|
|
1710
|
+
36 literal sites; the 37th's `defaultValue` is a template literal whose
|
|
1711
|
+
`${pkg.display_name}` becomes the `{{name}}` hole its call site already passes),
|
|
1712
|
+
so no English string a user sees today changes. The nine translations follow each
|
|
1713
|
+
pack's own neighbourhood — including two namespaces that legitimately take
|
|
1714
|
+
**different** second persons in `zh` (`marketplace` 你, `preview` 您) — and reuse
|
|
1715
|
+
an existing neighbour's translation wherever the `en` string already existed
|
|
1716
|
+
verbatim, so one English string never renders as two different sentences in the
|
|
1717
|
+
same language.
|
|
1718
|
+
|
|
1719
|
+
No component changed: an AST sweep of the whole `marketplace.*`/`preview.*`
|
|
1720
|
+
call-site surface found the slice's own dead-`||`-fallback count to be zero.
|
|
1721
|
+
|
|
1722
|
+
- 4dcd52a: `console.objectView.systemViewReadonly` and `console.objectView.expandToPage`
|
|
1723
|
+
are translated in the eight packs that stored English for them, so a Japanese,
|
|
1724
|
+
Korean, German, French, Spanish, Portuguese, Russian or Arabic session reads
|
|
1725
|
+
the system-view hint and the expand affordance in its own language (#3582).
|
|
1726
|
+
|
|
1727
|
+
This is a different defect class from #3546's ledger, and no gate in the repo
|
|
1728
|
+
could see it. There the key was _missing_ from a pack and `fallbackLng: 'en'`
|
|
1729
|
+
rendered English; here the key was **present in all ten** and eight of them
|
|
1730
|
+
stored English as the value. `all-locales-key-parity` compares key _sets_ and
|
|
1731
|
+
placeholder _shape_ — identical before and after this change, and neither key
|
|
1732
|
+
interpolates anything. `scripts/check-i18n-call-site-keys.mjs` and its baseline
|
|
1733
|
+
ratchet ask whether a `t()` key exists in `en`; it did, so these two were never
|
|
1734
|
+
in the 258.
|
|
1735
|
+
|
|
1736
|
+
`systemViewReadonly` carried the sharper half: the eight packs did not hold
|
|
1737
|
+
`en`'s sentence, they held one `en` had already abandoned. `en` says the view
|
|
1738
|
+
is read-only; the eight said `System view defined in code - duplicate to
|
|
1739
|
+
customize.` — pointing eight locales at a duplicate-to-customize path the
|
|
1740
|
+
product no longer presents. They are translated against `en`'s **current**
|
|
1741
|
+
read-only meaning, not against the stale English they replaced.
|
|
1742
|
+
|
|
1743
|
+
Each value is built from terminology the same pack already uses rather than
|
|
1744
|
+
invented: `view.readonlyTooltip` supplies "system view" and
|
|
1745
|
+
`console.objectView.cannotEditMetaView` (landed in #3583) supplies "defined in
|
|
1746
|
+
code", so the new hint agrees with the copy beside it in every pack. For
|
|
1747
|
+
`expandToPage`, `detail.openAsFullPage` is the identical English sentence one
|
|
1748
|
+
namespace over and was already translated everywhere — `en` and `zh` hold their
|
|
1749
|
+
two byte-identical to each other, so the eight now do too, and one locale
|
|
1750
|
+
cannot end up with two different words for one action.
|
|
1751
|
+
|
|
1752
|
+
`en` and `zh` are unchanged, byte for byte. No key is added or removed —
|
|
1753
|
+
the diff is 16 values in 8 files. A new
|
|
1754
|
+
`objectView-value-language-3582.test.ts` pins the `en` literal (so the next
|
|
1755
|
+
rewording of `en` fails loudly instead of silently orphaning nine
|
|
1756
|
+
translations), asserts that no pack but `en` serves either English spelling,
|
|
1757
|
+
and requires the zh/ja/ko/ru/ar values to contain characters of their own
|
|
1758
|
+
script. The repo-wide "no ASCII English sentence in a non-Latin pack" gate that
|
|
1759
|
+
#3582 also sketched is deliberately **not** here; it is a separate, lands-green
|
|
1760
|
+
change.
|
|
1761
|
+
|
|
1762
|
+
- 42ae5c6: The organization-management console is translatable. The 90 keys under
|
|
1763
|
+
`organization.*` — the org layout and its tabs, the members list, the whole
|
|
1764
|
+
invitation flow, organization settings including the leave and delete
|
|
1765
|
+
confirmations, the accept-invitation page, and the workspace switcher — are now
|
|
1766
|
+
defined in all ten locale packs, so a non-English session reads the org admin
|
|
1767
|
+
surface in its own language instead of English (part of #3546).
|
|
1768
|
+
|
|
1769
|
+
`scripts/check-i18n-call-site-keys.mjs` measured 258 keys that a `t()` call site
|
|
1770
|
+
asks for and no pack defines. `organization.*` was the largest namespace in that
|
|
1771
|
+
tally at 90 keys across 93 call sites in seven components. Every one of them
|
|
1772
|
+
carried an inline `t(key, { defaultValue: 'English' })`, which is why nothing
|
|
1773
|
+
looked broken: English rendered correctly at each site and all ten languages
|
|
1774
|
+
were pinned to it. That is the #3517 class, not the raw-key class slice one
|
|
1775
|
+
(#3583) held — no organization site rendered an identifier, and none had a dead
|
|
1776
|
+
`||` fallback to remove, which was measured before deciding not to touch the
|
|
1777
|
+
components.
|
|
1778
|
+
|
|
1779
|
+
Adding a `defaultValue` is deliberately not the fix; it is the mechanism that
|
|
1780
|
+
kept these invisible for months. The existing defaults stay where they are, and
|
|
1781
|
+
each `en` value is byte-identical to the default at its call site so the two
|
|
1782
|
+
paths cannot render different text.
|
|
1783
|
+
|
|
1784
|
+
`organization` is a new top-level namespace, sitting next to — and distinct
|
|
1785
|
+
from — `organizations`: the singular one is the management surface, the plural
|
|
1786
|
+
one the org picker. The ratchet in `scripts/i18n-call-site-key-baseline.json`
|
|
1787
|
+
shrinks by exactly these 90 entries, from 253 to 163. The
|
|
1788
|
+
`organization.invitations.status.*` template-key family is untouched and still
|
|
1789
|
+
baselined: enumerating an invitation status set is a different repair from
|
|
1790
|
+
backfilling literal keys.
|
|
1791
|
+
|
|
1792
|
+
- 6d762da: The five locale keys behind #3546's eight no-fallback `t()` call sites are now defined in all ten packs, so the built-in-view toasts, the activity-timeline source link, the wizard's required-field toast and the Gantt refresh button's accessible name are translated instead of falling back to English — or, on two surfaces, to the key itself (part of #3546).
|
|
1793
|
+
|
|
1794
|
+
`scripts/check-i18n-call-site-keys.mjs` measured 258 keys that a `t()` call site asks for and no pack defines. These five were the subset with no working inline default: `console.objectView.cannotEditMetaView`, `console.objectView.cannotDeleteMetaView`, `detail.viewSource`, `gantt.toolbar.refresh` and `wizard.missingRequired`. Adding a `defaultValue` is deliberately not the fix — that mechanism is what kept all 258 invisible for months.
|
|
1795
|
+
|
|
1796
|
+
**Two of the eight sites really did render the raw key**, and both go through a binding with nothing in front of i18next. `ObjectView.tsx` calls `useObjectTranslation()` directly, so five toasts read `console.objectView.cannotEditMetaView` / `cannotDeleteMetaView` on screen; the `|| 'Built-in views cannot be renamed.'` guards next to them were dead on every path, because i18next answers a miss with the key itself and a non-empty string never falls through `||`. Those four unreachable English strings are removed rather than repaired: one key served four call sites (rename / pin / set-as-default / configure), so the pack copy covers any change to a built-in view instead of naming one operation. `RecordActivityTimeline.tsx` fails the same way for a subtler reason — `useDetailTranslation` is `createSafeTranslation(..., 'detail.back')`, and because `detail.back` does resolve, the probe hands back i18next's `t` for every key and bypasses the defaults map wholesale, so `detail.viewSource` reached the user verbatim.
|
|
1797
|
+
|
|
1798
|
+
**The other two sites were not rendering a raw key**, contrary to the issue's description, and are fixed here as the milder "English in all ten languages" class. `wizard.missingRequired` is its own hook's probe key, so the probe failed and `createSafeTranslation` correctly served its English default. `gantt.toolbar.refresh` goes through `useGanttTranslation`, which deliberately does not use `createSafeTranslation` and falls back per key — so the refresh button's `aria-label` was "Refresh", in English, never the key. Screen-reader users heard an English word rather than an identifier; a `zh` session now hears 刷新.
|
|
1799
|
+
|
|
1800
|
+
Regression cover is provider-mounted on purpose: with no `I18nProvider` the defaults maps answer every one of these keys and the assertions pass while the console is broken, which is precisely the false-green the issue documents. For the two sites whose English output was already correct, `en` cannot discriminate before from after — the `zh` assertions are the ones that pin the fix.
|
|
1801
|
+
|
|
1802
|
+
- f9faa7d: Backfill the last 17 missing locale keys and both remaining template-key families, emptying the call-site key ratchet (objectui#3546, slice seven — final)
|
|
1803
|
+
|
|
1804
|
+
`scripts/check-i18n-call-site-keys.mjs` (objectui#3530) opened this backlog with
|
|
1805
|
+
**258 keys and 4 template-key families** that a `t()` call site asks for and that
|
|
1806
|
+
**no locale pack defined**. Seven slices later the last of it is paid: this change
|
|
1807
|
+
takes the ratchet from 17 keys to **zero** and from 2 prefix families to **zero**,
|
|
1808
|
+
and the gate now reports every one of the **2320** literal call-site keys
|
|
1809
|
+
resolving against `en`.
|
|
1810
|
+
|
|
1811
|
+
The residue was the long tail — nine namespaces across `app-shell`,
|
|
1812
|
+
`plugin-detail`, `plugin-dashboard`, `plugin-kanban` and `plugin-gantt`, none of
|
|
1813
|
+
them big enough to have been its own slice. 17 distinct keys at **23** call sites
|
|
1814
|
+
(five keys are used at more than one site) plus **3** call sites behind the two
|
|
1815
|
+
families.
|
|
1816
|
+
|
|
1817
|
+
What that meant on the page for a `zh` (or `ja`, `de`, `ar`, …) user: the "App not
|
|
1818
|
+
available" empty state a user lands on when an app is still publishing, including
|
|
1819
|
+
its whole explanation and its Retry button; the interface page's "source is not
|
|
1820
|
+
available" message; the system navigation's **Administration** group header,
|
|
1821
|
+
**Datasources** and **Documentation** entries; the "creating new organizations is
|
|
1822
|
+
disabled on this instance" guard in the workspace dialog; the invitation list's
|
|
1823
|
+
five status labels (All / Pending / Accepted / Rejected / Canceled) on both the
|
|
1824
|
+
filter tabs and every invitation badge; the Gantt dependency-drag hint that names
|
|
1825
|
+
which endpoint the drop will link (`start` / `end`); the record detail's Add,
|
|
1826
|
+
"Record deleted", "No history yet" and the concurrent-update dialog's "this
|
|
1827
|
+
record"; the kanban empty board's column count; the dashboard widget's screen
|
|
1828
|
+
reader "Loading…"; and the page editor's "Edit in studio" tooltip and accessible
|
|
1829
|
+
name. All of it rendered English, in every one of the ten languages.
|
|
1830
|
+
|
|
1831
|
+
Nothing here rendered a raw key — slice one (PR #3583) held those sites, and the
|
|
1832
|
+
three keys the issue body named as unprotected (`detail.viewSource`,
|
|
1833
|
+
`wizard.missingRequired`, `gantt.toolbar.refresh`) have resolved in `en` since.
|
|
1834
|
+
|
|
1835
|
+
Both families are repaired as **enumerations, not wildcards**, and the assertion
|
|
1836
|
+
that used to live in the ratchet's `missingPrefixes` moves into a test that fails
|
|
1837
|
+
if either union grows a member without a key:
|
|
1838
|
+
|
|
1839
|
+
- `gantt.linkEnd.` — the closed union `'start' | 'end'`, declared by GanttView's
|
|
1840
|
+
own `linkDrag` state.
|
|
1841
|
+
- `organization.invitations.status.` — `StatusFilter`
|
|
1842
|
+
(`all | pending | accepted | rejected | canceled`), declared by InvitationsPage.
|
|
1843
|
+
|
|
1844
|
+
Every `en` value is byte-identical to the English the call site rendered before,
|
|
1845
|
+
so no string a user sees today changes: 16 keys match an inline
|
|
1846
|
+
`t(key, { defaultValue: … })`; `dashboard.loading` matches `useSafeTranslate`'s
|
|
1847
|
+
positional fallback `tt(key, 'Loading…')`; `gantt.linkEnd.*` match
|
|
1848
|
+
`useGanttTranslation`'s per-key fallback map; and the five status labels match the
|
|
1849
|
+
CSS-capitalised wire value each badge and tab showed. The nine translations follow
|
|
1850
|
+
each pack's own neighbourhood and reuse an existing neighbour's row wherever the
|
|
1851
|
+
`en` string already existed verbatim **and** that row is grammatical here — the
|
|
1852
|
+
four invitation adjectives are deliberately not reused from the approvals family,
|
|
1853
|
+
because those agree with each pack's word for "request" (`ru` masculine `Отклонён`)
|
|
1854
|
+
while an invitation needs its own agreement (`ru` neuter `Отклонено`).
|
|
1855
|
+
|
|
1856
|
+
`scripts/i18n-call-site-key-baseline.json` is kept rather than deleted: empty is
|
|
1857
|
+
its terminal, load-bearing state — against an empty baseline any NEW unresolved
|
|
1858
|
+
call-site key is unexpected and fails the build.
|
|
1859
|
+
|
|
1860
|
+
No component changed.
|
|
1861
|
+
|
|
1862
|
+
- 33526fd: `view.readonlyTooltip` — the tooltip on a view tab's read-only lock — is
|
|
1863
|
+
retranslated in the eight packs (ja/ko/de/fr/es/pt/ru/ar) that still described
|
|
1864
|
+
the retired "duplicate to customize" workflow, so a Japanese, Korean, German,
|
|
1865
|
+
French, Spanish, Portuguese, Russian or Arabic session is told the view is
|
|
1866
|
+
defined in code and read-only, which is what `en` says and what the product
|
|
1867
|
+
does (#3625).
|
|
1868
|
+
|
|
1869
|
+
This is the same stale sentence #3582 fixed one namespace over, but it hid
|
|
1870
|
+
behind a much better disguise. In #3582 the eight packs stored the **English**
|
|
1871
|
+
string, so two cheap criteria could see it: "value equals `en`" and "a
|
|
1872
|
+
non-Latin pack holds pure ASCII". Neither can see this key. Its eight values
|
|
1873
|
+
were **idiomatic translations** — real Japanese, real Cyrillic, real Arabic —
|
|
1874
|
+
of a sentence `en` itself had already abandoned. Nothing about their form was
|
|
1875
|
+
wrong; only their meaning was. Key sets were complete, so
|
|
1876
|
+
`all-locales-key-parity` was green; the key exists in `en`, so the call-site
|
|
1877
|
+
guard and its ratchet were green; the values are distinct and in their own
|
|
1878
|
+
scripts, so every heuristic #3582 sketched would have been green too. Eight
|
|
1879
|
+
locales spent those releases pointing users at a path the product no longer
|
|
1880
|
+
offers, with every gate reporting success.
|
|
1881
|
+
|
|
1882
|
+
Each value is translated against `en`'s **current** meaning and built from
|
|
1883
|
+
words the same pack already uses — "read-only" from its own `view.readOnly` /
|
|
1884
|
+
`view.readonlyAriaLabel`, "defined in code" from
|
|
1885
|
+
`console.objectView.systemViewReadonly` / `cannotEditMetaView` — so the tooltip
|
|
1886
|
+
agrees with the copy beside it instead of introducing a ninth way to say
|
|
1887
|
+
read-only. Nothing is rewritten from the stale text.
|
|
1888
|
+
|
|
1889
|
+
`en` and `zh` are unchanged, byte for byte, and no key is added or removed —
|
|
1890
|
+
the diff is eight values in eight files. A new
|
|
1891
|
+
`viewReadonlyTooltip-semantics-3625.test.ts` tests **meaning** rather than
|
|
1892
|
+
form, in both directions: no pack may name the duplicate/copy workflow in its
|
|
1893
|
+
own language, and every pack must positively carry all three pieces of the
|
|
1894
|
+
sentence ("system view", "defined in code", "read-only") so the negative check
|
|
1895
|
+
cannot pass on a gutted string. It also pins the `en` literal, so the next
|
|
1896
|
+
rewording of `en` fails in the PR that does the rewording rather than orphaning
|
|
1897
|
+
nine translations for another release — which is the invariant this family of
|
|
1898
|
+
defects has actually been missing.
|
|
1899
|
+
|
|
1900
|
+
- 32413ec: Resolve `_views` translation keys by the bare view name only — the prefixed full name is no longer a second candidate
|
|
1901
|
+
|
|
1902
|
+
`useObjectLabel().viewLabel` / `viewDescription` / `viewEmptyState` build their key by stripping the object prefix off the runtime view id (`crm_opportunity.pipeline_kanban` → `objects.crm_opportunity._views.pipeline_kanban.<tail>`). Until now, if that bare key missed, the resolver **also** tried the prefixed full name — `objects.crm_opportunity._views.crm_opportunity.pipeline_kanban.<tail>` — so a bundle authored against the prefixed spelling resolved too.
|
|
1903
|
+
|
|
1904
|
+
**Behavior change:** it no longer does. A `_views` entry keyed by the prefixed full name is not read at all; the label falls back to the metadata default, exactly as it would if no translation had been written. Bundles keyed by the bare view name — the only spelling the extractor emits and `os lint` accepts — are unaffected.
|
|
1905
|
+
|
|
1906
|
+
This closes an asymmetry, not a feature. The server-side resolver reads the one bare key (objectstack#5165), so a prefixed-key bundle produced a **translated label in the Console and English everywhere else**: the REST boundary, mobile, plain HTTP and SDUI consumers do not run this second resolution pass. The half-success was harder to notice than a clean miss, and it fossilized a second de-facto spelling of a key the platform has now converged on: per the objectstack#5164 ruling (2026-08-06, option A), the canonical `_views` key is the runtime view identity's bare name, with the i18n extractor deriving it from the view composer (objectstack#6124) and `packages/lint` enforcing that single spelling (objectstack#6038). This is the third and last leg of that convergence.
|
|
1907
|
+
|
|
1908
|
+
The object-name axis is untouched: a bundle written against the short object name (`objects.opportunity._views.…`) still resolves when the runtime presents the namespaced name (`crm__opportunity`).
|
|
1909
|
+
|
|
1910
|
+
**If a label stopped translating after this upgrade,** its `_views` key is written with the object prefix. Drop the prefix — `_views.crm_opportunity.pipeline_kanban.label` becomes `_views.pipeline_kanban.label`. `os lint` names these for you: a prefixed key is reported as `translation-target-unknown`, because no view of the object declares it.
|
|
1911
|
+
|
|
3
1912
|
## 17.3.0
|
|
4
1913
|
|
|
5
1914
|
### Minor Changes
|
|
@@ -249,6 +2158,7 @@ first`. The form renderer passes a new `dependsOnLabels` widget prop (the
|
|
|
249
2158
|
ten packs missing it identically kept parity fully green.
|
|
250
2159
|
|
|
251
2160
|
## What changed
|
|
2161
|
+
|
|
252
2162
|
- `createTargetOrg` is backfilled into `en` as `Creates in {{org}}`, which makes
|
|
253
2163
|
the parity gate demand it from the other nine; each is translated to its pack's
|
|
254
2164
|
existing `form`-section tone rather than copied or machine-filled.
|
|
@@ -711,7 +2621,7 @@ plan` sentence users reported (cloud#959). Both spec builders now take a transla
|
|
|
711
2621
|
|
|
712
2622
|
### Patch Changes
|
|
713
2623
|
|
|
714
|
-
- 752e18f: fix(console,app-shell): readable reassign hand-off + "System" label for svc
|
|
2624
|
+
- 752e18f: fix(console,app-shell): readable reassign hand-off + "System" label for svc:\* audit actors — objectstack#4365 / objectstack#4366
|
|
715
2625
|
|
|
716
2626
|
- **Approvals inbox** (`ApprovalsInboxPage`): a reassign timeline entry now
|
|
717
2627
|
renders "from A to B" from the structured
|
|
@@ -1423,6 +3333,7 @@ os_tianshun_ehr_position — use a unique value or the record id` — with the f
|
|
|
1423
3333
|
is byte-identical, it just comes from the pack now.
|
|
1424
3334
|
|
|
1425
3335
|
Adds two guards, both mutation-verified:
|
|
3336
|
+
|
|
1426
3337
|
- `en` ↔ `zh` full key parity, asserted in both directions. The other eight
|
|
1427
3338
|
packs are still ~357 keys behind and are tracked separately (objectui#2872
|
|
1428
3339
|
part a), so they are deliberately not asserted yet.
|
|
@@ -2357,6 +4268,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2357
4268
|
wired by `RecentItemsProvider` + `useTrackRouteAsRecent` +
|
|
2358
4269
|
`RecordDetailView`). Multi-device by construction: open a record on
|
|
2359
4270
|
laptop, see it in `⌘K → Recently viewed` on phone.
|
|
4271
|
+
|
|
2360
4272
|
- Group renders only when input is empty (no competition with search).
|
|
2361
4273
|
- Limited to the 5 most recent record-type entries.
|
|
2362
4274
|
- New i18n key `console.commandPalette.recentRecords` (en + zh seeded;
|
|
@@ -2366,6 +4278,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2366
4278
|
|
|
2367
4279
|
When `useRecordSearch` is mid-flight (debounced fetch across objects
|
|
2368
4280
|
hasn't returned yet), the palette now surfaces a subtle visual:
|
|
4281
|
+
|
|
2369
4282
|
- A small pulsing primary-coloured dot next to the **Records** group
|
|
2370
4283
|
heading, so the user sees that more results may still appear.
|
|
2371
4284
|
- A `Searching…` placeholder inside the empty state when the user has
|
|
@@ -2379,6 +4292,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2379
4292
|
- Stage chevron (`record:path`): bump completed-stage contrast (emerald-800 text on emerald-500/15, was 700 on /10) and future-stage text from `foreground/70` to `foreground/85` for legibility.
|
|
2380
4293
|
- i18n: add `notifications.emptyUnread`, `notifications.filterUnread`, `notifications.filterAll` (en + zh) so the InboxPopover Unread/All sub-filter renders in the active locale.
|
|
2381
4294
|
- 5425608: CRM UX polish pass — calmer enterprise look across detail + kanban.
|
|
4295
|
+
|
|
2382
4296
|
- **plugin-kanban**: column headers now use a 2px muted accent stripe with
|
|
2383
4297
|
neutral foreground titles + a quiet grey count pill instead of full
|
|
2384
4298
|
rainbow gradient + colored title + colored count. Pipeline boards
|
|
@@ -2410,6 +4324,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2410
4324
|
`defaultCurrency` is set on the field/column, formatting is unchanged.
|
|
2411
4325
|
|
|
2412
4326
|
Fixed call sites:
|
|
4327
|
+
|
|
2413
4328
|
- `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
|
|
2414
4329
|
`CurrencyCellRenderer` no longer default-param `'USD'`.
|
|
2415
4330
|
- `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
|
|
@@ -2433,6 +4348,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2433
4348
|
### Minor Changes
|
|
2434
4349
|
|
|
2435
4350
|
- 49b1760: Polish the ConcurrentUpdateDialog and add i18n.
|
|
4351
|
+
|
|
2436
4352
|
- Internationalise all dialog strings (title, body, button labels, "your edit" / "current value" headings, audit-trail line) through `useDetailTranslation`. Locale strings added to `@object-ui/i18n` for English and Chinese.
|
|
2437
4353
|
- Replace the plain dialog header with an amber warning badge + `AlertTriangle` icon to communicate that this is a conflict, not a routine confirmation.
|
|
2438
4354
|
- Visually differentiate the two value blocks: amber tint for the user's pending edit, sky tint for the server's current value. Both wrap long values cleanly.
|
|
@@ -2498,6 +4414,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2498
4414
|
default detail view. They were missing cross-cutting affordances and
|
|
2499
4415
|
shipped with English-only tab labels and heavy bordered section cards
|
|
2500
4416
|
even when the host locale was Chinese. Track 1 closes the visible gap:
|
|
4417
|
+
|
|
2501
4418
|
- **app-shell `RecordDetailView`**: the `assignedPage` branch now wears
|
|
2502
4419
|
the same chrome as the default branch — lifecycle managed-by badge
|
|
2503
4420
|
and presence avatars in the top-right, `MetadataPanel` debug panel,
|
|
@@ -2553,6 +4470,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2553
4470
|
The matrix/summary "Group by" (rows) and "Columns axis" (cols) sections now
|
|
2554
4471
|
share the same searchable popup picker as the columns section, with a
|
|
2555
4472
|
commit-on-select single-pick mode wired through `FieldPickerDialog`.
|
|
4473
|
+
|
|
2556
4474
|
- Per-row field buttons display the human-readable field label and open a
|
|
2557
4475
|
dialog scoped to swap that single field (already-used fields filtered out)
|
|
2558
4476
|
- "Add grouping" trigger uses the same dialog
|
|
@@ -2562,6 +4480,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2562
4480
|
- Bigger row spacing (h-7 / text-xs) — the old `text-[10px]` was unreadable
|
|
2563
4481
|
|
|
2564
4482
|
`FieldPickerDialog` gains:
|
|
4483
|
+
|
|
2565
4484
|
- `commitOnSelect`: hides the Confirm/Cancel footer; clicking a row commits
|
|
2566
4485
|
- closes immediately (intended for `singleSelect` flows)
|
|
2567
4486
|
- `trigger`: custom trigger element override (used by the per-row field button)
|
|
@@ -2590,6 +4509,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2590
4509
|
in the spec.
|
|
2591
4510
|
|
|
2592
4511
|
New exports from `@object-ui/plugin-report`:
|
|
4512
|
+
|
|
2593
4513
|
- `JoinedBlocksEditor` — standalone component for embedding the
|
|
2594
4514
|
block editor anywhere.
|
|
2595
4515
|
- `validateJoinedBlocks` — pure helper returning translated
|
|
@@ -2621,6 +4541,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2621
4541
|
The report configuration panel is now safe to open on any spec-shape `Report` and only exposes fields that are actually persisted by `@objectstack/spec`.
|
|
2622
4542
|
|
|
2623
4543
|
`@object-ui/plugin-report`:
|
|
4544
|
+
|
|
2624
4545
|
- Add a bidirectional `SpecFilterAdapter` so `ReportConfigPanel` can edit
|
|
2625
4546
|
spec `FilterCondition` filters (`{field: value}`, `{field: {$op: value}}`,
|
|
2626
4547
|
top-level `$and`/`$or`). Complex / nested filters fall back to a
|
|
@@ -2642,12 +4563,14 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2642
4563
|
- 18 new unit tests cover the filter adapter round-trip.
|
|
2643
4564
|
|
|
2644
4565
|
`@object-ui/components`:
|
|
4566
|
+
|
|
2645
4567
|
- `FilterBuilder` now guards against malformed external `value` props.
|
|
2646
4568
|
Previously a spec-shape filter (`{is_active: true}`) would crash the
|
|
2647
4569
|
component on first render; the builder now falls back to an empty
|
|
2648
4570
|
AND group whenever `value` is not a valid `FilterGroup`.
|
|
2649
4571
|
|
|
2650
4572
|
`@object-ui/i18n`:
|
|
4573
|
+
|
|
2651
4574
|
- Add `report.editor.*` strings to `en` and `zh`.
|
|
2652
4575
|
|
|
2653
4576
|
- 8442c05: Improve report editor panel usability based on real-user browser testing:
|
|
@@ -2727,6 +4650,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2727
4650
|
no visible effect — the user/session were nulled but the UI stayed authenticated.
|
|
2728
4651
|
|
|
2729
4652
|
Changes:
|
|
4653
|
+
|
|
2730
4654
|
- **`@object-ui/auth`** — added `isAuthEnabled: boolean` to `AuthContextValue`
|
|
2731
4655
|
(`true` only when real auth is in use, `false` for guest/preview modes).
|
|
2732
4656
|
- **`@object-ui/app-shell`** — `AppHeader` and `AppSidebar` now hide the "Log out"
|
|
@@ -2744,6 +4668,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2744
4668
|
- fd15918: Comprehensive i18n refactor + CI test fix.
|
|
2745
4669
|
|
|
2746
4670
|
**i18n (`@object-ui/i18n`)**
|
|
4671
|
+
|
|
2747
4672
|
- Added ~130 new keys under 12 new top-level namespaces: `layout`, `search`,
|
|
2748
4673
|
`empty`, `renderer`, `actionDialog`, `rowAction`, `navigationSync`,
|
|
2749
4674
|
`objectActions`, `objectViewActions`, `dashboardActions`, `recordDetail`,
|
|
@@ -2755,6 +4680,7 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2755
4680
|
|
|
2756
4681
|
**App shell (`@object-ui/app-shell`)** — replaced hardcoded English in 14
|
|
2757
4682
|
files with `useObjectTranslation`:
|
|
4683
|
+
|
|
2758
4684
|
- Layout: `AppSidebar`, `ActivityFeed` (locale-aware relative time),
|
|
2759
4685
|
`MetadataInspector`.
|
|
2760
4686
|
- Views: `SearchResultsPage`, `ActionParamDialog`, `RecordFormPage`,
|
|
@@ -2766,12 +4692,14 @@ actionName, spec)`: overlays translated `title` / `description` /
|
|
|
2766
4692
|
`useObjectActions` (delete confirm + success / failure toasts).
|
|
2767
4693
|
|
|
2768
4694
|
**Plugin grid (`@object-ui/plugin-grid`)**
|
|
4695
|
+
|
|
2769
4696
|
- `ObjectGrid` record-detail panel now translates Empty / Yes / No / System
|
|
2770
4697
|
via the existing `useGridTranslation` safe-fallback wrapper.
|
|
2771
4698
|
- `RowActionMenu` adopts a local safe-fallback i18n wrapper for
|
|
2772
4699
|
`Open menu` / `Edit` / `Delete`, preserving standalone-usage guarantees.
|
|
2773
4700
|
|
|
2774
4701
|
**CLI test fix (`@object-ui/cli`)**
|
|
4702
|
+
|
|
2775
4703
|
- `cli-bin.test.ts` auto-builds the package on first run when `dist/cli.js`
|
|
2776
4704
|
is missing, instead of throwing. This unbreaks `pnpm test:coverage` in CI
|
|
2777
4705
|
(root vitest run does not honor turbo's `^build` deps) and removes the
|