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) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<LabelInput[]>([]);
|
||||
return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
|
||||
function Harness({
|
||||
initial = [],
|
||||
onChange,
|
||||
}: {
|
||||
initial?: LabelInput[];
|
||||
onChange?: (v: LabelInput[]) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState<LabelInput[]>(initial);
|
||||
return (
|
||||
<LabelEditor
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
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(
|
||||
<Harness
|
||||
initial={[
|
||||
{ lang: "en", label: "Bronze" },
|
||||
{ lang: "sv", label: "Brons" },
|
||||
]}
|
||||
onChange={(v) => 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(
|
||||
<Harness
|
||||
initial={[
|
||||
{ lang: "en", label: "Bronze" },
|
||||
{ lang: "sv", label: "Brons" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/other languages/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("no hint when only the default-language label exists", () => {
|
||||
renderApp(<Harness initial={[{ lang: "sv", label: "Brons" }]} />);
|
||||
expect(screen.queryByText(/other languages/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="label">{t("labels.label")}</Label>
|
||||
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
|
||||
{hasOtherLanguages && (
|
||||
<p className="text-xs text-muted-foreground">{t("labels.otherLanguages")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user