fix(web): VisibilityBadge typed to the union (#38); normalize localized_text to default language on save (#41)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:46:48 +02:00
parent 0a29127f7e
commit b4d71b0f80
4 changed files with 83 additions and 27 deletions
+38
View File
@@ -3,6 +3,7 @@ import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render"; import { renderApp } from "../test/render";
import { ObjectForm } from "./object-form"; import { ObjectForm } from "./object-form";
import { pruneFields } from "./prune-fields";
test("create mode: shows visibility (draft/internal only) and submits assembled values", async () => { test("create mode: shows visibility (draft/internal only) and submits assembled values", async () => {
const onSubmit = vi.fn(); const onSubmit = vi.fn();
@@ -47,3 +48,40 @@ test("edit mode: no visibility control, save button, prefilled values", async ()
expect(screen.queryByLabelText(/visibility/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/visibility/i)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
}); });
test("pruneFields: localized_text keeps only the default-language key, other object fields unaffected", () => {
const localizedTextKeys = new Set(["title_ml"]);
const result = pruneFields(
{ title_ml: { en: "Old", sv: "Ny" }, other: "x" },
localizedTextKeys,
"sv",
);
expect(result).toEqual({ title_ml: { sv: "Ny" }, other: "x" });
expect(Object.keys(result.title_ml as Record<string, unknown>)).not.toContain("en");
});
test("pruneFields: localized_text with only non-default lang produces empty object (key omitted)", () => {
const localizedTextKeys = new Set(["title_ml"]);
const result = pruneFields(
{ title_ml: { en: "English only" } },
localizedTextKeys,
"sv",
);
expect(result).toEqual({});
});
test("pruneFields: non-localized_text object fields are preserved as-is", () => {
const localizedTextKeys = new Set(["title_ml"]);
const result = pruneFields(
{ nested_obj: { a: "1", b: "2" } },
localizedTextKeys,
"sv",
);
expect(result).toEqual({ nested_obj: { a: "1", b: "2" } });
});
+8 -24
View File
@@ -3,7 +3,9 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFieldDefinitions } from "../api/queries"; import { useFieldDefinitions } from "../api/queries";
import { useConfig } from "../config/config-context";
import { FieldInput } from "./field-input"; import { FieldInput } from "./field-input";
import { pruneFields } from "./prune-fields";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -58,9 +60,14 @@ export function ObjectForm({
fieldErrorKey?: string | null; fieldErrorKey?: string | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { default_language } = useConfig();
const { data: definitions } = useFieldDefinitions(); const { data: definitions } = useFieldDefinitions();
const localizedTextKeys = new Set(
(definitions ?? []).filter((d) => d.data_type === "localized_text").map((d) => d.key),
);
const form = useForm<FormShape>({ const form = useForm<FormShape>({
defaultValues: { defaultValues: {
core: defaults?.core ?? EMPTY_CORE, core: defaults?.core ?? EMPTY_CORE,
@@ -81,7 +88,7 @@ export function ObjectForm({
}, [fieldErrorKey, form, t]); }, [fieldErrorKey, form, t]);
const submit = handleSubmit((data) => { const submit = handleSubmit((data) => {
const fields = pruneFields(data.fields); const fields = pruneFields(data.fields, localizedTextKeys, default_language);
onSubmit( onSubmit(
mode === "create" mode === "create"
@@ -182,26 +189,3 @@ export function ObjectForm({
); );
} }
function pruneFields(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === "") continue;
if (typeof value === "object" && !Array.isArray(value)) {
const inner = Object.fromEntries(
Object.entries(value as Record<string, unknown>).filter(
([, v]) => v !== undefined && v !== null && v !== "",
),
);
if (Object.keys(inner).length > 0) out[key] = inner;
continue;
}
out[key] = value;
}
return out;
}
+31
View File
@@ -0,0 +1,31 @@
export function pruneFields(
fields: Record<string, unknown>,
localizedTextKeys: Set<string>,
defaultLang: string,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === "") continue;
if (typeof value === "object" && !Array.isArray(value)) {
const map = value as Record<string, unknown>;
// Single-language authoring: a localized_text value keeps only the default lang.
const entries = localizedTextKeys.has(key)
? Object.entries(map).filter(([lang]) => lang === defaultLang)
: Object.entries(map);
const inner = Object.fromEntries(
entries.filter(([, v]) => v !== undefined && v !== null && v !== ""),
);
if (Object.keys(inner).length > 0) out[key] = inner;
continue;
}
out[key] = value;
}
return out;
}
+6 -3
View File
@@ -1,18 +1,21 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
const STYLES: Record<string, string> = { type Visibility = components["schemas"]["Visibility"];
const STYLES: Record<Visibility, string> = {
draft: "bg-neutral-100 text-neutral-600", draft: "bg-neutral-100 text-neutral-600",
internal: "bg-amber-100 text-amber-800", internal: "bg-amber-100 text-amber-800",
public: "bg-green-100 text-green-800", public: "bg-green-100 text-green-800",
}; };
export function VisibilityBadge({ visibility }: { visibility: string }) { export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Badge variant="outline" className={STYLES[visibility] ?? ""}> <Badge variant="outline" className={STYLES[visibility]}>
{t(`visibility.${visibility}`)} {t(`visibility.${visibility}`)}
</Badge> </Badge>
); );