Skip to content

fix(core,plugin-dashboard,plugin-charts): run resolved select-option labels through the i18n bundle on analytics surfaces (#4030) - #4324

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4030-analytics-option-i18n
Aug 11, 2026
Merged

fix(core,plugin-dashboard,plugin-charts): run resolved select-option labels through the i18n bundle on analytics surfaces (#4030)#4324
yinlianghui merged 1 commit into
mainfrom
claude/issue-4030-analytics-option-i18n

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #4030 — see "What this does NOT close" at the bottom for the half that is still open.

Source thread: objectstack-ai/objectstack#5076 (read-only; its triage comment is the ruling of record).

The seam I measured

There is one existing option-label translation channel, and analytics now reuses it rather than growing a chart-side per-locale dialect:

useObjectLabel().fieldOptionLabel(object, field, value, fallback){ns}.fieldOptions.< object >.< field >.< value >

  • packages/i18n/src/useObjectLabel.ts owns it (optionSuffixesfieldOptionLabel / translateOptions), reached from plugins through the provider-safe useSafeFieldLabel.
  • @objectstack/spec names objectui as that convention's reader by name — "objectui reads exactly those (as fieldOptions.< obj >.< fld >.< value >) off the full-bundle route" (ResolvedFieldLabel, dist/system/index.d.ts); the spacing inside the key is this body's, added so GitHub's tag stripper leaves the segments intact.
  • It is what list/form surfaces already use: DetailSection and ObjectKanban call translateOptions(...) and hand the localized options to SelectCellRenderer, which reads option.label and knows nothing about i18n. objectui#3336 pinned that same "one source, two faces" property for the record picker.
  • plugin-charts' legacy aggregate path was already on it (resolveGroupByLabels's translateOption argument). Its signature (value, authoredLabel) => display is reused verbatim as the new OptionLabelTranslator, so there is one shape for this seam in the repo, not two.

The application point

At the output of the analytics label net (#4053/PR #4261, #4263/PR #4273), on the shared option list every consumer reads — never per surface:

consumer how it picks the translation up
chart axis + legend / series (DatasetWidget, ObjectChart dataset path) relabelDimensions(rows, dimensionLabels)
table / pivot cells for a dotted dimension the same dimensionLabels, via displayRows
that table's CSV export rides displayRows (the objectui#4263 convention)
per-category colours buildOptionColorMap(localizeFieldOptions(...))
declared category order (funnel/pyramid) buildCategoryOrder(localizeFieldOptions(...))
pivot totals, bucket ids, drill filters untouched — identity keys, see below

packages/core/src/utils/chart-series.ts gains three things:

  • localizeFieldOptions(options, translate?) — the pure mirror of translateOptions. Colours, values and every other option key survive; only label changes, and the input array is returned by identity when nothing translated.
  • buildDimensionLabelMap(options, translate?) — optional translator. With one it emits a second key per option: the authored English label.
  • resolveDimensionFieldMeta(...) — the same single relationship walk resolveDimensionFieldOptions already performs, now keeping { object, field, options }. resolveDimensionFieldOptions becomes a thin wrapper over it, byte-identical contract. This exists because the bundle key names the object that owns the terminal field: for crm_account.industry that is crm_account, not the dataset's base object, and the walk already knew it and dropped it.

Both plugins now keep the fetched metadata locale-free in state and derive colours + label maps in a render memo, so switching language re-labels in place instead of waiting for a refetch.

Why the map carries two keys

The rows reach this net keyed either way — by stored value when the server did not resolve the dimension (the reason the net exists at all), by the English label when it did (ADR-0021). The reported screen is the second case: the chart read Orion Engineered Carbons, the object's authored label byte for byte. A value-keyed map alone cannot touch those rows, so a fix that only handled the un-resolved case would have left the issue open. One key resolves orion, the other re-translates Orion Engineered Carbons; relabelDimensions is value-wise and idempotent, so whichever the row carries lands on the same display.

Contract stop-condition: NOT triggered

The card's ruling was to stop and report needs_decision if closing the loop required adding a select type to spec DatasetDimension. It does not. The runtime knows a dimension's field is a select without that vocabulary: resolveDimensionFieldMeta loads the owning object's metadata doc through the caller's existing GET /meta/object/:name channel and reads the terminal field's options straight off it — the same read #4261 already performs. DatasetDimension.type is never consulted, so the card's "no authoring knob exists" concern is real at authoring time and moot at runtime. No spec change, no objectstack change.

Tests

New: packages/core/src/utils/__tests__/chart-series.i18n.test.ts (17 pure pins) and packages/plugin-dashboard/src/__tests__/DatasetWidget.optionLabelI18n.test.tsx (8 surface pins), both on the card's exact fixture — option orion, object label Orion Engineered Carbons, zh-CN bundle 欧励隆.

Four of the surface pins are boundaries, green in both directions, and they are deliberately not sequenced on the translated string so they survive the reverse verification:

  • an option with no bundle entry keeps its authored label (the card's "downstream impact" is precisely about not trading that away);
  • an en console renders exactly what it renders today, with the same bundle mounted;
  • a clicked segment drills by the stored value whatever it displays — the click is made with categories()[0], so it holds either way;
  • objectui#4263's own boundary restated under a mounted bundle: a local-only table still resolves nothing and fetches nothing.

Reverse verification — direction predicted first

Prediction, written into both test headers before running: removing the seam at the two consumer call sites (core helpers left in place) turns the three zh-CN chart pins and the dotted-table pin red, and leaves the four boundaries plus every pre-existing pin green. Taken out with a temp commit + git checkout, never git stash.

Measured, exactly that — 4 red, 32 green:

 × renders the zh-CN option label on a VALUE-keyed chart
 × re-translates a chart the SERVER already resolved to the English label
 × keeps the measure attached to its (now translated) category
 × renders the zh-CN label for a DOTTED dimension, keyed by the RELATIONSHIP TARGET
 Tests  4 failed | 32 passed (36)

AssertionError: expected [ 'Orion Engineered Carbons', ...(2) ] to include '欧励隆'

That assertion text is the card's screenshot in one line.

Restored, then the full affected suites:

pnpm exec vitest run packages/core/ packages/plugin-dashboard/ packages/plugin-charts/
 Test Files  140 passed (140)
      Tests  2197 passed (2197)

Every pin from #4261 / #4273 / #4263 / #4298 / cloud#667 / funnelOrder is green untouched — no existing test file was edited.

Also green: turbo run type-check for the three packages, eslint on the touched files (0 errors), check-i18n-call-site-keys, check-i18n-en-drift, check-control-bytes, check-changeset-presence.

What this does NOT close

Both are measured, not assumed:

  1. A LOCAL select dimension on a table/pivot. The server resolves that label (ADR-0021) and objectui#4263 deliberately keeps the client net off there — its pin asserts a local-only table issues no metadata read at all. Translating it needs the option list, i.e. that read. Reaching it means re-opening a landed acceptance boundary, which is a ruling, not an implementation detail. The dataset report block is the same shape: DatasetReportRenderer renders server-resolved rows and never loads the object schema.

  2. The dashboard global filter's field label ("Sales Channel:"). The one-channel rule cannot be satisfied here today, and the blocker is contract, not code: GlobalFilterSchema declares no object, DashboardSchema declares none either, so fields.< object >.< field > has no key to build; and the spec's TranslationData.dashboards.< name > node is strict with only label / description / actions / widgets, so a filters slot cannot be authored without new vocabulary. Deriving an object from the bound widgets is available only for provider: 'object' widgets (a dataset widget's object arrives with the query response), so it would be a guess on exactly the dashboards the report is about. This is the sibling of the stop-condition and is written up as an open question for the maintainer rather than guessed at.

Separately, two compiler-blind I18nLabel reads on that same filter bar were found and reported onto #4163 (whose Part 1 is precisely that audit) rather than opened as a twin issue: #4163 (comment)


Generated by Claude Code

…labels through the i18n bundle on analytics surfaces (#4030)

The analytics label net resolved a select dimension's option label and then
displayed the object's authored English label, so a chart legend read
`Orion Engineered Carbons` while the related list on the same page read 欧励隆.

Applies the ONE existing channel — `fieldOptionLabel`
(`{ns}.fieldOptions.<object>.<field>.<value>`, what list/form/kanban/record
picker surfaces already translate options through) — at the net's output, on
the shared option list every consumer reads: chart axis/legend, dotted
table/pivot cells, that table's CSV, per-category colours and category order.

- core: `localizeFieldOptions` (pure mirror of `translateOptions`), an optional
  translator on `buildDimensionLabelMap` (which also keys the authored English
  label, so a server-resolved row re-translates), and `resolveDimensionFieldMeta`
  — the same single relationship walk, keeping the object that OWNS the terminal
  field because that is what the bundle key names.
- plugin-dashboard / plugin-charts: keep the fetched metadata locale-free and
  derive colours + label maps during render, so a language switch re-labels in
  place instead of waiting for a refetch.

Identity is untouched: a segment clicked as 欧励隆 still drills by `orion`,
and an untranslated option (or an `en` console) renders exactly as today.

Refs objectstack#5076
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 11, 2026 3:01pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 29.5 KB 350 KB
Entry file index-DYTQabsj.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.10KB 108.43KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 150.04KB 39.79KB
fields (index.js) 228.45KB 56.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 16.38KB 5.47KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.18KB 17.67KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 121.58KB 31.58KB
plugin-designer (index.js) 210.91KB 42.67KB
plugin-detail (index.js) 238.95KB 59.76KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.00KB 49.94KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 109.93KB 26.65KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.60KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 15:17
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 5900ac5 Aug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4030-analytics-option-i18n branch August 11, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants