"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>; 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 | null; readonly values: Readonly>; } const initialState: ContentCommandFormState = { result: null, values: {} }; function readSubmittedValues( fields: readonly ContentCommandField[], requiresReason: boolean, formData: FormData, ): Readonly> { const values: Record = {}; 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 { 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; 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 ( ); } 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 (
{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 (