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 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" } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user