Files
EpicNext-Cms/src/features/housekeeping/domains/content/pages/content-command-form.tsx
T

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>
);
}