diff --git a/src/features/housekeeping/domains/content/pages/content-command-form.tsx b/src/features/housekeeping/domains/content/pages/content-command-form.tsx index fe02a7d7..09f3303e 100644 --- a/src/features/housekeeping/domains/content/pages/content-command-form.tsx +++ b/src/features/housekeeping/domains/content/pages/content-command-form.tsx @@ -41,6 +41,7 @@ interface ContentCommandFormProps extends ContentCommandSubmission { } const OMIT_FIELD = Symbol("omit optional Content command field"); +const UNCHECKED_CHECKBOX_VALUE = "false"; function optionalEmptyValue(field: ContentCommandField): unknown { return field.emptyValue === null || field.emptyValue === "" ? field.emptyValue @@ -115,7 +116,11 @@ function readSubmittedValues( for (const field of fields) { if (field.type === "file") continue; const value = formData.get(field.name); - if (typeof value === "string") values[field.name] = value; + if (typeof value === "string") { + values[field.name] = value; + } else if (field.type === "checkbox" && !field.allowUnchanged) { + values[field.name] = UNCHECKED_CHECKBOX_VALUE; + } } if (requiresReason) { const reason = formData.get("reason"); @@ -247,8 +252,9 @@ export function ContentCommandForm({ name={field.name} type="checkbox" defaultChecked={ - retainedValue === "on" || - (retainedValue === undefined && field.defaultValue === true) + retainedValue === undefined + ? field.defaultValue === true + : retainedValue === "on" } {...fieldErrorProps} />{" "} diff --git a/src/features/housekeeping/domains/content/pages/content-pages.test.tsx b/src/features/housekeeping/domains/content/pages/content-pages.test.tsx index 376a2cfc..6851f4f0 100644 --- a/src/features/housekeeping/domains/content/pages/content-pages.test.tsx +++ b/src/features/housekeeping/domains/content/pages/content-pages.test.tsx @@ -20,6 +20,22 @@ import { ContentHelpPage } from "./help"; import { ContentLocalizationPage } from "./localization"; import { ContentMediaPage } from "./media"; +const { actionState } = vi.hoisted(() => ({ + actionState: { current: null as unknown }, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useActionState: (_action: unknown, initialState: unknown) => [ + actionState.current ?? initialState, + vi.fn(), + false, + ], + }; +}); + vi.mock("@/actions/housekeeping-command", () => ({ executeHousekeepingCommand: vi.fn(), })); @@ -125,7 +141,10 @@ describe.each(cases)("Content %s page", (kind, Component, editPermission) => { }); describe("Content actionable form wiring", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + actionState.current = null; + }); it("retains configured string values after validation and excludes files", async () => { vi.mocked(executeHousekeepingCommand).mockResolvedValue( @@ -157,6 +176,82 @@ describe("Content actionable form wiring", () => { expect(state).not.toHaveProperty("values.cover"); }); + it("keeps an unchecked default-true checkbox unchecked after a failed remount", async () => { + vi.mocked(executeHousekeepingCommand).mockResolvedValue( + fail("VALIDATION", "errors.housekeeping.validation", "poll-checkbox"), + ); + const formData = new FormData(); + const state = await submitContentCommandForm( + { + commandId: "content.engagement.poll.change", + input: { action: "create" }, + fields: [ + { + name: "showResults", + label: "Show results", + type: "checkbox", + defaultValue: true, + }, + ], + }, + null, + formData, + ); + + expect(executeHousekeepingCommand).toHaveBeenCalledWith({ + commandId: "content.engagement.poll.change", + input: { action: "create", showResults: false }, + }); + actionState.current = state; + const html = renderToStaticMarkup( + , + ); + actionState.current = null; + const showResults = html.match(/]*name="showResults"[^>]*>/u)?.[0]; + + expect(showResults).toBeDefined(); + expect(showResults).not.toContain("checked"); + }); + + it("retains dependency-failed string values", async () => { + vi.mocked(executeHousekeepingCommand).mockResolvedValue( + fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + "poll-dependency", + ), + ); + const formData = new FormData(); + formData.set("title", "Draft title"); + + const state = await submitContentCommandForm( + { + commandId: "content.engagement.poll.change", + input: { action: "create" }, + fields: [{ name: "title", label: "Title", type: "text" }], + }, + null, + formData, + ); + + expect(state).toMatchObject({ + result: { ok: false, error: { code: "DEPENDENCY_UNAVAILABLE" } }, + values: { title: "Draft title" }, + }); + }); + it("retains submitted fields and reason after a conflict", async () => { vi.mocked(executeHousekeepingCommand).mockResolvedValue( fail("CONFLICT", "errors.housekeeping.conflict", "poll-conflict"),