import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useCreatePack, useUpdatePack } from "@/hooks/usePacks"; import type { PackResponse } from "@/api"; import { labelToRef } from "@/lib/format-utils"; import SchemaBuilder from "@/components/common/SchemaBuilder"; import ParamSchemaForm from "@/components/common/ParamSchemaForm"; import { RotateCcw } from "lucide-react"; interface PackFormProps { pack?: PackResponse; onSuccess?: () => void; onCancel?: () => void; } export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) { const navigate = useNavigate(); const isEditing = !!pack; // Store initial/database state for reset const initialConfSchema = pack?.conf_schema || {}; const initialConfig = pack?.config || {}; // Form state const [ref, setRef] = useState(pack?.ref || ""); const [label, setLabel] = useState(pack?.label || ""); const [description, setDescription] = useState(pack?.description || ""); const [version, setVersion] = useState(pack?.version || "1.0.0"); const [tags, setTags] = useState(pack?.tags?.join(", ") || ""); const [deps, setDeps] = useState(pack?.runtime_deps?.join(", ") || ""); const [isStandard, setIsStandard] = useState(pack?.is_standard ?? false); const [configValues, setConfigValues] = useState>(initialConfig); const [confSchema, setConfSchema] = useState>(initialConfSchema); const [meta, setMeta] = useState( pack?.meta ? JSON.stringify(pack.meta, null, 2) : "{}", ); const [errors, setErrors] = useState>({}); // Mutations const createPack = useCreatePack(); const updatePack = useUpdatePack(); // Check if schema has properties (flat format: each key is a parameter name) const hasSchemaProperties = confSchema && typeof confSchema === "object" && Object.keys(confSchema).length > 0; // Sync config values when schema changes (for ad-hoc packs only) useEffect(() => { if (!isStandard && hasSchemaProperties) { // Get current schema property names (flat format: keys are parameter names) const schemaKeys = Object.keys(confSchema); // Create new config with only keys that exist in schema const syncedConfig: Record = {}; schemaKeys.forEach((key) => { if (configValues[key] !== undefined) { // Preserve existing value syncedConfig[key] = configValues[key]; } else { // Use default from schema if available const defaultValue = confSchema[key]?.default; if (defaultValue !== undefined) { syncedConfig[key] = defaultValue; } } }); // Only update if there's a difference const currentKeys = Object.keys(configValues).sort().join(","); const syncedKeys = Object.keys(syncedConfig).sort().join(","); if (currentKeys !== syncedKeys) { setConfigValues(syncedConfig); } } }, [confSchema, isStandard]); const validateForm = (): boolean => { const newErrors: Record = {}; if (!label.trim()) { newErrors.label = "Label is required"; } if (!ref.trim()) { newErrors.ref = "Reference is required"; } else if (!/^[a-z0-9_-]+$/.test(ref)) { newErrors.ref = "Reference must contain only lowercase letters, numbers, hyphens, and underscores"; } if (!version.trim()) { newErrors.version = "Version is required"; } // Validate conf_schema (flat format: each value should be an object defining a parameter) if (confSchema && typeof confSchema === "object") { for (const [key, val] of Object.entries(confSchema)) { if (!val || typeof val !== "object" || Array.isArray(val)) { newErrors.confSchema = `Invalid parameter definition for "${key}" — each parameter must be an object`; break; } } } // Validate meta JSON if (meta.trim()) { try { JSON.parse(meta); } catch (e) { newErrors.meta = "Invalid JSON format"; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) { return; } const parsedConfSchema = Object.keys(confSchema || {}).length > 0 ? confSchema : {}; const parsedMeta = meta.trim() ? JSON.parse(meta) : {}; const tagsList = tags .split(",") .map((t) => t.trim()) .filter((t) => t); const depsList: string[] = deps .split(",") .map((d: string) => d.trim()) .filter((d: string) => d); try { if (isEditing) { const updateData = { label: label.trim(), description: description.trim() || undefined, version: version.trim(), conf_schema: parsedConfSchema, config: configValues, meta: parsedMeta, tags: tagsList, runtime_deps: depsList, is_standard: isStandard, }; await updatePack.mutateAsync({ ref: pack!.ref, data: updateData }); if (onSuccess) { onSuccess(); } } else { const createData = { ref: ref.trim(), label: label.trim(), description: description.trim() || undefined, version: version.trim(), conf_schema: parsedConfSchema, config: configValues, meta: parsedMeta, tags: tagsList, runtime_deps: depsList, is_standard: isStandard, }; const newPackResponse = await createPack.mutateAsync(createData); const newPack = newPackResponse?.data; if (newPack?.ref) { navigate(`/packs/${newPack.ref}`); return; } if (onSuccess) { onSuccess(); } } } catch (error: any) { setErrors({ submit: error.response?.data?.message || error.message || "Failed to save pack", }); } }; const handleCancel = () => { if (onCancel) { onCancel(); } else { navigate("/packs"); } }; const handleReset = () => { setConfSchema(initialConfSchema); setConfigValues(initialConfig); }; const insertSchemaExample = (type: "api" | "database" | "webhook") => { let example: Record; switch (type) { case "api": example = { api_key: { type: "string", description: "API authentication key", required: true, secret: true, }, endpoint: { type: "string", description: "API endpoint URL", default: "https://api.example.com", }, }; break; case "database": example = { host: { type: "string", description: "Database host", default: "localhost", required: true, }, port: { type: "integer", description: "Database port", default: 5432, }, database: { type: "string", description: "Database name", required: true, }, username: { type: "string", description: "Database username", required: true, }, password: { type: "string", description: "Database password", required: true, secret: true, }, }; break; case "webhook": example = { webhook_url: { type: "string", description: "Webhook destination URL", required: true, }, auth_token: { type: "string", description: "Authentication token", secret: true, }, timeout: { type: "integer", description: "Request timeout in seconds", minimum: 1, maximum: 300, default: 30, }, }; break; } // Update schema setConfSchema(example); // Immediately sync config values with schema defaults const syncedConfig: Record = {}; Object.entries(example).forEach(([key, propDef]: [string, any]) => { if (propDef.default !== undefined) { syncedConfig[key] = propDef.default; } }); setConfigValues(syncedConfig); }; return (
{errors.submit && (

{errors.submit}

)} {/* Basic Information */}

Basic Information

{/* Label (display name) - MOVED FIRST */}
setLabel(e.target.value)} onBlur={() => { // Auto-populate ref from label if ref is empty and not editing if (!isEditing && !ref.trim() && label.trim()) { setRef(labelToRef(label)); } }} placeholder="e.g., My Custom Pack" disabled={isStandard} className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ errors.label ? "border-red-500" : "border-gray-300" } ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`} /> {errors.label && (

{errors.label}

)}

Human-readable display name

{/* Ref (identifier) - MOVED AFTER LABEL */}
setRef(e.target.value)} disabled={isEditing} placeholder="e.g., my_custom_pack" className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ errors.ref ? "border-red-500" : "border-gray-300" } ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`} /> {errors.ref && (

{errors.ref}

)}

Unique identifier. Lowercase letters, numbers, hyphens, and underscores only. Auto-populated from label.

{/* Description */}