From 9d0475e8ec0b1154211e63c5f416ed0d7ad4f155 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 15:05:20 +0200 Subject: [PATCH] feat(web): single-language content authoring (LabelEditor + localized_text at default lang) Co-Authored-By: Claude Sonnet 4.6 --- web/src/authorities/authorities-page.tsx | 2 +- web/src/authorities/authorities.test.tsx | 2 +- web/src/components/label-editor.test.tsx | 38 +++++++++++------------- web/src/components/label-editor.tsx | 36 ++++++++-------------- web/src/fields/field-form.tsx | 4 +-- web/src/fields/fields.test.tsx | 6 ++-- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/objects/field-input.test.tsx | 5 ++-- web/src/objects/field-input.tsx | 30 +++++-------------- web/src/vocab/vocabularies.test.tsx | 2 +- web/src/vocab/vocabulary-terms.tsx | 2 +- 12 files changed, 49 insertions(+), 82 deletions(-) diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx index 13aa20d..6b97aa4 100644 --- a/web/src/authorities/authorities-page.tsx +++ b/web/src/authorities/authorities-page.tsx @@ -31,7 +31,7 @@ export function AuthoritiesPage() { const onCreate = (event: FormEvent) => { event.preventDefault(); - if (!labels.some((l) => l.lang === "en" && l.label)) { + if (!labels.some((l) => l.label)) { setError(true); return; } diff --git a/web/src/authorities/authorities.test.tsx b/web/src/authorities/authorities.test.tsx index 67406a7..fc2ffb8 100644 --- a/web/src/authorities/authorities.test.tsx +++ b/web/src/authorities/authorities.test.tsx @@ -25,7 +25,7 @@ test("lists authorities for the kind and creates one", async () => { ); renderApp(tree(), { route: "/authorities/person" }); expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Carl von Linné"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Carl von Linné"); await userEvent.click(screen.getByRole("button", { name: /create/i })); await waitFor(() => expect((body as { kind: string })?.kind).toBe("person")); expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné"); diff --git a/web/src/components/label-editor.test.tsx b/web/src/components/label-editor.test.tsx index 35bf81c..ac642e6 100644 --- a/web/src/components/label-editor.test.tsx +++ b/web/src/components/label-editor.test.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { expect, test } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderApp } from "../test/render"; import { LabelEditor } from "./label-editor"; @@ -10,28 +10,24 @@ type LabelInput = components["schemas"]["LabelInput"]; function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) { const [value, setValue] = useState([]); - return ( - { - setValue(v); - onChange(v); - }} - /> - ); + return { setValue(v); onChange(v); }} />; } -test("typing EN and SV emits both labels; empty langs are omitted", async () => { +test("emits a single label at the instance default language", async () => { const seen: LabelInput[][] = []; renderApp( seen.push(v)} />); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze"); - await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons"); - const last = seen[seen.length - 1]!; - expect(last).toEqual( - expect.arrayContaining([ - { lang: "en", label: "Bronze" }, - { lang: "sv", label: "Brons" }, - ]), - ); - expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true); + await userEvent.type(screen.getByLabelText(/^label$/i), "Brons"); + await waitFor(() => { + const last = seen[seen.length - 1]!; + expect(last).toEqual([{ lang: "sv", label: "Brons" }]); + }); +}); + +test("clearing the input emits an empty array", async () => { + const seen: LabelInput[][] = []; + renderApp( seen.push(v)} />); + const input = screen.getByLabelText(/^label$/i); + await userEvent.type(input, "X"); + await userEvent.clear(input); + await waitFor(() => expect(seen[seen.length - 1]).toEqual([])); }); diff --git a/web/src/components/label-editor.tsx b/web/src/components/label-editor.tsx index 1c07bc7..6d0ff33 100644 --- a/web/src/components/label-editor.tsx +++ b/web/src/components/label-editor.tsx @@ -1,12 +1,15 @@ import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; +import { useConfig } from "../config/config-context"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; type LabelInput = components["schemas"]["LabelInput"]; -/** Controlled sv/en label editor. Emits LabelInput[] with only the non-empty langs. */ +/** 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. */ export function LabelEditor({ value, onChange, @@ -15,33 +18,18 @@ export function LabelEditor({ onChange: (labels: LabelInput[]) => void; }) { const { t } = useTranslation(); + const { default_language } = useConfig(); - const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? ""; + const current = + value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? ""; - const set = (lang: string, label: string) => { - const others = value.filter((l) => l.lang !== lang); - - onChange(label.trim() ? [...others, { lang, label }] : others); - }; + const set = (label: string) => + onChange(label.trim() ? [{ lang: default_language, label }] : []); return ( -
-
- - set("en", e.target.value)} - /> -
-
- - set("sv", e.target.value)} - /> -
+
+ + set(e.target.value)} />
); } diff --git a/web/src/fields/field-form.tsx b/web/src/fields/field-form.tsx index d7c77da..8177fd7 100644 --- a/web/src/fields/field-form.tsx +++ b/web/src/fields/field-form.tsx @@ -42,10 +42,10 @@ export function FieldForm() { const onSubmit = (event: FormEvent) => { event.preventDefault(); - const hasEn = labels.some((l) => l.lang === "en" && l.label); + const hasLabel = labels.some((l) => l.label); const termNeedsVocab = dataType === "term" && !vocabularyId; - if (!key.trim() || !hasEn || termNeedsVocab) { + if (!key.trim() || !hasLabel || termNeedsVocab) { setError(true); return; } diff --git a/web/src/fields/fields.test.tsx b/web/src/fields/fields.test.tsx index a3df0e8..6cfdb8a 100644 --- a/web/src/fields/fields.test.tsx +++ b/web/src/fields/fields.test.tsx @@ -35,7 +35,7 @@ test("creates a text field — posts the body and clears the key input", async ( renderApp(tree(), { route: "/fields" }); await userEvent.type(screen.getByLabelText(/^key$/i), "notes"); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Notes"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Notes"); await userEvent.click(screen.getByRole("button", { name: /create field/i })); await waitFor(() => expect(body?.key).toBe("notes")); @@ -55,7 +55,7 @@ test("selecting Authority reveals the kind picker and posts the chosen kind", as renderApp(tree(), { route: "/fields" }); await userEvent.type(screen.getByLabelText(/^key$/i), "maker"); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Maker"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Maker"); await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "authority"); const kind = await screen.findByLabelText(/authority kind/i); await userEvent.selectOptions(kind, "person"); @@ -76,7 +76,7 @@ test("selecting Term reveals the vocabulary picker and blocks submit until chose renderApp(tree(), { route: "/fields" }); await userEvent.type(screen.getByLabelText(/^key$/i), "material"); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Material"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Material"); await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term"); const vocab = await screen.findByLabelText(/^vocabulary$/i); diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 0e61bc9..b07cfce 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -7,7 +7,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", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }, - "labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }, + "labels": { "label": "Label", "externalUri": "External URI (optional)" }, "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 844a80a..b6c7567 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -7,7 +7,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", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }, - "labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }, + "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", "create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer", diff --git a/web/src/objects/field-input.test.tsx b/web/src/objects/field-input.test.tsx index e8f8b3d..afb5c78 100644 --- a/web/src/objects/field-input.test.tsx +++ b/web/src/objects/field-input.test.tsx @@ -22,10 +22,9 @@ test("boolean field renders a checkbox", async () => { expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument(); }); -test("localized_text renders sv and en inputs", async () => { +test("localized_text renders a single input for the default language", async () => { renderApp(); - expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument(); + expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument(); }); test("term field renders a select populated from the vocabulary", async () => { diff --git a/web/src/objects/field-input.tsx b/web/src/objects/field-input.tsx index 859f919..5fc88e8 100644 --- a/web/src/objects/field-input.tsx +++ b/web/src/objects/field-input.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; import { useAuthorities, useTerms } from "../api/queries"; +import { useConfig } from "../config/config-context"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -70,6 +71,7 @@ export function FieldInput }>( form: FieldForm; }) { const { t, i18n } = useTranslation(); + const { default_language } = useConfig(); const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const label = labelIn(definition.labels, lang); const name = fieldPath(definition.key); @@ -128,30 +130,12 @@ export function FieldInput }>( case "localized_text": return (
-
{label}
- - - + (`${definition.key}.en`), { required: definition.required })} - /> - - - - (`${definition.key}.sv`))} + id={definition.key} + {...form.register(fieldPath(`${definition.key}.${default_language}`), { + required: definition.required, + })} />
); diff --git a/web/src/vocab/vocabularies.test.tsx b/web/src/vocab/vocabularies.test.tsx index 38e731c..5012fb8 100644 --- a/web/src/vocab/vocabularies.test.tsx +++ b/web/src/vocab/vocabularies.test.tsx @@ -45,7 +45,7 @@ test("selecting a vocabulary shows its terms and adds one", async () => { ); renderApp(tree(), { route: "/vocabularies/v-material" }); expect(await screen.findByText("Bronze")).toBeInTheDocument(); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Stone"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Stone"); await userEvent.click(screen.getByRole("button", { name: /add term/i })); await waitFor(() => expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"), diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index 1d8d886..c4c75e4 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -34,7 +34,7 @@ export function VocabularyTerms() { const onAdd = (event: FormEvent) => { event.preventDefault(); - if (!labels.some((l) => l.lang === "en" && l.label)) { + if (!labels.some((l) => l.label)) { setError(true); return; }