From 69335280e337aaea3eec554b508787a6a22f787a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 15:28:15 +0000 Subject: [PATCH] plugin-designer/app-shell: adopt the active-locale write-back for map-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 Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK --- .../dashboard-title-locale-writeback-5428.md | 46 +++++++ .../DashboardWidgetInspector.test.tsx | 115 ++++++++++++++++++ .../inspectors/DashboardWidgetInspector.tsx | 62 +++++++--- .../plugin-designer/src/DashboardEditor.tsx | 106 ++++++++++------ .../DashboardEditor.i18nTitle.test.tsx | 88 +++++++++++--- 5 files changed, 346 insertions(+), 71 deletions(-) create mode 100644 .changeset/dashboard-title-locale-writeback-5428.md diff --git a/.changeset/dashboard-title-locale-writeback-5428.md b/.changeset/dashboard-title-locale-writeback-5428.md new file mode 100644 index 0000000000..849b85b703 --- /dev/null +++ b/.changeset/dashboard-title-locale-writeback-5428.md @@ -0,0 +1,46 @@ +--- +'@object-ui/plugin-designer': patch +'@object-ui/app-shell': patch +--- + +A widget title stored as an inline per-locale map is editable again in both dashboard +authoring surfaces, and a save writes back only the active locale's entry +(objectui#5428). + +`@objectstack/spec` widened `I18nLabel` from `string` to `string | Record` at +17.0.0-rc.6, so a stored widget title may be an inline per-locale map while both +authoring panels edit a title in ONE single-line input. Writing the input's value back +as the whole value would collapse every other locale on the first keystroke, so both +surfaces took the same conservative branch: show a map-valued title resolved, and make +it READ-ONLY. + +That branch could not lose data, but it rested on a premise the spec had already +invalidated — "nothing can reach this path from stored metadata yet, `I18nLabel` was +plain `string` through rc.5" — stated sixty lines below a comment in the same file +documenting the rc.6 widening that makes a stored map reachable. Both could not hold. +The pinned spec is 17.0.0. What the read-only branch did in practice from rc.6 onward +was not protect an unreachable path: it denied an author the ability to edit a widget +title in their own locale. + +objectui#5301's maintainer ruling settled the write rule for the sibling surface — a +save replaces only the active locale's entry and preserves the others — and +`@object-ui/i18n` ships it as `setLocalized`, co-located with `pickLocalized` because +the read and the write have to agree. Both panels now adopt it: + +- `@object-ui/plugin-designer`'s `DashboardEditor` widget property panel; +- `@object-ui/app-shell`'s `DashboardWidgetInspector` in metadata-admin. + +A plain-string title keeps saving as a plain string, so the common path is unchanged. +An edit made in a locale the stored map does not carry ADDS an entry under that locale +rather than overwriting the entry the display fell back to. + +The pins are preservation pins, not "the input is editable" pins: at both surfaces a +keystroke on a map-valued title must leave every other locale's entry byte-identical. +Reverse-verified by mutating each write back to the flattening form and confirming those +assertions go red at both surfaces. + +Not a multi-locale editor: an author still reaches only the entry for the locale they +are in. Authoring every locale from one panel remains an open product question. The +stale deferrals both comments carried pointed at objectui#4163, which closed as +completed on 2026-08-15 while the placeholders were still in the tree; they are replaced +with the rule that is actually in force rather than re-pointed at another tracker. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx index 265e64964b..7c799d5538 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx @@ -274,3 +274,118 @@ describe('DashboardWidgetInspector — dashboard filter bindings (framework#2501 }); }); }); + +/** + * The Title field's `I18nLabel` write-back (objectui#5428, under objectui#5301's + * maintainer ruling of 2026-08-20). + * + * `@objectstack/spec` widened `I18nLabel` to `string | Record< string, string >` + * at 17.0.0-rc.6, and this inspector edits a widget title in ONE single-line + * input. Writing `e.target.value` back as the whole value collapses every other + * locale on the first keystroke, so PR #4169 made a map-valued title read-only + * on the premise that "nothing in any corpus can hit this path yet". The pinned + * spec is 17.0.0; the premise is expired, and what the branch actually did was + * deny an author an edit in their own locale. + * + * The replacement rule: a save replaces ONLY the active locale's entry and + * carries every other locale across untouched (`setLocalized`). + * + * ⚠️ The load-bearing assertions here are the PRESERVATION ones. "The input is + * no longer read-only" is equally green against a fix that flattens the map — + * which is precisely the data loss the read-only branch was protecting against + * — so every case below asserts the shape of what was written, not merely that + * something was. + * + * ⚠️ Not a multi-locale editor: the author reaches only the entry for the + * locale they are in. `locale` is a prop here, which is why the active-locale + * half of the rule is pinned at THIS surface (the designer's sibling panel + * takes its language from the i18n provider). + */ +describe('DashboardWidgetInspector — map-valued title write-back (#5428)', () => { + const MAP_TITLE = { en: 'Pipeline', 'zh-CN': '销售漏斗' } as const; + + /** The `title` of the single patched widget from the last `onPatch` call. */ + function patchedTitle(onPatch: ReturnType): unknown { + const calls = onPatch.mock.calls; + const last = calls[calls.length - 1][0] as { widgets: Array<{ title?: unknown }> }; + return last.widgets[0].title; + } + + /** + * The title input, by id rather than by label text: these cases vary the + * ACTIVE LOCALE, and the field's label is itself translated ('Title' / + * '标题'). The label association is asserted once, in English, below. + */ + function titleInput(): HTMLInputElement { + return document.getElementById('widget-title') as HTMLInputElement; + } + + function typeTitle(value: string, extra: Record, props: Record = {}) { + const onPatch = vi.fn(); + renderWidget(extra, { ...props, onPatch }); + fireEvent.change(titleInput(), { target: { value } }); + return onPatch; + } + + it('shows a map-valued title resolved, and EDITABLE', () => { + // Necessary, NOT sufficient — see the preservation pins below. + renderWidget({ title: MAP_TITLE }); + const input = screen.getByLabelText('Title') as HTMLInputElement; + expect(input.value).toBe('Pipeline'); + expect(input.readOnly).toBe(false); + }); + + it('⛔ writes ONLY the active locale entry — every other locale survives byte-identical', () => { + const onPatch = typeTitle('Pipelinex', { title: MAP_TITLE }); + const title = patchedTitle(onPatch); + // Still a map. The flattening write emits the bare string 'Pipelinex'. + expect(typeof title).toBe('object'); + const map = title as Record; + expect(map.en).toBe('Pipelinex'); + // The locale the author never saw, character for character. + expect(map['zh-CN']).toBe('销售漏斗'); + expect(map['zh-CN']).toBe(MAP_TITLE['zh-CN']); + expect(Object.keys(map).sort()).toEqual(['en', 'zh-CN']); + // The stored object is not mutated in place. + expect(MAP_TITLE).toEqual({ en: 'Pipeline', 'zh-CN': '销售漏斗' }); + }); + + it('⛔ the ACTIVE locale is the one written — editing under zh-CN leaves `en` alone', () => { + // Non-vacuity for the pin above, which would also pass a fix hard-wired to + // `en`: same fixture, different active locale, opposite entry edited. + const onPatch = typeTitle('销售管道', { title: MAP_TITLE }, { locale: 'zh-CN' }); + const map = patchedTitle(onPatch) as Record; + expect(map['zh-CN']).toBe('销售管道'); + expect(map.en).toBe('Pipeline'); + expect(Object.keys(map).sort()).toEqual(['en', 'zh-CN']); + }); + + it('a locale the map does not carry ADDS an entry rather than overwriting the displayed one', () => { + // The author sees English (the display fallback) while editing in French. + // Writing what they see back into `en` would overwrite a locale they never + // opened — so the write key stops at the first three resolution limbs. + const onPatch = typeTitle('Pipeline commercial', { title: MAP_TITLE }, { locale: 'fr' }); + const map = patchedTitle(onPatch) as Record; + expect(map.fr).toBe('Pipeline commercial'); + expect(map.en).toBe('Pipeline'); + expect(map['zh-CN']).toBe('销售漏斗'); + expect(Object.keys(map).sort()).toEqual(['en', 'fr', 'zh-CN']); + }); + + it('a plain-string title still saves as a plain string — the common path is unchanged', () => { + // Non-vacuity in the other direction: a fix that wrapped every edit into a + // map would satisfy every assertion above and fail here, changing the + // stored shape of titles that were never localized. + const onPatch = typeTitle('Revenue (net)', {}); + expect(patchedTitle(onPatch)).toBe('Revenue (net)'); + }); + + it('an unrelated edit leaves a map-valued title byte-identical', () => { + // The round trip the ruling requires: touching another field must not + // rewrite the title at all — not even into an equal-but-rebuilt object. + const onPatch = vi.fn(); + renderWidget({ title: MAP_TITLE, dataset: 'sales_pipeline' }, { onPatch }); + fireEvent.change(screen.getByLabelText('Height'), { target: { value: '4' } }); + expect(patchedTitle(onPatch)).toBe(MAP_TITLE); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx index f8d439080b..0689a37544 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx @@ -36,6 +36,8 @@ import { t, tFormat } from '../i18n.js'; // The spec's `I18nLabel` resolver (new in @objectstack/spec 17.0.0-rc.6), // aliased apart from objectui's same-named translation-KEY resolver. import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; +// The WRITE-side twin of that resolver (objectui#5301) — see the Title field. +import { setLocalized } from '@object-ui/i18n'; import { InspectorCheckboxField, InspectorReorderButtons, moveArray } from './_shared.js'; import { InspectorComboField, type InspectorComboOption } from './InspectorComboField.js'; import { DatasetNamesEditor } from './ReportDefaultInspector.js'; @@ -231,29 +233,53 @@ export function DashboardWidgetInspector({ {/* The ONE authoring — not display — read of `widget.title`, and the only - site in this change where following the rc.6 widening mechanically - would have destroyed data. `I18nLabel` now admits an inline per-locale - map, and this is a single-line text input: resolving the map into it - and writing `e.target.value` straight back would silently collapse - every other locale the author wrote, on the first keystroke. So the - input stays the plain-string editor it has always been, and a - map-valued title is shown resolved and READ-ONLY instead of being - flattened. Nothing in any corpus can hit this path yet — `I18nLabel` - was plain `string` through 17.0.0-rc.5, so no stored widget title can - be a map — which is exactly why the conservative branch is safe to - take now and why authoring the map form is follow-up work - (objectui#4163, part 2) rather than a guess made here. */} + site in this inspector where following the `I18nLabel` widening + mechanically would destroy data. `I18nLabel` admits an inline + per-locale map and this is a single-line text input, so the read and + the write are two different rules: + + READ the spec's `resolveI18nLabel` — the producer's own resolution + order, which objectui#4163's ruling requires this package to + call rather than hand-roll. + WRITE `setLocalized` from `@object-ui/i18n` — replace ONLY the + active locale's entry, carry every other locale across + untouched (objectui#5301's maintainer ruling, 2026-08-20). + + Pairing a spec-side read with an objectui-side write is safe because + the two resolvers are held limb for limb by + `@object-ui/plugin-list`'s `src/__tests__/i18nLabel-resolver-parity.test.ts` + (they differ only in how each spells a MISS, and `setLocalized` always + produces a hit for the locale it wrote), and `setLocalized`'s write key + follows the first three of those limbs exactly. Pinned locally by the + round-trip assertions in `DashboardWidgetInspector.test.tsx` so the + transfer is checked here, not merely cited. + + This input used to be READ-ONLY for a map-valued title, justified by + "nothing in any corpus can hit this path yet — `I18nLabel` was plain + `string` through 17.0.0-rc.5". `@objectstack/spec` is pinned at 17.0.0, + so a stored map is reachable and that justification has expired; what + the branch did in practice was deny an author the ability to edit a + title in their own locale. Authoring EVERY locale from one panel is a + different, still-open product question — deliberately not deferred to + a tracker here, because the deferral this replaced named objectui#4163 + part 2 and #4163 closed as completed on 2026-08-15 with the + placeholder still in the tree. */} + patchWidget({ + // Never `{ title: e.target.value }` — that is the flattening + // write. The cast states the contract `setLocalized` widens for + // its own callers: it carries non-string entries across untouched + // so its map limb is `Record`, while a stored + // title that parses as `I18nLabel` has string entries only and the + // entry written here is `e.target.value`. + title: setLocalized(widget.title, locale, e.target.value) as DashboardWidgetSchema['title'], + }) } - onChange={(e) => patchWidget({ title: e.target.value })} disabled={readOnly} - readOnly={widget.title != null && typeof widget.title !== 'string'} /> diff --git a/packages/plugin-designer/src/DashboardEditor.tsx b/packages/plugin-designer/src/DashboardEditor.tsx index 44602731b0..433ba3a80f 100644 --- a/packages/plugin-designer/src/DashboardEditor.tsx +++ b/packages/plugin-designer/src/DashboardEditor.tsx @@ -46,7 +46,7 @@ import { } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { pickLocalized } from '@object-ui/i18n'; +import { pickLocalized, setLocalized } from '@object-ui/i18n'; import { useUndoRedo } from './hooks/useUndoRedo'; import { useDesignerTranslation } from './hooks/useDesignerTranslation'; @@ -118,8 +118,10 @@ function createWidgetId(): string { * of a dashboard and the runtime dashboard itself cannot start disagreeing * about which locale entry wins. * - * ⛔ This is for DISPLAY only. The title INPUT must not resolve through here — - * see `isAuthorableTitle`. + * This is the DISPLAY half of the title rule. The authoring INPUT resolves + * through here too — it can only show one locale — but it must never write + * back what it shows as the whole value; see `writeWidgetTitle` for the WRITE + * half, and note that the two have to stay paired. */ function resolveWidgetTitle( title: DashboardWidgetSchema['title'], @@ -129,30 +131,57 @@ function resolveWidgetTitle( } /** - * Is this widget title editable in a single-line text input? + * Write an edited title string back into `DashboardWidget.title` — the WRITE + * half of the rule whose DISPLAY half is `resolveWidgetTitle`. * - * The conservative branch PR #4169 took on `DashboardWidgetInspector`, applied - * to the other authoring surface, and the reason is data loss rather than - * types: resolving a per-locale map into one `` and writing - * `e.target.value` straight back would collapse **every other locale** on the - * first keystroke. An author who opened a dashboard to move a widget and - * happened to focus the title field would silently destroy the translations. + * A single-line input holds one locale's string, so writing `e.target.value` + * back as the whole value collapses **every other locale** on the first + * keystroke: an author who opened a dashboard to move a widget and happened to + * focus the title field would silently destroy the translations. PR #4169 met + * that on `DashboardWidgetInspector` by showing a map-valued title resolved and + * **read-only**, and this file copied the branch. * - * So a map-valued title is shown resolved and **read-only**, and the stored map - * passes through an edit-and-save round trip untouched. Nothing can reach this - * path from stored metadata yet — `I18nLabel` was plain `string` through - * rc.5, so no persisted widget title can be a map — which is why the branch is - * safe to take without a ruling on the authoring UX. + * That branch could not lose data, but its stated justification has expired. + * It read "nothing can reach this path from stored metadata yet — `I18nLabel` + * was plain `string` through rc.5" while `resolveWidgetTitle` sixty lines above + * documented the widening that makes a stored map reachable; `@objectstack/spec` + * is pinned at 17.0.0, whose `I18nLabelSchema` is + * `string | Record`. Both could not hold. What the read-only + * branch actually did from rc.6 onward was deny an author the ability to edit a + * title in their own locale. * - * The real answer (a per-locale editor, a "translate this label" affordance, or - * a deliberate decision that Studio only ever authors the string form) is - * objectui#4163 **part 2**, which is unclaimed and pending design. This is a - * placeholder that cannot lose data, not that answer. + * objectui#5301's maintainer ruling (2026-08-20) settled the write rule for the + * sibling surface — a save replaces only the active locale's entry and + * preserves the others — and `@object-ui/i18n` ships it as `setLocalized`, + * co-located with `pickLocalized` because the two must agree. Their pairing + * (`pickLocalized(setLocalized(map, lang, s), lang) === s`) is pinned in + * `@object-ui/i18n`'s `src/__tests__/setLocalized.test.ts`: an edit always + * lands in the entry this panel displays, never in one it does not. The write + * key follows only the first three resolution limbs (exact tag, base language, + * region-qualified sibling) and stops — `default` / `en` / first-value are + * DISPLAY fallbacks that hand back another locale's string, so an author + * editing in `fr` against an English-only map ADDS `fr` instead of overwriting + * `en`. + * + * ⚠️ Scope: this is the minimal non-destructive write for a single-locale + * editor, not a multi-locale authoring UI — an author reaches only the entry + * for the locale they are in. Authoring every locale from one panel remains an + * open product question and is deliberately NOT filed against a tracker here: + * the deferral this replaced named objectui#4163 part 2, #4163 closed as + * completed on 2026-08-15 with the placeholder still in the tree, and a comment + * pointing at a closed card is how the stale premise above survived a year. */ -function isAuthorableTitle( +function writeWidgetTitle( title: DashboardWidgetSchema['title'], -): title is string | undefined { - return title == null || typeof title === 'string'; + language: string | undefined, + next: string, +): DashboardWidgetSchema['title'] { + // `setLocalized` types its map result `Record` because it + // carries non-string entries across untouched rather than dropping them. A + // stored title that parses as `I18nLabel` has string entries only, and the + // one entry written here is `next` — so the cast states that contract at the + // boundary rather than widening what this function returns. + return setLocalized(title, language, next) as DashboardWidgetSchema['title']; } // ============================================================================ @@ -266,8 +295,8 @@ function WidgetPropertyPanel({ onClose, }: WidgetPropertyPanelProps) { const { t, language } = useDesignerTranslation(); - // Shown in the title input when the stored title is a map the input cannot - // safely author — resolved for reading, never written back. + // What the title input SHOWS: the active locale's entry. What a keystroke + // WRITES is `writeWidgetTitle` — never this resolved string as a whole value. const titleDisplay = resolveWidgetTitle(widget.title, language); return (
{/* Title — the ONE authoring (not display) read of `widget.title`, and the - only place where following rc.6's widening mechanically would destroy - data. A map-valued title is shown resolved and READ-ONLY so the other - locales survive; see `isAuthorableTitle` for why, and objectui#4163 - part 2 for the authoring design this is standing in for. */} + only place where following the `I18nLabel` widening mechanically would + destroy data. A stored title may be an inline per-locale map and this + is a single-line input, so the read and the write are two different + rules and both come from `@object-ui/i18n`: + + READ `pickLocalized` (via `resolveWidgetTitle`) — show the active + locale's entry. + WRITE `setLocalized` (via `writeWidgetTitle`) — replace ONLY that + entry; every other locale is carried across untouched. + + The input is no longer read-only for a map-valued title (objectui#5301's + ruling made the write rule available); authoring every locale from this + panel is a separate, still-open question — see `writeWidgetTitle`. */}
{ - // Guarded, not cast: without this the keystroke would replace an - // inline locale map with one locale's string. - if (!isAuthorableTitle(widget.title)) return; - onChange({ title: e.target.value }); + // Never `{ title: e.target.value }`: that is the flattening write + // that replaces an inline locale map with one locale's string. + onChange({ title: writeWidgetTitle(widget.title, language, e.target.value) }); }} - readOnly={!isAuthorableTitle(widget.title)} disabled={readOnly} - className="block w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50 read-only:bg-gray-50 read-only:text-gray-500" + className="block w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-50" />
diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx index 607884d136..2829821620 100644 --- a/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx +++ b/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx @@ -15,17 +15,28 @@ * `[object Object]`. * * The third is an AUTHORING WRITE — the property panel's single-line title - * `` — and it is the one where following rc.6's widening mechanically - * would have destroyed data rather than merely looked wrong. Resolving a map - * into the input and writing `e.target.value` back collapses every other locale - * on the first keystroke. This file's round-trip pin is the acceptance test for - * the conservative branch PR #4169 established on `DashboardWidgetInspector` - * and objectui#4163's dispatch ruling extends here: an inline map survives an - * unrelated edit-and-save untouched. + * `` — and it is the one where following the `I18nLabel` widening + * mechanically would destroy data rather than merely look wrong. Resolving a + * map into the input and writing `e.target.value` back collapses every other + * locale on the first keystroke. * - * Part 2 of #4163 (what Studio SHOULD offer for authoring a per-locale label) - * is unclaimed and pending design; these pins describe the placeholder, and - * they are written so that the real answer replaces them deliberately. + * PR #4169 met that by making a map-valued title READ-ONLY, on the premise that + * "no persisted widget title can be a map" — a premise `@objectstack/spec` + * 17.0.0 invalidates, so from rc.6 onward the branch denied authors an edit + * rather than protecting anything reachable (objectui#5428). + * + * objectui#5301's maintainer ruling (2026-08-20) supplies the replacement: a + * save writes back ONLY the active locale's entry and preserves the others, + * shipped as `@object-ui/i18n`'s `setLocalized`. The pins below are that rule's + * acceptance test at this surface, and the one that carries the weight is the + * PRESERVATION pin — "the input is no longer read-only" is equally green + * against a fix that flattens the map, which is the exact data loss the + * read-only branch existed to prevent. + * + * ⚠️ These pins describe a SINGLE-locale editor: an author reaches only the + * entry for the locale they are in. Authoring every locale from one panel is a + * separate open question, deliberately not deferred to a tracker here — the + * deferral this replaced pointed at #4163, closed as completed 2026-08-15. */ import { describe, it, expect, vi } from 'vitest'; @@ -97,20 +108,61 @@ describe('DashboardEditor — the title INPUT is a write path, not a display (#4 return onChange; } - it('shows a map-valued title resolved, and READ-ONLY', () => { + it('shows a map-valued title resolved, and EDITABLE', () => { + // The read-only branch is gone: its premise ("no persisted title can be a + // map") expired at @objectstack/spec 17.0.0. Editable is NECESSARY but not + // sufficient — the preservation pin below is what makes it correct. openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w1'); const input = screen.getByTestId('widget-prop-title') as HTMLInputElement; expect(input.value).toBe('Pipeline'); - expect(input.readOnly).toBe(true); + expect(input.readOnly).toBe(false); + }); + + it('⛔ a keystroke on a map-valued title writes ONLY the displayed locale — every other entry survives byte-identical', () => { + // THE data-loss pin. The flattening write (`onChange({ title: + // e.target.value })`) emits a plain string and `zh-CN` is gone forever on + // the next save. Asserting the map SHAPE is what catches that; an "is no + // longer read-only" assertion is green against it. + const onChange = openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w1'); + fireEvent.change(screen.getByTestId('widget-prop-title'), { + target: { value: 'Pipelinex' }, + }); + + expect(onChange).toHaveBeenCalled(); + const title = lastSchema(onChange).widgets!.find((w) => w.id === 'w1')!.title; + // Still a map — not flattened to the edited string. + expect(typeof title).toBe('object'); + const map = title as Record; + // The displayed locale carries the new string... + expect(map.en).toBe('Pipelinex'); + // ...and the locale the author never saw is untouched, character for + // character. This is the assertion a flattening fix cannot satisfy. + expect(map['zh-CN']).toBe('销售漏斗'); + expect(map['zh-CN']).toBe(MAP_TITLE['zh-CN']); + // No entry invented, none dropped: exactly the stored key set. + expect(Object.keys(map).sort()).toEqual(['en', 'zh-CN']); + // The stored object is not mutated in place — the editor's undo history + // holds the previous schema by reference. + expect(MAP_TITLE).toEqual({ en: 'Pipeline', 'zh-CN': '销售漏斗' }); }); - it('⛔ a keystroke on a map-valued title writes NOTHING — the other locales survive', () => { - // The data-loss pin. Without the guard this emits - // `{ title: 'Pipelinex' }`, and `zh-CN` is gone forever on the next save. + it('what the panel SHOWS after a save is what was typed — this surface\'s read and write agree', () => { + // `pickLocalized ∘ setLocalized` as this panel wires it: an edit that landed + // in an entry the panel does not display would "save" and then vanish. The + // pair is pinned in @object-ui/i18n; this pins that THIS panel reads and + // writes through the pair rather than around it. const onChange = openPanelFor(schemaWith(MAP_TITLE), 'dashboard-widget-w1'); - const input = screen.getByTestId('widget-prop-title'); - fireEvent.change(input, { target: { value: 'Pipelinex' } }); - expect(onChange).not.toHaveBeenCalled(); + fireEvent.change(screen.getByTestId('widget-prop-title'), { + target: { value: 'Pipelinex' }, + }); + const saved = lastSchema(onChange); + + // Re-open the saved schema in a fresh editor and read the field back. + render( {}} />); + const reopenedCard = screen.getAllByTestId('dashboard-widget-w1')[1]; + fireEvent.click(reopenedCard); + const reopened = screen.getAllByTestId('widget-prop-title')[1] as HTMLInputElement; + expect(reopened.value).toBe('Pipelinex'); }); it('an inline map survives an UNRELATED edit-and-save round trip untouched', () => {