import { Button, Dialog, Input, InputArea, Select, Switch } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { TextT, TextAlignLeft, Hash, ToggleLeft, Calendar, List, ListChecks, FileText, Image as ImageIcon, File, LinkSimple, BracketsCurly, Link, GlobeSimple, Rows, Plus, Trash, X, } from "@phosphor-icons/react"; import * as React from "react"; import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api"; import { cn } from "../lib/utils"; import { AllowedTypesEditor } from "./AllowedTypesEditor"; // ============================================================================ // Constants // ============================================================================ const SLUG_INVALID_CHARS_REGEX = /[^a-z0-9]+/g; const SLUG_LEADING_TRAILING_REGEX = /^_|_$/g; // ============================================================================ // Types // ============================================================================ export interface FieldEditorProps { open: boolean; onOpenChange: (open: boolean) => void; field?: SchemaField; onSave: (input: CreateFieldInput) => void; isSaving?: boolean; } interface FieldTypeConfig { type: FieldType; label: string; description: string; icon: React.ElementType; } interface RepeaterSubFieldState { slug: string; type: string; label: string; required: boolean; } interface FieldFormState { step: "type" | "config"; selectedType: FieldType | null; slug: string; label: string; required: boolean; unique: boolean; searchable: boolean; minLength: string; maxLength: string; min: string; max: string; pattern: string; options: string; subFields: RepeaterSubFieldState[]; minItems: string; maxItems: string; allowedMimeTypes: string[]; } function getInitialFormState(field?: SchemaField): FieldFormState { if (field) { return { step: "config", selectedType: field.type, slug: field.slug, label: field.label, required: field.required, unique: field.unique, searchable: field.searchable, minLength: field.validation?.minLength?.toString() ?? "", maxLength: field.validation?.maxLength?.toString() ?? "", min: field.validation?.min?.toString() ?? "", max: field.validation?.max?.toString() ?? "", pattern: field.validation?.pattern ?? "", options: field.validation?.options?.join("\n") ?? "", subFields: (field.validation as Record)?.subFields ? ((field.validation as Record).subFields as RepeaterSubFieldState[]) : [], minItems: (field.validation as Record)?.minItems?.toString() ?? "", maxItems: (field.validation as Record)?.maxItems?.toString() ?? "", allowedMimeTypes: field.validation?.allowedMimeTypes ?? [], }; } return { step: "type", selectedType: null, slug: "", label: "", required: false, unique: false, searchable: false, minLength: "", maxLength: "", min: "", max: "", pattern: "", options: "", subFields: [], minItems: "", maxItems: "", allowedMimeTypes: [], }; } /** * Field editor dialog for creating/editing fields */ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: FieldEditorProps) { const { t } = useLingui(); const [formState, setFormState] = React.useState(() => getInitialFormState(field)); // Reset state when dialog opens React.useEffect(() => { if (open) { setFormState(getInitialFormState(field)); } }, [open, field]); const { step, selectedType, slug, label, required, unique, searchable } = formState; const { minLength, maxLength, min, max, pattern, options } = formState; const setField = (key: K, value: FieldFormState[K]) => setFormState((prev) => ({ ...prev, [key]: value })); // Build field types inside the component so t`` works const FIELD_TYPES: FieldTypeConfig[] = [ { type: "string", label: t`Short Text`, description: t`Single line text input`, icon: TextT, }, { type: "text", label: t`Long Text`, description: t`Multi-line plain text`, icon: TextAlignLeft, }, { type: "number", label: t`Number`, description: t`Decimal number`, icon: Hash, }, { type: "integer", label: t`Integer`, description: t`Whole number`, icon: Hash, }, { type: "boolean", label: t`Boolean`, description: t`True/false toggle`, icon: ToggleLeft, }, { type: "datetime", label: t`Date & Time`, description: t`Date and time picker`, icon: Calendar, }, { type: "select", label: t`Select`, description: t`Single choice from options`, icon: List, }, { type: "multiSelect", label: t`Multi Select`, description: t`Multiple choices from options`, icon: ListChecks, }, { type: "portableText", label: t`Rich Text`, description: t`Rich text editor`, icon: FileText, }, { type: "image", label: t`Image`, description: t`Image from media library`, icon: ImageIcon, }, { type: "file", label: t`File`, description: t`File from media library`, icon: File, }, { type: "reference", label: t`Reference`, description: t`Link to another content item`, icon: LinkSimple, }, { type: "json", label: t`JSON`, description: t`Arbitrary JSON data`, icon: BracketsCurly, }, { type: "slug", label: t`Slug`, description: t`URL-friendly identifier`, icon: Link, }, { type: "url", label: t`URL`, description: t`Web address`, icon: GlobeSimple, }, { type: "repeater", label: t`Repeater`, description: t`Repeating group of fields`, icon: Rows, }, ]; // Auto-generate slug from label const handleLabelChange = (value: string) => { setField("label", value); if (!field) { // Only auto-generate for new fields setField( "slug", value .toLowerCase() .replace(SLUG_INVALID_CHARS_REGEX, "_") .replace(SLUG_LEADING_TRAILING_REGEX, ""), ); } }; const handleTypeSelect = (type: FieldType) => { setFormState((prev) => ({ ...prev, selectedType: type, step: "config" })); }; const handleSave = () => { if (!selectedType || !slug || !label) return; const validation: CreateFieldInput["validation"] = {}; // Build validation based on field type if (selectedType === "string" || selectedType === "text" || selectedType === "slug") { if (minLength) validation.minLength = parseInt(minLength, 10); if (maxLength) validation.maxLength = parseInt(maxLength, 10); if (pattern) validation.pattern = pattern; } if (selectedType === "number" || selectedType === "integer") { if (min) validation.min = parseFloat(min); if (max) validation.max = parseFloat(max); } if (selectedType === "select" || selectedType === "multiSelect") { const optionList = options .split("\n") .map((o) => o.trim()) .filter(Boolean); if (optionList.length > 0) { validation.options = optionList; } } if (selectedType === "repeater") { if (formState.subFields.length > 0) { (validation as Record).subFields = formState.subFields.map((sf) => ({ slug: sf.slug, type: sf.type, label: sf.label, required: sf.required || undefined, })); } if (formState.minItems) (validation as Record).minItems = parseInt(formState.minItems, 10); if (formState.maxItems) (validation as Record).maxItems = parseInt(formState.maxItems, 10); } if ( (selectedType === "file" || selectedType === "image") && formState.allowedMimeTypes.length > 0 ) { validation.allowedMimeTypes = formState.allowedMimeTypes; } // Only include searchable for text-based fields const isSearchableType = selectedType === "string" || selectedType === "text" || selectedType === "portableText" || selectedType === "slug" || selectedType === "url"; const input: CreateFieldInput = { slug, label, type: selectedType, required, unique, searchable: isSearchableType ? searchable : undefined, validation: Object.keys(validation).length > 0 ? validation : null, }; onSave(input); }; const typeConfig = FIELD_TYPES.find((fieldType) => fieldType.type === selectedType); return (
{field ? t`Edit Field` : step === "type" ? t`Add Field` : t`Configure Field`} ( )} />
{step === "type" ? (
{FIELD_TYPES.map((ft) => { const Icon = ft.icon; return ( ); })}
) : (
{/* Type indicator */} {typeConfig && (

{typeConfig.label}

{typeConfig.description}

{!field && ( )}
)} {/* Basic info */}
handleLabelChange(e.target.value)} placeholder={t`Field Label`} />
setField("slug", e.target.value)} placeholder="field_slug" disabled={!!field} /> {field && (

{t`Field slugs cannot be changed after creation`}

)}
{/* Toggles */}
setField("required", checked)} label={{t`Required`}} /> setField("unique", checked)} label={{t`Unique`}} /> {(selectedType === "string" || selectedType === "text" || selectedType === "portableText" || selectedType === "slug" || selectedType === "url") && ( setField("searchable", checked)} label={{t`Searchable`}} /> )}
{/* Type-specific validation */} {(selectedType === "string" || selectedType === "text" || selectedType === "slug") && (

{t`Validation`}

setField("minLength", e.target.value)} placeholder={t`No minimum`} /> setField("maxLength", e.target.value)} placeholder={t`No maximum`} />
{selectedType === "string" && ( setField("pattern", e.target.value)} placeholder="^[a-z]+$" /> )}
)} {(selectedType === "number" || selectedType === "integer") && (

{t`Validation`}

setField("min", e.target.value)} placeholder={t`No minimum`} /> setField("max", e.target.value)} placeholder={t`No maximum`} />
)} {(selectedType === "select" || selectedType === "multiSelect") && ( setField("options", e.target.value)} placeholder={t`Option 1\nOption 2\nOption 3`} rows={5} /> )} {selectedType === "repeater" && (

{t`Sub-Fields`}

{formState.subFields.length === 0 && (

{t`Add at least one sub-field to define the repeater structure.`}

)} {formState.subFields.map((sf, i) => (
{ const updated = [...formState.subFields]; updated[i] = { ...sf, label: e.target.value, slug: e.target.value .toLowerCase() .replace(SLUG_INVALID_CHARS_REGEX, "_") .replace(SLUG_LEADING_TRAILING_REGEX, ""), }; setFormState((prev) => ({ ...prev, subFields: updated })); }} placeholder={t`Field label`} />
setField("minItems", e.target.value)} placeholder="0" /> setField("maxItems", e.target.value)} placeholder={t`No limit`} />
)} {(selectedType === "file" || selectedType === "image") && ( setField("allowedMimeTypes", next)} /> )}
)} {step === "config" && (
)}
); }