feat(housekeeping): retain invalid command input

This commit is contained in:
Simo committed 2026-09-02 19:38:38 +02:00
1 parent f64b4d67b4
commit 3b2012e704
2 files changed
+326 -106

No files matched your search

@@ -99,14 +99,43 @@ function parseField(field: ContentCommandField, formData: FormData): unknown {
); );
} }
const initialState: HousekeepingResult<unknown> | null = null; export interface ContentCommandFormState {
readonly result: HousekeepingResult<unknown> | null;
readonly values: Readonly<Record<string, string>>;
}
const initialState: ContentCommandFormState = { result: null, values: {} };
function readSubmittedValues(
fields: readonly ContentCommandField[],
requiresReason: boolean,
formData: FormData,
): Readonly<Record<string, string>> {
const values: Record<string, string> = {};
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 (requiresReason) {
const reason = formData.get("reason");
if (typeof reason === "string") values.reason = reason;
}
return values;
}
export async function submitContentCommandForm( export async function submitContentCommandForm(
configuration: ContentCommandSubmission, configuration: ContentCommandSubmission,
_previous: HousekeepingResult<unknown> | null, _previous: ContentCommandFormState | null,
formData: FormData, formData: FormData,
): Promise<HousekeepingResult<unknown>> { ): Promise<ContentCommandFormState> {
const submittedFields = (configuration.fields ?? []).flatMap((field) => { const fields = configuration.fields ?? [];
const submittedValues = readSubmittedValues(
fields,
configuration.requiresReason ?? false,
formData,
);
const submittedFields = fields.flatMap((field) => {
const value = parseField(field, formData); const value = parseField(field, formData);
return value === OMIT_FIELD ? [] : [[field.name, value] as const]; return value === OMIT_FIELD ? [] : [[field.name, value] as const];
}); });
@@ -121,11 +150,31 @@ export async function submitContentCommandForm(
const { executeHousekeepingCommand } = await import( const { executeHousekeepingCommand } = await import(
"@/actions/housekeeping-command" "@/actions/housekeeping-command"
); );
return executeHousekeepingCommand({ const result = await executeHousekeepingCommand({
commandId: configuration.commandId, commandId: configuration.commandId,
input, input,
...(configuration.requiresReason ? { reason } : {}), ...(configuration.requiresReason ? { reason } : {}),
}); });
return { result, values: result.ok ? {} : submittedValues };
}
export function ContentCommandFieldError({
result,
fieldName,
errorId,
}: {
readonly result: HousekeepingResult<unknown>;
readonly fieldName: string;
readonly errorId: string;
}) {
if (result.ok) return null;
const messages = result.error.fieldErrors?.[fieldName];
if (!messages || messages.length === 0) return null;
return (
<p id={errorId} role="alert" className="text-xs text-[var(--admin-error)]">
{messages.join(" · ")}
</p>
);
} }
export function ContentCommandForm({ export function ContentCommandForm({
@@ -136,7 +185,7 @@ export function ContentCommandForm({
requiresReason = false, requiresReason = false,
}: ContentCommandFormProps) { }: ContentCommandFormProps) {
const formId = useId(); const formId = useId();
const [result, submit, pending] = useActionState( const [state, submit, pending] = useActionState(
submitContentCommandForm.bind(null, { submitContentCommandForm.bind(null, {
commandId, commandId,
input, input,
@@ -145,121 +194,168 @@ export function ContentCommandForm({
}), }),
initialState, initialState,
); );
const result = state.result;
const retainedValues = result && !result.ok ? state.values : {};
const remountKey = `${formId}-${result?.correlationId ?? "initial"}`;
const reasonErrorId = `${formId}-reason-error`;
const hasReasonError =
!result || result.ok
? false
: Boolean(result.error.fieldErrors?.reason?.length);
return ( return (
<form <form
key={remountKey}
action={submit} action={submit}
data-housekeeping-command={commandId} data-housekeeping-command={commandId}
className="space-y-3 rounded border border-[var(--admin-border)] p-3" className="space-y-3 rounded border border-[var(--admin-border)] p-3"
> >
{fields.map((field) => ( {fields.map((field) => {
<label const retainedValue = retainedValues[field.name];
key={field.name} const errorId = `${formId}-${field.name}-error`;
htmlFor={`${formId}-${field.name}`} const hasError =
className="block text-sm" !result?.ok &&
> Boolean(result?.error.fieldErrors?.[field.name]?.length);
{field.type === "checkbox" && field.allowUnchanged ? ( const fieldErrorProps = hasError
<> ? { "aria-invalid": true, "aria-describedby": errorId }
{field.label} : {};
<select return (
id={`${formId}-${field.name}`} <label
name={field.name} key={field.name}
defaultValue="" htmlFor={`${formId}-${field.name}`}
className="mt-1 block w-full" className="block text-sm"
> >
<option value="">No change</option> {field.type === "checkbox" && field.allowUnchanged ? (
<option value="true">Enabled</option> <>
<option value="false">Disabled</option> {field.label}
</select> <select
</> id={`${formId}-${field.name}`}
) : field.type === "checkbox" ? ( name={field.name}
<> defaultValue={retainedValue ?? ""}
<input className="mt-1 block w-full"
id={`${formId}-${field.name}`} {...fieldErrorProps}
name={field.name} >
type="checkbox" <option value="">No change</option>
defaultChecked={field.defaultValue === true} <option value="true">Enabled</option>
/>{" "} <option value="false">Disabled</option>
{field.label} </select>
</> </>
) : field.type === "select" ? ( ) : field.type === "checkbox" ? (
<> <>
{field.label} <input
<select id={`${formId}-${field.name}`}
id={`${formId}-${field.name}`} name={field.name}
name={field.name} type="checkbox"
defaultValue={ defaultChecked={
typeof field.defaultValue === "boolean" retainedValue === "on" ||
? undefined (retainedValue === undefined && field.defaultValue === true)
: field.defaultValue }
} {...fieldErrorProps}
required={field.required} />{" "}
className="mt-1 block w-full" {field.label}
> </>
{field.required ? null : <option value="">No change</option>} ) : field.type === "select" ? (
{field.options?.map((option) => ( <>
<option key={option.value} value={option.value}> {field.label}
{option.label} <select
</option> id={`${formId}-${field.name}`}
))} name={field.name}
</select> defaultValue={
</> retainedValue ??
) : field.type === "textarea" || field.type === "json" ? ( (typeof field.defaultValue === "boolean"
<> ? undefined
{field.label} : field.defaultValue)
<textarea }
id={`${formId}-${field.name}`} required={field.required}
name={field.name} className="mt-1 block w-full"
defaultValue={ {...fieldErrorProps}
typeof field.defaultValue === "boolean" >
? undefined {field.required ? null : <option value="">No change</option>}
: field.defaultValue {field.options?.map((option) => (
} <option key={option.value} value={option.value}>
required={field.required} {option.label}
maxLength={field.maxLength} </option>
className="mt-1 block w-full" ))}
</select>
</>
) : field.type === "textarea" || field.type === "json" ? (
<>
{field.label}
<textarea
id={`${formId}-${field.name}`}
name={field.name}
defaultValue={
retainedValue ??
(typeof field.defaultValue === "boolean"
? undefined
: field.defaultValue)
}
required={field.required}
maxLength={field.maxLength}
className="mt-1 block w-full"
{...fieldErrorProps}
/>
</>
) : (
<>
{field.label}
<input
id={`${formId}-${field.name}`}
name={field.name}
type={
field.type === "number"
? "number"
: field.type === "datetime-local"
? "datetime-local"
: field.type === "file"
? "file"
: "text"
}
defaultValue={
field.type === "file" ||
typeof field.defaultValue === "boolean"
? undefined
: (retainedValue ?? field.defaultValue)
}
required={field.required}
min={field.min}
max={field.max}
maxLength={field.maxLength}
className="mt-1 block w-full"
{...fieldErrorProps}
/>
</>
)}
{result ? (
<ContentCommandFieldError
result={result}
fieldName={field.name}
errorId={errorId}
/> />
</> ) : null}
) : ( </label>
<> );
{field.label} })}
<input
id={`${formId}-${field.name}`}
name={field.name}
type={
field.type === "number"
? "number"
: field.type === "datetime-local"
? "datetime-local"
: field.type === "file"
? "file"
: "text"
}
defaultValue={
field.type === "file" ||
typeof field.defaultValue === "boolean"
? undefined
: field.defaultValue
}
required={field.required}
min={field.min}
max={field.max}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
)}
</label>
))}
{requiresReason ? ( {requiresReason ? (
<label htmlFor={`${formId}-reason`} className="block text-sm"> <label htmlFor={`${formId}-reason`} className="block text-sm">
Reason Reason
<textarea <textarea
id={`${formId}-reason`} id={`${formId}-reason`}
name="reason" name="reason"
defaultValue={retainedValues.reason}
required required
maxLength={1000} maxLength={1000}
className="mt-1 block w-full" className="mt-1 block w-full"
aria-invalid={hasReasonError || undefined}
aria-describedby={hasReasonError ? reasonErrorId : undefined}
/> />
{result ? (
<ContentCommandFieldError
result={result}
fieldName="reason"
errorId={reasonErrorId}
/>
) : null}
</label> </label>
) : null} ) : null}
<button type="submit" disabled={pending}> <button type="submit" disabled={pending}>
@@ -267,7 +363,8 @@ export function ContentCommandForm({
</button> </button>
{result ? ( {result ? (
<p role="status" className="text-xs text-[var(--admin-text-muted)]"> <p role="status" className="text-xs text-[var(--admin-text-muted)]">
{result.ok ? "Completed" : "Failed"} ({result.correlationId}) {result.ok ? "Completed" : result.error.messageKey} (
{result.correlationId})
</p> </p>
) : null} ) : null}
</form> </form>
@@ -9,6 +9,7 @@ import {
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import { ContentBrandPage } from "./brand"; import { ContentBrandPage } from "./brand";
import { import {
ContentCommandFieldError,
ContentCommandForm, ContentCommandForm,
submitContentCommandForm, submitContentCommandForm,
} from "./content-command-form"; } from "./content-command-form";
@@ -125,6 +126,128 @@ describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
describe("Content actionable form wiring", () => { describe("Content actionable form wiring", () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
it("retains configured string values after validation and excludes files", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
fail("VALIDATION", "errors.housekeeping.validation", "poll-validation", {
title: ["errors.validation.invalid"],
}),
);
const formData = new FormData();
formData.set("title", "Draft title");
formData.set("cover", new Blob(["asset"]), "cover.png");
const state = await submitContentCommandForm(
{
commandId: "content.engagement.poll.change",
input: { action: "create" },
fields: [
{ name: "title", label: "Title", type: "text", required: true },
{ name: "cover", label: "Cover", type: "file" },
],
},
null,
formData,
);
expect(state).toMatchObject({
result: { ok: false, error: { code: "VALIDATION" } },
values: { title: "Draft title" },
});
expect(state).not.toHaveProperty("values.cover");
});
it("retains submitted fields and reason after a conflict", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
fail("CONFLICT", "errors.housekeeping.conflict", "poll-conflict"),
);
const formData = new FormData();
formData.set("title", "Conflicting draft");
formData.set("reason", "Keep the local wording");
const state = await submitContentCommandForm(
{
commandId: "content.engagement.poll.change",
input: { action: "update", id: "41" },
fields: [{ name: "title", label: "Title", type: "text" }],
requiresReason: true,
},
null,
formData,
);
expect(state).toMatchObject({
result: { ok: false, error: { code: "CONFLICT" } },
values: {
title: "Conflicting draft",
reason: "Keep the local wording",
},
});
});
it("clears retained values after a successful command", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { id: "41" } }, "poll-success"),
);
const formData = new FormData();
formData.set("title", "Published poll");
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: true }, values: {} });
});
it("renders an accessible stable field error", () => {
const html = renderToStaticMarkup(
<ContentCommandFieldError
result={fail(
"VALIDATION",
"errors.housekeeping.validation",
"poll-validation",
{ title: ["errors.validation.invalid"] },
)}
fieldName="title"
errorId="poll-validation-title-error"
/>,
);
expect(html).toContain('id="poll-validation-title-error"');
expect(html).toContain('role="alert"');
expect(html).toContain("errors.validation.invalid");
});
it("generates distinct control IDs for multiple forms on one page", () => {
const html = renderToStaticMarkup(
<>
<ContentCommandForm
commandId="content.engagement.poll.change"
buttonLabel="Create poll"
input={{ action: "create" }}
fields={[{ name: "title", label: "Title", type: "text" }]}
/>
<ContentCommandForm
commandId="content.engagement.poll.change"
buttonLabel="Update poll"
input={{ action: "update", id: "41" }}
fields={[{ name: "title", label: "Title", type: "text" }]}
/>
</>,
);
const titleIds = [...html.matchAll(/id="([^"]+-title)"/gu)].map(
(match) => match[1],
);
expect(titleIds).toHaveLength(2);
expect(new Set(titleIds).size).toBe(2);
});
it("renders and submits native event date-time fields", async () => { it("renders and submits native event date-time fields", async () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<ContentCommandForm <ContentCommandForm
@@ -244,7 +367,7 @@ describe("Content actionable form wiring", () => {
}, },
reason: "r".repeat(1000), reason: "r".repeat(1000),
}); });
expect(result).toMatchObject({ ok: true }); expect(result).toMatchObject({ result: { ok: true }, values: {} });
}); });
it("parses structured JSON fields before dispatching real brand and localization forms", async () => { it("parses structured JSON fields before dispatching real brand and localization forms", async () => {