Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/dashboard-title-locale-writeback-5428.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof vi.fn>): 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<string, unknown>, props: Record<string, unknown> = {}) {
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<string, string>;
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<string, string>;
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<string, string>;
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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -231,29 +233,53 @@ export function DashboardWidgetInspector({
</div>

{/* 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. */}
<Field id="widget-title" label={t('engine.inspector.widget.title', locale)}>
<Input
id="widget-title"
value={
typeof widget.title === 'string' || widget.title == null
? widget.title ?? ''
: resolveInlineI18nLabel(widget.title, locale) ?? ''
value={resolveInlineI18nLabel(widget.title, locale) ?? ''}
onChange={(e) =>
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<string, unknown>`, 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'}
/>
</Field>

Expand Down
Loading
Loading