From b4d71b0f801781c5870b14d2b7cb642496806459 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 15:46:48 +0200 Subject: [PATCH] fix(web): VisibilityBadge typed to the union (#38); normalize localized_text to default language on save (#41) Co-Authored-By: Claude Sonnet 4.6 --- web/src/objects/object-form.test.tsx | 38 ++++++++++++++++++++++++++++ web/src/objects/object-form.tsx | 32 ++++++----------------- web/src/objects/prune-fields.ts | 31 +++++++++++++++++++++++ web/src/objects/visibility-badge.tsx | 9 ++++--- 4 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 web/src/objects/prune-fields.ts diff --git a/web/src/objects/object-form.test.tsx b/web/src/objects/object-form.test.tsx index a53c91c..476dbfb 100644 --- a/web/src/objects/object-form.test.tsx +++ b/web/src/objects/object-form.test.tsx @@ -3,6 +3,7 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderApp } from "../test/render"; import { ObjectForm } from "./object-form"; +import { pruneFields } from "./prune-fields"; test("create mode: shows visibility (draft/internal only) and submits assembled values", async () => { 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.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)).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" } }); +}); diff --git a/web/src/objects/object-form.tsx b/web/src/objects/object-form.tsx index de84807..ba16cd0 100644 --- a/web/src/objects/object-form.tsx +++ b/web/src/objects/object-form.tsx @@ -3,7 +3,9 @@ import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useFieldDefinitions } from "../api/queries"; +import { useConfig } from "../config/config-context"; import { FieldInput } from "./field-input"; +import { pruneFields } from "./prune-fields"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -58,9 +60,14 @@ export function ObjectForm({ fieldErrorKey?: string | null; }) { const { t } = useTranslation(); + const { default_language } = useConfig(); const { data: definitions } = useFieldDefinitions(); + const localizedTextKeys = new Set( + (definitions ?? []).filter((d) => d.data_type === "localized_text").map((d) => d.key), + ); + const form = useForm({ defaultValues: { core: defaults?.core ?? EMPTY_CORE, @@ -81,7 +88,7 @@ export function ObjectForm({ }, [fieldErrorKey, form, t]); const submit = handleSubmit((data) => { - const fields = pruneFields(data.fields); + const fields = pruneFields(data.fields, localizedTextKeys, default_language); onSubmit( mode === "create" @@ -182,26 +189,3 @@ export function ObjectForm({ ); } -function pruneFields(fields: Record): Record { - const out: Record = {}; - - 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).filter( - ([, v]) => v !== undefined && v !== null && v !== "", - ), - ); - - if (Object.keys(inner).length > 0) out[key] = inner; - - continue; - } - - out[key] = value; - } - - return out; -} diff --git a/web/src/objects/prune-fields.ts b/web/src/objects/prune-fields.ts new file mode 100644 index 0000000..fe1f196 --- /dev/null +++ b/web/src/objects/prune-fields.ts @@ -0,0 +1,31 @@ +export function pruneFields( + fields: Record, + localizedTextKeys: Set, + defaultLang: string, +): Record { + const out: Record = {}; + + 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; + // 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; +} diff --git a/web/src/objects/visibility-badge.tsx b/web/src/objects/visibility-badge.tsx index dd9b6a9..40c398f 100644 --- a/web/src/objects/visibility-badge.tsx +++ b/web/src/objects/visibility-badge.tsx @@ -1,18 +1,21 @@ import { useTranslation } from "react-i18next"; +import type { components } from "../api/schema"; import { Badge } from "@/components/ui/badge"; -const STYLES: Record = { +type Visibility = components["schemas"]["Visibility"]; + +const STYLES: Record = { draft: "bg-neutral-100 text-neutral-600", internal: "bg-amber-100 text-amber-800", public: "bg-green-100 text-green-800", }; -export function VisibilityBadge({ visibility }: { visibility: string }) { +export function VisibilityBadge({ visibility }: { visibility: Visibility }) { const { t } = useTranslation(); return ( - + {t(`visibility.${visibility}`)} );