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>
);
}