feat(housekeeping): deliver content vertical
This commit is contained in:
1 parent
bdab924bdf
commit
fd68819d9b
67 files changed
+5742
-1916
No files matched your search
@@ -0,0 +1,208 @@
|
||||
"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;
|
||||
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;
|
||||
}
|
||||
|
||||
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||
const rawValue = formData.get(field.name);
|
||||
if (field.type === "checkbox") return rawValue === "on";
|
||||
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
||||
const raw = String(rawValue ?? "").normalize("NFC").trim();
|
||||
if (field.type === "number") {
|
||||
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<unknown> | null = null;
|
||||
|
||||
export async function submitContentCommandForm(
|
||||
configuration: ContentCommandSubmission,
|
||||
_previous: HousekeepingResult<unknown> | null,
|
||||
formData: FormData,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const input = {
|
||||
...configuration.input,
|
||||
...Object.fromEntries(
|
||||
(configuration.fields ?? []).map((field) => [
|
||||
field.name,
|
||||
parseField(field, formData),
|
||||
]),
|
||||
),
|
||||
};
|
||||
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 (
|
||||
<form
|
||||
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={`${commandId}-${field.name}`}
|
||||
className="block text-sm"
|
||||
>
|
||||
{field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
/> {field.label}
|
||||
</>
|
||||
) : field.type === "select" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={field.defaultValue}
|
||||
required={field.required}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
{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={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{field.label}
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "file"
|
||||
? "file"
|
||||
: "text"
|
||||
}
|
||||
defaultValue={
|
||||
field.type === "file" ? undefined : field.defaultValue
|
||||
}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{requiresReason ? (
|
||||
<label htmlFor={`${commandId}-reason`} className="block text-sm">
|
||||
Reason
|
||||
<textarea
|
||||
id={`${commandId}-reason`}
|
||||
name="reason"
|
||||
required
|
||||
maxLength={1000}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</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" : "Failed"} ({result.correlationId})
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user