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:
@@ -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<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" } });
|
||||
});
|
||||
|
||||
@@ -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<FormShape>({
|
||||
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<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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
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",
|
||||
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 (
|
||||
<Badge variant="outline" className={STYLES[visibility] ?? ""}>
|
||||
<Badge variant="outline" className={STYLES[visibility]}>
|
||||
{t(`visibility.${visibility}`)}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user