feat(housekeeping): retain invalid command input
This commit is contained in:
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 () => {
|
||||||
|
|||||||
Reference in new issue
Block a user