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(
|
||||
configuration: ContentCommandSubmission,
|
||||
_previous: HousekeepingResult<unknown> | null,
|
||||
_previous: ContentCommandFormState | null,
|
||||
formData: FormData,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const submittedFields = (configuration.fields ?? []).flatMap((field) => {
|
||||
): Promise<ContentCommandFormState> {
|
||||
const fields = configuration.fields ?? [];
|
||||
const submittedValues = readSubmittedValues(
|
||||
fields,
|
||||
configuration.requiresReason ?? false,
|
||||
formData,
|
||||
);
|
||||
const submittedFields = fields.flatMap((field) => {
|
||||
const value = parseField(field, formData);
|
||||
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
|
||||
});
|
||||
@@ -121,11 +150,31 @@ export async function submitContentCommandForm(
|
||||
const { executeHousekeepingCommand } = await import(
|
||||
"@/actions/housekeeping-command"
|
||||
);
|
||||
return executeHousekeepingCommand({
|
||||
const result = await executeHousekeepingCommand({
|
||||
commandId: configuration.commandId,
|
||||
input,
|
||||
...(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({
|
||||
@@ -136,7 +185,7 @@ export function ContentCommandForm({
|
||||
requiresReason = false,
|
||||
}: ContentCommandFormProps) {
|
||||
const formId = useId();
|
||||
const [result, submit, pending] = useActionState(
|
||||
const [state, submit, pending] = useActionState(
|
||||
submitContentCommandForm.bind(null, {
|
||||
commandId,
|
||||
input,
|
||||
@@ -145,121 +194,168 @@ export function ContentCommandForm({
|
||||
}),
|
||||
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 (
|
||||
<form
|
||||
key={remountKey}
|
||||
action={submit}
|
||||
data-housekeeping-command={commandId}
|
||||
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<label
|
||||
key={field.name}
|
||||
htmlFor={`${formId}-${field.name}`}
|
||||
className="block text-sm"
|
||||
>
|
||||
{field.type === "checkbox" && field.allowUnchanged ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue=""
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
<option value="">No change</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</>
|
||||
) : field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
defaultChecked={field.defaultValue === true}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
) : field.type === "select" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={
|
||||
typeof field.defaultValue === "boolean"
|
||||
? undefined
|
||||
: field.defaultValue
|
||||
}
|
||||
required={field.required}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
{field.required ? null : <option value="">No change</option>}
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : field.type === "textarea" || field.type === "json" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<textarea
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={
|
||||
typeof field.defaultValue === "boolean"
|
||||
? undefined
|
||||
: field.defaultValue
|
||||
}
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
{fields.map((field) => {
|
||||
const retainedValue = retainedValues[field.name];
|
||||
const errorId = `${formId}-${field.name}-error`;
|
||||
const hasError =
|
||||
!result?.ok &&
|
||||
Boolean(result?.error.fieldErrors?.[field.name]?.length);
|
||||
const fieldErrorProps = hasError
|
||||
? { "aria-invalid": true, "aria-describedby": errorId }
|
||||
: {};
|
||||
return (
|
||||
<label
|
||||
key={field.name}
|
||||
htmlFor={`${formId}-${field.name}`}
|
||||
className="block text-sm"
|
||||
>
|
||||
{field.type === "checkbox" && field.allowUnchanged ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={retainedValue ?? ""}
|
||||
className="mt-1 block w-full"
|
||||
{...fieldErrorProps}
|
||||
>
|
||||
<option value="">No change</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</>
|
||||
) : field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
defaultChecked={
|
||||
retainedValue === "on" ||
|
||||
(retainedValue === undefined && field.defaultValue === true)
|
||||
}
|
||||
{...fieldErrorProps}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
) : field.type === "select" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={
|
||||
retainedValue ??
|
||||
(typeof field.defaultValue === "boolean"
|
||||
? undefined
|
||||
: field.defaultValue)
|
||||
}
|
||||
required={field.required}
|
||||
className="mt-1 block w-full"
|
||||
{...fieldErrorProps}
|
||||
>
|
||||
{field.required ? null : <option value="">No change</option>}
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{requiresReason ? (
|
||||
<label htmlFor={`${formId}-reason`} className="block text-sm">
|
||||
Reason
|
||||
<textarea
|
||||
id={`${formId}-reason`}
|
||||
name="reason"
|
||||
defaultValue={retainedValues.reason}
|
||||
required
|
||||
maxLength={1000}
|
||||
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>
|
||||
) : null}
|
||||
<button type="submit" disabled={pending}>
|
||||
@@ -267,7 +363,8 @@ export function ContentCommandForm({
|
||||
</button>
|
||||
{result ? (
|
||||
<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>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
Reference in new issue
Block a user