feat(housekeeping): complete content events workflow
This commit is contained in:
1 parent
f2e67ebace
commit
d6235c37a1
42 files changed
+2868
-307
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useActionState, useId } from "react";
|
||||
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||
|
||||
export interface ContentCommandField {
|
||||
@@ -12,6 +12,7 @@ export interface ContentCommandField {
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "datetime-local"
|
||||
| "checkbox"
|
||||
| "select"
|
||||
| "file";
|
||||
@@ -21,6 +22,7 @@ export interface ContentCommandField {
|
||||
readonly maxLength?: number;
|
||||
readonly defaultValue?: string | number | boolean;
|
||||
readonly allowUnchanged?: boolean;
|
||||
readonly emptyValue?: null | "";
|
||||
readonly options?: readonly Readonly<{
|
||||
value: string | number;
|
||||
label: string;
|
||||
@@ -39,6 +41,20 @@ interface ContentCommandFormProps extends ContentCommandSubmission {
|
||||
}
|
||||
|
||||
const OMIT_FIELD = Symbol("omit optional Content command field");
|
||||
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);
|
||||
@@ -51,12 +67,12 @@ function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||
}
|
||||
return rawValue === "on";
|
||||
}
|
||||
if (rawValue === null && !field.required) return OMIT_FIELD;
|
||||
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 OMIT_FIELD;
|
||||
if (!raw && !field.required) return optionalEmptyValue(field);
|
||||
if (field.type === "number") {
|
||||
if (!raw) return raw;
|
||||
const value = Number(raw);
|
||||
@@ -69,6 +85,7 @@ function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||
)?.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));
|
||||
@@ -118,6 +135,7 @@ export function ContentCommandForm({
|
||||
fields = [],
|
||||
requiresReason = false,
|
||||
}: ContentCommandFormProps) {
|
||||
const formId = useId();
|
||||
const [result, submit, pending] = useActionState(
|
||||
submitContentCommandForm.bind(null, {
|
||||
commandId,
|
||||
@@ -136,14 +154,14 @@ export function ContentCommandForm({
|
||||
{fields.map((field) => (
|
||||
<label
|
||||
key={field.name}
|
||||
htmlFor={`${commandId}-${field.name}`}
|
||||
htmlFor={`${formId}-${field.name}`}
|
||||
className="block text-sm"
|
||||
>
|
||||
{field.type === "checkbox" && field.allowUnchanged ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${commandId}-${field.name}`}
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue=""
|
||||
className="mt-1 block w-full"
|
||||
@@ -156,7 +174,7 @@ export function ContentCommandForm({
|
||||
) : field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
defaultChecked={field.defaultValue === true}
|
||||
@@ -167,7 +185,7 @@ export function ContentCommandForm({
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={`${commandId}-${field.name}`}
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={
|
||||
typeof field.defaultValue === "boolean"
|
||||
@@ -189,7 +207,7 @@ export function ContentCommandForm({
|
||||
<>
|
||||
{field.label}
|
||||
<textarea
|
||||
id={`${commandId}-${field.name}`}
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={
|
||||
typeof field.defaultValue === "boolean"
|
||||
@@ -205,14 +223,16 @@ export function ContentCommandForm({
|
||||
<>
|
||||
{field.label}
|
||||
<input
|
||||
id={`${commandId}-${field.name}`}
|
||||
id={`${formId}-${field.name}`}
|
||||
name={field.name}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "file"
|
||||
? "file"
|
||||
: "text"
|
||||
: field.type === "datetime-local"
|
||||
? "datetime-local"
|
||||
: field.type === "file"
|
||||
? "file"
|
||||
: "text"
|
||||
}
|
||||
defaultValue={
|
||||
field.type === "file" ||
|
||||
@@ -231,10 +251,10 @@ export function ContentCommandForm({
|
||||
</label>
|
||||
))}
|
||||
{requiresReason ? (
|
||||
<label htmlFor={`${commandId}-reason`} className="block text-sm">
|
||||
<label htmlFor={`${formId}-reason`} className="block text-sm">
|
||||
Reason
|
||||
<textarea
|
||||
id={`${commandId}-reason`}
|
||||
id={`${formId}-reason`}
|
||||
name="reason"
|
||||
required
|
||||
maxLength={1000}
|
||||
|
||||
Reference in new issue
Block a user