Skip to content

fix(plugin-dashboard,i18n): the widget config panel reads AND writes an inline-locale-map label (#5301) - #5427

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5301-widget-config-panel-locale-map
Aug 20, 2026
Merged

fix(plugin-dashboard,i18n): the widget config panel reads AND writes an inline-locale-map label (#5301)#5427
os-support-ai merged 1 commit into
mainfrom
claude/issue-5301-widget-config-panel-locale-map

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5301

WidgetConfigPanel carried a private resolveLabel documented as resolving an
I18nLabel while reading defaultValue || key — the key-reference form
@objectstack/spec retired at 17.0.0-rc.6 (objectstack#5055). The inline
per-locale map I18nLabelSchema actually admits has neither limb, so
{ en: 'Revenue', zh: '收入' } resolved to ''. Fourth private copy of that
resolver; objectui#4032 swept the other three out of DashboardRenderer,
MetricWidget and MetricCard.

Not a display bug: the resolved value seeds the editable draft, so a map-titled
widget opened with an empty Title field and the next save wrote '' over the
author's map — on the ordinary path (open widget, change anything, save).

The measurement the dispatch asked for FIRST

Triage flagged an unchecked assumption: nobody had verified what the dashboard
save path persists for other object-valued config keys. Measured — the panel
already round-trips untouched keys; it does not rebuild the config from the
draft.
No diff mechanism is needed and the card is the small shape. The chain,
end to end:

  1. useConfigDraft(source) seeds useState({ ...source }) — a shallow copy of
    the whole config object, not a projection over the schema's field list.
  2. updateField returns { ...prev, [field]: value } — only the named key moves.
  3. sanitizeDraftForType(draft) spreads the draft and deletes a fixed deny-list
    (legacy analytics keys, retired action keys, dimensions for metric-likes).
  4. DashboardWithConfig.handleWidgetSave forwards that object verbatim.

Object-valued keys therefore already survive — dimensions / values are arrays
carried across untouched, pinned by the existing sanitizeDraftForType tests.
The normalization of title / description was the single lossy step in the
path, which is exactly where the fix lands.

The fix (maintainer ruling, 2026-08-20)

  • ReadingpickLocalized(value, language), matching every sibling surface
    post-objectui#4032.
  • Writing — a save replaces only the active locale's entry. An untouched
    label round-trips the stored object itself (asserted with toBe, not
    toEqual: an equal-but-rebuilt object is a weaker guarantee, since a rebuild
    would add an entry for the active locale to a map that never carried one). An
    edited label merges into the entry that was displayed.
  • onFieldChange too. The live-update callback is the panel's other way out
    and a data-loss path of its own: DashboardWithConfig writes it into
    liveSchema, which is where the next config prop is derived from. Forwarding
    the bare editor string dropped the map before a save ever ran — open, type,
    close without saving, re-open, and the other locales were already gone.
  • A full multi-locale editor stays out of scope (objectui#4163).

@object-ui/i18n gains setLocalized(value, language, next), the write-side
inverse of pickLocalized, co-located in the same file because the entry
written must be the entry read. It follows pickLocalized's first three limbs —
exact tag, base language, region-qualified sibling — and deliberately stops
there: default / en / first-value are DISPLAY fallbacks handing back another
locale's string, so writing to one would let an author editing in fr overwrite
English. With no entry for the active locale the edit adds one. The pairing
pickLocalized(setLocalized(map, lang, s), lang) === s is pinned as a property
over 11 cases.

language is a dependency of the normalization memo and has to be: it is the
locale the draft was seeded from, and the write-back compares the draft against
pickLocalized(stored, language) to tell untouched from edited. Omitting it
would let a mid-session language switch compare an old-locale draft against the
new locale — every untouched label reading as edited, merged into the wrong entry.

Verification (tree 369e6a073)

Run from the repo root. Vitest resolves @object-ui/i18n to packages/i18n/src
via the root config's alias, so no build artifact sits between the edit and the
thing under test
on any test leg. (The type-check leg is different — tsc has
no paths entry for @object-ui/i18n and resolves it to dist, so the
dependency closure was built first.)

legresult
vitest run packages/plugin-dashboard packages/i18n115 files, 1496 passed
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n type-checkDone, both echoed
pnpm --filter ... lint0 errors (329 pre-existing any / fast-refresh warnings)
check:control-bytesOK, 4485 files
check:self-import0 self-imports
check:phantom-depsevery in-scope import declared
check:i18n-keys / check:i18n-drift2904 keys resolve; 0 en values changed
check-changeset-{presence,no-major,fixed}all OK

New coverage: 29 cases in packages/i18n/src/__tests__/setLocalized.test.ts,
21 in packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.inlineLocaleMap.test.tsx.

Reverse verification — two legs, predicted before running

Leg A — read half reverted to the retired-form resolver. Predicted 6 red
(3 display cases, 2 untouched-round-trip cases, the de case's display
assertion). Observed 7. The extra one is honest and worth recording: "writes
an emptied field into the active locale"
types '' into a field the ablated
read had already rendered as '', so React dispatches no change event, the panel
never goes dirty, and the Save button — which only renders when dirty — is never
found. It fails for a mechanical reason rather than the assertion, which is why
the prediction missed it. Assertion messages on the other six were the defect
verbatim: expected '' to be '收入', and
expected { en: 'Revenue', zh: '' } to be { en: 'Revenue', zh: '收入' } — the
draft seeded empty, then merged back as an empty entry.

Leg B — write-back removed from the save path, read intact. Predicted 6 red,
every save-payload case, with display and onFieldChange staying green.
Observed exactly 6, messages showing the data-loss shape directly:
expected '收入' to be { en: 'Revenue', zh: '收入' },
expected 'Umsatz' to deeply equal { en: 'Revenue', zh: '收入', …(1) }.

Both legs were restored with git checkout HEAD -- ... from the committed fix
and the marker's absence verified before re-running green.


Generated by Claude Code

…an inline-locale-map label (#5301)
`WidgetConfigPanel` carried a private `resolveLabel` reading `defaultValue ||
key` — the key-reference form `@objectstack/spec` retired at 17.0.0-rc.6
(objectstack#5055). The inline per-locale map `I18nLabelSchema` admits has
neither limb, so it resolved to `''`. That value seeds the editable draft, so a
map-titled widget opened with an empty Title field and the next save wrote `''`
over the author's map.
Reading now goes through `pickLocalized(value, language)`. Writing replaces only
the active locale's entry: an untouched label round-trips the stored object
itself, an edited one merges into the entry that was displayed, and
`onFieldChange` forwards the merged map for the same reason.
`@object-ui/i18n` gains `setLocalized`, the write-side inverse of
`pickLocalized`, co-located with it because the entry written must be the entry
read — pinned as a property in `setLocalized.test.ts`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-ZU2B-gjV.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.94KB113.61KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.07KB59.46KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.43KB32.92KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.30KB53.06KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-support-ai
os-support-ai marked this pull request as ready for review August 20, 2026 17:05
@os-support-ai
os-support-ai added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit fb96ecbAug 20, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5301-widget-config-panel-locale-map branch August 20, 2026 17:06
os-sales pushed a commit that referenced this pull request Aug 21, 2026
…-valued widget titles (#5428)
Both dashboard authoring surfaces guarded their widget-title input by making a
map-valued title read-only, justified by "nothing can reach this path from
stored metadata yet - I18nLabel was plain string through rc.5". The pinned
@objectstack/spec is 17.0.0, whose I18nLabelSchema admits the map, and the same
file family documented that widening sixty lines away. The branch was denying
authors an edit rather than protecting an unreachable path, and its deferral
pointed at objectui#4163, closed as completed on 2026-08-15.
Adopts @object-ui/i18n's setLocalized (objectui#5301's ruling, shipped by
PR #5427) at both surfaces: a save replaces only the active locale's entry and
carries every other locale across untouched. Plain-string titles are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-support-ai@claude