feat(web): shared sv/en LabelEditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user