re-uploading work

This commit is contained in:
2026-02-04 17:46:30 -06:00
commit 3b14c65998
1388 changed files with 381262 additions and 0 deletions

View File

@@ -0,0 +1,647 @@
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 || {
type: "object",
properties: {},
required: [],
};
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 [runtimeDeps, setRuntimeDeps] = 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
const hasSchemaProperties =
confSchema?.properties && Object.keys(confSchema.properties).length > 0;
// Sync config values when schema changes (for ad-hoc packs only)
useEffect(() => {
if (!isStandard && hasSchemaProperties) {
// Get current schema property names
const schemaKeys = Object.keys(confSchema.properties || {});
// 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.properties[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
if (confSchema && confSchema.type !== "object") {
newErrors.confSchema =
'Config schema must have type "object" at root level';
}
// 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.properties || {}).length > 0 ? confSchema : {};
const parsedMeta = meta.trim() ? JSON.parse(meta) : {};
const tagsList = tags
.split(",")
.map((t) => t.trim())
.filter((t) => t);
const runtimeDepsList = runtimeDeps
.split(",")
.map((d) => d.trim())
.filter((d) => 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: runtimeDepsList,
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: runtimeDepsList,
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;
switch (type) {
case "api":
example = {
type: "object",
properties: {
api_key: {
type: "string",
description: "API authentication key",
},
endpoint: {
type: "string",
description: "API endpoint URL",
default: "https://api.example.com",
},
},
required: ["api_key"],
};
break;
case "database":
example = {
type: "object",
properties: {
host: {
type: "string",
description: "Database host",
default: "localhost",
},
port: {
type: "integer",
description: "Database port",
default: 5432,
},
database: {
type: "string",
description: "Database name",
},
username: {
type: "string",
description: "Database username",
},
password: {
type: "string",
description: "Database password",
},
},
required: ["host", "database", "username", "password"],
};
break;
case "webhook":
example = {
type: "object",
properties: {
webhook_url: {
type: "string",
description: "Webhook destination URL",
},
auth_token: {
type: "string",
description: "Authentication token",
},
timeout: {
type: "integer",
description: "Request timeout in seconds",
minimum: 1,
maximum: 300,
default: 30,
},
},
required: ["webhook_url"],
};
break;
}
// Update schema
setConfSchema(example);
// Immediately sync config values with schema defaults
const syncedConfig: Record<string, any> = {};
if (example.properties) {
Object.entries(example.properties).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>
{/* Runtime Dependencies */}
<div>
<label
htmlFor="runtimeDeps"
className="block text-sm font-medium text-gray-700 mb-1"
>
Runtime Dependencies
</label>
<input
type="text"
id="runtimeDeps"
value={runtimeDeps}
onChange={(e) => setRuntimeDeps(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
</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.properties}
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>
);
}

View File

@@ -0,0 +1,550 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { usePacks } from "@/hooks/usePacks";
import { useTriggers, useTrigger } from "@/hooks/useTriggers";
import { useActions, useAction } from "@/hooks/useActions";
import { useCreateRule, useUpdateRule } from "@/hooks/useRules";
import ParamSchemaForm, {
validateParamSchema,
type ParamSchema,
} from "@/components/common/ParamSchemaForm";
import type { RuleResponse } from "@/types/api";
import { labelToRef, extractLocalRef, combineRefs } from "@/lib/format-utils";
interface RuleFormProps {
rule?: RuleResponse;
onSuccess?: () => void;
onCancel?: () => void;
}
export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
const navigate = useNavigate();
const isEditing = !!rule;
// Form state
const [packId, setPackId] = useState<number>(rule?.pack || 0);
const [localRef, setLocalRef] = useState(
rule?.ref ? extractLocalRef(rule.ref) : "",
);
const [label, setLabel] = useState(rule?.label || "");
const [description, setDescription] = useState(rule?.description || "");
const [triggerId, setTriggerId] = useState<number>(rule?.trigger || 0);
const [actionId, setActionId] = useState<number>(rule?.action || 0);
const [conditions, setConditions] = useState(
rule?.conditions ? JSON.stringify(rule.conditions, null, 2) : "",
);
const [triggerParameters, setTriggerParameters] = useState<
Record<string, any>
>(rule?.trigger_params || {});
const [actionParameters, setActionParameters] = useState<Record<string, any>>(
rule?.action_params || {},
);
const [enabled, setEnabled] = useState(rule?.enabled ?? true);
const [errors, setErrors] = useState<Record<string, string>>({});
const [triggerParamErrors, setTriggerParamErrors] = useState<
Record<string, string>
>({});
const [actionParamErrors, setActionParamErrors] = useState<
Record<string, string>
>({});
// Data fetching
const { data: packsData } = usePacks({ pageSize: 1000 });
const packs = packsData?.data || [];
const selectedPack = packs.find((p) => p.id === packId);
// Fetch ALL triggers and actions from all packs, not just the selected pack
// This allows rules in ad-hoc packs to reference triggers/actions from other packs
const { data: triggersData } = useTriggers({ pageSize: 1000 });
const { data: actionsData } = useActions({ pageSize: 1000 });
const triggers = triggersData?.data || [];
const actions = actionsData?.data || [];
// Get selected trigger and action refs for detail fetching
const selectedTriggerSummary = triggers.find((t) => t.id === triggerId);
const selectedActionSummary = actions.find((a: any) => a.id === actionId);
// Fetch full trigger details (including param_schema) when a trigger is selected
const { data: triggerDetailsData } = useTrigger(
selectedTriggerSummary?.ref || "",
);
const selectedTrigger = triggerDetailsData?.data;
// Fetch full action details (including param_schema) when an action is selected
const { data: actionDetailsData } = useAction(
selectedActionSummary?.ref || "",
);
const selectedAction = actionDetailsData?.data;
// Extract param schemas from full details
const triggerParamSchema: ParamSchema =
((selectedTrigger as any)?.param_schema as ParamSchema) || {};
const actionParamSchema: ParamSchema =
((selectedAction as any)?.param_schema as ParamSchema) || {};
// Mutations
const createRule = useCreateRule();
const updateRule = useUpdateRule();
// Reset triggers, actions, and parameters when pack changes
useEffect(() => {
if (!isEditing) {
setTriggerId(0);
setActionId(0);
setTriggerParameters({});
setActionParameters({});
}
}, [packId, isEditing]);
// Reset trigger parameters when trigger changes
useEffect(() => {
if (!isEditing) {
setTriggerParameters({});
}
}, [triggerId, isEditing]);
// Reset action parameters when action changes
useEffect(() => {
if (!isEditing) {
setActionParameters({});
}
}, [actionId, isEditing]);
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!localRef.trim()) {
newErrors.ref = "Reference is required";
}
if (!label.trim()) {
newErrors.label = "Label is required";
}
if (!packId) {
newErrors.pack = "Pack is required";
}
if (!triggerId) {
newErrors.trigger = "Trigger is required";
}
if (!actionId) {
newErrors.action = "Action is required";
}
// Validate conditions JSON if provided
if (conditions.trim()) {
try {
JSON.parse(conditions);
} catch (e) {
newErrors.conditions = "Invalid JSON format";
}
}
// Validate trigger parameters
const triggerErrors = validateParamSchema(
triggerParamSchema,
triggerParameters,
);
setTriggerParamErrors(triggerErrors);
// Validate action parameters
const actionErrors = validateParamSchema(
actionParamSchema,
actionParameters,
);
setActionParamErrors(actionErrors);
setErrors(newErrors);
return (
Object.keys(newErrors).length === 0 &&
Object.keys(triggerErrors).length === 0 &&
Object.keys(actionErrors).length === 0
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
// Get the selected pack, trigger, and action
const selectedPackData = packs.find((p) => p.id === packId);
// Combine pack ref and local ref to create full ref
const fullRef = combineRefs(selectedPackData?.ref || "", localRef.trim());
const formData: any = {
pack_ref: selectedPackData?.ref || "",
ref: fullRef,
label: label.trim(),
description: description.trim(),
trigger_ref: selectedTrigger?.ref || "",
action_ref: selectedAction?.ref || "",
enabled,
};
// Only add optional fields if they have values
if (conditions.trim()) {
formData.conditions = JSON.parse(conditions);
}
// Add trigger parameters if any
if (Object.keys(triggerParameters).length > 0) {
formData.trigger_params = triggerParameters;
}
// Add action parameters if any
if (Object.keys(actionParameters).length > 0) {
formData.action_params = actionParameters;
}
try {
if (isEditing && rule) {
await updateRule.mutateAsync({ ref: rule.ref, data: formData });
} else {
const newRuleResponse = await createRule.mutateAsync(formData);
if (!onSuccess) {
navigate(`/rules/${newRuleResponse.data.ref}`);
}
}
if (onSuccess) {
onSuccess();
}
} catch (err) {
console.error("Failed to save rule:", err);
setErrors({
submit: err instanceof Error ? err.message : "Failed to save rule",
});
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
navigate("/rules");
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{errors.submit && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-600">{errors.submit}</p>
</div>
)}
{/* Basic Information */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Basic Information
</h3>
{/* Pack Selection */}
<div>
<label
htmlFor="pack"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pack <span className="text-red-500">*</span>
</label>
<select
id="pack"
value={packId}
onChange={(e) => setPackId(Number(e.target.value))}
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.pack ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select a pack...</option>
{packs.map((pack: any) => (
<option key={pack.id} value={pack.id}>
{pack.label} ({pack.version})
</option>
))}
</select>
{errors.pack && (
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
)}
</div>
{/* Label - 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 localRef from label if localRef is empty and not editing
if (!isEditing && !localRef.trim() && label.trim()) {
setLocalRef(labelToRef(label));
}
}}
placeholder="e.g., Notify on Error"
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"
}`}
/>
{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 name for display
</p>
</div>
{/* Reference - MOVED AFTER LABEL with Pack Prefix */}
<div>
<label
htmlFor="ref"
className="block text-sm font-medium text-gray-700 mb-1"
>
Reference <span className="text-red-500">*</span>
</label>
<div className="input-with-prefix">
<span className={`prefix ${errors.ref ? "error" : ""}`}>
{selectedPack?.ref || "pack"}.
</span>
<input
type="text"
id="ref"
value={localRef}
onChange={(e) => setLocalRef(e.target.value)}
placeholder="e.g., notify_on_error"
disabled={isEditing}
className={errors.ref ? "error" : ""}
/>
</div>
{errors.ref && (
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Local identifier within the pack. 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 rule does..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Enabled Toggle */}
<div className="flex items-center">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="enabled" className="ml-2 text-sm text-gray-700">
Enable rule immediately
</label>
</div>
</div>
{/* Trigger Configuration */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Trigger Configuration
</h3>
{!packId ? (
<p className="text-sm text-gray-500">
Select a pack first to choose a trigger
</p>
) : !triggers || triggers.length === 0 ? (
<p className="text-sm text-gray-500">
No triggers available in the system
</p>
) : (
<>
{/* Trigger Selection */}
<div>
<label
htmlFor="trigger"
className="block text-sm font-medium text-gray-700 mb-1"
>
Trigger <span className="text-red-500">*</span>
</label>
<select
id="trigger"
value={triggerId}
onChange={(e) => setTriggerId(Number(e.target.value))}
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.trigger ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select a trigger...</option>
{triggers.map((trigger: any) => (
<option key={trigger.id} value={trigger.id}>
{trigger.ref} - {trigger.label}
</option>
))}
</select>
{errors.trigger && (
<p className="mt-1 text-sm text-red-600">{errors.trigger}</p>
)}
</div>
{/* Trigger Parameters - Dynamic Form */}
{selectedTrigger && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Trigger Parameters
</h4>
<ParamSchemaForm
schema={triggerParamSchema}
values={triggerParameters}
onChange={setTriggerParameters}
errors={triggerParamErrors}
/>
</div>
)}
{/* Conditions (JSON) */}
<div>
<label
htmlFor="conditions"
className="block text-sm font-medium text-gray-700 mb-1"
>
Match Conditions (JSON)
</label>
<textarea
id="conditions"
value={conditions}
onChange={(e) => setConditions(e.target.value)}
placeholder={`{\n "and": [\n {"var": "payload.severity", ">=": 3},\n {"var": "payload.status", "==": "error"}\n ]\n}`}
rows={8}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm ${
errors.conditions ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.conditions && (
<p className="mt-1 text-sm text-red-600">{errors.conditions}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Optional. Leave empty to match all events from this trigger.
</p>
</div>
</>
)}
</div>
{/* Action Configuration */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Action Configuration
</h3>
{!packId ? (
<p className="text-sm text-gray-500">
Select a pack first to choose an action
</p>
) : !actions || actions.length === 0 ? (
<p className="text-sm text-gray-500">
No actions available in the system
</p>
) : (
<>
{/* Action Selection */}
<div>
<label
htmlFor="action"
className="block text-sm font-medium text-gray-700 mb-1"
>
Action <span className="text-red-500">*</span>
</label>
<select
id="action"
value={actionId}
onChange={(e) => setActionId(Number(e.target.value))}
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.action ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select an action...</option>
{actions.map((action: any) => (
<option key={action.id} value={action.id}>
{action.ref} - {action.label}
</option>
))}
</select>
{errors.action && (
<p className="mt-1 text-sm text-red-600">{errors.action}</p>
)}
</div>
{/* Action Parameters - Dynamic Form */}
{selectedAction && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Action Parameters
</h4>
<ParamSchemaForm
schema={actionParamSchema}
values={actionParameters}
onChange={setActionParameters}
errors={actionParamErrors}
/>
</div>
)}
</>
)}
</div>
{/* Form Actions */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createRule.isPending || updateRule.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createRule.isPending || updateRule.isPending
? "Saving..."
: isEditing
? "Update Rule"
: "Create Rule"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,439 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { usePacks } from "@/hooks/usePacks";
import { useCreateTrigger, useUpdateTrigger } from "@/hooks/useTriggers";
import {
labelToRef,
extractLocalRef,
combinePackLocalRef,
} from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder";
import { WebhooksService } from "@/api";
interface TriggerFormProps {
initialData?: any;
isEditing?: boolean;
}
export default function TriggerForm({
initialData,
isEditing = false,
}: TriggerFormProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
// Form fields
const [packId, setPackId] = useState<number>(0);
const [localRef, setLocalRef] = useState("");
const [label, setLabel] = useState("");
const [description, setDescription] = useState("");
const [webhookEnabled, setWebhookEnabled] = useState(false);
const [enabled, setEnabled] = useState(true);
const [paramSchema, setParamSchema] = useState<Record<string, any>>({
type: "object",
properties: {},
required: [],
});
const [outSchema, setOutSchema] = useState<Record<string, any>>({
type: "object",
properties: {},
required: [],
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Fetch packs
const { data: packsData } = usePacks({ page: 1, pageSize: 100 });
const packs = packsData?.data || [];
const selectedPack = packs.find((p: any) => p.id === packId);
// Mutations
const createTrigger = useCreateTrigger();
const updateTrigger = useUpdateTrigger();
// Initialize form with existing data
useEffect(() => {
if (initialData) {
setLabel(initialData.label || "");
setDescription(initialData.description || "");
setWebhookEnabled(initialData.webhook_enabled || false);
setEnabled(initialData.enabled ?? true);
setParamSchema(
initialData.param_schema || {
type: "object",
properties: {},
required: [],
},
);
setOutSchema(
initialData.out_schema || {
type: "object",
properties: {},
required: [],
},
);
if (isEditing) {
// Find pack by pack_ref
const pack = packs.find((p: any) => p.ref === initialData.pack_ref);
if (pack) {
setPackId(pack.id);
}
// Extract local ref from full ref
setLocalRef(extractLocalRef(initialData.ref, initialData.pack_ref));
}
}
}, [initialData, packs, isEditing]);
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!packId) {
newErrors.pack = "Pack is required";
}
if (!label.trim()) {
newErrors.label = "Label is required";
}
if (!localRef.trim()) {
newErrors.ref = "Reference is required";
} else if (!/^[a-z0-9_]+$/.test(localRef)) {
newErrors.ref =
"Reference must contain only lowercase letters, numbers, and underscores";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
const selectedPackData = packs.find((p: any) => p.id === packId);
if (!selectedPackData) {
throw new Error("Selected pack not found");
}
const fullRef = combinePackLocalRef(selectedPackData.ref, localRef);
const formData = {
pack_ref: selectedPackData.ref,
ref: fullRef,
label: label.trim(),
description: description.trim() || undefined,
enabled,
param_schema:
Object.keys(paramSchema.properties || {}).length > 0
? paramSchema
: undefined,
out_schema:
Object.keys(outSchema.properties || {}).length > 0
? outSchema
: undefined,
};
if (isEditing && initialData?.ref) {
await updateTrigger.mutateAsync({
ref: initialData.ref,
data: formData,
});
// Handle webhook enable/disable separately for updates
if (webhookEnabled !== initialData?.webhook_enabled) {
try {
if (webhookEnabled) {
await WebhooksService.enableWebhook({ ref: initialData.ref });
} else {
await WebhooksService.disableWebhook({ ref: initialData.ref });
}
// Invalidate trigger cache to refresh UI with updated webhook status
queryClient.invalidateQueries({
queryKey: ["triggers", initialData.ref],
});
queryClient.invalidateQueries({ queryKey: ["triggers"] });
} catch (webhookError) {
console.error("Failed to update webhook status:", webhookError);
// Continue anyway - user can update it manually
}
}
// Navigate back to trigger detail page
navigate(`/triggers/${encodeURIComponent(initialData.ref)}`);
return;
} else {
const response = await createTrigger.mutateAsync(formData);
const newTrigger = response?.data;
if (newTrigger?.ref) {
// If webhook is enabled, enable it after trigger creation
if (webhookEnabled) {
try {
await WebhooksService.enableWebhook({ ref: newTrigger.ref });
} catch (webhookError) {
console.error("Failed to enable webhook:", webhookError);
// Continue anyway - user can enable it manually
}
// Invalidate trigger cache to refresh UI with webhook data
queryClient.invalidateQueries({
queryKey: ["triggers", newTrigger.ref],
});
queryClient.invalidateQueries({ queryKey: ["triggers"] });
}
navigate(`/triggers/${encodeURIComponent(newTrigger.ref)}`);
return;
}
}
navigate("/triggers");
} catch (error: any) {
console.error("Error submitting trigger:", error);
setErrors({
submit:
error.response?.data?.message ||
error.message ||
"Failed to save trigger",
});
}
};
const handleCancel = () => {
if (isEditing && initialData?.ref) {
navigate(`/triggers/${encodeURIComponent(initialData.ref)}`);
} else {
navigate("/triggers");
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{errors.submit && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-600">{errors.submit}</p>
</div>
)}
{/* Basic Information */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Basic Information
</h3>
{/* Pack Selection */}
<div>
<label
htmlFor="pack"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pack <span className="text-red-500">*</span>
</label>
<select
id="pack"
value={packId}
onChange={(e) => setPackId(Number(e.target.value))}
disabled={isEditing}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.pack ? "border-red-500" : "border-gray-300"
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
>
<option value={0}>Select a pack...</option>
{packs.map((pack: any) => (
<option key={pack.id} value={pack.id}>
{pack.label} ({pack.version})
</option>
))}
</select>
{errors.pack && (
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
)}
</div>
{/* Label */}
<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 localRef from label if localRef is empty and not editing
if (!isEditing && !localRef.trim() && label.trim()) {
setLocalRef(labelToRef(label));
}
}}
placeholder="e.g., Webhook Received"
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"
}`}
/>
{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 name for display
</p>
</div>
{/* Reference with Pack Prefix */}
<div>
<label
htmlFor="ref"
className="block text-sm font-medium text-gray-700 mb-1"
>
Reference <span className="text-red-500">*</span>
</label>
<div className="input-with-prefix">
<span className={`prefix ${errors.ref ? "error" : ""}`}>
{selectedPack?.ref || "pack"}.
</span>
<input
type="text"
id="ref"
value={localRef}
onChange={(e) => setLocalRef(e.target.value)}
placeholder="e.g., webhook_received"
disabled={isEditing}
className={errors.ref ? "error" : ""}
/>
</div>
{errors.ref && (
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Local identifier within the pack. 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)}
rows={3}
placeholder="Describe what this trigger does..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Schema Configuration */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Schema Configuration
</h3>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-700">
Define schemas to validate event parameters and outputs. Leave empty
for flexible schemas.
</p>
</div>
{/* Parameter Schema */}
<SchemaBuilder
label="Parameter Schema"
value={paramSchema}
onChange={setParamSchema}
error={errors.paramSchema}
/>
<p className="text-xs text-gray-500 -mt-2">
Define the structure of event parameters that will be passed to this
trigger
</p>
{/* Output Schema */}
<SchemaBuilder
label="Output Schema"
value={outSchema}
onChange={setOutSchema}
error={errors.outSchema}
/>
<p className="text-xs text-gray-500 -mt-2">
Define the structure of event data that will be produced by this
trigger
</p>
</div>
{/* Settings */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Settings</h3>
{/* Webhook Enabled */}
<div className="flex items-center">
<input
type="checkbox"
id="webhookEnabled"
checked={webhookEnabled}
onChange={(e) => setWebhookEnabled(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label
htmlFor="webhookEnabled"
className="ml-2 block text-sm text-gray-900"
>
Enable Webhook
</label>
</div>
<p className="text-xs text-gray-500 ml-6">
Allow this trigger to be activated via HTTP webhook
</p>
{/* Enabled */}
<div className="flex items-center">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="enabled" className="ml-2 block text-sm text-gray-900">
Enabled
</label>
</div>
<p className="text-xs text-gray-500 ml-6">
Enable or disable this trigger
</p>
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={createTrigger.isPending || updateTrigger.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createTrigger.isPending || updateTrigger.isPending
? "Saving..."
: isEditing
? "Update Trigger"
: "Create Trigger"}
</button>
</div>
</form>
);
}