feat(web): single-language content authoring (LabelEditor + localized_text at default lang)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:05:20 +02:00
parent 04e9c95c52
commit 9d0475e8ec
12 changed files with 49 additions and 82 deletions
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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é");
+16 -20
View File
@@ -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<LabelInput[]>([]);
return (
<LabelEditor
value={value}
onChange={(v) => {
setValue(v);
onChange(v);
}}
/>
);
return <LabelEditor value={value} onChange={(v) => { 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(<Harness onChange={(v) => seen.push(v)} />);
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
await waitFor(() => {
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);
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
});
});
test("clearing the input emits an empty array", async () => {
const seen: LabelInput[][] = [];
renderApp(<Harness onChange={(v) => 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([]));
});
+11 -23
View File
@@ -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 (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="label-en">{t("labels.en")}</Label>
<Input
id="label-en"
value={valueFor("en")}
onChange={(e) => set("en", e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="label-sv">{t("labels.sv")}</Label>
<Input
id="label-sv"
value={valueFor("sv")}
onChange={(e) => set("sv", e.target.value)}
/>
</div>
<Label htmlFor="label">{t("labels.label")}</Label>
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
</div>
);
}
+2 -2
View File
@@ -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;
}
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -3
View File
@@ -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(<Harness defKey="title_ml" />);
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 () => {
+7 -23
View File
@@ -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<TValues extends { fields: Record<string, unknown> }>(
form: FieldForm<TValues>;
}) {
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<TValues>(definition.key);
@@ -128,30 +130,12 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
case "localized_text":
return (
<div className="space-y-1">
<div className="text-sm font-medium">{label}</div>
<Label
htmlFor={`${definition.key}-en`}
className="text-xs text-neutral-500"
>
{label} (EN)
</Label>
<Label htmlFor={definition.key}>{label}</Label>
<Input
id={`${definition.key}-en`}
{...form.register(fieldPath<TValues>(`${definition.key}.en`), { required: definition.required })}
/>
<Label
htmlFor={`${definition.key}-sv`}
className="text-xs text-neutral-500"
>
{label} (SV)
</Label>
<Input
id={`${definition.key}-sv`}
{...form.register(fieldPath<TValues>(`${definition.key}.sv`))}
id={definition.key}
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
required: definition.required,
})}
/>
</div>
);
+1 -1
View File
@@ -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"),
+1 -1
View File
@@ -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;
}