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', () => {