[WIP] workflow builder

This commit is contained in:
2026-02-23 20:45:10 -06:00
parent d629da32fa
commit 53a3fbb6b1
66 changed files with 7887 additions and 1608 deletions

View File

@@ -4,6 +4,7 @@ import { OpenAPI } from "@/api";
import { Play, X } from "lucide-react";
import ParamSchemaForm, {
validateParamSchema,
extractProperties,
type ParamSchema,
} from "@/components/common/ParamSchemaForm";
@@ -28,11 +29,11 @@ export default function ExecuteActionModal({
const queryClient = useQueryClient();
const paramSchema: ParamSchema = (action.param_schema as ParamSchema) || {};
const paramProperties = extractProperties(paramSchema);
// If initialParameters are provided, use them (stripping out any keys not in the schema)
const buildInitialValues = (): Record<string, any> => {
if (!initialParameters) return {};
const properties = paramSchema.properties || {};
const values: Record<string, any> = {};
// Include all initial parameters - even those not in the schema
// so users can see exactly what was run before
@@ -42,7 +43,7 @@ export default function ExecuteActionModal({
}
}
// Also fill in defaults for any schema properties not covered
for (const [key, param] of Object.entries(properties)) {
for (const [key, param] of Object.entries(paramProperties)) {
if (values[key] === undefined && param?.default !== undefined) {
values[key] = param.default;
}
@@ -50,9 +51,8 @@ export default function ExecuteActionModal({
return values;
};
const [parameters, setParameters] = useState<Record<string, any>>(
buildInitialValues,
);
const [parameters, setParameters] =
useState<Record<string, any>>(buildInitialValues);
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
[{ key: "", value: "" }],

View File

@@ -1,29 +1,12 @@
/**
* ParamSchemaDisplay - Read-only display component for parameters
* Shows parameter values in a human-friendly format based on their schema
* Supports standard JSON Schema format (https://json-schema.org/draft/2020-12/schema)
* Shows parameter values in a human-friendly format based on their schema.
* Expects StackStorm-style flat parameter format with inline required/secret.
*/
/**
* Standard JSON Schema format for parameters
*/
export interface ParamSchema {
type?: "object";
properties?: {
[key: string]: {
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
default?: any;
enum?: string[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
secret?: boolean;
};
};
required?: string[];
}
import type { ParamSchema } from "./ParamSchemaForm";
export type { ParamSchema };
import { extractProperties } from "./ParamSchemaForm";
interface ParamSchemaDisplayProps {
schema: ParamSchema;
@@ -41,8 +24,7 @@ export default function ParamSchemaDisplay({
className = "",
emptyMessage = "No parameters configured",
}: ParamSchemaDisplayProps) {
const properties = schema.properties || {};
const requiredFields = schema.required || [];
const properties = extractProperties(schema);
const paramEntries = Object.entries(properties);
// Filter to only show parameters that have values
@@ -63,7 +45,7 @@ export default function ParamSchemaDisplay({
* Check if a field is required
*/
const isRequired = (key: string): boolean => {
return requiredFields.includes(key);
return !!properties[key]?.required;
};
/**
@@ -320,7 +302,7 @@ export function ParamSchemaDisplayCompact({
values,
className = "",
}: ParamSchemaDisplayProps) {
const properties = schema.properties || {};
const properties = extractProperties(schema);
const paramEntries = Object.entries(properties);
const populatedParams = paramEntries.filter(([key]) => {
const value = values[key];

View File

@@ -1,30 +1,59 @@
import { useState, useEffect } from "react";
/**
* Standard JSON Schema format for parameters
* Follows https://json-schema.org/draft/2020-12/schema
* StackStorm-style parameter schema format.
* Parameters are defined as a flat map of parameter name to definition,
* with `required` and `secret` inlined per-parameter.
*
* Example:
* {
* "url": { "type": "string", "description": "Target URL", "required": true },
* "token": { "type": "string", "secret": true }
* }
*/
export interface ParamSchemaProperty {
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
default?: any;
enum?: string[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
secret?: boolean;
required?: boolean;
position?: number;
items?: any;
}
export interface ParamSchema {
type?: "object";
properties?: {
[key: string]: {
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
description?: string;
default?: any;
enum?: string[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
secret?: boolean;
};
};
required?: string[];
[key: string]: ParamSchemaProperty;
}
/**
* Props for ParamSchemaForm component
*/
/**
* Extract the parameter properties from a flat parameter schema.
*
* All schemas (param_schema, out_schema, conf_schema) use the same flat format:
* { param_name: { type, description, required, secret, ... }, ... }
*/
export function extractProperties(
schema: ParamSchema | any,
): Record<string, ParamSchemaProperty> {
if (!schema || typeof schema !== "object") return {};
// StackStorm-style flat format: { param_name: { type, description, required, ... }, ... }
// Filter out entries that don't look like parameter definitions (e.g., stray "type" or "required" keys)
const props: Record<string, ParamSchemaProperty> = {};
for (const [key, value] of Object.entries(schema)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
props[key] = value as ParamSchemaProperty;
}
}
return props;
}
interface ParamSchemaFormProps {
schema: ParamSchema;
values: Record<string, any>;
@@ -117,8 +146,7 @@ export default function ParamSchemaForm({
// Merge external and local errors
const allErrors = { ...localErrors, ...errors };
const properties = schema.properties || {};
const requiredFields = schema.required || [];
const properties = extractProperties(schema);
// Initialize values with defaults from schema
useEffect(() => {
@@ -159,7 +187,7 @@ export default function ParamSchemaForm({
* Check if a field is required
*/
const isRequired = (key: string): boolean => {
return requiredFields.includes(key);
return !!properties[key]?.required;
};
/**
@@ -506,14 +534,15 @@ export function validateParamSchema(
allowTemplates: boolean = false,
): Record<string, string> {
const errors: Record<string, string> = {};
const properties = schema.properties || {};
const requiredFields = schema.required || [];
const properties = extractProperties(schema);
// Check required fields
requiredFields.forEach((key) => {
const value = values[key];
if (value === undefined || value === null || value === "") {
errors[key] = "This field is required";
// Check required fields (inline per-parameter)
Object.entries(properties).forEach(([key, param]) => {
if (param?.required) {
const value = values[key];
if (value === undefined || value === null || value === "") {
errors[key] = "This field is required";
}
}
});
@@ -524,7 +553,7 @@ export function validateParamSchema(
// Skip if no value and not required
if (
(value === undefined || value === null || value === "") &&
!requiredFields.includes(key)
!param?.required
) {
return;
}

View File

@@ -6,6 +6,7 @@ interface SchemaProperty {
type: string;
description: string;
required: boolean;
secret: boolean;
default?: string;
minimum?: number;
maximum?: number;
@@ -52,32 +53,34 @@ export default function SchemaBuilder({
);
// Initialize properties from schema value
// Expects StackStorm-style flat format: { param_name: { type, required, secret, ... }, ... }
useEffect(() => {
if (value && value.properties) {
const props: SchemaProperty[] = [];
const requiredFields = value.required || [];
if (!value || typeof value !== "object") return;
const props: SchemaProperty[] = [];
Object.entries(value.properties).forEach(
([name, propDef]: [string, any]) => {
props.push({
name,
type: propDef.type || "string",
description: propDef.description || "",
required: requiredFields.includes(name),
default:
propDef.default !== undefined
? JSON.stringify(propDef.default)
: undefined,
minimum: propDef.minimum,
maximum: propDef.maximum,
minLength: propDef.minLength,
maxLength: propDef.maxLength,
pattern: propDef.pattern,
enum: propDef.enum,
});
},
);
Object.entries(value).forEach(([name, propDef]: [string, any]) => {
if (propDef && typeof propDef === "object" && !Array.isArray(propDef)) {
props.push({
name,
type: propDef.type || "string",
description: propDef.description || "",
required: propDef.required === true,
secret: propDef.secret === true,
default:
propDef.default !== undefined
? JSON.stringify(propDef.default)
: undefined,
minimum: propDef.minimum,
maximum: propDef.maximum,
minLength: propDef.minLength,
maxLength: propDef.maxLength,
pattern: propDef.pattern,
enum: propDef.enum,
});
}
});
if (props.length > 0) {
setProperties(props);
}
}, []);
@@ -90,20 +93,13 @@ export default function SchemaBuilder({
}
}, [showRawJson]);
// Build StackStorm-style flat parameter schema
const buildSchema = (): Record<string, any> => {
if (properties.length === 0) {
return {
type: "object",
properties: {},
required: [],
};
return {};
}
const schema: Record<string, any> = {
type: "object",
properties: {},
required: [] as string[],
};
const schema: Record<string, any> = {};
properties.forEach((prop) => {
const propSchema: Record<string, any> = {
@@ -114,6 +110,14 @@ export default function SchemaBuilder({
propSchema.description = prop.description;
}
if (prop.required) {
propSchema.required = true;
}
if (prop.secret) {
propSchema.secret = true;
}
if (prop.default !== undefined && prop.default !== "") {
try {
propSchema.default = JSON.parse(prop.default);
@@ -135,11 +139,7 @@ export default function SchemaBuilder({
if (prop.maximum !== undefined) propSchema.maximum = prop.maximum;
}
schema.properties[prop.name] = propSchema;
if (prop.required) {
schema.required.push(prop.name);
}
schema[prop.name] = propSchema;
});
return schema;
@@ -151,22 +151,15 @@ export default function SchemaBuilder({
onChange(schema);
};
// Build StackStorm-style flat parameter schema from properties array
const buildSchemaFromProperties = (
props: SchemaProperty[],
): Record<string, any> => {
if (props.length === 0) {
return {
type: "object",
properties: {},
required: [],
};
return {};
}
const schema: Record<string, any> = {
type: "object",
properties: {},
required: [] as string[],
};
const schema: Record<string, any> = {};
props.forEach((prop) => {
const propSchema: Record<string, any> = {
@@ -177,6 +170,14 @@ export default function SchemaBuilder({
propSchema.description = prop.description;
}
if (prop.required) {
propSchema.required = true;
}
if (prop.secret) {
propSchema.secret = true;
}
if (prop.default !== undefined && prop.default !== "") {
try {
propSchema.default = JSON.parse(prop.default);
@@ -197,11 +198,7 @@ export default function SchemaBuilder({
if (prop.maximum !== undefined) propSchema.maximum = prop.maximum;
}
schema.properties[prop.name] = propSchema;
if (prop.required) {
schema.required.push(prop.name);
}
schema[prop.name] = propSchema;
});
return schema;
@@ -209,10 +206,11 @@ export default function SchemaBuilder({
const addProperty = () => {
const newProp: SchemaProperty = {
name: `property_${properties.length + 1}`,
name: `param${properties.length + 1}`,
type: "string",
description: "",
required: false,
secret: false,
};
const newIndex = properties.length;
handlePropertiesChange([...properties, newProp]);
@@ -258,38 +256,37 @@ export default function SchemaBuilder({
try {
const parsed = JSON.parse(newJson);
if (parsed.type !== "object") {
setRawJsonError('Schema must have type "object" at root level');
if (typeof parsed !== "object" || Array.isArray(parsed)) {
setRawJsonError("Schema must be a JSON object");
return;
}
onChange(parsed);
// Update properties from parsed JSON
// Expects StackStorm-style flat format: { param_name: { type, required, secret, ... }, ... }
const props: SchemaProperty[] = [];
const requiredFields = parsed.required || [];
if (parsed.properties) {
Object.entries(parsed.properties).forEach(
([name, propDef]: [string, any]) => {
props.push({
name,
type: propDef.type || "string",
description: propDef.description || "",
required: requiredFields.includes(name),
default:
propDef.default !== undefined
? JSON.stringify(propDef.default)
: undefined,
minimum: propDef.minimum,
maximum: propDef.maximum,
minLength: propDef.minLength,
maxLength: propDef.maxLength,
pattern: propDef.pattern,
enum: propDef.enum,
});
},
);
}
Object.entries(parsed).forEach(([name, propDef]: [string, any]) => {
if (propDef && typeof propDef === "object" && !Array.isArray(propDef)) {
props.push({
name,
type: propDef.type || "string",
description: propDef.description || "",
required: propDef.required === true,
secret: propDef.secret === true,
default:
propDef.default !== undefined
? JSON.stringify(propDef.default)
: undefined,
minimum: propDef.minimum,
maximum: propDef.maximum,
minLength: propDef.minLength,
maxLength: propDef.maxLength,
pattern: propDef.pattern,
enum: propDef.enum,
});
}
});
setProperties(props);
} catch (e: any) {
@@ -467,28 +464,56 @@ export default function SchemaBuilder({
/>
</div>
{/* Required checkbox */}
<div className="flex items-center">
<input
type="checkbox"
id={`required-${index}`}
checked={prop.required}
onChange={(e) =>
updateProperty(index, {
required: e.target.checked,
})
}
disabled={disabled}
className={`h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}
/>
<label
htmlFor={`required-${index}`}
className="ml-2 text-xs font-medium text-gray-700"
>
Required field
</label>
{/* Required and Secret checkboxes */}
<div className="flex items-center gap-6">
<div className="flex items-center">
<input
type="checkbox"
id={`required-${index}`}
checked={prop.required}
onChange={(e) =>
updateProperty(index, {
required: e.target.checked,
})
}
disabled={disabled}
className={`h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded ${
disabled
? "cursor-not-allowed opacity-50"
: ""
}`}
/>
<label
htmlFor={`required-${index}`}
className="ml-2 text-xs font-medium text-gray-700"
>
Required
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id={`secret-${index}`}
checked={prop.secret}
onChange={(e) =>
updateProperty(index, {
secret: e.target.checked,
})
}
disabled={disabled}
className={`h-4 w-4 text-yellow-600 focus:ring-yellow-500 border-gray-300 rounded ${
disabled
? "cursor-not-allowed opacity-50"
: ""
}`}
/>
<label
htmlFor={`secret-${index}`}
className="ml-2 text-xs font-medium text-gray-700"
>
Secret
</label>
</div>
</div>
{/* Default value */}

View File

@@ -18,11 +18,7 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
const isEditing = !!pack;
// Store initial/database state for reset
const initialConfSchema = pack?.conf_schema || {
type: "object",
properties: {},
required: [],
};
const initialConfSchema = pack?.conf_schema || {};
const initialConfig = pack?.config || {};
// Form state
@@ -47,15 +43,17 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
const createPack = useCreatePack();
const updatePack = useUpdatePack();
// Check if schema has properties
// Check if schema has properties (flat format: each key is a parameter name)
const hasSchemaProperties =
confSchema?.properties && Object.keys(confSchema.properties).length > 0;
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
const schemaKeys = Object.keys(confSchema.properties || {});
// 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> = {};
@@ -65,7 +63,7 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
syncedConfig[key] = configValues[key];
} else {
// Use default from schema if available
const defaultValue = confSchema.properties[key]?.default;
const defaultValue = confSchema[key]?.default;
if (defaultValue !== undefined) {
syncedConfig[key] = defaultValue;
}
@@ -99,10 +97,14 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
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 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
@@ -126,7 +128,7 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
}
const parsedConfSchema =
Object.keys(confSchema.properties || {}).length > 0 ? confSchema : {};
Object.keys(confSchema || {}).length > 0 ? confSchema : {};
const parsedMeta = meta.trim() ? JSON.parse(meta) : {};
const tagsList = tags
.split(",")
@@ -201,78 +203,75 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
};
const insertSchemaExample = (type: "api" | "database" | "webhook") => {
let example;
let example: Record<string, any>;
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",
},
api_key: {
type: "string",
description: "API authentication key",
required: true,
secret: true,
},
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",
},
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,
},
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,
},
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,
},
required: ["webhook_url"],
};
break;
}
@@ -282,15 +281,11 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
// 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;
}
},
);
}
Object.entries(example).forEach(([key, propDef]: [string, any]) => {
if (propDef.default !== undefined) {
syncedConfig[key] = propDef.default;
}
});
setConfigValues(syncedConfig);
};
@@ -578,7 +573,7 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
</p>
</div>
<ParamSchemaForm
schema={confSchema.properties}
schema={confSchema}
values={configValues}
onChange={setConfigValues}
errors={errors}

View File

@@ -123,6 +123,10 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
newErrors.label = "Label is required";
}
if (!description.trim()) {
newErrors.description = "Description is required";
}
if (!packId) {
newErrors.pack = "Pack is required";
}
@@ -347,7 +351,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description
Description <span className="text-red-500">*</span>
</label>
<textarea
id="description"
@@ -355,8 +359,13 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
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"
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.description ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
)}
</div>
{/* Enabled Toggle */}

View File

@@ -30,16 +30,8 @@ export default function TriggerForm({
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 [paramSchema, setParamSchema] = useState<Record<string, any>>({});
const [outSchema, setOutSchema] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
// Fetch packs
@@ -58,20 +50,8 @@ export default function TriggerForm({
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: [],
},
);
setParamSchema(initialData.param_schema || {});
setOutSchema(initialData.out_schema || {});
if (isEditing) {
// Find pack by pack_ref
@@ -129,13 +109,8 @@ export default function TriggerForm({
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,
Object.keys(paramSchema).length > 0 ? paramSchema : undefined,
out_schema: Object.keys(outSchema).length > 0 ? outSchema : undefined,
};
if (isEditing && initialData?.ref) {

View File

@@ -0,0 +1,168 @@
import { useState, useMemo } from "react";
import { Search, X, ChevronDown, ChevronRight, GripVertical } from "lucide-react";
import type { PaletteAction } from "@/types/workflow";
interface ActionPaletteProps {
actions: PaletteAction[];
isLoading: boolean;
onAddTask: (action: PaletteAction) => void;
}
export default function ActionPalette({
actions,
isLoading,
onAddTask,
}: ActionPaletteProps) {
const [searchQuery, setSearchQuery] = useState("");
const [collapsedPacks, setCollapsedPacks] = useState<Set<string>>(new Set());
const filteredActions = useMemo(() => {
if (!searchQuery.trim()) return actions;
const query = searchQuery.toLowerCase();
return actions.filter(
(action) =>
action.label?.toLowerCase().includes(query) ||
action.ref?.toLowerCase().includes(query) ||
action.description?.toLowerCase().includes(query) ||
action.pack_ref?.toLowerCase().includes(query)
);
}, [actions, searchQuery]);
const actionsByPack = useMemo(() => {
const grouped = new Map<string, PaletteAction[]>();
filteredActions.forEach((action) => {
const packRef = action.pack_ref;
if (!grouped.has(packRef)) {
grouped.set(packRef, []);
}
grouped.get(packRef)!.push(action);
});
return new Map(
[...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0]))
);
}, [filteredActions]);
const togglePack = (packRef: string) => {
setCollapsedPacks((prev) => {
const next = new Set(prev);
if (next.has(packRef)) {
next.delete(packRef);
} else {
next.add(packRef);
}
return next;
});
};
return (
<div className="w-64 border-r border-gray-200 bg-gray-50 flex flex-col h-full overflow-hidden">
<div className="p-3 border-b border-gray-200 bg-white flex-shrink-0">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-2">
Action Palette
</h3>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-2 flex items-center pointer-events-none">
<Search className="h-3.5 w-3.5 text-gray-400" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search actions..."
className="block w-full pl-8 pr-8 py-1.5 border border-gray-300 rounded text-xs focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute inset-y-0 right-0 pr-2 flex items-center"
>
<X className="h-3.5 w-3.5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
</div>
) : actions.length === 0 ? (
<div className="text-center py-8 text-xs text-gray-500">
No actions available
</div>
) : filteredActions.length === 0 ? (
<div className="text-center py-8">
<p className="text-xs text-gray-500">No actions match your search</p>
<button
onClick={() => setSearchQuery("")}
className="mt-1 text-xs text-blue-600 hover:text-blue-800"
>
Clear search
</button>
</div>
) : (
<div className="space-y-1">
{Array.from(actionsByPack.entries()).map(
([packRef, packActions]) => {
const isCollapsed = collapsedPacks.has(packRef);
return (
<div key={packRef} className="rounded overflow-hidden">
<button
onClick={() => togglePack(packRef)}
className="w-full px-2 py-1.5 flex items-center justify-between hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-1.5">
{isCollapsed ? (
<ChevronRight className="w-3 h-3 text-gray-500 flex-shrink-0" />
) : (
<ChevronDown className="w-3 h-3 text-gray-500 flex-shrink-0" />
)}
<span className="font-semibold text-xs text-gray-800 truncate">
{packRef}
</span>
</div>
<span className="text-[10px] text-gray-500 bg-gray-200 px-1.5 py-0.5 rounded flex-shrink-0">
{packActions.length}
</span>
</button>
{!isCollapsed && (
<div className="pl-1 pb-1">
{packActions.map((action) => (
<button
key={action.id}
onClick={() => onAddTask(action)}
className="w-full text-left px-2 py-1.5 rounded hover:bg-blue-50 hover:border-blue-200 border border-transparent transition-colors group cursor-pointer"
title={`Click to add "${action.label}" as a task`}
>
<div className="flex items-start gap-1.5">
<GripVertical className="w-3 h-3 text-gray-300 group-hover:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-medium text-xs text-gray-900 truncate">
{action.label}
</div>
<div className="font-mono text-[10px] text-gray-500 truncate">
{action.ref}
</div>
{action.description && (
<div className="text-[10px] text-gray-400 truncate mt-0.5">
{action.description}
</div>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
)}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,417 @@
import { memo, useCallback, useRef, useState } from "react";
import { Trash2, Settings, GripVertical } from "lucide-react";
import type { WorkflowTask, TransitionPreset } from "@/types/workflow";
import {
PRESET_LABELS,
PRESET_WHEN,
classifyTransitionWhen,
} from "@/types/workflow";
export type { TransitionPreset };
interface TaskNodeProps {
task: WorkflowTask;
isSelected: boolean;
allTaskNames: string[];
onSelect: (taskId: string) => void;
onDelete: (taskId: string) => void;
onPositionChange: (
taskId: string,
position: { x: number; y: number },
) => void;
onStartConnection: (taskId: string, preset: TransitionPreset) => void;
connectingFrom: { taskId: string; preset: TransitionPreset } | null;
onCompleteConnection: (targetTaskId: string) => void;
}
/** Handle visual configuration for each transition preset */
const HANDLE_CONFIG: {
preset: TransitionPreset;
color: string;
hoverColor: string;
activeColor: string;
ringColor: string;
}[] = [
{
preset: "succeeded",
color: "#22c55e",
hoverColor: "#16a34a",
activeColor: "#15803d",
ringColor: "rgba(34, 197, 94, 0.3)",
},
{
preset: "failed",
color: "#ef4444",
hoverColor: "#dc2626",
activeColor: "#b91c1c",
ringColor: "rgba(239, 68, 68, 0.3)",
},
{
preset: "always",
color: "#6b7280",
hoverColor: "#4b5563",
activeColor: "#374151",
ringColor: "rgba(107, 114, 128, 0.3)",
},
];
/**
* Check if a task has an active transition matching a given preset.
*/
function hasActiveTransition(
task: WorkflowTask,
preset: TransitionPreset,
): boolean {
if (!task.next) return false;
const whenExpr = PRESET_WHEN[preset];
return task.next.some((t) => {
if (whenExpr === undefined) return t.when === undefined;
return (
t.when?.toLowerCase().replace(/\s+/g, "") ===
whenExpr.toLowerCase().replace(/\s+/g, "")
);
});
}
/**
* Compute a short summary of outgoing transitions for the node body.
*/
function transitionSummary(task: WorkflowTask): string | null {
if (!task.next || task.next.length === 0) return null;
const totalTargets = task.next.reduce(
(sum, t) => sum + (t.do?.length ?? 0),
0,
);
if (
totalTargets === 0 &&
task.next.some((t) => t.publish && t.publish.length > 0)
) {
return `${task.next.length} transition${task.next.length !== 1 ? "s" : ""} (publish only)`;
}
if (totalTargets === 0) return null;
return `${totalTargets} target${totalTargets !== 1 ? "s" : ""} via ${task.next.length} transition${task.next.length !== 1 ? "s" : ""}`;
}
function TaskNodeInner({
task,
isSelected,
onSelect,
onDelete,
onPositionChange,
onStartConnection,
connectingFrom,
onCompleteConnection,
}: TaskNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
null,
);
const [isInputHandleHovered, setIsInputHandleHovered] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-action-button]")) return;
if (target.closest("[data-handle]")) return;
e.stopPropagation();
setIsDragging(true);
dragOffset.current = {
x: e.clientX - task.position.x,
y: e.clientY - task.position.y,
};
const handleMouseMove = (moveEvent: MouseEvent) => {
const newX = moveEvent.clientX - dragOffset.current.x;
const newY = moveEvent.clientY - dragOffset.current.y;
onPositionChange(task.id, {
x: Math.max(0, newX),
y: Math.max(0, newY),
});
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[task.id, task.position.x, task.position.y, onPositionChange],
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (connectingFrom && connectingFrom.taskId !== task.id) {
onCompleteConnection(task.id);
} else if (!connectingFrom) {
onSelect(task.id);
}
},
[task.id, onSelect, connectingFrom, onCompleteConnection],
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(task.id);
},
[task.id, onDelete],
);
const handleHandleMouseDown = useCallback(
(e: React.MouseEvent, preset: TransitionPreset) => {
e.stopPropagation();
e.preventDefault();
onStartConnection(task.id, preset);
},
[task.id, onStartConnection],
);
const handleInputHandleMouseUp = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (connectingFrom && connectingFrom.taskId !== task.id) {
onCompleteConnection(task.id);
}
},
[task.id, connectingFrom, onCompleteConnection],
);
const isConnectionTarget =
connectingFrom !== null && connectingFrom.taskId !== task.id;
const borderColor = isSelected
? "border-blue-500 ring-2 ring-blue-200"
: isConnectionTarget
? "border-purple-400 ring-2 ring-purple-200"
: "border-gray-300 hover:border-gray-400";
const hasAction = task.action && task.action.length > 0;
const summary = transitionSummary(task);
// Count custom transitions (those not matching any preset)
const customTransitionCount = (task.next || []).filter((t) => {
const ct = classifyTransitionWhen(t.when);
return ct === "custom";
}).length;
return (
<div
ref={nodeRef}
className={`absolute select-none ${isDragging ? "cursor-grabbing z-50" : "cursor-grab z-10"}`}
style={{
left: task.position.x,
top: task.position.y,
width: 240,
}}
onMouseDown={handleMouseDown}
onClick={handleClick}
>
{/* Input handle (top center) — drop target */}
<div
data-handle
className="absolute left-1/2 -translate-x-1/2 -top-[7px] z-20"
onMouseUp={handleInputHandleMouseUp}
onMouseEnter={() => setIsInputHandleHovered(true)}
onMouseLeave={() => setIsInputHandleHovered(false)}
>
<div
className="transition-all duration-150 rounded-full border-2 border-white shadow-sm"
style={{
width:
isConnectionTarget && isInputHandleHovered
? 16
: isConnectionTarget
? 14
: 10,
height:
isConnectionTarget && isInputHandleHovered
? 16
: isConnectionTarget
? 14
: 10,
backgroundColor:
isConnectionTarget && isInputHandleHovered
? "#8b5cf6"
: isConnectionTarget
? "#a78bfa"
: "#9ca3af",
boxShadow:
isConnectionTarget && isInputHandleHovered
? "0 0 0 4px rgba(139, 92, 246, 0.3), 0 1px 3px rgba(0,0,0,0.2)"
: isConnectionTarget
? "0 0 0 3px rgba(167, 139, 250, 0.3), 0 1px 2px rgba(0,0,0,0.15)"
: "0 1px 2px rgba(0,0,0,0.1)",
cursor: isConnectionTarget ? "pointer" : "default",
}}
/>
</div>
<div
className={`bg-white rounded-lg border-2 shadow-sm transition-colors ${borderColor}`}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-t-md bg-blue-500 bg-opacity-10 border-b border-gray-100">
<GripVertical className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold text-xs text-gray-900 truncate">
{task.name}
</div>
</div>
</div>
{/* Body */}
<div className="px-2.5 py-2">
{hasAction ? (
<div className="font-mono text-[11px] text-gray-600 truncate">
{task.action}
</div>
) : (
<div className="text-[11px] text-orange-500 italic">
No action assigned
</div>
)}
{/* Input summary */}
{Object.keys(task.input).length > 0 && (
<div className="mt-1.5 text-[10px] text-gray-400">
{Object.keys(task.input).length} input
{Object.keys(task.input).length !== 1 ? "s" : ""}
</div>
)}
{/* Transition summary */}
{summary && (
<div className="mt-1 text-[10px] text-gray-400">{summary}</div>
)}
{/* Delay badge */}
{task.delay && (
<div className="mt-1 inline-block px-1.5 py-0.5 bg-yellow-50 border border-yellow-200 rounded text-[10px] text-yellow-700 truncate max-w-full">
delay: {task.delay}s
</div>
)}
{/* With-items badge */}
{task.with_items && (
<div className="mt-1 inline-block px-1.5 py-0.5 bg-indigo-50 border border-indigo-200 rounded text-[10px] text-indigo-700 truncate max-w-full">
with_items
</div>
)}
{/* Retry badge */}
{task.retry && (
<div className="mt-1 inline-block px-1.5 py-0.5 bg-orange-50 border border-orange-200 rounded text-[10px] text-orange-700 ml-1">
retry: {task.retry.count}×
</div>
)}
{/* Custom transitions badge */}
{customTransitionCount > 0 && (
<div className="mt-1 inline-block px-1.5 py-0.5 bg-violet-50 border border-violet-200 rounded text-[10px] text-violet-700 ml-1">
{customTransitionCount} custom transition
{customTransitionCount !== 1 ? "s" : ""}
</div>
)}
</div>
{/* Footer actions */}
<div className="flex items-center justify-end px-2 py-1.5 border-t border-gray-100 bg-gray-50 rounded-b-md">
<div className="flex gap-1">
<button
data-action-button
onClick={(e) => {
e.stopPropagation();
onSelect(task.id);
}}
className="p-1 rounded hover:bg-blue-100 text-gray-400 hover:text-blue-600 transition-colors"
title="Configure task"
>
<Settings className="w-3 h-3" />
</button>
<button
data-action-button
onClick={handleDelete}
className="p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
title="Delete task"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{/* Connection target overlay */}
{isConnectionTarget && (
<div className="absolute inset-0 rounded-lg bg-purple-100 bg-opacity-20 pointer-events-none flex items-center justify-center">
<div className="text-xs font-medium text-purple-600 bg-white px-2 py-1 rounded shadow-sm">
Drop to connect
</div>
</div>
)}
</div>
{/* Output handles (bottom) — drag sources */}
<div
className="flex items-center justify-center gap-3 -mt-[7px] relative z-20"
data-handle
>
{HANDLE_CONFIG.map((handle) => {
const isActive = hasActiveTransition(task, handle.preset);
const isHovered = hoveredHandle === handle.preset;
const isCurrentlyDragging =
connectingFrom?.taskId === task.id &&
connectingFrom?.preset === handle.preset;
return (
<div
key={handle.preset}
className="relative group"
onMouseEnter={() => setHoveredHandle(handle.preset)}
onMouseLeave={() => setHoveredHandle(null)}
>
<div
data-handle
onMouseDown={(e) => handleHandleMouseDown(e, handle.preset)}
className="transition-all duration-150 rounded-full border-2 border-white cursor-crosshair"
style={{
width: isHovered || isCurrentlyDragging ? 14 : 10,
height: isHovered || isCurrentlyDragging ? 14 : 10,
backgroundColor: isCurrentlyDragging
? handle.activeColor
: isHovered
? handle.hoverColor
: isActive
? handle.color
: `${handle.color}80`,
boxShadow: isCurrentlyDragging
? `0 0 0 4px ${handle.ringColor}, 0 1px 3px rgba(0,0,0,0.2)`
: isHovered
? `0 0 0 3px ${handle.ringColor}, 0 1px 2px rgba(0,0,0,0.15)`
: "0 1px 2px rgba(0,0,0,0.1)",
}}
/>
{/* Tooltip */}
<div
className={`absolute left-1/2 -translate-x-1/2 top-full mt-1.5 px-2 py-1 bg-gray-900 text-white text-[10px] font-medium rounded shadow-lg whitespace-nowrap pointer-events-none transition-opacity duration-150 ${
isHovered ? "opacity-100" : "opacity-0"
}`}
>
{PRESET_LABELS[handle.preset]}
<div className="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-gray-900 rotate-45" />
</div>
</div>
);
})}
</div>
</div>
);
}
const TaskNode = memo(TaskNodeInner);
export default TaskNode;

View File

@@ -0,0 +1,275 @@
import { useState, useCallback, useRef, useMemo } from "react";
import TaskNode from "./TaskNode";
import type { TransitionPreset } from "./TaskNode";
import WorkflowEdges from "./WorkflowEdges";
import type { EdgeHoverInfo } from "./WorkflowEdges";
import type {
WorkflowTask,
PaletteAction,
WorkflowEdge,
} from "@/types/workflow";
import {
deriveEdges,
generateUniqueTaskName,
generateTaskId,
PRESET_LABELS,
} from "@/types/workflow";
import { Plus } from "lucide-react";
interface WorkflowCanvasProps {
tasks: WorkflowTask[];
selectedTaskId: string | null;
availableActions: PaletteAction[];
onSelectTask: (taskId: string | null) => void;
onUpdateTask: (taskId: string, updates: Partial<WorkflowTask>) => void;
onDeleteTask: (taskId: string) => void;
onAddTask: (task: WorkflowTask) => void;
onSetConnection: (
fromTaskId: string,
preset: TransitionPreset,
toTaskName: string,
) => void;
onEdgeHover?: (info: EdgeHoverInfo | null) => void;
}
/** Label color mapping for the connecting banner */
const PRESET_BANNER_COLORS: Record<TransitionPreset, string> = {
succeeded: "text-green-200 font-bold",
failed: "text-red-200 font-bold",
always: "text-gray-200 font-bold",
};
export default function WorkflowCanvas({
tasks,
selectedTaskId,
onSelectTask,
onUpdateTask,
onDeleteTask,
onAddTask,
onSetConnection,
onEdgeHover,
}: WorkflowCanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const [connectingFrom, setConnectingFrom] = useState<{
taskId: string;
preset: TransitionPreset;
} | null>(null);
const [mousePosition, setMousePosition] = useState<{
x: number;
y: number;
} | null>(null);
const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]);
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
// Only deselect if clicking the canvas background
if (
e.target === canvasRef.current ||
(e.target as HTMLElement).dataset.canvasBg === "true"
) {
if (connectingFrom) {
setConnectingFrom(null);
setMousePosition(null);
} else {
onSelectTask(null);
}
}
},
[onSelectTask, connectingFrom],
);
const handleCanvasMouseMove = useCallback(
(e: React.MouseEvent) => {
if (connectingFrom && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const scrollLeft = canvasRef.current.scrollLeft;
const scrollTop = canvasRef.current.scrollTop;
setMousePosition({
x: e.clientX - rect.left + scrollLeft,
y: e.clientY - rect.top + scrollTop,
});
}
},
[connectingFrom],
);
const handleCanvasMouseUp = useCallback(() => {
// If we're connecting and mouseup happens on the canvas (not on a node),
// cancel the connection
if (connectingFrom) {
setConnectingFrom(null);
setMousePosition(null);
}
}, [connectingFrom]);
const handlePositionChange = useCallback(
(taskId: string, position: { x: number; y: number }) => {
onUpdateTask(taskId, { position });
},
[onUpdateTask],
);
const handleStartConnection = useCallback(
(taskId: string, preset: TransitionPreset) => {
setConnectingFrom({ taskId, preset });
},
[],
);
const handleCompleteConnection = useCallback(
(targetTaskId: string) => {
if (!connectingFrom) return;
const targetTask = tasks.find((t) => t.id === targetTaskId);
if (!targetTask) return;
onSetConnection(
connectingFrom.taskId,
connectingFrom.preset,
targetTask.name,
);
setConnectingFrom(null);
setMousePosition(null);
},
[connectingFrom, tasks, onSetConnection],
);
const handleAddEmptyTask = useCallback(() => {
const name = generateUniqueTaskName(tasks);
// Position new tasks below existing ones
let maxY = 0;
for (const task of tasks) {
if (task.position.y > maxY) {
maxY = task.position.y;
}
}
const newTask: WorkflowTask = {
id: generateTaskId(),
name,
action: "",
input: {},
position: {
x: 300,
y: tasks.length === 0 ? 60 : maxY + 160,
},
};
onAddTask(newTask);
onSelectTask(newTask.id);
}, [tasks, onAddTask, onSelectTask]);
// Calculate minimum canvas dimensions based on node positions
const canvasDimensions = useMemo(() => {
let maxX = 800;
let maxY = 600;
for (const task of tasks) {
maxX = Math.max(maxX, task.position.x + 340);
maxY = Math.max(maxY, task.position.y + 220);
}
return { width: maxX, height: maxY };
}, [tasks]);
return (
<div
className="flex-1 overflow-auto bg-gray-100 relative"
ref={canvasRef}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
>
{/* Grid background */}
<div
data-canvas-bg="true"
className="absolute inset-0"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
backgroundImage: `
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px)
`,
backgroundSize: "20px 20px",
}}
/>
{/* Connecting mode indicator */}
{connectingFrom && (
<div className="sticky top-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
<div className="mt-3 px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-full shadow-lg pointer-events-auto">
Drag to a task to connect as{" "}
<span className={PRESET_BANNER_COLORS[connectingFrom.preset]}>
{PRESET_LABELS[connectingFrom.preset]}
</span>{" "}
transition or release to cancel
</div>
</div>
)}
{/* Edge rendering layer */}
<WorkflowEdges
edges={edges}
tasks={tasks}
connectingFrom={connectingFrom}
mousePosition={mousePosition}
onEdgeHover={onEdgeHover}
/>
{/* Task nodes */}
{tasks.map((task) => (
<TaskNode
key={task.id}
task={task}
isSelected={task.id === selectedTaskId}
allTaskNames={allTaskNames}
onSelect={onSelectTask}
onDelete={onDeleteTask}
onPositionChange={handlePositionChange}
onStartConnection={handleStartConnection}
connectingFrom={connectingFrom}
onCompleteConnection={handleCompleteConnection}
/>
))}
{/* Empty state / Add task button */}
{tasks.length === 0 ? (
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
}}
>
<div className="text-center pointer-events-auto">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-200 flex items-center justify-center">
<Plus className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-600 mb-2">
Empty Workflow
</h3>
<p className="text-sm text-gray-400 mb-4 max-w-xs">
Add tasks from the action palette on the left, or click the button
below to add a blank task.
</p>
<button
onClick={handleAddEmptyTask}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
<Plus className="w-4 h-4 inline-block mr-1.5 -mt-0.5" />
Add First Task
</button>
</div>
</div>
) : (
<button
onClick={handleAddEmptyTask}
className="fixed bottom-6 right-6 z-40 w-12 h-12 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center"
title="Add a new task"
>
<Plus className="w-6 h-6" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,379 @@
import { memo, useMemo } from "react";
import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow";
import type { TransitionPreset } from "./TaskNode";
export interface EdgeHoverInfo {
taskId: string;
transitionIndex: number;
}
interface WorkflowEdgesProps {
edges: WorkflowEdge[];
tasks: WorkflowTask[];
/** Width of each task node (must match TaskNode width) */
nodeWidth?: number;
/** Approximate height of each task node */
nodeHeight?: number;
/** The task ID currently being connected from (for preview line) */
connectingFrom?: { taskId: string; preset: TransitionPreset } | null;
/** Mouse position for drawing the preview connection line */
mousePosition?: { x: number; y: number } | null;
/** Called when the mouse enters/leaves an edge hit area */
onEdgeHover?: (info: EdgeHoverInfo | null) => void;
}
const NODE_WIDTH = 240;
const NODE_HEIGHT = 120;
/** Color for each edge type */
const EDGE_COLORS: Record<EdgeType, string> = {
success: "#22c55e", // green-500
failure: "#ef4444", // red-500
complete: "#6b7280", // gray-500 (unconditional / always)
custom: "#8b5cf6", // violet-500
};
const EDGE_DASH: Record<EdgeType, string> = {
success: "",
failure: "6,4",
complete: "4,4",
custom: "8,4,2,4",
};
/** Map presets to edge colors for the preview line */
const PRESET_COLORS: Record<TransitionPreset, string> = {
succeeded: EDGE_COLORS.success,
failed: EDGE_COLORS.failure,
always: EDGE_COLORS.complete,
};
/** Calculate the center-bottom of a task node */
function getNodeBottomCenter(
task: WorkflowTask,
nodeWidth: number,
nodeHeight: number,
) {
return {
x: task.position.x + nodeWidth / 2,
y: task.position.y + nodeHeight,
};
}
/** Calculate the center-top of a task node */
function getNodeTopCenter(task: WorkflowTask, nodeWidth: number) {
return {
x: task.position.x + nodeWidth / 2,
y: task.position.y,
};
}
/** Calculate the left-center of a task node */
function getNodeLeftCenter(task: WorkflowTask, nodeHeight: number) {
return {
x: task.position.x,
y: task.position.y + nodeHeight / 2,
};
}
/** Calculate the right-center of a task node */
function getNodeRightCenter(
task: WorkflowTask,
nodeWidth: number,
nodeHeight: number,
) {
return {
x: task.position.x + nodeWidth,
y: task.position.y + nodeHeight / 2,
};
}
/**
* Determine the best connection points between two nodes.
* Returns the start and end points for the edge.
*/
function getBestConnectionPoints(
fromTask: WorkflowTask,
toTask: WorkflowTask,
nodeWidth: number,
nodeHeight: number,
): { start: { x: number; y: number }; end: { x: number; y: number } } {
const fromCenter = {
x: fromTask.position.x + nodeWidth / 2,
y: fromTask.position.y + nodeHeight / 2,
};
const toCenter = {
x: toTask.position.x + nodeWidth / 2,
y: toTask.position.y + nodeHeight / 2,
};
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
// If the target is mostly below the source, use bottom→top
if (dy > 0 && Math.abs(dy) > Math.abs(dx) * 0.5) {
return {
start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight),
end: getNodeTopCenter(toTask, nodeWidth),
};
}
// If the target is mostly above the source, use top→bottom
if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) {
return {
start: getNodeTopCenter(fromTask, nodeWidth),
end: getNodeBottomCenter(toTask, nodeWidth, nodeHeight),
};
}
// If the target is to the right, use right→left
if (dx > 0) {
return {
start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight),
end: getNodeLeftCenter(toTask, nodeHeight),
};
}
// Target is to the left, use left→right
return {
start: getNodeLeftCenter(fromTask, nodeHeight),
end: getNodeRightCenter(toTask, nodeWidth, nodeHeight),
};
}
/**
* Build an SVG path string for a curved edge between two points.
* Uses a cubic bezier curve.
*/
function buildCurvePath(
start: { x: number; y: number },
end: { x: number; y: number },
): string {
const dx = end.x - start.x;
const dy = end.y - start.y;
// Determine control points based on dominant direction
let cp1: { x: number; y: number };
let cp2: { x: number; y: number };
if (Math.abs(dy) > Math.abs(dx) * 0.5) {
// Mostly vertical connection
const offset = Math.min(Math.abs(dy) * 0.5, 80);
const direction = dy > 0 ? 1 : -1;
cp1 = { x: start.x, y: start.y + offset * direction };
cp2 = { x: end.x, y: end.y - offset * direction };
} else {
// Mostly horizontal connection
const offset = Math.min(Math.abs(dx) * 0.5, 80);
const direction = dx > 0 ? 1 : -1;
cp1 = { x: start.x + offset * direction, y: start.y };
cp2 = { x: end.x - offset * direction, y: end.y };
}
return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
}
function WorkflowEdgesInner({
edges,
tasks,
nodeWidth = NODE_WIDTH,
nodeHeight = NODE_HEIGHT,
connectingFrom,
mousePosition,
onEdgeHover,
}: WorkflowEdgesProps) {
const taskMap = useMemo(() => {
const map = new Map<string, WorkflowTask>();
for (const task of tasks) {
map.set(task.id, task);
}
return map;
}, [tasks]);
// Calculate SVG bounds to cover all nodes + padding
const svgBounds = useMemo(() => {
if (tasks.length === 0) return { width: 2000, height: 2000 };
let maxX = 0;
let maxY = 0;
for (const task of tasks) {
maxX = Math.max(maxX, task.position.x + nodeWidth + 100);
maxY = Math.max(maxY, task.position.y + nodeHeight + 100);
}
return {
width: Math.max(maxX, 2000),
height: Math.max(maxY, 2000),
};
}, [tasks, nodeWidth, nodeHeight]);
const renderedEdges = useMemo(() => {
return edges
.map((edge, index) => {
const fromTask = taskMap.get(edge.from);
const toTask = taskMap.get(edge.to);
if (!fromTask || !toTask) return null;
const { start, end } = getBestConnectionPoints(
fromTask,
toTask,
nodeWidth,
nodeHeight,
);
const pathD = buildCurvePath(start, end);
const color =
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
const dash = EDGE_DASH[edge.type] || "";
// Calculate label position (midpoint of curve)
const labelX = (start.x + end.x) / 2;
const labelY = (start.y + end.y) / 2 - 8;
// Measure approximate label width
const labelText = edge.label || "";
const labelWidth = Math.max(labelText.length * 5.5 + 12, 48);
const arrowId = edge.color
? `arrow-custom-${index}`
: `arrow-${edge.type}`;
return (
<g key={`edge-${index}-${edge.from}-${edge.to}`}>
{/* Edge path */}
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={2}
strokeDasharray={dash}
markerEnd={`url(#${arrowId})`}
className="transition-opacity"
opacity={0.75}
/>
{/* Wider invisible path for easier hovering */}
<path
d={pathD}
fill="none"
stroke="transparent"
strokeWidth={12}
className="cursor-pointer"
onMouseEnter={() =>
onEdgeHover?.({
taskId: edge.from,
transitionIndex: edge.transitionIndex,
})
}
onMouseLeave={() => onEdgeHover?.(null)}
/>
{/* Label */}
{edge.label && (
<g>
<rect
x={labelX - labelWidth / 2}
y={labelY - 7}
width={labelWidth}
height={14}
rx={3}
fill="white"
stroke={color}
strokeWidth={0.5}
opacity={0.9}
/>
<text
x={labelX}
y={labelY + 3}
textAnchor="middle"
fontSize={9}
fontWeight={500}
fill={color}
className="select-none pointer-events-none"
>
{labelText.length > 24
? labelText.slice(0, 21) + "..."
: labelText}
</text>
</g>
)}
</g>
);
})
.filter(Boolean);
}, [edges, taskMap, nodeWidth, nodeHeight, onEdgeHover]);
// Preview line when connecting
const previewLine = useMemo(() => {
if (!connectingFrom || !mousePosition) return null;
const fromTask = taskMap.get(connectingFrom.taskId);
if (!fromTask) return null;
const start = getNodeBottomCenter(fromTask, nodeWidth, nodeHeight);
const end = mousePosition;
const pathD = buildCurvePath(start, end);
const color = PRESET_COLORS[connectingFrom.preset] || EDGE_COLORS.complete;
return (
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={2}
strokeDasharray="6,4"
opacity={0.5}
className="pointer-events-none"
/>
);
}, [connectingFrom, mousePosition, taskMap, nodeWidth, nodeHeight]);
return (
<svg
className="absolute inset-0 pointer-events-none overflow-visible"
width={svgBounds.width}
height={svgBounds.height}
style={{ zIndex: 1 }}
>
<defs>
{/* Arrow markers for each edge type */}
{Object.entries(EDGE_COLORS).map(([type, color]) => (
<marker
key={`arrow-${type}`}
id={`arrow-${type}`}
viewBox="0 0 10 10"
refX={9}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={color} opacity={0.8} />
</marker>
))}
</defs>
{/* Render edges */}
<g className="pointer-events-auto">
{/* Dynamic arrow markers for custom-colored edges */}
{edges.map((edge, index) => {
if (!edge.color) return null;
return (
<marker
key={`arrow-custom-${index}`}
id={`arrow-custom-${index}`}
viewBox="0 0 10 10"
refX={9}
refY={5}
markerWidth={8}
markerHeight={8}
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={edge.color} opacity={0.8} />
</marker>
);
})}
{renderedEdges}
</g>
{/* Preview line */}
{previewLine}
</svg>
);
}
const WorkflowEdges = memo(WorkflowEdgesInner);
export default WorkflowEdges;