Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+138
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
const suffix = values
|
||||
? Object.entries(values)
|
||||
.map(([name, value]) => `${name}=${String(value)}`)
|
||||
.join(",")
|
||||
: "";
|
||||
return suffix ? `${key}:${suffix}` : key;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: WorkflowBlockUtils } = await import("./WorkflowBlockUtils");
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto> = {}): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "log",
|
||||
variableName: "log",
|
||||
description: "",
|
||||
input: { message: "hello" },
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workflow(blocks: WorkflowBlockDto[]): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
appliesToAllTenants: false,
|
||||
blocks,
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowBlockUtils.getBlockErrors", () => {
|
||||
it("indexes incoming block ids once for validation callers", () => {
|
||||
const trigger = block({
|
||||
id: "trigger_1",
|
||||
type: "manual",
|
||||
isTrigger: true,
|
||||
isBlock: false,
|
||||
input: {},
|
||||
toBlocks: [{ id: "edge_1", fromBlockId: "trigger_1", toBlockId: "action_1", condition: null }],
|
||||
});
|
||||
const action = block({ id: "action_1" });
|
||||
|
||||
expect(Array.from(WorkflowBlockUtils.getIncomingBlockIds(workflow([trigger, action])))).toEqual(["action_1"]);
|
||||
expect(
|
||||
WorkflowBlockUtils.getBlockErrors({
|
||||
workflow: workflow([trigger, action]),
|
||||
block: action,
|
||||
validationContext: { incomingBlockIds: new Set(["action_1"]) },
|
||||
})
|
||||
).not.toContain("workflows.validation.errors.nothingConnectingToBlock");
|
||||
});
|
||||
|
||||
it("returns translated trigger and missing-caller validation errors", () => {
|
||||
const trigger = block({ id: "trigger_1", type: "manual", isTrigger: true, isBlock: false, input: {} });
|
||||
const action = block({ id: "action_1", input: { message: "hello" } });
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([trigger, action]), block: trigger })).toContain(
|
||||
"workflows.validation.errors.addNextBlockForTrigger",
|
||||
);
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([trigger, action]), block: action })).toContain(
|
||||
"workflows.validation.errors.nothingConnectingToBlock",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated branch connection errors", () => {
|
||||
const branch = block({
|
||||
id: "branch_1",
|
||||
type: "if",
|
||||
input: {},
|
||||
toBlocks: [{ id: "edge_1", fromBlockId: "branch_1", toBlockId: "next_1", condition: "true" }],
|
||||
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "{{$params.plan}}", operator: "equals", value: "pro" }] }],
|
||||
});
|
||||
const next = block({ id: "next_1" });
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([branch, next]), block: branch })).toContain(
|
||||
"workflows.validation.errors.addNextBlockForFalseCondition",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated required-input and condition errors", () => {
|
||||
const email = block({ id: "email_1", type: "email", input: { provider: "smtp" } });
|
||||
const invalidCondition = block({
|
||||
id: "if_1",
|
||||
type: "if",
|
||||
input: {},
|
||||
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "", operator: "equals", value: "" }] }],
|
||||
});
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([email]), block: email })).toContain(
|
||||
"workflows.validation.errors.missingRequiredInput:name=to",
|
||||
);
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([invalidCondition]), block: invalidCondition })).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("workflows.validation.errors.invalidConditions:errors=")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated invalid block type errors", () => {
|
||||
const invalid = block({ type: "missingBlockType" as WorkflowBlockDto["type"] });
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([invalid]), block: invalid })).toContain(
|
||||
"workflows.validation.errors.invalidWorkflowBlockType:type=missingBlockType",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated manual validation schema errors", () => {
|
||||
const manual = block({
|
||||
id: "manual_1",
|
||||
type: "manual",
|
||||
isTrigger: true,
|
||||
isBlock: false,
|
||||
input: { validation: "{not-json" },
|
||||
});
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([manual]), block: manual })).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("workflows.validation.errors.invalidManualValidationSchema:error=")])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowBlockInput, WorkflowBlockType, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import WorkflowConditionUtils from "./WorkflowConditionUtils";
|
||||
import { WorkflowBlockWithDetails } from "../db/workflowBlocks.db.server";
|
||||
import { WorkflowConditionOperator } from "../dtos/WorkflowConditionDtos";
|
||||
import WorkflowsValidationService from "../services/WorkflowsValidationService";
|
||||
|
||||
function rowToDto(block: WorkflowBlockWithDetails, index: number = 0) {
|
||||
let input: any = {};
|
||||
try {
|
||||
input = JSON.parse(block.input);
|
||||
} catch (e) {}
|
||||
const workflowBlock: WorkflowBlockDto = {
|
||||
id: block.id,
|
||||
index,
|
||||
type: block.type as WorkflowBlockType,
|
||||
variableName: "",
|
||||
description: block.description,
|
||||
input,
|
||||
isTrigger: block.isTrigger,
|
||||
isBlock: block.isBlock,
|
||||
toBlocks: block.toBlocks.map((toBlock) => {
|
||||
return {
|
||||
id: toBlock.id,
|
||||
fromBlockId: block.id,
|
||||
toBlockId: toBlock.toBlockId,
|
||||
condition: toBlock.condition,
|
||||
};
|
||||
}),
|
||||
conditionGroups: block.conditionsGroups
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((group) => {
|
||||
return {
|
||||
index: group.index,
|
||||
type: group.type as "AND" | "OR",
|
||||
conditions: group.conditions
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((condition) => {
|
||||
return {
|
||||
index: condition.index,
|
||||
variable: condition.variable,
|
||||
operator: condition.operator as WorkflowConditionOperator,
|
||||
value: condition.value,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return workflowBlock;
|
||||
}
|
||||
|
||||
export interface WorkflowValidationContext {
|
||||
incomingBlockIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function getIncomingBlockIds(workflow: Pick<WorkflowDto, "blocks">): Set<string> {
|
||||
const incomingBlockIds = new Set<string>();
|
||||
workflow.blocks.forEach((fromBlock) => {
|
||||
fromBlock.toBlocks.forEach((toBlock) => {
|
||||
incomingBlockIds.add(toBlock.toBlockId);
|
||||
});
|
||||
});
|
||||
return incomingBlockIds;
|
||||
}
|
||||
|
||||
function getBlockErrors({ workflow, block, validationContext }: { workflow: WorkflowDto; block: WorkflowBlockDto; validationContext?: WorkflowValidationContext }) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (block.isTrigger) {
|
||||
if (block.toBlocks.length === 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForTrigger"));
|
||||
}
|
||||
// if (!block.description) {
|
||||
// errors.push("Add a description for this trigger");
|
||||
// }
|
||||
} else if (block.isBlock) {
|
||||
const hasIncomingConnection =
|
||||
validationContext?.incomingBlockIds?.has(block.id) ?? workflow.blocks.some((fromBlock) => fromBlock.toBlocks.some((toBlock) => toBlock.toBlockId === block.id));
|
||||
if (!hasIncomingConnection) {
|
||||
errors.push(i18nT("workflows.validation.errors.nothingConnectingToBlock"));
|
||||
}
|
||||
if (block.type === "if") {
|
||||
const hasTrue = block.toBlocks.find((f) => f.condition === "true");
|
||||
const hasFalse = block.toBlocks.find((f) => f.condition === "false");
|
||||
if (!hasTrue) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForTrueCondition"));
|
||||
}
|
||||
if (!hasFalse) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForFalseCondition"));
|
||||
}
|
||||
} else if (block.type === "switch") {
|
||||
const hasDefault = block.toBlocks.find((f) => f.condition === "default");
|
||||
if (!hasDefault) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForDefaultCondition"));
|
||||
}
|
||||
block.conditionGroups.forEach((conditionGroup) => {
|
||||
const hasCase = block.toBlocks.find((f) => f.condition === `case${conditionGroup.index + 1}`);
|
||||
if (!hasCase) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForCase", { index: conditionGroup.index + 1 }));
|
||||
}
|
||||
});
|
||||
} else if (block.type === "iterator") {
|
||||
const hasLoopNext = block.toBlocks.find((f) => f.condition === "loopNext");
|
||||
const hasLoopEnd = block.toBlocks.find((f) => f.condition === "loopEnd");
|
||||
if (!hasLoopNext) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForLoopNextCondition"));
|
||||
}
|
||||
if (!hasLoopEnd) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForLoopEndCondition"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
|
||||
if (!workflowBlock) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidWorkflowBlockType", { type: block.type }));
|
||||
}
|
||||
if (workflowBlock?.inputs) {
|
||||
workflowBlock.inputs.forEach((input) => {
|
||||
const { required } = input as WorkflowBlockInput;
|
||||
if (required && !block.input[input.name]) {
|
||||
errors.push(i18nT("workflows.validation.errors.missingRequiredInput", { name: input.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (workflowBlock?.value === "manual" && block.input.validation) {
|
||||
const { error } = WorkflowsValidationService.parseJsonSchema(block.input.validation);
|
||||
if (error) {
|
||||
errors.push(
|
||||
i18nT("workflows.validation.errors.invalidManualValidationSchema", {
|
||||
error,
|
||||
defaultValue: "Manual trigger validation must be valid JSON Schema: {{error}}",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowBlock?.value === "if") {
|
||||
const conditionGroups = block.conditionGroups;
|
||||
if (conditionGroups.length !== 1) {
|
||||
errors.push(i18nT("workflows.validation.errors.conditionGroupRequired"));
|
||||
} else {
|
||||
if (conditionGroups[0].conditions.length === 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.conditionRequired"));
|
||||
} else {
|
||||
const groupErrors = WorkflowConditionUtils.getConditionsErrors(conditionGroups[0].conditions);
|
||||
if (groupErrors.length > 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidConditions", { errors: groupErrors.flatMap((f) => f.errors).join(", ") }));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (workflowBlock?.value === "switch") {
|
||||
const conditionGroups = block.conditionGroups;
|
||||
if (conditionGroups.length < 2) {
|
||||
errors.push(i18nT("workflows.validation.errors.twoConditionGroupsRequired"));
|
||||
} else {
|
||||
conditionGroups.forEach((group) => {
|
||||
if (group.conditions.length === 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.conditionRequired"));
|
||||
} else {
|
||||
const groupErrors = WorkflowConditionUtils.getConditionsErrors(group.conditions);
|
||||
if (groupErrors.length > 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidConditions", { errors: groupErrors.flatMap((f) => f.errors).join(", ") }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default {
|
||||
rowToDto,
|
||||
getIncomingBlockIds,
|
||||
getBlockErrors,
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowConditionsDto } from "../dtos/WorkflowConditionDtos";
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
const suffix = values
|
||||
? Object.entries(values)
|
||||
.map(([name, value]) => `${name}=${String(value)}`)
|
||||
.join(",")
|
||||
: "";
|
||||
return suffix ? `${key}:${suffix}` : key;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: WorkflowConditionUtils } = await import("./WorkflowConditionUtils");
|
||||
|
||||
function condition(overrides: Partial<WorkflowConditionsDto> = {}): WorkflowConditionsDto {
|
||||
return {
|
||||
index: 0,
|
||||
variable: "{{$params.plan}}",
|
||||
operator: "=",
|
||||
value: "pro",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowConditionUtils.getConditionString", () => {
|
||||
it("uses the workflow validation translation for unknown operators", () => {
|
||||
const translate = vi.fn((key: string, values?: Record<string, unknown>) => `${key}:${values?.value ?? ""}`);
|
||||
|
||||
expect(WorkflowConditionUtils.getConditionString(condition({ operator: "mystery" as WorkflowConditionsDto["operator"] }), translate)).toBe(
|
||||
"workflows.validation.errors.invalidOperator:mystery"
|
||||
);
|
||||
expect(translate).toHaveBeenCalledWith("workflows.validation.errors.invalidOperator", { value: "mystery" });
|
||||
});
|
||||
|
||||
it("falls back to the i18next translator for unknown operators", () => {
|
||||
expect(WorkflowConditionUtils.getConditionString(condition({ operator: "missing" as WorkflowConditionsDto["operator"] }))).toBe(
|
||||
"workflows.validation.errors.invalidOperator:value=missing"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy one-argument translators working for valid operators", () => {
|
||||
expect(WorkflowConditionUtils.getConditionString(condition(), (key: string) => `translated:${key}`)).toBe(
|
||||
"{{$params.plan}} translated:workflows.conditions.operators.equals pro"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { WorkflowConditionOperators, WorkflowConditionsDto } from "../dtos/WorkflowConditionDtos";
|
||||
|
||||
type WorkflowConditionTranslator = (key: string, values?: Record<string, unknown>) => string;
|
||||
|
||||
function getConditionErrors(condition: WorkflowConditionsDto) {
|
||||
const errors: string[] = [];
|
||||
const operator = WorkflowConditionOperators.find((f) => f.value === condition.operator);
|
||||
if (!operator) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidOperator", { value: condition.operator }));
|
||||
}
|
||||
if (!condition.variable) {
|
||||
errors.push(i18nT("workflows.validation.errors.variableRequired"));
|
||||
}
|
||||
if (!condition.operator) {
|
||||
errors.push(i18nT("workflows.validation.errors.operatorRequired"));
|
||||
}
|
||||
if (operator?.requiresValue && !condition.value) {
|
||||
errors.push(i18nT("workflows.validation.errors.valueRequired"));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function getConditionsErrors(conditions: WorkflowConditionsDto[]) {
|
||||
const errors: {
|
||||
condition: WorkflowConditionsDto;
|
||||
errors: string[];
|
||||
}[] = [];
|
||||
conditions.forEach((condition) => {
|
||||
const conditionErrors = getConditionErrors(condition);
|
||||
if (conditionErrors.length > 0) {
|
||||
errors.push({
|
||||
condition,
|
||||
errors: conditionErrors,
|
||||
});
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
function getOperator(operatorValue: string) {
|
||||
const operator = WorkflowConditionOperators.find((f) => f.value === operatorValue);
|
||||
return operator;
|
||||
}
|
||||
|
||||
function getConditionString(condition: WorkflowConditionsDto, translate?: WorkflowConditionTranslator) {
|
||||
const operator = getOperator(condition.operator);
|
||||
if (!operator) {
|
||||
const t = translate ?? i18nT;
|
||||
return t("workflows.validation.errors.invalidOperator", { value: condition.operator });
|
||||
}
|
||||
const operatorLabel = translate ? translate(operator.translationKey) : operator.name;
|
||||
if (operator.requiresValue) {
|
||||
return `${condition.variable} ${operatorLabel} ${condition.value}`;
|
||||
}
|
||||
return `${condition.variable} ${operatorLabel}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
getConditionErrors,
|
||||
getConditionsErrors,
|
||||
getOperator,
|
||||
getConditionString,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { WorkflowExecutionWithDetails } from "../db/workflowExecutions.db.server";
|
||||
import { WorkflowBlockExecutionDto } from "../dtos/WorkflowBlockExecutionDto";
|
||||
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
|
||||
import WorkflowBlockUtils from "./WorkflowBlockUtils";
|
||||
|
||||
function rowToDto(item: WorkflowExecutionWithDetails): WorkflowExecutionDto {
|
||||
const execution: WorkflowExecutionDto = {
|
||||
id: item.id,
|
||||
createdAt: item.createdAt,
|
||||
status: item.status as WorkflowExecutionDto["status"],
|
||||
input: parseData(item.input),
|
||||
output: parseData(item.output),
|
||||
duration: item.duration,
|
||||
endedAt: item.endedAt,
|
||||
error: item.error,
|
||||
blockRuns: item.blockRuns.map((block) => {
|
||||
const blockRun: WorkflowBlockExecutionDto = {
|
||||
id: block.id,
|
||||
workflowBlockId: block.workflowBlockId,
|
||||
fromWorkflowBlockId: block.fromWorkflowBlockId,
|
||||
status: block.status as WorkflowBlockExecutionDto["status"],
|
||||
startedAt: block.startedAt,
|
||||
endedAt: block.endedAt,
|
||||
input: parseData(block.input),
|
||||
output: parseData(block.output),
|
||||
error: block.error,
|
||||
workflowBlock: {
|
||||
type: block.workflowBlock.type as WorkflowBlockExecutionDto["workflowBlock"]["type"],
|
||||
description: block.workflowBlock.description,
|
||||
},
|
||||
};
|
||||
return blockRun;
|
||||
}),
|
||||
executionAlerts: [],
|
||||
waitingBlock: null,
|
||||
};
|
||||
const alertUserBlockRuns = execution.blockRuns.filter((block) => block.workflowBlock.type === "alertUser");
|
||||
alertUserBlockRuns.forEach((blockRun) => {
|
||||
if (blockRun.output?.type && blockRun.output?.message) {
|
||||
execution.executionAlerts.push({
|
||||
type: blockRun.output?.type,
|
||||
message: blockRun.output?.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (item.waitingBlock) {
|
||||
execution.waitingBlock = WorkflowBlockUtils.rowToDto(item.waitingBlock);
|
||||
}
|
||||
|
||||
return execution;
|
||||
}
|
||||
|
||||
// function executionToDto(item: WorkflowBlockExecution, workflowBlock: WorkflowBlockDto): WorkflowBlockExecutionDto {
|
||||
// const blockRun: WorkflowBlockExecutionDto = {
|
||||
// id: item.id,
|
||||
// workflowBlockId: item.workflowBlockId,
|
||||
// fromWorkflowBlockId: item.fromWorkflowBlockId,
|
||||
// status: item.status as WorkflowBlockExecutionDto["status"],
|
||||
// startedAt: item.startedAt,
|
||||
// endedAt: item.endedAt,
|
||||
// input: parseData(item.input),
|
||||
// output: parseData(item.output),
|
||||
// error: item.error,
|
||||
// workflowBlock: {
|
||||
// type: workflowBlock.type as WorkflowBlockExecutionDto["workflowBlock"]["type"],
|
||||
// description: workflowBlock.description,
|
||||
// },
|
||||
// };
|
||||
// return blockRun;
|
||||
// }
|
||||
|
||||
function parseData(data: string | null): { [key: string]: any } | null {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
rowToDto,
|
||||
};
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import WorkflowLayoutUtils from "./WorkflowLayoutUtils";
|
||||
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import type { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
|
||||
|
||||
function createBlock(id: string, toBlocks: WorkflowBlockDto["toBlocks"] = []): WorkflowBlockDto {
|
||||
return {
|
||||
id,
|
||||
index: 0,
|
||||
type: "log",
|
||||
variableName: id,
|
||||
description: id,
|
||||
input: {},
|
||||
isTrigger: id === "trigger",
|
||||
isBlock: id !== "trigger",
|
||||
conditionGroups: [],
|
||||
toBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflow(blocks: WorkflowBlockDto[]): WorkflowDto {
|
||||
return {
|
||||
id: "workflow-1",
|
||||
tenantId: "tenant-1",
|
||||
tenant: null,
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks,
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowLayoutUtils", () => {
|
||||
it("lays out nodes without mutating the input node objects", () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: "trigger",
|
||||
type: "blockEditorNode",
|
||||
data: { label: "Trigger" },
|
||||
position: { x: 10, y: 20 },
|
||||
draggable: false,
|
||||
},
|
||||
{
|
||||
id: "next",
|
||||
type: "blockEditorNode",
|
||||
data: { label: "Next" },
|
||||
position: { x: 30, y: 40 },
|
||||
draggable: false,
|
||||
},
|
||||
];
|
||||
const edges = [{ id: "edge-1", source: "trigger", target: "next" }];
|
||||
const originalNodes = structuredClone(nodes);
|
||||
|
||||
const result = WorkflowLayoutUtils.getLayoutedElements({ nodes, edges, direction: "TB", readOnly: true });
|
||||
|
||||
expect(nodes).toEqual(originalNodes);
|
||||
expect(result.nodes).not.toBe(nodes);
|
||||
expect(result.nodes[0]).not.toBe(nodes[0]);
|
||||
expect(result.nodes[0]?.position).not.toEqual(originalNodes[0]?.position);
|
||||
expect(result.nodes[0]?.draggable).toBe(false);
|
||||
expect(result.edges).not.toBe(edges);
|
||||
expect(result.edges).toEqual(edges);
|
||||
});
|
||||
|
||||
it("sorts rendered edges without mutating workflow block connections", () => {
|
||||
const originalConnections = [
|
||||
{ id: "edge-later", toBlockId: "block-later", condition: "z" },
|
||||
{ id: "edge-earlier", toBlockId: "block-earlier", condition: "a" },
|
||||
];
|
||||
const trigger = createBlock("trigger", originalConnections);
|
||||
const blocks = [trigger, createBlock("block-earlier"), createBlock("block-later")];
|
||||
|
||||
const { edges } = WorkflowLayoutUtils.workflowToReactFlow({
|
||||
workflow: createWorkflow(blocks),
|
||||
workflowBlocks: blocks,
|
||||
workflowExecution: null,
|
||||
});
|
||||
|
||||
expect(edges.map((edge) => edge.id)).toEqual(["edge-earlier", "edge-later"]);
|
||||
expect(trigger.toBlocks.map((connection) => connection.id)).toEqual(["edge-later", "edge-earlier"]);
|
||||
});
|
||||
|
||||
it("marks executed nodes and edges from indexed block runs", () => {
|
||||
const trigger = createBlock("trigger", [{ id: "edge-executed", toBlockId: "block-next", condition: null }]);
|
||||
const nextBlock = createBlock("block-next");
|
||||
const blocks = [trigger, nextBlock];
|
||||
const blockRun = {
|
||||
id: "run-next",
|
||||
workflowBlockId: "block-next",
|
||||
fromWorkflowBlockId: "trigger",
|
||||
status: "success",
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: null,
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
workflowBlock: { type: "log", description: "Log" },
|
||||
} satisfies WorkflowExecutionDto["blockRuns"][number];
|
||||
const workflowExecution: WorkflowExecutionDto = {
|
||||
id: "execution-1",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
status: "success",
|
||||
input: null,
|
||||
output: null,
|
||||
duration: null,
|
||||
endedAt: null,
|
||||
error: null,
|
||||
blockRuns: [blockRun],
|
||||
executionAlerts: [],
|
||||
waitingBlock: null,
|
||||
};
|
||||
|
||||
const { nodes, edges } = WorkflowLayoutUtils.workflowToReactFlow({
|
||||
workflow: createWorkflow(blocks),
|
||||
workflowBlocks: blocks,
|
||||
workflowExecution,
|
||||
});
|
||||
|
||||
expect(nodes.find((node) => node.id === "block-next")?.data.workflowBlockExecution).toBe(blockRun);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]?.data?.isExecuted).toBe(true);
|
||||
expect(edges[0]?.style?.stroke).toBe("#14b8a6");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type Node, type Edge, Position, MarkerType } from "@xyflow/react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import dagre from "dagre";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import WorkflowUtils from "./WorkflowUtils";
|
||||
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
|
||||
import { WorkflowBlockExecutionDto } from "../dtos/WorkflowBlockExecutionDto";
|
||||
const nodeWidth = 250;
|
||||
const nodeHeight = 80;
|
||||
|
||||
const getLayoutedElements = ({
|
||||
nodes,
|
||||
edges,
|
||||
direction = "LR",
|
||||
readOnly = false,
|
||||
}: {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
direction?: "LR" | "TB" | "RL" | "BT";
|
||||
readOnly?: boolean;
|
||||
}) => {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
const isHorizontal = direction === "LR";
|
||||
dagreGraph.setGraph({ rankdir: direction });
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
|
||||
// We are shifting the dagre node position (anchor=center center) to the top left
|
||||
// so it matches the React Flow node anchor point (top left).
|
||||
return {
|
||||
...node,
|
||||
targetPosition: isHorizontal ? Position.Left : Position.Top,
|
||||
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
draggable: !readOnly,
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges: [...edges] };
|
||||
};
|
||||
|
||||
const workflowToReactFlow = ({
|
||||
workflow,
|
||||
workflowBlocks,
|
||||
workflowExecution,
|
||||
}: {
|
||||
workflow: WorkflowDto;
|
||||
workflowBlocks: WorkflowBlockDto[];
|
||||
workflowExecution: WorkflowExecutionDto | null;
|
||||
}) => {
|
||||
const blockRunsByWorkflowBlockId = new Map((workflowExecution?.blockRuns ?? []).map((blockRun) => [blockRun.workflowBlockId, blockRun]));
|
||||
const executedEdgeKeys = new Set(
|
||||
(workflowExecution?.blockRuns ?? [])
|
||||
.filter((blockRun) => blockRun.fromWorkflowBlockId)
|
||||
.map((blockRun) => `${blockRun.fromWorkflowBlockId}:${blockRun.workflowBlockId}`)
|
||||
);
|
||||
let nodes: Node[] = workflowBlocks.map((block) => {
|
||||
const workflowBlockExecution: WorkflowBlockExecutionDto | null = blockRunsByWorkflowBlockId.get(block.id) ?? null;
|
||||
return {
|
||||
id: block.id,
|
||||
type: workflowExecution ? "blockExecutionNode" : "blockEditorNode",
|
||||
data: { workflow, block, workflowExecution, workflowBlockExecution },
|
||||
position: { x: 0, y: 0 },
|
||||
draggable: false,
|
||||
};
|
||||
});
|
||||
|
||||
let edges = workflowBlocks.flatMap((block) =>
|
||||
[...block.toBlocks]
|
||||
.sort((a, b) => a.condition?.localeCompare(b.condition || "") || 0)
|
||||
.map((toBlock) => {
|
||||
const fromBlockId = block.id;
|
||||
const toBlockId = toBlock.toBlockId;
|
||||
const isExecuted = executedEdgeKeys.has(`${fromBlockId}:${toBlockId}`);
|
||||
const edge: Edge = {
|
||||
id: toBlock.id,
|
||||
source: block.id,
|
||||
target: toBlock.toBlockId,
|
||||
type: "customEdge",
|
||||
animated: true,
|
||||
deletable: false,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
style: {
|
||||
strokeWidth: 1.5,
|
||||
stroke: isExecuted ? "#14b8a6" : "#9ca3af",
|
||||
},
|
||||
data: {
|
||||
condition: toBlock.condition,
|
||||
isExecuted,
|
||||
},
|
||||
};
|
||||
return edge;
|
||||
})
|
||||
);
|
||||
|
||||
const hasTrigger = WorkflowUtils.hasTriggerNode(workflow);
|
||||
if (!hasTrigger) {
|
||||
nodes = [
|
||||
{
|
||||
type: "setTriggerNode",
|
||||
id: "1",
|
||||
data: { label: i18nT("workflows.builder.setTriggerPrompt") },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
];
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
getLayoutedElements,
|
||||
workflowToReactFlow,
|
||||
};
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import { extractWorkflowReferences, getMissingWorkflowReferences } from "./WorkflowReferenceUtils";
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto>): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "trigger",
|
||||
variableName: "trigger",
|
||||
description: "",
|
||||
input: {},
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowReferenceUtils", () => {
|
||||
it("extracts variable and credential references from nested block inputs", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "http",
|
||||
input: {
|
||||
url: "{{$vars.apiBaseUrl}}/users",
|
||||
headers: [{ key: "Authorization", value: "Bearer {{ $credentials.apiToken }}" }],
|
||||
body: { tenant: "{{$vars.tenantId}}" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["apiBaseUrl", "tenantId"]);
|
||||
expect(references.credentials).toEqual(["apiToken"]);
|
||||
expect(references.byBlock[0]).toMatchObject({
|
||||
blockId: "http",
|
||||
variables: ["apiBaseUrl", "tenantId"],
|
||||
credentials: ["apiToken"],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts references from condition variables and values", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "condition",
|
||||
type: "if",
|
||||
conditionGroups: [
|
||||
{
|
||||
index: 0,
|
||||
type: "AND",
|
||||
conditions: [{ index: 0, variable: "{{$vars.plan}}", operator: "equals", value: "{{$vars.expectedPlan}}" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["expectedPlan", "plan"]);
|
||||
});
|
||||
|
||||
it("deduplicates references and ignores params session and block outputs", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "message",
|
||||
input: {
|
||||
message: "{{ $vars.subject }} {{$vars.subject}} {{$params.email}} {{$session.user.email}} {{httpRequest.body}}",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["subject"]);
|
||||
expect(references.credentials).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts references from supported Handlebars lookup and with expressions", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "message",
|
||||
input: {
|
||||
message: 'Bearer {{lookup $credentials "apiToken"}} for {{lookup $vars \'planTier\'}}',
|
||||
audit: "{{#with $credentials}}{{webhookSecret}}{{/with}} {{#with $vars}}{{region}}{{/with}}",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["planTier", "region"]);
|
||||
expect(references.credentials).toEqual(["apiToken", "webhookSecret"]);
|
||||
});
|
||||
|
||||
it("treats variable blocks as local variable definitions", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({ id: "define", type: "variable", input: { variableName: "temporaryToken", value: "abc" } }),
|
||||
block({ id: "use", input: { value: "{{$vars.temporaryToken}} {{$credentials.mailer}}" } }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual([]);
|
||||
expect(references.credentials).toEqual(["mailer"]);
|
||||
});
|
||||
|
||||
it("reports missing references against available names", () => {
|
||||
expect(
|
||||
getMissingWorkflowReferences({
|
||||
workflow: { blocks: [block({ input: { value: "{{$vars.present}} {{$vars.missing}} {{$credentials.missingToken}}" } })] },
|
||||
variables: ["present"],
|
||||
credentials: [],
|
||||
})
|
||||
).toEqual({
|
||||
missingVariables: ["missing"],
|
||||
missingCredentials: ["missingToken"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
extractWorkflowReferences,
|
||||
getMissingWorkflowReferences,
|
||||
preflightWorkflow,
|
||||
validateWorkflowRequiredInputs,
|
||||
type WorkflowPreflightBlock,
|
||||
type WorkflowPreflightInput,
|
||||
type WorkflowPreflightResult,
|
||||
type WorkflowReferenceSummary,
|
||||
type WorkflowRequiredInputIssue,
|
||||
} from "@toolctrl/foundation";
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import WorkflowUtils from "./WorkflowUtils";
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto> = {}): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "log",
|
||||
variableName: "log",
|
||||
description: "",
|
||||
input: {},
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
appliesToAllTenants: false,
|
||||
blocks: [block()],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowUtils.canRun", () => {
|
||||
it("rejects non-live workflows even when references are satisfied", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
status: "draft",
|
||||
$variables: ["recipient"],
|
||||
$credentials: ["mailer"],
|
||||
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects live workflows with missing variable or credential references", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
|
||||
$variables: ["recipient"],
|
||||
$credentials: [],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects live workflows with missing required block inputs", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
blocks: [
|
||||
block({
|
||||
input: { message: "{{$vars.recipient}}" },
|
||||
type: "email",
|
||||
}),
|
||||
],
|
||||
$variables: ["recipient"],
|
||||
$credentials: ["mailer"],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows live workflows when referenced variables and credentials are available", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
|
||||
$variables: ["recipient"],
|
||||
$credentials: ["mailer"],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import WorkflowBlockUtils from "./WorkflowBlockUtils";
|
||||
import { preflightWorkflow } from "./WorkflowReferenceUtils";
|
||||
import { getWorkflowBlockName, getWorkflowBlockOutputLabel, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
|
||||
|
||||
function canRun(workflow: WorkflowDto) {
|
||||
const preflight = preflightWorkflow({
|
||||
workflow,
|
||||
variables: workflow.$variables ?? [],
|
||||
credentials: workflow.$credentials ?? [],
|
||||
});
|
||||
return (
|
||||
workflow.status === "live" &&
|
||||
preflight.missingVariables.length === 0 &&
|
||||
preflight.missingCredentials.length === 0 &&
|
||||
preflight.missingRequiredInputs.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
function canRunManually(workflow: WorkflowDto) {
|
||||
return workflow.blocks.find((f) => f.type === "manual");
|
||||
}
|
||||
|
||||
function getStatusColor(workflow: WorkflowDto): Colors {
|
||||
switch (workflow.status) {
|
||||
case "draft":
|
||||
return Colors.SLATE;
|
||||
case "live":
|
||||
return Colors.GREEN;
|
||||
case "archived":
|
||||
return Colors.GRAY;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTriggerNode(workflow: WorkflowDto) {
|
||||
return workflow.blocks.find((f) => f.isTrigger);
|
||||
}
|
||||
|
||||
function getBlocksErrors(workflow: WorkflowDto): {
|
||||
block: WorkflowBlockDto;
|
||||
errors: string[];
|
||||
}[] {
|
||||
const blockErrors: { block: WorkflowBlockDto; errors: string[] }[] = [];
|
||||
const validationContext = { incomingBlockIds: WorkflowBlockUtils.getIncomingBlockIds(workflow) };
|
||||
|
||||
workflow.blocks.forEach((block) => {
|
||||
const errors = WorkflowBlockUtils.getBlockErrors({ workflow, block, validationContext });
|
||||
if (errors.length > 0) {
|
||||
blockErrors.push({ block, errors });
|
||||
}
|
||||
});
|
||||
|
||||
return blockErrors;
|
||||
}
|
||||
|
||||
function getBlocksWithoutCallers(workflow: WorkflowDto): WorkflowBlockDto[] {
|
||||
const blocksWithoutCallers: WorkflowBlockDto[] = [];
|
||||
const incomingBlockIds = WorkflowBlockUtils.getIncomingBlockIds(workflow);
|
||||
|
||||
workflow.blocks.forEach((block) => {
|
||||
if (block.isBlock) {
|
||||
if (!incomingBlockIds.has(block.id)) {
|
||||
blocksWithoutCallers.push(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return blocksWithoutCallers;
|
||||
}
|
||||
|
||||
function isReady(workflow: WorkflowDto) {
|
||||
const errors = getBlocksErrors(workflow);
|
||||
const blocksWithoutCallers = getBlocksWithoutCallers(workflow);
|
||||
const hasTrigger = hasTriggerNode(workflow);
|
||||
|
||||
return errors.length === 0 && blocksWithoutCallers.length === 0 && hasTrigger;
|
||||
}
|
||||
|
||||
function getVariables({ workflow, currentBlock, onlyInBlock }: { workflow: WorkflowDto; currentBlock: WorkflowBlockDto; onlyInBlock?: boolean }) {
|
||||
const triggerNode = workflow.blocks.find((f) => f.isTrigger);
|
||||
if (!triggerNode) return [];
|
||||
// const paths = buildPaths(workflow, triggerNode.id, [], {});
|
||||
// const currentPath = paths[currentBlock.id] || [];
|
||||
|
||||
const variables: { group: string; name: string; label: string }[] = [];
|
||||
|
||||
if (!onlyInBlock) {
|
||||
variables.push(
|
||||
{ group: i18nT("workflows.variables.groups.params"), name: "{{$params.}}", label: i18nT("workflows.variables.suggestions.paramsValue") },
|
||||
{ group: i18nT("workflows.variables.groups.session"), name: "{{$session.tenant.id}}", label: i18nT("workflows.variables.suggestions.sessionTenantId") },
|
||||
{ group: i18nT("workflows.variables.groups.session"), name: "{{$session.user.email}}", label: i18nT("workflows.variables.suggestions.sessionUserEmail") }
|
||||
);
|
||||
}
|
||||
|
||||
workflow.blocks.forEach((block, idxBlock) => {
|
||||
// if (!currentPath.includes(block.id)) return;
|
||||
if (onlyInBlock && block.id !== currentBlock.id) return;
|
||||
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
|
||||
if (!workflowBlock) return;
|
||||
|
||||
workflowBlock.outputs.forEach((output) => {
|
||||
const variableName = `{{${block.variableName}.${output.name}}}`;
|
||||
let variableLabel = getWorkflowBlockOutputLabel(output, i18nT);
|
||||
if (onlyInBlock) {
|
||||
variableLabel = getWorkflowBlockOutputLabel(output, i18nT);
|
||||
}
|
||||
variables.push({
|
||||
group: `${getWorkflowBlockName(workflowBlock, i18nT)} ${idxBlock + 1}`,
|
||||
name: variableName,
|
||||
label: variableLabel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
function getVariableName({ workflow, currentBlock }: { workflow: WorkflowDto; currentBlock: { id: string; type: string } }) {
|
||||
// Function to recursively build a list of block IDs in execution order
|
||||
function buildExecutionOrder(blockId: string, order: string[] = [], visited = new Set()) {
|
||||
if (visited.has(blockId)) return order;
|
||||
visited.add(blockId);
|
||||
|
||||
const block = workflow.blocks.find((b) => b.id === blockId);
|
||||
if (!block) return order;
|
||||
|
||||
order.push(blockId);
|
||||
|
||||
const orders = ["true", "false", "default", "case", "loopNext", "loopEnd"];
|
||||
block.toBlocks
|
||||
.sort((a, b) => {
|
||||
if (!a.condition) {
|
||||
return -1;
|
||||
}
|
||||
if (!b.condition) {
|
||||
return 1;
|
||||
}
|
||||
const aOrder = orders.findIndex((f) => a.condition?.startsWith(f));
|
||||
const bOrder = orders.findIndex((f) => b.condition?.startsWith(f));
|
||||
return aOrder - bOrder;
|
||||
})
|
||||
.forEach((toBlock) => {
|
||||
buildExecutionOrder(toBlock.toBlockId, order, visited);
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
// Get execution order starting from the first block (assuming it's a trigger)
|
||||
const executionOrder = buildExecutionOrder(workflow.blocks.find((b) => b.isTrigger)?.id || "");
|
||||
|
||||
// Count occurrences of block types in execution order
|
||||
const blockTypeCounts: { [key: string]: number } = {};
|
||||
executionOrder.forEach((blockId) => {
|
||||
const block = workflow.blocks.find((b) => b.id === blockId);
|
||||
if (block) {
|
||||
blockTypeCounts[block.type] = (blockTypeCounts[block.type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Determine the index of the current block in its type group
|
||||
const currentBlockIndex = executionOrder.filter((id) => workflow.blocks.find((b) => b.id === id)?.type === currentBlock.type).indexOf(currentBlock.id) + 1;
|
||||
|
||||
// Append index if there are multiple blocks of the same type
|
||||
return {
|
||||
variableName: blockTypeCounts[currentBlock.type] > 1 ? `${currentBlock.type}${currentBlockIndex}` : currentBlock.type,
|
||||
index: currentBlockIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function getBlockTypeName(workflowBlock: { type: string }) {
|
||||
const workflowBlockType = WorkflowBlockTypes.find((f) => f.value === workflowBlock.type);
|
||||
if (!workflowBlockType) return "";
|
||||
return getWorkflowBlockName(workflowBlockType, i18nT);
|
||||
}
|
||||
|
||||
export default {
|
||||
canRun,
|
||||
canRunManually,
|
||||
getStatusColor,
|
||||
hasTriggerNode,
|
||||
getBlocksErrors,
|
||||
getBlocksWithoutCallers,
|
||||
isReady,
|
||||
getVariables,
|
||||
getVariableName,
|
||||
getBlockTypeName,
|
||||
};
|
||||
Reference in new issue
Block a user