From a9e6788b0bbd49b17fcba7488c67c7f82d9ab48f Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 14:37:25 +0200 Subject: [PATCH] fix(web): LabelEditor preserves other-language labels on edit (#55) Editing a term/authority/field that already had labels in other languages silently replaced the whole multilingual set with one default-language entry. onChange now keeps non-default-language entries; the editor shows only the default-language label (no longer falling back to an other-language one, which made the clear/edit path write the wrong language) and surfaces a hint when other-language labels exist on the record. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/components/label-editor.stories.tsx | 19 ++++++- web/src/components/label-editor.test.tsx | 60 +++++++++++++++++++-- web/src/components/label-editor.tsx | 19 ++++--- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- 5 files changed, 89 insertions(+), 13 deletions(-) diff --git a/web/src/components/label-editor.stories.tsx b/web/src/components/label-editor.stories.tsx index ef83d97..fd15099 100644 --- a/web/src/components/label-editor.stories.tsx +++ b/web/src/components/label-editor.stories.tsx @@ -31,9 +31,9 @@ export const Empty: Story = { } export const Prefilled: Story = { - args: { value: [{ lang: 'en', label: 'Bronze' }] }, + args: { value: [{ lang: 'sv', label: 'Brons' }] }, play: async ({ canvas }) => { - await expect(canvas.getByLabelText('Label')).toHaveValue('Bronze') + await expect(canvas.getByLabelText('Label')).toHaveValue('Brons') }, } @@ -44,3 +44,18 @@ export const Editing: Story = { await expect(input).toHaveValue('Ceramic') }, } + +// An entry that already has labels in other languages (e.g. English) shows only the +// default-language label, with a hint that the other-language labels are preserved. +export const WithOtherLanguages: Story = { + args: { + value: [ + { lang: 'en', label: 'Bronze' }, + { lang: 'sv', label: 'Brons' }, + ], + }, + play: async ({ canvas }) => { + await expect(canvas.getByLabelText('Label')).toHaveValue('Brons') + await expect(canvas.getByText(/other languages/i)).toBeInTheDocument() + }, +} diff --git a/web/src/components/label-editor.test.tsx b/web/src/components/label-editor.test.tsx index ac642e6..d8f6ae3 100644 --- a/web/src/components/label-editor.test.tsx +++ b/web/src/components/label-editor.test.tsx @@ -8,9 +8,23 @@ import type { components } from "../api/schema"; type LabelInput = components["schemas"]["LabelInput"]; -function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) { - const [value, setValue] = useState([]); - return { setValue(v); onChange(v); }} />; +function Harness({ + initial = [], + onChange, +}: { + initial?: LabelInput[]; + onChange?: (v: LabelInput[]) => void; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(v); + onChange?.(v); + }} + /> + ); } test("emits a single label at the instance default language", async () => { @@ -31,3 +45,43 @@ test("clearing the input emits an empty array", async () => { await userEvent.clear(input); await waitFor(() => expect(seen[seen.length - 1]).toEqual([])); }); + +test("editing preserves labels in other languages", async () => { + const seen: LabelInput[][] = []; + renderApp( + seen.push(v)} + />, + ); + const input = screen.getByLabelText(/^label$/i); + expect((input as HTMLInputElement).value).toBe("Brons"); + await userEvent.clear(input); + await userEvent.type(input, "Brons (ny)"); + await waitFor(() => { + expect(seen[seen.length - 1]).toEqual([ + { lang: "en", label: "Bronze" }, + { lang: "sv", label: "Brons (ny)" }, + ]); + }); +}); + +test("shows a hint when the entry has labels in other languages", () => { + renderApp( + , + ); + expect(screen.getByText(/other languages/i)).toBeInTheDocument(); +}); + +test("no hint when only the default-language label exists", () => { + renderApp(); + expect(screen.queryByText(/other languages/i)).not.toBeInTheDocument(); +}); diff --git a/web/src/components/label-editor.tsx b/web/src/components/label-editor.tsx index 6d0ff33..81dc0bd 100644 --- a/web/src/components/label-editor.tsx +++ b/web/src/components/label-editor.tsx @@ -7,9 +7,10 @@ import { Label } from "@/components/ui/label"; type LabelInput = components["schemas"]["LabelInput"]; -/** Single-language label editor. Authors one label at the instance default language; - * emits a one-entry LabelInput[] (empty array when blank). The multilingual data model - * is unchanged — this only simplifies authoring. */ +/** Single-language label editor. Authors one label at the instance default language. + * Editing only touches the default-language entry — labels in other languages on the + * same record are preserved (not collapsed), so editing a term/authority that already + * has e.g. an English label keeps it. */ export function LabelEditor({ value, onChange, @@ -20,16 +21,22 @@ export function LabelEditor({ const { t } = useTranslation(); const { default_language } = useConfig(); - const current = - value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? ""; + const current = value.find((l) => l.lang === default_language)?.label ?? ""; + const hasOtherLanguages = value.some((l) => l.lang !== default_language); const set = (label: string) => - onChange(label.trim() ? [{ lang: default_language, label }] : []); + onChange([ + ...value.filter((l) => l.lang !== default_language), + ...(label.trim() ? [{ lang: default_language, label }] : []), + ]); return (
set(e.target.value)} /> + {hasOtherLanguages && ( +

{t("labels.otherLanguages")}

+ )}
); } diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index edf4743..f853041 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -8,7 +8,7 @@ "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, - "labels": { "label": "Label", "externalUri": "External URI (optional)" }, + "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", "create": "Create", "selectPrompt": "Select a vocabulary to manage its terms", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 1ff6d5a..e160c7e 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -8,7 +8,7 @@ "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, - "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" }, + "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", "create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",