Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,120 @@
import { Fragment, useContext } from "react";
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getStraightPath } from "@xyflow/react";
import WorkflowContext from "../../context/WorkflowContext";
import clsx from "clsx";
import { t as i18nT } from "i18next";
function getConditionLabel(condition: string) {
if (condition === "true" || condition === "false") {
return i18nT(`workflows.boolean.${condition}`);
}
if (condition === "default") {
return i18nT("workflows.blocks.editor.defaultCase");
}
if (condition === "loopNext") {
return i18nT("workflows.blocks.editor.loopNext");
}
if (condition === "loopEnd") {
return i18nT("workflows.blocks.editor.loopEnd");
}
return condition;
}
export default function CustomEdge({ id, data, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, markerEnd }: EdgeProps) {
const { isReadOnly, onEdgeDelete } = useContext(WorkflowContext);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
// sourcePosition,
targetX,
targetY,
// targetPosition,
});
const condition = data?.condition as string | null | undefined;
const isExecuted = data?.isExecuted as boolean | undefined;
const conditionLabel = condition ? getConditionLabel(condition) : null;
return (
<>
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
strokeWidth: 1.5,
opacity: isExecuted ? 1 : 0.5,
transition: "stroke 0.3s, opacity 0.3s",
}}
/>
<EdgeLabelRenderer>
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
// everything inside EdgeLabelRenderer has no pointer events by default
// if you have an interactive element, set pointer-events: all
pointerEvents: "all",
}}
className="nodrag nopan"
>
{condition ? (
<button
className={clsx(
"rounded-md border px-1 py-0.5",
!isReadOnly && "group",
condition === "true"
? "border-green-300 bg-green-50 text-green-800"
: condition === "false"
? "border-red-300 bg-red-50 text-red-800"
: "border-border text-foreground bg-secondary"
)}
onClick={(e) => {
e.stopPropagation();
onEdgeDelete(id);
}}
disabled={isReadOnly}
aria-label={i18nT("workflows.blocks.editor.deleteConditionConnection", { condition: conditionLabel })}
>
<span className=" group-hover:hidden">{conditionLabel}</span>
<span className="hidden group-hover:block">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="h-4 w-4">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</span>
</button>
) : (
<Fragment>
{!isReadOnly && (
<button
type="button"
aria-label={i18nT("workflows.blocks.editor.deleteConnection")}
className="bg-background rounded-full"
onClick={(e) => {
e.stopPropagation();
onEdgeDelete(id);
}}
>
<svg
className="text-muted-foreground h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
)}
</Fragment>
)}
</div>
</EdgeLabelRenderer>
</>
);
}
@@ -0,0 +1,60 @@
import { Colors } from "~/application/enums/shared/Colors";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import { WorkflowStatus } from "../../dtos/WorkflowStatus";
import ShowModalButton from "~/components/ui/json/ShowModalButton";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { t as i18nT } from "i18next";
import { formatWorkflowExecutionErrorText } from "../../utils/workflowExecutionErrorText";
interface Props {
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
status: WorkflowStatus | (string & {});
error: string | null;
}
enum State {
Running = "running",
Success = "success",
Error = "error",
WaitingForBlock = "waitingBlock",
Unknown = "unknown",
}
export default function WorkflowResultBadge({ startedAt, completedAt, status, error }: Props) {
function getState() {
if (status === "waitingBlock") {
return State.WaitingForBlock;
} else if (status === "running") {
return State.Running;
} else if (status === "success") {
return State.Success;
} else if (status === "error") {
return State.Error;
}
return State.Unknown;
}
function getColor() {
if (!status || status === "waitingBlock") {
return Colors.YELLOW;
} else if (status === "running") {
return Colors.BLUE;
} else if (status === "success") {
return Colors.GREEN;
} else if (status === "error") {
return Colors.RED;
} else {
return Colors.UNDEFINED;
}
}
return (
<div>
{error ? (
<ShowModalButton title={<SimpleBadge className="hover:underline" title={i18nT(`workflows.executionStatus.${getState()}`)} color={getColor()} />}>
<ErrorBanner title={i18nT("workflows.executionStatus.error")} text={formatWorkflowExecutionErrorText(error, i18nT)} />
</ShowModalButton>
) : (
<SimpleBadge title={i18nT(`workflows.executionStatus.${getState()}`)} color={getColor()} />
)}
</div>
);
}
@@ -0,0 +1,5 @@
import { FeatherBox } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherBox)
@@ -0,0 +1,5 @@
import { FeatherKey } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherKey)
@@ -0,0 +1,5 @@
import { FeatherKey } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherKey)
@@ -0,0 +1,5 @@
import { FeatherPlay } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherPlay)
@@ -0,0 +1,5 @@
import { FeatherPlay } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherPlay)
@@ -0,0 +1,5 @@
import { FeatherWorkflow } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherWorkflow)
@@ -0,0 +1,5 @@
import { FeatherWorkflow } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherWorkflow)
@@ -0,0 +1,5 @@
import { FeatherVariable } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherVariable)
@@ -0,0 +1,5 @@
import { FeatherVariable } from "@subframe/core"
import { featherIcon } from "~/components/ui/icons/_feather"
export default featherIcon(FeatherVariable)
@@ -0,0 +1,99 @@
import { Position, Handle, type NodeProps } from "@xyflow/react";
import { t as i18nT } from "i18next";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import { getWorkflowBlockCategoryLabel, getWorkflowBlockName, WorkflowBlockTypes } from "../../dtos/WorkflowBlockTypes";
import WorkflowContext from "../../context/WorkflowContext";
import { useContext, useMemo } from "react";
import WorkflowBlockUtils from "../../helpers/WorkflowBlockUtils";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import clsx from "clsx";
import { useParams } from "react-router";
export default function BlockEditorNode({ id, data }: NodeProps) {
const params = useParams();
const { onNodeDelete, isNodeSelected, incomingBlockIds } = useContext(WorkflowContext);
const block = data.block as WorkflowBlockDto;
const workflow = data.workflow as WorkflowDto;
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block.type);
const errors = useMemo(
() => (workflow && block ? WorkflowBlockUtils.getBlockErrors({ workflow, block, validationContext: { incomingBlockIds } }) : []),
[block, incomingBlockIds, workflow]
);
if (!workflow) {
return <div>{i18nT("workflows.blocks.node.unknownWorkflow")}</div>;
}
if (!workflowBlock) {
return <div>{i18nT("workflows.blocks.node.unknownBlockType", { value: block.type })}</div>;
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
const workflowBlockCategory = getWorkflowBlockCategoryLabel(workflowBlock.category, i18nT);
return (
<button
type="button"
data-ui-id="ui.workflows.builder.node"
data-workflow-node-id={block.id}
data-workflow-node-kind="block"
className={clsx(
"relative flex h-[80px] w-[250px] flex-col justify-center rounded-md border p-2 text-left focus:ring-1 focus:ring-blue-600 focus:ring-offset-2",
isNodeSelected(id) ? "bg-blue-50 ring-1 ring-blue-600 ring-offset-2" : "bg-background",
errors.length ? "border-red-300" : "border-border"
)}
onKeyDown={(e) => {
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
e.stopPropagation();
onNodeDelete(block.id);
}
}}
>
<div className="w-full space-y-2">
<div className="flex justify-between space-x-2">
<div className="flex items-center space-x-2 truncate">
<workflowBlock.icon className="text-muted-foreground h-4 w-4 shrink-0" />
<div className="text-foreground/80 truncate text-sm font-medium">{workflowBlockName}</div>
</div>
<div>
<div
className={clsx(
"rounded-md border px-2 py-1 text-xs",
workflowBlock.type === "trigger" ? "border-yellow-200 bg-yellow-50 text-yellow-800" : "border-border text-muted-foreground bg-secondary"
)}
>
{workflowBlock.type === "trigger" ? <span>{i18nT("workflows.blocks.types.trigger")}</span> : <span>{workflowBlockCategory}</span>}
</div>
</div>
</div>
<div className="border-border border-t" />
{errors.length > 0 ? (
<div>
<div className="line-clamp-1 text-xs text-red-500">{errors.join(", ")}</div>
</div>
) : (
<div className=" text-muted-foreground line-clamp-1 text-xs">
{workflowBlock.appliesToAllTenants && !params.tenant && <span className="text-xs text-blue-500">({i18nT("workflows.blocks.node.allAccounts")}) </span>}
{block.description || block.input.event || i18nT("workflows.blocks.node.noDescription")}
</div>
)}
{workflowBlock.type === "action" && (
<Handle type="target" position={Position.Top} id="in" style={{ opacity: 0, width: "100%", height: "100%", top: 0 }} />
)}
<Handle
type="source"
position={Position.Bottom}
id="out"
style={{
bottom: "-7px",
left: "calc(50%)",
width: "15px",
height: "15px",
background: "#fff",
border: "1px solid #2563eb",
}}
/>
</div>
</button>
);
}
@@ -0,0 +1,92 @@
import { Position, Handle, type NodeProps } from "@xyflow/react";
import { t as i18nT } from "i18next";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import { getWorkflowBlockCategoryLabel, getWorkflowBlockName, WorkflowBlockTypes } from "../../dtos/WorkflowBlockTypes";
import WorkflowContext from "../../context/WorkflowContext";
import { useContext, useMemo } from "react";
import WorkflowBlockUtils from "../../helpers/WorkflowBlockUtils";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import clsx from "clsx";
import { WorkflowBlockExecutionDto } from "../../dtos/WorkflowBlockExecutionDto";
export default function BlockExecutionNode({ id, data }: NodeProps) {
const { isNodeSelected, incomingBlockIds } = useContext(WorkflowContext);
const block = data.block as WorkflowBlockDto;
const workflow = data.workflow as WorkflowDto;
const workflowBlockExecution = data.workflowBlockExecution as WorkflowBlockExecutionDto | null;
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block.type);
const errors = useMemo(
() => (workflow && block ? WorkflowBlockUtils.getBlockErrors({ workflow, block, validationContext: { incomingBlockIds } }) : []),
[block, incomingBlockIds, workflow]
);
if (!workflow) {
return <div>{i18nT("workflows.blocks.node.unknownWorkflow")}</div>;
}
if (!workflowBlock) {
return <div>{i18nT("workflows.blocks.node.unknownBlockType", { value: block.type })}</div>;
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
const workflowBlockCategory = getWorkflowBlockCategoryLabel(workflowBlock.category, i18nT);
return (
<button
type="button"
data-ui-id="ui.workflows.builder.node"
data-workflow-node-id={block.id}
className={clsx(
"relative flex h-[80px] w-[250px] flex-col justify-center rounded-md border p-2 text-left focus:ring-1 focus:ring-offset-2",
isNodeSelected(id) ? "ring-1 ring-offset-2" : "",
!workflowBlockExecution && "bg-background border-border focus:ring-blue-600",
workflowBlockExecution?.status === "success" && "border-teal-300 bg-teal-50 ring-teal-600 focus:ring-teal-600",
workflowBlockExecution?.status === "error" && "border-red-300 bg-red-50 ring-red-600 focus:ring-red-600"
)}
>
<div className="w-full space-y-2">
<div className="flex justify-between space-x-2">
<div className="flex items-center space-x-2 truncate">
<workflowBlock.icon className="text-muted-foreground h-4 w-4 shrink-0" />
<div className="text-foreground/80 truncate text-sm font-medium">{workflowBlockName}</div>
</div>
<div>
<div
className={clsx(
"rounded-md border px-2 py-1 text-xs",
workflowBlock.type === "trigger" ? "border-yellow-200 bg-yellow-50 text-yellow-800" : "border-border text-muted-foreground bg-secondary"
)}
>
{workflowBlock.type === "trigger" ? <span>{i18nT("workflows.blocks.types.trigger")}</span> : <span>{workflowBlockCategory}</span>}
</div>
</div>
</div>
<div className="border-border border-t" />
{errors.length > 0 ? (
<div>
<div className="line-clamp-1 text-xs text-red-500">{errors.join(", ")}</div>
</div>
) : (
<div className=" text-muted-foreground line-clamp-1 text-xs">{block.description || block.input.event || i18nT("workflows.blocks.node.noDescription")}</div>
)}
{workflowBlock.type === "action" && (
<Handle type="target" position={Position.Top} id="in" style={{ opacity: 0, width: "100%", height: "100%", top: 0 }} isConnectable={false} />
)}
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
id="out"
style={{
bottom: "-7px",
left: "calc(50%)",
width: "15px",
height: "15px",
background: "#fff",
border: "1px solid #2563eb",
}}
/>
</div>
</button>
);
}
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { t as i18nT } from "i18next";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import { WorkflowConditionOperators, WorkflowConditionsDto, WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
import WorkflowConditionUtils from "~/modules/workflowEngine/helpers/WorkflowConditionUtils";
import WorkflowVariableTextInput from "../../variables/WorkflowVariableTextInput";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { TrashIcon } from "~/components/ui/icons/vendor";
function ConditionsGroupInput({
workflow,
block,
type,
conditionsGroup,
onSave,
onCancel,
}: {
workflow: WorkflowDto;
block: WorkflowBlockDto;
type: "if" | "switch";
conditionsGroup: WorkflowConditionsGroupDto;
onSave: (conditionsGroup: WorkflowConditionsGroupDto) => void;
onCancel: () => void;
}) {
const [conditions, setConditions] = useState<WorkflowConditionsDto[]>(
conditionsGroup.conditions.length > 0
? conditionsGroup.conditions
: [
{
index: 0,
variable: "",
operator: "isNotEmpty",
value: "",
},
]
);
const [errors, setErrors] = useState<{ condition: WorkflowConditionsDto; errors: string[] }[]>([]);
const [conditionGroupType, setConditionGroupType] = useState<"AND" | "OR">(conditionsGroup.type);
useEffect(() => {
setErrors(WorkflowConditionUtils.getConditionsErrors(conditions));
}, [conditions]);
function updateConditionInGroup(idx: number, updatedCondition: WorkflowConditionsDto) {
const operator = WorkflowConditionUtils.getOperator(updatedCondition.operator);
if (!operator?.requiresValue) {
updatedCondition.value = "";
}
setConditions(conditions.map((condition, i) => (i === idx ? updatedCondition : condition)));
}
function removeConditionFromGroup(idx: number) {
setConditions(conditions.filter((_condition, i) => i !== idx));
}
function addConditionToGroup() {
const newCondition: WorkflowConditionsDto = {
index: conditions.length,
variable: "",
operator: "isNotEmpty",
value: "",
};
setConditions([...conditions, newCondition]);
}
return (
<div>
{conditions.length === 0 ? (
<div className=" border-border bg-secondary/90 flex flex-col items-center justify-center rounded-md border p-2 text-center">
<div className="text-muted-foreground text-sm font-medium">{i18nT("workflows.conditions.empty")}</div>
</div>
) : (
<div className="space-y-1">
<div className="flex h-10 items-center justify-between space-x-2">
{/* <div className="font-medium">{conditionsGroup.name}</div> */}
<div className="font-medium">{i18nT("workflows.conditions.count", { count: conditions.length })}</div>
{conditions.length > 1 && (
<select
aria-label={i18nT("workflows.conditions.logic.label")}
value={conditionGroupType}
onChange={(e) => setConditionGroupType(e.target.value as "AND" | "OR")}
className="border-border bg-secondary hover:bg-secondary/90 col-span-2 rounded-lg border px-2 py-2 pr-10 text-sm focus:outline-hidden"
>
<option value="AND">{i18nT("workflows.conditions.logic.and")}</option>
<option value="OR">{i18nT("workflows.conditions.logic.or")}</option>
</select>
)}
</div>
<div className="space-y-1 pb-2">
{conditions.map((condition, idx) => (
<div key={idx} className="group relative">
<button
type="button"
aria-label={i18nT("workflows.conditions.deleteCondition")}
onClick={() => removeConditionFromGroup(idx)}
className="text-muted-foreground absolute right-2 top-2.5 hidden rounded-md hover:text-red-500 focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 group-focus-within:block group-hover:block"
>
<TrashIcon className="h-4 w-4" />
</button>
<div className="grid grid-cols-1 gap-1 sm:grid-cols-10">
<WorkflowVariableTextInput
workflow={workflow}
block={block}
className="sm:col-span-4"
name="variable"
placeholder={i18nT("workflows.conditions.variable")}
value={condition.variable}
onChange={(e) => updateConditionInGroup(idx, { ...condition, variable: e })}
/>
<select
aria-label={i18nT("workflows.conditions.operator")}
className="border-border bg-secondary hover:bg-secondary/90 w-full rounded-lg border px-2 py-2 text-sm focus:outline-hidden sm:col-span-2"
value={condition.operator}
onChange={(e) => updateConditionInGroup(idx, { ...condition, operator: e.target.value as any })}
>
{WorkflowConditionOperators.map((operator) => {
return (
<option key={operator.value} value={operator.value}>
{i18nT(operator.translationKey)}
</option>
);
})}
</select>
{/* <input
className="col-span-4 bg-secondary border border-border hover:bg-secondary/90 rounded-lg w-full px-2 py-2 text-sm focus:outline-hidden"
type="text"
placeholder={i18nT("workflows.conditions.value")}
value={condition.value}
onChange={(e) => updateConditionInGroup(idx, { ...condition, value: e.target.value })}
/> */}
{WorkflowConditionUtils.getOperator(condition.operator)?.requiresValue && (
<WorkflowVariableTextInput
workflow={workflow}
block={block}
className="sm:col-span-4"
name="value"
placeholder={i18nT("workflows.conditions.value")}
value={condition.value}
onChange={(e) => updateConditionInGroup(idx, { ...condition, value: e })}
/>
)}
</div>
</div>
))}
</div>
</div>
)}
<div className="border-border border-t">
<div className="flex justify-between space-x-2 pt-2">
<div>
{/* <ButtonSecondary onClick={onCancel} className="mr-2">
Cancel
</ButtonSecondary> */}
<ButtonSecondary
onClick={() => addConditionToGroup()}
// className="bg-secondary hover:bg-secondary/90 border border-border px-2 py-1 rounded-md text-muted-foreground text-xs"
>
{i18nT("workflows.conditions.add")}
</ButtonSecondary>
</div>
<div className="flex items-center space-x-1">
<ButtonPrimary disabled={errors.length > 0} onClick={() => onSave({ ...conditionsGroup, conditions, type: conditionGroupType })}>
{i18nT("shared.save")}
</ButtonPrimary>
</div>
</div>
</div>
</div>
);
}
export default ConditionsGroupInput;
@@ -0,0 +1,150 @@
import { useState } from "react";
import { t as i18nT } from "i18next";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
import ConditionsGroupInput from "./ConditionsGroupInput";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowConditionUtils from "~/modules/workflowEngine/helpers/WorkflowConditionUtils";
import clsx from "clsx";
import { TrashIcon } from "~/components/ui/icons/vendor";
export default function ConditionsGroupsInfo({
workflow,
block,
type,
onUpdateConditionsGroups,
}: {
workflow: WorkflowDto;
block: WorkflowBlockDto;
type: "if" | "switch";
onUpdateConditionsGroups: (conditionsGroups: WorkflowConditionsGroupDto[]) => void;
}) {
const [selectedConditionsGroup, setSelectedConditionsGroup] = useState<WorkflowConditionsGroupDto | null>(null);
const [addingNewGroup, setAddingNewGroup] = useState(false);
function onSaveGroup(conditionsGroup: WorkflowConditionsGroupDto) {
if (conditionsGroup.index === block.conditionGroups.length) {
onUpdateConditionsGroups([...block.conditionGroups, conditionsGroup]);
} else {
onUpdateConditionsGroups(block.conditionGroups.map((group) => (group.index === conditionsGroup.index ? conditionsGroup : group)));
}
setSelectedConditionsGroup(null);
setAddingNewGroup(false);
}
function onDeleteGroup(idx: number) {
const newConditionsGroups = block.conditionGroups.filter((_group, i) => i !== idx);
onUpdateConditionsGroups(newConditionsGroups);
}
return (
<div className="space-y-1">
{block.conditionGroups.map((group) => {
return (
<div key={group.index} className="space-y-0.5">
{type === "switch" && (
<div className="text-muted-foreground text-xs font-medium uppercase">{i18nT("workflows.conditions.caseNumber", { index: group.index + 1 })}</div>
)}
{/* role=button instead of <button>: this row contains a nested delete <button>, so it cannot be a button element. */}
<div
role="button"
tabIndex={0}
onClick={() => setSelectedConditionsGroup(group)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedConditionsGroup(group);
}
}}
className={clsx(
"border-border group relative cursor-pointer rounded-lg border border-dashed px-2 py-2 text-sm hover:border-dotted focus:outline-hidden",
group.conditions.length === 0
? "border-red-300 bg-red-50 text-red-800 hover:border-red-800 hover:bg-red-100"
: "border-border bg-background hover:bg-secondary/90 hover:border-border"
)}
>
{type === "switch" && (
<button
type="button"
aria-label={i18nT("workflows.conditions.deleteCase", { index: group.index + 1 })}
onClick={(e) => {
e.stopPropagation();
onDeleteGroup(group.index);
}}
className="text-muted-foreground absolute right-2 top-2.5 hidden rounded-md hover:text-red-500 focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 group-focus-within:block group-hover:block"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<div className="space-y-0.5">
{group.conditions.length === 0 ? (
<div>
<div className="font-medium">{i18nT("workflows.conditions.empty")}</div>
</div>
) : group.conditions.length === 1 ? (
<div className=" border-border bg-secondary/90 flex flex-col items-center justify-center rounded-md border p-2 text-center">
<div key={group.index} className="flex items-center space-x-2">
<div className="font-medium">{WorkflowConditionUtils.getConditionString(group.conditions[0], i18nT)}</div>
</div>
</div>
) : (
<div>
<div className="font-medium">{i18nT("workflows.conditions.count", { count: group.conditions.length })}</div>
</div>
)}
</div>
</div>
</div>
);
})}
{type === "switch" && (
<button
type="button"
onClick={() => setAddingNewGroup(true)}
className="border-border text-muted-foreground bg-secondary hover:bg-secondary/90 rounded-md border px-2 py-1 text-xs"
>
{i18nT("workflows.conditions.addCase")}
</button>
)}
<SlideOverWideEmpty
className="sm:max-w-2xl"
title={!selectedConditionsGroup ? "" : type === "if" ? i18nT("workflows.conditions.ifTitle") : i18nT("workflows.conditions.caseTitle", { index: selectedConditionsGroup.index + 1 })}
open={!!selectedConditionsGroup}
onClose={() => setSelectedConditionsGroup(null)}
>
{selectedConditionsGroup && (
<ConditionsGroupInput
workflow={workflow}
block={block}
type={type}
conditionsGroup={selectedConditionsGroup}
onCancel={() => setSelectedConditionsGroup(null)}
onSave={onSaveGroup}
/>
)}
</SlideOverWideEmpty>
<SlideOverWideEmpty
className="sm:max-w-2xl"
title={i18nT("workflows.conditions.addCaseTitle", { index: block.conditionGroups.length + 1 })}
open={addingNewGroup}
onClose={() => setAddingNewGroup(false)}
>
{addingNewGroup && (
<ConditionsGroupInput
workflow={workflow}
block={block}
type={type}
conditionsGroup={{
index: block.conditionGroups.length,
type: "AND",
conditions: [],
}}
onCancel={() => setAddingNewGroup(false)}
onSave={onSaveGroup}
/>
)}
</SlideOverWideEmpty>
</div>
);
}
@@ -0,0 +1,5 @@
import { ArrowLeftRightIcon } from "~/components/ui/icons/vendor";
export default function ConditionalIcon({ className }: { className?: string }) {
return <ArrowLeftRightIcon className={className} />;
}
@@ -0,0 +1,7 @@
export default function DoNothingIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="50" height="50" viewBox="0 0 50 50">
<path d="M 32 9 L 32 21.851563 L 11 8.152344 L 11 41.847656 L 32 28.148438 L 32 41 L 40 41 L 40 9 Z"></path>
</svg>
);
}
@@ -0,0 +1,7 @@
export default function HttpRequestIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="50" height="50" viewBox="0 0 50 50">
<path d="M 3 6 C 2.449219 6 2 6.449219 2 7 L 2 43 C 2 43.554688 2.449219 44 3 44 L 47 44 C 47.554688 44 48 43.554688 48 43 L 48 7 C 48 6.449219 47.554688 6 47 6 Z M 14.5 16 C 14.9375 16 15.339844 16.300781 15.46875 16.71875 L 19.96875 31.34375 C 19.996094 31.441406 20 31.523438 20 31.625 L 20 32 C 20 32.554688 19.550781 33 19 33 C 18.449219 33 18 32.554688 18 32 L 18 31.78125 L 17.15625 29 L 11.84375 29 L 11 31.78125 L 11 32 C 11 32.554688 10.550781 33 10 33 C 9.449219 33 9 32.554688 9 32 L 9 31.625 C 9 31.523438 9.003906 31.441406 9.03125 31.34375 L 13.53125 16.71875 C 13.660156 16.300781 14.0625 16 14.5 16 Z M 24 16 L 28 16 C 30.207031 16 32 17.792969 32 20 L 32 23 C 32 25.207031 30.207031 27 28 27 L 25 27 L 25 32 C 25 32.554688 24.550781 33 24 33 C 23.449219 33 23 32.554688 23 32 L 23 17 C 23 16.449219 23.449219 16 24 16 Z M 35.5 16 L 38.5 16 C 39.054688 16 39.5 16.449219 39.5 17 C 39.5 17.550781 39.054688 18 38.5 18 L 38 18 L 38 31 L 38.5 31 C 39.054688 31 39.5 31.445313 39.5 32 C 39.5 32.554688 39.054688 33 38.5 33 L 35.5 33 C 34.945313 33 34.5 32.554688 34.5 32 C 34.5 31.445313 34.945313 31 35.5 31 L 36 31 L 36 18 L 35.5 18 C 34.945313 18 34.5 17.550781 34.5 17 C 34.5 16.449219 34.945313 16 35.5 16 Z M 25 18 L 25 25 L 28 25 C 29.101563 25 30 24.101563 30 23 L 30 20 C 30 18.898438 29.101563 18 28 18 Z M 14.5 20.40625 L 12.46875 27 L 16.53125 27 Z"></path>
</svg>
);
}
@@ -0,0 +1,7 @@
export default function LogIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="64" height="64" viewBox="0 0 64 64">
<path d="M 11 6 L 11 58 L 53 58 L 53 24.154297 C 53 22.117297 52.280703 20.144938 50.970703 18.585938 L 42.109375 8.0429688 C 41.021375 6.7479688 39.416609 6 37.724609 6 L 11 6 z M 15 10 L 36.695312 10 C 39.985312 10 41 11.365078 41 12.580078 C 41 14.365078 39 17 39 17 C 47.724993 21.129645 48.835785 21.437688 48.974609 24 L 15 24 L 15 10 z M 30.927734 27.791016 C 33.318734 27.791016 35.044922 29.770094 35.044922 32.996094 C 35.044922 36.222094 33.360672 38.228516 31.013672 38.228516 C 28.622672 38.228516 26.8125 36.291094 26.8125 32.996094 C 26.8125 29.770094 28.565734 27.791016 30.927734 27.791016 z M 41.285156 27.791016 C 42.855156 27.791016 43.959609 28.342172 44.849609 29.826172 L 43.109375 30.986328 C 42.727375 30.236328 41.922156 29.714844 41.285156 29.714844 C 39.800156 29.714844 39.050781 30.818094 39.050781 32.996094 C 39.050781 34.948094 39.928156 36.361328 41.285156 36.361328 C 42.290156 36.361328 42.981672 35.740047 43.138672 34.623047 L 41.257812 34.623047 L 41.257812 32.8125 L 45.259766 32.8125 L 45.259766 33.632812 C 45.259766 35.981812 43.563156 38.228516 41.285156 38.228516 C 38.824156 38.228516 37.169922 35.966094 37.169922 32.996094 C 37.169922 29.800094 38.626156 27.791016 41.285156 27.791016 z M 19.527344 27.931641 L 21.351562 27.931641 L 21.351562 36.291016 L 25.425781 36.291016 L 25.425781 38.087891 L 19.527344 38.087891 L 19.527344 27.931641 z M 30.927734 29.585938 C 29.570734 29.585938 28.609375 30.717141 28.609375 32.994141 C 28.609375 35.271141 29.640734 36.404297 30.927734 36.404297 C 32.242734 36.404297 33.248047 35.271141 33.248047 32.994141 C 33.248047 30.717141 32.412734 29.585937 30.927734 29.585938 z M 15 42 L 49 42 L 49 54 L 15 54 L 15 42 z"></path>
</svg>
);
}
@@ -0,0 +1,17 @@
import { type NodeProps } from "@xyflow/react";
import { t as i18nT } from "i18next";
export default function SetTriggerNode({ id }: NodeProps) {
return (
<div
data-ui-id="ui.workflows.builder.node"
data-workflow-node-id={id}
data-workflow-node-kind="set-trigger"
className="border-border h-20 w-64 rounded-md border border-dashed bg-slate-100 ring-1 ring-blue-600 ring-offset-2"
>
<div className="flex h-full flex-col justify-center space-y-1">
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.builder.setTriggerPrompt")}</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
export default function TriggerManualIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="currentColor" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="50" height="50" viewBox="0 0 50 50">
<path d="M 19.5 2 C 15.363281 2 12 5.363281 12 9.5 C 12 11.945313 13.183594 14.128906 15 15.5 L 15 12.65625 C 14.371094 11.761719 14 10.671875 14 9.5 C 14 6.464844 16.46875 4 19.5 4 C 22.53125 4 25 6.46875 25 9.5 C 25 10.671875 24.628906 11.761719 24 12.65625 L 24 15.5 C 25.816406 14.128906 27 11.945313 27 9.5 C 27 5.363281 23.636719 2 19.5 2 Z M 19.5 7.03125 C 18.535156 7.03125 17 7.695313 17 9.4375 L 17 34.0625 L 10.96875 28.03125 C 9.601563 26.664063 7.398438 26.664063 6.03125 28.03125 C 4.664063 29.398438 4.664063 31.601563 6.03125 32.96875 L 17 44 C 17.683594 44.683594 21.113281 48 25.78125 48 C 25.816406 48.003906 34 48 34 48 C 38.425781 48 43 44.632813 43 39 L 43 24.59375 C 43 23.21875 42.253906 22 40.53125 22 C 38.808594 22 38.03125 23.1875 38.03125 24.53125 L 38.03125 27 C 38.03125 27.550781 37.585938 28 37.03125 28 C 36.476563 28 36 27.550781 36 27 L 36 22.46875 C 36.019531 21.261719 35.28125 20 33.53125 20 C 31.78125 20 31 21.355469 31 22.5 L 31 25 C 31 25.550781 30.554688 26 30 26 C 29.449219 26 28.96875 25.550781 28.96875 25 L 29 21.5 C 29 20.488281 28.210938 19 26.5 19 C 24.789063 19 24 20.488281 24 21.5 L 24 25 C 24 25.550781 23.550781 26 23 26 C 22.449219 26 22 25.550781 22 25 L 22 9.5625 C 22 7.722656 20.507813 7.03125 19.5 7.03125 Z"></path>
</svg>
);
}
@@ -0,0 +1,43 @@
import TableSimple from "~/components/ui/tables/TableSimple";
import { WorkflowsTemplateDto } from "../../dtos/WorkflowsTemplateDto";
import { t as i18nT } from "i18next";
import { getTemplateBlockDescription, getTemplateWorkflowName } from "../../utils/workflowTemplateI18n";
export default function PreviewWorkflowsTemplate({ template }: { template: WorkflowsTemplateDto }) {
return (
<div>
{template.workflows.map((workflow) => {
return (
<div key={workflow.name} className="space-y-2">
<h3 className="font-medium">{getTemplateWorkflowName(template, workflow, i18nT)}</h3>
<TableSimple
items={workflow.blocks}
headers={[
{
name: "block",
title: i18nT("workflows.templates.blockType"),
value: (i) => (
<div className="flex-col">
<div>{i.type}</div>
<div className="text-muted-foreground text-xs">
{getTemplateBlockDescription(template, workflow, i, i18nT) || i18nT("workflows.blocks.node.noDescription")}
</div>
</div>
),
},
{
name: "input",
title: i18nT("workflows.run.input"),
value: (i) =>
JSON.stringify({
input: i.input,
}),
},
]}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,96 @@
import React, { useState } from "react";
import WorkflowVariableTextInput from "~/modules/workflowEngine/components/variables/WorkflowVariableTextInput";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { useTranslation } from "react-i18next";
import { TrashIcon } from "~/components/ui/icons/vendor";
export default function WorkflowKeyValueInputs({
workflow,
block,
initialData,
onChange,
}: {
workflow: WorkflowDto;
block: WorkflowBlockDto;
initialData: {
[key: string]: string;
};
onChange: (data: any) => void;
}) {
const { t } = useTranslation();
const initialPairs = Object.keys(initialData).map((key) => ({
id: crypto.randomUUID(),
key,
value: initialData[key],
}));
const [pairs, setPairs] = useState(initialPairs);
const handleAddPair = () => {
setPairs([...pairs, { id: crypto.randomUUID(), key: "", value: "" }]);
};
const handleKeyChange = (id: string, newKey: string) => {
const updatedPairs = pairs.map((pair) => (pair.id === id ? { ...pair, key: newKey } : pair));
setPairs(updatedPairs);
triggerOnChange(updatedPairs);
};
const handleValueChange = (id: string, newValue: string) => {
const updatedPairs = pairs.map((pair) => (pair.id === id ? { ...pair, value: newValue } : pair));
setPairs(updatedPairs);
triggerOnChange(updatedPairs);
};
const triggerOnChange = (updatedPairs: { id: string; key: string; value: string }[]) => {
const newData = updatedPairs.reduce((acc: any, { key, value }) => ({ ...acc, [key]: value }), {});
onChange(newData);
};
return (
<div className="border-border bg-secondary space-y-1 rounded-md border p-2">
{pairs.map(({ id, key, value }) => (
<div key={id} className="group relative grid grid-cols-1 gap-1 truncate sm:grid-cols-12">
<button
type="button"
aria-label={t("workflows.variables.removePair")}
onClick={() => {
const updatedPairs = pairs.filter((pair) => pair.id !== id);
setPairs(updatedPairs);
triggerOnChange(updatedPairs);
}}
className="absolute right-1 top-1.5 hidden rounded-md p-1 focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 group-focus-within:block group-hover:block"
>
<TrashIcon className="h-4 w-4" />
</button>
<div className="p-0.5 sm:col-span-6">
<WorkflowVariableTextInput
workflow={workflow}
block={block}
name="key"
label={t("workflows.variables.key")}
value={key}
onChange={(e) => handleKeyChange(id, e)}
placeholder={t("workflows.variables.key")}
/>
</div>
<div className="p-0.5 sm:col-span-6">
<WorkflowVariableTextInput
workflow={workflow}
block={block}
name="value"
label={t("workflows.variables.value")}
value={value}
onChange={(e) => handleValueChange(id, e)}
placeholder={t("workflows.variables.value")}
/>
</div>
</div>
))}
<button type="button" onClick={handleAddPair} className="border-border text-muted-foreground bg-secondary hover:bg-secondary/90 rounded-md border px-2 py-1 text-xs">
{t("shared.add")}
</button>
</div>
);
}
@@ -0,0 +1,107 @@
import { Fragment, useEffect, useState } from "react";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
import WorkflowUtils from "../../helpers/WorkflowUtils";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import { useTranslation } from "react-i18next";
import { VariableIcon } from "~/components/ui/icons/vendor";
export default function WorkflowVariableButton({
workflow,
block,
onSelected,
}: {
workflow: WorkflowDto;
block: WorkflowBlockDto;
onSelected: (variable: { name: string }) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
return (
<Fragment>
<button
type="button"
aria-label={t("workflows.variables.insert")}
onClick={() => setIsOpen(true)}
className="border-border text-muted-foreground hover:text-foreground hover:bg-background bg-secondary rounded-md border p-1 text-xs font-medium focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
<VariableIcon className="h-4 w-4" />
</button>
<SlideOverWideEmpty className="sm:max-w-sm" title={t("workflows.variables.title")} open={isOpen} onClose={() => setIsOpen(false)}>
<VariableList
workflow={workflow}
block={block}
onSelected={(variable) => {
onSelected(variable);
setIsOpen(false);
}}
/>
</SlideOverWideEmpty>
</Fragment>
);
}
function VariableList({ workflow, block, onSelected }: { workflow: WorkflowDto; block: WorkflowBlockDto; onSelected: (variable: { name: string }) => void }) {
const [groups, setGroups] = useState<{ name: string; variables: { name: string; label: string }[] }[]>([]);
const { t } = useTranslation();
useEffect(() => {
const variables = WorkflowUtils.getVariables({ workflow, currentBlock: block });
const groups = variables.reduce<{ name: string; variables: { name: string; label: string }[] }[]>((acc, variable) => {
const group = acc.find((x) => x.name === variable.group);
if (group) {
group.variables.push(variable);
} else {
acc.push({ name: variable.group, variables: [variable] });
}
return acc;
}, []);
if (workflow.$credentials && workflow.$credentials.length > 0) {
groups.unshift({
name: t("workflows.variables.credentials"),
variables: workflow.$credentials.map((f) => ({
name: `{{$credentials.${f}}}`,
label: f,
})),
});
}
let globalVariables = workflow.$variables ?? [];
const variableBlocks = workflow.blocks.filter((x) => x.type === "variable");
variableBlocks.forEach((variableBlock) => {
globalVariables.push(variableBlock.input.variableName);
});
if (globalVariables.length > 0) {
groups.unshift({
name: t("workflows.variables.global"),
variables: globalVariables.map((f) => ({
name: `{{$vars.${f}}}`,
label: f,
})),
});
}
setGroups(groups);
}, [workflow, block, t]);
return (
<div className="space-y-2">
{groups.map((group, idx) => {
return (
<div key={idx} className="space-y-1">
<div className="font-medium">{group.name}</div>
<div className="space-y-1">
{group.variables.map((variable) => (
<button
type="button"
key={variable.name}
className="hover:bg-secondary border-border bg-background flex w-full items-center space-x-2 truncate rounded-md border px-2 py-2 text-sm"
onClick={() => onSelected(variable)}
>
<div>{variable.label}</div>
</button>
))}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,65 @@
import { useRef } from "react";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import WorkflowVariableButton from "./WorkflowVariableButton";
import clsx from "clsx";
export default function WorkflowVariableTextInput({
name,
placeholder,
label,
workflow,
block,
value,
onChange,
className,
}: {
name: string;
placeholder: string | undefined;
label?: string;
workflow: WorkflowDto;
block: WorkflowBlockDto;
value: string;
onChange: (value: string) => void;
className?: string;
}) {
const refString = useRef<HTMLInputElement>(null);
function onSelected(variable: { name: string }) {
if (!refString.current) {
return;
}
var start = refString.current.selectionStart || 0;
var end = refString.current.selectionEnd || 0;
var text = refString.current.value;
var before = text.substring(0, start);
var after = text.substring(end, text.length);
onChange(before + variable.name + after);
}
return (
<div className={clsx("relative rounded-md shadow-2xs", className)}>
<input
ref={refString}
name={name}
id={name}
className="border-border bg-secondary hover:bg-secondary/90 w-full rounded-lg border px-2 py-2 pr-10 text-sm focus:outline-hidden"
value={value}
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
// @ts-ignore
e.target.select();
}}
placeholder={placeholder}
aria-label={label ?? placeholder ?? name}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
/>
<div className="absolute inset-y-0 right-0 flex py-1.5 pr-1.5 ">
<WorkflowVariableButton workflow={workflow} block={block} onSelected={onSelected} />
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { t as i18nT } from "i18next";
import InfoBanner from "~/components/ui/banners/InfoBanner";
import FormGroup from "~/components/ui/forms/FormGroup";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
interface Props {
item?: { id: string; name: string; value: string };
}
export default function WorkflowCredentialForm({ item }: Props) {
const [name, setName] = useState<string>(item?.name || "");
const [value, setValue] = useState<string>("");
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, []);
function isValidName(name: string) {
// Regular expression for lowercase alphanumeric with dashes
const regex = /^[a-zA-Z0-9-_]+$/;
// Test the variable name against the regular expression
return regex.test(name);
}
return (
<FormGroup id={item?.id} editing={true} submitDisabled={!name || !value || !isValidName(name)}>
<InputText
ref={item ? null : mainInput}
name="credential-name"
title={i18nT("workflows.columns.name")}
value={name}
setValue={setName}
required
disabled={!!item?.id}
placeholder={i18nT("workflows.credentials.placeholders.name")}
autoComplete="off"
/>
{name && !isValidName(name) && (
<div className="text-sm text-red-600">
<p>{i18nT("workflows.variables.invalidName")}</p>
</div>
)}
<InputText
type="password"
ref={item ? mainInput : null}
name="credential-value"
title={i18nT("workflows.columns.value")}
value={value}
setValue={setValue}
required
autoComplete="off"
/>
<InfoBanner title={i18nT("workflows.credentials.securityAssurance")}>
<div>{i18nT("workflows.credentials.encryptionNotice")}</div>
</InfoBanner>
</FormGroup>
);
}
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from "react";
import { t as i18nT } from "i18next";
import FormGroup from "~/components/ui/forms/FormGroup";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
interface Props {
item?: { id: string; name: string; value: string };
}
export default function WorkflowVariableForm({ item }: Props) {
const [name, setName] = useState<string>(item?.name || "");
const [value, setValue] = useState<string>(item?.value ?? "");
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, []);
function isValidName(name: string) {
// Regular expression for lowercase alphanumeric with dashes
const regex = /^[a-zA-Z0-9-_]+$/;
// Test the variable name against the regular expression
return regex.test(name);
}
return (
<FormGroup id={item?.id} editing={true}>
<InputText
ref={mainInput}
name="name"
title={i18nT("workflows.columns.name")}
value={name}
setValue={setName}
required
disabled={!!item?.id}
placeholder={i18nT("workflows.variables.placeholders.name")}
autoComplete="off"
/>
{name && !isValidName(name) && (
<div className="text-sm text-red-600">
<p>{i18nT("workflows.variables.invalidName")}</p>
</div>
)}
<InputText name="value" title={i18nT("workflows.columns.value")} value={value} setValue={setValue} required placeholder={i18nT("workflows.variables.placeholders.value")} autoComplete="off" />
</FormGroup>
);
}
@@ -0,0 +1,12 @@
import { lazy, Suspense } from "react";
import type { WorkflowBuilderProps } from "./WorkflowBuilder";
const WorkflowBuilder = lazy(() => import("./WorkflowBuilder"));
export default function LazyWorkflowBuilder(props: WorkflowBuilderProps) {
return (
<Suspense fallback={<div className="h-full min-h-[420px]" />}>
<WorkflowBuilder {...props} />
</Suspense>
);
}
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
describe("workflow builder accessibility source guards", () => {
it("keeps selectable rows keyboard-accessible without nested interactive buttons", () => {
const sidebar = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowEditorSidebar.tsx");
const blockEditor = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowBlockEditor.tsx");
expect(sidebar).toContain('role="button"');
expect(sidebar).toContain("onKeyDown");
expect(sidebar).toContain('aria-label={i18nT("shared.delete")}');
expect(sidebar).toContain("group-focus-within:block");
expect(sidebar).toContain("const blockErrors = useMemo(() => WorkflowUtils.getBlocksErrors(workflow), [workflow])");
expect(sidebar).toContain("blockErrors.length === 0");
expect(sidebar).toContain("blockErrors.map");
expect(sidebar).not.toContain('<button\n type="button"\n key={block.id}');
expect(sidebar).not.toContain("WorkflowUtils.getBlocksErrors(workflow).map");
expect(blockEditor).toContain('role="button"');
expect(blockEditor).toContain("onKeyDown");
expect(blockEditor).toContain('aria-label={i18nT("workflows.blocks.editor.disconnect")}');
expect(blockEditor).not.toContain('<button\n type="button"\n key={toBlockId}');
});
it("gives icon-only workflow actions stable accessible names", () => {
const blockEditor = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowBlockEditor.tsx");
const customEdge = readRepoFile("apps/web/app/modules/workflowEngine/components/edges/CustomEdge.tsx");
const conditionsInfo = readRepoFile("apps/web/app/modules/workflowEngine/components/nodes/flow/ConditionsGroupsInfo.tsx");
const conditionInput = readRepoFile("apps/web/app/modules/workflowEngine/components/nodes/flow/ConditionsGroupInput.tsx");
expect(blockEditor).toContain('aria-label={i18nT("shared.delete")}');
expect(customEdge).toContain('aria-label={i18nT("workflows.blocks.editor.deleteConnection")}');
expect(customEdge).toContain('aria-label={i18nT("workflows.blocks.editor.deleteConditionConnection", { condition: conditionLabel })}');
expect(conditionsInfo).toContain('aria-label={i18nT("workflows.conditions.deleteCase"');
expect(conditionInput).toContain('aria-label={i18nT("workflows.conditions.deleteCondition")}');
});
it("keeps workflow builder route layouts responsive on narrow screens", () => {
const editorRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.index.view.tsx");
const executionsRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.executions.view.tsx");
const templatesRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/templates.view.tsx");
for (const source of [editorRoute, executionsRoute]) {
expect(source).toContain("flex-col lg:h-[calc(100vh-140px)] lg:flex-row");
expect(source).toContain("w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0");
expect(source).not.toContain('className="flex h-[calc(100vh-140px)]"');
expect(source).not.toContain('className="border-border bg-background w-96 overflow-y-auto border-l"');
}
expect(templatesRoute).toContain("grid grid-cols-1 gap-3 sm:grid-cols-2");
expect(templatesRoute).toContain('setConfiguration(JSON.stringify(localizeWorkflowTemplate(t, i18nT), null, "\\t"))');
expect(templatesRoute).not.toContain("grid grid-cols-2 gap-3");
});
it("keeps workflow variable and condition inputs keyboard-friendly on narrow screens", () => {
const keyValueInputs = readRepoFile("apps/web/app/modules/workflowEngine/components/variables/WorkflowKeyValueInputs.tsx");
const conditionInput = readRepoFile("apps/web/app/modules/workflowEngine/components/nodes/flow/ConditionsGroupInput.tsx");
expect(keyValueInputs).toContain("grid grid-cols-1 gap-1 truncate sm:grid-cols-12");
expect(keyValueInputs).toContain("group-focus-within:block group-hover:block");
expect(keyValueInputs).toContain("sm:col-span-6");
expect(keyValueInputs).not.toContain("grid grid-cols-12 gap-1 truncate");
expect(conditionInput).toContain('aria-label={i18nT("workflows.conditions.logic.label")}');
expect(conditionInput).toContain('aria-label={i18nT("workflows.conditions.operator")}');
expect(conditionInput).toContain("grid grid-cols-1 gap-1 sm:grid-cols-10");
expect(conditionInput).toContain("sm:col-span-4");
expect(conditionInput).not.toContain("grid grid-cols-10 gap-1");
});
it("keeps workflow overview counts on locale-aware formatters", () => {
const overviewRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/index.view.tsx");
const workflowsRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/workflows.index.view.tsx");
const blockExecution = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowBlockExecution.tsx");
const executionsSidebar = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowExecutionsSidebar.tsx");
const executionsRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/executions.view.tsx");
const source = `${overviewRoute}\n${workflowsRoute}\n${blockExecution}\n${executionsSidebar}\n${executionsRoute}`;
expect(source).toContain("useLocaleFormatters");
expect(source).toContain("formatters.number");
expect(source).toContain("formatters.relativeDate");
expect(source).toContain("formatters.dateTime");
expect(source).toContain("workflows.executions.durationValue.seconds");
expect(source).not.toContain("NumberUtils");
expect(source).not.toContain("DateUtils");
});
it("keeps workflow status toggles named for assistive technology", () => {
const workflowsRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/workflows.index.view.tsx");
expect(workflowsRoute).toContain("workflows.list.toggleStatus");
expect(workflowsRoute).toContain("withLabel={false}");
});
it("cleans up delayed workflow focus timers on rerender or unmount", () => {
const focusTimerSources = [
readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.api.view.tsx"),
readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.view.tsx"),
readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/stream/WorkflowStreamProgress.tsx"),
readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowInputExamples.tsx"),
readRepoFile("apps/web/app/modules/workflowEngine/components/workflowVariables/WorkflowVariableForm.tsx"),
readRepoFile("apps/web/app/modules/workflowEngine/components/workflowVariables/WorkflowCredentialForm.tsx"),
];
for (const source of focusTimerSources) {
expect(source).toContain("const focusTimer = window.setTimeout");
expect(source).toContain("return () => window.clearTimeout(focusTimer)");
expect(source).not.toContain("useEffect(() => {\n setTimeout");
}
});
it("keeps workflow continuation controls addressable for mobile e2e coverage", () => {
const manualRun = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.view.tsx");
const apiRun = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.api.view.tsx");
const streamRun = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.view.tsx");
const streamProgress = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/stream/WorkflowStreamProgress.tsx");
const runDropdown = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown.tsx");
const inputExamplesDropdown = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown.tsx");
const mobileE2e = readRepoFile("tests/e2e/workflow-builder-run.mobile-a11y.spec.ts");
expect(manualRun).toContain('data-ui-id="ui.workflows.run.manual.waitingInput.form"');
expect(manualRun).toContain('data-ui-id="ui.workflows.run.manual.waitingInput.input"');
expect(manualRun).toContain('data-ui-id="ui.workflows.run.manual.waitingInput.submit"');
expect(apiRun).toContain('data-ui-id="ui.workflows.run.api.executionResult"');
expect(apiRun).toContain('data-ui-id="ui.workflows.run.api.waitingInput.form"');
expect(apiRun).toContain('data-ui-id="ui.workflows.run.api.waitingInput.input"');
expect(apiRun).toContain('data-ui-id="ui.workflows.run.api.waitingInput.submit"');
expect(streamRun).toContain('data-ui-id="ui.workflows.run.stream.title"');
expect(streamRun).toContain('data-ui-id="ui.workflows.run.stream.form"');
expect(streamRun).toContain('data-ui-id="ui.workflows.run.stream.inputEditor"');
expect(streamRun).toContain('dataUiId="ui.workflows.run.stream.submit"');
expect(streamProgress).toContain('data-ui-id="ui.workflows.run.stream.progress"');
expect(streamProgress).toContain('data-ui-id="ui.workflows.run.stream.waitingInput.form"');
expect(streamProgress).toContain('data-ui-id="ui.workflows.run.stream.waitingInput.input"');
expect(streamProgress).toContain('data-ui-id="ui.workflows.run.stream.waitingInput.submit"');
expect(runDropdown).toContain('buttonDataUiId="ui.workflows.run.dropdown.button"');
expect(inputExamplesDropdown).toContain('buttonDataUiId="ui.workflows.inputExamples.dropdown.button"');
expect(mobileE2e).toContain('workflowRunStreamPath)}(?:[?#].*)?$');
expect(mobileE2e).toContain('page.keyboard.press("Enter")');
expect(mobileE2e).toContain('page.keyboard.press("Escape")');
expect(mobileE2e).toContain("await expect(triggerButton).toBeFocused()");
});
});
@@ -0,0 +1,673 @@
import { useState, useEffect, Fragment, useMemo, useRef } from "react";
import { t as i18nT } from "i18next";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import {
getWorkflowBlockInputHrefLabel,
getWorkflowBlockInputLabel,
getWorkflowBlockInputPlaceholder,
getWorkflowBlockName,
getWorkflowBlockOptionLabel,
getWorkflowBlockOutputLabel,
WorkflowBlockTypes,
WorkflowBlockInput,
WorkflowBlockOutput,
} from "../../dtos/WorkflowBlockTypes";
import { WorkflowConditionsGroupDto } from "../../dtos/WorkflowConditionDtos";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import WorkflowBlockUtils from "../../helpers/WorkflowBlockUtils";
import WorkflowUtils from "../../helpers/WorkflowUtils";
import ConditionsGroupsInfo from "../nodes/flow/ConditionsGroupsInfo";
import WorkflowKeyValueInputs from "~/modules/workflowEngine/components/variables/WorkflowKeyValueInputs";
import WorkflowVariableTextInput from "../variables/WorkflowVariableTextInput";
import WorkflowVariableButton from "../variables/WorkflowVariableButton";
import MonacoEditor from "~/components/editors/MonacoEditor";
import WorkflowBlockErrors from "./misc/WorkflowBlockErrors";
import InputCheckbox from "~/components/ui/input/InputCheckbox";
import { Link, useParams } from "react-router";
import { ChevronLeftIcon, TrashIcon } from "~/components/ui/icons/vendor";
type WorkflowBlockTypeDefinition = (typeof WorkflowBlockTypes)[number];
export default function WorkflowBlockEditor({
workflow,
block,
onBack,
onSaveBlock,
onAddingNextBlock,
onDeleteBlock,
onDeleteConnection,
onSelectedBlock,
onUpdateConditionsGroups,
}: {
workflow: WorkflowDto;
block: WorkflowBlockDto;
onBack: () => void;
onSaveBlock: (block: WorkflowBlockDto) => void;
onAddingNextBlock: ({ fromBlock, condition }: { fromBlock: WorkflowBlockDto; condition: string | null }) => void;
onDeleteBlock: (blockId: string) => void;
onDeleteConnection: (connection: { fromBlockId: string; toBlockId: string }) => void;
onSelectedBlock: (workflowBlock: WorkflowBlockDto | null) => void;
onUpdateConditionsGroups: (blockId: string, conditionGroups: WorkflowConditionsGroupDto[]) => void;
}) {
const workflowBlocks = workflow.blocks;
const workflowBlockTypeByValue = useMemo(
() => new Map(WorkflowBlockTypes.map((workflowBlockType) => [workflowBlockType.value, workflowBlockType])),
[]
);
const workflowBlock = workflowBlockTypeByValue.get(block.type);
const workflowBlockById = useMemo(() => new Map(workflowBlocks.map((workflowBlock) => [workflowBlock.id, workflowBlock])), [workflowBlocks]);
const workflowBlockInputByName = useMemo(
() => new Map((workflowBlock?.inputs ?? []).map((input) => [input.name, input])),
[workflowBlock]
);
const blockToBlockByCondition = useMemo(() => new Map(block.toBlocks.map((toBlock) => [toBlock.condition ?? "", toBlock])), [block.toBlocks]);
const blockOutputVariables = useMemo(
() => WorkflowUtils.getVariables({ workflow, currentBlock: block, onlyInBlock: true }),
[workflow, block]
);
const [errors, setErrors] = useState<string[]>([]);
useEffect(() => {
setErrors(WorkflowBlockUtils.getBlockErrors({ workflow, block }));
}, [workflow, block]);
if (!workflowBlock) {
return (
<div>
<div>{i18nT("workflows.blocks.node.unknownBlockType", { value: block.type })}</div>
<button type="button" onClick={() => onDeleteBlock(block.id)}>{i18nT("shared.delete")}</button>
</div>
);
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
return (
<div data-ui-id="ui.workflows.builder.nodeSettings" data-workflow-node-id={block.id}>
<div className="">
<div className="border-border flex h-12 justify-between border-b px-4 py-3 pr-6">
<button
type="button"
className="hover:text-muted-foreground text-muted-foreground bg-background rounded-md focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
onClick={onBack}
>
<span className="sr-only">{i18nT("shared.close")}</span>
<ChevronLeftIcon className="h-5 w-5" />
</button>
<button
type="button"
aria-label={i18nT("shared.delete")}
data-ui-id="ui.workflows.builder.deleteBlock"
onClick={() => onDeleteBlock(block.id)}
className="hover:text-muted-foreground text-muted-foreground bg-background rounded-md focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 px-4 py-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<workflowBlock.icon className="h-5 w-5 text-indigo-500" />
<div className="text-foreground/80 font-medium">{workflowBlockName}</div>
</div>
<div>
<input
data-ui-id="ui.workflows.builder.blockDescription"
className=" text-muted-foreground hover:bg-secondary/90 w-full rounded-lg px-1 py-1 text-sm focus:outline-hidden"
defaultValue={block.description}
placeholder={i18nT("workflows.blocks.editor.descriptionPlaceholder")}
// on click select all text
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
// @ts-ignore
e.target.select();
}}
// onBlur or onEnter save
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
onBlur={(e) => {
if (e.target.value !== block.description) {
block.description = e.target.value;
onSaveBlock({
...block,
description: e.target.value,
});
}
}}
/>
</div>
</div>
{errors.length > 0 && (
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.editor.sections.errors")}</div>
<WorkflowBlockErrors errors={errors} />
</div>
</Fragment>
)}
{workflowBlock.value === "if" && (
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.editor.sections.conditions")}</div>
<div className="space-y-1">
<ConditionsGroupsInfo workflow={workflow} block={block} type="if" onUpdateConditionsGroups={(i) => onUpdateConditionsGroups(block.id, i)} />
</div>
</div>
</Fragment>
)}
{workflowBlock.value === "switch" && (
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.editor.sections.conditions")}</div>
<div className="space-y-1">
<ConditionsGroupsInfo
workflow={workflow}
block={block}
type="switch"
onUpdateConditionsGroups={(i) => onUpdateConditionsGroups(block.id, i)}
/>
</div>
</div>
</Fragment>
)}
{workflowBlock.inputs.length > 0 && (
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.editor.sections.inputs")}</div>
<div className="space-y-1">
{workflowBlock.inputs.map((input) => {
const ifInput = input.if?.name ?? "";
if (ifInput) {
const workflowBlockInput = workflowBlockInputByName.get(ifInput);
if (!workflowBlockInput) {
return null;
}
const value = block.input[workflowBlockInput.name];
if (!value || !input.if?.values.includes(value)) {
return null;
}
}
return (
<BlockInput
key={input.name}
workflow={workflow}
block={block}
workflowBlock={workflowBlock}
input={input as WorkflowBlockInput}
onChange={(value) => {
block.input[input.name] = value;
onSaveBlock({
...block,
input: {
...block.input,
[input.name]: value,
},
});
}}
/>
);
})}
</div>
</div>
</Fragment>
)}
{workflowBlock.outputs.length > 0 && (
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.editor.sections.outputs")}</div>
<div className="space-y-1">
{blockOutputVariables.map((output) => {
return <BlockOutput key={output.name} block={block} output={output as WorkflowBlockOutput} />;
})}
</div>
</div>
</Fragment>
)}
<div className="border-border border-t"></div>
{block.type === "if" ? (
<div className="space-y-3">
<div className="space-y-0.5 text-sm">
<div className="text-foreground/80 font-medium">{i18nT("workflows.blocks.editor.nextBlocks.title")}</div>
<div className="text-muted-foreground">{i18nT("workflows.blocks.editor.nextBlocks.description")}</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
<NextBlockWithCondition
title={i18nT("workflows.boolean.true")}
block={block}
condition="true"
workflowBlockById={workflowBlockById}
workflowBlockTypeByValue={workflowBlockTypeByValue}
blockToBlockByCondition={blockToBlockByCondition}
onDeleteConnection={onDeleteConnection}
onAddingNextBlock={onAddingNextBlock}
onSelectedBlock={onSelectedBlock}
/>
<NextBlockWithCondition
title={i18nT("workflows.boolean.false")}
block={block}
condition="false"
workflowBlockById={workflowBlockById}
workflowBlockTypeByValue={workflowBlockTypeByValue}
blockToBlockByCondition={blockToBlockByCondition}
onDeleteConnection={onDeleteConnection}
onAddingNextBlock={onAddingNextBlock}
onSelectedBlock={onSelectedBlock}
/>
</div>
</div>
</div>
) : block.type === "switch" ? (
<div className="space-y-3">
<div className="space-y-0.5 text-sm">
<div className="text-foreground/80 font-medium">{i18nT("workflows.blocks.editor.nextBlocks.title")}</div>
<div className="text-muted-foreground">{i18nT("workflows.blocks.editor.nextBlocks.description")}</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
{block.conditionGroups.map((group, idx) => {
return (
<NextBlockWithCondition
key={idx}
title={i18nT("workflows.conditions.caseTitle", { index: idx + 1 })}
block={block}
condition={`case${idx + 1}`}
workflowBlockById={workflowBlockById}
workflowBlockTypeByValue={workflowBlockTypeByValue}
blockToBlockByCondition={blockToBlockByCondition}
onDeleteConnection={onDeleteConnection}
onAddingNextBlock={onAddingNextBlock}
onSelectedBlock={onSelectedBlock}
/>
);
})}
<NextBlockWithCondition
title={i18nT("workflows.blocks.editor.defaultCase")}
block={block}
condition={`default`}
workflowBlockById={workflowBlockById}
workflowBlockTypeByValue={workflowBlockTypeByValue}
blockToBlockByCondition={blockToBlockByCondition}
onDeleteConnection={onDeleteConnection}
onAddingNextBlock={onAddingNextBlock}
onSelectedBlock={onSelectedBlock}
/>
</div>
</div>
</div>
) : block.type === "iterator" ? (
<div className="space-y-3">
<div className="space-y-0.5 text-sm">
<div className="text-foreground/80 font-medium">{i18nT("workflows.blocks.editor.nextBlocks.title")}</div>
<div className="text-muted-foreground">{i18nT("workflows.blocks.editor.nextBlocks.description")}</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
<NextBlockWithCondition
title={i18nT("workflows.blocks.editor.loopNext")}
block={block}
condition="loopNext"
workflowBlockById={workflowBlockById}
workflowBlockTypeByValue={workflowBlockTypeByValue}
blockToBlockByCondition={blockToBlockByCondition}
onDeleteConnection={onDeleteConnection}
onAddingNextBlock={onAddingNextBlock}
onSelectedBlock={onSelectedBlock}
/>
<NextBlockWithCondition
title={i18nT("workflows.blocks.editor.loopEnd")}
block={block}
condition="loopEnd"
workflowBlockById={workflowBlockById}
workflowBlockTypeByValue={workflowBlockTypeByValue}
blockToBlockByCondition={blockToBlockByCondition}
onDeleteConnection={onDeleteConnection}
onAddingNextBlock={onAddingNextBlock}
onSelectedBlock={onSelectedBlock}
/>
</div>
</div>
</div>
) : (
<div className="space-y-3">
<div className="space-y-0.5 text-sm">
<div className="text-foreground/80 font-medium">{i18nT("workflows.blocks.editor.nextBlock.title")}</div>
<div className="text-muted-foreground">{i18nT("workflows.blocks.editor.nextBlock.description")}</div>
</div>
<div className="space-y-1">
{block.toBlocks.map(({ toBlockId }) => {
const nextWorkflowBlock = workflowBlockById.get(toBlockId);
if (!nextWorkflowBlock) {
return <div key={toBlockId}>{i18nT("workflows.blocks.node.unknownToBlockId", { value: toBlockId })}</div>;
}
const workflowBlock = workflowBlockTypeByValue.get(nextWorkflowBlock.type);
if (!workflowBlock) {
return <div key={toBlockId}>{i18nT("workflows.blocks.node.unknownBlockType", { value: nextWorkflowBlock.type })}</div>;
}
const nextWorkflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
return (
<div
role="button"
tabIndex={0}
key={toBlockId}
className="hover:bg-secondary border-border bg-background group w-full cursor-pointer rounded-lg border p-2 text-left focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
onClick={() => onSelectedBlock(nextWorkflowBlock)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectedBlock(nextWorkflowBlock);
}
}}
>
<div className="flex justify-between space-x-2 text-sm font-medium">
<div>
{nextWorkflowBlockName}{" "}
{nextWorkflowBlock.description && (
<span className="text-muted-foreground text-xs font-normal">({nextWorkflowBlock.description || ""})</span>
)}
</div>
<button
type="button"
aria-label={i18nT("workflows.blocks.editor.disconnect")}
onClick={(e) => {
e.stopPropagation();
onDeleteConnection({ fromBlockId: block.id, toBlockId });
}}
className="hidden shrink-0 text-xs text-red-600 group-focus-within:block group-hover:block"
>{i18nT("workflows.blocks.editor.disconnect")}</button>
</div>
</div>
);
})}
<button
type="button"
data-ui-id="ui.workflows.builder.addNextBlock"
className="bg-background hover:bg-secondary/90 border-border w-full rounded-lg border border-dashed p-2 text-left text-sm font-medium hover:border-dotted hover:border-gray-800"
onClick={() => onAddingNextBlock({ fromBlock: block, condition: null })}
>{i18nT("workflows.blocks.editor.addNextBlock")}</button>
</div>
</div>
)}
<div></div>
</div>
</div>
</div>
);
}
function BlockInput({
workflow,
block,
workflowBlock,
input,
onChange,
}: {
workflow: WorkflowDto;
block: WorkflowBlockDto;
workflowBlock: WorkflowBlockTypeDefinition;
input: WorkflowBlockInput;
onChange: (value: string) => void;
}) {
const params = useParams();
const refMonaco = useRef(null);
const [inputValue, setInputValue] = useState<string | boolean | number>(
block.input[input.name] === undefined ? input.defaultValue : block.input[input.name] || ""
);
const [debouncedInputValue, setDebouncedInputValue] = useState(block.input[input.name] || "");
// Update debounced value after specified delay
useEffect(() => {
let timeout = 1000;
if (input.type === "select") {
timeout = 0;
}
const timer = setTimeout(() => setDebouncedInputValue(inputValue), timeout);
return () => clearTimeout(timer);
}, [input.type, inputValue]);
// Call onChange with the debounced value
useEffect(() => {
if ((debouncedInputValue || "") !== (block.input[input.name] || "")) {
onChange(debouncedInputValue);
}
}, [debouncedInputValue, onChange, block.input, input.name]);
function getKeyValueData() {
try {
return JSON.parse(block.input[input.name]) || {};
} catch {
return {};
}
}
return (
<div key={input.name} className=" space-y-1">
<div className="flex items-center justify-between space-x-2">
<label htmlFor={"input-" + input.name} className="text-muted-foreground flex justify-between space-x-2 truncate text-xs font-medium">
{getWorkflowBlockInputLabel(input, i18nT)}{" "}
{input.href && (
<span className=" ml-1 font-normal underline">
<Link to={input.href.url} target="_blank">
({getWorkflowBlockInputHrefLabel(input, i18nT)})
</Link>
</span>
)}
</label>
{input.type === "monaco" && (
<WorkflowVariableButton
workflow={workflow}
block={block}
onSelected={(variable) => {
if (refMonaco.current) {
try {
// @ts-ignore
var selection = refMonaco.current.getSelection();
var id = { major: 1, minor: 1 };
var op = { identifier: id, range: selection, text: variable.name, forceMoveMarkers: true };
// @ts-ignore
refMonaco.current.executeEdits("my-source", [op]);
} catch {
setInputValue((prev) => {
return prev + variable.name;
});
}
} else {
setInputValue((prev) => {
return prev + variable.name;
});
}
}}
/>
)}
</div>
{input.type === "string" && (
<WorkflowVariableTextInput
name={input.name}
placeholder={workflowBlock ? getWorkflowBlockInputPlaceholder(workflowBlock, input, i18nT) : input.placeholder}
workflow={workflow}
block={block}
value={inputValue as string}
onChange={(e) => setInputValue(e)}
/>
)}
{input.type === "select" && (
<select
name={input.name}
id={input.name}
className="border-border bg-secondary hover:bg-secondary/90 w-full rounded-lg border px-2 py-2 text-sm focus:outline-hidden"
value={inputValue as string}
onChange={(e) => setInputValue(e.target.value)}
onBlur={(e) => {
if (e.target.value !== block.input[input.name]) {
block.input[input.name] = e.target.value;
onChange(e.target.value);
}
}}
>
<option value="">{i18nT("workflows.blocks.editor.selectOption")}</option>
{input.options
?.filter((f) => !f.adminOnly || !params.tenant)
.map((option) => (
<option key={option.value} value={option.value}>
{getWorkflowBlockOptionLabel(option, i18nT)}
</option>
))}
</select>
)}
{input.type === "monaco" && (
<div className=" overflow-hidden">
{typeof window !== "undefined" && (
<MonacoEditor
onMount={(editor: any) => {
refMonaco.current = editor;
}}
value={inputValue as string}
language="json"
fontSize={12}
tabSize={2}
onChange={(e) => setInputValue(e.toString())}
className="h-32"
hideLineNumbers
theme="vs-dark"
/>
)}
</div>
)}
{input.type === "keyValue" && (
<div>
<WorkflowKeyValueInputs
workflow={workflow}
block={block}
initialData={getKeyValueData()}
onChange={(newData) => setInputValue(JSON.stringify(newData))}
/>
</div>
)}
{input.type === "boolean" && (
<InputCheckbox
name={input.name}
value={inputValue as boolean}
setValue={(v) => {
setInputValue(v as boolean);
}}
/>
)}
</div>
);
}
function BlockOutput({ output }: { block: WorkflowBlockDto; output: WorkflowBlockOutput }) {
const outputLabel = getWorkflowBlockOutputLabel(output, i18nT);
return (
<div key={output.name}>
<div className="flex items-center justify-between space-x-2">
<label htmlFor={"output-" + output.name} className="mb-1 flex justify-between space-x-2 truncate text-xs font-medium">
{outputLabel}
</label>
</div>
<input
name={output.name}
id={output.name}
className="border-border bg-secondary hover:bg-secondary/90 w-full rounded-lg border px-2 py-2 text-sm focus:outline-hidden"
value={`${outputLabel} (${output.name})`}
disabled
/>
</div>
);
}
function NextBlockWithCondition({
title,
block,
condition,
workflowBlockById,
workflowBlockTypeByValue,
blockToBlockByCondition,
onDeleteConnection,
onAddingNextBlock,
onSelectedBlock,
}: {
title: string;
block: WorkflowBlockDto;
condition: string;
workflowBlockById: ReadonlyMap<string, WorkflowBlockDto>;
workflowBlockTypeByValue: ReadonlyMap<string, WorkflowBlockTypeDefinition>;
blockToBlockByCondition: ReadonlyMap<string, WorkflowBlockDto["toBlocks"][number]>;
onDeleteConnection: (connection: { fromBlockId: string; toBlockId: string }) => void;
onAddingNextBlock: ({ fromBlock, condition }: { fromBlock: WorkflowBlockDto; condition: string | null }) => void;
onSelectedBlock: (workflowBlock: WorkflowBlockDto | null) => void;
}) {
const nextBlock = blockToBlockByCondition.get(condition);
if (!nextBlock) {
return (
<div className="space-y-0.5">
<label className="text-muted-foreground flex justify-between space-x-2 truncate text-xs font-medium uppercase">{title}</label>
<button
type="button"
data-ui-id="ui.workflows.builder.addConditionalNextBlock"
data-workflow-edge-condition={condition}
className="w-full rounded-lg border border-dashed border-red-300 bg-red-50 p-2 text-left text-sm font-medium text-red-800 hover:border-dotted hover:border-red-800 hover:bg-red-100"
onClick={() => onAddingNextBlock({ fromBlock: block, condition })}
>
{i18nT("workflows.blocks.node.addConditionBlock", { title })}
</button>
</div>
);
}
const nextWorkflowBlock = workflowBlockById.get(nextBlock.toBlockId);
if (!nextWorkflowBlock) {
return <div>{i18nT("workflows.blocks.node.unknownToBlockId", { value: nextBlock.toBlockId })}</div>;
}
const workflowBlock = workflowBlockTypeByValue.get(nextWorkflowBlock.type);
if (!workflowBlock) {
return <div>{i18nT("workflows.blocks.node.unknownBlockType", { value: nextWorkflowBlock.type })}</div>;
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
return (
<div className="space-y-0.5">
<label className="text-muted-foreground flex justify-between space-x-2 truncate text-xs font-medium uppercase">{title}</label>
<div
role="button"
tabIndex={0}
data-ui-id="ui.workflows.builder.conditionalNextBlock"
data-workflow-edge-condition={condition}
onClick={() => onSelectedBlock(nextWorkflowBlock)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectedBlock(nextWorkflowBlock);
}
}}
className="hover:bg-secondary border-border bg-background group w-full cursor-pointer rounded-lg border p-2 text-left focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
<div className="flex justify-between space-x-2 text-sm font-medium">
<div>
{workflowBlockName}{" "}
{nextWorkflowBlock.description && <span className="text-muted-foreground text-xs font-normal">({nextWorkflowBlock.description || ""})</span>}
</div>
<button
type="button"
data-ui-id="ui.workflows.builder.disconnectConditionalNextBlock"
data-workflow-edge-condition={condition}
aria-label={i18nT("workflows.blocks.editor.disconnect")}
onClick={(e) => {
e.stopPropagation();
onDeleteConnection({ fromBlockId: block.id, toBlockId: nextBlock.toBlockId });
}}
className="hidden shrink-0 text-xs text-red-600 group-focus-within:block group-hover:block"
>{i18nT("workflows.blocks.editor.disconnect")}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
import { Fragment } from "react";
import { t as i18nT } from "i18next";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import { getWorkflowBlockName, WorkflowBlockTypes } from "../../dtos/WorkflowBlockTypes";
import MonacoEditor from "~/components/editors/MonacoEditor";
import { WorkflowBlockExecutionDto } from "../../dtos/WorkflowBlockExecutionDto";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { WorkflowExecutionDto } from "../../dtos/WorkflowExecutionDto";
import { useLocaleFormatters } from "~/locale/format";
import { formatWorkflowExecutionErrorText } from "../../utils/workflowExecutionErrorText";
import { ChevronLeftIcon } from "~/components/ui/icons/vendor";
export default function WorkflowBlockExecution({
block,
blockRun,
execution,
onBack,
}: {
block: WorkflowBlockDto;
blockRun: WorkflowBlockExecutionDto | null;
execution: WorkflowExecutionDto | null;
onBack: () => void;
}) {
const formatters = useLocaleFormatters();
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block.type);
if (!workflowBlock) {
return (
<div>
<div>{i18nT("workflows.blocks.node.unknownBlockType", { value: block.type })}</div>
</div>
);
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
return (
<div>
<div className="">
<div className="border-border flex h-12 justify-between border-b px-4 py-3 pr-6">
<button
type="button"
className="hover:text-muted-foreground text-muted-foreground bg-background rounded-md focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
onClick={onBack}
>
<span className="sr-only">{i18nT("shared.close")}</span>
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-4 py-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<workflowBlock.icon className="h-5 w-5 text-indigo-500" />
<div className="text-foreground/80 font-medium">{workflowBlockName}</div>
</div>
<div>
<div className="bg-secondary/90 text-muted-foreground w-full rounded-lg px-1 py-1 text-sm focus:outline-none">
{block.description || block.input.event || i18nT("workflows.blocks.node.noDescription")}
</div>
</div>
</div>
{blockRun === null ? (
<div>
{execution ? (
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.execution.notExecuted")}</div>
) : (
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.execution.selectExecution")}</div>
)}
</div>
) : (
<Fragment>
<div className="flex justify-between">
<div>
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.columns.status")}</div>
<div className="text-muted-foreground text-sm">{i18nT(`workflows.executionStatus.${blockRun.status}`, { defaultValue: blockRun.status })}</div>
</div>
</div>
<div className="flex justify-between">
<div>
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.columns.started")}</div>
<div className="text-muted-foreground text-sm">{blockRun.startedAt ? formatters.relativeDate(blockRun.startedAt) : "-"}</div>
</div>
<div>
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.columns.ended")}</div>
<div className="text-muted-foreground text-sm">{blockRun.endedAt ? formatters.relativeDate(blockRun.endedAt) : "-"}</div>
</div>
</div>
{blockRun.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={formatWorkflowExecutionErrorText(blockRun.error, i18nT)} />}
{blockRun.input && (
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.execution.input")}</div>
<div className="space-y-1">
<div className="overflow-hidden">
{typeof window !== "undefined" && (
<MonacoEditor
value={blockRun.input ? JSON.stringify(blockRun.input, null, 2) : "{}"}
language="json"
fontSize={12}
tabSize={2}
className="h-32"
hideLineNumbers
theme="vs-dark"
/>
)}
</div>
</div>
</div>
</Fragment>
)}
{blockRun.output && (
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.blocks.execution.output")}</div>
<div className="space-y-1">
<div className="overflow-hidden">
<MonacoEditor
value={blockRun.output ? JSON.stringify(blockRun.output, null, 2) : "{}"}
language="json"
fontSize={12}
tabSize={2}
className="h-32"
hideLineNumbers
theme="vs-dark"
/>
</div>
</div>
</div>
</Fragment>
)}
</Fragment>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,266 @@
import {
ReactFlow,
Controls,
useReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
ConnectionLineType,
useEdgesState,
useNodesState,
type Connection,
type Edge,
type Node,
type NodeMouseHandler,
} from "@xyflow/react";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import WorkflowContext from "../../context/WorkflowContext";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import SetTriggerNode from "../nodes/triggers/SetTriggerNode";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import BlockEditorNode from "../nodes/BlockEditorNode";
import CustomEdge from "../edges/CustomEdge";
import WorkflowLayoutUtils from "../../helpers/WorkflowLayoutUtils";
import { WorkflowExecutionDto } from "../../dtos/WorkflowExecutionDto";
import BlockExecutionNode from "../nodes/BlockExecutionNode";
import WorkflowBlockUtils from "../../helpers/WorkflowBlockUtils";
export interface WorkflowBuilderProps {
workflow: WorkflowDto;
selectedBlock: WorkflowBlockDto | null;
workflowExecution: WorkflowExecutionDto | null;
onSelectedBlock: (node: WorkflowBlockDto | null) => void;
onConnectBlocks?: (data: { fromBlockId: string; toBlockId: string; condition: string | null }) => void;
onDeleteConnection?: (edgeId: string) => void;
onDeleteBlock?: (nodeId: string) => void;
readOnly?: boolean;
}
export default function WorkflowBuilder(props: WorkflowBuilderProps) {
return (
<ReactFlowProvider>
<WorkflowBuilderFlow {...props} />
</ReactFlowProvider>
);
}
function WorkflowBuilderFlow({
workflow,
selectedBlock,
workflowExecution,
onSelectedBlock,
onConnectBlocks,
onDeleteConnection,
onDeleteBlock,
readOnly,
}: WorkflowBuilderProps) {
const reactFlowInstance = useReactFlow();
const { t } = useTranslation();
const [nodes, setNodes] = useNodesState<Node>([]);
const [edges, setEdges] = useEdgesState<Edge>([]);
const connectFitViewTimer = useRef<number | null>(null);
const workflowBlocks = workflow.blocks;
const nodeTypes = useMemo(
() => ({
blockEditorNode: BlockEditorNode,
blockExecutionNode: BlockExecutionNode,
setTriggerNode: SetTriggerNode,
}),
[]
);
const edgeTypes = useMemo(() => {
return {
customEdge: CustomEdge,
};
}, []);
const blockById = useMemo(() => new Map(workflowBlocks.map((block) => [block.id, block])), [workflowBlocks]);
const incomingBlockIds = useMemo(() => WorkflowBlockUtils.getIncomingBlockIds({ blocks: workflowBlocks }), [workflowBlocks]);
const hasWorkflowExecution = !!workflowExecution;
const isReadOnly = useMemo(() => {
return readOnly || hasWorkflowExecution;
}, [readOnly, hasWorkflowExecution]);
useEffect(() => {
const { nodes: initialNodes, edges: initialEdges } = WorkflowLayoutUtils.workflowToReactFlow({
workflow,
workflowExecution,
workflowBlocks,
});
const { nodes: layoutedNodes, edges: layoutedEdges } = WorkflowLayoutUtils.getLayoutedElements({
readOnly: isReadOnly,
nodes: initialNodes,
edges: initialEdges,
direction: "TB",
});
setNodes(layoutedNodes);
setEdges(layoutedEdges);
// Layout is intentionally keyed by the block graph and execution view state.
// Parent routes may refresh workflow metadata without changing the canvas topology.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReadOnly, setEdges, setNodes, workflowBlocks, workflowExecution]);
useEffect(() => {
const fitViewTimer = window.setTimeout(() => {
reactFlowInstance?.fitView();
}, 100);
return () => window.clearTimeout(fitViewTimer);
}, [reactFlowInstance, readOnly, workflow.id, workflowExecution?.id]);
useEffect(() => {
return () => {
if (connectFitViewTimer.current !== null) {
window.clearTimeout(connectFitViewTimer.current);
}
};
}, []);
const onConnect = useCallback(
(params: Connection) => {
if (!onConnectBlocks) {
return;
}
const fromBlock = blockById.get(params.source ?? "");
const toBlock = blockById.get(params.target ?? "");
if (!fromBlock || !toBlock) {
return;
}
if (fromBlock.id === toBlock.id) {
return;
}
if (fromBlock.toBlocks.find((x) => x.toBlockId === toBlock.id)) {
// already connected
return;
}
if (toBlock.toBlocks.find((x) => x.toBlockId === fromBlock.id)) {
// already connected
return;
}
let condition: string | null = null;
if (fromBlock.type === "if") {
const hasTrue = fromBlock.toBlocks.find((x) => x.condition === "true");
const hasFalse = fromBlock.toBlocks.find((x) => x.condition === "false");
if (!hasTrue) {
condition = "true";
} else if (!hasFalse) {
condition = "false";
} else {
return;
}
} else if (fromBlock.type === "switch") {
let availableNextCase = -1;
fromBlock.conditionGroups.forEach((group) => {
const existingConnection = fromBlock.toBlocks.find((f) => f.condition === `case${group.index + 1}`);
if (!existingConnection) {
availableNextCase = group.index;
}
});
if (availableNextCase === -1) {
const existingDefaultConnection = fromBlock.toBlocks.find((f) => f.condition === "default");
if (existingDefaultConnection) {
return;
} else {
condition = "default";
}
} else {
condition = `case${availableNextCase + 1}`;
}
} else if (fromBlock.type === "iterator") {
const hasLoopNext = fromBlock.toBlocks.find((x) => x.condition === "loopNext");
const hasLoopEnd = fromBlock.toBlocks.find((x) => x.condition === "loopEnd");
if (!hasLoopNext) {
condition = "loopNext";
} else if (!hasLoopEnd) {
condition = "loopEnd";
} else {
return;
}
}
onConnectBlocks({
fromBlockId: params.source!,
toBlockId: params.target!,
condition,
});
if (connectFitViewTimer.current !== null) {
window.clearTimeout(connectFitViewTimer.current);
}
connectFitViewTimer.current = window.setTimeout(() => {
reactFlowInstance?.fitView();
}, 1000);
},
[blockById, onConnectBlocks, reactFlowInstance]
);
const onEdgeDelete = useCallback(
(edgeId: string) => {
if (!onDeleteConnection || hasWorkflowExecution) {
return;
}
onDeleteConnection(edgeId);
},
[hasWorkflowExecution, onDeleteConnection]
);
const onNodeDelete = useCallback(
(nodeId: string) => {
if (!onDeleteBlock || hasWorkflowExecution) {
return;
}
onDeleteBlock(nodeId);
},
[hasWorkflowExecution, onDeleteBlock]
);
const isNodeSelected = useCallback(
(nodeId: string) => {
return selectedBlock?.id === nodeId;
},
[selectedBlock?.id]
);
const workflowContextValue = useMemo(
() => ({ isReadOnly, onEdgeDelete, onNodeDelete, isNodeSelected, incomingBlockIds }),
[incomingBlockIds, isReadOnly, isNodeSelected, onEdgeDelete, onNodeDelete]
);
const handleNodeClick: NodeMouseHandler = useCallback(
(_event, node) => {
const block = blockById.get(node.id);
if (block) {
onSelectedBlock(block);
}
},
[blockById, onSelectedBlock]
);
return (
<WorkflowContext.Provider value={workflowContextValue}>
<ReactFlow
data-ui-id="ui.workflows.builder.canvas"
aria-label={t("workflows.builder.canvas")}
tabIndex={0}
// Hide the React Flow attribution watermark: its #999 link on the canvas
// fails WCAG AA color-contrast (2.79:1) and is not our content.
proOptions={{ hideAttribution: true }}
nodes={nodes}
edges={edges}
onConnect={onConnect}
connectionLineType={ConnectionLineType.Straight}
onNodeClick={handleNodeClick}
onPaneClick={() => onSelectedBlock(null)}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
minZoom={0.5} // 50%
maxZoom={2} // 200%
fitView
>
<Background color="#94a3b8" variant={BackgroundVariant.Dots} />
<Controls showInteractive={false} />
</ReactFlow>
</WorkflowContext.Provider>
);
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
describe("WorkflowBuilder performance source guards", () => {
it("keys canvas relayouts by graph inputs instead of every workflow object refresh", () => {
const source = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowBuilder.tsx");
expect(source).toContain("const workflowBlocks = workflow.blocks;");
expect(source).toContain("new Map(workflowBlocks.map((block) => [block.id, block]))");
expect(source).toContain("workflowBlocks, workflowExecution");
expect(source).toContain("blockById.get(params.source ?? \"\")");
expect(source).toContain("blockById.get(node.id)");
expect(source).not.toContain("workflow.blocks.find((x) => x.id === params.source)");
expect(source).not.toContain("workflow.blocks.find((x) => x.id === node.id)");
expect(source).not.toContain("}, [readOnly, setEdges, setNodes, workflow, workflowExecution]);");
});
it("keeps workflow builder behind a route-level lazy boundary", () => {
const builder = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowBuilder.tsx");
const lazyBuilder = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/LazyWorkflowBuilder.tsx");
const editorRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.index.view.tsx");
const executionsRoute = readRepoFile("apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.executions.view.tsx");
expect(builder).toContain("export interface WorkflowBuilderProps");
expect(lazyBuilder).toContain('lazy(() => import("./WorkflowBuilder"))');
expect(lazyBuilder).toContain("<Suspense fallback=");
expect(lazyBuilder).toContain("WorkflowBuilderProps");
expect(lazyBuilder).not.toContain('@xyflow/react/dist/style.css');
for (const source of [editorRoute, executionsRoute]) {
expect(source).toContain('import "@xyflow/react/dist/style.css";');
expect(source).toContain('import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";');
expect(source).toContain("<LazyWorkflowBuilder");
expect(source).not.toContain('from "~/modules/workflowEngine/components/workflows/WorkflowBuilder"');
expect(source).not.toContain("<WorkflowBuilder");
}
});
it("memoizes workflow block editor lookups instead of scanning during render", () => {
const source = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowBlockEditor.tsx");
expect(source).toContain("const workflowBlocks = workflow.blocks;");
expect(source).toContain("new Map(workflowBlocks.map((workflowBlock) => [workflowBlock.id, workflowBlock]))");
expect(source).toContain("new Map(WorkflowBlockTypes.map((workflowBlockType) => [workflowBlockType.value, workflowBlockType]))");
expect(source).toContain("const workflowBlockInputByName = useMemo(");
expect(source).toContain("const blockToBlockByCondition = useMemo(");
expect(source).toContain("const blockOutputVariables = useMemo(");
expect(source).toContain("blockOutputVariables.map");
expect(source).not.toContain("WorkflowUtils.getVariables({ workflow, currentBlock: block, onlyInBlock: true }).map");
expect(source).not.toContain("workflow.blocks.find((x) => x.id === toBlockId)");
expect(source).not.toContain("workflow.blocks.find((x) => x.id === nextBlock.toBlockId)");
expect(source).not.toContain("workflowBlock.inputs.find((x) => x.name === ifInput)");
});
it("memoizes execution sidebar JSON panel values independently for input and output", () => {
const source = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowExecutionsSidebar.tsx");
expect(source).toContain("const selectedExecutionInputJson = useMemo(() => stringifyJsonForPanel(selectedExecution?.input), [selectedExecution?.input]);");
expect(source).toContain("const selectedExecutionOutputJson = useMemo(() => stringifyJsonForPanel(selectedExecution?.output), [selectedExecution?.output]);");
expect(source).toContain("value={selectedExecutionInputJson}");
expect(source).toContain("value={selectedExecutionOutputJson}");
expect(source).toContain("function stringifyJsonForPanel(value: unknown)");
expect(source).not.toContain("selectedExecution.input ? JSON.stringify(selectedExecution.output, null, 2) : \"{}\"");
});
it("memoizes workflow context values for React Flow node and edge consumers", () => {
const source = readRepoFile("apps/web/app/modules/workflowEngine/components/workflows/WorkflowBuilder.tsx");
expect(source).toContain("const hasWorkflowExecution = !!workflowExecution;");
expect(source).toContain("const incomingBlockIds = useMemo(() => WorkflowBlockUtils.getIncomingBlockIds({ blocks: workflowBlocks }), [workflowBlocks]);");
expect(source).toContain("const workflowContextValue = useMemo(");
expect(source).toContain("() => ({ isReadOnly, onEdgeDelete, onNodeDelete, isNodeSelected, incomingBlockIds })");
expect(source).toContain("[incomingBlockIds, isReadOnly, isNodeSelected, onEdgeDelete, onNodeDelete]");
expect(source).toContain("<WorkflowContext.Provider value={workflowContextValue}>");
expect(source).toContain("[selectedBlock?.id]");
expect(source).toContain("[hasWorkflowExecution, onDeleteConnection]");
expect(source).toContain("[hasWorkflowExecution, onDeleteBlock]");
expect(source).not.toContain("value={{ isReadOnly, onEdgeDelete, onNodeDelete, isNodeSelected }}");
});
it("precomputes incoming workflow edges for node validation renders", () => {
const blockUtils = readRepoFile("apps/web/app/modules/workflowEngine/helpers/WorkflowBlockUtils.ts");
const workflowUtils = readRepoFile("apps/web/app/modules/workflowEngine/helpers/WorkflowUtils.ts");
const editorNode = readRepoFile("apps/web/app/modules/workflowEngine/components/nodes/BlockEditorNode.tsx");
const executionNode = readRepoFile("apps/web/app/modules/workflowEngine/components/nodes/BlockExecutionNode.tsx");
expect(blockUtils).toContain("function getIncomingBlockIds(workflow: Pick<WorkflowDto, \"blocks\">): Set<string>");
expect(blockUtils).toContain("validationContext?.incomingBlockIds?.has(block.id)");
expect(workflowUtils).toContain("const validationContext = { incomingBlockIds: WorkflowBlockUtils.getIncomingBlockIds(workflow) };");
expect(workflowUtils).not.toContain("workflow.blocks.filter((f) =>");
for (const source of [editorNode, executionNode]) {
expect(source).toContain("incomingBlockIds");
expect(source).toContain("validationContext: { incomingBlockIds }");
expect(source).not.toContain("const [errors, setErrors]");
expect(source).not.toContain("setErrors(WorkflowBlockUtils.getBlockErrors");
}
});
});
@@ -0,0 +1,326 @@
import React, { useMemo, useState } from "react";
import { t as i18nT } from "i18next";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import WorkflowUtils from "../../helpers/WorkflowUtils";
import InputText from "~/components/ui/input/InputText";
import { getWorkflowBlockCategoryLabel, getWorkflowBlockName, WorkflowBlockType, WorkflowBlockTypes } from "../../dtos/WorkflowBlockTypes";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import WorkflowIcon from "~/modules/workflowEngine/components/icons/WorkflowIcon";
import { WorkflowConditionsGroupDto } from "../../dtos/WorkflowConditionDtos";
import WorkflowEditBlock from "./WorkflowBlockEditor";
import WorkflowBlockErrors from "./misc/WorkflowBlockErrors";
import WorkflowInputExamples from "./WorkflowInputExamples";
import { Input } from "~/components/ui/input";
import { useTranslation } from "react-i18next";
import { ChevronLeftIcon, TrashIcon } from "~/components/ui/icons/vendor";
interface Props {
selectedBlock: WorkflowBlockDto | null;
workflow: WorkflowDto;
addingNextBlockFrom: { fromBlock: WorkflowBlockDto; condition: string | null } | null;
onSave: (workflow: WorkflowDto) => void;
onSetTrigger: (node: WorkflowBlockType) => void;
onSelectedBlock: (workflowBlock: WorkflowBlockDto | null) => void;
onSaveBlock: (block: WorkflowBlockDto) => void;
onAddingNextBlock: (data: { fromBlock: WorkflowBlockDto; condition: string | null } | null) => void;
onAddBlock: (block: { from: WorkflowBlockDto; type: WorkflowBlockType; condition: string | null }) => void;
onDeleteBlock: (blockId: string) => void;
onDeleteConnection: (connection: { fromBlockId: string; toBlockId: string }) => void;
onUpdateConditionsGroups: (blockId: string, conditionGroups: WorkflowConditionsGroupDto[]) => void;
}
export default function WorkflowEditorSidebar({
selectedBlock,
workflow,
addingNextBlockFrom,
onSave,
onSetTrigger,
onSelectedBlock,
onSaveBlock,
onAddingNextBlock,
onAddBlock,
onDeleteBlock,
onDeleteConnection,
onUpdateConditionsGroups,
}: Props) {
let content;
if (!WorkflowUtils.hasTriggerNode(workflow)) {
content = (
<SelectBlockSidebar
title={i18nT("workflows.sidebar.selectTrigger.title")}
description={i18nT("workflows.sidebar.selectTrigger.description")}
placeholder={i18nT("workflows.sidebar.selectTrigger.placeholder")}
type="trigger"
onSelected={(e) => onSetTrigger(e)}
onBack={() => onSelectedBlock(null)}
/>
);
} else if (addingNextBlockFrom) {
content = (
<SelectBlockSidebar
title={i18nT("workflows.sidebar.selectAction.title")}
description={i18nT("workflows.sidebar.selectAction.description")}
placeholder={i18nT("workflows.sidebar.selectAction.placeholder")}
type="action"
onSelected={(e) =>
onAddBlock({
from: addingNextBlockFrom.fromBlock,
condition: addingNextBlockFrom.condition,
type: e,
})
}
onBack={() => onAddingNextBlock(null)}
/>
);
} else if (!selectedBlock) {
content = <WorkflowSettingsSidebar workflow={workflow} onSave={onSave} onSelectedBlock={onSelectedBlock} onDeleteBlock={onDeleteBlock} />;
} else {
content = (
<WorkflowEditBlock
key={selectedBlock.id}
workflow={workflow}
block={selectedBlock}
onBack={() => onSelectedBlock(null)}
onSaveBlock={onSaveBlock}
onAddingNextBlock={onAddingNextBlock}
onDeleteBlock={onDeleteBlock}
onDeleteConnection={onDeleteConnection}
onSelectedBlock={onSelectedBlock}
onUpdateConditionsGroups={onUpdateConditionsGroups}
/>
);
}
return <div className="">{content}</div>;
}
function SelectBlockSidebar({
title,
description,
placeholder,
type,
onSelected,
onBack,
}: {
title: string;
description: string;
placeholder: string;
type: "trigger" | "action";
onSelected: (node: WorkflowBlockType) => void;
onBack: () => void;
}) {
const { t } = useTranslation();
const categories = WorkflowBlockTypes.filter((f) => f.type === type)
.map((f) => f.category)
.filter((v, i, a) => a.indexOf(v) === i);
const [searchInput, setSearchInput] = useState("");
return (
<div>
<div className="border-border flex h-12 justify-between border-b px-4 py-3 pr-6">
<button
type="button"
className="hover:text-muted-foreground text-muted-foreground bg-background rounded-md focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
onClick={onBack}
>
<span className="sr-only">{i18nT("shared.close")}</span>
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="px-4 py-4 ">
<div className="space-y-2">
<div className="space-y-1 text-sm">
<div className="text-foreground/80 font-medium">{title}</div>
<div className="text-muted-foreground">{description}</div>
</div>
<div className="border-border border-t"></div>
<div>
<InputText autoFocus title={t("shared.search")} withLabel={false} placeholder={placeholder} value={searchInput} setValue={setSearchInput} />
</div>
<div className="space-y-2">
{categories.map((category) => {
let nodeTypes = WorkflowBlockTypes.filter((f) => f.type === type && f.category === category);
const categoryLabel = getWorkflowBlockCategoryLabel(category, t);
if (searchInput) {
nodeTypes = nodeTypes.filter(
(f) =>
getWorkflowBlockName(f, t).toLowerCase().includes(searchInput.toLowerCase()) ||
getWorkflowBlockCategoryLabel(f.category, t).toLowerCase().includes(searchInput.toLowerCase())
);
}
if (nodeTypes.length === 0) {
return null;
}
return (
<div key={category} className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">{categoryLabel}</div>
{nodeTypes.map((item) => {
const itemName = getWorkflowBlockName(item, t);
return (
<button
key={item.name}
type="button"
data-ui-id="ui.workflows.builder.blockTypeOption"
data-workflow-block-type={item.value}
onClick={() => onSelected(item.value)}
className="border-border bg-secondary hover:bg-secondary/90 w-full rounded-lg border p-2 text-left"
>
<div className="flex items-center space-x-2">
<item.icon className="text-muted-foreground h-4 w-4" />
<div className="text-muted-foreground text-sm">{itemName}</div>
</div>
</button>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function WorkflowSettingsSidebar({
workflow,
onSave,
onSelectedBlock,
onDeleteBlock,
}: {
workflow: WorkflowDto;
onSave: (workflow: WorkflowDto) => void;
onSelectedBlock: (workflowBlock: WorkflowBlockDto) => void;
onDeleteBlock: (blockId: string) => void;
}) {
const { t } = useTranslation();
const blockErrors = useMemo(() => WorkflowUtils.getBlocksErrors(workflow), [workflow]);
return (
<div className="px-4 py-4 ">
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center space-x-1">
<div className="bg-secondary/90 rounded-md p-2">
<WorkflowIcon className="text-foreground h-4 w-4" />
</div>
<Input
aria-label={t("workflows.settings.name")}
className="hover:bg-secondary/90 w-full rounded-lg px-1 py-1 text-lg font-semibold focus:outline-hidden"
defaultValue={workflow.name}
placeholder={t("workflows.settings.namePlaceholder")}
// on click select all text
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
// @ts-ignore
e.target.select();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
onBlur={(e) => {
if (e.target.value !== workflow.name) {
workflow.name = e.target.value;
onSave({
...workflow,
name: e.target.value,
});
}
}}
/>
</div>
<div>
<Input
aria-label={t("workflows.settings.description")}
className=" hover:bg-secondary/90 w-full truncate rounded-lg px-1 py-1 text-sm focus:outline-hidden"
defaultValue={workflow.description}
placeholder={t("workflows.settings.descriptionPlaceholder")}
// on click select all text
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
// @ts-ignore
e.target.select();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
onBlur={(e) => {
if (e.target.value !== workflow.description) {
workflow.description = e.target.value;
onSave({
...workflow,
description: e.target.value,
});
}
}}
/>
</div>
</div>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="space-y-0.5 text-sm">
<div className="text-foreground/80 font-medium">{i18nT("shared.status")}</div>
<div className="text-muted-foreground">{i18nT("workflows.validation.nodes")}</div>
</div>
<div className="space-y-2">
{blockErrors.length === 0 ? (
<div>
<div className="group flex w-full flex-col items-center justify-center rounded-lg border border-dashed border-teal-300 bg-teal-50 p-3 text-center text-xs dark:bg-teal-900 dark:text-teal-50">
{i18nT("workflows.validation.allNodesValid")}
</div>
</div>
) : (
blockErrors.map(({ block, errors }) => {
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block.type);
if (!workflowBlock) {
return <div key={block.id}>{i18nT("workflows.blocks.node.unknownBlockType", { value: block.type })}</div>;
}
return (
<div
role="button"
tabIndex={0}
key={block.id}
className="hover:bg-secondary border-border bg-background group relative w-full cursor-pointer overflow-y-auto rounded-lg border p-2 text-left focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
onClick={() => onSelectedBlock(block)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectedBlock(block);
}
}}
>
<button
type="button"
aria-label={i18nT("shared.delete")}
onClick={(e) => {
e.stopPropagation();
onDeleteBlock(block.id);
}}
className="hover:text-muted-foreground text-muted-foreground bg-background absolute right-2 top-2 hidden rounded-md focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 group-focus-within:block group-hover:block"
>
<TrashIcon className="h-4 w-4" />
</button>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<workflowBlock.icon className="text-muted-foreground h-4 w-4" />
<div className="text-muted-foreground text-sm">{getWorkflowBlockName(workflowBlock, i18nT)}</div>
</div>
<div className="border-border border-t" />
<WorkflowBlockErrors errors={errors} />
</div>
</div>
);
})
)}
</div>
</div>
<div className="border-border border-t"></div>
<WorkflowInputExamples workflow={workflow} />
</div>
</div>
);
}
@@ -0,0 +1,264 @@
import { Fragment, useMemo } from "react";
import { t as i18nT } from "i18next";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import { WorkflowExecutionDto } from "../../dtos/WorkflowExecutionDto";
import { Link, useParams, useSearchParams } from "react-router";
import WorkflowBlockExecution from "./WorkflowBlockExecution";
import { WorkflowBlockExecutionDto } from "../../dtos/WorkflowBlockExecutionDto";
import clsx from "clsx";
import DateCell from "~/components/ui/dates/DateCell";
import { Colors } from "~/application/enums/shared/Colors";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
import MonacoEditor from "~/components/editors/MonacoEditor";
import UrlUtils from "~/utils/app/UrlUtils";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { useLocaleFormatters } from "~/locale/format";
import { formatWorkflowExecutionErrorText } from "../../utils/workflowExecutionErrorText";
import { ChevronLeftIcon } from "~/components/ui/icons/vendor";
interface Props {
workflow: WorkflowDto;
selectedBlock: WorkflowBlockDto | null;
selectedExecution: WorkflowExecutionDto | null;
executions: WorkflowExecutionDto[];
onSelectedBlock: (node: WorkflowBlockDto | null) => void;
onSelectedExecution: (execution: WorkflowExecutionDto | null) => void;
}
export default function WorkflowExecutionsSidebar({ workflow, selectedBlock, selectedExecution, executions, onSelectedBlock }: Props) {
const formatters = useLocaleFormatters();
const [searchParams, setSearchParams] = useSearchParams();
const selectedExecutionInputJson = useMemo(() => stringifyJsonForPanel(selectedExecution?.input), [selectedExecution?.input]);
const selectedExecutionOutputJson = useMemo(() => stringifyJsonForPanel(selectedExecution?.output), [selectedExecution?.output]);
function formatDateTime(value: Date | null) {
return value ? formatters.dateTime(value, { timeStyle: "medium" }) : "-";
}
function formatDuration(start: Date | null, end: Date | null) {
if (!start || !end) {
return "-";
}
const seconds = Math.max(0, Math.floor((+new Date(end) - +new Date(start)) / 1000));
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes === 0) {
return i18nT("workflows.executions.durationValue.seconds", { count: seconds, value: formatters.number(seconds) });
}
return i18nT("workflows.executions.durationValue.minutesSeconds", {
minutes: formatters.number(minutes),
seconds: formatters.number(remainingSeconds),
});
}
let content;
if (!selectedBlock && !selectedExecution) {
content = (
<ExecutePanel
workflow={workflow}
executions={executions}
selectedExecution={selectedExecution}
onSelected={(item) => {
searchParams.set("executionId", item.id);
setSearchParams(searchParams);
}}
/>
);
} else if (!selectedBlock && selectedExecution) {
content = (
<div>
<div className="">
<div className="border-border flex h-12 justify-between border-b px-4 py-3 pr-6">
<button
type="button"
className="hover:text-muted-foreground text-muted-foreground bg-background rounded-md focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
onClick={() => {
onSelectedBlock(null);
searchParams.delete("executionId");
setSearchParams(searchParams);
}}
>
<span className="sr-only">{i18nT("shared.close")}</span>
<ChevronLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-4 py-4">
<div className="space-y-2">
<div className="space-y-1">
<div className="border-border flex items-center justify-between space-x-2 border-b border-dashed pb-1">
<div className="text-xs font-medium">{i18nT("shared.status")}</div>
<div className="text-sm">{selectedExecution.status}</div>
</div>
<div className="border-border flex items-center justify-between space-x-2 border-b border-dashed pb-1">
<div className="text-xs font-medium">{i18nT("shared.createdAt")}</div>
<div className="text-sm">{formatDateTime(selectedExecution.createdAt)}</div>
</div>
<div className="border-border flex items-center justify-between space-x-2 border-b border-dashed pb-1">
<div className="text-xs font-medium">{i18nT("workflows.executions.endedAt")}</div>
<div className="text-sm">{formatDateTime(selectedExecution.endedAt)}</div>
</div>
<div className="border-border flex items-center justify-between space-x-2 border-b border-dashed pb-1">
<div className="text-xs font-medium">{i18nT("workflows.executions.duration")}</div>
<div className="text-sm">{formatDuration(selectedExecution.createdAt, selectedExecution.endedAt)}</div>
</div>
</div>
</div>
{selectedExecution.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={formatWorkflowExecutionErrorText(selectedExecution.error, i18nT)} />}
<Fragment>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.executions.params")}</div>
<div className="space-y-1">
<div className="overflow-hidden">
{typeof window !== "undefined" && (
<MonacoEditor
value={selectedExecutionInputJson}
language="json"
fontSize={12}
tabSize={2}
className="h-32"
hideLineNumbers
theme="vs-dark"
/>
)}
</div>
</div>
</div>
</Fragment>
<Fragment>
<div className="border-border border-t"></div>
<div className="space-y-3">
<div className="text-foreground/80 text-sm font-medium">{i18nT("workflows.executions.output")}</div>
<div className="space-y-1">
<div className="overflow-hidden">
<MonacoEditor
value={selectedExecutionOutputJson}
language="json"
fontSize={12}
tabSize={2}
className="h-32"
hideLineNumbers
theme="vs-dark"
/>
</div>
</div>
</div>
</Fragment>
</div>
</div>
</div>
);
} else if (selectedBlock) {
let blockRun: WorkflowBlockExecutionDto | null = null;
if (selectedExecution) {
blockRun = selectedExecution.blockRuns.find((x) => x.workflowBlockId === selectedBlock.id) ?? null;
}
content = (
<WorkflowBlockExecution
key={selectedBlock.id}
execution={selectedExecution}
block={selectedBlock}
blockRun={blockRun}
onBack={() => onSelectedBlock(null)}
/>
);
}
return <div className="">{content}</div>;
}
function stringifyJsonForPanel(value: unknown) {
return value === null || value === undefined ? "{}" : JSON.stringify(value, null, 2);
}
function ExecutePanel({
workflow,
executions,
selectedExecution,
onSelected,
}: {
workflow: WorkflowDto;
executions: WorkflowExecutionDto[];
selectedExecution: WorkflowExecutionDto | null | undefined;
onSelected: (execution: WorkflowExecutionDto) => void;
}) {
const params = useParams();
return (
<div className="space-y-4 p-4">
<div className="space-y-1">
<div className="flex justify-between space-x-2">
<div className="text-foreground font-medium">{i18nT("workflows.run.executeWorkflow")}</div>
</div>
<div className="flex flex-col space-y-2">
<Link to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}/run/manual`)}>
<div className="border-border bg-secondary hover:bg-secondary/90 hover:border-border rounded-md border border-dashed p-3 hover:border-dotted">
<div className="space-y-1">
<div className="text-foreground text-sm font-medium">{i18nT("workflows.run.modes.manual")}</div>
<div className=" text-muted-foreground text-xs">{i18nT("workflows.run.manualDescription")}</div>
</div>
</div>
</Link>
<Link to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}/run/api`)}>
<div className="border-border bg-secondary hover:bg-secondary/90 hover:border-border rounded-md border border-dashed p-3 hover:border-dotted">
<div className="space-y-1">
<div className="text-foreground text-sm font-medium">{i18nT("workflows.run.modes.api")}</div>
<div className=" text-muted-foreground text-xs">{i18nT("workflows.run.apiDescription")}</div>
</div>
</div>
</Link>
<Link to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}/run/stream`)}>
<div className="border-border bg-secondary hover:border-border hover:bg-secondary/90 rounded-md border border-dashed p-3 hover:border-dotted">
<div className="space-y-1">
<div className="text-sm font-medium text-gray-800">{i18nT("workflows.run.modes.stream")}</div>
<div className=" text-muted-foreground text-xs">{i18nT("workflows.run.streamDescription")}</div>
</div>
</div>
</Link>
</div>
</div>
<div className="space-y-1 overflow-y-scroll">
<div className="font-medium">{i18nT("workflows.executions.title")}</div>
{executions.length === 0 ? (
<div>
<div className="text-muted-foreground text-sm">{i18nT("workflows.executions.empty")}</div>
</div>
) : (
<div className="space-y-0.5 overflow-y-auto">
{executions.map((execution, idx) => {
return (
<button
type="button"
key={idx}
className={clsx(
"hover:bg-secondary/90 block w-full cursor-pointer rounded-md border px-2 py-1.5 text-left",
selectedExecution?.id === execution.id ? "border-border bg-secondary/90" : "bg-background border-transparent"
)}
onClick={() => onSelected(execution)}
>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center space-x-2 truncate">
{/* <div className="text-muted-foreground font-light truncate w-6">#{idx + 1}</div> */}
<div className="text-foreground/80 w-14 font-medium">
<DateCell date={execution.createdAt} displays={["hms"]} />
</div>
<div className="text-muted-foreground truncate">
{i18nT("workflows.executions.nodesExecuted", { count: execution.blockRuns.length })}
</div>
</div>
<div>
{!execution.endedAt ? (
<SimpleBadge title={i18nT("workflows.executionStatus.incomplete")} color={Colors.YELLOW} />
) : execution.error ? (
<SimpleBadge title={i18nT("workflows.executionStatus.error")} color={Colors.RED} />
) : (
<SimpleBadge title={i18nT("workflows.executionStatus.success")} color={Colors.GREEN} />
)}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "../../dtos/WorkflowDto";
import WorkflowInputExamples from "./WorkflowInputExamples";
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
Form: ({ children, ...props }: React.ComponentProps<"form">) => <form {...props}>{children}</form>,
useSubmit: () => vi.fn(),
};
});
vi.mock("i18next", () => ({
t: (key: string) => key,
}));
vi.mock("~/components/editors/MonacoEditor", () => ({
default: ({ value }: { value: string }) => <textarea readOnly value={value} aria-label="Monaco editor" />,
}));
vi.mock("~/components/ui/slideOvers/SlideOverWideEmpty", () => ({
default: ({ open, children }: { open: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
}));
function workflow(inputExamples: WorkflowDto["inputExamples"]): WorkflowDto {
return {
id: "workflow-1",
tenantId: "tenant-1",
tenant: null,
name: "Example workflow",
description: "",
status: "draft",
createdAt: new Date("2026-05-28T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples,
_count: { executions: 0 },
};
}
describe("WorkflowInputExamples", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("memoizes rendered input preview strings between unchanged renders", () => {
const inputExamples = [{ id: "example-1", title: "Lead example", input: { lead: { id: "lead_1" } } }];
const stringifySpy = vi.spyOn(JSON, "stringify");
const { rerender } = render(<WorkflowInputExamples workflow={workflow(inputExamples)} />);
expect(screen.getByText(/lead_1/)).toBeInTheDocument();
const callsAfterInitialRender = stringifySpy.mock.calls.length;
rerender(<WorkflowInputExamples workflow={workflow(inputExamples)} />);
expect(stringifySpy.mock.calls.length).toBe(callsAfterInitialRender);
rerender(<WorkflowInputExamples workflow={workflow([...inputExamples, { id: "example-2", title: "Account example", input: { accountId: "account_1" } }])} />);
expect(stringifySpy.mock.calls.length).toBeGreaterThan(callsAfterInitialRender);
expect(screen.getByText(/account_1/)).toBeInTheDocument();
});
});
@@ -0,0 +1,191 @@
import { Form, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import MonacoEditor from "~/components/editors/MonacoEditor";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
import { WorkflowDto } from "../../dtos/WorkflowDto";
import { WorkflowInputExampleDto } from "../../dtos/WorkflowInputExampleDto";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
export default function WorkflowInputExamples({ workflow }: { workflow: WorkflowDto }) {
const submit = useSubmit();
const [addingInputExample, setAddingInputExample] = useState(false);
const [selectedInputExample, setSelectedInputExample] = useState<WorkflowInputExampleDto | null>(null);
const inputExamplePreviews = useMemo(
() =>
workflow.inputExamples.map((inputExample) => ({
inputExample,
preview: JSON.stringify(inputExample.input, null, 2),
})),
[workflow.inputExamples]
);
function onCreate(item: WorkflowInputExampleDto) {
setSelectedInputExample(null);
setAddingInputExample(false);
const form = new FormData();
form.set("action", "create-input-example");
form.set("title", item.title);
form.set("input", JSON.stringify(item.input));
submit(form, {
method: "post",
});
}
function onUpdate(item: WorkflowInputExampleDto) {
if (!item.id) {
return;
}
setSelectedInputExample(null);
setAddingInputExample(false);
const form = new FormData();
form.set("action", "update-input-example");
form.set("id", item.id || "");
form.set("title", item.title);
form.set("input", JSON.stringify(item.input));
submit(form, {
method: "post",
});
}
function onDelete(item: WorkflowInputExampleDto) {
if (!item.id) {
return;
}
setSelectedInputExample(null);
setAddingInputExample(false);
const form = new FormData();
form.set("action", "delete-input-example");
form.set("id", item.id || "");
submit(form, {
method: "post",
});
}
return (
<div>
<div className="space-y-3">
<div className="space-y-0.5 text-sm">
<div className="text-foreground/80 font-medium">{i18nT("workflows.inputExamples.title")}</div>
<div className="text-muted-foreground">{i18nT("workflows.inputExamples.description")}</div>
</div>
<div className="space-y-2">
{inputExamplePreviews.map(({ inputExample, preview }) => {
return (
<button
type="button"
key={inputExample.id}
className="border-border bg-secondary hover:bg-secondary/90 w-full rounded-md border border-dotted p-2 hover:border-dashed"
onClick={() => {
setAddingInputExample(false);
setSelectedInputExample(inputExample);
}}
>
<div className="flex justify-between space-x-2">
<div className="font-sm text-foreground/80 shrink-0 text-xs">{inputExample.title}</div>
<div className="text-muted-foreground truncate text-xs">{preview}</div>
</div>
</button>
);
})}
<button
type="button"
className="hover:bg-secondary border-border bg-background w-full rounded-md border border-dotted p-2 hover:border-dashed"
onClick={() => {
setAddingInputExample(true);
setSelectedInputExample(null);
}}
>
<div className="font-sm text-foreground/80 text-xs">{i18nT("workflows.inputExamples.addCta")}</div>
</button>
</div>
</div>
<SlideOverWideEmpty
open={!!selectedInputExample}
title={i18nT("workflows.inputExamples.editTitle", { title: selectedInputExample?.title ?? "" })}
onClose={() => setSelectedInputExample(null)}
className="sm:max-w-sm"
>
<WorkflowInputExampleForm item={selectedInputExample} onSave={onUpdate} onDelete={onDelete} onCancel={() => setSelectedInputExample(null)} />
</SlideOverWideEmpty>
<SlideOverWideEmpty open={addingInputExample} title={i18nT("workflows.inputExamples.addTitle")} onClose={() => setAddingInputExample(false)} className="sm:max-w-sm">
<WorkflowInputExampleForm item={null} onSave={onCreate} onCancel={() => setAddingInputExample(false)} />
</SlideOverWideEmpty>
</div>
);
}
function WorkflowInputExampleForm({
item,
onSave,
onCancel,
onDelete,
}: {
item: WorkflowInputExampleDto | null;
onSave: (item: WorkflowInputExampleDto) => void;
onCancel: () => void;
onDelete?: (item: WorkflowInputExampleDto) => void;
}) {
const [title, setTitle] = useState(item?.title || "");
const [input, setInput] = useState(
item?.input
? JSON.stringify(item.input, null, 2)
: JSON.stringify(
{
sample: "value",
},
null,
2
)
);
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, []);
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
let inputJson: { [key: string]: any } = {};
try {
inputJson = JSON.parse(input);
} catch (e) {
toast.error(i18nT("workflows.errors.invalidJson"));
return;
}
onSave({
id: item?.id || undefined,
title,
input: inputJson,
});
}
return (
<Form onSubmit={onSubmit}>
<div className="space-y-1">
<InputText ref={mainInput} title={i18nT("workflows.inputExamples.fields.title")} value={title} setValue={setTitle} required />
<div className="overflow-hidden">
<label className="text-foreground/80 mb-1 block text-sm font-medium">{i18nT("workflows.inputExamples.fields.input")}</label>
<MonacoEditor theme="vs-dark" className=" h-80" value={input} onChange={setInput} language="json" tabSize={2} hideLineNumbers />
</div>
</div>
<div className="border-border mt-2 border-t pt-2">
<div className="flex justify-between space-x-2">
<div>
{onDelete && item && (
<ButtonSecondary destructive onClick={() => onDelete(item)}>{i18nT("shared.delete")}</ButtonSecondary>
)}
</div>
<div className="flex items-center space-x-2">
<ButtonSecondary type="button" onClick={onCancel}>{i18nT("shared.cancel")}</ButtonSecondary>
<ButtonPrimary type="submit">{i18nT("shared.save")}</ButtonPrimary>
</div>
</div>
</div>
</Form>
);
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
const workflowCallSites = [
"apps/web/app/components/ui/input/InputText.tsx",
"apps/web/app/modules/workflowEngine/components/workflows/WorkflowBlockEditor.tsx",
"apps/web/app/modules/workflowEngine/components/workflows/WorkflowBlockExecution.tsx",
"apps/web/app/modules/workflowEngine/components/workflows/WorkflowExecutionsSidebar.tsx",
];
describe("Workflow Monaco lazy boundary", () => {
it("routes generic input and workflow panels through the lazy Monaco boundary", () => {
for (const path of workflowCallSites) {
const source = readRepoFile(path);
expect(source, `${path} must not import Monaco directly with double quotes`).not.toContain('from "@monaco-editor/react"');
expect(source, `${path} must not import Monaco directly with single quotes`).not.toContain("from '@monaco-editor/react'");
}
});
it("keeps the client editor as the Monaco package import owner behind the lazy boundary", () => {
const boundarySource = readRepoFile("apps/web/app/components/editors/MonacoEditor.tsx");
const clientSource = readRepoFile("apps/web/app/components/editors/MonacoEditor.client.tsx");
expect(boundarySource).toContain('lazy(() => import("./MonacoEditor.client"))');
expect(boundarySource).not.toContain('@monaco-editor/react');
expect(clientSource).toContain('from "@monaco-editor/react"');
});
});
@@ -0,0 +1,47 @@
import { Menu } from "@headlessui/react";
import { t as i18nT } from "i18next";
import clsx from "clsx";
import DropdownOptions from "~/components/ui/dropdowns/DropdownOptions";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowInputExampleDto } from "~/modules/workflowEngine/dtos/WorkflowInputExampleDto";
export default function WorkflowInputExamplesDropdown({
workflow,
onSelected,
}: {
workflow: WorkflowDto;
onSelected: (inputExample: WorkflowInputExampleDto) => void;
}) {
return (
<DropdownOptions
width="w-80"
buttonDataUiId="ui.workflows.inputExamples.dropdown.button"
button={
<div data-ui-id="ui.workflows.inputExamples.dropdown.trigger" className="hover:bg-secondary border-border bg-background rounded-md border px-2 py-1 text-sm font-medium">
{i18nT("workflows.inputExamples.pick")}
</div>
}
options={
<div data-ui-id="ui.workflows.inputExamples.dropdown.menu">
{workflow.inputExamples.map((inputExample) => {
return (
<Menu.Item key={inputExample.id}>
{({ active }) => (
<button
type="button"
data-ui-id="ui.workflows.inputExamples.dropdown.item"
data-workflow-input-example-id={inputExample.id}
onClick={() => onSelected(inputExample)}
className={clsx("w-full truncate text-left", active ? "text-foreground bg-secondary/90" : "text-foreground/80", "block px-4 py-2 text-sm")}
>
{inputExample.title}
</button>
)}
</Menu.Item>
);
})}
</div>
}
></DropdownOptions>
);
}
@@ -0,0 +1,102 @@
import { Menu } from "@headlessui/react";
import { Link, useParams } from "react-router";
import clsx from "clsx";
import { t as i18nT } from "i18next";
import DropdownOptions from "~/components/ui/dropdowns/DropdownOptions";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import UrlUtils from "~/utils/app/UrlUtils";
export default function WorkflowRunDropdown({ workflow }: { workflow: WorkflowDto }) {
const params = useParams();
return (
<DropdownOptions
disabled={!WorkflowUtils.canRun(workflow)}
buttonDataUiId="ui.workflows.run.dropdown.button"
button={
<div
data-ui-id="ui.workflows.run.dropdown.trigger"
className={clsx(
"text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300",
WorkflowUtils.canRun(workflow) ? "bg-background hover:bg-secondary/90" : "bg-secondary/90 cursor-not-allowed opacity-50"
)}
>
<div>
{i18nT("workflows.actions.run")}
{!WorkflowUtils.canRun(workflow) && <span className="text-xs opacity-50"> {i18nT("workflows.statusHints.notLiveInline")}</span>}
</div>
</div>
}
options={
<div data-ui-id="ui.workflows.run.dropdown.menu">
{[
{
id: "manual",
name: i18nT("workflows.run.modes.manual"),
value: UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}/run/manual`),
disabled: !WorkflowUtils.canRun(workflow),
},
{
id: "api",
name: i18nT("workflows.run.modes.api"),
value: UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}/run/api`),
disabled: !WorkflowUtils.canRun(workflow),
},
{
id: "stream",
name: i18nT("workflows.run.modes.stream"),
value: UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}/run/stream`),
disabled: !WorkflowUtils.canRun(workflow),
},
].map((option) => {
return (
<Menu.Item key={option.name}>
{({ active, close }) =>
// Return a single element, NOT a Fragment: Menu.Item forwards its
// menuitem props (id/ref/role/tabIndex/onClick/onFocus/…) onto the
// rendered child, and HeadlessUI throws "Passing props on Fragment"
// if that child is a Fragment — which crashed the whole builder into
// the 500 error boundary the moment the run menu opened.
option.disabled ? (
<button
type="button"
data-ui-id={`ui.workflows.run.dropdown.${option.id}`}
data-workflow-run-mode={option.id}
onClick={() => {
close();
}}
className={clsx(
"hover:bg-secondary/90 w-full truncate text-left",
active ? "text-foreground bg-secondary/90" : "text-foreground/80",
"block px-4 py-2 text-sm"
)}
>
{option.name}
</button>
) : (
<Link
to={option.value}
type="button"
data-ui-id={`ui.workflows.run.dropdown.${option.id}`}
data-workflow-run-mode={option.id}
onClick={() => {
close();
}}
className={clsx(
"hover:bg-secondary/90 w-full truncate text-left",
active ? "text-foreground bg-secondary/90" : "text-foreground/80",
"block px-4 py-2 text-sm"
)}
>
{option.name}
</Link>
)
}
</Menu.Item>
);
})}
</div>
}
></DropdownOptions>
);
}
@@ -0,0 +1,17 @@
import { AlertTriangleIcon } from "~/components/ui/icons/vendor";
export default function WorkflowBlockErrors({ errors }: { errors: string[] }) {
return (
<div className="space-y-1 text-sm">
{errors.map((error) => {
return (
<div key={error} className="flex items-center space-x-2">
<AlertTriangleIcon className="h-4 w-4 text-red-600" />
<div className="text-muted-foreground">{error}</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,156 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import WorkflowStreamProgress from "./WorkflowStreamProgress";
const submitMock = vi.fn();
let navigationState: "idle" | "submitting" = "idle";
let eventSourceData: string | null = null;
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
Form: ({ children, ...props }: React.ComponentProps<"form">) => <form {...props}>{children}</form>,
useNavigation: () => ({ state: navigationState }),
useSubmit: () => submitMock,
};
});
vi.mock("i18next", () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
"shared.submit": "Submit",
"workflows.executionStatus.running": "Running",
"workflows.executionStatus.error": "Error",
"workflows.executionStatus.success": "Success",
"workflows.executionStatus.unknown": "Unknown",
"workflows.executionErrors.variableNotArray": "Variable {{name}} is not an array.",
"workflows.blocks.variable.name": "Variable",
};
return (translations[key] ?? key).replace(/\{\{(\w+)\}\}/gu, (_match, name) => String(options?.[name] ?? ""));
},
}));
vi.mock("~/utils/stream/use-event-source", () => ({
useEventSource: () => eventSourceData,
}));
function waitingExecutionJson(overrides: Record<string, unknown> = {}) {
return JSON.stringify({
id: "execution-waiting-1",
createdAt: "2026-05-28T00:00:00.000Z",
status: "waitingBlock",
input: {},
output: null,
duration: null,
endedAt: null,
error: null,
blockRuns: [],
executionAlerts: [],
waitingBlock: {
id: "wait-1",
index: 0,
type: "waitForInput",
variableName: "approval",
description: "Approval",
input: { title: "Approve run", placeholder: "Type approval" },
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
},
...overrides,
});
}
describe("WorkflowStreamProgress", () => {
beforeEach(() => {
submitMock.mockReset();
consoleErrorSpy.mockClear();
navigationState = "idle";
eventSourceData = waitingExecutionJson();
});
it("renders waiting-block continuation controls from stream events", async () => {
render(<WorkflowStreamProgress streamUrl="/stream/execution-waiting-1" onCompleted={vi.fn()} />);
expect(await screen.findByLabelText("Approve run")).toHaveAttribute("data-ui-id", "ui.workflows.run.stream.waitingInput.input");
expect(screen.getByLabelText("Approve run")).toHaveAttribute("placeholder", "Type approval");
expect(document.querySelector('[data-ui-id="ui.workflows.run.stream.progress"]')).toBeInTheDocument();
expect(document.querySelector('[data-ui-id="ui.workflows.run.stream.waitingInput.form"]')).toBeInTheDocument();
});
it("submits raw continuation input with execution identity", async () => {
render(<WorkflowStreamProgress streamUrl="/stream/execution-waiting-1" onCompleted={vi.fn()} />);
const input = await screen.findByLabelText("Approve run");
fireEvent.change(input, { target: { value: "approved" } });
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(submitMock).toHaveBeenCalledOnce();
const form = submitMock.mock.calls[0]?.[0] as FormData;
expect(form.get("action")).toBe("continue-execution");
expect(form.get("executionId")).toBe("execution-waiting-1");
expect(form.get("input")).toBe("approved");
expect(submitMock.mock.calls[0]?.[1]).toEqual({ method: "post" });
expect(input).toHaveValue("");
});
it("logs malformed stream events without rendering continuation controls", () => {
eventSourceData = "{";
render(<WorkflowStreamProgress streamUrl="/stream/bad" onCompleted={vi.fn()} />);
expect(consoleErrorSpy).toHaveBeenCalled();
expect(document.querySelector('[data-ui-id="ui.workflows.run.stream.waitingInput.form"]')).not.toBeInTheDocument();
expect(submitMock).not.toHaveBeenCalled();
});
it("renders known runtime errors with localized display text", () => {
eventSourceData = waitingExecutionJson({
blockRuns: [
{
id: "block-run-1",
workflowBlockId: "block-1",
fromWorkflowBlockId: null,
status: "error",
startedAt: "2026-05-28T00:00:00.000Z",
endedAt: "2026-05-28T00:00:01.000Z",
duration: 1000,
input: null,
output: null,
error: 'Variable "orders" is not an array',
workflowBlock: {
id: "block-1",
index: 0,
type: "variable",
variableName: "orders",
description: "",
input: {},
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
},
},
],
});
render(<WorkflowStreamProgress streamUrl="/stream/execution-waiting-1" onCompleted={vi.fn()} />);
expect(screen.getByText("Variable orders is not an array.")).toBeInTheDocument();
expect(screen.queryByText('Variable "orders" is not an array')).not.toBeInTheDocument();
});
it("marks the continuation input disabled while a submission is pending", async () => {
navigationState = "submitting";
render(<WorkflowStreamProgress streamUrl="/stream/execution-waiting-1" onCompleted={vi.fn()} />);
expect(await screen.findByLabelText("Approve run")).toBeDisabled();
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
});
});
@@ -0,0 +1,196 @@
import { Form, useNavigation, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import clsx from "clsx";
import { useEffect, useId, useRef, useState } from "react";
import { Colors } from "~/application/enums/shared/Colors";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import { getWorkflowBlockName, WorkflowBlockTypes } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import { formatWorkflowExecutionErrorText } from "~/modules/workflowEngine/utils/workflowExecutionErrorText";
import { useEventSource } from "~/utils/stream/use-event-source";
import { appendCSRFToken } from "~/utils/csrf";
import { SendIcon } from "~/components/ui/icons/vendor";
interface Props {
streamUrl: string;
onCompleted: () => void;
}
export default function WorkflowStreamProgress({ streamUrl, onCompleted }: Props) {
const submit = useSubmit();
const navigation = useNavigation();
const lastBlockExecution = useEventSource(streamUrl);
const isLoading = navigation.state === "submitting";
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
const [waitingBlockInput, setWaitingBlockInput] = useState("");
const waitingBlockInputId = useId();
const waitingForInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
waitingForInputRef.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, [lastBlockExecution]);
useEffect(() => {
try {
if (lastBlockExecution) {
let execution = JSON.parse(lastBlockExecution) as WorkflowExecutionDto;
if (execution.status === "running" || execution.status === "waitingBlock") {
onCompleted();
}
setExecution(execution);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}, [lastBlockExecution, onCompleted]);
// useEffect(() => {
// setBlockExecutions((blockExecutions) => {
// if (!lastBlockExecution) {
// return blockExecutions;
// }
// const data = JSON.parse(lastBlockExecution) as WorkflowStreamProgressDto;
// const existing = blockExecutions.find((item) => item.blockExecution.id === data.blockExecution.id);
// if (existing) {
// return blockExecutions;
// }
// if (data.result && data.block?.type === "alertUser") {
// let { type, message } = data.result.output;
// if (message) {
// if (type === "error") {
// toast.error(message, {
// position: "bottom-right",
// duration: 10000,
// });
// } else {
// toast.success(message, {
// position: "bottom-right",
// duration: 10000,
// });
// }
// }
// }
// if (data.workflowCompleted) {
// onCompleted();
// }
// return [...blockExecutions, data];
// });
// }, [lastBlockExecution, onCompleted]);
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
appendCSRFToken(form);
form.set("action", "continue-execution");
form.set("executionId", execution?.id ?? "");
submit(form, {
method: "post",
});
setWaitingBlockInput("");
}
return (
<div data-ui-id="ui.workflows.run.stream.progress">
<div className="space-y-4">
{execution?.blockRuns.map((blockRun, index) => {
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === blockRun.workflowBlock.type);
if (!workflowBlock) {
return <div key={index}>{i18nT("workflows.blocks.node.unknownBlockType", { value: blockRun.workflowBlock.type })}</div>;
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
return (
<div key={index} className="border-border cursor-pointer overflow-hidden rounded-md border bg-white p-2 select-none hover:bg-gray-50">
<div className="flex justify-between space-x-2">
<div className="flex items-center space-x-1">
<div className="flex items-center space-x-2 truncate">
<workflowBlock.icon aria-hidden="true" className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<div className="text-muted-foreground flex-shrink-0 truncate text-xs font-medium">[{workflowBlockName}]</div>
{blockRun.error ? (
<div className="truncate text-xs text-red-600">{formatWorkflowExecutionErrorText(blockRun.error, i18nT)}</div>
) : blockRun.output ? (
<div className="text-muted-foreground truncate text-xs">{JSON.stringify({ output: blockRun.output })}</div>
) : null}
</div>
</div>
<div className="flex-shrink-0">
{blockRun.workflowBlock.type === "if" ? (
<div>
{blockRun.output?.condition ? (
<SimpleBadge title={i18nT("workflows.boolean.true")} color={Colors.BLUE} />
) : (
<SimpleBadge title={i18nT("workflows.boolean.false")} color={Colors.ORANGE} />
)}
</div>
) : blockRun.workflowBlock.type === "switch" ? (
<SimpleBadge title={blockRun.output?.condition?.toString()} color={Colors.BLUE} />
) : (
<div>
{blockRun.status === "running" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.running")} color={Colors.YELLOW} />
) : blockRun.status === "error" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.error")} color={Colors.RED} />
) : blockRun.status === "success" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.success")} color={Colors.GREEN} />
) : (
<SimpleBadge title={i18nT("workflows.executionStatus.unknown")} color={Colors.GRAY} />
)}
</div>
)}
</div>
</div>
</div>
);
})}
{execution?.waitingBlock && (
<Form data-ui-id="ui.workflows.run.stream.waitingInput.form" onSubmit={onSubmitWaitingBlock}>
<div className="space-y-1">
{execution.waitingBlock.input.title && (
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
{execution.waitingBlock.input.title}
</label>
)}
<div className="relative flex items-center">
<input
ref={waitingForInputRef}
data-ui-id="ui.workflows.run.stream.waitingInput.input"
autoFocus
type="text"
name="input"
id={waitingBlockInputId}
autoComplete="off"
value={waitingBlockInput}
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
className={clsx(
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
)}
placeholder={execution.waitingBlock.input.placeholder}
required
disabled={isLoading}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
<button
type="submit"
data-ui-id="ui.workflows.run.stream.waitingInput.submit"
disabled={isLoading}
aria-label={i18nT("shared.submit")}
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
>
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
</button>
</div>
</div>
</div>
</Form>
)}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { createContext } from "react";
const WorkflowContext = createContext<{
isReadOnly: boolean;
onEdgeDelete: (edgeId: string) => void;
onNodeDelete: (nodeId: string) => void;
isNodeSelected: (nodeId: string) => boolean;
incomingBlockIds?: ReadonlySet<string>;
}>({
isReadOnly: false,
onEdgeDelete: (edgeId) => {},
onNodeDelete: (nodeId) => {},
isNodeSelected: (nodeId) => false,
});
export default WorkflowContext;
@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const source = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "workflowBlocks.db.server.ts"), "utf8");
describe("workflow block tenant scope", () => {
it("verifies parent workflow tenant before creating blocks", () => {
expect(source).toContain("tenantId: string | null");
expect(source).toContain("db.workflow.findFirstOrThrow");
expect(source).toContain("where: { id: data.workflowId, tenantId: data.tenantId }");
});
it("updates blocks through tenant-scoped parent workflow filters", () => {
expect(source).toContain("updateWorkflowBlock(\n scope: { id: string; workflowId: string; tenantId: string | null }");
expect(source).toContain("db.workflowBlock.updateMany");
expect(source).toContain("workflow: { tenantId: scope.tenantId }");
expect(source).toContain("db.workflowBlock.findFirstOrThrow");
expect(source).not.toContain("db.workflowBlock.update({");
expect(source).not.toContain("where: { id: scope.id, workflowId: scope.workflowId },");
});
});
@@ -0,0 +1,88 @@
import { WorkflowBlock, WorkflowBlockCondition, WorkflowBlockConditionGroup, WorkflowBlockToBlock } from "@toolctrl/db/prisma-browser";
import { db } from "~/utils/db.server";
export type WorkflowBlockWithDetails = WorkflowBlock & {
toBlocks: (WorkflowBlockToBlock & {
toBlock: WorkflowBlock;
})[];
conditionsGroups: (WorkflowBlockConditionGroup & {
conditions: WorkflowBlockCondition[];
})[];
};
export async function getWorkflowBlockTypes(workflowId: string) {
const workflowBlocks = await db.workflowBlock.findMany({
where: {
workflowId,
},
select: { type: true },
});
return workflowBlocks.map((block) => block.type);
}
export async function createWorkflowBlock(data: {
workflowId: string;
tenantId: string | null;
type: string;
input: string;
description: string;
isTrigger: boolean;
isBlock: boolean;
// positionX: number;
// positionY: number;
}) {
await db.workflow.findFirstOrThrow({
where: { id: data.workflowId, tenantId: data.tenantId },
select: { id: true },
});
return await db.workflowBlock.create({
data: {
workflowId: data.workflowId,
type: data.type,
description: data.description,
isTrigger: data.isTrigger,
isBlock: data.isBlock,
input: data.input,
// positionX: data.positionX,
// positionY: data.positionY,
},
});
}
export async function updateWorkflowBlock(
scope: { id: string; workflowId: string; tenantId: string | null },
data: {
type?: string;
input?: string;
description?: string;
isTrigger?: boolean;
isBlock?: boolean;
}
) {
const updated = await db.workflowBlock.updateMany({
where: {
id: scope.id,
workflowId: scope.workflowId,
workflow: { tenantId: scope.tenantId },
},
data: {
type: data.type,
description: data.description,
input: data.input,
isTrigger: data.isTrigger,
isBlock: data.isBlock,
// positionX: data.positionX,
// positionY: data.positionY,
},
});
if (updated.count !== 1) {
throw new Error(`Workflow block ${scope.id} not found`);
}
return await db.workflowBlock.findFirstOrThrow({
where: {
id: scope.id,
workflowId: scope.workflowId,
workflow: { tenantId: scope.tenantId },
},
});
}
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type WorkflowCredentialDbTestMocks = {
findFirstOrThrow: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
deleteMany: ReturnType<typeof vi.fn>;
};
function getDbMocks(): WorkflowCredentialDbTestMocks {
const store = globalThis as typeof globalThis & { __workflowCredentialDbTestMocks__?: WorkflowCredentialDbTestMocks };
store.__workflowCredentialDbTestMocks__ ??= {
findFirstOrThrow: vi.fn(),
updateMany: vi.fn(),
deleteMany: vi.fn(),
};
return store.__workflowCredentialDbTestMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
workflowCredential: {
findFirstOrThrow: getDbMocks().findFirstOrThrow,
updateMany: getDbMocks().updateMany,
deleteMany: getDbMocks().deleteMany,
},
},
}));
import { deleteWorkflowCredential, updateWorkflowCredential } from "./workflowCredentials.db.server";
const dbMocks = getDbMocks();
describe("workflow credential mutations", () => {
beforeEach(() => {
vi.clearAllMocks();
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "credential_1", tenantId: "tenant_1", name: "token", value: "next" });
dbMocks.updateMany.mockResolvedValue({ count: 1 });
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
});
it("updates credentials with tenant scope", async () => {
await expect(updateWorkflowCredential("credential_1", { tenantId: "tenant_1", value: "next" })).resolves.toEqual({
id: "credential_1",
tenantId: "tenant_1",
name: "token",
value: "next",
});
expect(dbMocks.updateMany).toHaveBeenCalledWith({
where: { id: "credential_1", tenantId: "tenant_1" },
data: { value: "next" },
});
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "credential_1", tenantId: "tenant_1" } });
});
it("deletes credentials with tenant scope", async () => {
await deleteWorkflowCredential("credential_1", { tenantId: "tenant_1" });
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
where: { id: "credential_1", tenantId: "tenant_1" },
});
});
it("throws when scoped updates or deletes miss", async () => {
dbMocks.updateMany.mockResolvedValueOnce({ count: 0 });
dbMocks.deleteMany.mockResolvedValueOnce({ count: 0 });
await expect(updateWorkflowCredential("missing", { tenantId: "tenant_1", value: "next" })).rejects.toThrow(
"Workflow credential missing not found"
);
await expect(deleteWorkflowCredential("missing", { tenantId: "tenant_1" })).rejects.toThrow("Workflow credential missing not found");
});
});
@@ -0,0 +1,77 @@
import { db } from "~/utils/db.server";
export async function getAllWorkflowCredentials({ tenantId }: { tenantId: string | null }) {
return await db.workflowCredential.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
});
}
export async function getWorkflowCredentialById(id: string, { tenantId }: { tenantId: string | null }) {
return await db.workflowCredential
.findFirstOrThrow({
where: { id, tenantId },
})
.catch(() => {
return null;
});
}
export async function getWorkflowCredentialByName(name: string, { tenantId }: { tenantId: string | null }) {
return await db.workflowCredential
.findFirstOrThrow({
where: { name, tenantId },
})
.catch(() => {
return null;
});
}
export async function createWorkflowCredential({
tenantId,
createdByUserId,
name,
value,
}: {
tenantId: string | null;
createdByUserId: string | null;
name: string;
value: string;
}) {
return await db.workflowCredential.create({
data: {
tenantId,
createdByUserId,
name,
value,
},
});
}
export async function updateWorkflowCredential(id: string, { tenantId, value }: { tenantId: string | null; value?: string }) {
const updated = await db.workflowCredential.updateMany({
where: {
id,
tenantId,
},
data: {
value,
},
});
if (updated.count !== 1) {
throw new Error(`Workflow credential ${id} not found`);
}
return await getWorkflowCredentialById(id, { tenantId });
}
export async function deleteWorkflowCredential(id: string, { tenantId }: { tenantId: string | null }) {
const deleted = await db.workflowCredential.deleteMany({
where: {
id,
tenantId,
},
});
if (deleted.count !== 1) {
throw new Error(`Workflow credential ${id} not found`);
}
}
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type WorkflowExecutionDbTestMocks = {
findMany: ReturnType<typeof vi.fn>;
findFirstOrThrow: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
function getDbMocks(): WorkflowExecutionDbTestMocks {
const store = globalThis as typeof globalThis & { __workflowExecutionDbTestMocks__?: WorkflowExecutionDbTestMocks };
store.__workflowExecutionDbTestMocks__ ??= {
findMany: vi.fn(),
findFirstOrThrow: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
};
return store.__workflowExecutionDbTestMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
workflowExecution: {
findMany: getDbMocks().findMany,
findFirstOrThrow: getDbMocks().findFirstOrThrow,
updateMany: getDbMocks().updateMany,
count: getDbMocks().count,
},
},
}));
import { getAllWorkflowExecutions, getWorkflowExecutions, updateWorkflowExecution } from "./workflowExecutions.db.server";
const dbMocks = getDbMocks();
describe("workflow execution filters", () => {
beforeEach(() => {
vi.clearAllMocks();
dbMocks.findMany.mockResolvedValue([]);
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "execution_1", tenantId: "tenant_1" });
dbMocks.updateMany.mockResolvedValue({ count: 1 });
dbMocks.count.mockResolvedValue(0);
});
it("maps type filters to workflow execution type instead of workflow id", async () => {
await getAllWorkflowExecutions({
tenantId: "tenant_1",
pagination: { page: 1, pageSize: 25 },
filters: {
query: "",
properties: [
{ name: "workflowId", title: "Workflow", value: "workflow_1" },
{ name: "status", title: "Status", value: "running" },
{ name: "type", title: "Type", value: "api" },
],
},
});
expect(dbMocks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
tenantId: "tenant_1",
appliesToAllTenants: false,
workflowId: "workflow_1",
status: "running",
type: "api",
},
}),
);
});
it("uses a lightweight list projection for workflow execution indexes", async () => {
await getAllWorkflowExecutions({
tenantId: "tenant_1",
pagination: { page: 2, pageSize: 10 },
filters: {
query: "",
properties: [],
},
});
expect(dbMocks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: 10,
skip: 10,
select: expect.objectContaining({
id: true,
workflowId: true,
workflow: { select: { id: true, name: true } },
tenant: { select: { id: true, name: true, slug: true } },
_count: { select: { blockRuns: true } },
blockRuns: {
take: 1,
orderBy: { startedAt: "asc" },
select: {
workflowBlock: {
select: { type: true },
},
},
},
}),
}),
);
const query = dbMocks.findMany.mock.calls[0][0];
expect(query).not.toHaveProperty("include");
expect(query.select).not.toHaveProperty("input");
expect(query.select).not.toHaveProperty("output");
expect(query.select).not.toHaveProperty("waitingBlock");
expect(dbMocks.count).toHaveBeenCalledWith(
expect.objectContaining({
where: {
tenantId: "tenant_1",
appliesToAllTenants: false,
},
}),
);
});
it("updates workflow executions only within the caller tenant scope", async () => {
await updateWorkflowExecution(
"execution_1",
{ tenantId: "tenant_1" },
{
status: "success",
output: "{\"ok\":true}",
duration: 12,
error: null,
waitingBlockId: null,
},
);
expect(dbMocks.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "execution_1", tenantId: "tenant_1" },
data: expect.objectContaining({
status: "success",
output: "{\"ok\":true}",
duration: 12,
error: null,
waitingBlockId: null,
}),
}),
);
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "execution_1", tenantId: "tenant_1" },
}),
);
});
it("keeps global workflow execution lists scoped to tenantId null", async () => {
await getWorkflowExecutions({ workflowId: "workflow_global" }, { tenantId: null });
expect(dbMocks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { workflowId: "workflow_global", tenantId: null },
}),
);
});
});
@@ -0,0 +1,241 @@
import { Prisma, WorkflowBlockExecution, WorkflowExecution } from "@toolctrl/db/prisma-browser";
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import { db } from "~/utils/db.server";
import { WorkflowBlockWithDetails } from "./workflowBlocks.db.server";
import { WorkflowStatus } from "../dtos/WorkflowStatus";
export type WorkflowExecutionWithDetails = WorkflowExecution & {
workflow: { id: string; name: string };
tenant: { id: string; name: string; slug: string } | null;
waitingBlock: WorkflowBlockWithDetails | null;
blockRuns: (WorkflowBlockExecution & {
workflowBlock: {
type: string;
description: string;
};
})[];
};
export type WorkflowExecutionListItem = Pick<WorkflowExecution, "id" | "workflowId" | "type" | "status" | "createdAt" | "endedAt" | "error"> & {
workflow: { id: string; name: string };
tenant: { id: string; name: string; slug: string } | null;
_count: { blockRuns: number };
blockRuns: {
workflowBlock: {
type: string;
};
}[];
};
type WorkflowExecutionClient = Pick<typeof db, "workflowExecution">;
export async function getAllWorkflowExecutions({
tenantId,
pagination,
filters,
}: {
tenantId: string | null;
pagination: { page: number; pageSize: number };
filters: FiltersDto;
}): Promise<{ items: WorkflowExecutionListItem[]; pagination: PaginationDto }> {
let where: Prisma.WorkflowExecutionWhereInput = {};
if (tenantId === null) {
where = {
OR: [{ appliesToAllTenants: true }, { tenantId: null }],
};
} else {
where = {
tenantId,
appliesToAllTenants: false,
};
}
const workflowId = filters?.properties.find((f) => f.name === "workflowId")?.value ?? filters?.query ?? "";
const status = filters?.properties.find((f) => f.name === "status")?.value ?? filters?.query ?? "";
const type = filters?.properties.find((f) => f.name === "type")?.value ?? filters?.query ?? "";
if (workflowId) {
where.workflowId = workflowId;
}
if (status) {
where.status = status;
}
if (type) {
where.type = type;
}
const [items, totalItems] = await Promise.all([
db.workflowExecution.findMany({
take: pagination.pageSize,
skip: pagination.pageSize * (pagination.page - 1),
where,
select: {
id: true,
workflowId: true,
type: true,
status: true,
createdAt: true,
endedAt: true,
error: true,
workflow: { select: { id: true, name: true } },
tenant: { select: { id: true, name: true, slug: true } },
_count: { select: { blockRuns: true } },
blockRuns: {
take: 1,
orderBy: { startedAt: "asc" },
select: {
workflowBlock: {
select: { type: true },
},
},
},
},
orderBy: { createdAt: "desc" },
}),
db.workflowExecution.count({
where,
}),
]);
return {
items,
pagination: {
page: pagination.page,
pageSize: pagination.pageSize,
totalItems,
totalPages: Math.ceil(totalItems / pagination.pageSize),
},
};
}
export async function getWorkflowExecutions(
{ workflowId }: { workflowId: string },
session: { tenantId: string | null }
): Promise<WorkflowExecutionWithDetails[]> {
const where: Prisma.WorkflowExecutionWhereInput = {
workflowId,
tenantId: session.tenantId,
};
return db.workflowExecution.findMany({
where,
include: {
workflow: { select: { id: true, name: true } },
tenant: { select: { id: true, name: true, slug: true } },
waitingBlock: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
},
},
blockRuns: {
orderBy: { startedAt: "asc" },
include: {
workflowBlock: {
select: { type: true, description: true },
},
},
},
},
orderBy: { createdAt: "desc" },
});
}
export async function getWorkflowExecution(
id: string,
session: { tenantId: string | null },
client: WorkflowExecutionClient = db
): Promise<WorkflowExecutionWithDetails | null> {
// findFirst (not findFirstOrThrow): the return type is `| null` and every
// caller null-checks. A stale/deleted executionId (e.g. a deep link to an
// execution removed by a re-seed) must resolve to null → graceful 404, not an
// unhandled throw that renders a 500 builder/executions page.
return client.workflowExecution
.findFirst({
where: {
id,
tenantId: session.tenantId,
},
include: {
workflow: { select: { id: true, name: true } },
tenant: { select: { id: true, name: true, slug: true } },
waitingBlock: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
},
},
blockRuns: {
orderBy: { startedAt: "asc" },
include: {
workflowBlock: {
select: { type: true, description: true },
},
},
},
},
orderBy: { createdAt: "desc" },
})
.catch(() => null);
}
export async function countWorkflowExecutions({ tenantId }: { tenantId: string | null }): Promise<number> {
let where: Prisma.WorkflowExecutionWhereInput = {};
if (tenantId === null) {
where = {
OR: [{ appliesToAllTenants: true }, { tenantId: null }],
};
} else {
where = {
tenantId,
appliesToAllTenants: false,
};
}
return await db.workflowExecution.count({
where,
});
}
export async function updateWorkflowExecution(
id: string,
session: { tenantId: string | null },
{
error,
status,
output,
duration,
waitingBlockId,
}: {
error: string | null;
status: WorkflowStatus;
output: string;
duration: number;
waitingBlockId: string | null;
},
client: WorkflowExecutionClient = db
): Promise<WorkflowExecutionWithDetails> {
const updated = await client.workflowExecution.updateMany({
where: { id, tenantId: session.tenantId },
data: {
status: error ? "error" : status,
output,
duration: Math.round(duration),
endedAt: new Date(),
error,
waitingBlockId,
},
});
if (updated.count === 0) {
throw new Error("Workflow execution not found");
}
const execution = await getWorkflowExecution(id, session, client);
if (!execution) {
throw new Error("Workflow execution not found");
}
return execution;
}
export async function deleteWorkflowExecution(id: string, session: { tenantId: string | null }) {
return await db.workflowExecution.deleteMany({
where: {
id,
tenantId: session.tenantId,
},
});
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type WorkflowVariableDbTestMocks = {
findFirstOrThrow: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
deleteMany: ReturnType<typeof vi.fn>;
};
function getDbMocks(): WorkflowVariableDbTestMocks {
const store = globalThis as typeof globalThis & { __workflowVariableDbTestMocks__?: WorkflowVariableDbTestMocks };
store.__workflowVariableDbTestMocks__ ??= {
findFirstOrThrow: vi.fn(),
updateMany: vi.fn(),
deleteMany: vi.fn(),
};
return store.__workflowVariableDbTestMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
workflowVariable: {
findFirstOrThrow: getDbMocks().findFirstOrThrow,
updateMany: getDbMocks().updateMany,
deleteMany: getDbMocks().deleteMany,
},
},
}));
import { deleteWorkflowVariable, updateWorkflowVariable } from "./workflowVariable.db.server";
const dbMocks = getDbMocks();
describe("workflow variable mutations", () => {
beforeEach(() => {
vi.clearAllMocks();
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "variable_1", tenantId: "tenant_1", name: "token", value: "next" });
dbMocks.updateMany.mockResolvedValue({ count: 1 });
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
});
it("updates variables with tenant scope", async () => {
await expect(updateWorkflowVariable("variable_1", { tenantId: "tenant_1", value: "next" })).resolves.toEqual({
id: "variable_1",
tenantId: "tenant_1",
name: "token",
value: "next",
});
expect(dbMocks.updateMany).toHaveBeenCalledWith({
where: { id: "variable_1", tenantId: "tenant_1" },
data: { name: undefined, value: "next" },
});
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "variable_1", tenantId: "tenant_1" } });
});
it("deletes variables with tenant scope", async () => {
await deleteWorkflowVariable("variable_1", { tenantId: "tenant_1" });
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
where: { id: "variable_1", tenantId: "tenant_1" },
});
});
it("throws when scoped updates miss", async () => {
dbMocks.updateMany.mockResolvedValue({ count: 0 });
await expect(updateWorkflowVariable("missing", { tenantId: "tenant_1", value: "next" })).rejects.toThrow("Workflow variable missing not found");
});
});
@@ -0,0 +1,78 @@
import { db } from "~/utils/db.server";
export async function getAllWorkflowVariables({ tenantId }: { tenantId: string | null }) {
return await db.workflowVariable.findMany({
where: { tenantId },
orderBy: { updatedAt: "desc" },
});
}
export async function getWorkflowVariableById(id: string, { tenantId }: { tenantId: string | null }) {
return await db.workflowVariable
.findFirstOrThrow({
where: { id, tenantId },
})
.catch(() => {
return null;
});
}
export async function getWorkflowVariableByName(name: string, { tenantId }: { tenantId: string | null }) {
return await db.workflowVariable
.findFirstOrThrow({
where: { name, tenantId },
})
.catch(() => {
return null;
});
}
export async function createWorkflowVariable({
tenantId,
createdByUserId,
name,
value,
}: {
tenantId: string | null;
createdByUserId: string | null;
name: string;
value: string;
}) {
return await db.workflowVariable.create({
data: {
tenantId,
createdByUserId,
name,
value,
},
});
}
export async function updateWorkflowVariable(id: string, { tenantId, name, value }: { tenantId: string | null; name?: string; value?: string }) {
const updated = await db.workflowVariable.updateMany({
where: {
id,
tenantId,
},
data: {
name,
value,
},
});
if (updated.count !== 1) {
throw new Error(`Workflow variable ${id} not found`);
}
return await getWorkflowVariableById(id, { tenantId });
}
export async function deleteWorkflowVariable(id: string, { tenantId }: { tenantId: string | null }) {
const deleted = await db.workflowVariable.deleteMany({
where: {
id,
tenantId,
},
});
if (deleted.count !== 1) {
throw new Error(`Workflow variable ${id} not found`);
}
}
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type WorkflowDbTestMocks = {
findFirstOrThrow: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
deleteMany: ReturnType<typeof vi.fn>;
};
function getDbMocks(): WorkflowDbTestMocks {
const store = globalThis as typeof globalThis & { __workflowDbTestMocks__?: WorkflowDbTestMocks };
store.__workflowDbTestMocks__ ??= {
findFirstOrThrow: vi.fn(),
updateMany: vi.fn(),
deleteMany: vi.fn(),
};
return store.__workflowDbTestMocks__;
}
vi.mock("~/utils/db.server", () => ({
db: {
workflow: {
findFirstOrThrow: getDbMocks().findFirstOrThrow,
updateMany: getDbMocks().updateMany,
deleteMany: getDbMocks().deleteMany,
},
},
}));
import { deleteWorkflow, getWorkflowByAccessibleId, updateWorkflow } from "./workflows.db.server";
const dbMocks = getDbMocks();
describe("workflow mutations", () => {
beforeEach(() => {
vi.clearAllMocks();
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "workflow_1", tenantId: "tenant_1", name: "Updated" });
dbMocks.updateMany.mockResolvedValue({ count: 1 });
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
});
it("updates workflows with tenant scope", async () => {
await expect(updateWorkflow({ id: "workflow_1", tenantId: "tenant_1" }, { name: "Updated" })).resolves.toEqual({
id: "workflow_1",
tenantId: "tenant_1",
name: "Updated",
});
expect(dbMocks.updateMany).toHaveBeenCalledWith({
where: { id: "workflow_1", tenantId: "tenant_1" },
data: {
name: "Updated",
description: undefined,
status: undefined,
appliesToAllTenants: undefined,
},
});
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "workflow_1", tenantId: "tenant_1" } });
});
it("deletes workflows with tenant scope", async () => {
await expect(deleteWorkflow({ id: "workflow_1", tenantId: "tenant_1" })).resolves.toEqual({ count: 1 });
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
where: { id: "workflow_1", tenantId: "tenant_1" },
});
});
it("throws when scoped workflow mutations miss", async () => {
dbMocks.updateMany.mockResolvedValueOnce({ count: 0 });
dbMocks.deleteMany.mockResolvedValueOnce({ count: 0 });
await expect(updateWorkflow({ id: "missing", tenantId: "tenant_1" }, { name: "Updated" })).rejects.toThrow("Workflow missing not found");
await expect(deleteWorkflow({ id: "missing", tenantId: "tenant_1" })).rejects.toThrow("Workflow missing not found");
});
it("reads execution workflows only through tenant or public global scope", async () => {
await getWorkflowByAccessibleId({ id: "workflow_1", tenantId: "tenant_1" });
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: "workflow_1",
OR: [
{ tenantId: "tenant_1" },
{
tenantId: null,
appliesToAllTenants: true,
},
],
},
})
);
});
it("reads anonymous execution workflows only from public global scope", async () => {
await getWorkflowByAccessibleId({ id: "workflow_1", tenantId: null });
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "workflow_1", tenantId: null, appliesToAllTenants: true },
})
);
});
});
@@ -0,0 +1,239 @@
import { Prisma, Workflow, WorkflowInputExample } from "@toolctrl/db/prisma-browser";
import { db } from "~/utils/db.server";
import { WorkflowBlockWithDetails } from "./workflowBlocks.db.server";
import { TenantSimple } from "~/utils/db/tenants.db.server";
import TenantModelHelper from "~/utils/helpers/models/TenantModelHelper";
export type WorkflowWithDetails = Workflow & {
tenant: TenantSimple | null;
blocks: WorkflowBlockWithDetails[];
inputExamples: WorkflowInputExample[];
_count: {
executions: number;
};
};
export async function getAllWorkflows({
tenantId,
status,
}: {
tenantId: string | null;
status?: "draft" | "live" | "archived";
}): Promise<WorkflowWithDetails[]> {
return await db.workflow.findMany({
where: { tenantId, status },
include: {
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
blocks: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
},
},
inputExamples: true,
_count: { select: { executions: true } },
},
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
});
}
export async function getAllWorkflowsAppliesToAllTenants({
tenantId,
status,
}: {
tenantId: string | null;
status?: "draft" | "live" | "archived";
}): Promise<WorkflowWithDetails[]> {
const where: Prisma.WorkflowWhereInput = { status };
if (tenantId === null) {
where.tenantId = null;
} else {
where.OR = [{ tenantId }, { appliesToAllTenants: true, tenantId: null }];
}
return await db.workflow.findMany({
where,
include: {
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
blocks: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
},
},
inputExamples: true,
_count: { select: { executions: true } },
},
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
});
}
export async function getWorkflowsIdsAndNames({ tenantId }: { tenantId: string | null }): Promise<{ id: string; name: string }[]> {
return await db.workflow.findMany({
where: { tenantId },
select: { id: true, name: true },
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
});
}
export async function getWorkflowById({ id, tenantId }: { id: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
return await db.workflow
.findFirstOrThrow({
where: { id, tenantId },
include: {
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
blocks: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: {
include: { conditions: { orderBy: { index: "asc" } } },
orderBy: { index: "asc" },
},
},
},
inputExamples: true,
_count: { select: { executions: true } },
},
})
.catch(() => {
return null;
});
}
export async function getWorkflowByAccessibleId({ id, tenantId }: { id: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
const where: Prisma.WorkflowWhereInput =
tenantId === null
? { id, tenantId: null, appliesToAllTenants: true }
: {
id,
OR: [
{ tenantId },
{
tenantId: null,
appliesToAllTenants: true,
},
],
};
return await db.workflow
.findFirstOrThrow({
where,
include: {
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
blocks: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: {
include: { conditions: { orderBy: { index: "asc" } } },
orderBy: { index: "asc" },
},
},
},
inputExamples: true,
_count: { select: { executions: true } },
},
})
.catch(() => {
return null;
});
}
export async function getWorkflow(id: string): Promise<WorkflowWithDetails | null> {
return await db.workflow
.findUnique({
where: { id },
include: {
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
blocks: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: {
include: { conditions: { orderBy: { index: "asc" } } },
orderBy: { index: "asc" },
},
},
},
inputExamples: true,
_count: { select: { executions: true } },
},
})
.catch(() => {
return null;
});
}
export async function getWorkflowByName({ name, tenantId }: { name: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
return await db.workflow
.findFirstOrThrow({
where: { name, tenantId },
include: {
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
blocks: {
include: {
toBlocks: { include: { toBlock: true } },
conditionsGroups: {
include: { conditions: { orderBy: { index: "asc" } } },
orderBy: { index: "asc" },
},
},
},
inputExamples: true,
_count: { select: { executions: true } },
},
})
.catch(() => {
return null;
});
}
export async function createWorkflow(data: { tenantId: string | null; createdByUserId: string | null; name: string; description: string }) {
return await db.workflow.create({
data: {
tenantId: data.tenantId,
name: data.name,
description: data.description,
createdByUserId: data.createdByUserId,
},
});
}
export async function updateWorkflow(
scope: { id: string; tenantId: string | null },
data: {
name?: string;
description?: string;
status?: "draft" | "live" | "archived";
appliesToAllTenants?: boolean;
}
) {
const updated = await db.workflow.updateMany({
where: { id: scope.id, tenantId: scope.tenantId },
data: {
name: data.name,
description: data.description,
status: data.status,
appliesToAllTenants: data.appliesToAllTenants,
},
});
if (updated.count !== 1) {
throw new Error(`Workflow ${scope.id} not found`);
}
return await db.workflow.findFirstOrThrow({
where: { id: scope.id, tenantId: scope.tenantId },
});
}
export async function deleteWorkflow(scope: { id: string; tenantId: string | null }) {
const deleted = await db.workflow.deleteMany({
where: { id: scope.id, tenantId: scope.tenantId },
});
if (deleted.count !== 1) {
throw new Error(`Workflow ${scope.id} not found`);
}
return deleted;
}
export async function countWorkflows({ tenantId }: { tenantId: string | null }) {
return await db.workflow.count({
where: { tenantId },
});
}
@@ -0,0 +1,16 @@
import { WorkflowBlockDto } from "./WorkflowBlockDto";
import { WorkflowDto } from "./WorkflowDto";
import type { OutboundHttpFetchOptions } from "~/utils/outboundHttp.server";
export type BlockExecutionParamsDto = {
workflow: WorkflowDto;
workflowContext: { [key: string]: any };
block: WorkflowBlockDto;
workflowExecutionId: string;
session: {
tenantId: string | null;
userId: string | null;
};
executionPath?: string[];
httpFetchOptions?: OutboundHttpFetchOptions;
};
@@ -0,0 +1,6 @@
export type BlockExecutionResultDto = {
output: { [key: string]: any } | any | null;
toBlockIds: string[];
error?: string | null;
throwsError?: boolean;
};
@@ -0,0 +1,25 @@
import { WorkflowConditionsGroupDto } from "./WorkflowConditionDtos";
import { WorkflowBlockType } from "./WorkflowBlockTypes";
export type WorkflowBlockDto = {
id: string;
index: number;
type: WorkflowBlockType;
variableName: string;
description: string;
input: { [key: string]: any };
isTrigger: boolean;
isBlock: boolean;
// position: {
// x: number;
// y: number;
// };
conditionGroups: WorkflowConditionsGroupDto[];
toBlocks: {
id: string;
// flowId: string;
// sourceHandle?: string | null;
toBlockId: string;
condition: string | null;
}[];
};
@@ -0,0 +1,18 @@
import { WorkflowBlockType } from "./WorkflowBlockTypes";
import { WorkflowStatus } from "./WorkflowStatus";
export type WorkflowBlockExecutionDto = {
id: string;
workflowBlockId: string;
fromWorkflowBlockId: string | null;
status: WorkflowStatus;
startedAt: Date;
endedAt: Date | null;
input: { [key: string]: any } | null;
output: { [key: string]: any } | null;
error: string | null;
workflowBlock: {
type: WorkflowBlockType;
description: string;
};
};
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { NATIVE_WORKFLOW_TRIGGER_SUPPORTED_BLOCK_TYPES, workflowBlockContracts } from "@toolctrl/foundation";
import { WorkflowBlockTypes } from "./WorkflowBlockTypes";
import { CORE_WORKFLOW_BLOCK_TYPES } from "../services/blocks/WorkflowBlockService";
import { PRO_WORKFLOW_BLOCK_TYPES } from "../services/blocks/WorkflowBlockProService";
import { getWorkflowBlockOptionLabel } from "./WorkflowBlockTypes";
const repoRoot = process.cwd();
describe("WorkflowBlockTypes registry contract", () => {
it("keeps registry block values unique", () => {
const values = WorkflowBlockTypes.map((block) => block.value);
expect(new Set(values).size).toBe(values.length);
});
it("keeps every visible block backed by exactly one executor", () => {
const registryValues = WorkflowBlockTypes.map((block) => block.value).sort();
const executorValues = [...CORE_WORKFLOW_BLOCK_TYPES, ...PRO_WORKFLOW_BLOCK_TYPES].sort();
expect(registryValues).toEqual(executorValues);
});
it("keeps the visible registry in sync with the worker-safe block contract", () => {
const registryValues = WorkflowBlockTypes.map((block) => block.value).sort();
const contractValues = workflowBlockContracts.map((contract) => contract.value).sort();
expect(registryValues).toEqual(contractValues);
for (const contract of workflowBlockContracts) {
const block = WorkflowBlockTypes.find((candidate) => candidate.value === contract.value);
const requiredInputs = block?.inputs
.filter((input) => input.required)
.map((input) => input.name)
.sort();
expect(requiredInputs).toEqual([...contract.requiredInputs].sort());
}
});
it("keeps executor ownership aligned with the shared contract", () => {
const coreContracts = workflowBlockContracts
.filter((contract) => contract.executor === "core")
.map((contract) => contract.value)
.sort();
const proContracts = workflowBlockContracts
.filter((contract) => contract.executor === "pro")
.map((contract) => contract.value)
.sort();
expect([...CORE_WORKFLOW_BLOCK_TYPES].sort()).toEqual(coreContracts);
expect([...PRO_WORKFLOW_BLOCK_TYPES].sort()).toEqual(proContracts);
});
it("keeps native worker support aligned with the shared contract", () => {
const nativeContracts = workflowBlockContracts
.filter((contract) => contract.nativeSupport === "supported")
.map((contract) => contract.value)
.sort();
expect([...NATIVE_WORKFLOW_TRIGGER_SUPPORTED_BLOCK_TYPES].sort()).toEqual(nativeContracts);
expect(workflowBlockContracts.find((contract) => contract.value === "if")?.nativeSupport).toBe("supported");
expect(workflowBlockContracts.find((contract) => contract.value === "switch")?.nativeSupport).toBe("supported");
expect(workflowBlockContracts.find((contract) => contract.value === "rowGet")?.nativeSupport).toBe("supported");
expect(workflowBlockContracts.find((contract) => contract.value === "iterator")?.nativeSupport).toBe("supported");
});
it("keeps the production worker block surface classified intentionally", () => {
const surface = Object.fromEntries(
workflowBlockContracts.map((contract) => [
contract.value,
{
executor: contract.executor,
nativeSupport: contract.nativeSupport,
},
])
);
expect(surface).toEqual({
alertUser: { executor: "core", nativeSupport: "supported" },
doNothing: { executor: "core", nativeSupport: "supported" },
email: { executor: "pro", nativeSupport: "supported" },
event: { executor: "core", nativeSupport: "supported" },
httpRequest: { executor: "core", nativeSupport: "supported" },
if: { executor: "core", nativeSupport: "supported" },
iterator: { executor: "core", nativeSupport: "supported" },
log: { executor: "core", nativeSupport: "supported" },
manual: { executor: "core", nativeSupport: "supported" },
rowCreate: { executor: "pro", nativeSupport: "preflight-skip" },
rowDelete: { executor: "pro", nativeSupport: "preflight-skip" },
rowGet: { executor: "pro", nativeSupport: "supported" },
rowUpdate: { executor: "pro", nativeSupport: "preflight-skip" },
switch: { executor: "core", nativeSupport: "supported" },
variable: { executor: "core", nativeSupport: "supported" },
waitForInput: { executor: "pro", nativeSupport: "preflight-skip" },
});
});
it("keeps visible registry copy as translation tokens", () => {
for (const block of WorkflowBlockTypes) {
expect(block.name).toBe(block.value);
expect(block.category).toMatch(/^[a-z]+$/u);
for (const input of block.inputs) {
expect(input.label).toBe(input.name);
if (input.href) {
expect(input.href.label).toMatch(/^[a-z][a-zA-Z0-9]*$/u);
}
if (input.placeholder) {
expect(input.placeholder).toMatch(/^(https:\/\/|workflows\.blocks\.)/u);
}
}
for (const output of block.outputs) {
expect(output.label).toBe(output.name);
}
}
});
it("publishes the complete http request runner output contract", () => {
const httpRequestBlock = WorkflowBlockTypes.find((block) => block.value === "httpRequest");
expect(httpRequestBlock?.outputs.map((output) => output.name).sort()).toEqual(["body", "error", "statusCode", "truncated"]);
});
it("keeps every visible block category backed by en/de translations", () => {
const locales = ["en", "de"] as const;
const categoryKeys = [...new Set(WorkflowBlockTypes.map((block) => block.category))].sort();
for (const locale of locales) {
const translations = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales", locale, "translations.json"), "utf8"));
for (const category of categoryKeys) {
expect(translations.workflows.blocks.categories, `${locale} workflows.blocks.categories.${category}`).toHaveProperty(category);
}
}
});
it("keeps every visible block label backed by en/de translations", () => {
const locales = ["en", "de"] as const;
const inputKeys = [...new Set(WorkflowBlockTypes.flatMap((block) => block.inputs.map((input) => input.name)))].sort();
const optionKeys = [
...new Set(
WorkflowBlockTypes.flatMap((block) =>
block.value === "event" ? [] : block.inputs.flatMap((input) => input.options?.map((option) => option.value) ?? [])
)
),
].sort();
for (const locale of locales) {
const translations = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales", locale, "translations.json"), "utf8"));
for (const block of WorkflowBlockTypes) {
expect(translations.workflows.blocks[block.value], `${locale} workflows.blocks.${block.value}.name`).toHaveProperty("name");
}
for (const input of inputKeys) {
expect(translations.workflows.blocks.inputs, `${locale} workflows.blocks.inputs.${input}`).toHaveProperty(input);
}
for (const option of optionKeys) {
expect(translations.workflows.blocks.options, `${locale} workflows.blocks.options.${option}`).toHaveProperty(option);
}
}
});
it("keeps every visible block output backed by en/de translations", () => {
const locales = ["en", "de"] as const;
const outputKeys = [...new Set(WorkflowBlockTypes.flatMap((block) => block.outputs.map((output) => output.name)))].sort();
expect(outputKeys).toContain("truncated");
for (const locale of locales) {
const translations = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales", locale, "translations.json"), "utf8"));
for (const output of outputKeys) {
expect(translations.workflows.blocks.outputs, `${locale} workflows.blocks.outputs.${output}`).toHaveProperty(output);
}
}
});
it("localizes application event option labels with readable names", () => {
const label = getWorkflowBlockOptionLabel(
{ value: "row.created", label: "row.created (row.created)" },
(key, defaultValue) => (key === "events.names.row_created" ? "Localized row created" : defaultValue)
);
expect(label).toBe("row.created - Localized row created");
});
});
@@ -0,0 +1,328 @@
import { ApplicationEvents, getApplicationEventNameKey } from "~/modules/events/types/ApplicationEvent";
import WorkflowBlockActionIcon from "../components/icons/WorkflowBlockActionIcon";
import ConditionalIcon from "../components/nodes/flow/conditional/ConditionalIcon";
import DoNothingIcon from "../components/nodes/helpers/doNothing/DoNothingIcon";
import HttpRequestIcon from "../components/nodes/helpers/httpRequest/HttpRequestIcon";
import LogIcon from "../components/nodes/helpers/log/LogIcon";
import TriggerManualIcon from "../components/nodes/triggers/manual/TriggerManualIcon";
export const WorkflowBlockTypes: WorkflowBlockTypeDto[] = [
{
type: "trigger",
category: "misc",
name: "manual",
value: "manual",
icon: TriggerManualIcon,
inputs: [
{ name: "validation", type: "monaco", label: "validation", required: false, href: { url: "https://github.com/ajv-validator/ajv", label: "learnMore" } },
],
outputs: [],
},
{
type: "trigger",
category: "app",
name: "event",
value: "event",
icon: WorkflowBlockActionIcon,
appliesToAllTenants: true,
inputs: [
{
name: "event",
type: "select",
label: "event",
required: true,
options: ApplicationEvents.map((f) => ({ value: f.value, label: `${f.value} (${f.value})`, adminOnly: f.adminOnly })),
},
{ name: "entity", type: "string", label: "entity", required: false, if: { name: "event", values: ["row.created", "row.updated", "row.deleted"] } },
],
outputs: [{ name: "data", label: "data" }],
},
{
type: "action",
category: "flow",
name: "if",
value: "if",
icon: ConditionalIcon,
inputs: [],
outputs: [],
},
{
type: "action",
category: "flow",
name: "switch",
value: "switch",
icon: ConditionalIcon,
inputs: [],
outputs: [],
},
{
type: "action",
category: "helpers",
name: "httpRequest",
value: "httpRequest",
icon: HttpRequestIcon,
inputs: [
{ name: "url", type: "string", label: "url", required: true, placeholder: "https://api.example.com" },
{
name: "method",
type: "select",
label: "method",
required: true,
options: [
{ label: "GET", value: "GET" },
{ label: "POST", value: "POST" },
{ label: "PUT", value: "PUT" },
{ label: "DELETE", value: "DELETE" },
{ label: "PATCH", value: "PATCH" },
{ label: "HEAD", value: "HEAD" },
{ label: "OPTIONS", value: "OPTIONS" },
],
},
{ name: "body", type: "monaco", label: "body", required: false },
{ name: "headers", type: "keyValue", label: "headers", required: false },
{ name: "throwsError", type: "boolean", label: "throwsError", defaultValue: true },
],
outputs: [
{ name: "statusCode", label: "statusCode" },
{ name: "body", label: "body" },
{ name: "error", label: "error" },
{ name: "truncated", label: "truncated" },
],
},
{
type: "action",
category: "helpers",
name: "log",
value: "log",
icon: LogIcon,
inputs: [{ name: "message", type: "string", label: "message", required: true, placeholder: "workflows.blocks.log.inputs.message.placeholder" }],
outputs: [],
},
{
type: "action",
category: "helpers",
name: "alertUser",
value: "alertUser",
icon: LogIcon,
inputs: [
{ name: "message", type: "string", label: "message", required: true, placeholder: "workflows.blocks.alertUser.inputs.message.placeholder" },
{
name: "type",
type: "select",
label: "type",
required: false,
defaultValue: "success",
options: [
{ label: "success", value: "success" },
{ label: "error", value: "error" },
],
},
],
outputs: [],
},
{
type: "action",
category: "helpers",
name: "doNothing",
value: "doNothing",
icon: DoNothingIcon,
inputs: [],
outputs: [],
},
{
type: "action",
category: "interaction",
name: "waitForInput",
value: "waitForInput",
icon: WorkflowBlockActionIcon,
inputs: [
{ name: "title", type: "string", label: "title", required: false },
{ name: "placeholder", type: "string", label: "placeholder", required: false },
],
outputs: [{ name: "input", label: "input" }],
},
{
type: "action",
category: "entities",
name: "rowGet",
value: "rowGet",
icon: WorkflowBlockActionIcon,
inputs: [
{ name: "entity", type: "string", label: "entity", required: true },
{ name: "id", type: "string", label: "id", required: true },
],
outputs: [{ name: "row", label: "row" }],
},
{
type: "action",
category: "entities",
name: "rowCreate",
value: "rowCreate",
icon: WorkflowBlockActionIcon,
inputs: [
{ name: "entity", type: "string", label: "entity", required: true },
{ name: "row", type: "monaco", label: "row", required: true },
],
outputs: [{ name: "row", label: "row" }],
},
{
type: "action",
category: "entities",
name: "rowUpdate",
value: "rowUpdate",
icon: WorkflowBlockActionIcon,
inputs: [
{ name: "entity", type: "string", label: "entity", required: true },
{ name: "id", type: "string", label: "id", required: true },
{ name: "data", type: "monaco", label: "data", required: true },
],
outputs: [{ name: "row", label: "row" }],
},
{
type: "action",
category: "entities",
name: "rowDelete",
value: "rowDelete",
icon: WorkflowBlockActionIcon,
inputs: [
{ name: "entity", type: "string", label: "entity", required: true },
{ name: "id", type: "string", label: "id", required: true },
],
outputs: [{ name: "row", label: "row" }],
},
{
type: "action",
category: "flow",
name: "iterator",
value: "iterator",
icon: WorkflowBlockActionIcon,
inputs: [{ name: "variableName", type: "string", label: "variableName", required: true }],
outputs: [
{ name: "index", label: "index" },
{ name: "item", label: "item" },
{ name: "isFirst", label: "isFirst" },
{ name: "isLast", label: "isLast" },
],
},
{
type: "action",
category: "flow",
name: "variable",
value: "variable",
icon: WorkflowBlockActionIcon,
inputs: [
{ name: "variableName", type: "string", label: "variableName", required: true },
{ name: "value", type: "string", label: "value", required: true },
],
outputs: [],
},
{
type: "action",
category: "app",
name: "email",
value: "email",
icon: WorkflowBlockActionIcon,
inputs: [
{
name: "provider",
type: "select",
label: "provider",
required: true,
options: [
{ label: "Mailjet", value: "mailjet" },
],
},
{ name: "to", type: "string", label: "to", required: true },
{ name: "subject", type: "string", label: "subject", required: true },
{ name: "body", type: "string", label: "body", required: true },
],
outputs: [],
},
];
export type WorkflowBlockType =
| "manual"
| "if"
| "switch"
| "httpRequest"
| "log"
| "alertUser"
| "doNothing"
| "waitForInput"
| "rowGet"
| "rowCreate"
| "rowUpdate"
| "rowDelete"
| "iterator"
| "variable"
| "event"
| "email";
export type WorkflowBlockTypeDto = {
type: "trigger" | "action";
category: "misc" | "flow" | "helpers" | "interaction" | "ai" | "entities" | "app";
name: string;
value: WorkflowBlockType;
icon: any;
inputs: WorkflowBlockInput[];
outputs: WorkflowBlockOutput[];
appliesToAllTenants?: boolean;
};
export type WorkflowBlockInput = {
name: string;
type: "string" | "monaco" | "keyValue" | "select" | "boolean";
label: string;
required?: boolean;
placeholder?: string;
options?: {
label: string;
value: string;
adminOnly?: boolean;
}[];
defaultValue?: string | boolean;
href?: { url: string; label: string };
if?: { name: string; values: string[] };
};
export type WorkflowBlockOutput = {
name: string;
label: string;
};
type WorkflowBlockTranslator = (key: string, defaultValue: string) => string;
export function getWorkflowBlockName(block: Pick<WorkflowBlockTypeDto, "value" | "name">, t: WorkflowBlockTranslator): string {
return t(`workflows.blocks.${block.value}.name`, block.name);
}
export function getWorkflowBlockCategoryLabel(category: WorkflowBlockTypeDto["category"], t: WorkflowBlockTranslator): string {
return t(`workflows.blocks.categories.${category}`, category);
}
export function getWorkflowBlockInputLabel(input: Pick<WorkflowBlockInput, "name" | "label">, t: WorkflowBlockTranslator): string {
return t(`workflows.blocks.inputs.${input.name}`, input.label);
}
export function getWorkflowBlockInputPlaceholder(
block: Pick<WorkflowBlockTypeDto, "value">,
input: Pick<WorkflowBlockInput, "name" | "placeholder">,
t: WorkflowBlockTranslator
): string | undefined {
if (!input.placeholder) return undefined;
return t(`workflows.blocks.${block.value}.inputs.${input.name}.placeholder`, input.placeholder);
}
export function getWorkflowBlockInputHrefLabel(input: Pick<WorkflowBlockInput, "href">, t: WorkflowBlockTranslator): string | undefined {
if (!input.href) return undefined;
return t("workflows.blocks.common.learnMore", input.href.label);
}
export function getWorkflowBlockOptionLabel(option: { label: string; value: string }, t: WorkflowBlockTranslator): string {
const event = ApplicationEvents.find((item) => item.value === option.value);
if (event) {
const eventLabel = t(getApplicationEventNameKey(event.value), event.value);
return `${option.value} - ${eventLabel}`;
}
return t(`workflows.blocks.options.${option.value}`, option.label);
}
export function getWorkflowBlockOutputLabel(output: Pick<WorkflowBlockOutput, "name" | "label">, t: WorkflowBlockTranslator): string {
return t(`workflows.blocks.outputs.${output.name}`, output.label);
}
@@ -0,0 +1,30 @@
export type WorkflowConditionsGroupDto = {
// id: string;
index: number;
type: "AND" | "OR";
conditions: WorkflowConditionsDto[];
};
export type WorkflowConditionsDto = {
// id: string;
index: number;
variable: string; // Refers to the variable in the workflow context
operator: WorkflowConditionOperator; // '=', '!=', '>', '<', '>=', '<=', etc.
value: string; // The value to compare against
};
export type WorkflowConditionOperator = typeof WorkflowConditionOperators[number]["value"];
export const WorkflowConditionOperators = [
{ name: "Equals", value: "=", requiresValue: true, translationKey: "workflows.conditions.operators.equals" },
{ name: "Not equals", value: "!=", requiresValue: true, translationKey: "workflows.conditions.operators.notEquals" },
{ name: "Greater than", value: ">", requiresValue: true, translationKey: "workflows.conditions.operators.greaterThan" },
{ name: "Less than", value: "<", requiresValue: true, translationKey: "workflows.conditions.operators.lessThan" },
{ name: "Greater than or equals", value: ">=", requiresValue: true, translationKey: "workflows.conditions.operators.greaterThanOrEquals" },
{ name: "Less than or equals", value: "<=", requiresValue: true, translationKey: "workflows.conditions.operators.lessThanOrEquals" },
{ name: "Contains", value: "contains", requiresValue: true, translationKey: "workflows.conditions.operators.contains" },
{ name: "Does not contain", value: "doesNotContain", requiresValue: true, translationKey: "workflows.conditions.operators.doesNotContain" },
{ name: "Starts with", value: "startsWith", requiresValue: true, translationKey: "workflows.conditions.operators.startsWith" },
{ name: "Ends with", value: "endsWith", requiresValue: true, translationKey: "workflows.conditions.operators.endsWith" },
{ name: "Is empty", value: "isEmpty", requiresValue: false, translationKey: "workflows.conditions.operators.isEmpty" },
{ name: "Is not empty", value: "isNotEmpty", requiresValue: false, translationKey: "workflows.conditions.operators.isNotEmpty" },
] as const;
@@ -0,0 +1,22 @@
import { TenantSimple } from "~/utils/db/tenants.db.server";
import { WorkflowBlockDto } from "./WorkflowBlockDto";
import { WorkflowInputExampleDto } from "./WorkflowInputExampleDto";
export type WorkflowDto = {
id: string;
tenantId: string | null;
tenant: TenantSimple | null;
name: string;
description: string;
status: "draft" | "live" | "archived";
createdAt: Date;
updatedAt: Date | null;
appliesToAllTenants: boolean;
blocks: WorkflowBlockDto[];
inputExamples: WorkflowInputExampleDto[];
_count: {
executions: number;
};
$variables?: string[];
$credentials?: string[];
};
@@ -0,0 +1,17 @@
import { WorkflowBlockDto } from "./WorkflowBlockDto";
import { WorkflowBlockExecutionDto } from "./WorkflowBlockExecutionDto";
import { WorkflowStatus } from "./WorkflowStatus";
export type WorkflowExecutionDto = {
id: string;
createdAt: Date;
status: WorkflowStatus;
input: { [key: string]: any } | null;
output: { [key: string]: any } | null;
duration: number | null;
endedAt: Date | null;
error: string | null;
blockRuns: WorkflowBlockExecutionDto[];
executionAlerts: { type: "success" | "error"; message: string }[];
waitingBlock: WorkflowBlockDto | null;
};
@@ -0,0 +1,6 @@
export type WorkflowInputExampleDto = {
id?: string;
title: string;
input: { [key: string]: any };
createdAt?: Date;
};
@@ -0,0 +1 @@
export type WorkflowStatus = "running" | "success" | "error" | "waitingBlock";
@@ -0,0 +1,44 @@
import { WorkflowBlockType } from "./WorkflowBlockTypes";
import { WorkflowConditionOperator } from "./WorkflowConditionDtos";
export type WorkflowsTemplateDto = {
key?: string;
title: string;
workflows: WorkflowTemplateDto[];
variables?: { name: string; value: string }[];
credentialsRequired?: string[];
adminOnly?: boolean;
};
export type WorkflowTemplateDto = {
key?: string;
name: string;
description?: string;
blocks: {
id: string;
type: WorkflowBlockType; // "manual" | "api" | "if" | "switch" | "httpRequest" | "log" | "doNothing"
description?: string;
input?: { [key: string]: any };
conditionGroups?: {
// index: number;
type: "AND" | "OR";
case?: string; // switch (case1,case2,default)
conditions: {
// index: number;
variable: string;
operator: WorkflowConditionOperator; // '=', '!=', '>', '<', '>=', '<=', 'contains', 'doesNotContain', 'startsWith', 'endsWith', 'isEmpty', 'isNotEmpty'
value: string; // The value to compare against
}[];
}[];
}[];
toBlocks: {
fromBlockId: string;
toBlockId: string;
condition?: string; // if (true, false), switch (case1,case2,default)
}[];
inputExamples: {
key?: string;
title: string;
input: { [key: string]: any };
}[];
};
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -0,0 +1,21 @@
import { LoaderFunctionArgs } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { t as i18nT } from "i18next";
import { featureKeys } from "@toolctrl/entitlements";
import { requireTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
export namespace WorkflowEngineApi {
type LoaderData = {
metatags: MetaTagsDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
if (params.tenant) {
await requireTenantProductAccess({ request, params, featureKey: featureKeys.workflowEngine });
}
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.index", { value0: process.env.APP_NAME }) }],
};
return data;
};
}
@@ -0,0 +1,76 @@
import { Outlet, useParams } from "react-router";
import IncreaseIcon from "~/components/ui/icons/crm/IncreaseIcon";
import IncreaseIconFilled from "~/components/ui/icons/crm/IncreaseIconFilled";
import WorkflowCredentialIcon from "~/modules/workflowEngine/components/icons/WorkflowCredentialIcon";
import WorkflowCredentialIconFilled from "~/modules/workflowEngine/components/icons/WorkflowCredentialIconFilled";
import WorkflowExecutionIcon from "~/modules/workflowEngine/components/icons/WorkflowExecutionIcon";
import WorkflowExecutionIconFilled from "~/modules/workflowEngine/components/icons/WorkflowExecutionIconFilled";
import WorkflowIcon from "~/modules/workflowEngine/components/icons/WorkflowIcon";
import WorkflowIconFilled from "~/modules/workflowEngine/components/icons/WorkflowIconFilled";
import WorkflowVariableIcon from "~/modules/workflowEngine/components/icons/WorkflowVariableIcon";
import WorkflowVariableIconFilled from "~/modules/workflowEngine/components/icons/WorkflowVariableIconFilled";
import SidebarIconsLayout from "~/components/ui/layouts/SidebarIconsLayout";
import UrlUtils from "~/utils/app/UrlUtils";
import TemplateIcon from "~/components/ui/icons/entities/TemplateIcon";
import TemplateIconFilled from "~/components/ui/icons/entities/TemplateIconFilled";
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon } from "~/components/ui/icons/vendor";
export default function WorkflowEngineView() {
const { t } = useTranslation();
const params = useParams();
return (
<SidebarIconsLayout
label={{ align: "right" }}
items={[
{
name: t("shared.overview"),
href: UrlUtils.getModulePath(params, `workflow-engine`),
exact: true,
icon: <IncreaseIcon className="h-5 w-5" />,
iconSelected: <IncreaseIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/workflows`),
icon: <WorkflowIcon className="h-5 w-5" />,
iconSelected: <WorkflowIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.variables.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/variables`),
icon: <WorkflowVariableIcon className="h-5 w-5" />,
iconSelected: <WorkflowVariableIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.credentials.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/credentials`),
icon: <WorkflowCredentialIcon className="h-5 w-5" />,
iconSelected: <WorkflowCredentialIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.executions.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/executions`),
icon: <WorkflowExecutionIcon className="h-5 w-5" />,
iconSelected: <WorkflowExecutionIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.templates.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/templates`),
icon: <TemplateIcon className="h-5 w-5" />,
iconSelected: <TemplateIconFilled className="h-5 w-5" />,
bottom: true,
},
{
name: t("workflows.nav.danger"),
href: UrlUtils.getModulePath(params, `workflow-engine/danger`),
bottom: true,
icon: <AlertTriangleIcon className="h-5 w-5" />,
iconSelected: <AlertTriangleIcon className="h-5 w-5" />,
},
]}
>
<Outlet />
</SidebarIconsLayout>
);
}
@@ -0,0 +1,34 @@
import { LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import UrlUtils from "~/utils/app/UrlUtils";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { t as i18nT } from "i18next";
export namespace WorkflowsIdExecutionsApi {
export type LoaderData = {
metatags: MetaTagsDto;
item: WorkflowDto;
executions: WorkflowExecutionDto[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const item = await WorkflowsService.get(params.id!, { tenantId });
if (!item) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const executions = await WorkflowsService.getExecutions(item, { tenantId });
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.executions", { value0: item.name, value1: webConfig.appName }) }],
item,
executions,
};
return data;
};
}
@@ -0,0 +1,83 @@
import { Link, useLoaderData, useParams, useSearchParams } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useState } from "react";
import "@xyflow/react/dist/style.css";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
import WorkflowExecutionsSidebar from "~/modules/workflowEngine/components/workflows/WorkflowExecutionsSidebar";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdExecutionsApi } from "./workflows.$id.executions.api.server";
export default function WorkflowsIdExecutionsView() {
const data = useLoaderData<WorkflowsIdExecutionsApi.LoaderData>();
const params = useParams();
const [searchParams] = useSearchParams();
const [workflow] = useState<WorkflowDto>(data.item);
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecutionDto | null>(null);
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
useEffect(() => {
const executionId = searchParams.get("executionId");
if (executionId) {
const execution = data.executions.find((x) => x.id === executionId);
if (execution) {
setSelectedExecution(execution);
}
} else {
setSelectedExecution(null);
}
}, [data.executions, searchParams]);
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
</div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}`)}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToEditor")}
</Link>
<WorkflowRunDropdown workflow={workflow} />
</div>
</div>
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
height. On mobile the outer flex container only has min-height, so a
flex-1 child stays indefinite and the canvas renders hidden/collapsed.
Use a fixed vh height on mobile (always definite); flex sizing only on
lg. Mirrors the builder index view. */}
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
<LazyWorkflowBuilder
key={selectedExecution?.id}
readOnly={true}
workflow={workflow}
selectedBlock={selectedBlock}
workflowExecution={selectedExecution}
onSelectedBlock={setSelectedBlock}
/>
</div>
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
<WorkflowExecutionsSidebar
key={selectedExecution?.id}
workflow={workflow}
selectedBlock={selectedBlock}
selectedExecution={selectedExecution}
executions={data.executions}
onSelectedBlock={setSelectedBlock}
onSelectedExecution={setSelectedExecution}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTranslations: vi.fn(),
getTenantIdOrNull: vi.fn(),
requireWorkflowMutationAccess: vi.fn(),
validateCSRFToken: vi.fn(),
getWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: mocks.getTranslations,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("../workflowAccess.server", () => ({
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
}));
vi.mock("~/utils/session.server", () => ({
validateCSRFToken: mocks.validateCSRFToken,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
default: {
get: mocks.getWorkflow,
update: mocks.updateWorkflow,
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Signup flow",
description: "",
status: "draft",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowsIdIndexApi.action", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue({ userId: "user_1" });
mocks.getTranslations.mockResolvedValue({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
"workflows.errors.workflowBuilderActionFailed": "Workflow builder action failed. Check your changes and try again.",
"workflows.errors.workflowBuilderInvalidPayload": "Workflow builder payload is not valid JSON.",
};
return translations[key] ?? String(options?.defaultValue ?? key);
},
});
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
mocks.validateCSRFToken.mockResolvedValue(undefined);
mocks.getWorkflow.mockResolvedValue(workflow());
mocks.updateWorkflow.mockResolvedValue(workflow());
});
it("neutralizes external save redirect targets and keeps the workflow tenant-scoped", async () => {
const savedWorkflow = workflow({ name: "Updated flow" });
const form = new URLSearchParams();
form.set("action", "save");
form.set("workflow", JSON.stringify(savedWorkflow));
form.set("redirectTo", "https://evil.example/phish");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1?tab=builder", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdIndexApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
"workflow_1",
expect.objectContaining({ id: savedWorkflow.id, name: savedWorkflow.name }),
expect.objectContaining({ tenantId: "tenant_1", userId: "user_1" })
);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/acme/workflow-engine/workflows/workflow_1?tab=builder");
expect(response.headers.get("Location")).not.toContain("evil.example");
});
it("returns a generic translated error for unknown save failures", async () => {
mocks.updateWorkflow.mockRejectedValueOnce(new Error("database connection leaked-secret"));
const form = new URLSearchParams();
form.set("action", "save");
form.set("workflow", JSON.stringify(workflow({ name: "Updated flow" })));
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdIndexApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workflow builder action failed. Check your changes and try again.",
});
});
it("returns a translated invalid payload error instead of the JSON parser message", async () => {
const form = new URLSearchParams();
form.set("action", "save");
form.set("workflow", "{");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdIndexApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workflow builder payload is not valid JSON.",
});
expect(mocks.updateWorkflow).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,234 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
import UrlUtils from "~/utils/app/UrlUtils";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { requireAuth } from "~/utils/loaders.middleware";
import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server";
import { validateCSRFToken } from "~/utils/session.server";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT, type TFunction } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowBuilderActionError } from "../workflowRunActionErrors.server";
function actionText(t: TFunction, key: string, fallback: string, values?: Record<string, unknown>) {
const translated = t(key, { defaultValue: fallback, ...(values ?? {}) });
return translated === key ? fallback : translated;
}
export namespace WorkflowsIdIndexApi {
export type LoaderData = {
metatags: MetaTagsDto;
item: WorkflowDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const item = await WorkflowsService.get(params.id!, { tenantId });
if (!item) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.build", { value0: item.name, value1: webConfig.appName }) }],
item,
};
return data;
};
export type ActionData = {
success?: string;
error?: string;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const auth = await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
const item = await WorkflowsService.get(params.id!, { tenantId });
if (!item) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
if (action === "save") {
try {
const workflow = JSON.parse(form.get("workflow")?.toString() ?? "{}") as WorkflowDto;
const redirectTo = form.get("redirectTo")?.toString();
await WorkflowsService.update(params.id!, workflow, { tenantId, userId: auth.userId ?? null });
if (redirectTo) {
return redirect(safeRedirectPathFromRequest(request, redirectTo));
}
return Response.json({ success: actionText(t, "workflows.responses.workflowSaved", "Workflow saved: {{value0}}", { value0: workflow.name }) });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "update-block") {
const id = form.get("id")?.toString();
const block = item.blocks.find((x) => x.id === id);
if (!block) {
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
}
try {
const blockData = JSON.parse(form.get("block")?.toString() ?? "{}") as WorkflowBlockDto;
const redirectTo = form.get("redirectTo")?.toString();
await WorkflowsService.updateBlock(block.id, blockData, { workflow: item, userId: auth.userId ?? null });
if (redirectTo) {
return redirect(safeRedirectPathFromRequest(request, redirectTo));
}
return Response.json({ success: actionText(t, "workflows.responses.blockUpdated", "Block updated: {{value0}}", { value0: blockData.type }) });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "add-block") {
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
const condition = form.get("condition")?.toString() ?? "";
const type = form.get("type")?.toString() as WorkflowBlockType;
if (fromBlockId) {
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
if (!fromBlock) {
return Response.json({ error: actionText(t, "workflows.errors.fromBlockNotFound", "From block not found") }, { status: 404 });
}
}
try {
const newBlock = await WorkflowsService.addBlock({
workflow: item,
fromBlockId,
type,
condition,
userId: auth.userId ?? null,
});
return Response.json({
success: actionText(t, "workflows.responses.blockAdded", "Block added"),
newBlockId: newBlock.id,
});
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "delete-block") {
const id = form.get("id")?.toString() ?? "";
const block = item.blocks.find((x) => x.id === id);
if (!block) {
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
}
try {
await WorkflowsService.deleteBlock(block.id, { workflow: item, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.blockDeleted", "Block deleted") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "delete-connection") {
const id = form.get("id")?.toString() ?? "";
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
const toBlockId = form.get("toBlockId")?.toString() ?? "";
try {
if (fromBlockId && toBlockId) {
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
const toBlock = item.blocks.find((x) => x.id === toBlockId);
if (!fromBlock || !toBlock) {
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
}
await WorkflowsService.deleteConnection({ fromBlockId, toBlockId }, { workflow: item, userId: auth.userId ?? null });
} else {
const connection = item.blocks.flatMap((block) => block.toBlocks).find((candidate) => candidate.id === id);
if (!connection) {
return Response.json({ error: actionText(t, "workflows.errors.connectionNotFound", "Connection not found") }, { status: 404 });
}
await WorkflowsService.deleteConnection({ id: connection.id }, { workflow: item, userId: auth.userId ?? null });
}
return Response.json({ success: actionText(t, "workflows.responses.connectionDeleted", "Connection deleted") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "connect-blocks") {
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
const toBlockId = form.get("toBlockId")?.toString() ?? "";
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
const toBlock = item.blocks.find((x) => x.id === toBlockId);
const condition = form.get("condition")?.toString() ?? "";
if (!fromBlock || !toBlock) {
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
}
try {
await WorkflowsService.connectBlocks({ fromBlockId, toBlockId, condition }, { workflow: item, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.blocksConnected", "Blocks connected") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "update-conditions-groups") {
const blockId = form.get("blockId")?.toString() ?? "";
const block = item.blocks.find((x) => x.id === blockId);
if (!block) {
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
}
try {
const conditionsGroups = JSON.parse(form.get("conditionsGroups")?.toString() ?? "[]") as WorkflowConditionsGroupDto[];
await WorkflowsService.updateConditionsGroups(block, conditionsGroups, { workflow: item, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.conditionsGroupsUpdated", "Condition groups updated") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "create-input-example") {
const title = form.get("title")?.toString() ?? "";
const input = form.get("input")?.toString() ?? "";
await WorkflowsService.createInputExample({
workflowId: item.id,
tenantId: item.tenantId,
title,
input,
});
return Response.json({ success: actionText(t, "workflows.responses.inputExampleCreated", "Input example created") });
} else if (action === "update-input-example") {
const id = form.get("id")?.toString() ?? "";
const title = form.get("title")?.toString() ?? "";
const input = form.get("input")?.toString() ?? "";
const inputExample = item.inputExamples.find((x) => x.id === id);
if (!inputExample) {
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
}
await WorkflowsService.updateInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId }, {
title,
input,
});
return Response.json({ success: actionText(t, "workflows.responses.inputExampleUpdated", "Input example updated") });
} else if (action === "delete-input-example") {
const id = form.get("id")?.toString() ?? "";
const inputExample = item.inputExamples.find((x) => x.id === id);
if (!inputExample) {
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
}
await WorkflowsService.deleteInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId });
return Response.json({ success: actionText(t, "workflows.responses.inputExampleDeleted", "Input example deleted") });
} else if (action === "delete") {
try {
await WorkflowsService.del(item.id, { tenantId });
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "toggle") {
const enabled = form.get("enabled")?.toString() === "true";
if (enabled && item.status === "draft") {
await WorkflowsService.update(item.id, { status: "live" }, { tenantId, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.workflowLive", "Workflow is now live") });
} else if (!enabled && item.status === "live") {
await WorkflowsService.update(
item.id,
{
status: "draft",
},
{ tenantId, userId: auth.userId ?? null }
);
return Response.json({ success: actionText(t, "workflows.responses.workflowDraft", "Workflow is now a draft") });
}
}
return Response.json({ error: actionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,233 @@
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useState } from "react";
import "@xyflow/react/dist/style.css";
import { toast } from "sonner";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
import WorkflowEditorSidebar from "~/modules/workflowEngine/components/workflows/WorkflowEditorSidebar";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import InputCheckbox from "~/components/ui/input/InputCheckbox";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
import UrlUtils from "~/utils/app/UrlUtils";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
import { appendCSRFToken } from "~/utils/csrf";
export default function WorkflowsIdIndexView() {
const data = useLoaderData<WorkflowsIdIndexApi.LoaderData>();
const actionData = useActionData<WorkflowsIdIndexApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const [workflow, setWorkflow] = useState<WorkflowDto>(data.item);
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
const [addingNextBlockFrom, setAddingNextBlockFrom] = useState<{ fromBlock: WorkflowBlockDto; condition: string | null } | null>(null);
useEffect(() => {
if (actionData?.success) {
toast.success(actionData.success);
} else if (actionData?.error) {
toast.error(actionData.error);
}
}, [actionData]);
useEffect(() => {
setWorkflow(data.item);
if (selectedBlock) {
setSelectedBlock(data.item.blocks.find((x) => x.id === selectedBlock.id) ?? null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.item]);
useEffect(() => {
if (selectedBlock === null && addingNextBlockFrom !== null) {
setAddingNextBlockFrom(null);
}
}, [addingNextBlockFrom, selectedBlock]);
function onSave(workflow: WorkflowDto) {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "save");
form.append("workflow", JSON.stringify(workflow));
submit(form, {
method: "post",
});
}
function onSaveBlock(block: WorkflowBlockDto) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "update-block");
form.set("id", block.id);
form.append("block", JSON.stringify(block));
submit(form, {
method: "post",
});
}
function onAddBlock({ type, from, condition }: { type: WorkflowBlockType; from: WorkflowBlockDto | undefined; condition: string | null }) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "add-block");
form.set("type", type);
if (condition) {
form.set("condition", condition);
}
if (from) {
form.set("fromBlockId", from.id);
}
submit(form, {
method: "post",
});
}
function onConnectBlocks(params: { fromBlockId: string; toBlockId: string; condition: string | null }) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "connect-blocks");
form.set("fromBlockId", params.fromBlockId);
form.set("toBlockId", params.toBlockId);
if (params.condition) {
form.set("condition", params.condition);
}
submit(form, {
method: "post",
});
}
function onDeleteBlock(id: string) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete-block");
form.set("id", id);
submit(form, {
method: "post",
});
}
function onDeleteConnection(params: { fromBlockId: string; toBlockId: string } | { id: string }) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete-connection");
if ("id" in params) {
form.set("id", params.id);
} else {
form.set("fromBlockId", params.fromBlockId);
form.set("toBlockId", params.toBlockId);
}
submit(form, {
method: "post",
});
}
function onUpdateConditionsGroups(blockId: string, conditionsGroups: WorkflowConditionsGroupDto[]) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "update-conditions-groups");
form.set("blockId", blockId);
form.set("conditionsGroups", JSON.stringify(conditionsGroups));
submit(form, {
method: "post",
});
}
function onToggle(enabled: boolean) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "toggle");
form.set("enabled", enabled ? "true" : "false");
submit(form, {
method: "post",
});
}
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
</div>
{/* relative z-50 gives the toolbar its own stacking context above the
ReactFlow canvas below. ReactFlow's root sets no position/z-index, so
its internal positioned layers (pane/renderer/controls, z-index ≤ 5)
otherwise paint over this static toolbar where they overlap at mobile
width — stealing pointer/keyboard from the run dropdown. */}
<div className="border-border bg-background relative z-50 w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between space-x-2">
<div className="flex items-center space-x-2">
<div data-ui-id="ui.workflows.builder.statusToggle">
<InputCheckbox
disabled={!WorkflowUtils.isReady(workflow) && workflow.status !== "live"}
asToggle
withLabel={false}
title={i18nT("workflows.list.toggleStatus", { name: workflow.name })}
value={workflow.status === "live"}
setValue={(checked) => onToggle(Boolean(checked))}
/>
</div>
<SimpleBadge title={i18nT(`workflows.status.${workflow.status}`)} color={WorkflowUtils.getStatusColor(workflow)} />
</div>
<div className="flex items-center space-x-2">
<Link
to={`executions`}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
>
{i18nT("workflows.navigation.viewAllExecutions")}
</Link>
<WorkflowRunDropdown workflow={workflow} />
</div>
</div>
</div>
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
height. On mobile the outer flex container only has min-height, so a
flex-1 child stays indefinite and the canvas collapses to 0. Use a
fixed vh height on mobile (always definite); flex sizing only on lg. */}
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
<LazyWorkflowBuilder
workflow={workflow}
selectedBlock={selectedBlock}
workflowExecution={null}
onSelectedBlock={setSelectedBlock}
onConnectBlocks={onConnectBlocks}
onDeleteConnection={(id) => onDeleteConnection({ id })}
onDeleteBlock={onDeleteBlock}
/>
</div>
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
<WorkflowEditorSidebar
workflow={workflow}
onSave={onSave}
selectedBlock={selectedBlock}
addingNextBlockFrom={addingNextBlockFrom}
onSelectedBlock={(block) => {
setSelectedBlock(block);
}}
onSaveBlock={onSaveBlock}
onSetTrigger={(type) => {
onAddBlock({ type: type, from: undefined, condition: null });
setAddingNextBlockFrom(null);
}}
onAddBlock={({ from, type, condition }) => {
onAddBlock({ type: type, from: from, condition });
setAddingNextBlockFrom(null);
}}
onAddingNextBlock={(data) => {
setAddingNextBlockFrom(data);
}}
onDeleteBlock={onDeleteBlock}
onDeleteConnection={onDeleteConnection}
onUpdateConditionsGroups={onUpdateConditionsGroups}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,116 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import { apiPath } from "~/utils/app/CanonicalUrlUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { getFirstApiKey } from "~/utils/db/apiKeys.db.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { validateCSRFToken } from "~/utils/session.server";
import { getAbsoluteApiUrl } from "~/utils/url.server";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
export namespace WorkflowsIdRunApiApi {
export type LoaderData = {
metatags: MetaTagsDto;
workflow: WorkflowDto;
apiRunUrl: string;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const workflow = await WorkflowsService.get(params.id!, {
tenantId,
});
if (!workflow) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.runApi", { value0: workflow.name, value1: webConfig.appName }) }],
workflow,
apiRunUrl: getAbsoluteApiUrl(request, apiPath(`workflows/run/${workflow.id}`, { mode: "host-aware" })),
};
return data;
};
export type ActionData = {
success?: string;
error?: string;
errorCode?: "noApiKey" | "noValidApiKey";
execution?: WorkflowExecutionDto;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
let apiKey: string | null = null;
if (tenantId === null) {
apiKey = loadWebConfig().config.apiAccessToken || null;
if (!apiKey) {
return Response.json({ error: workflowActionText(t, "workflows.errors.noApiKey", "No API key found"), errorCode: "noApiKey" }, { status: 400 });
}
} else {
const validApiKey = await getFirstApiKey(tenantId);
if (!validApiKey) {
return Response.json({ error: workflowActionText(t, "workflows.errors.noValidApiKey", "No valid API key found"), errorCode: "noValidApiKey" }, { status: 400 });
}
apiKey = validApiKey.key;
}
if (action === "execute") {
try {
const input = form.get("input")?.toString() ?? "{}";
const url = getAbsoluteApiUrl(request, apiPath(`workflows/run/${params.id}`, { mode: "host-aware" }));
const response = await fetch(url, {
method: "POST",
body: input,
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
},
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return Response.json({ execution: data.execution });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
}
} else if (action === "continue-execution") {
try {
const executionId = form.get("executionId")?.toString() ?? "";
const input = form.get("input")?.toString() ?? "{}";
const url = getAbsoluteApiUrl(request, apiPath(`workflows/executions/${executionId}/continue`, { mode: "host-aware" }));
const response = await fetch(url, {
method: "POST",
body: input,
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
},
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return Response.json({ execution: data.execution });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
}
}
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,242 @@
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useId, useRef, useState } from "react";
import toast from "react-hot-toast";
import MonacoEditor from "~/components/editors/MonacoEditor";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText from "~/components/ui/input/InputText";
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdRunApiApi } from "./workflows.$id.run.api.server";
import clsx from "clsx";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import { appendCSRFToken } from "~/utils/csrf";
import { SendIcon } from "~/components/ui/icons/vendor";
export default function WorkflowsIdRunApiApiView() {
const data = useLoaderData<WorkflowsIdRunApiApi.LoaderData>();
const actionData = useActionData<WorkflowsIdRunApiApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const navigation = useNavigation();
const isLoading = navigation.state === "submitting";
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
const [inputData, setInputData] = useState("{}");
const [waitingBlockInput, setWaitingBlockInput] = useState("");
const waitingBlockInputId = useId();
const waitingForInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
waitingForInputRef.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, [execution]);
useEffect(() => {
if (data.workflow.inputExamples.length > 0) {
setSelectedTemplate(data.workflow.inputExamples[0].title);
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
}
}, [data]);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
if (actionData?.execution) {
setExecution(actionData.execution);
}
}, [actionData]);
function onExecute() {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "execute");
form.append("input", inputData);
submit(form, {
method: "post",
});
}
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
appendCSRFToken(form);
form.set("action", "continue-execution");
form.set("executionId", execution?.id ?? "");
form.set("input", JSON.stringify({ input: waitingBlockInput }));
submit(form, {
method: "post",
});
setWaitingBlockInput("");
}
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple
menu={[
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
{ title: data.workflow.name },
]}
/>
</div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToExecutions")}
</Link>
<WorkflowRunDropdown workflow={data.workflow} />
</div>
</div>
<div data-ui-id="ui.workflows.run.api.form" className="mx-auto max-w-2xl space-y-2 p-4">
<div className="flex justify-between space-x-2">
<div data-ui-id="ui.workflows.run.api.title" className="text-lg font-semibold">
{i18nT("workflows.run.apiTitle")}
</div>
</div>
{!execution ? (
<div>
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.body")}</div>
{data.workflow.inputExamples.length > 0 && (
<div data-ui-id="ui.workflows.run.api.inputExampleSelect">
<WorkflowInputExamplesDropdown
workflow={data.workflow}
onSelected={(item) => {
setSelectedTemplate(item.title);
setInputData(JSON.stringify(item.input, null, 2));
}}
/>
</div>
)}
</div>
<div data-ui-id="ui.workflows.run.api.inputEditor" className="border-border overflow-hidden rounded-md border">
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
</div>
<InputText dataUiId="ui.workflows.run.api.field.url" title={i18nT("workflows.run.url")} defaultValue={data.apiRunUrl} readOnly />
<InputText dataUiId="ui.workflows.run.api.field.method" title={i18nT("workflows.run.method")} defaultValue="POST" readOnly />
<InputText
dataUiId="ui.workflows.run.api.field.apiKey"
title={i18nT("workflows.run.apiKeyHeader")}
defaultValue={i18nT("workflows.run.apiKeyPlaceholder")}
readOnly
/>
</div>
<div className="flex justify-end pt-2">
<LoadingButton dataUiId="ui.workflows.run.api.submit" actionName="execute" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)}>
{i18nT("workflows.actions.run")}
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
</LoadingButton>
</div>
</div>
) : (
<div className="space-y-4" data-ui-id="ui.workflows.run.api.executionResult">
<div className="grid grid-cols-2 gap-4">
<Link
target="_blank"
data-ui-id="ui.workflows.run.api.viewExecutionFlow"
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
</div>
</>
</Link>
<button
type="button"
data-ui-id="ui.workflows.run.api.runAgain"
onClick={() => {
setExecution(null);
}}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
</div>
</button>
</div>
<div className="border-border overflow-hidden rounded-md border">
<MonacoEditor className="h-40" theme="vs-dark" value={JSON.stringify({ execution }, null, 2)} hideLineNumbers tabSize={2} language="json" />
</div>
{execution.waitingBlock && (
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.api.waitingInput.form">
<div className="space-y-1">
{execution.waitingBlock.input.title && (
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
{execution.waitingBlock.input.title}
</label>
)}
<div className="relative flex items-center">
<input
ref={waitingForInputRef}
autoFocus
type="text"
name="input"
id={waitingBlockInputId}
data-ui-id="ui.workflows.run.api.waitingInput.input"
autoComplete="off"
value={waitingBlockInput}
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
className={clsx(
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
)}
placeholder={execution.waitingBlock.input.placeholder}
required
disabled={isLoading}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
<button
type="submit"
disabled={isLoading}
data-ui-id="ui.workflows.run.api.waitingInput.submit"
aria-label={i18nT("shared.submit")}
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
>
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
</button>
</div>
</div>
</div>
</Form>
)}
</div>
)}
{actionData?.error && (
<ErrorBanner title={i18nT("workflows.executionStatus.error")}>
<div>{actionData.error}</div>
{actionData.errorCode === "noValidApiKey" && params.tenant && (
<div>
<Link className="underline" target="_blank" to={appPath({ tenantSlug: params.tenant, path: "settings/api/keys", mode: "host-aware" })}>
{i18nT("workflows.run.createApiKeyLink")}
</Link>
</div>
)}
</ErrorBanner>
)}
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTranslations: vi.fn(),
getTenantIdOrNull: vi.fn(),
getUserInfo: vi.fn(),
requireWorkflowMutationAccess: vi.fn(),
validateCSRFToken: vi.fn(),
getWorkflow: vi.fn(),
queueManualExecution: vi.fn(),
queueContinuationExecution: vi.fn(),
continueExecution: vi.fn(),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: mocks.getTranslations,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("~/utils/session.server", () => ({
getUserInfo: mocks.getUserInfo,
validateCSRFToken: mocks.validateCSRFToken,
}));
vi.mock("../workflowAccess.server", () => ({
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
default: {
get: mocks.getWorkflow,
},
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
default: {
queueManualExecution: mocks.queueManualExecution,
queueContinuationExecution: mocks.queueContinuationExecution,
continueExecution: mocks.continueExecution,
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Signup flow",
description: "",
status: "live",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowsIdRunManualApi.action", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
mocks.validateCSRFToken.mockResolvedValue(undefined);
mocks.getWorkflow.mockResolvedValue(workflow());
});
it("rejects invalid execute input JSON before queueing and keeps the workflow lookup tenant-scoped", async () => {
const form = new URLSearchParams();
form.set("action", "execute");
form.set("input", "{");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunManualApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
await expect(response.json()).resolves.toEqual({ error: "Input data is not valid JSON" });
expect(response.status).toBe(400);
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
expect(mocks.queueManualExecution).not.toHaveBeenCalled();
});
it("queues manual executions instead of executing inline", async () => {
mocks.queueManualExecution.mockResolvedValue({ id: "execution_1", status: "running" });
const form = new URLSearchParams();
form.set("action", "execute");
form.set("input", JSON.stringify({ name: "Ada" }));
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunManualApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
success: "Workflow queued",
execution: { id: "execution_1", status: "running" },
});
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
expect(mocks.queueManualExecution).toHaveBeenCalledWith("workflow_1", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.queueContinuationExecution).not.toHaveBeenCalled();
expect(mocks.continueExecution).not.toHaveBeenCalled();
});
it("queues manual continuations instead of executing inline", async () => {
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waiting" });
const form = new URLSearchParams();
form.set("action", "continue-execution");
form.set("executionId", "execution_1");
form.set("input", "approved");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunManualApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({
success: "Workflow queued",
execution: { id: "execution_1", status: "waiting" },
});
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
type: "manual",
input: { input: "approved" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.continueExecution).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import UrlUtils from "~/utils/app/UrlUtils";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
export namespace WorkflowsIdRunManualApi {
export type LoaderData = {
metatags: MetaTagsDto;
workflow: WorkflowDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const workflow = await WorkflowsService.get(params.id!, {
tenantId,
});
if (!workflow) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.runManual", { value0: workflow.name, value1: webConfig.appName }) }],
workflow,
};
return data;
};
export type ActionData = {
success?: string;
error?: string;
execution?: WorkflowExecutionDto;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const { userId } = await getUserInfo(request);
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const workflow = await WorkflowsService.get(params.id!, { tenantId });
if (!workflow || workflow.status !== "live") {
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
}
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "execute") {
try {
let input = form.get("input")?.toString() ?? "{}";
let inputData: { [key: string]: any } | null = null;
if (!input.trim()) {
input = "{}";
}
if (input) {
try {
inputData = JSON.parse(input);
} catch {
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
}
}
const execution = await WorkflowsExecutionsService.queueManualExecution(params.id!, {
input: inputData,
session: {
tenantId,
userId,
},
});
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
}
} else if (action === "continue-execution") {
try {
const executionId = form.get("executionId")?.toString() ?? "";
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
type: "manual",
input: { input: form.get("input")?.toString() },
session: { tenantId, userId },
});
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
}
}
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,399 @@
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import clsx from "clsx";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Colors } from "~/application/enums/shared/Colors";
import MonacoEditor from "~/components/editors/MonacoEditor";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import { WorkflowBlockExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowBlockExecutionDto";
import { getWorkflowBlockName, WorkflowBlockTypes } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
import { WorkflowDto } from "../../../dtos/WorkflowDto";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { appendCSRFToken } from "~/utils/csrf";
import { formatWorkflowExecutionErrorText } from "~/modules/workflowEngine/utils/workflowExecutionErrorText";
import { SendIcon } from "~/components/ui/icons/vendor";
export default function WorkflowsIdRunManualView() {
const data = useLoaderData<WorkflowsIdRunManualApi.LoaderData>();
const actionData = useActionData<WorkflowsIdRunManualApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const navigation = useNavigation();
const isLoading = navigation.state === "submitting";
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
const [inputData, setInputData] = useState("{}");
const [waitingBlockInput, setWaitingBlockInput] = useState("");
const waitingBlockInputId = useId();
const waitingForInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
waitingForInputRef.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, [execution]);
useEffect(() => {
if (data.workflow.inputExamples.length > 0) {
setSelectedTemplate(data.workflow.inputExamples[0].title);
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.workflow.inputExamples.length]);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
if (actionData?.execution) {
setExecution(actionData.execution);
actionData?.execution?.executionAlerts.forEach((executionAlert) => {
if (executionAlert.type === "error") {
toast.error(executionAlert.message, {
position: "bottom-right",
duration: 10000,
});
} else {
toast.success(executionAlert.message, {
position: "bottom-right",
duration: 10000,
});
}
});
}
}, [actionData]);
function onExecute() {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "execute");
form.append("input", inputData);
submit(form, {
method: "post",
});
}
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
appendCSRFToken(form);
form.set("action", "continue-execution");
form.set("executionId", execution?.id ?? "");
submit(form, {
method: "post",
});
setWaitingBlockInput("");
}
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple
menu={[
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
{ title: data.workflow.name },
]}
/>
</div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToExecutions")}
</Link>
<WorkflowRunDropdown workflow={data.workflow} />
</div>
</div>
<div className="mx-auto max-w-2xl space-y-2 p-4">
<div className="flex justify-between space-x-2">
<div className="text-lg font-semibold" data-ui-id="ui.workflows.run.manual.title">
{i18nT("workflows.run.manualTitle")}
</div>
</div>
{!execution ? (
<div data-ui-id="ui.workflows.run.manual.form">
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
{data.workflow.inputExamples.length > 0 && (
<div data-ui-id="ui.workflows.run.manual.inputExampleSelect">
<WorkflowInputExamplesDropdown
workflow={data.workflow}
onSelected={(item) => {
setSelectedTemplate(item.title);
setInputData(JSON.stringify(item.input, null, 2));
}}
/>
</div>
)}
</div>
<div className="border-border overflow-hidden rounded-md border" data-ui-id="ui.workflows.run.manual.inputEditor">
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
</div>
</div>
<div className="flex justify-end pt-2">
<LoadingButton
actionName="execute"
onClick={onExecute}
disabled={!WorkflowUtils.canRun(data.workflow)}
className="ui-workflows-run-manual-submit"
dataUiId="ui.workflows.run.manual.submit"
>
{i18nT("workflows.actions.run")}
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
</LoadingButton>
</div>
</div>
) : (
<div className="space-y-4" data-ui-id="ui.workflows.run.manual.executionResult">
<div className="grid grid-cols-2 gap-4">
<Link
target="_blank"
data-ui-id="ui.workflows.run.manual.viewExecutionFlow"
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
</div>
</>
</Link>
<button
type="button"
data-ui-id="ui.workflows.run.manual.runAgain"
onClick={() => {
setExecution(null);
}}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
</div>
</button>
</div>
<WorkflowRun workflow={execution} />
<div className="text-foreground font-medium">{i18nT("workflows.executions.blocksExecuted", { count: execution.blockRuns.length })}</div>
<div className="w-full space-y-1">
{execution.blockRuns.map((blockRun) => {
return <BlockRun key={blockRun.id} workflow={data.workflow} blockRun={blockRun} />;
})}
</div>
{execution.waitingBlock && (
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.manual.waitingInput.form">
<div className="space-y-1">
{execution.waitingBlock.input.title && (
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
{execution.waitingBlock.input.title}
</label>
)}
<div className="relative flex items-center">
<input
ref={waitingForInputRef}
autoFocus
type="text"
name="input"
id={waitingBlockInputId}
data-ui-id="ui.workflows.run.manual.waitingInput.input"
autoComplete="off"
value={waitingBlockInput}
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
className={clsx(
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
)}
placeholder={execution.waitingBlock.input.placeholder}
required
disabled={isLoading}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
<button
type="submit"
disabled={isLoading}
data-ui-id="ui.workflows.run.manual.waitingInput.submit"
aria-label={i18nT("shared.submit")}
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
>
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
</button>
</div>
</div>
</div>
</Form>
)}
</div>
)}
{actionData?.error ? (
<div role="alert" data-ui-id="ui.workflows.run.manual.error">
<ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />
</div>
) : null}
</div>
</div>
);
}
function BlockRun({ workflow, blockRun }: { workflow: WorkflowDto; blockRun: WorkflowBlockExecutionDto }) {
const [isExpanded, setIsExpanded] = useState(false);
const outputPreview = useMemo(() => (blockRun.output ? JSON.stringify({ output: blockRun.output }) : null), [blockRun.output]);
const block = workflow.blocks.find((x) => x.id === blockRun.workflowBlockId);
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block?.type);
if (!block || !workflowBlock) {
return null;
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
return (
<div className="space-y-0">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
className={clsx(
"hover:bg-secondary border-border bg-background block w-full cursor-pointer overflow-hidden rounded-md border p-2 text-left select-none",
isExpanded ? "rounded-b-none" : "rounded-md"
)}
>
<div className="flex justify-between space-x-2">
<div className="flex items-center space-x-1 truncate">
<div className="flex items-center space-x-2 truncate">
<workflowBlock.icon aria-hidden="true" className="text-muted-foreground h-4 w-4 shrink-0" />
<div className="text-foreground/80 shrink-0 truncate text-xs font-medium">[{workflowBlockName}]</div>
{blockRun.error ? (
<div className="truncate text-xs text-red-600">{formatWorkflowExecutionErrorText(blockRun.error, i18nT)}</div>
) : outputPreview ? (
<div className="text-muted-foreground truncate text-xs">{outputPreview}</div>
) : null}
</div>
<div className="text-muted-foreground text-sm">{block.description || ""}</div>
</div>
<div className="shrink-0">
{block.type === "if" ? (
<div>
{blockRun.output?.condition ? (
<SimpleBadge title={i18nT("workflows.boolean.true")} color={Colors.BLUE} />
) : (
<SimpleBadge title={i18nT("workflows.boolean.false")} color={Colors.ORANGE} />
)}
</div>
) : block.type === "switch" ? (
<SimpleBadge title={blockRun.output?.condition?.toString()} color={Colors.BLUE} />
) : (
<div>
{blockRun.status === "running" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.running")} color={Colors.YELLOW} />
) : blockRun.status === "error" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.error")} color={Colors.RED} />
) : blockRun.status === "success" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.success")} color={Colors.GREEN} />
) : (
<SimpleBadge title={i18nT("workflows.executionStatus.unknown")} color={Colors.GRAY} />
)}
</div>
)}
</div>
</div>
</button>
{isExpanded && (
<div className="border-border bg-secondary/90 overflow-hidden rounded-md rounded-t-none border p-2">
{blockRun.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={formatWorkflowExecutionErrorText(blockRun.error, i18nT)} />}
<InputOutput input={blockRun.input} output={blockRun.output} />
</div>
)}
</div>
);
}
function WorkflowRun({ workflow }: { workflow: WorkflowExecutionDto }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="space-y-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<div className="flex justify-center">{i18nT("workflows.run.inputOutput")}</div>
</button>
{isExpanded && (
<div className="border-border bg-secondary/90 overflow-hidden rounded-md border p-2">
<InputOutput input={workflow.input} output={workflow.output} />
</div>
)}
</div>
);
}
function InputOutput({ input, output }: { input?: any; output?: any }) {
const compactInput = useMemo(() => (input ? JSON.stringify(input) : ""), [input]);
const compactOutput = useMemo(() => (output ? JSON.stringify(output) : ""), [output]);
const formattedInput = useMemo(() => (input ? JSON.stringify(input, null, 2) : ""), [input]);
const formattedOutput = useMemo(() => (output ? JSON.stringify(output, null, 2) : ""), [output]);
const hasInput = Boolean(input && compactInput !== "{}");
const hasOutput = Boolean(output && compactOutput !== "{}");
return (
<div className="space-y-1">
{!hasInput ? (
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noInput")}</div>
</div>
) : (
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.input")}</div>
<button
type="button"
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
onClick={() => navigator.clipboard.writeText(compactInput)}
>
{i18nT("workflows.actions.copy")}
</button>
</div>
<div className="prose max-h-24 overflow-auto">
<pre>{formattedInput}</pre>
</div>
</div>
)}
{!hasOutput ? (
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noOutput")}</div>
</div>
) : (
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.outputs")}</div>
<button
type="button"
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
onClick={() => navigator.clipboard.writeText(compactOutput)}
>
{i18nT("workflows.actions.copy")}
</button>
</div>
<div className="prose max-h-24 overflow-auto">
<pre>{formattedOutput}</pre>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { LoaderFunctionArgs } from "react-router";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import { requireAuth } from "~/utils/loaders.middleware";
import { emitter } from "~/utils/stream/emitter.server";
import { eventStream } from "~/utils/stream/event-stream";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { getTranslations } from "~/locale/i18next.server";
export const workflowExecutionStreamLoader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const executionId = params.executionId;
if (!executionId) {
return Response.json({ error: t("workflows.errors.executionIdRequired") }, { status: 400 });
}
const execution = await WorkflowsService.getExecution(executionId, { tenantId });
if (!execution) {
return Response.json({ error: t("workflows.errors.executionNotFound") }, { status: 404 });
}
const eventName = `workflowExecutionUpdate:${executionId}`;
return eventStream(request.signal, (send) => {
const listener = (data: string) => send({ data });
emitter.on(eventName, listener);
// Emit the current execution snapshot immediately (after subscribing, so no
// concurrent update is dropped). The executor only emits on UPDATES, so a
// subscriber that connects after the run already reached its state — a slow
// connect under load, a reload, or a late mount — would otherwise see an
// empty stream until the next change (which, for a waitingBlock, never comes
// until the user continues). Same wire shape as the emitter's updates.
send({ data: JSON.stringify(execution) });
return () => {
emitter.off(eventName, listener);
};
});
};
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTranslations: vi.fn(),
getTenantIdOrNull: vi.fn(),
getUserInfo: vi.fn(),
requireWorkflowMutationAccess: vi.fn(),
validateCSRFToken: vi.fn(),
getWorkflow: vi.fn(),
stream: vi.fn(),
queueContinuationExecution: vi.fn(),
continueExecution: vi.fn(),
}));
vi.mock("@toolctrl/config", () => ({
loadWebConfig: () => ({ config: { appName: "Test App" } }),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: mocks.getTranslations,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("~/utils/session.server", () => ({
getUserInfo: mocks.getUserInfo,
validateCSRFToken: mocks.validateCSRFToken,
}));
vi.mock("../workflowAccess.server", () => ({
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
default: {
get: mocks.getWorkflow,
},
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
default: {
stream: mocks.stream,
queueContinuationExecution: mocks.queueContinuationExecution,
continueExecution: mocks.continueExecution,
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Signup flow",
description: "",
status: "live",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowsIdRunStreamApi.action", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
mocks.validateCSRFToken.mockResolvedValue(undefined);
mocks.getWorkflow.mockResolvedValue(workflow());
});
it("continues stream wait blocks through the workflow outbox", async () => {
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waitingBlock" });
const form = new URLSearchParams();
form.set("action", "continue-execution");
form.set("executionId", "execution_1");
form.set("input", "approved");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/stream", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunStreamApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({
success: "Workflow queued",
execution: { id: "execution_1", status: "waitingBlock" },
});
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
type: "manual",
input: { input: "approved" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.continueExecution).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import UrlUtils from "~/utils/app/UrlUtils";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
export namespace WorkflowsIdRunStreamApi {
export type LoaderData = {
metatags: MetaTagsDto;
workflow: WorkflowDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const workflow = await WorkflowsService.get(params.id!, {
tenantId,
});
if (!workflow) {
return redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.runStream", { value0: workflow.name, value1: webConfig.appName }) }],
workflow,
};
return Response.json(data);
};
export type ActionData = {
success?: string;
error?: string;
executionId: string;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const { userId } = await getUserInfo(request);
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const workflow = await WorkflowsService.get(params.id!, { tenantId });
if (!workflow || workflow.status !== "live") {
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
}
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "execute") {
try {
let input = form.get("input")?.toString() ?? "{}";
let inputData: { [key: string]: any } | null = null;
if (!input.trim()) {
input = "{}";
}
if (input) {
try {
inputData = JSON.parse(input);
} catch {
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
}
}
const execution = await WorkflowsExecutionsService.stream(params.id!, {
input: inputData,
session: {
tenantId,
userId,
},
});
return Response.json({ executionId: execution.id });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
}
} else if (action === "continue-execution") {
try {
const executionId = form.get("executionId")?.toString() ?? "";
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
type: "manual",
input: { input: form.get("input")?.toString() },
session: { tenantId, userId },
});
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
}
}
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,120 @@
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import MonacoEditor from "~/components/editors/MonacoEditor";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import WorkflowStreamProgress from "~/modules/workflowEngine/components/workflows/stream/WorkflowStreamProgress";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { appendCSRFToken } from "~/utils/csrf";
export default function WorkflowsIdRunStreamView() {
const data = useLoaderData<WorkflowsIdRunStreamApi.LoaderData>();
const actionData = useActionData<WorkflowsIdRunStreamApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const [executionId, setExecutionId] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [inputData, setInputData] = useState("{}");
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (data.workflow.inputExamples.length > 0) {
setSelectedTemplate(data.workflow.inputExamples[0].title);
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
}
}, [data]);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
if (actionData?.executionId) {
setExecutionId(actionData.executionId);
setIsRunning(true);
}
}, [actionData]);
function onExecute() {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "execute");
form.append("input", inputData);
submit(form, {
method: "post",
});
}
return (
<div>
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
<BreadcrumbSimple
menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: data.workflow.name }]}
/>
</div>
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
className="text-muted-foreground rounded bg-white px-2 py-1 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToExecutions")}
</Link>
<WorkflowRunDropdown workflow={data.workflow} />
</div>
</div>
<div className="mx-auto max-w-2xl space-y-2 p-4">
<div className="flex justify-between space-x-2">
<div data-ui-id="ui.workflows.run.stream.title" className="text-lg font-semibold">
{i18nT("workflows.run.streamTitle")}
</div>
</div>
<div data-ui-id="ui.workflows.run.stream.form">
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
{data.workflow.inputExamples.length > 0 && (
<div data-ui-id="ui.workflows.run.stream.inputExampleSelect">
<WorkflowInputExamplesDropdown
workflow={data.workflow}
onSelected={(item) => {
setSelectedTemplate(item.title);
setInputData(JSON.stringify(item.input, null, 2));
}}
/>
</div>
)}
</div>
<div data-ui-id="ui.workflows.run.stream.inputEditor" className="border-border overflow-hidden rounded-md border">
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
</div>
</div>
<div className="flex justify-end pt-2">
<LoadingButton dataUiId="ui.workflows.run.stream.submit" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)} isLoading={isRunning}>
{i18nT("workflows.actions.run")}
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
</LoadingButton>
</div>
</div>
{executionId && (
<WorkflowStreamProgress
key={executionId}
streamUrl={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/run/stream/${executionId}`)}
onCompleted={() => setIsRunning(false)}
/>
)}
{actionData?.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />}
</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { deleteWorkflowCredential, getAllWorkflowCredentials } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
import UrlUtils from "~/utils/app/UrlUtils";
import { requireAuth } from "~/utils/loaders.middleware";
import { validateCSRFToken } from "~/utils/session.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsCredentialsApi {
export type LoaderData = {
metatags: MetaTagsDto;
items: WorkflowCredential[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
const items = await getAllWorkflowCredentials({ tenantId });
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.credentials", { value0: process.env.APP_NAME }) }],
items,
};
return data;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "delete") {
const id = form.get("id")?.toString() ?? "";
await deleteWorkflowCredential(id, { tenantId });
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
} else {
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
}
};
}
@@ -0,0 +1,85 @@
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
import { t as i18nT } from "i18next";
import { MetaFunction, useLoaderData } from "react-router";
import { Outlet, useSubmit } from "react-router";
import { useRef } from "react";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import DateCell from "~/components/ui/dates/DateCell";
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import TableSimple from "~/components/ui/tables/TableSimple";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowsCredentialsApi } from "./credentials.api.server";
import { appendCSRFToken } from "~/utils/csrf";
export const meta: MetaFunction = () => [{ title: i18nT("workflows.credentials.title") }];
export default function WorkflowsCredentialsView() {
const data = useLoaderData<WorkflowsCredentialsApi.LoaderData>();
const submit = useSubmit();
const confirmDelete = useRef<RefConfirmModal>(null);
function onDelete(item: WorkflowCredential) {
confirmDelete.current?.setValue(item);
confirmDelete.current?.show(
i18nT("workflows.credentials.confirmDelete.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.credentials.confirmDelete.description")
);
}
function onDeleteConfirm(item: WorkflowDto) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete");
form.set("id", item.id.toString() ?? "");
submit(form, {
method: "post",
});
}
return (
<EditPageLayout
title={i18nT("workflows.credentials.title")}
buttons={
<>
<ButtonPrimary to="new">{i18nT("workflows.actions.new")}</ButtonPrimary>
</>
}
>
<div className="space-y-3">
<TableSimple
headers={[
{
title: i18nT("workflows.columns.name"),
name: "name",
value: (item) => <div className="select-all">{`{{$credentials.${item.name}}}`}</div>,
},
{
title: i18nT("workflows.columns.value"),
name: "value",
className: "w-full",
value: (item) => <div className="max-w-sm truncate">{"".padEnd(36, "*")}</div>,
},
{
title: i18nT("shared.createdAt"),
name: "createdAt",
value: (item) => <DateCell date={item.createdAt} />,
},
]}
items={data.items}
actions={[
{
title: i18nT("workflows.actions.delete"),
onClick: (_, i) => onDelete(i),
destructive: true,
},
]}
/>
</div>
<Outlet />
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
</EditPageLayout>
);
}
@@ -0,0 +1,61 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import UrlUtils from "~/utils/app/UrlUtils";
import { createWorkflowCredential, getWorkflowCredentialByName } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import CryptoApi from "~/utils/api/.server/CryptoApi";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsCredentialsNewApi {
export type LoaderData = {
metatags: MetaTagsDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.newCredential", { value0: process.env.APP_NAME }) }],
};
return data;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const userInfo = await getUserInfo(request);
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString() ?? "";
const name = form.get("credential-name")?.toString() ?? "";
let value = form.get("credential-value")?.toString() ?? "";
if (action === "create") {
try {
const existing = await getWorkflowCredentialByName(name, { tenantId });
if (existing) {
throw Error(
t("workflows.errors.credentialAlreadyExists", {
value0: name,
})
);
}
value = CryptoApi.encrypt(value);
await createWorkflowCredential({
tenantId,
createdByUserId: userInfo.userId,
name,
value,
});
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400 });
}
}
return Response.json({ error: t("workflows.errors.invalidAction") }, { status: 400 });
};
}
@@ -0,0 +1,18 @@
import { useNavigate, useParams } from "react-router";
import { t as i18nT } from "i18next";
import WorkflowCredentialForm from "~/modules/workflowEngine/components/workflowVariables/WorkflowCredentialForm";
import UrlUtils from "~/utils/app/UrlUtils";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
export default function WorkflowsCredentialsNewView() {
const params = useParams();
const navigate = useNavigate();
function close() {
navigate(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
}
return (
<SlideOverWideEmpty title={i18nT("workflows.credentials.newTitle")} className="sm:max-w-sm" open={true} onClose={close}>
<WorkflowCredentialForm />
</SlideOverWideEmpty>
);
}
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTenantIdOrNull: vi.fn(),
workflowCount: vi.fn(),
credentialCount: vi.fn(),
variableCount: vi.fn(),
executionCount: vi.fn(),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("~/utils/db.server", () => ({
db: {
workflow: { count: mocks.workflowCount },
workflowCredential: { count: mocks.credentialCount },
workflowVariable: { count: mocks.variableCount },
workflowExecution: { count: mocks.executionCount },
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsDangerApi } from "./danger.api.server";
describe("WorkflowsDangerApi.loader", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
});
it("loads destructive-scope summary counts concurrently", async () => {
const workflows = deferred<number>();
const credentials = deferred<number>();
const variables = deferred<number>();
const executions = deferred<number>();
mocks.workflowCount.mockReturnValue(workflows.promise);
mocks.credentialCount.mockReturnValue(credentials.promise);
mocks.variableCount.mockReturnValue(variables.promise);
mocks.executionCount.mockReturnValue(executions.promise);
const loaderPromise = WorkflowsDangerApi.loader({
request: new Request("https://app.example.test/acme/workflow-engine/danger"),
params: { tenant: "acme" },
context: {},
});
await Promise.resolve();
await Promise.resolve();
expect(mocks.workflowCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
expect(mocks.credentialCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
expect(mocks.variableCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
expect(mocks.executionCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
workflows.resolve(2);
credentials.resolve(3);
variables.resolve(5);
executions.resolve(8);
await expect(loaderPromise).resolves.toMatchObject({
summary: {
workflows: 2,
credentials: 3,
variables: 5,
executions: 8,
},
});
});
});
@@ -0,0 +1,89 @@
import { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { db } from "~/utils/db.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { validateCSRFToken } from "~/utils/session.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsDangerApi {
export type LoaderData = {
metatags: MetaTagsDto;
summary: {
workflows: number;
variables: number;
credentials: number;
executions: number;
};
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const [workflows, credentials, variables, executions] = await Promise.all([
db.workflow.count({ where: { tenantId } }),
db.workflowCredential.count({ where: { tenantId } }),
db.workflowVariable.count({ where: { tenantId } }),
db.workflowExecution.count({ where: { tenantId } }),
]);
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.danger", { value0: process.env.APP_NAME }) }],
summary: {
workflows,
credentials,
variables,
executions,
},
};
return data;
};
export type ActionData = {
error?: string;
success?: string;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "reset-all-data") {
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.workflowDataReset", {
value0: workflows.count,
value1: variables.count,
value2: credentials.count,
}),
});
} else if (action === "delete-all-executions") {
const executions = await db.workflowExecution.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.executionsDeleted", { count: executions.count }),
});
} else if (action === "delete-all-credentials") {
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.credentialsDeleted", { count: credentials.count }),
});
} else if (action === "delete-all-variables") {
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.variablesDeleted", { count: variables.count }),
});
} else if (action === "delete-all-workflows") {
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.workflowsDeleted", { count: workflows.count }),
});
} else {
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
}
};
}
@@ -0,0 +1,151 @@
import { useActionData, useLoaderData, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { WorkflowsDangerApi } from "./danger.api.server";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import DropdownOptions from "~/components/ui/dropdowns/DropdownOptions";
import { Menu } from "@headlessui/react";
import clsx from "clsx";
import { appendCSRFToken } from "~/utils/csrf";
export default function WorkflowsDangerView() {
const data = useLoaderData<WorkflowsDangerApi.LoaderData>();
const actionData = useActionData<WorkflowsDangerApi.ActionData>();
const submit = useSubmit();
const confirmReset = useRef<RefConfirmModal>(null);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
}, [actionData]);
function onReset(type: string) {
confirmReset.current?.setValue(type);
if (type === "reset-all-data") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmResetAll.title"),
i18nT("workflows.danger.actions.reset"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmResetAll.description")
);
} else if (type === "delete-all-executions") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteExecutions.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteExecutions.description")
);
} else if (type === "delete-all-variables") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteVariables.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteVariables.description")
);
} else if (type === "delete-all-credentials") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteCredentials.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteCredentials.description")
);
} else if (type === "delete-all-workflows") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteWorkflows.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteWorkflows.description")
);
}
}
function onResetConfirm(type: string) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", type);
submit(form, {
method: "post",
});
}
return (
<div className="h-screen flex-1 overflow-x-auto xl:overflow-y-auto">
<div className="mx-auto max-w-5xl px-4 py-4 sm:px-6 lg:px-8 lg:py-12">
<h1 className="text-foreground text-3xl font-bold tracking-tight">{i18nT("workflows.danger.title")}</h1>
<div className="divide-y-gray-200 mt-6 space-y-8 divide-y">
<div className="grid grid-cols-1 gap-y-6 sm:grid-cols-6 sm:gap-x-6">
<div className="sm:col-span-6">
<h2 className="text-foreground text-xl font-medium">{i18nT("workflows.danger.confirmResetAll.title")}</h2>
<p className="text-muted-foreground mt-1 text-sm">{i18nT("workflows.danger.confirmResetAll.description")}</p>
</div>
</div>
<div className="flex justify-end space-x-2 pt-8">
<DropdownOptions
width="w-80"
button={
<div className="hover:bg-secondary border-border bg-background rounded-md border px-2 py-2 text-sm font-medium text-red-600">
{i18nT("workflows.danger.actions.resetData")}
</div>
}
options={
<div>
{[
{
action: "delete-all-credentials",
title: i18nT("workflows.danger.options.deleteAllCredentials", { count: data.summary.credentials }),
},
{
action: "delete-all-variables",
title: i18nT("workflows.danger.options.deleteAllVariables", { count: data.summary.variables }),
},
{
action: "delete-all-executions",
title: i18nT("workflows.danger.options.deleteAllExecutions", { count: data.summary.executions }),
},
{
action: "delete-all-workflows",
title: i18nT("workflows.danger.options.deleteAllWorkflows", { count: data.summary.workflows }),
},
{
action: "reset-all-data",
title: i18nT("workflows.danger.confirmResetAll.title"),
className: "text-red-600 font-medium",
},
].map((option) => {
return (
<Menu.Item key={option.action}>
{({ active }) => (
<button
type="button"
onClick={() => onReset(option.action)}
className={clsx(
option.className,
"w-full truncate text-left",
active ? "text-foreground bg-secondary/90" : "text-foreground/80",
"block px-4 py-2 text-sm"
)}
>
{option.title}
</button>
)}
</Menu.Item>
);
})}
</div>
}
></DropdownOptions>
</div>
</div>
</div>
<ConfirmModal ref={confirmReset} onYes={onResetConfirm} destructive />
</div>
);
}
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff. Show more