"use client"; import * as React from "react"; import { DndContext, type DragEndEvent, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical, Trash2 } from "lucide-react"; import type { inferRouterOutputs } from "@trpc/server"; import type { AppRouter } from "@/server/root"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import type { TemplateSchemaJson } from "./template-picker"; type TemplateRow = inferRouterOutputs["templates"]["getById"]; const TARGET_TYPES = ["task", "document", "project"] as const; const FIELD_TYPES = [ "text", "textarea", "number", "date", "select", "checkbox", "url", "email", ] as const; type PropertyRow = { id: string; name: string; fieldType: string; defaultValue: string; }; function newPropertyRow(): PropertyRow { return { id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `p-${Date.now()}-${Math.random().toString(16).slice(2)}`, name: "", fieldType: "text", defaultValue: "", }; } function schemaToRows(schema: TemplateSchemaJson | null | undefined): PropertyRow[] { const props = schema?.properties ?? []; return props.map((p, i) => ({ id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `p-${i}`, name: p.name, fieldType: p.fieldType, defaultValue: p.defaultValue === undefined || p.defaultValue === null ? "" : typeof p.defaultValue === "string" ? p.defaultValue : JSON.stringify(p.defaultValue), })); } function rowsToSchema( rows: PropertyRow[], defaultContent: string, ): TemplateSchemaJson { return { properties: rows .filter((r) => r.name.trim() !== "") .map((r) => { let defaultValue: unknown = r.defaultValue; if (r.fieldType === "number" && r.defaultValue.trim() !== "") { const n = Number(r.defaultValue); defaultValue = Number.isFinite(n) ? n : r.defaultValue; } else if (r.fieldType === "checkbox") { defaultValue = r.defaultValue === "true" || r.defaultValue === "1"; } else if (r.defaultValue.trim() === "") { defaultValue = undefined; } return { name: r.name.trim(), fieldType: r.fieldType, ...(defaultValue !== undefined ? { defaultValue } : {}), }; }), ...(defaultContent.trim() !== "" ? { defaultContent } : {}), }; } function SortablePropertyRow({ row, onChange, onRemove, }: { row: PropertyRow; onChange: (id: string, patch: Partial) => void; onRemove: (id: string) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id }); const style = { transform: CSS.Transform.toString(transform), transition, }; return (
onChange(row.id, { name: e.target.value })} placeholder="Property name" />
onChange(row.id, { defaultValue: e.target.value })} placeholder="Optional" />
); } export type TemplateEditorProps = { template?: TemplateRow; workspaceId: string; onSave: () => void; }; export function TemplateEditor({ template, workspaceId, onSave }: TemplateEditorProps) { const [name, setName] = React.useState(template?.name ?? ""); const [targetType, setTargetType] = React.useState( template?.targetType && TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number]) ? template.targetType : "task", ); const [defaultContent, setDefaultContent] = React.useState( (template?.schema as TemplateSchemaJson | null | undefined)?.defaultContent ?? "", ); const [rows, setRows] = React.useState(() => template?.schema ? schemaToRows(template.schema as TemplateSchemaJson) : [newPropertyRow()], ); React.useEffect(() => { if (!template) return; setName(template.name); setTargetType( TARGET_TYPES.includes(template.targetType as (typeof TARGET_TYPES)[number]) ? template.targetType : "task", ); const sch = template.schema as TemplateSchemaJson | null | undefined; setDefaultContent(sch?.defaultContent ?? ""); setRows(schemaToRows(sch)); }, [template]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const createMut = api.templates.create.useMutation({ onSuccess: onSave }); const updateMut = api.templates.update.useMutation({ onSuccess: onSave }); const pending = createMut.isPending || updateMut.isPending; const updateRow = React.useCallback((id: string, patch: Partial) => { setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); }, []); const removeRow = React.useCallback((id: string) => { setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== id))); }, []); const onDragEnd = React.useCallback((event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; setRows((items) => { const oldIndex = items.findIndex((i) => i.id === active.id); const newIndex = items.findIndex((i) => i.id === over.id); if (oldIndex < 0 || newIndex < 0) return items; return arrayMove(items, oldIndex, newIndex); }); }, []); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const schema = rowsToSchema(rows, defaultContent); if (!name.trim()) return; if (template?.id) { updateMut.mutate({ id: template.id, name: name.trim(), schema, }); } else { createMut.mutate({ workspaceId, name: name.trim(), targetType, schema, }); } }; return (
setName(e.target.value)} placeholder="Template name" />
{template?.id ? (

Target type cannot be changed after creation.

) : null}
Properties
r.id)} strategy={verticalListSortingStrategy}>
{rows.map((row) => ( ))}