379 lines
10 KiB
TypeScript
379 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useActionState, useId } from "react";
|
|
import type { HousekeepingResult } from "../../../foundation/contracts";
|
|
|
|
export interface ContentCommandField {
|
|
readonly name: string;
|
|
readonly label: string;
|
|
readonly type:
|
|
| "identifier"
|
|
| "json"
|
|
| "text"
|
|
| "textarea"
|
|
| "number"
|
|
| "datetime-local"
|
|
| "checkbox"
|
|
| "select"
|
|
| "file";
|
|
readonly required?: boolean;
|
|
readonly min?: number;
|
|
readonly max?: number;
|
|
readonly maxLength?: number;
|
|
readonly defaultValue?: string | number | boolean;
|
|
readonly allowUnchanged?: boolean;
|
|
readonly emptyValue?: null | "";
|
|
readonly options?: readonly Readonly<{
|
|
value: string | number;
|
|
label: string;
|
|
}>[];
|
|
}
|
|
|
|
export interface ContentCommandSubmission {
|
|
readonly commandId: string;
|
|
readonly input: Readonly<Record<string, unknown>>;
|
|
readonly fields?: readonly ContentCommandField[];
|
|
readonly requiresReason?: boolean;
|
|
}
|
|
|
|
interface ContentCommandFormProps extends ContentCommandSubmission {
|
|
readonly buttonLabel: string;
|
|
}
|
|
|
|
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
|
|
: OMIT_FIELD;
|
|
}
|
|
|
|
function normalizeUtcDateTimeLocal(value: string): string {
|
|
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/u.test(value)) return value;
|
|
const iso = `${value}:00.000Z`;
|
|
const parsed = new Date(iso);
|
|
return Number.isFinite(parsed.getTime()) && parsed.toISOString() === iso
|
|
? iso
|
|
: value;
|
|
}
|
|
|
|
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
|
const rawValue = formData.get(field.name);
|
|
if (field.type === "checkbox") {
|
|
if (field.allowUnchanged) {
|
|
if (rawValue === null || rawValue === "") return OMIT_FIELD;
|
|
if (rawValue === "true") return true;
|
|
if (rawValue === "false") return false;
|
|
return OMIT_FIELD;
|
|
}
|
|
return rawValue === "on";
|
|
}
|
|
if (rawValue === null && !field.required) return optionalEmptyValue(field);
|
|
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
|
const raw = String(rawValue ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
if (!raw && !field.required) return optionalEmptyValue(field);
|
|
if (field.type === "number") {
|
|
if (!raw) return raw;
|
|
const value = Number(raw);
|
|
if (!Number.isSafeInteger(value)) return 0;
|
|
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
|
}
|
|
if (field.type === "select") {
|
|
const selected = field.options?.find(
|
|
(option) => String(option.value) === raw,
|
|
)?.value;
|
|
return selected ?? raw.slice(0, 500);
|
|
}
|
|
if (field.type === "datetime-local") return normalizeUtcDateTimeLocal(raw);
|
|
if (field.type === "json") {
|
|
try {
|
|
return JSON.parse(raw.slice(0, field.maxLength ?? 20_000));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return raw.slice(
|
|
0,
|
|
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
|
|
);
|
|
}
|
|
|
|
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;
|
|
} else if (field.type === "checkbox" && !field.allowUnchanged) {
|
|
values[field.name] = UNCHECKED_CHECKBOX_VALUE;
|
|
}
|
|
}
|
|
if (requiresReason) {
|
|
const reason = formData.get("reason");
|
|
if (typeof reason === "string") values.reason = reason;
|
|
}
|
|
return values;
|
|
}
|
|
|
|
export async function submitContentCommandForm(
|
|
configuration: ContentCommandSubmission,
|
|
_previous: ContentCommandFormState | null,
|
|
formData: FormData,
|
|
): 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];
|
|
});
|
|
const input = {
|
|
...configuration.input,
|
|
...Object.fromEntries(submittedFields),
|
|
};
|
|
const reason = String(formData.get("reason") ?? "")
|
|
.normalize("NFC")
|
|
.trim()
|
|
.slice(0, 1000);
|
|
const { executeHousekeepingCommand } = await import(
|
|
"@/actions/housekeeping-command"
|
|
);
|
|
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({
|
|
commandId,
|
|
buttonLabel,
|
|
input,
|
|
fields = [],
|
|
requiresReason = false,
|
|
}: ContentCommandFormProps) {
|
|
const formId = useId();
|
|
const [state, submit, pending] = useActionState(
|
|
submitContentCommandForm.bind(null, {
|
|
commandId,
|
|
input,
|
|
fields,
|
|
requiresReason,
|
|
}),
|
|
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) => {
|
|
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 === undefined
|
|
? field.defaultValue === true
|
|
: retainedValue === "on"
|
|
}
|
|
{...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}
|
|
/>
|
|
) : 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}>
|
|
{pending ? "Working..." : buttonLabel}
|
|
</button>
|
|
{result ? (
|
|
<p role="status" className="text-xs text-[var(--admin-text-muted)]">
|
|
{result.ok ? "Completed" : result.error.messageKey} (
|
|
{result.correlationId})
|
|
</p>
|
|
) : null}
|
|
</form>
|
|
);
|
|
}
|