"use client"; import { useActionState } from "react"; import type { HousekeepingResult } from "../../../foundation/contracts"; export interface ContentCommandField { readonly name: string; readonly label: string; readonly type: | "identifier" | "json" | "text" | "textarea" | "number" | "checkbox" | "select" | "file"; readonly required?: boolean; readonly min?: number; readonly max?: number; readonly maxLength?: number; readonly defaultValue?: string | number | boolean; readonly allowUnchanged?: boolean; 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"); 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 OMIT_FIELD; if (field.type === "file") return rawValue instanceof File ? rawValue : null; const raw = String(rawValue ?? "") .normalize("NFC") .trim(); if (!raw && !field.required) return OMIT_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 === "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), ); } const initialState: HousekeepingResult | null = null; export async function submitContentCommandForm( configuration: ContentCommandSubmission, _previous: HousekeepingResult | null, formData: FormData, ): Promise> { const submittedFields = (configuration.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" ); return executeHousekeepingCommand({ commandId: configuration.commandId, input, ...(configuration.requiresReason ? { reason } : {}), }); } export function ContentCommandForm({ commandId, buttonLabel, input, fields = [], requiresReason = false, }: ContentCommandFormProps) { const [result, submit, pending] = useActionState( submitContentCommandForm.bind(null, { commandId, input, fields, requiresReason, }), initialState, ); return (
{fields.map((field) => (