[WIP] workflow builder
This commit is contained in:
@@ -16,6 +16,9 @@ const PackRegisterPage = lazy(() => import("@/pages/packs/PackRegisterPage"));
|
||||
const PackInstallPage = lazy(() => import("@/pages/packs/PackInstallPage"));
|
||||
const PackEditPage = lazy(() => import("@/pages/packs/PackEditPage"));
|
||||
const ActionsPage = lazy(() => import("@/pages/actions/ActionsPage"));
|
||||
const WorkflowBuilderPage = lazy(
|
||||
() => import("@/pages/actions/WorkflowBuilderPage"),
|
||||
);
|
||||
const RulesPage = lazy(() => import("@/pages/rules/RulesPage"));
|
||||
const RuleCreatePage = lazy(() => import("@/pages/rules/RuleCreatePage"));
|
||||
const RuleEditPage = lazy(() => import("@/pages/rules/RuleEditPage"));
|
||||
@@ -78,6 +81,14 @@ function App() {
|
||||
<Route path="packs/:ref" element={<PacksPage />} />
|
||||
<Route path="packs/:ref/edit" element={<PackEditPage />} />
|
||||
<Route path="actions" element={<ActionsPage />} />
|
||||
<Route
|
||||
path="actions/workflows/new"
|
||||
element={<WorkflowBuilderPage />}
|
||||
/>
|
||||
<Route
|
||||
path="actions/workflows/:ref/edit"
|
||||
element={<WorkflowBuilderPage />}
|
||||
/>
|
||||
<Route path="actions/:ref" element={<ActionsPage />} />
|
||||
<Route path="rules" element={<RulesPage />} />
|
||||
<Route path="rules/new" element={<RuleCreatePage />} />
|
||||
|
||||
@@ -6,25 +6,20 @@
|
||||
* Request DTO for installing a pack from remote source
|
||||
*/
|
||||
export type InstallPackRequest = {
|
||||
/**
|
||||
* Force reinstall if pack already exists
|
||||
*/
|
||||
force?: boolean;
|
||||
/**
|
||||
* Git branch, tag, or commit reference
|
||||
*/
|
||||
ref_spec?: string | null;
|
||||
/**
|
||||
* Skip dependency validation (not recommended)
|
||||
*/
|
||||
skip_deps?: boolean;
|
||||
/**
|
||||
* Skip running pack tests during installation
|
||||
*/
|
||||
skip_tests?: boolean;
|
||||
/**
|
||||
* Repository URL or source location
|
||||
*/
|
||||
source: string;
|
||||
/**
|
||||
* Git branch, tag, or commit reference
|
||||
*/
|
||||
ref_spec?: string | null;
|
||||
/**
|
||||
* Skip dependency validation (not recommended)
|
||||
*/
|
||||
skip_deps?: boolean;
|
||||
/**
|
||||
* Skip running pack tests during installation
|
||||
*/
|
||||
skip_tests?: boolean;
|
||||
/**
|
||||
* Repository URL or source location
|
||||
*/
|
||||
source: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "" }],
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
168
web/src/components/workflows/ActionPalette.tsx
Normal file
168
web/src/components/workflows/ActionPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1143
web/src/components/workflows/TaskInspector.tsx
Normal file
1143
web/src/components/workflows/TaskInspector.tsx
Normal file
File diff suppressed because it is too large
Load Diff
417
web/src/components/workflows/TaskNode.tsx
Normal file
417
web/src/components/workflows/TaskNode.tsx
Normal 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;
|
||||
275
web/src/components/workflows/WorkflowCanvas.tsx
Normal file
275
web/src/components/workflows/WorkflowCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
web/src/components/workflows/WorkflowEdges.tsx
Normal file
379
web/src/components/workflows/WorkflowEdges.tsx
Normal 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;
|
||||
@@ -96,13 +96,11 @@ export function useInstallPack() {
|
||||
mutationFn: async ({
|
||||
source,
|
||||
refSpec,
|
||||
force = false,
|
||||
skipTests = false,
|
||||
skipDeps = false,
|
||||
}: {
|
||||
source: string;
|
||||
refSpec?: string;
|
||||
force?: boolean;
|
||||
skipTests?: boolean;
|
||||
skipDeps?: boolean;
|
||||
}) => {
|
||||
@@ -110,7 +108,6 @@ export function useInstallPack() {
|
||||
requestBody: {
|
||||
source,
|
||||
ref_spec: refSpec,
|
||||
force,
|
||||
skip_tests: skipTests,
|
||||
skip_deps: skipDeps,
|
||||
},
|
||||
|
||||
163
web/src/hooks/useWorkflows.ts
Normal file
163
web/src/hooks/useWorkflows.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { WorkflowsService } from "@/api";
|
||||
import type { CreateWorkflowRequest, UpdateWorkflowRequest } from "@/api";
|
||||
import type { SaveWorkflowFileRequest } from "@/types/workflow";
|
||||
import { OpenAPI } from "@/api/core/OpenAPI";
|
||||
import { request as __request } from "@/api/core/request";
|
||||
|
||||
interface WorkflowsQueryParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
packRef?: string;
|
||||
tags?: string;
|
||||
enabled?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Fetch all workflows with pagination and filtering
|
||||
export function useWorkflows(params?: WorkflowsQueryParams) {
|
||||
return useQuery({
|
||||
queryKey: ["workflows", params],
|
||||
queryFn: async () => {
|
||||
const response = await WorkflowsService.listWorkflows({
|
||||
page: params?.page || 1,
|
||||
pageSize: params?.pageSize || 50,
|
||||
tags: params?.tags,
|
||||
enabled: params?.enabled,
|
||||
search: params?.search,
|
||||
packRef: params?.packRef,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch single workflow by ref
|
||||
export function useWorkflow(ref: string) {
|
||||
return useQuery({
|
||||
queryKey: ["workflows", ref],
|
||||
queryFn: async () => {
|
||||
const response = await WorkflowsService.getWorkflow({ ref });
|
||||
return response;
|
||||
},
|
||||
enabled: !!ref,
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new workflow
|
||||
export function useCreateWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateWorkflowRequest) => {
|
||||
const response = await WorkflowsService.createWorkflow({
|
||||
requestBody: data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update existing workflow
|
||||
export function useUpdateWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
ref,
|
||||
data,
|
||||
}: {
|
||||
ref: string;
|
||||
data: UpdateWorkflowRequest;
|
||||
}) => {
|
||||
const response = await WorkflowsService.updateWorkflow({
|
||||
ref,
|
||||
requestBody: data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflows", variables.ref],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete workflow
|
||||
export function useDeleteWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (ref: string) => {
|
||||
await WorkflowsService.deleteWorkflow({ ref });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Save workflow file to disk and sync to DB
|
||||
// This calls a custom endpoint not in the generated client
|
||||
export function useSaveWorkflowFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: SaveWorkflowFileRequest) => {
|
||||
const response = await __request(OpenAPI, {
|
||||
method: "POST",
|
||||
url: "/api/v1/packs/{pack_ref}/workflow-files",
|
||||
path: {
|
||||
pack_ref: data.pack_ref,
|
||||
},
|
||||
body: data,
|
||||
mediaType: "application/json",
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["actions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update an existing workflow file on disk and sync to DB
|
||||
export function useUpdateWorkflowFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowRef,
|
||||
data,
|
||||
}: {
|
||||
workflowRef: string;
|
||||
data: SaveWorkflowFileRequest;
|
||||
}) => {
|
||||
const response = await __request(OpenAPI, {
|
||||
method: "PUT",
|
||||
url: "/api/v1/workflows/{ref}/file",
|
||||
path: {
|
||||
ref: workflowRef,
|
||||
},
|
||||
body: data,
|
||||
mediaType: "application/json",
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflows", variables.workflowRef],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["actions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,21 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes flash-highlight {
|
||||
0% {
|
||||
background-color: rgb(191 219 254); /* blue-200 */
|
||||
box-shadow: 0 0 12px 2px rgb(147 197 253 / 0.5); /* blue-300 glow */
|
||||
}
|
||||
40% {
|
||||
background-color: rgb(219 234 254); /* blue-100 */
|
||||
box-shadow: 0 0 6px 1px rgb(147 197 253 / 0.3);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Input field with non-editable prefix */
|
||||
.input-with-prefix {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import { useActions, useAction, useDeleteAction } from "@/hooks/useActions";
|
||||
import { useExecutions } from "@/hooks/useExecutions";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Search, X, Play } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Search, X, Play, Plus } from "lucide-react";
|
||||
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
|
||||
import ErrorDisplay from "@/components/common/ErrorDisplay";
|
||||
import { extractProperties } from "@/components/common/ParamSchemaForm";
|
||||
|
||||
export default function ActionsPage() {
|
||||
const { ref } = useParams<{ ref?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useActions();
|
||||
const actions = data?.data || [];
|
||||
const [collapsedPacks, setCollapsedPacks] = useState<Set<string>>(new Set());
|
||||
@@ -78,10 +80,22 @@ export default function ActionsPage() {
|
||||
{/* Left sidebar - Actions List */}
|
||||
<div className="w-96 border-r border-gray-200 overflow-y-auto bg-gray-50">
|
||||
<div className="p-4 border-b border-gray-200 bg-white sticky top-0 z-10">
|
||||
<h1 className="text-2xl font-bold">Actions</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{filteredActions.length} of {actions.length} actions
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Actions</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{filteredActions.length} of {actions.length} actions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate("/actions/workflows/new")}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
||||
title="Create a new workflow action"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Workflow
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mt-3 relative">
|
||||
@@ -261,8 +275,7 @@ function ActionDetail({ actionRef }: { actionRef: string }) {
|
||||
|
||||
const executions = executionsData?.data || [];
|
||||
const paramSchema = action.data?.param_schema || {};
|
||||
const properties = paramSchema.properties || {};
|
||||
const requiredFields = paramSchema.required || [];
|
||||
const properties = extractProperties(paramSchema);
|
||||
const paramEntries = Object.entries(properties);
|
||||
|
||||
return (
|
||||
@@ -420,11 +433,16 @@ function ActionDetail({ actionRef }: { actionRef: string }) {
|
||||
<span className="font-mono font-semibold text-sm">
|
||||
{key}
|
||||
</span>
|
||||
{requiredFields.includes(key) && (
|
||||
{param?.required && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
{param?.secret && (
|
||||
<span className="text-xs px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded">
|
||||
Secret
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
|
||||
{param?.type || "any"}
|
||||
</span>
|
||||
|
||||
672
web/src/pages/actions/WorkflowBuilderPage.tsx
Normal file
672
web/src/pages/actions/WorkflowBuilderPage.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
AlertTriangle,
|
||||
FileCode,
|
||||
Code,
|
||||
LayoutDashboard,
|
||||
} from "lucide-react";
|
||||
import yaml from "js-yaml";
|
||||
import type { WorkflowYamlDefinition } from "@/types/workflow";
|
||||
import ActionPalette from "@/components/workflows/ActionPalette";
|
||||
import WorkflowCanvas from "@/components/workflows/WorkflowCanvas";
|
||||
import type { EdgeHoverInfo } from "@/components/workflows/WorkflowEdges";
|
||||
import TaskInspector from "@/components/workflows/TaskInspector";
|
||||
import { useActions } from "@/hooks/useActions";
|
||||
import { usePacks } from "@/hooks/usePacks";
|
||||
import { useWorkflow } from "@/hooks/useWorkflows";
|
||||
import {
|
||||
useSaveWorkflowFile,
|
||||
useUpdateWorkflowFile,
|
||||
} from "@/hooks/useWorkflows";
|
||||
import type {
|
||||
WorkflowTask,
|
||||
WorkflowBuilderState,
|
||||
PaletteAction,
|
||||
TransitionPreset,
|
||||
} from "@/types/workflow";
|
||||
import {
|
||||
generateUniqueTaskName,
|
||||
generateTaskId,
|
||||
builderStateToDefinition,
|
||||
definitionToBuilderState,
|
||||
validateWorkflow,
|
||||
addTransitionTarget,
|
||||
removeTaskFromTransitions,
|
||||
} from "@/types/workflow";
|
||||
|
||||
const INITIAL_STATE: WorkflowBuilderState = {
|
||||
name: "",
|
||||
label: "",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
packRef: "",
|
||||
parameters: {},
|
||||
output: {},
|
||||
vars: {},
|
||||
tasks: [],
|
||||
tags: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export default function WorkflowBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { ref: editRef } = useParams<{ ref?: string }>();
|
||||
const isEditing = !!editRef;
|
||||
|
||||
// Data fetching
|
||||
const { data: actionsData, isLoading: actionsLoading } = useActions({
|
||||
pageSize: 200,
|
||||
});
|
||||
const { data: packsData } = usePacks({ pageSize: 100 });
|
||||
const { data: existingWorkflow, isLoading: workflowLoading } = useWorkflow(
|
||||
editRef || "",
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const saveWorkflowFile = useSaveWorkflowFile();
|
||||
const updateWorkflowFile = useUpdateWorkflowFile();
|
||||
|
||||
// Builder state
|
||||
const [state, setState] = useState<WorkflowBuilderState>(INITIAL_STATE);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [showYamlPreview, setShowYamlPreview] = useState(false);
|
||||
const [highlightedTransition, setHighlightedTransition] = useState<{
|
||||
taskId: string;
|
||||
transitionIndex: number;
|
||||
} | null>(null);
|
||||
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleEdgeHover = useCallback(
|
||||
(info: EdgeHoverInfo | null) => {
|
||||
// Clear any pending auto-clear timeout
|
||||
if (highlightTimeoutRef.current) {
|
||||
clearTimeout(highlightTimeoutRef.current);
|
||||
highlightTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (info) {
|
||||
// Select the source task so TaskInspector opens for it
|
||||
setSelectedTaskId(info.taskId);
|
||||
setHighlightedTransition(info);
|
||||
|
||||
// Auto-clear highlight after 2 seconds so the flash animation plays once
|
||||
highlightTimeoutRef.current = setTimeout(() => {
|
||||
setHighlightedTransition(null);
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 2000);
|
||||
} else {
|
||||
setHighlightedTransition(null);
|
||||
}
|
||||
},
|
||||
[setSelectedTaskId],
|
||||
);
|
||||
|
||||
// Initialize state from existing workflow (edit mode)
|
||||
if (isEditing && existingWorkflow && !initialized && !workflowLoading) {
|
||||
const workflow = existingWorkflow.data;
|
||||
if (workflow) {
|
||||
// Extract name from ref (e.g., "pack.name" -> "name")
|
||||
const refParts = workflow.ref.split(".");
|
||||
const name =
|
||||
refParts.length > 1 ? refParts.slice(1).join(".") : workflow.ref;
|
||||
|
||||
const builderState = definitionToBuilderState(
|
||||
{
|
||||
ref: workflow.ref,
|
||||
label: workflow.label,
|
||||
description: workflow.description || undefined,
|
||||
version: workflow.version,
|
||||
parameters: workflow.param_schema || undefined,
|
||||
output: workflow.out_schema || undefined,
|
||||
tasks:
|
||||
((workflow.definition as Record<string, unknown>)
|
||||
?.tasks as WorkflowYamlDefinition["tasks"]) || [],
|
||||
tags: workflow.tags,
|
||||
},
|
||||
workflow.pack_ref,
|
||||
name,
|
||||
);
|
||||
setState(builderState);
|
||||
setInitialized(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived data
|
||||
const paletteActions: PaletteAction[] = useMemo(() => {
|
||||
const actions = (actionsData?.data || []) as Array<{
|
||||
id: number;
|
||||
ref: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
pack_ref: string;
|
||||
param_schema?: Record<string, unknown> | null;
|
||||
out_schema?: Record<string, unknown> | null;
|
||||
}>;
|
||||
return actions.map((a) => ({
|
||||
id: a.id,
|
||||
ref: a.ref,
|
||||
label: a.label,
|
||||
description: a.description || "",
|
||||
pack_ref: a.pack_ref,
|
||||
param_schema: a.param_schema || null,
|
||||
out_schema: a.out_schema || null,
|
||||
}));
|
||||
}, [actionsData]);
|
||||
|
||||
// Build action schema map for stripping defaults during serialization
|
||||
const actionSchemaMap = useMemo(() => {
|
||||
const map = new Map<string, Record<string, unknown> | null>();
|
||||
for (const action of paletteActions) {
|
||||
map.set(action.ref, action.param_schema);
|
||||
}
|
||||
return map;
|
||||
}, [paletteActions]);
|
||||
|
||||
const packs = useMemo(() => {
|
||||
return (packsData?.data || []) as Array<{
|
||||
id: number;
|
||||
ref: string;
|
||||
label: string;
|
||||
}>;
|
||||
}, [packsData]);
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => state.tasks.find((t) => t.id === selectedTaskId) || null,
|
||||
[state.tasks, selectedTaskId],
|
||||
);
|
||||
|
||||
const allTaskNames = useMemo(
|
||||
() => state.tasks.map((t) => t.name),
|
||||
[state.tasks],
|
||||
);
|
||||
|
||||
// State updaters
|
||||
const updateMetadata = useCallback(
|
||||
(updates: Partial<WorkflowBuilderState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }));
|
||||
setSaveSuccess(false);
|
||||
setSaveError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAddTaskFromPalette = useCallback(
|
||||
(action: PaletteAction) => {
|
||||
// Generate a task name from the action ref
|
||||
const baseName = action.ref.split(".").pop() || "task";
|
||||
const name = generateUniqueTaskName(state.tasks, baseName);
|
||||
|
||||
// Position below existing tasks
|
||||
let maxY = 0;
|
||||
for (const task of state.tasks) {
|
||||
if (task.position.y > maxY) {
|
||||
maxY = task.position.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-populate input from action's param_schema
|
||||
const input: Record<string, unknown> = {};
|
||||
if (action.param_schema && typeof action.param_schema === "object") {
|
||||
for (const [key, param] of Object.entries(action.param_schema)) {
|
||||
const meta = param as { default?: unknown };
|
||||
input[key] = meta?.default !== undefined ? meta.default : "";
|
||||
}
|
||||
}
|
||||
|
||||
const newTask: WorkflowTask = {
|
||||
id: generateTaskId(),
|
||||
name,
|
||||
action: action.ref,
|
||||
input,
|
||||
position: {
|
||||
x: 300,
|
||||
y: state.tasks.length === 0 ? 60 : maxY + 160,
|
||||
},
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tasks: [...prev.tasks, newTask],
|
||||
}));
|
||||
setSelectedTaskId(newTask.id);
|
||||
setSaveSuccess(false);
|
||||
},
|
||||
[state.tasks],
|
||||
);
|
||||
|
||||
const handleAddTask = useCallback((task: WorkflowTask) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tasks: [...prev.tasks, task],
|
||||
}));
|
||||
setSaveSuccess(false);
|
||||
}, []);
|
||||
|
||||
const handleUpdateTask = useCallback(
|
||||
(taskId: string, updates: Partial<WorkflowTask>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tasks: prev.tasks.map((t) =>
|
||||
t.id === taskId ? { ...t, ...updates } : t,
|
||||
),
|
||||
}));
|
||||
setSaveSuccess(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const taskToDelete = state.tasks.find((t) => t.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tasks: prev.tasks
|
||||
.filter((t) => t.id !== taskId)
|
||||
.map((t) => {
|
||||
// Clean up any transitions that reference the deleted task
|
||||
const cleanedNext = removeTaskFromTransitions(
|
||||
t.next,
|
||||
taskToDelete.name,
|
||||
);
|
||||
if (cleanedNext !== t.next) {
|
||||
return { ...t, next: cleanedNext };
|
||||
}
|
||||
return t;
|
||||
}),
|
||||
}));
|
||||
|
||||
if (selectedTaskId === taskId) {
|
||||
setSelectedTaskId(null);
|
||||
}
|
||||
setSaveSuccess(false);
|
||||
},
|
||||
[state.tasks, selectedTaskId],
|
||||
);
|
||||
|
||||
const handleSetConnection = useCallback(
|
||||
(fromTaskId: string, preset: TransitionPreset, toTaskName: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tasks: prev.tasks.map((t) => {
|
||||
if (t.id !== fromTaskId) return t;
|
||||
const next = addTransitionTarget(t, preset, toTaskName);
|
||||
return { ...t, next };
|
||||
}),
|
||||
}));
|
||||
setSaveSuccess(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Validate
|
||||
const errors = validateWorkflow(state);
|
||||
setValidationErrors(errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = builderStateToDefinition(state, actionSchemaMap);
|
||||
|
||||
try {
|
||||
setSaveError(null);
|
||||
|
||||
if (isEditing && editRef) {
|
||||
await updateWorkflowFile.mutateAsync({
|
||||
workflowRef: editRef,
|
||||
data: {
|
||||
name: state.name,
|
||||
label: state.label,
|
||||
description: state.description || undefined,
|
||||
version: state.version,
|
||||
pack_ref: state.packRef,
|
||||
definition,
|
||||
param_schema:
|
||||
Object.keys(state.parameters).length > 0
|
||||
? state.parameters
|
||||
: undefined,
|
||||
out_schema:
|
||||
Object.keys(state.output).length > 0 ? state.output : undefined,
|
||||
tags: state.tags.length > 0 ? state.tags : undefined,
|
||||
enabled: state.enabled,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await saveWorkflowFile.mutateAsync({
|
||||
name: state.name,
|
||||
label: state.label,
|
||||
description: state.description || undefined,
|
||||
version: state.version,
|
||||
pack_ref: state.packRef,
|
||||
definition,
|
||||
param_schema:
|
||||
Object.keys(state.parameters).length > 0
|
||||
? state.parameters
|
||||
: undefined,
|
||||
out_schema:
|
||||
Object.keys(state.output).length > 0 ? state.output : undefined,
|
||||
tags: state.tags.length > 0 ? state.tags : undefined,
|
||||
enabled: state.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { body?: { message?: string }; message?: string };
|
||||
const message =
|
||||
error?.body?.message || error?.message || "Failed to save workflow";
|
||||
setSaveError(message);
|
||||
}
|
||||
}, [
|
||||
state,
|
||||
isEditing,
|
||||
editRef,
|
||||
saveWorkflowFile,
|
||||
updateWorkflowFile,
|
||||
actionSchemaMap,
|
||||
]);
|
||||
|
||||
// YAML preview — generate proper YAML from builder state
|
||||
const yamlPreview = useMemo(() => {
|
||||
if (!showYamlPreview) return "";
|
||||
try {
|
||||
const definition = builderStateToDefinition(state, actionSchemaMap);
|
||||
return yaml.dump(definition, {
|
||||
indent: 2,
|
||||
lineWidth: 120,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
});
|
||||
} catch {
|
||||
return "# Error generating YAML preview";
|
||||
}
|
||||
}, [state, showYamlPreview, actionSchemaMap]);
|
||||
|
||||
const isSaving = saveWorkflowFile.isPending || updateWorkflowFile.isPending;
|
||||
|
||||
if (isEditing && workflowLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
{/* Top toolbar */}
|
||||
<div className="flex-shrink-0 bg-white border-b border-gray-200 px-4 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left section: Back + metadata */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => navigate("/actions")}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors flex-shrink-0"
|
||||
title="Back to Actions"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Pack selector */}
|
||||
<select
|
||||
value={state.packRef}
|
||||
onChange={(e) => updateMetadata({ packRef: e.target.value })}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 max-w-[140px]"
|
||||
>
|
||||
<option value="">Pack...</option>
|
||||
{packs.map((pack) => (
|
||||
<option key={pack.id} value={pack.ref}>
|
||||
{pack.ref}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="text-gray-400 text-lg font-light">/</span>
|
||||
|
||||
{/* Workflow name */}
|
||||
<input
|
||||
type="text"
|
||||
value={state.name}
|
||||
onChange={(e) =>
|
||||
updateMetadata({
|
||||
name: e.target.value.replace(/[^a-zA-Z0-9_-]/g, "_"),
|
||||
})
|
||||
}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-48"
|
||||
placeholder="workflow_name"
|
||||
/>
|
||||
|
||||
<span className="text-gray-400 text-lg font-light">—</span>
|
||||
|
||||
{/* Label */}
|
||||
<input
|
||||
type="text"
|
||||
value={state.label}
|
||||
onChange={(e) => updateMetadata({ label: e.target.value })}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 flex-1 min-w-[160px] max-w-[300px]"
|
||||
placeholder="Workflow Label"
|
||||
/>
|
||||
|
||||
{/* Version */}
|
||||
<input
|
||||
type="text"
|
||||
value={state.version}
|
||||
onChange={(e) => updateMetadata({ version: e.target.value })}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-20"
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section: Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||
{/* Validation errors badge */}
|
||||
{validationErrors.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowErrors(!showErrors)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{validationErrors.length} issue
|
||||
{validationErrors.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Raw YAML / Visual mode toggle */}
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setShowYamlPreview(false)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
!showYamlPreview
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Visual builder"
|
||||
>
|
||||
<LayoutDashboard className="w-3.5 h-3.5" />
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowYamlPreview(true)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
showYamlPreview
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Raw YAML view"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
Raw YAML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save success indicator */}
|
||||
{saveSuccess && (
|
||||
<span className="text-xs text-green-600 font-medium">
|
||||
✓ Saved
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save error indicator */}
|
||||
{saveError && (
|
||||
<span
|
||||
className="text-xs text-red-600 font-medium max-w-[200px] truncate"
|
||||
title={saveError}
|
||||
>
|
||||
✗ {saveError}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{isSaving ? "Saving..." : isEditing ? "Update" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description row (collapsible) */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={state.description}
|
||||
onChange={(e) => updateMetadata({ description: e.target.value })}
|
||||
className="flex-1 px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Workflow description (optional)"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={state.tags.join(", ")}
|
||||
onChange={(e) =>
|
||||
updateMetadata({
|
||||
tags: e.target.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
className="px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 w-40"
|
||||
placeholder="Tags (comma-sep)"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.enabled}
|
||||
onChange={(e) => updateMetadata({ enabled: e.target.checked })}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation errors panel */}
|
||||
{showErrors && validationErrors.length > 0 && (
|
||||
<div className="flex-shrink-0 bg-amber-50 border-b border-amber-200 px-4 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-amber-800 mb-1">
|
||||
Please fix the following issues before saving:
|
||||
</p>
|
||||
<ul className="text-xs text-amber-700 space-y-0.5">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowErrors(false)}
|
||||
className="text-amber-400 hover:text-amber-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{showYamlPreview ? (
|
||||
/* Raw YAML mode — full-width YAML view */
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-gray-900">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-gray-800 border-b border-gray-700 flex-shrink-0">
|
||||
<FileCode className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-300">
|
||||
Workflow Definition
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 ml-1">
|
||||
(read-only preview of the generated YAML)
|
||||
</span>
|
||||
</div>
|
||||
<pre className="flex-1 overflow-auto p-6 text-sm font-mono text-green-400 whitespace-pre leading-relaxed">
|
||||
{yamlPreview}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Left: Action Palette */}
|
||||
<ActionPalette
|
||||
actions={paletteActions}
|
||||
isLoading={actionsLoading}
|
||||
onAddTask={handleAddTaskFromPalette}
|
||||
/>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<WorkflowCanvas
|
||||
tasks={state.tasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
availableActions={paletteActions}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
onUpdateTask={handleUpdateTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
onAddTask={handleAddTask}
|
||||
onSetConnection={handleSetConnection}
|
||||
onEdgeHover={handleEdgeHover}
|
||||
/>
|
||||
|
||||
{/* Right: Task Inspector */}
|
||||
{selectedTask && (
|
||||
<TaskInspector
|
||||
task={selectedTask}
|
||||
allTaskNames={allTaskNames}
|
||||
availableActions={paletteActions}
|
||||
onUpdate={handleUpdateTask}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
highlightTransitionIndex={
|
||||
highlightedTransition?.taskId === selectedTask.id
|
||||
? highlightedTransition.transitionIndex
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export default function PackInstallPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
source: "",
|
||||
refSpec: "",
|
||||
force: false,
|
||||
skipTests: false,
|
||||
skipDeps: false,
|
||||
});
|
||||
@@ -42,7 +41,6 @@ export default function PackInstallPage() {
|
||||
const result = await installPack.mutateAsync({
|
||||
source: formData.source,
|
||||
refSpec: formData.refSpec || undefined,
|
||||
force: formData.force,
|
||||
skipTests: formData.skipTests,
|
||||
skipDeps: formData.skipDeps,
|
||||
});
|
||||
@@ -360,33 +358,6 @@ export default function PackInstallPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Force Installation */}
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="force"
|
||||
name="force"
|
||||
checked={formData.force}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<label
|
||||
htmlFor="force"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Force Installation
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Proceed with installation even if pack exists, dependencies
|
||||
are missing, or tests fail. This will replace any existing
|
||||
pack.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useDisableTrigger,
|
||||
} from "@/hooks/useTriggers";
|
||||
import { useState, useMemo } from "react";
|
||||
import { extractProperties } from "@/components/common/ParamSchemaForm";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -328,13 +329,11 @@ function TriggerDetail({ triggerRef }: { triggerRef: string }) {
|
||||
}
|
||||
|
||||
const paramSchema = trigger.data?.param_schema || {};
|
||||
const properties = paramSchema.properties || {};
|
||||
const requiredFields = paramSchema.required || [];
|
||||
const properties = extractProperties(paramSchema);
|
||||
const paramEntries = Object.entries(properties);
|
||||
|
||||
const outSchema = trigger.data?.out_schema || {};
|
||||
const outProperties = outSchema.properties || {};
|
||||
const outRequiredFields = outSchema.required || [];
|
||||
const outProperties = extractProperties(outSchema);
|
||||
const outEntries = Object.entries(outProperties);
|
||||
|
||||
return (
|
||||
@@ -496,11 +495,16 @@ function TriggerDetail({ triggerRef }: { triggerRef: string }) {
|
||||
<span className="font-mono font-semibold text-sm">
|
||||
{key}
|
||||
</span>
|
||||
{requiredFields.includes(key) && (
|
||||
{param?.required && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
{param?.secret && (
|
||||
<span className="text-xs px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded">
|
||||
Secret
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
|
||||
{param?.type || "any"}
|
||||
</span>
|
||||
@@ -543,7 +547,7 @@ function TriggerDetail({ triggerRef }: { triggerRef: string }) {
|
||||
<span className="font-mono font-semibold text-sm">
|
||||
{key}
|
||||
</span>
|
||||
{outRequiredFields.includes(key) && (
|
||||
{param?.required && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Required
|
||||
</span>
|
||||
|
||||
788
web/src/types/workflow.ts
Normal file
788
web/src/types/workflow.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Workflow Builder Types
|
||||
*
|
||||
* These types represent the client-side workflow builder state
|
||||
* and map to the backend workflow YAML format.
|
||||
*
|
||||
* Uses the Orquesta-style task transition model where each task has a `next`
|
||||
* list of transitions. Each transition specifies:
|
||||
* - `when` — a condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}")
|
||||
* - `publish` — variables to publish into the workflow context
|
||||
* - `do` — next tasks to invoke when the condition is met
|
||||
*/
|
||||
|
||||
/** Position of a node on the canvas */
|
||||
export interface NodePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single task transition evaluated after task completion.
|
||||
*
|
||||
* Transitions are evaluated in order. When `when` is not defined,
|
||||
* the transition is unconditional (fires on any completion).
|
||||
*/
|
||||
export interface TaskTransition {
|
||||
/** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */
|
||||
when?: string;
|
||||
/** Variables to publish into the workflow context on this transition */
|
||||
publish?: PublishDirective[];
|
||||
/** Next tasks to invoke when transition criteria is met */
|
||||
do?: string[];
|
||||
/** Custom display label for the transition (overrides auto-derived label) */
|
||||
label?: string;
|
||||
/** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** A task node in the workflow builder */
|
||||
export interface WorkflowTask {
|
||||
/** Unique ID for the builder (not persisted) */
|
||||
id: string;
|
||||
/** Task name (used in YAML) */
|
||||
name: string;
|
||||
/** Action reference (e.g., "core.echo") */
|
||||
action: string;
|
||||
/** Input parameters (template strings or values) */
|
||||
input: Record<string, unknown>;
|
||||
/** Task transitions — evaluated in order after task completes */
|
||||
next?: TaskTransition[];
|
||||
/** Delay in seconds before executing this task */
|
||||
delay?: number;
|
||||
/** Retry configuration */
|
||||
retry?: RetryConfig;
|
||||
/** Timeout in seconds */
|
||||
timeout?: number;
|
||||
/** With-items iteration expression */
|
||||
with_items?: string;
|
||||
/** Batch size for with-items */
|
||||
batch_size?: number;
|
||||
/** Concurrency limit for with-items */
|
||||
concurrency?: number;
|
||||
/** Join barrier count */
|
||||
join?: number;
|
||||
/** Visual position on canvas */
|
||||
position: NodePosition;
|
||||
}
|
||||
|
||||
/** Retry configuration */
|
||||
export interface RetryConfig {
|
||||
/** Number of retry attempts */
|
||||
count: number;
|
||||
/** Initial delay in seconds */
|
||||
delay: number;
|
||||
/** Backoff strategy */
|
||||
backoff?: "constant" | "linear" | "exponential";
|
||||
/** Maximum delay in seconds */
|
||||
max_delay?: number;
|
||||
/** Only retry on specific error conditions */
|
||||
on_error?: string;
|
||||
}
|
||||
|
||||
/** Variable publishing directive */
|
||||
export type PublishDirective = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Transition handle presets for the visual builder.
|
||||
*
|
||||
* These map to common `when` expressions and provide a quick way
|
||||
* to create transitions without typing expressions manually.
|
||||
*/
|
||||
export type TransitionPreset = "succeeded" | "failed" | "always";
|
||||
|
||||
/** The `when` expression for each preset (undefined = unconditional) */
|
||||
export const PRESET_WHEN: Record<TransitionPreset, string | undefined> = {
|
||||
succeeded: "{{ succeeded() }}",
|
||||
failed: "{{ failed() }}",
|
||||
always: undefined,
|
||||
};
|
||||
|
||||
/** Human-readable labels for presets */
|
||||
export const PRESET_LABELS: Record<TransitionPreset, string> = {
|
||||
succeeded: "On Success",
|
||||
failed: "On Failure",
|
||||
always: "Always",
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a `when` expression into an edge visual type.
|
||||
* Used for edge coloring and labeling.
|
||||
*/
|
||||
export type EdgeType = "success" | "failure" | "complete" | "custom";
|
||||
|
||||
export function classifyTransitionWhen(when?: string): EdgeType {
|
||||
if (!when) return "complete"; // unconditional
|
||||
const lower = when.toLowerCase().replace(/\s+/g, "");
|
||||
if (lower.includes("succeeded()")) return "success";
|
||||
if (lower.includes("failed()")) return "failure";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
/** Human-readable short label for a `when` expression */
|
||||
export function transitionLabel(when?: string, customLabel?: string): string {
|
||||
if (customLabel) return customLabel;
|
||||
if (!when) return "always";
|
||||
const lower = when.toLowerCase().replace(/\s+/g, "");
|
||||
if (lower.includes("succeeded()")) return "succeeded";
|
||||
if (lower.includes("failed()")) return "failed";
|
||||
// Truncate custom expressions for display
|
||||
if (when.length > 30) return when.slice(0, 27) + "...";
|
||||
return when;
|
||||
}
|
||||
|
||||
/** An edge/connection between two tasks */
|
||||
export interface WorkflowEdge {
|
||||
/** Source task ID */
|
||||
from: string;
|
||||
/** Target task ID */
|
||||
to: string;
|
||||
/** Visual type of transition (derived from `when`) */
|
||||
type: EdgeType;
|
||||
/** Label to display on the edge */
|
||||
label?: string;
|
||||
/** Index of the transition in the source task's `next` array */
|
||||
transitionIndex: number;
|
||||
/** Custom color override for the edge (CSS color string) */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Complete workflow builder state */
|
||||
export interface WorkflowBuilderState {
|
||||
/** Workflow name (used to derive ref and filename) */
|
||||
name: string;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Semantic version */
|
||||
version: string;
|
||||
/** Pack reference this workflow belongs to */
|
||||
packRef: string;
|
||||
/** Input parameter schema (flat format) */
|
||||
parameters: Record<string, ParamDefinition>;
|
||||
/** Output schema (flat format) */
|
||||
output: Record<string, ParamDefinition>;
|
||||
/** Workflow-scoped variables */
|
||||
vars: Record<string, unknown>;
|
||||
/** Task nodes */
|
||||
tasks: WorkflowTask[];
|
||||
/** Tags */
|
||||
tags: string[];
|
||||
/** Whether the workflow is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Parameter definition in flat schema format */
|
||||
export interface ParamDefinition {
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
secret?: boolean;
|
||||
default?: unknown;
|
||||
enum?: string[];
|
||||
}
|
||||
|
||||
/** Workflow definition as stored in the YAML file / API */
|
||||
export interface WorkflowYamlDefinition {
|
||||
ref: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
version: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
vars?: Record<string, unknown>;
|
||||
tasks: WorkflowYamlTask[];
|
||||
output_map?: Record<string, string>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** Transition as represented in YAML format */
|
||||
export interface WorkflowYamlTransition {
|
||||
when?: string;
|
||||
publish?: PublishDirective[];
|
||||
do?: string[];
|
||||
/** Custom display label for the transition */
|
||||
label?: string;
|
||||
/** Custom color for the transition edge */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Task as represented in YAML format */
|
||||
export interface WorkflowYamlTask {
|
||||
name: string;
|
||||
action?: string;
|
||||
input?: Record<string, unknown>;
|
||||
delay?: number;
|
||||
with_items?: string;
|
||||
batch_size?: number;
|
||||
concurrency?: number;
|
||||
retry?: RetryConfig;
|
||||
timeout?: number;
|
||||
next?: WorkflowYamlTransition[];
|
||||
join?: number;
|
||||
}
|
||||
|
||||
/** Request to save a workflow file to disk and sync to DB */
|
||||
export interface SaveWorkflowFileRequest {
|
||||
/** Workflow name (becomes filename: {name}.workflow.yaml) */
|
||||
name: string;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
/** Description */
|
||||
description?: string;
|
||||
/** Semantic version */
|
||||
version: string;
|
||||
/** Pack reference */
|
||||
pack_ref: string;
|
||||
/** The full workflow definition as JSON */
|
||||
definition: WorkflowYamlDefinition;
|
||||
/** Parameter schema (flat format) */
|
||||
param_schema?: Record<string, unknown>;
|
||||
/** Output schema (flat format) */
|
||||
out_schema?: Record<string, unknown>;
|
||||
/** Tags */
|
||||
tags?: string[];
|
||||
/** Whether the workflow is enabled */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** An action summary used in the action palette */
|
||||
export interface PaletteAction {
|
||||
id: number;
|
||||
ref: string;
|
||||
label: string;
|
||||
description: string;
|
||||
pack_ref: string;
|
||||
param_schema: Record<string, unknown> | null;
|
||||
out_schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if two values are deeply equal for the purpose of default comparison.
|
||||
* Handles primitives, arrays, and plain objects.
|
||||
*/
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a !== "object") return false;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => deepEqual(v, b[i]));
|
||||
}
|
||||
const aObj = a as Record<string, unknown>;
|
||||
const bObj = b as Record<string, unknown>;
|
||||
const aKeys = Object.keys(aObj);
|
||||
const bKeys = Object.keys(bObj);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip input values that match their schema defaults.
|
||||
* Returns a new object containing only user-modified values.
|
||||
*/
|
||||
export function stripDefaultInputs(
|
||||
input: Record<string, unknown>,
|
||||
paramSchema: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!paramSchema || typeof paramSchema !== "object") return input;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
const schemaDef = paramSchema[key] as
|
||||
| { default?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
if (
|
||||
schemaDef &&
|
||||
schemaDef.default !== undefined &&
|
||||
deepEqual(value, schemaDef.default)
|
||||
) {
|
||||
continue; // skip — matches default
|
||||
}
|
||||
// Also skip empty strings when there's no default (user never filled it in)
|
||||
if (value === "" && (!schemaDef || schemaDef.default === undefined)) {
|
||||
continue;
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert builder state to YAML definition for saving.
|
||||
*
|
||||
* When `actionSchemas` is provided (a map of action ref → param_schema),
|
||||
* input values that match their schema defaults are omitted from the output
|
||||
* so only user-modified parameters appear in the generated YAML.
|
||||
*/
|
||||
export function builderStateToDefinition(
|
||||
state: WorkflowBuilderState,
|
||||
actionSchemas?: Map<string, Record<string, unknown> | null>,
|
||||
): WorkflowYamlDefinition {
|
||||
const tasks: WorkflowYamlTask[] = state.tasks.map((task) => {
|
||||
const yamlTask: WorkflowYamlTask = {
|
||||
name: task.name,
|
||||
};
|
||||
|
||||
if (task.action) {
|
||||
yamlTask.action = task.action;
|
||||
}
|
||||
|
||||
// Filter input: strip values that match schema defaults
|
||||
const schema = actionSchemas?.get(task.action);
|
||||
const effectiveInput = schema
|
||||
? stripDefaultInputs(task.input, schema)
|
||||
: task.input;
|
||||
if (Object.keys(effectiveInput).length > 0) {
|
||||
yamlTask.input = effectiveInput;
|
||||
}
|
||||
|
||||
if (task.delay) yamlTask.delay = task.delay;
|
||||
if (task.with_items) yamlTask.with_items = task.with_items;
|
||||
if (task.batch_size) yamlTask.batch_size = task.batch_size;
|
||||
if (task.concurrency) yamlTask.concurrency = task.concurrency;
|
||||
if (task.retry) yamlTask.retry = task.retry;
|
||||
if (task.timeout) yamlTask.timeout = task.timeout;
|
||||
if (task.join) yamlTask.join = task.join;
|
||||
|
||||
// Serialize transitions as `next` array
|
||||
if (task.next && task.next.length > 0) {
|
||||
yamlTask.next = task.next.map((t) => {
|
||||
const yt: WorkflowYamlTransition = {};
|
||||
if (t.when) yt.when = t.when;
|
||||
if (t.publish && t.publish.length > 0) yt.publish = t.publish;
|
||||
if (t.do && t.do.length > 0) yt.do = t.do;
|
||||
if (t.label) yt.label = t.label;
|
||||
if (t.color) yt.color = t.color;
|
||||
return yt;
|
||||
});
|
||||
}
|
||||
|
||||
return yamlTask;
|
||||
});
|
||||
|
||||
const definition: WorkflowYamlDefinition = {
|
||||
ref: `${state.packRef}.${state.name}`,
|
||||
label: state.label,
|
||||
version: state.version,
|
||||
tasks,
|
||||
};
|
||||
|
||||
if (state.description) {
|
||||
definition.description = state.description;
|
||||
}
|
||||
|
||||
if (Object.keys(state.parameters).length > 0) {
|
||||
definition.parameters = state.parameters;
|
||||
}
|
||||
|
||||
if (Object.keys(state.output).length > 0) {
|
||||
definition.output = state.output;
|
||||
}
|
||||
|
||||
if (Object.keys(state.vars).length > 0) {
|
||||
definition.vars = state.vars;
|
||||
}
|
||||
|
||||
if (state.tags.length > 0) {
|
||||
definition.tags = state.tags;
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy format conversion helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Legacy task fields that may appear in older workflow definitions */
|
||||
interface LegacyYamlTask extends WorkflowYamlTask {
|
||||
on_success?: string;
|
||||
on_failure?: string;
|
||||
on_complete?: string;
|
||||
on_timeout?: string;
|
||||
decision?: { when?: string; next: string; default?: boolean }[];
|
||||
publish?: PublishDirective[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert legacy on_success/on_failure/etc fields to `next` transitions.
|
||||
* This allows the builder to load workflows saved in the old format.
|
||||
*/
|
||||
function legacyTransitionsToNext(task: LegacyYamlTask): TaskTransition[] {
|
||||
const transitions: TaskTransition[] = [];
|
||||
|
||||
if (task.on_success) {
|
||||
transitions.push({
|
||||
when: "{{ succeeded() }}",
|
||||
do: [task.on_success],
|
||||
});
|
||||
}
|
||||
|
||||
if (task.on_failure) {
|
||||
transitions.push({
|
||||
when: "{{ failed() }}",
|
||||
do: [task.on_failure],
|
||||
});
|
||||
}
|
||||
|
||||
if (task.on_complete) {
|
||||
// on_complete = unconditional (fires regardless of success/failure)
|
||||
transitions.push({
|
||||
do: [task.on_complete],
|
||||
});
|
||||
}
|
||||
|
||||
if (task.on_timeout) {
|
||||
transitions.push({
|
||||
when: "{{ timed_out() }}",
|
||||
do: [task.on_timeout],
|
||||
});
|
||||
}
|
||||
|
||||
// Convert legacy decision branches
|
||||
if (task.decision) {
|
||||
for (const branch of task.decision) {
|
||||
transitions.push({
|
||||
when: branch.when || undefined,
|
||||
do: [branch.next],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If legacy task had publish but no transitions, create a publish-only transition
|
||||
if (task.publish && task.publish.length > 0 && transitions.length === 0) {
|
||||
transitions.push({
|
||||
when: "{{ succeeded() }}",
|
||||
publish: task.publish,
|
||||
});
|
||||
} else if (
|
||||
task.publish &&
|
||||
task.publish.length > 0 &&
|
||||
transitions.length > 0
|
||||
) {
|
||||
// Attach publish to the first succeeded transition, or the first transition
|
||||
const succeededIdx = transitions.findIndex(
|
||||
(t) => t.when && t.when.toLowerCase().includes("succeeded()"),
|
||||
);
|
||||
const idx = succeededIdx >= 0 ? succeededIdx : 0;
|
||||
transitions[idx].publish = task.publish;
|
||||
}
|
||||
|
||||
return transitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a YAML definition back to builder state (for editing existing workflows).
|
||||
* Supports both new `next` format and legacy `on_success`/`on_failure` format.
|
||||
*/
|
||||
export function definitionToBuilderState(
|
||||
definition: WorkflowYamlDefinition,
|
||||
packRef: string,
|
||||
name: string,
|
||||
): WorkflowBuilderState {
|
||||
const tasks: WorkflowTask[] = (definition.tasks || []).map(
|
||||
(rawTask, index) => {
|
||||
const task = rawTask as LegacyYamlTask;
|
||||
|
||||
// Determine transitions: prefer `next` if present, otherwise convert legacy fields
|
||||
let next: TaskTransition[] | undefined;
|
||||
if (task.next && task.next.length > 0) {
|
||||
next = task.next.map((t) => ({
|
||||
when: t.when,
|
||||
publish: t.publish,
|
||||
do: t.do,
|
||||
label: t.label,
|
||||
color: t.color,
|
||||
}));
|
||||
} else {
|
||||
const converted = legacyTransitionsToNext(task);
|
||||
next = converted.length > 0 ? converted : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `task-${index}-${Date.now()}`,
|
||||
name: task.name,
|
||||
action: task.action || "",
|
||||
input: task.input || {},
|
||||
next,
|
||||
delay: task.delay,
|
||||
retry: task.retry,
|
||||
timeout: task.timeout,
|
||||
with_items: task.with_items,
|
||||
batch_size: task.batch_size,
|
||||
concurrency: task.concurrency,
|
||||
join: task.join,
|
||||
position: {
|
||||
x: 300,
|
||||
y: 80 + index * 160,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
name,
|
||||
label: definition.label,
|
||||
description: definition.description || "",
|
||||
version: definition.version,
|
||||
packRef,
|
||||
parameters: (definition.parameters || {}) as Record<
|
||||
string,
|
||||
ParamDefinition
|
||||
>,
|
||||
output: (definition.output || {}) as Record<string, ParamDefinition>,
|
||||
vars: definition.vars || {},
|
||||
tasks,
|
||||
tags: definition.tags || [],
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derive visual edges from task transitions.
|
||||
*
|
||||
* Each entry in a task's `next` array can target multiple tasks via `do`.
|
||||
* Each target produces a separate edge with the same visual type/label.
|
||||
*/
|
||||
export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
|
||||
const edges: WorkflowEdge[] = [];
|
||||
const taskNameToId = new Map<string, string>();
|
||||
|
||||
for (const task of tasks) {
|
||||
taskNameToId.set(task.name, task.id);
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.next) continue;
|
||||
|
||||
for (let ti = 0; ti < task.next.length; ti++) {
|
||||
const transition = task.next[ti];
|
||||
const edgeType = classifyTransitionWhen(transition.when);
|
||||
const label = transitionLabel(transition.when, transition.label);
|
||||
|
||||
if (transition.do) {
|
||||
for (const targetName of transition.do) {
|
||||
const targetId = taskNameToId.get(targetName);
|
||||
if (targetId) {
|
||||
edges.push({
|
||||
from: task.id,
|
||||
to: targetId,
|
||||
type: edgeType,
|
||||
label,
|
||||
transitionIndex: ti,
|
||||
color: transition.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task transition helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find or create a transition in a task's `next` array that matches a preset.
|
||||
*
|
||||
* If a transition with a matching `when` expression already exists, returns
|
||||
* its index. Otherwise, appends a new transition and returns the new index.
|
||||
*/
|
||||
export function findOrCreateTransition(
|
||||
task: WorkflowTask,
|
||||
preset: TransitionPreset,
|
||||
): { next: TaskTransition[]; index: number } {
|
||||
const whenExpr = PRESET_WHEN[preset];
|
||||
const next = [...(task.next || [])];
|
||||
|
||||
// Look for an existing transition with the same `when`
|
||||
const existingIndex = next.findIndex((t) => {
|
||||
if (whenExpr === undefined) return t.when === undefined;
|
||||
return (
|
||||
t.when?.toLowerCase().replace(/\s+/g, "") ===
|
||||
whenExpr.toLowerCase().replace(/\s+/g, "")
|
||||
);
|
||||
});
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
return { next, index: existingIndex };
|
||||
}
|
||||
|
||||
// Create new transition
|
||||
const newTransition: TaskTransition = {};
|
||||
if (whenExpr) newTransition.when = whenExpr;
|
||||
next.push(newTransition);
|
||||
return { next, index: next.length - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a target task to a transition's `do` list.
|
||||
* If the target is already in the list, this is a no-op.
|
||||
* Returns the updated `next` array.
|
||||
*/
|
||||
export function addTransitionTarget(
|
||||
task: WorkflowTask,
|
||||
preset: TransitionPreset,
|
||||
targetTaskName: string,
|
||||
): TaskTransition[] {
|
||||
const { next, index } = findOrCreateTransition(task, preset);
|
||||
const transition = { ...next[index] };
|
||||
const doList = [...(transition.do || [])];
|
||||
|
||||
if (!doList.includes(targetTaskName)) {
|
||||
doList.push(targetTaskName);
|
||||
}
|
||||
|
||||
transition.do = doList;
|
||||
next[index] = transition;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all references to a task name from all transitions.
|
||||
* Cleans up transitions that become empty (no `do` and no `publish`).
|
||||
*/
|
||||
export function removeTaskFromTransitions(
|
||||
next: TaskTransition[] | undefined,
|
||||
taskName: string,
|
||||
): TaskTransition[] | undefined {
|
||||
if (!next) return undefined;
|
||||
|
||||
const cleaned = next
|
||||
.map((t) => {
|
||||
if (!t.do || !t.do.includes(taskName)) return t;
|
||||
const newDo = t.do.filter((name) => name !== taskName);
|
||||
return { ...t, do: newDo.length > 0 ? newDo : undefined };
|
||||
})
|
||||
// Keep transitions that still have `do` targets or `publish` directives
|
||||
.filter(
|
||||
(t) => (t.do && t.do.length > 0) || (t.publish && t.publish.length > 0),
|
||||
);
|
||||
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a unique task ID
|
||||
*/
|
||||
export function generateTaskId(): string {
|
||||
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new empty task
|
||||
*/
|
||||
export function createEmptyTask(
|
||||
name: string,
|
||||
position: NodePosition,
|
||||
): WorkflowTask {
|
||||
return {
|
||||
id: generateTaskId(),
|
||||
name,
|
||||
action: "",
|
||||
input: {},
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique task name that doesn't conflict with existing tasks
|
||||
*/
|
||||
export function generateUniqueTaskName(
|
||||
existingTasks: WorkflowTask[],
|
||||
baseName: string = "task",
|
||||
): string {
|
||||
const existingNames = new Set(existingTasks.map((t) => t.name));
|
||||
let counter = existingTasks.length + 1;
|
||||
let name = `${baseName}_${counter}`;
|
||||
while (existingNames.has(name)) {
|
||||
counter++;
|
||||
name = `${baseName}_${counter}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a workflow builder state and return any errors
|
||||
*/
|
||||
export function validateWorkflow(state: WorkflowBuilderState): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!state.name.trim()) {
|
||||
errors.push("Workflow name is required");
|
||||
}
|
||||
|
||||
if (!state.label.trim()) {
|
||||
errors.push("Workflow label is required");
|
||||
}
|
||||
|
||||
if (!state.version.trim()) {
|
||||
errors.push("Workflow version is required");
|
||||
}
|
||||
|
||||
if (!state.packRef) {
|
||||
errors.push("Pack reference is required");
|
||||
}
|
||||
|
||||
if (state.tasks.length === 0) {
|
||||
errors.push("Workflow must have at least one task");
|
||||
}
|
||||
|
||||
// Check for duplicate task names
|
||||
const taskNames = new Set<string>();
|
||||
for (const task of state.tasks) {
|
||||
if (taskNames.has(task.name)) {
|
||||
errors.push(`Duplicate task name: "${task.name}"`);
|
||||
}
|
||||
taskNames.add(task.name);
|
||||
}
|
||||
|
||||
// Check that tasks have an action reference
|
||||
for (const task of state.tasks) {
|
||||
if (!task.action) {
|
||||
errors.push(`Task "${task.name}" must have an action assigned`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all transition targets reference existing tasks
|
||||
for (const task of state.tasks) {
|
||||
if (!task.next) continue;
|
||||
|
||||
for (let ti = 0; ti < task.next.length; ti++) {
|
||||
const transition = task.next[ti];
|
||||
if (!transition.do) continue;
|
||||
|
||||
for (const targetName of transition.do) {
|
||||
if (!taskNames.has(targetName)) {
|
||||
const whenLabel = transition.when
|
||||
? ` (when: ${transition.when})`
|
||||
: " (always)";
|
||||
errors.push(
|
||||
`Task "${task.name}" transition${whenLabel} references non-existent task "${targetName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
Reference in New Issue
Block a user