Files
attune/web/src/components/forms/PackForm.tsx
2026-02-23 20:45:10 -06:00

642 lines
21 KiB
TypeScript

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<Record<string, any>>(initialConfig);
const [confSchema, setConfSchema] =
useState<Record<string, any>>(initialConfSchema);
const [meta, setMeta] = useState(
pack?.meta ? JSON.stringify(pack.meta, null, 2) : "{}",
);
const [errors, setErrors] = useState<Record<string, string>>({});
// 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<string, any> = {};
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<string, string> = {};
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<string, any>;
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<string, any> = {};
Object.entries(example).forEach(([key, propDef]: [string, any]) => {
if (propDef.default !== undefined) {
syncedConfig[key] = propDef.default;
}
});
setConfigValues(syncedConfig);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{errors.submit && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{errors.submit}</p>
</div>
)}
{/* Basic Information */}
<div className="bg-white shadow rounded-lg p-6 space-y-4">
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">
Basic Information
</h3>
{/* Label (display name) - MOVED FIRST */}
<div>
<label
htmlFor="label"
className="block text-sm font-medium text-gray-700 mb-1"
>
Label <span className="text-red-500">*</span>
</label>
<input
type="text"
id="label"
value={label}
onChange={(e) => 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 && (
<p className="mt-1 text-sm text-red-600">{errors.label}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Human-readable display name
</p>
</div>
{/* Ref (identifier) - MOVED AFTER LABEL */}
<div>
<label
htmlFor="ref"
className="block text-sm font-medium text-gray-700 mb-1"
>
Reference ID <span className="text-red-500">*</span>
</label>
<input
type="text"
id="ref"
value={ref}
onChange={(e) => 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 && (
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Unique identifier. Lowercase letters, numbers, hyphens, and
underscores only. Auto-populated from label.
</p>
</div>
{/* Description */}
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this pack does..."
rows={3}
disabled={isStandard}
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
}`}
/>
</div>
{/* Version */}
<div>
<label
htmlFor="version"
className="block text-sm font-medium text-gray-700 mb-1"
>
Version <span className="text-red-500">*</span>
</label>
<input
type="text"
id="version"
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder="1.0.0"
disabled={isStandard}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.version ? "border-red-500" : "border-gray-300"
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
/>
{errors.version && (
<p className="mt-1 text-sm text-red-600">{errors.version}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Semantic version (e.g., 1.0.0)
</p>
</div>
{/* Tags */}
<div>
<label
htmlFor="tags"
className="block text-sm font-medium text-gray-700 mb-1"
>
Tags
</label>
<input
type="text"
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
disabled={isStandard}
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
}`}
placeholder="e.g., automation, cloud, monitoring"
/>
<p className="mt-1 text-xs text-gray-500">
Comma-separated tags for categorization
</p>
</div>
{/* Pack Dependencies */}
<div>
<label
htmlFor="deps"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pack Dependencies
</label>
<input
type="text"
id="deps"
value={deps}
onChange={(e) => setDeps(e.target.value)}
disabled={isStandard}
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
}`}
placeholder="e.g., core, utils"
/>
<p className="mt-1 text-xs text-gray-500">
Comma-separated list of required pack refs (other packs this pack
depends on)
</p>
</div>
{/* Standard Pack toggle */}
{!isEditing && (
<div className="flex items-center">
<input
type="checkbox"
id="isStandard"
checked={isStandard}
onChange={(e) => setIsStandard(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label
htmlFor="isStandard"
className="ml-2 block text-sm text-gray-700"
>
Mark as standard pack (installed/deployed)
</label>
</div>
)}
</div>
{/* Configuration Schema */}
<div className="bg-white shadow rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-medium text-gray-900">
Configuration Schema
</h3>
{!isStandard && isEditing && (
<button
type="button"
onClick={handleReset}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 px-3 py-1 border border-gray-300 rounded-lg hover:bg-gray-50"
title="Reset to database values"
>
<RotateCcw className="h-4 w-4" />
Reset
</button>
)}
</div>
{!isStandard && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500">Quick examples:</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => insertSchemaExample("api")}
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
>
API Example
</button>
<button
type="button"
onClick={() => insertSchemaExample("database")}
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
>
Database Example
</button>
<button
type="button"
onClick={() => insertSchemaExample("webhook")}
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
>
Webhook Example
</button>
</div>
</div>
)}
<SchemaBuilder
label="Configuration Schema"
value={confSchema}
onChange={setConfSchema}
error={errors.confSchema}
disabled={isStandard}
/>
{isStandard ? (
<div className="-mt-2">
<p className="text-xs text-gray-500">
Schema is locked for installed packs. Only configuration values
can be edited.
</p>
{!hasSchemaProperties && (
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> This installed pack has no
configuration schema. Configuration schemas for installed
packs must be updated via pack installation/upgrade and cannot
be edited through the web interface.
</p>
</div>
)}
</div>
) : (
<p className="text-xs text-gray-500 -mt-2">
Define the pack's configuration parameters that can be customized
</p>
)}
{/* Configuration Values - Only show if schema has properties */}
{hasSchemaProperties && (
<div className="pt-4 border-t">
<div className="mb-3">
<h4 className="text-sm font-medium text-gray-900">
Configuration Values
</h4>
<p className="text-xs text-gray-500 mt-1">
Set values for the configuration parameters defined above
</p>
</div>
<ParamSchemaForm
schema={confSchema}
values={configValues}
onChange={setConfigValues}
errors={errors}
/>
</div>
)}
</div>
{/* Metadata */}
<div className="bg-white shadow rounded-lg p-6 space-y-4">
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">
Metadata
</h3>
<div>
<label
htmlFor="meta"
className="block text-sm font-medium text-gray-700 mb-1"
>
Metadata (JSON)
</label>
<textarea
id="meta"
value={meta}
onChange={(e) => setMeta(e.target.value)}
rows={6}
disabled={isStandard}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-xs ${
errors.meta ? "border-red-500" : "border-gray-300"
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
placeholder='{"author": "Your Name", "homepage": "https://..."}'
/>
{errors.meta && (
<p className="mt-1 text-sm text-red-600">{errors.meta}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Additional metadata for the pack (author, license, etc.)
</p>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Cancel
</button>
<button
type="submit"
disabled={createPack.isPending || updatePack.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{createPack.isPending || updatePack.isPending
? "Saving..."
: isEditing
? "Update Pack"
: "Create Pack"}
</button>
</div>
</form>
);
}