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 c0315aa3..315357c5 100644 --- a/src/features/housekeeping/domains/content/pages/content-command-form.tsx +++ b/src/features/housekeeping/domains/content/pages/content-command-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useActionState, useId } from "react"; import { requiresHousekeepingReason } from "../../../foundation/commands/reason-policy"; import type { HousekeepingResult } from "../../../foundation/contracts"; @@ -161,7 +162,22 @@ export async function submitContentCommandForm( input, ...(reasonRequired ? { reason } : {}), }); - return { result, values: result.ok ? {} : submittedValues }; + return { result, values: submittedValues }; +} + +export async function submitContentCommandFormAndRefresh( + configuration: ContentCommandSubmission, + refresh: () => void, + previous: ContentCommandFormState | null, + formData: FormData, +): Promise { + const state = await submitContentCommandForm( + configuration, + previous, + formData, + ); + if (state.result?.ok) refresh(); + return state; } export function ContentCommandFieldError({ @@ -191,18 +207,23 @@ export function ContentCommandForm({ requiresReason = false, }: ContentCommandFormProps) { const formId = useId(); + const router = useRouter(); const reasonRequired = requiresHousekeepingReason(requiresReason, input); const [state, submit, pending] = useActionState( - submitContentCommandForm.bind(null, { - commandId, - input, - fields, - requiresReason: reasonRequired, - }), + submitContentCommandFormAndRefresh.bind( + null, + { + commandId, + input, + fields, + requiresReason: reasonRequired, + }, + () => router.refresh(), + ), initialState, ); const result = state.result; - const retainedValues = result && !result.ok ? state.values : {}; + const retainedValues = result ? state.values : {}; const remountKey = `${formId}-${result?.correlationId ?? "initial"}`; const reasonErrorId = `${formId}-reason-error`; const hasReasonError = 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 975d569c..c8d706f0 100644 --- a/src/features/housekeeping/domains/content/pages/content-pages.test.tsx +++ b/src/features/housekeeping/domains/content/pages/content-pages.test.tsx @@ -23,8 +23,9 @@ import { ContentHelpPage } from "./help"; import { ContentLocalizationPage } from "./localization"; import { ContentMediaPage } from "./media"; -const { actionState } = vi.hoisted(() => ({ +const { actionState, routerRefresh } = vi.hoisted(() => ({ actionState: { current: null as unknown }, + routerRefresh: vi.fn(), })); vi.mock("react", async (importOriginal) => { @@ -43,6 +44,10 @@ vi.mock("@/actions/housekeeping-command", () => ({ executeHousekeepingCommand: vi.fn(), })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: routerRefresh }), +})); + function context(granted: readonly string[]): HousekeepingCapabilityContext { const permissions = new Set(granted); return { @@ -313,7 +318,7 @@ describe("Content actionable form wiring", () => { }); }); - it("clears retained values after a successful command", async () => { + it("retains submitted values after a successful command", async () => { vi.mocked(executeHousekeepingCommand).mockResolvedValue( ok({ before: null, after: { id: "41" } }, "poll-success"), ); @@ -330,7 +335,96 @@ describe("Content actionable form wiring", () => { formData, ); - expect(state).toMatchObject({ result: { ok: true }, values: {} }); + expect(state).toMatchObject({ + result: { ok: true }, + values: { title: "Published poll" }, + }); + }); + + it("keeps the first successful edit across stale SSR defaults and refreshes each success once", async () => { + const module = (await import("./content-command-form")) as Record< + string, + unknown + >; + const submitAndRefresh = module.submitContentCommandFormAndRefresh; + expect(submitAndRefresh).toBeTypeOf("function"); + if (typeof submitAndRefresh !== "function") return; + + vi.mocked(executeHousekeepingCommand) + .mockResolvedValueOnce( + ok( + { before: { title: "Title A" }, after: { title: "Title B" } }, + "first-success", + ), + ) + .mockResolvedValueOnce( + ok( + { + before: { title: "Title B", description: "Old summary" }, + after: { title: "Title B", description: "New summary" }, + }, + "second-success", + ), + ); + const configuration = { + commandId: "content.editorial.article.change", + input: { action: "update", id: "41" }, + fields: [ + { name: "title", label: "Title", type: "text" as const }, + { + name: "description", + label: "Summary", + type: "text" as const, + }, + ], + }; + const firstData = new FormData(); + firstData.set("title", "Title B"); + firstData.set("description", "Old summary"); + const firstState = await Reflect.apply(submitAndRefresh, undefined, [ + configuration, + routerRefresh, + null, + firstData, + ]); + + expect(firstState).toMatchObject({ + result: { ok: true }, + values: { title: "Title B", description: "Old summary" }, + }); + actionState.current = firstState; + const remounted = renderToStaticMarkup( + , + ); + expect(remounted).toMatch(/name="title"[^>]*value="Title B"/u); + + const secondData = new FormData(); + secondData.set("title", "Title B"); + secondData.set("description", "New summary"); + await Reflect.apply(submitAndRefresh, undefined, [ + configuration, + routerRefresh, + firstState, + secondData, + ]); + + expect(executeHousekeepingCommand).toHaveBeenNthCalledWith(2, { + commandId: "content.editorial.article.change", + input: { + action: "update", + id: "41", + title: "Title B", + description: "New summary", + }, + }); + expect(routerRefresh).toHaveBeenCalledTimes(2); }); it("renders an accessible stable field error", () => { diff --git a/src/features/housekeeping/domains/people/pages/people-command-form.tsx b/src/features/housekeeping/domains/people/pages/people-command-form.tsx index 0c06a405..53098372 100644 --- a/src/features/housekeeping/domains/people/pages/people-command-form.tsx +++ b/src/features/housekeeping/domains/people/pages/people-command-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useId } from "react"; import type { HousekeepingResult } from "../../../foundation/contracts"; export interface PeopleCommandField { @@ -204,6 +204,7 @@ export function PeopleCommandForm({ includeReasonInInput = false, nestFields = false, }: PeopleCommandFormProps) { + const formId = useId(); const [result, submit, pending] = useActionState( submitPeopleCommandForm.bind(null, { commandId, @@ -222,61 +223,56 @@ export function PeopleCommandForm({ data-housekeeping-command={commandId} className="space-y-3 rounded border border-[var(--admin-border)] p-3" > - {fields.map((field) => ( - - ))} + {fields.map((field) => { + const fieldId = `${formId}-${field.name}`; + return ( + + ); + })} {requiresReason ? ( -