feat(web): shared sv/en LabelEditor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 09:14:16 +02:00
parent 6afc358334
commit 8d2323ed95
5 changed files with 88 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
import { useState } from "react";
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { LabelEditor } from "./label-editor";
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);
}}
/>
);
}
test("typing EN and SV emits both labels; empty langs are omitted", 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");
const last = seen.at(-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);
});
+47
View File
@@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
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. */
export function LabelEditor({
value,
onChange,
}: {
value: LabelInput[];
onChange: (labels: LabelInput[]) => void;
}) {
const { t } = useTranslation();
const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? "";
const set = (lang: string, label: string) => {
const others = value.filter((l) => l.lang !== lang);
onChange(label ? [...others, { lang, label }] : others);
};
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>
</div>
);
}
+1
View File
@@ -7,6 +7,7 @@
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "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" }, "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." }, "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
"labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" },
"publish": { "publish": {
"heading": "Visibility", "heading": "Visibility",
"advanceInternal": "Advance to internal", "advanceInternal": "Advance to internal",
+1
View File
@@ -7,6 +7,7 @@
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "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" }, "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." }, "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)" },
"publish": { "publish": {
"heading": "Synlighet", "heading": "Synlighet",
"advanceInternal": "Gör intern", "advanceInternal": "Gör intern",
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,