Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+60
@@ -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>
|
||||
);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { FeatherBox } from "@subframe/core"
|
||||
|
||||
import { featherIcon } from "~/components/ui/icons/_feather"
|
||||
|
||||
export default featherIcon(FeatherBox)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { FeatherKey } from "@subframe/core"
|
||||
|
||||
import { featherIcon } from "~/components/ui/icons/_feather"
|
||||
|
||||
export default featherIcon(FeatherKey)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { FeatherKey } from "@subframe/core"
|
||||
|
||||
import { featherIcon } from "~/components/ui/icons/_feather"
|
||||
|
||||
export default featherIcon(FeatherKey)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { FeatherPlay } from "@subframe/core"
|
||||
|
||||
import { featherIcon } from "~/components/ui/icons/_feather"
|
||||
|
||||
export default featherIcon(FeatherPlay)
|
||||
+5
@@ -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)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { FeatherWorkflow } from "@subframe/core"
|
||||
|
||||
import { featherIcon } from "~/components/ui/icons/_feather"
|
||||
|
||||
export default featherIcon(FeatherWorkflow)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { FeatherVariable } from "@subframe/core"
|
||||
|
||||
import { featherIcon } from "~/components/ui/icons/_feather"
|
||||
|
||||
export default featherIcon(FeatherVariable)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { FeatherVariable } from "@subframe/core"
|
||||
|
||||
import { featherIcon } from "~/components/ui/icons/_feather"
|
||||
|
||||
export default featherIcon(FeatherVariable)
|
||||
+99
@@ -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>
|
||||
);
|
||||
}
|
||||
+92
@@ -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>
|
||||
);
|
||||
}
|
||||
+173
@@ -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;
|
||||
+150
@@ -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>
|
||||
);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { ArrowLeftRightIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function ConditionalIcon({ className }: { className?: string }) {
|
||||
return <ArrowLeftRightIcon className={className} />;
|
||||
}
|
||||
+7
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
@@ -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>
|
||||
);
|
||||
}
|
||||
+17
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
@@ -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>
|
||||
);
|
||||
}
|
||||
+43
@@ -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>
|
||||
);
|
||||
}
|
||||
+96
@@ -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>
|
||||
);
|
||||
}
|
||||
+107
@@ -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>
|
||||
);
|
||||
}
|
||||
+65
@@ -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>
|
||||
);
|
||||
}
|
||||
+64
@@ -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>
|
||||
);
|
||||
}
|
||||
+51
@@ -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>
|
||||
);
|
||||
}
|
||||
+12
@@ -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>
|
||||
);
|
||||
}
|
||||
+143
@@ -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()");
|
||||
});
|
||||
});
|
||||
+673
@@ -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>
|
||||
);
|
||||
}
|
||||
+141
@@ -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>
|
||||
);
|
||||
}
|
||||
+266
@@ -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>
|
||||
);
|
||||
}
|
||||
+99
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
+326
@@ -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>
|
||||
);
|
||||
}
|
||||
+264
@@ -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>
|
||||
);
|
||||
}
|
||||
+69
@@ -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();
|
||||
});
|
||||
});
|
||||
+191
@@ -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>
|
||||
);
|
||||
}
|
||||
+29
@@ -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"');
|
||||
});
|
||||
});
|
||||
+47
@@ -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>
|
||||
);
|
||||
}
|
||||
+102
@@ -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>
|
||||
);
|
||||
}
|
||||
+17
@@ -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>
|
||||
);
|
||||
}
|
||||
+156
@@ -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();
|
||||
});
|
||||
});
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { Form, useNavigation, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { getWorkflowBlockName, WorkflowBlockTypes } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import { formatWorkflowExecutionErrorText } from "~/modules/workflowEngine/utils/workflowExecutionErrorText";
|
||||
import { useEventSource } from "~/utils/stream/use-event-source";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
import { SendIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
interface Props {
|
||||
streamUrl: string;
|
||||
onCompleted: () => void;
|
||||
}
|
||||
export default function WorkflowStreamProgress({ streamUrl, onCompleted }: Props) {
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const lastBlockExecution = useEventSource(streamUrl);
|
||||
const isLoading = navigation.state === "submitting";
|
||||
|
||||
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
|
||||
const [waitingBlockInput, setWaitingBlockInput] = useState("");
|
||||
const waitingBlockInputId = useId();
|
||||
|
||||
const waitingForInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
waitingForInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [lastBlockExecution]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (lastBlockExecution) {
|
||||
let execution = JSON.parse(lastBlockExecution) as WorkflowExecutionDto;
|
||||
if (execution.status === "running" || execution.status === "waitingBlock") {
|
||||
onCompleted();
|
||||
}
|
||||
setExecution(execution);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
}, [lastBlockExecution, onCompleted]);
|
||||
|
||||
// useEffect(() => {
|
||||
// setBlockExecutions((blockExecutions) => {
|
||||
// if (!lastBlockExecution) {
|
||||
// return blockExecutions;
|
||||
// }
|
||||
// const data = JSON.parse(lastBlockExecution) as WorkflowStreamProgressDto;
|
||||
// const existing = blockExecutions.find((item) => item.blockExecution.id === data.blockExecution.id);
|
||||
// if (existing) {
|
||||
// return blockExecutions;
|
||||
// }
|
||||
|
||||
// if (data.result && data.block?.type === "alertUser") {
|
||||
// let { type, message } = data.result.output;
|
||||
// if (message) {
|
||||
// if (type === "error") {
|
||||
// toast.error(message, {
|
||||
// position: "bottom-right",
|
||||
// duration: 10000,
|
||||
// });
|
||||
// } else {
|
||||
// toast.success(message, {
|
||||
// position: "bottom-right",
|
||||
// duration: 10000,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (data.workflowCompleted) {
|
||||
// onCompleted();
|
||||
// }
|
||||
// return [...blockExecutions, data];
|
||||
// });
|
||||
// }, [lastBlockExecution, onCompleted]);
|
||||
|
||||
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", execution?.id ?? "");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
setWaitingBlockInput("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-ui-id="ui.workflows.run.stream.progress">
|
||||
<div className="space-y-4">
|
||||
{execution?.blockRuns.map((blockRun, index) => {
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === blockRun.workflowBlock.type);
|
||||
if (!workflowBlock) {
|
||||
return <div key={index}>{i18nT("workflows.blocks.node.unknownBlockType", { value: blockRun.workflowBlock.type })}</div>;
|
||||
}
|
||||
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
|
||||
return (
|
||||
<div key={index} className="border-border cursor-pointer overflow-hidden rounded-md border bg-white p-2 select-none hover:bg-gray-50">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex items-center space-x-2 truncate">
|
||||
<workflowBlock.icon aria-hidden="true" className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<div className="text-muted-foreground flex-shrink-0 truncate text-xs font-medium">[{workflowBlockName}]</div>
|
||||
{blockRun.error ? (
|
||||
<div className="truncate text-xs text-red-600">{formatWorkflowExecutionErrorText(blockRun.error, i18nT)}</div>
|
||||
) : blockRun.output ? (
|
||||
<div className="text-muted-foreground truncate text-xs">{JSON.stringify({ output: blockRun.output })}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{blockRun.workflowBlock.type === "if" ? (
|
||||
<div>
|
||||
{blockRun.output?.condition ? (
|
||||
<SimpleBadge title={i18nT("workflows.boolean.true")} color={Colors.BLUE} />
|
||||
) : (
|
||||
<SimpleBadge title={i18nT("workflows.boolean.false")} color={Colors.ORANGE} />
|
||||
)}
|
||||
</div>
|
||||
) : blockRun.workflowBlock.type === "switch" ? (
|
||||
<SimpleBadge title={blockRun.output?.condition?.toString()} color={Colors.BLUE} />
|
||||
) : (
|
||||
<div>
|
||||
{blockRun.status === "running" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.running")} color={Colors.YELLOW} />
|
||||
) : blockRun.status === "error" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.error")} color={Colors.RED} />
|
||||
) : blockRun.status === "success" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.success")} color={Colors.GREEN} />
|
||||
) : (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.unknown")} color={Colors.GRAY} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{execution?.waitingBlock && (
|
||||
<Form data-ui-id="ui.workflows.run.stream.waitingInput.form" onSubmit={onSubmitWaitingBlock}>
|
||||
<div className="space-y-1">
|
||||
{execution.waitingBlock.input.title && (
|
||||
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
|
||||
{execution.waitingBlock.input.title}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={waitingForInputRef}
|
||||
data-ui-id="ui.workflows.run.stream.waitingInput.input"
|
||||
autoFocus
|
||||
type="text"
|
||||
name="input"
|
||||
id={waitingBlockInputId}
|
||||
autoComplete="off"
|
||||
value={waitingBlockInput}
|
||||
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
|
||||
className={clsx(
|
||||
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
|
||||
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
|
||||
)}
|
||||
placeholder={execution.waitingBlock.input.placeholder}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
data-ui-id="ui.workflows.run.stream.waitingInput.submit"
|
||||
disabled={isLoading}
|
||||
aria-label={i18nT("shared.submit")}
|
||||
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
const WorkflowContext = createContext<{
|
||||
isReadOnly: boolean;
|
||||
onEdgeDelete: (edgeId: string) => void;
|
||||
onNodeDelete: (nodeId: string) => void;
|
||||
isNodeSelected: (nodeId: string) => boolean;
|
||||
incomingBlockIds?: ReadonlySet<string>;
|
||||
}>({
|
||||
isReadOnly: false,
|
||||
onEdgeDelete: (edgeId) => {},
|
||||
onNodeDelete: (nodeId) => {},
|
||||
isNodeSelected: (nodeId) => false,
|
||||
});
|
||||
export default WorkflowContext;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "workflowBlocks.db.server.ts"), "utf8");
|
||||
|
||||
describe("workflow block tenant scope", () => {
|
||||
it("verifies parent workflow tenant before creating blocks", () => {
|
||||
expect(source).toContain("tenantId: string | null");
|
||||
expect(source).toContain("db.workflow.findFirstOrThrow");
|
||||
expect(source).toContain("where: { id: data.workflowId, tenantId: data.tenantId }");
|
||||
});
|
||||
|
||||
it("updates blocks through tenant-scoped parent workflow filters", () => {
|
||||
expect(source).toContain("updateWorkflowBlock(\n scope: { id: string; workflowId: string; tenantId: string | null }");
|
||||
expect(source).toContain("db.workflowBlock.updateMany");
|
||||
expect(source).toContain("workflow: { tenantId: scope.tenantId }");
|
||||
expect(source).toContain("db.workflowBlock.findFirstOrThrow");
|
||||
expect(source).not.toContain("db.workflowBlock.update({");
|
||||
expect(source).not.toContain("where: { id: scope.id, workflowId: scope.workflowId },");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { WorkflowBlock, WorkflowBlockCondition, WorkflowBlockConditionGroup, WorkflowBlockToBlock } from "@toolctrl/db/prisma-browser";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export type WorkflowBlockWithDetails = WorkflowBlock & {
|
||||
toBlocks: (WorkflowBlockToBlock & {
|
||||
toBlock: WorkflowBlock;
|
||||
})[];
|
||||
conditionsGroups: (WorkflowBlockConditionGroup & {
|
||||
conditions: WorkflowBlockCondition[];
|
||||
})[];
|
||||
};
|
||||
|
||||
export async function getWorkflowBlockTypes(workflowId: string) {
|
||||
const workflowBlocks = await db.workflowBlock.findMany({
|
||||
where: {
|
||||
workflowId,
|
||||
},
|
||||
select: { type: true },
|
||||
});
|
||||
return workflowBlocks.map((block) => block.type);
|
||||
}
|
||||
|
||||
export async function createWorkflowBlock(data: {
|
||||
workflowId: string;
|
||||
tenantId: string | null;
|
||||
type: string;
|
||||
input: string;
|
||||
description: string;
|
||||
isTrigger: boolean;
|
||||
isBlock: boolean;
|
||||
// positionX: number;
|
||||
// positionY: number;
|
||||
}) {
|
||||
await db.workflow.findFirstOrThrow({
|
||||
where: { id: data.workflowId, tenantId: data.tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
return await db.workflowBlock.create({
|
||||
data: {
|
||||
workflowId: data.workflowId,
|
||||
type: data.type,
|
||||
description: data.description,
|
||||
isTrigger: data.isTrigger,
|
||||
isBlock: data.isBlock,
|
||||
input: data.input,
|
||||
// positionX: data.positionX,
|
||||
// positionY: data.positionY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowBlock(
|
||||
scope: { id: string; workflowId: string; tenantId: string | null },
|
||||
data: {
|
||||
type?: string;
|
||||
input?: string;
|
||||
description?: string;
|
||||
isTrigger?: boolean;
|
||||
isBlock?: boolean;
|
||||
}
|
||||
) {
|
||||
const updated = await db.workflowBlock.updateMany({
|
||||
where: {
|
||||
id: scope.id,
|
||||
workflowId: scope.workflowId,
|
||||
workflow: { tenantId: scope.tenantId },
|
||||
},
|
||||
data: {
|
||||
type: data.type,
|
||||
description: data.description,
|
||||
input: data.input,
|
||||
isTrigger: data.isTrigger,
|
||||
isBlock: data.isBlock,
|
||||
// positionX: data.positionX,
|
||||
// positionY: data.positionY,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow block ${scope.id} not found`);
|
||||
}
|
||||
return await db.workflowBlock.findFirstOrThrow({
|
||||
where: {
|
||||
id: scope.id,
|
||||
workflowId: scope.workflowId,
|
||||
workflow: { tenantId: scope.tenantId },
|
||||
},
|
||||
});
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowCredentialDbTestMocks = {
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowCredentialDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowCredentialDbTestMocks__?: WorkflowCredentialDbTestMocks };
|
||||
store.__workflowCredentialDbTestMocks__ ??= {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
return store.__workflowCredentialDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflowCredential: {
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
deleteMany: getDbMocks().deleteMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { deleteWorkflowCredential, updateWorkflowCredential } from "./workflowCredentials.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow credential mutations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "credential_1", tenantId: "tenant_1", name: "token", value: "next" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
it("updates credentials with tenant scope", async () => {
|
||||
await expect(updateWorkflowCredential("credential_1", { tenantId: "tenant_1", value: "next" })).resolves.toEqual({
|
||||
id: "credential_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "token",
|
||||
value: "next",
|
||||
});
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "credential_1", tenantId: "tenant_1" },
|
||||
data: { value: "next" },
|
||||
});
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "credential_1", tenantId: "tenant_1" } });
|
||||
});
|
||||
|
||||
it("deletes credentials with tenant scope", async () => {
|
||||
await deleteWorkflowCredential("credential_1", { tenantId: "tenant_1" });
|
||||
|
||||
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "credential_1", tenantId: "tenant_1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when scoped updates or deletes miss", async () => {
|
||||
dbMocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
dbMocks.deleteMany.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await expect(updateWorkflowCredential("missing", { tenantId: "tenant_1", value: "next" })).rejects.toThrow(
|
||||
"Workflow credential missing not found"
|
||||
);
|
||||
await expect(deleteWorkflowCredential("missing", { tenantId: "tenant_1" })).rejects.toThrow("Workflow credential missing not found");
|
||||
});
|
||||
});
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export async function getAllWorkflowCredentials({ tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowCredential.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowCredentialById(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowCredential
|
||||
.findFirstOrThrow({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowCredentialByName(name: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowCredential
|
||||
.findFirstOrThrow({
|
||||
where: { name, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflowCredential({
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
createdByUserId: string | null;
|
||||
name: string;
|
||||
value: string;
|
||||
}) {
|
||||
return await db.workflowCredential.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowCredential(id: string, { tenantId, value }: { tenantId: string | null; value?: string }) {
|
||||
const updated = await db.workflowCredential.updateMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
data: {
|
||||
value,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow credential ${id} not found`);
|
||||
}
|
||||
return await getWorkflowCredentialById(id, { tenantId });
|
||||
}
|
||||
|
||||
export async function deleteWorkflowCredential(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
const deleted = await db.workflowCredential.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(`Workflow credential ${id} not found`);
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowExecutionDbTestMocks = {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowExecutionDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowExecutionDbTestMocks__?: WorkflowExecutionDbTestMocks };
|
||||
store.__workflowExecutionDbTestMocks__ ??= {
|
||||
findMany: vi.fn(),
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
};
|
||||
return store.__workflowExecutionDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflowExecution: {
|
||||
findMany: getDbMocks().findMany,
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
count: getDbMocks().count,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { getAllWorkflowExecutions, getWorkflowExecutions, updateWorkflowExecution } from "./workflowExecutions.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow execution filters", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findMany.mockResolvedValue([]);
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "execution_1", tenantId: "tenant_1" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it("maps type filters to workflow execution type instead of workflow id", async () => {
|
||||
await getAllWorkflowExecutions({
|
||||
tenantId: "tenant_1",
|
||||
pagination: { page: 1, pageSize: 25 },
|
||||
filters: {
|
||||
query: "",
|
||||
properties: [
|
||||
{ name: "workflowId", title: "Workflow", value: "workflow_1" },
|
||||
{ name: "status", title: "Status", value: "running" },
|
||||
{ name: "type", title: "Type", value: "api" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbMocks.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
tenantId: "tenant_1",
|
||||
appliesToAllTenants: false,
|
||||
workflowId: "workflow_1",
|
||||
status: "running",
|
||||
type: "api",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a lightweight list projection for workflow execution indexes", async () => {
|
||||
await getAllWorkflowExecutions({
|
||||
tenantId: "tenant_1",
|
||||
pagination: { page: 2, pageSize: 10 },
|
||||
filters: {
|
||||
query: "",
|
||||
properties: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbMocks.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: 10,
|
||||
skip: 10,
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
workflowId: true,
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
_count: { select: { blockRuns: true } },
|
||||
blockRuns: {
|
||||
take: 1,
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: {
|
||||
workflowBlock: {
|
||||
select: { type: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const query = dbMocks.findMany.mock.calls[0][0];
|
||||
expect(query).not.toHaveProperty("include");
|
||||
expect(query.select).not.toHaveProperty("input");
|
||||
expect(query.select).not.toHaveProperty("output");
|
||||
expect(query.select).not.toHaveProperty("waitingBlock");
|
||||
expect(dbMocks.count).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
tenantId: "tenant_1",
|
||||
appliesToAllTenants: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates workflow executions only within the caller tenant scope", async () => {
|
||||
await updateWorkflowExecution(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1" },
|
||||
{
|
||||
status: "success",
|
||||
output: "{\"ok\":true}",
|
||||
duration: 12,
|
||||
error: null,
|
||||
waitingBlockId: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "execution_1", tenantId: "tenant_1" },
|
||||
data: expect.objectContaining({
|
||||
status: "success",
|
||||
output: "{\"ok\":true}",
|
||||
duration: 12,
|
||||
error: null,
|
||||
waitingBlockId: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "execution_1", tenantId: "tenant_1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps global workflow execution lists scoped to tenantId null", async () => {
|
||||
await getWorkflowExecutions({ workflowId: "workflow_global" }, { tenantId: null });
|
||||
|
||||
expect(dbMocks.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { workflowId: "workflow_global", tenantId: null },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import { Prisma, WorkflowBlockExecution, WorkflowExecution } from "@toolctrl/db/prisma-browser";
|
||||
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { WorkflowBlockWithDetails } from "./workflowBlocks.db.server";
|
||||
import { WorkflowStatus } from "../dtos/WorkflowStatus";
|
||||
|
||||
export type WorkflowExecutionWithDetails = WorkflowExecution & {
|
||||
workflow: { id: string; name: string };
|
||||
tenant: { id: string; name: string; slug: string } | null;
|
||||
waitingBlock: WorkflowBlockWithDetails | null;
|
||||
blockRuns: (WorkflowBlockExecution & {
|
||||
workflowBlock: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
})[];
|
||||
};
|
||||
|
||||
export type WorkflowExecutionListItem = Pick<WorkflowExecution, "id" | "workflowId" | "type" | "status" | "createdAt" | "endedAt" | "error"> & {
|
||||
workflow: { id: string; name: string };
|
||||
tenant: { id: string; name: string; slug: string } | null;
|
||||
_count: { blockRuns: number };
|
||||
blockRuns: {
|
||||
workflowBlock: {
|
||||
type: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
type WorkflowExecutionClient = Pick<typeof db, "workflowExecution">;
|
||||
|
||||
export async function getAllWorkflowExecutions({
|
||||
tenantId,
|
||||
pagination,
|
||||
filters,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
pagination: { page: number; pageSize: number };
|
||||
filters: FiltersDto;
|
||||
}): Promise<{ items: WorkflowExecutionListItem[]; pagination: PaginationDto }> {
|
||||
let where: Prisma.WorkflowExecutionWhereInput = {};
|
||||
if (tenantId === null) {
|
||||
where = {
|
||||
OR: [{ appliesToAllTenants: true }, { tenantId: null }],
|
||||
};
|
||||
} else {
|
||||
where = {
|
||||
tenantId,
|
||||
appliesToAllTenants: false,
|
||||
};
|
||||
}
|
||||
const workflowId = filters?.properties.find((f) => f.name === "workflowId")?.value ?? filters?.query ?? "";
|
||||
const status = filters?.properties.find((f) => f.name === "status")?.value ?? filters?.query ?? "";
|
||||
const type = filters?.properties.find((f) => f.name === "type")?.value ?? filters?.query ?? "";
|
||||
if (workflowId) {
|
||||
where.workflowId = workflowId;
|
||||
}
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
if (type) {
|
||||
where.type = type;
|
||||
}
|
||||
const [items, totalItems] = await Promise.all([
|
||||
db.workflowExecution.findMany({
|
||||
take: pagination.pageSize,
|
||||
skip: pagination.pageSize * (pagination.page - 1),
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
workflowId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
endedAt: true,
|
||||
error: true,
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
_count: { select: { blockRuns: true } },
|
||||
blockRuns: {
|
||||
take: 1,
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: {
|
||||
workflowBlock: {
|
||||
select: { type: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
db.workflowExecution.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / pagination.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWorkflowExecutions(
|
||||
{ workflowId }: { workflowId: string },
|
||||
session: { tenantId: string | null }
|
||||
): Promise<WorkflowExecutionWithDetails[]> {
|
||||
const where: Prisma.WorkflowExecutionWhereInput = {
|
||||
workflowId,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
return db.workflowExecution.findMany({
|
||||
where,
|
||||
include: {
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
waitingBlock: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
blockRuns: {
|
||||
orderBy: { startedAt: "asc" },
|
||||
include: {
|
||||
workflowBlock: {
|
||||
select: { type: true, description: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowExecution(
|
||||
id: string,
|
||||
session: { tenantId: string | null },
|
||||
client: WorkflowExecutionClient = db
|
||||
): Promise<WorkflowExecutionWithDetails | null> {
|
||||
// findFirst (not findFirstOrThrow): the return type is `| null` and every
|
||||
// caller null-checks. A stale/deleted executionId (e.g. a deep link to an
|
||||
// execution removed by a re-seed) must resolve to null → graceful 404, not an
|
||||
// unhandled throw that renders a 500 builder/executions page.
|
||||
return client.workflowExecution
|
||||
.findFirst({
|
||||
where: {
|
||||
id,
|
||||
tenantId: session.tenantId,
|
||||
},
|
||||
include: {
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
waitingBlock: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
blockRuns: {
|
||||
orderBy: { startedAt: "asc" },
|
||||
include: {
|
||||
workflowBlock: {
|
||||
select: { type: true, description: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
export async function countWorkflowExecutions({ tenantId }: { tenantId: string | null }): Promise<number> {
|
||||
let where: Prisma.WorkflowExecutionWhereInput = {};
|
||||
if (tenantId === null) {
|
||||
where = {
|
||||
OR: [{ appliesToAllTenants: true }, { tenantId: null }],
|
||||
};
|
||||
} else {
|
||||
where = {
|
||||
tenantId,
|
||||
appliesToAllTenants: false,
|
||||
};
|
||||
}
|
||||
return await db.workflowExecution.count({
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowExecution(
|
||||
id: string,
|
||||
session: { tenantId: string | null },
|
||||
{
|
||||
error,
|
||||
status,
|
||||
output,
|
||||
duration,
|
||||
waitingBlockId,
|
||||
}: {
|
||||
error: string | null;
|
||||
status: WorkflowStatus;
|
||||
output: string;
|
||||
duration: number;
|
||||
waitingBlockId: string | null;
|
||||
},
|
||||
client: WorkflowExecutionClient = db
|
||||
): Promise<WorkflowExecutionWithDetails> {
|
||||
const updated = await client.workflowExecution.updateMany({
|
||||
where: { id, tenantId: session.tenantId },
|
||||
data: {
|
||||
status: error ? "error" : status,
|
||||
output,
|
||||
duration: Math.round(duration),
|
||||
endedAt: new Date(),
|
||||
error,
|
||||
waitingBlockId,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
const execution = await getWorkflowExecution(id, session, client);
|
||||
if (!execution) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
return execution;
|
||||
}
|
||||
|
||||
export async function deleteWorkflowExecution(id: string, session: { tenantId: string | null }) {
|
||||
return await db.workflowExecution.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId: session.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowVariableDbTestMocks = {
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowVariableDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowVariableDbTestMocks__?: WorkflowVariableDbTestMocks };
|
||||
store.__workflowVariableDbTestMocks__ ??= {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
return store.__workflowVariableDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflowVariable: {
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
deleteMany: getDbMocks().deleteMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { deleteWorkflowVariable, updateWorkflowVariable } from "./workflowVariable.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow variable mutations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "variable_1", tenantId: "tenant_1", name: "token", value: "next" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
it("updates variables with tenant scope", async () => {
|
||||
await expect(updateWorkflowVariable("variable_1", { tenantId: "tenant_1", value: "next" })).resolves.toEqual({
|
||||
id: "variable_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "token",
|
||||
value: "next",
|
||||
});
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "variable_1", tenantId: "tenant_1" },
|
||||
data: { name: undefined, value: "next" },
|
||||
});
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "variable_1", tenantId: "tenant_1" } });
|
||||
});
|
||||
|
||||
it("deletes variables with tenant scope", async () => {
|
||||
await deleteWorkflowVariable("variable_1", { tenantId: "tenant_1" });
|
||||
|
||||
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "variable_1", tenantId: "tenant_1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when scoped updates miss", async () => {
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await expect(updateWorkflowVariable("missing", { tenantId: "tenant_1", value: "next" })).rejects.toThrow("Workflow variable missing not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export async function getAllWorkflowVariables({ tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowVariable.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowVariableById(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowVariable
|
||||
.findFirstOrThrow({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowVariableByName(name: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowVariable
|
||||
.findFirstOrThrow({
|
||||
where: { name, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflowVariable({
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
createdByUserId: string | null;
|
||||
name: string;
|
||||
value: string;
|
||||
}) {
|
||||
return await db.workflowVariable.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowVariable(id: string, { tenantId, name, value }: { tenantId: string | null; name?: string; value?: string }) {
|
||||
const updated = await db.workflowVariable.updateMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
value,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow variable ${id} not found`);
|
||||
}
|
||||
return await getWorkflowVariableById(id, { tenantId });
|
||||
}
|
||||
|
||||
export async function deleteWorkflowVariable(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
const deleted = await db.workflowVariable.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(`Workflow variable ${id} not found`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowDbTestMocks = {
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowDbTestMocks__?: WorkflowDbTestMocks };
|
||||
store.__workflowDbTestMocks__ ??= {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
return store.__workflowDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflow: {
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
deleteMany: getDbMocks().deleteMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { deleteWorkflow, getWorkflowByAccessibleId, updateWorkflow } from "./workflows.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow mutations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "workflow_1", tenantId: "tenant_1", name: "Updated" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
it("updates workflows with tenant scope", async () => {
|
||||
await expect(updateWorkflow({ id: "workflow_1", tenantId: "tenant_1" }, { name: "Updated" })).resolves.toEqual({
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "workflow_1", tenantId: "tenant_1" },
|
||||
data: {
|
||||
name: "Updated",
|
||||
description: undefined,
|
||||
status: undefined,
|
||||
appliesToAllTenants: undefined,
|
||||
},
|
||||
});
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "workflow_1", tenantId: "tenant_1" } });
|
||||
});
|
||||
|
||||
it("deletes workflows with tenant scope", async () => {
|
||||
await expect(deleteWorkflow({ id: "workflow_1", tenantId: "tenant_1" })).resolves.toEqual({ count: 1 });
|
||||
|
||||
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "workflow_1", tenantId: "tenant_1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when scoped workflow mutations miss", async () => {
|
||||
dbMocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
dbMocks.deleteMany.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await expect(updateWorkflow({ id: "missing", tenantId: "tenant_1" }, { name: "Updated" })).rejects.toThrow("Workflow missing not found");
|
||||
await expect(deleteWorkflow({ id: "missing", tenantId: "tenant_1" })).rejects.toThrow("Workflow missing not found");
|
||||
});
|
||||
|
||||
it("reads execution workflows only through tenant or public global scope", async () => {
|
||||
await getWorkflowByAccessibleId({ id: "workflow_1", tenantId: "tenant_1" });
|
||||
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: "workflow_1",
|
||||
OR: [
|
||||
{ tenantId: "tenant_1" },
|
||||
{
|
||||
tenantId: null,
|
||||
appliesToAllTenants: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("reads anonymous execution workflows only from public global scope", async () => {
|
||||
await getWorkflowByAccessibleId({ id: "workflow_1", tenantId: null });
|
||||
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "workflow_1", tenantId: null, appliesToAllTenants: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Prisma, Workflow, WorkflowInputExample } from "@toolctrl/db/prisma-browser";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { WorkflowBlockWithDetails } from "./workflowBlocks.db.server";
|
||||
import { TenantSimple } from "~/utils/db/tenants.db.server";
|
||||
import TenantModelHelper from "~/utils/helpers/models/TenantModelHelper";
|
||||
|
||||
export type WorkflowWithDetails = Workflow & {
|
||||
tenant: TenantSimple | null;
|
||||
blocks: WorkflowBlockWithDetails[];
|
||||
inputExamples: WorkflowInputExample[];
|
||||
_count: {
|
||||
executions: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getAllWorkflows({
|
||||
tenantId,
|
||||
status,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
status?: "draft" | "live" | "archived";
|
||||
}): Promise<WorkflowWithDetails[]> {
|
||||
return await db.workflow.findMany({
|
||||
where: { tenantId, status },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllWorkflowsAppliesToAllTenants({
|
||||
tenantId,
|
||||
status,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
status?: "draft" | "live" | "archived";
|
||||
}): Promise<WorkflowWithDetails[]> {
|
||||
const where: Prisma.WorkflowWhereInput = { status };
|
||||
if (tenantId === null) {
|
||||
where.tenantId = null;
|
||||
} else {
|
||||
where.OR = [{ tenantId }, { appliesToAllTenants: true, tenantId: null }];
|
||||
}
|
||||
return await db.workflow.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowsIdsAndNames({ tenantId }: { tenantId: string | null }): Promise<{ id: string; name: string }[]> {
|
||||
return await db.workflow.findMany({
|
||||
where: { tenantId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowById({ id, tenantId }: { id: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
|
||||
return await db.workflow
|
||||
.findFirstOrThrow({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowByAccessibleId({ id, tenantId }: { id: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
|
||||
const where: Prisma.WorkflowWhereInput =
|
||||
tenantId === null
|
||||
? { id, tenantId: null, appliesToAllTenants: true }
|
||||
: {
|
||||
id,
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{
|
||||
tenantId: null,
|
||||
appliesToAllTenants: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return await db.workflow
|
||||
.findFirstOrThrow({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflow(id: string): Promise<WorkflowWithDetails | null> {
|
||||
return await db.workflow
|
||||
.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowByName({ name, tenantId }: { name: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
|
||||
return await db.workflow
|
||||
.findFirstOrThrow({
|
||||
where: { name, tenantId },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflow(data: { tenantId: string | null; createdByUserId: string | null; name: string; description: string }) {
|
||||
return await db.workflow.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdByUserId: data.createdByUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflow(
|
||||
scope: { id: string; tenantId: string | null },
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: "draft" | "live" | "archived";
|
||||
appliesToAllTenants?: boolean;
|
||||
}
|
||||
) {
|
||||
const updated = await db.workflow.updateMany({
|
||||
where: { id: scope.id, tenantId: scope.tenantId },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
appliesToAllTenants: data.appliesToAllTenants,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow ${scope.id} not found`);
|
||||
}
|
||||
return await db.workflow.findFirstOrThrow({
|
||||
where: { id: scope.id, tenantId: scope.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(scope: { id: string; tenantId: string | null }) {
|
||||
const deleted = await db.workflow.deleteMany({
|
||||
where: { id: scope.id, tenantId: scope.tenantId },
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(`Workflow ${scope.id} not found`);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export async function countWorkflows({ tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflow.count({
|
||||
where: { tenantId },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WorkflowBlockDto } from "./WorkflowBlockDto";
|
||||
import { WorkflowDto } from "./WorkflowDto";
|
||||
import type { OutboundHttpFetchOptions } from "~/utils/outboundHttp.server";
|
||||
|
||||
export type BlockExecutionParamsDto = {
|
||||
workflow: WorkflowDto;
|
||||
workflowContext: { [key: string]: any };
|
||||
block: WorkflowBlockDto;
|
||||
workflowExecutionId: string;
|
||||
session: {
|
||||
tenantId: string | null;
|
||||
userId: string | null;
|
||||
};
|
||||
executionPath?: string[];
|
||||
httpFetchOptions?: OutboundHttpFetchOptions;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type BlockExecutionResultDto = {
|
||||
output: { [key: string]: any } | any | null;
|
||||
toBlockIds: string[];
|
||||
error?: string | null;
|
||||
throwsError?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WorkflowConditionsGroupDto } from "./WorkflowConditionDtos";
|
||||
import { WorkflowBlockType } from "./WorkflowBlockTypes";
|
||||
|
||||
export type WorkflowBlockDto = {
|
||||
id: string;
|
||||
index: number;
|
||||
type: WorkflowBlockType;
|
||||
variableName: string;
|
||||
description: string;
|
||||
input: { [key: string]: any };
|
||||
isTrigger: boolean;
|
||||
isBlock: boolean;
|
||||
// position: {
|
||||
// x: number;
|
||||
// y: number;
|
||||
// };
|
||||
conditionGroups: WorkflowConditionsGroupDto[];
|
||||
toBlocks: {
|
||||
id: string;
|
||||
// flowId: string;
|
||||
// sourceHandle?: string | null;
|
||||
toBlockId: string;
|
||||
condition: string | null;
|
||||
}[];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { WorkflowBlockType } from "./WorkflowBlockTypes";
|
||||
import { WorkflowStatus } from "./WorkflowStatus";
|
||||
|
||||
export type WorkflowBlockExecutionDto = {
|
||||
id: string;
|
||||
workflowBlockId: string;
|
||||
fromWorkflowBlockId: string | null;
|
||||
status: WorkflowStatus;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
input: { [key: string]: any } | null;
|
||||
output: { [key: string]: any } | null;
|
||||
error: string | null;
|
||||
workflowBlock: {
|
||||
type: WorkflowBlockType;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { NATIVE_WORKFLOW_TRIGGER_SUPPORTED_BLOCK_TYPES, workflowBlockContracts } from "@toolctrl/foundation";
|
||||
import { WorkflowBlockTypes } from "./WorkflowBlockTypes";
|
||||
import { CORE_WORKFLOW_BLOCK_TYPES } from "../services/blocks/WorkflowBlockService";
|
||||
import { PRO_WORKFLOW_BLOCK_TYPES } from "../services/blocks/WorkflowBlockProService";
|
||||
import { getWorkflowBlockOptionLabel } from "./WorkflowBlockTypes";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
describe("WorkflowBlockTypes registry contract", () => {
|
||||
it("keeps registry block values unique", () => {
|
||||
const values = WorkflowBlockTypes.map((block) => block.value);
|
||||
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
|
||||
it("keeps every visible block backed by exactly one executor", () => {
|
||||
const registryValues = WorkflowBlockTypes.map((block) => block.value).sort();
|
||||
const executorValues = [...CORE_WORKFLOW_BLOCK_TYPES, ...PRO_WORKFLOW_BLOCK_TYPES].sort();
|
||||
|
||||
expect(registryValues).toEqual(executorValues);
|
||||
});
|
||||
|
||||
it("keeps the visible registry in sync with the worker-safe block contract", () => {
|
||||
const registryValues = WorkflowBlockTypes.map((block) => block.value).sort();
|
||||
const contractValues = workflowBlockContracts.map((contract) => contract.value).sort();
|
||||
|
||||
expect(registryValues).toEqual(contractValues);
|
||||
|
||||
for (const contract of workflowBlockContracts) {
|
||||
const block = WorkflowBlockTypes.find((candidate) => candidate.value === contract.value);
|
||||
const requiredInputs = block?.inputs
|
||||
.filter((input) => input.required)
|
||||
.map((input) => input.name)
|
||||
.sort();
|
||||
|
||||
expect(requiredInputs).toEqual([...contract.requiredInputs].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps executor ownership aligned with the shared contract", () => {
|
||||
const coreContracts = workflowBlockContracts
|
||||
.filter((contract) => contract.executor === "core")
|
||||
.map((contract) => contract.value)
|
||||
.sort();
|
||||
const proContracts = workflowBlockContracts
|
||||
.filter((contract) => contract.executor === "pro")
|
||||
.map((contract) => contract.value)
|
||||
.sort();
|
||||
|
||||
expect([...CORE_WORKFLOW_BLOCK_TYPES].sort()).toEqual(coreContracts);
|
||||
expect([...PRO_WORKFLOW_BLOCK_TYPES].sort()).toEqual(proContracts);
|
||||
});
|
||||
|
||||
it("keeps native worker support aligned with the shared contract", () => {
|
||||
const nativeContracts = workflowBlockContracts
|
||||
.filter((contract) => contract.nativeSupport === "supported")
|
||||
.map((contract) => contract.value)
|
||||
.sort();
|
||||
|
||||
expect([...NATIVE_WORKFLOW_TRIGGER_SUPPORTED_BLOCK_TYPES].sort()).toEqual(nativeContracts);
|
||||
expect(workflowBlockContracts.find((contract) => contract.value === "if")?.nativeSupport).toBe("supported");
|
||||
expect(workflowBlockContracts.find((contract) => contract.value === "switch")?.nativeSupport).toBe("supported");
|
||||
expect(workflowBlockContracts.find((contract) => contract.value === "rowGet")?.nativeSupport).toBe("supported");
|
||||
expect(workflowBlockContracts.find((contract) => contract.value === "iterator")?.nativeSupport).toBe("supported");
|
||||
});
|
||||
|
||||
it("keeps the production worker block surface classified intentionally", () => {
|
||||
const surface = Object.fromEntries(
|
||||
workflowBlockContracts.map((contract) => [
|
||||
contract.value,
|
||||
{
|
||||
executor: contract.executor,
|
||||
nativeSupport: contract.nativeSupport,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(surface).toEqual({
|
||||
alertUser: { executor: "core", nativeSupport: "supported" },
|
||||
doNothing: { executor: "core", nativeSupport: "supported" },
|
||||
email: { executor: "pro", nativeSupport: "supported" },
|
||||
event: { executor: "core", nativeSupport: "supported" },
|
||||
httpRequest: { executor: "core", nativeSupport: "supported" },
|
||||
if: { executor: "core", nativeSupport: "supported" },
|
||||
iterator: { executor: "core", nativeSupport: "supported" },
|
||||
log: { executor: "core", nativeSupport: "supported" },
|
||||
manual: { executor: "core", nativeSupport: "supported" },
|
||||
rowCreate: { executor: "pro", nativeSupport: "preflight-skip" },
|
||||
rowDelete: { executor: "pro", nativeSupport: "preflight-skip" },
|
||||
rowGet: { executor: "pro", nativeSupport: "supported" },
|
||||
rowUpdate: { executor: "pro", nativeSupport: "preflight-skip" },
|
||||
switch: { executor: "core", nativeSupport: "supported" },
|
||||
variable: { executor: "core", nativeSupport: "supported" },
|
||||
waitForInput: { executor: "pro", nativeSupport: "preflight-skip" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps visible registry copy as translation tokens", () => {
|
||||
for (const block of WorkflowBlockTypes) {
|
||||
expect(block.name).toBe(block.value);
|
||||
expect(block.category).toMatch(/^[a-z]+$/u);
|
||||
|
||||
for (const input of block.inputs) {
|
||||
expect(input.label).toBe(input.name);
|
||||
if (input.href) {
|
||||
expect(input.href.label).toMatch(/^[a-z][a-zA-Z0-9]*$/u);
|
||||
}
|
||||
if (input.placeholder) {
|
||||
expect(input.placeholder).toMatch(/^(https:\/\/|workflows\.blocks\.)/u);
|
||||
}
|
||||
}
|
||||
|
||||
for (const output of block.outputs) {
|
||||
expect(output.label).toBe(output.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes the complete http request runner output contract", () => {
|
||||
const httpRequestBlock = WorkflowBlockTypes.find((block) => block.value === "httpRequest");
|
||||
|
||||
expect(httpRequestBlock?.outputs.map((output) => output.name).sort()).toEqual(["body", "error", "statusCode", "truncated"]);
|
||||
});
|
||||
|
||||
it("keeps every visible block category backed by en/de translations", () => {
|
||||
const locales = ["en", "de"] as const;
|
||||
const categoryKeys = [...new Set(WorkflowBlockTypes.map((block) => block.category))].sort();
|
||||
|
||||
for (const locale of locales) {
|
||||
const translations = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales", locale, "translations.json"), "utf8"));
|
||||
|
||||
for (const category of categoryKeys) {
|
||||
expect(translations.workflows.blocks.categories, `${locale} workflows.blocks.categories.${category}`).toHaveProperty(category);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every visible block label backed by en/de translations", () => {
|
||||
const locales = ["en", "de"] as const;
|
||||
const inputKeys = [...new Set(WorkflowBlockTypes.flatMap((block) => block.inputs.map((input) => input.name)))].sort();
|
||||
const optionKeys = [
|
||||
...new Set(
|
||||
WorkflowBlockTypes.flatMap((block) =>
|
||||
block.value === "event" ? [] : block.inputs.flatMap((input) => input.options?.map((option) => option.value) ?? [])
|
||||
)
|
||||
),
|
||||
].sort();
|
||||
|
||||
for (const locale of locales) {
|
||||
const translations = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales", locale, "translations.json"), "utf8"));
|
||||
|
||||
for (const block of WorkflowBlockTypes) {
|
||||
expect(translations.workflows.blocks[block.value], `${locale} workflows.blocks.${block.value}.name`).toHaveProperty("name");
|
||||
}
|
||||
for (const input of inputKeys) {
|
||||
expect(translations.workflows.blocks.inputs, `${locale} workflows.blocks.inputs.${input}`).toHaveProperty(input);
|
||||
}
|
||||
for (const option of optionKeys) {
|
||||
expect(translations.workflows.blocks.options, `${locale} workflows.blocks.options.${option}`).toHaveProperty(option);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every visible block output backed by en/de translations", () => {
|
||||
const locales = ["en", "de"] as const;
|
||||
const outputKeys = [...new Set(WorkflowBlockTypes.flatMap((block) => block.outputs.map((output) => output.name)))].sort();
|
||||
|
||||
expect(outputKeys).toContain("truncated");
|
||||
|
||||
for (const locale of locales) {
|
||||
const translations = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales", locale, "translations.json"), "utf8"));
|
||||
|
||||
for (const output of outputKeys) {
|
||||
expect(translations.workflows.blocks.outputs, `${locale} workflows.blocks.outputs.${output}`).toHaveProperty(output);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("localizes application event option labels with readable names", () => {
|
||||
const label = getWorkflowBlockOptionLabel(
|
||||
{ value: "row.created", label: "row.created (row.created)" },
|
||||
(key, defaultValue) => (key === "events.names.row_created" ? "Localized row created" : defaultValue)
|
||||
);
|
||||
|
||||
expect(label).toBe("row.created - Localized row created");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { ApplicationEvents, getApplicationEventNameKey } from "~/modules/events/types/ApplicationEvent";
|
||||
import WorkflowBlockActionIcon from "../components/icons/WorkflowBlockActionIcon";
|
||||
import ConditionalIcon from "../components/nodes/flow/conditional/ConditionalIcon";
|
||||
import DoNothingIcon from "../components/nodes/helpers/doNothing/DoNothingIcon";
|
||||
import HttpRequestIcon from "../components/nodes/helpers/httpRequest/HttpRequestIcon";
|
||||
import LogIcon from "../components/nodes/helpers/log/LogIcon";
|
||||
import TriggerManualIcon from "../components/nodes/triggers/manual/TriggerManualIcon";
|
||||
|
||||
export const WorkflowBlockTypes: WorkflowBlockTypeDto[] = [
|
||||
{
|
||||
type: "trigger",
|
||||
category: "misc",
|
||||
name: "manual",
|
||||
value: "manual",
|
||||
icon: TriggerManualIcon,
|
||||
inputs: [
|
||||
{ name: "validation", type: "monaco", label: "validation", required: false, href: { url: "https://github.com/ajv-validator/ajv", label: "learnMore" } },
|
||||
],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: "trigger",
|
||||
category: "app",
|
||||
name: "event",
|
||||
value: "event",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
appliesToAllTenants: true,
|
||||
inputs: [
|
||||
{
|
||||
name: "event",
|
||||
type: "select",
|
||||
label: "event",
|
||||
required: true,
|
||||
options: ApplicationEvents.map((f) => ({ value: f.value, label: `${f.value} (${f.value})`, adminOnly: f.adminOnly })),
|
||||
},
|
||||
{ name: "entity", type: "string", label: "entity", required: false, if: { name: "event", values: ["row.created", "row.updated", "row.deleted"] } },
|
||||
],
|
||||
outputs: [{ name: "data", label: "data" }],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "flow",
|
||||
name: "if",
|
||||
value: "if",
|
||||
icon: ConditionalIcon,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "flow",
|
||||
name: "switch",
|
||||
value: "switch",
|
||||
icon: ConditionalIcon,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "helpers",
|
||||
name: "httpRequest",
|
||||
value: "httpRequest",
|
||||
icon: HttpRequestIcon,
|
||||
inputs: [
|
||||
{ name: "url", type: "string", label: "url", required: true, placeholder: "https://api.example.com" },
|
||||
{
|
||||
name: "method",
|
||||
type: "select",
|
||||
label: "method",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "GET", value: "GET" },
|
||||
{ label: "POST", value: "POST" },
|
||||
{ label: "PUT", value: "PUT" },
|
||||
{ label: "DELETE", value: "DELETE" },
|
||||
{ label: "PATCH", value: "PATCH" },
|
||||
{ label: "HEAD", value: "HEAD" },
|
||||
{ label: "OPTIONS", value: "OPTIONS" },
|
||||
],
|
||||
},
|
||||
{ name: "body", type: "monaco", label: "body", required: false },
|
||||
{ name: "headers", type: "keyValue", label: "headers", required: false },
|
||||
{ name: "throwsError", type: "boolean", label: "throwsError", defaultValue: true },
|
||||
],
|
||||
outputs: [
|
||||
{ name: "statusCode", label: "statusCode" },
|
||||
{ name: "body", label: "body" },
|
||||
{ name: "error", label: "error" },
|
||||
{ name: "truncated", label: "truncated" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "helpers",
|
||||
name: "log",
|
||||
value: "log",
|
||||
icon: LogIcon,
|
||||
inputs: [{ name: "message", type: "string", label: "message", required: true, placeholder: "workflows.blocks.log.inputs.message.placeholder" }],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "helpers",
|
||||
name: "alertUser",
|
||||
value: "alertUser",
|
||||
icon: LogIcon,
|
||||
inputs: [
|
||||
{ name: "message", type: "string", label: "message", required: true, placeholder: "workflows.blocks.alertUser.inputs.message.placeholder" },
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
label: "type",
|
||||
required: false,
|
||||
defaultValue: "success",
|
||||
options: [
|
||||
{ label: "success", value: "success" },
|
||||
{ label: "error", value: "error" },
|
||||
],
|
||||
},
|
||||
],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "helpers",
|
||||
name: "doNothing",
|
||||
value: "doNothing",
|
||||
icon: DoNothingIcon,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "interaction",
|
||||
name: "waitForInput",
|
||||
value: "waitForInput",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [
|
||||
{ name: "title", type: "string", label: "title", required: false },
|
||||
{ name: "placeholder", type: "string", label: "placeholder", required: false },
|
||||
],
|
||||
outputs: [{ name: "input", label: "input" }],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "entities",
|
||||
name: "rowGet",
|
||||
value: "rowGet",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [
|
||||
{ name: "entity", type: "string", label: "entity", required: true },
|
||||
{ name: "id", type: "string", label: "id", required: true },
|
||||
],
|
||||
outputs: [{ name: "row", label: "row" }],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "entities",
|
||||
name: "rowCreate",
|
||||
value: "rowCreate",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [
|
||||
{ name: "entity", type: "string", label: "entity", required: true },
|
||||
{ name: "row", type: "monaco", label: "row", required: true },
|
||||
],
|
||||
outputs: [{ name: "row", label: "row" }],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "entities",
|
||||
name: "rowUpdate",
|
||||
value: "rowUpdate",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [
|
||||
{ name: "entity", type: "string", label: "entity", required: true },
|
||||
{ name: "id", type: "string", label: "id", required: true },
|
||||
{ name: "data", type: "monaco", label: "data", required: true },
|
||||
],
|
||||
outputs: [{ name: "row", label: "row" }],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "entities",
|
||||
name: "rowDelete",
|
||||
value: "rowDelete",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [
|
||||
{ name: "entity", type: "string", label: "entity", required: true },
|
||||
{ name: "id", type: "string", label: "id", required: true },
|
||||
],
|
||||
outputs: [{ name: "row", label: "row" }],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "flow",
|
||||
name: "iterator",
|
||||
value: "iterator",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [{ name: "variableName", type: "string", label: "variableName", required: true }],
|
||||
outputs: [
|
||||
{ name: "index", label: "index" },
|
||||
{ name: "item", label: "item" },
|
||||
{ name: "isFirst", label: "isFirst" },
|
||||
{ name: "isLast", label: "isLast" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "flow",
|
||||
name: "variable",
|
||||
value: "variable",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [
|
||||
{ name: "variableName", type: "string", label: "variableName", required: true },
|
||||
{ name: "value", type: "string", label: "value", required: true },
|
||||
],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
category: "app",
|
||||
name: "email",
|
||||
value: "email",
|
||||
icon: WorkflowBlockActionIcon,
|
||||
inputs: [
|
||||
{
|
||||
name: "provider",
|
||||
type: "select",
|
||||
label: "provider",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Mailjet", value: "mailjet" },
|
||||
],
|
||||
},
|
||||
{ name: "to", type: "string", label: "to", required: true },
|
||||
{ name: "subject", type: "string", label: "subject", required: true },
|
||||
{ name: "body", type: "string", label: "body", required: true },
|
||||
],
|
||||
outputs: [],
|
||||
},
|
||||
];
|
||||
export type WorkflowBlockType =
|
||||
| "manual"
|
||||
| "if"
|
||||
| "switch"
|
||||
| "httpRequest"
|
||||
| "log"
|
||||
| "alertUser"
|
||||
| "doNothing"
|
||||
| "waitForInput"
|
||||
| "rowGet"
|
||||
| "rowCreate"
|
||||
| "rowUpdate"
|
||||
| "rowDelete"
|
||||
| "iterator"
|
||||
| "variable"
|
||||
| "event"
|
||||
| "email";
|
||||
export type WorkflowBlockTypeDto = {
|
||||
type: "trigger" | "action";
|
||||
category: "misc" | "flow" | "helpers" | "interaction" | "ai" | "entities" | "app";
|
||||
name: string;
|
||||
value: WorkflowBlockType;
|
||||
icon: any;
|
||||
inputs: WorkflowBlockInput[];
|
||||
outputs: WorkflowBlockOutput[];
|
||||
appliesToAllTenants?: boolean;
|
||||
};
|
||||
export type WorkflowBlockInput = {
|
||||
name: string;
|
||||
type: "string" | "monaco" | "keyValue" | "select" | "boolean";
|
||||
label: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
adminOnly?: boolean;
|
||||
}[];
|
||||
defaultValue?: string | boolean;
|
||||
href?: { url: string; label: string };
|
||||
if?: { name: string; values: string[] };
|
||||
};
|
||||
export type WorkflowBlockOutput = {
|
||||
name: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type WorkflowBlockTranslator = (key: string, defaultValue: string) => string;
|
||||
|
||||
export function getWorkflowBlockName(block: Pick<WorkflowBlockTypeDto, "value" | "name">, t: WorkflowBlockTranslator): string {
|
||||
return t(`workflows.blocks.${block.value}.name`, block.name);
|
||||
}
|
||||
|
||||
export function getWorkflowBlockCategoryLabel(category: WorkflowBlockTypeDto["category"], t: WorkflowBlockTranslator): string {
|
||||
return t(`workflows.blocks.categories.${category}`, category);
|
||||
}
|
||||
|
||||
export function getWorkflowBlockInputLabel(input: Pick<WorkflowBlockInput, "name" | "label">, t: WorkflowBlockTranslator): string {
|
||||
return t(`workflows.blocks.inputs.${input.name}`, input.label);
|
||||
}
|
||||
|
||||
export function getWorkflowBlockInputPlaceholder(
|
||||
block: Pick<WorkflowBlockTypeDto, "value">,
|
||||
input: Pick<WorkflowBlockInput, "name" | "placeholder">,
|
||||
t: WorkflowBlockTranslator
|
||||
): string | undefined {
|
||||
if (!input.placeholder) return undefined;
|
||||
return t(`workflows.blocks.${block.value}.inputs.${input.name}.placeholder`, input.placeholder);
|
||||
}
|
||||
|
||||
export function getWorkflowBlockInputHrefLabel(input: Pick<WorkflowBlockInput, "href">, t: WorkflowBlockTranslator): string | undefined {
|
||||
if (!input.href) return undefined;
|
||||
return t("workflows.blocks.common.learnMore", input.href.label);
|
||||
}
|
||||
|
||||
export function getWorkflowBlockOptionLabel(option: { label: string; value: string }, t: WorkflowBlockTranslator): string {
|
||||
const event = ApplicationEvents.find((item) => item.value === option.value);
|
||||
if (event) {
|
||||
const eventLabel = t(getApplicationEventNameKey(event.value), event.value);
|
||||
return `${option.value} - ${eventLabel}`;
|
||||
}
|
||||
return t(`workflows.blocks.options.${option.value}`, option.label);
|
||||
}
|
||||
|
||||
export function getWorkflowBlockOutputLabel(output: Pick<WorkflowBlockOutput, "name" | "label">, t: WorkflowBlockTranslator): string {
|
||||
return t(`workflows.blocks.outputs.${output.name}`, output.label);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type WorkflowConditionsGroupDto = {
|
||||
// id: string;
|
||||
index: number;
|
||||
type: "AND" | "OR";
|
||||
conditions: WorkflowConditionsDto[];
|
||||
};
|
||||
|
||||
export type WorkflowConditionsDto = {
|
||||
// id: string;
|
||||
index: number;
|
||||
variable: string; // Refers to the variable in the workflow context
|
||||
operator: WorkflowConditionOperator; // '=', '!=', '>', '<', '>=', '<=', etc.
|
||||
value: string; // The value to compare against
|
||||
};
|
||||
|
||||
export type WorkflowConditionOperator = typeof WorkflowConditionOperators[number]["value"];
|
||||
export const WorkflowConditionOperators = [
|
||||
{ name: "Equals", value: "=", requiresValue: true, translationKey: "workflows.conditions.operators.equals" },
|
||||
{ name: "Not equals", value: "!=", requiresValue: true, translationKey: "workflows.conditions.operators.notEquals" },
|
||||
{ name: "Greater than", value: ">", requiresValue: true, translationKey: "workflows.conditions.operators.greaterThan" },
|
||||
{ name: "Less than", value: "<", requiresValue: true, translationKey: "workflows.conditions.operators.lessThan" },
|
||||
{ name: "Greater than or equals", value: ">=", requiresValue: true, translationKey: "workflows.conditions.operators.greaterThanOrEquals" },
|
||||
{ name: "Less than or equals", value: "<=", requiresValue: true, translationKey: "workflows.conditions.operators.lessThanOrEquals" },
|
||||
{ name: "Contains", value: "contains", requiresValue: true, translationKey: "workflows.conditions.operators.contains" },
|
||||
{ name: "Does not contain", value: "doesNotContain", requiresValue: true, translationKey: "workflows.conditions.operators.doesNotContain" },
|
||||
{ name: "Starts with", value: "startsWith", requiresValue: true, translationKey: "workflows.conditions.operators.startsWith" },
|
||||
{ name: "Ends with", value: "endsWith", requiresValue: true, translationKey: "workflows.conditions.operators.endsWith" },
|
||||
{ name: "Is empty", value: "isEmpty", requiresValue: false, translationKey: "workflows.conditions.operators.isEmpty" },
|
||||
{ name: "Is not empty", value: "isNotEmpty", requiresValue: false, translationKey: "workflows.conditions.operators.isNotEmpty" },
|
||||
] as const;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TenantSimple } from "~/utils/db/tenants.db.server";
|
||||
import { WorkflowBlockDto } from "./WorkflowBlockDto";
|
||||
import { WorkflowInputExampleDto } from "./WorkflowInputExampleDto";
|
||||
|
||||
export type WorkflowDto = {
|
||||
id: string;
|
||||
tenantId: string | null;
|
||||
tenant: TenantSimple | null;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "draft" | "live" | "archived";
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
appliesToAllTenants: boolean;
|
||||
blocks: WorkflowBlockDto[];
|
||||
inputExamples: WorkflowInputExampleDto[];
|
||||
_count: {
|
||||
executions: number;
|
||||
};
|
||||
$variables?: string[];
|
||||
$credentials?: string[];
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { WorkflowBlockDto } from "./WorkflowBlockDto";
|
||||
import { WorkflowBlockExecutionDto } from "./WorkflowBlockExecutionDto";
|
||||
import { WorkflowStatus } from "./WorkflowStatus";
|
||||
|
||||
export type WorkflowExecutionDto = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
status: WorkflowStatus;
|
||||
input: { [key: string]: any } | null;
|
||||
output: { [key: string]: any } | null;
|
||||
duration: number | null;
|
||||
endedAt: Date | null;
|
||||
error: string | null;
|
||||
blockRuns: WorkflowBlockExecutionDto[];
|
||||
executionAlerts: { type: "success" | "error"; message: string }[];
|
||||
waitingBlock: WorkflowBlockDto | null;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type WorkflowInputExampleDto = {
|
||||
id?: string;
|
||||
title: string;
|
||||
input: { [key: string]: any };
|
||||
createdAt?: Date;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type WorkflowStatus = "running" | "success" | "error" | "waitingBlock";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { WorkflowBlockType } from "./WorkflowBlockTypes";
|
||||
import { WorkflowConditionOperator } from "./WorkflowConditionDtos";
|
||||
|
||||
export type WorkflowsTemplateDto = {
|
||||
key?: string;
|
||||
title: string;
|
||||
workflows: WorkflowTemplateDto[];
|
||||
variables?: { name: string; value: string }[];
|
||||
credentialsRequired?: string[];
|
||||
adminOnly?: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowTemplateDto = {
|
||||
key?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
blocks: {
|
||||
id: string;
|
||||
type: WorkflowBlockType; // "manual" | "api" | "if" | "switch" | "httpRequest" | "log" | "doNothing"
|
||||
description?: string;
|
||||
input?: { [key: string]: any };
|
||||
conditionGroups?: {
|
||||
// index: number;
|
||||
type: "AND" | "OR";
|
||||
case?: string; // switch (case1,case2,default)
|
||||
conditions: {
|
||||
// index: number;
|
||||
variable: string;
|
||||
operator: WorkflowConditionOperator; // '=', '!=', '>', '<', '>=', '<=', 'contains', 'doesNotContain', 'startsWith', 'endsWith', 'isEmpty', 'isNotEmpty'
|
||||
value: string; // The value to compare against
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
toBlocks: {
|
||||
fromBlockId: string;
|
||||
toBlockId: string;
|
||||
condition?: string; // if (true, false), switch (case1,case2,default)
|
||||
}[];
|
||||
inputExamples: {
|
||||
key?: string;
|
||||
title: string;
|
||||
input: { [key: string]: any };
|
||||
}[];
|
||||
};
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
const suffix = values
|
||||
? Object.entries(values)
|
||||
.map(([name, value]) => `${name}=${String(value)}`)
|
||||
.join(",")
|
||||
: "";
|
||||
return suffix ? `${key}:${suffix}` : key;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: WorkflowBlockUtils } = await import("./WorkflowBlockUtils");
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto> = {}): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "log",
|
||||
variableName: "log",
|
||||
description: "",
|
||||
input: { message: "hello" },
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workflow(blocks: WorkflowBlockDto[]): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
appliesToAllTenants: false,
|
||||
blocks,
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowBlockUtils.getBlockErrors", () => {
|
||||
it("indexes incoming block ids once for validation callers", () => {
|
||||
const trigger = block({
|
||||
id: "trigger_1",
|
||||
type: "manual",
|
||||
isTrigger: true,
|
||||
isBlock: false,
|
||||
input: {},
|
||||
toBlocks: [{ id: "edge_1", fromBlockId: "trigger_1", toBlockId: "action_1", condition: null }],
|
||||
});
|
||||
const action = block({ id: "action_1" });
|
||||
|
||||
expect(Array.from(WorkflowBlockUtils.getIncomingBlockIds(workflow([trigger, action])))).toEqual(["action_1"]);
|
||||
expect(
|
||||
WorkflowBlockUtils.getBlockErrors({
|
||||
workflow: workflow([trigger, action]),
|
||||
block: action,
|
||||
validationContext: { incomingBlockIds: new Set(["action_1"]) },
|
||||
})
|
||||
).not.toContain("workflows.validation.errors.nothingConnectingToBlock");
|
||||
});
|
||||
|
||||
it("returns translated trigger and missing-caller validation errors", () => {
|
||||
const trigger = block({ id: "trigger_1", type: "manual", isTrigger: true, isBlock: false, input: {} });
|
||||
const action = block({ id: "action_1", input: { message: "hello" } });
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([trigger, action]), block: trigger })).toContain(
|
||||
"workflows.validation.errors.addNextBlockForTrigger",
|
||||
);
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([trigger, action]), block: action })).toContain(
|
||||
"workflows.validation.errors.nothingConnectingToBlock",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated branch connection errors", () => {
|
||||
const branch = block({
|
||||
id: "branch_1",
|
||||
type: "if",
|
||||
input: {},
|
||||
toBlocks: [{ id: "edge_1", fromBlockId: "branch_1", toBlockId: "next_1", condition: "true" }],
|
||||
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "{{$params.plan}}", operator: "equals", value: "pro" }] }],
|
||||
});
|
||||
const next = block({ id: "next_1" });
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([branch, next]), block: branch })).toContain(
|
||||
"workflows.validation.errors.addNextBlockForFalseCondition",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated required-input and condition errors", () => {
|
||||
const email = block({ id: "email_1", type: "email", input: { provider: "smtp" } });
|
||||
const invalidCondition = block({
|
||||
id: "if_1",
|
||||
type: "if",
|
||||
input: {},
|
||||
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "", operator: "equals", value: "" }] }],
|
||||
});
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([email]), block: email })).toContain(
|
||||
"workflows.validation.errors.missingRequiredInput:name=to",
|
||||
);
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([invalidCondition]), block: invalidCondition })).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("workflows.validation.errors.invalidConditions:errors=")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated invalid block type errors", () => {
|
||||
const invalid = block({ type: "missingBlockType" as WorkflowBlockDto["type"] });
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([invalid]), block: invalid })).toContain(
|
||||
"workflows.validation.errors.invalidWorkflowBlockType:type=missingBlockType",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns translated manual validation schema errors", () => {
|
||||
const manual = block({
|
||||
id: "manual_1",
|
||||
type: "manual",
|
||||
isTrigger: true,
|
||||
isBlock: false,
|
||||
input: { validation: "{not-json" },
|
||||
});
|
||||
|
||||
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([manual]), block: manual })).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("workflows.validation.errors.invalidManualValidationSchema:error=")])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowBlockInput, WorkflowBlockType, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import WorkflowConditionUtils from "./WorkflowConditionUtils";
|
||||
import { WorkflowBlockWithDetails } from "../db/workflowBlocks.db.server";
|
||||
import { WorkflowConditionOperator } from "../dtos/WorkflowConditionDtos";
|
||||
import WorkflowsValidationService from "../services/WorkflowsValidationService";
|
||||
|
||||
function rowToDto(block: WorkflowBlockWithDetails, index: number = 0) {
|
||||
let input: any = {};
|
||||
try {
|
||||
input = JSON.parse(block.input);
|
||||
} catch (e) {}
|
||||
const workflowBlock: WorkflowBlockDto = {
|
||||
id: block.id,
|
||||
index,
|
||||
type: block.type as WorkflowBlockType,
|
||||
variableName: "",
|
||||
description: block.description,
|
||||
input,
|
||||
isTrigger: block.isTrigger,
|
||||
isBlock: block.isBlock,
|
||||
toBlocks: block.toBlocks.map((toBlock) => {
|
||||
return {
|
||||
id: toBlock.id,
|
||||
fromBlockId: block.id,
|
||||
toBlockId: toBlock.toBlockId,
|
||||
condition: toBlock.condition,
|
||||
};
|
||||
}),
|
||||
conditionGroups: block.conditionsGroups
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((group) => {
|
||||
return {
|
||||
index: group.index,
|
||||
type: group.type as "AND" | "OR",
|
||||
conditions: group.conditions
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((condition) => {
|
||||
return {
|
||||
index: condition.index,
|
||||
variable: condition.variable,
|
||||
operator: condition.operator as WorkflowConditionOperator,
|
||||
value: condition.value,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return workflowBlock;
|
||||
}
|
||||
|
||||
export interface WorkflowValidationContext {
|
||||
incomingBlockIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function getIncomingBlockIds(workflow: Pick<WorkflowDto, "blocks">): Set<string> {
|
||||
const incomingBlockIds = new Set<string>();
|
||||
workflow.blocks.forEach((fromBlock) => {
|
||||
fromBlock.toBlocks.forEach((toBlock) => {
|
||||
incomingBlockIds.add(toBlock.toBlockId);
|
||||
});
|
||||
});
|
||||
return incomingBlockIds;
|
||||
}
|
||||
|
||||
function getBlockErrors({ workflow, block, validationContext }: { workflow: WorkflowDto; block: WorkflowBlockDto; validationContext?: WorkflowValidationContext }) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (block.isTrigger) {
|
||||
if (block.toBlocks.length === 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForTrigger"));
|
||||
}
|
||||
// if (!block.description) {
|
||||
// errors.push("Add a description for this trigger");
|
||||
// }
|
||||
} else if (block.isBlock) {
|
||||
const hasIncomingConnection =
|
||||
validationContext?.incomingBlockIds?.has(block.id) ?? workflow.blocks.some((fromBlock) => fromBlock.toBlocks.some((toBlock) => toBlock.toBlockId === block.id));
|
||||
if (!hasIncomingConnection) {
|
||||
errors.push(i18nT("workflows.validation.errors.nothingConnectingToBlock"));
|
||||
}
|
||||
if (block.type === "if") {
|
||||
const hasTrue = block.toBlocks.find((f) => f.condition === "true");
|
||||
const hasFalse = block.toBlocks.find((f) => f.condition === "false");
|
||||
if (!hasTrue) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForTrueCondition"));
|
||||
}
|
||||
if (!hasFalse) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForFalseCondition"));
|
||||
}
|
||||
} else if (block.type === "switch") {
|
||||
const hasDefault = block.toBlocks.find((f) => f.condition === "default");
|
||||
if (!hasDefault) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForDefaultCondition"));
|
||||
}
|
||||
block.conditionGroups.forEach((conditionGroup) => {
|
||||
const hasCase = block.toBlocks.find((f) => f.condition === `case${conditionGroup.index + 1}`);
|
||||
if (!hasCase) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForCase", { index: conditionGroup.index + 1 }));
|
||||
}
|
||||
});
|
||||
} else if (block.type === "iterator") {
|
||||
const hasLoopNext = block.toBlocks.find((f) => f.condition === "loopNext");
|
||||
const hasLoopEnd = block.toBlocks.find((f) => f.condition === "loopEnd");
|
||||
if (!hasLoopNext) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForLoopNextCondition"));
|
||||
}
|
||||
if (!hasLoopEnd) {
|
||||
errors.push(i18nT("workflows.validation.errors.addNextBlockForLoopEndCondition"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
|
||||
if (!workflowBlock) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidWorkflowBlockType", { type: block.type }));
|
||||
}
|
||||
if (workflowBlock?.inputs) {
|
||||
workflowBlock.inputs.forEach((input) => {
|
||||
const { required } = input as WorkflowBlockInput;
|
||||
if (required && !block.input[input.name]) {
|
||||
errors.push(i18nT("workflows.validation.errors.missingRequiredInput", { name: input.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (workflowBlock?.value === "manual" && block.input.validation) {
|
||||
const { error } = WorkflowsValidationService.parseJsonSchema(block.input.validation);
|
||||
if (error) {
|
||||
errors.push(
|
||||
i18nT("workflows.validation.errors.invalidManualValidationSchema", {
|
||||
error,
|
||||
defaultValue: "Manual trigger validation must be valid JSON Schema: {{error}}",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowBlock?.value === "if") {
|
||||
const conditionGroups = block.conditionGroups;
|
||||
if (conditionGroups.length !== 1) {
|
||||
errors.push(i18nT("workflows.validation.errors.conditionGroupRequired"));
|
||||
} else {
|
||||
if (conditionGroups[0].conditions.length === 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.conditionRequired"));
|
||||
} else {
|
||||
const groupErrors = WorkflowConditionUtils.getConditionsErrors(conditionGroups[0].conditions);
|
||||
if (groupErrors.length > 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidConditions", { errors: groupErrors.flatMap((f) => f.errors).join(", ") }));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (workflowBlock?.value === "switch") {
|
||||
const conditionGroups = block.conditionGroups;
|
||||
if (conditionGroups.length < 2) {
|
||||
errors.push(i18nT("workflows.validation.errors.twoConditionGroupsRequired"));
|
||||
} else {
|
||||
conditionGroups.forEach((group) => {
|
||||
if (group.conditions.length === 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.conditionRequired"));
|
||||
} else {
|
||||
const groupErrors = WorkflowConditionUtils.getConditionsErrors(group.conditions);
|
||||
if (groupErrors.length > 0) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidConditions", { errors: groupErrors.flatMap((f) => f.errors).join(", ") }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default {
|
||||
rowToDto,
|
||||
getIncomingBlockIds,
|
||||
getBlockErrors,
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowConditionsDto } from "../dtos/WorkflowConditionDtos";
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
const suffix = values
|
||||
? Object.entries(values)
|
||||
.map(([name, value]) => `${name}=${String(value)}`)
|
||||
.join(",")
|
||||
: "";
|
||||
return suffix ? `${key}:${suffix}` : key;
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: WorkflowConditionUtils } = await import("./WorkflowConditionUtils");
|
||||
|
||||
function condition(overrides: Partial<WorkflowConditionsDto> = {}): WorkflowConditionsDto {
|
||||
return {
|
||||
index: 0,
|
||||
variable: "{{$params.plan}}",
|
||||
operator: "=",
|
||||
value: "pro",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowConditionUtils.getConditionString", () => {
|
||||
it("uses the workflow validation translation for unknown operators", () => {
|
||||
const translate = vi.fn((key: string, values?: Record<string, unknown>) => `${key}:${values?.value ?? ""}`);
|
||||
|
||||
expect(WorkflowConditionUtils.getConditionString(condition({ operator: "mystery" as WorkflowConditionsDto["operator"] }), translate)).toBe(
|
||||
"workflows.validation.errors.invalidOperator:mystery"
|
||||
);
|
||||
expect(translate).toHaveBeenCalledWith("workflows.validation.errors.invalidOperator", { value: "mystery" });
|
||||
});
|
||||
|
||||
it("falls back to the i18next translator for unknown operators", () => {
|
||||
expect(WorkflowConditionUtils.getConditionString(condition({ operator: "missing" as WorkflowConditionsDto["operator"] }))).toBe(
|
||||
"workflows.validation.errors.invalidOperator:value=missing"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy one-argument translators working for valid operators", () => {
|
||||
expect(WorkflowConditionUtils.getConditionString(condition(), (key: string) => `translated:${key}`)).toBe(
|
||||
"{{$params.plan}} translated:workflows.conditions.operators.equals pro"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { WorkflowConditionOperators, WorkflowConditionsDto } from "../dtos/WorkflowConditionDtos";
|
||||
|
||||
type WorkflowConditionTranslator = (key: string, values?: Record<string, unknown>) => string;
|
||||
|
||||
function getConditionErrors(condition: WorkflowConditionsDto) {
|
||||
const errors: string[] = [];
|
||||
const operator = WorkflowConditionOperators.find((f) => f.value === condition.operator);
|
||||
if (!operator) {
|
||||
errors.push(i18nT("workflows.validation.errors.invalidOperator", { value: condition.operator }));
|
||||
}
|
||||
if (!condition.variable) {
|
||||
errors.push(i18nT("workflows.validation.errors.variableRequired"));
|
||||
}
|
||||
if (!condition.operator) {
|
||||
errors.push(i18nT("workflows.validation.errors.operatorRequired"));
|
||||
}
|
||||
if (operator?.requiresValue && !condition.value) {
|
||||
errors.push(i18nT("workflows.validation.errors.valueRequired"));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function getConditionsErrors(conditions: WorkflowConditionsDto[]) {
|
||||
const errors: {
|
||||
condition: WorkflowConditionsDto;
|
||||
errors: string[];
|
||||
}[] = [];
|
||||
conditions.forEach((condition) => {
|
||||
const conditionErrors = getConditionErrors(condition);
|
||||
if (conditionErrors.length > 0) {
|
||||
errors.push({
|
||||
condition,
|
||||
errors: conditionErrors,
|
||||
});
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
function getOperator(operatorValue: string) {
|
||||
const operator = WorkflowConditionOperators.find((f) => f.value === operatorValue);
|
||||
return operator;
|
||||
}
|
||||
|
||||
function getConditionString(condition: WorkflowConditionsDto, translate?: WorkflowConditionTranslator) {
|
||||
const operator = getOperator(condition.operator);
|
||||
if (!operator) {
|
||||
const t = translate ?? i18nT;
|
||||
return t("workflows.validation.errors.invalidOperator", { value: condition.operator });
|
||||
}
|
||||
const operatorLabel = translate ? translate(operator.translationKey) : operator.name;
|
||||
if (operator.requiresValue) {
|
||||
return `${condition.variable} ${operatorLabel} ${condition.value}`;
|
||||
}
|
||||
return `${condition.variable} ${operatorLabel}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
getConditionErrors,
|
||||
getConditionsErrors,
|
||||
getOperator,
|
||||
getConditionString,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { WorkflowExecutionWithDetails } from "../db/workflowExecutions.db.server";
|
||||
import { WorkflowBlockExecutionDto } from "../dtos/WorkflowBlockExecutionDto";
|
||||
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
|
||||
import WorkflowBlockUtils from "./WorkflowBlockUtils";
|
||||
|
||||
function rowToDto(item: WorkflowExecutionWithDetails): WorkflowExecutionDto {
|
||||
const execution: WorkflowExecutionDto = {
|
||||
id: item.id,
|
||||
createdAt: item.createdAt,
|
||||
status: item.status as WorkflowExecutionDto["status"],
|
||||
input: parseData(item.input),
|
||||
output: parseData(item.output),
|
||||
duration: item.duration,
|
||||
endedAt: item.endedAt,
|
||||
error: item.error,
|
||||
blockRuns: item.blockRuns.map((block) => {
|
||||
const blockRun: WorkflowBlockExecutionDto = {
|
||||
id: block.id,
|
||||
workflowBlockId: block.workflowBlockId,
|
||||
fromWorkflowBlockId: block.fromWorkflowBlockId,
|
||||
status: block.status as WorkflowBlockExecutionDto["status"],
|
||||
startedAt: block.startedAt,
|
||||
endedAt: block.endedAt,
|
||||
input: parseData(block.input),
|
||||
output: parseData(block.output),
|
||||
error: block.error,
|
||||
workflowBlock: {
|
||||
type: block.workflowBlock.type as WorkflowBlockExecutionDto["workflowBlock"]["type"],
|
||||
description: block.workflowBlock.description,
|
||||
},
|
||||
};
|
||||
return blockRun;
|
||||
}),
|
||||
executionAlerts: [],
|
||||
waitingBlock: null,
|
||||
};
|
||||
const alertUserBlockRuns = execution.blockRuns.filter((block) => block.workflowBlock.type === "alertUser");
|
||||
alertUserBlockRuns.forEach((blockRun) => {
|
||||
if (blockRun.output?.type && blockRun.output?.message) {
|
||||
execution.executionAlerts.push({
|
||||
type: blockRun.output?.type,
|
||||
message: blockRun.output?.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (item.waitingBlock) {
|
||||
execution.waitingBlock = WorkflowBlockUtils.rowToDto(item.waitingBlock);
|
||||
}
|
||||
|
||||
return execution;
|
||||
}
|
||||
|
||||
// function executionToDto(item: WorkflowBlockExecution, workflowBlock: WorkflowBlockDto): WorkflowBlockExecutionDto {
|
||||
// const blockRun: WorkflowBlockExecutionDto = {
|
||||
// id: item.id,
|
||||
// workflowBlockId: item.workflowBlockId,
|
||||
// fromWorkflowBlockId: item.fromWorkflowBlockId,
|
||||
// status: item.status as WorkflowBlockExecutionDto["status"],
|
||||
// startedAt: item.startedAt,
|
||||
// endedAt: item.endedAt,
|
||||
// input: parseData(item.input),
|
||||
// output: parseData(item.output),
|
||||
// error: item.error,
|
||||
// workflowBlock: {
|
||||
// type: workflowBlock.type as WorkflowBlockExecutionDto["workflowBlock"]["type"],
|
||||
// description: workflowBlock.description,
|
||||
// },
|
||||
// };
|
||||
// return blockRun;
|
||||
// }
|
||||
|
||||
function parseData(data: string | null): { [key: string]: any } | null {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
rowToDto,
|
||||
};
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import WorkflowLayoutUtils from "./WorkflowLayoutUtils";
|
||||
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import type { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
|
||||
|
||||
function createBlock(id: string, toBlocks: WorkflowBlockDto["toBlocks"] = []): WorkflowBlockDto {
|
||||
return {
|
||||
id,
|
||||
index: 0,
|
||||
type: "log",
|
||||
variableName: id,
|
||||
description: id,
|
||||
input: {},
|
||||
isTrigger: id === "trigger",
|
||||
isBlock: id !== "trigger",
|
||||
conditionGroups: [],
|
||||
toBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflow(blocks: WorkflowBlockDto[]): WorkflowDto {
|
||||
return {
|
||||
id: "workflow-1",
|
||||
tenantId: "tenant-1",
|
||||
tenant: null,
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks,
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowLayoutUtils", () => {
|
||||
it("lays out nodes without mutating the input node objects", () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: "trigger",
|
||||
type: "blockEditorNode",
|
||||
data: { label: "Trigger" },
|
||||
position: { x: 10, y: 20 },
|
||||
draggable: false,
|
||||
},
|
||||
{
|
||||
id: "next",
|
||||
type: "blockEditorNode",
|
||||
data: { label: "Next" },
|
||||
position: { x: 30, y: 40 },
|
||||
draggable: false,
|
||||
},
|
||||
];
|
||||
const edges = [{ id: "edge-1", source: "trigger", target: "next" }];
|
||||
const originalNodes = structuredClone(nodes);
|
||||
|
||||
const result = WorkflowLayoutUtils.getLayoutedElements({ nodes, edges, direction: "TB", readOnly: true });
|
||||
|
||||
expect(nodes).toEqual(originalNodes);
|
||||
expect(result.nodes).not.toBe(nodes);
|
||||
expect(result.nodes[0]).not.toBe(nodes[0]);
|
||||
expect(result.nodes[0]?.position).not.toEqual(originalNodes[0]?.position);
|
||||
expect(result.nodes[0]?.draggable).toBe(false);
|
||||
expect(result.edges).not.toBe(edges);
|
||||
expect(result.edges).toEqual(edges);
|
||||
});
|
||||
|
||||
it("sorts rendered edges without mutating workflow block connections", () => {
|
||||
const originalConnections = [
|
||||
{ id: "edge-later", toBlockId: "block-later", condition: "z" },
|
||||
{ id: "edge-earlier", toBlockId: "block-earlier", condition: "a" },
|
||||
];
|
||||
const trigger = createBlock("trigger", originalConnections);
|
||||
const blocks = [trigger, createBlock("block-earlier"), createBlock("block-later")];
|
||||
|
||||
const { edges } = WorkflowLayoutUtils.workflowToReactFlow({
|
||||
workflow: createWorkflow(blocks),
|
||||
workflowBlocks: blocks,
|
||||
workflowExecution: null,
|
||||
});
|
||||
|
||||
expect(edges.map((edge) => edge.id)).toEqual(["edge-earlier", "edge-later"]);
|
||||
expect(trigger.toBlocks.map((connection) => connection.id)).toEqual(["edge-later", "edge-earlier"]);
|
||||
});
|
||||
|
||||
it("marks executed nodes and edges from indexed block runs", () => {
|
||||
const trigger = createBlock("trigger", [{ id: "edge-executed", toBlockId: "block-next", condition: null }]);
|
||||
const nextBlock = createBlock("block-next");
|
||||
const blocks = [trigger, nextBlock];
|
||||
const blockRun = {
|
||||
id: "run-next",
|
||||
workflowBlockId: "block-next",
|
||||
fromWorkflowBlockId: "trigger",
|
||||
status: "success",
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: null,
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
workflowBlock: { type: "log", description: "Log" },
|
||||
} satisfies WorkflowExecutionDto["blockRuns"][number];
|
||||
const workflowExecution: WorkflowExecutionDto = {
|
||||
id: "execution-1",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
status: "success",
|
||||
input: null,
|
||||
output: null,
|
||||
duration: null,
|
||||
endedAt: null,
|
||||
error: null,
|
||||
blockRuns: [blockRun],
|
||||
executionAlerts: [],
|
||||
waitingBlock: null,
|
||||
};
|
||||
|
||||
const { nodes, edges } = WorkflowLayoutUtils.workflowToReactFlow({
|
||||
workflow: createWorkflow(blocks),
|
||||
workflowBlocks: blocks,
|
||||
workflowExecution,
|
||||
});
|
||||
|
||||
expect(nodes.find((node) => node.id === "block-next")?.data.workflowBlockExecution).toBe(blockRun);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]?.data?.isExecuted).toBe(true);
|
||||
expect(edges[0]?.style?.stroke).toBe("#14b8a6");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type Node, type Edge, Position, MarkerType } from "@xyflow/react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import dagre from "dagre";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import WorkflowUtils from "./WorkflowUtils";
|
||||
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
|
||||
import { WorkflowBlockExecutionDto } from "../dtos/WorkflowBlockExecutionDto";
|
||||
const nodeWidth = 250;
|
||||
const nodeHeight = 80;
|
||||
|
||||
const getLayoutedElements = ({
|
||||
nodes,
|
||||
edges,
|
||||
direction = "LR",
|
||||
readOnly = false,
|
||||
}: {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
direction?: "LR" | "TB" | "RL" | "BT";
|
||||
readOnly?: boolean;
|
||||
}) => {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
const isHorizontal = direction === "LR";
|
||||
dagreGraph.setGraph({ rankdir: direction });
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
|
||||
// We are shifting the dagre node position (anchor=center center) to the top left
|
||||
// so it matches the React Flow node anchor point (top left).
|
||||
return {
|
||||
...node,
|
||||
targetPosition: isHorizontal ? Position.Left : Position.Top,
|
||||
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
draggable: !readOnly,
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges: [...edges] };
|
||||
};
|
||||
|
||||
const workflowToReactFlow = ({
|
||||
workflow,
|
||||
workflowBlocks,
|
||||
workflowExecution,
|
||||
}: {
|
||||
workflow: WorkflowDto;
|
||||
workflowBlocks: WorkflowBlockDto[];
|
||||
workflowExecution: WorkflowExecutionDto | null;
|
||||
}) => {
|
||||
const blockRunsByWorkflowBlockId = new Map((workflowExecution?.blockRuns ?? []).map((blockRun) => [blockRun.workflowBlockId, blockRun]));
|
||||
const executedEdgeKeys = new Set(
|
||||
(workflowExecution?.blockRuns ?? [])
|
||||
.filter((blockRun) => blockRun.fromWorkflowBlockId)
|
||||
.map((blockRun) => `${blockRun.fromWorkflowBlockId}:${blockRun.workflowBlockId}`)
|
||||
);
|
||||
let nodes: Node[] = workflowBlocks.map((block) => {
|
||||
const workflowBlockExecution: WorkflowBlockExecutionDto | null = blockRunsByWorkflowBlockId.get(block.id) ?? null;
|
||||
return {
|
||||
id: block.id,
|
||||
type: workflowExecution ? "blockExecutionNode" : "blockEditorNode",
|
||||
data: { workflow, block, workflowExecution, workflowBlockExecution },
|
||||
position: { x: 0, y: 0 },
|
||||
draggable: false,
|
||||
};
|
||||
});
|
||||
|
||||
let edges = workflowBlocks.flatMap((block) =>
|
||||
[...block.toBlocks]
|
||||
.sort((a, b) => a.condition?.localeCompare(b.condition || "") || 0)
|
||||
.map((toBlock) => {
|
||||
const fromBlockId = block.id;
|
||||
const toBlockId = toBlock.toBlockId;
|
||||
const isExecuted = executedEdgeKeys.has(`${fromBlockId}:${toBlockId}`);
|
||||
const edge: Edge = {
|
||||
id: toBlock.id,
|
||||
source: block.id,
|
||||
target: toBlock.toBlockId,
|
||||
type: "customEdge",
|
||||
animated: true,
|
||||
deletable: false,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
style: {
|
||||
strokeWidth: 1.5,
|
||||
stroke: isExecuted ? "#14b8a6" : "#9ca3af",
|
||||
},
|
||||
data: {
|
||||
condition: toBlock.condition,
|
||||
isExecuted,
|
||||
},
|
||||
};
|
||||
return edge;
|
||||
})
|
||||
);
|
||||
|
||||
const hasTrigger = WorkflowUtils.hasTriggerNode(workflow);
|
||||
if (!hasTrigger) {
|
||||
nodes = [
|
||||
{
|
||||
type: "setTriggerNode",
|
||||
id: "1",
|
||||
data: { label: i18nT("workflows.builder.setTriggerPrompt") },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
];
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
getLayoutedElements,
|
||||
workflowToReactFlow,
|
||||
};
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import { extractWorkflowReferences, getMissingWorkflowReferences } from "./WorkflowReferenceUtils";
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto>): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "trigger",
|
||||
variableName: "trigger",
|
||||
description: "",
|
||||
input: {},
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowReferenceUtils", () => {
|
||||
it("extracts variable and credential references from nested block inputs", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "http",
|
||||
input: {
|
||||
url: "{{$vars.apiBaseUrl}}/users",
|
||||
headers: [{ key: "Authorization", value: "Bearer {{ $credentials.apiToken }}" }],
|
||||
body: { tenant: "{{$vars.tenantId}}" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["apiBaseUrl", "tenantId"]);
|
||||
expect(references.credentials).toEqual(["apiToken"]);
|
||||
expect(references.byBlock[0]).toMatchObject({
|
||||
blockId: "http",
|
||||
variables: ["apiBaseUrl", "tenantId"],
|
||||
credentials: ["apiToken"],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts references from condition variables and values", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "condition",
|
||||
type: "if",
|
||||
conditionGroups: [
|
||||
{
|
||||
index: 0,
|
||||
type: "AND",
|
||||
conditions: [{ index: 0, variable: "{{$vars.plan}}", operator: "equals", value: "{{$vars.expectedPlan}}" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["expectedPlan", "plan"]);
|
||||
});
|
||||
|
||||
it("deduplicates references and ignores params session and block outputs", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "message",
|
||||
input: {
|
||||
message: "{{ $vars.subject }} {{$vars.subject}} {{$params.email}} {{$session.user.email}} {{httpRequest.body}}",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["subject"]);
|
||||
expect(references.credentials).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts references from supported Handlebars lookup and with expressions", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({
|
||||
id: "message",
|
||||
input: {
|
||||
message: 'Bearer {{lookup $credentials "apiToken"}} for {{lookup $vars \'planTier\'}}',
|
||||
audit: "{{#with $credentials}}{{webhookSecret}}{{/with}} {{#with $vars}}{{region}}{{/with}}",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual(["planTier", "region"]);
|
||||
expect(references.credentials).toEqual(["apiToken", "webhookSecret"]);
|
||||
});
|
||||
|
||||
it("treats variable blocks as local variable definitions", () => {
|
||||
const references = extractWorkflowReferences({
|
||||
blocks: [
|
||||
block({ id: "define", type: "variable", input: { variableName: "temporaryToken", value: "abc" } }),
|
||||
block({ id: "use", input: { value: "{{$vars.temporaryToken}} {{$credentials.mailer}}" } }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(references.variables).toEqual([]);
|
||||
expect(references.credentials).toEqual(["mailer"]);
|
||||
});
|
||||
|
||||
it("reports missing references against available names", () => {
|
||||
expect(
|
||||
getMissingWorkflowReferences({
|
||||
workflow: { blocks: [block({ input: { value: "{{$vars.present}} {{$vars.missing}} {{$credentials.missingToken}}" } })] },
|
||||
variables: ["present"],
|
||||
credentials: [],
|
||||
})
|
||||
).toEqual({
|
||||
missingVariables: ["missing"],
|
||||
missingCredentials: ["missingToken"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
extractWorkflowReferences,
|
||||
getMissingWorkflowReferences,
|
||||
preflightWorkflow,
|
||||
validateWorkflowRequiredInputs,
|
||||
type WorkflowPreflightBlock,
|
||||
type WorkflowPreflightInput,
|
||||
type WorkflowPreflightResult,
|
||||
type WorkflowReferenceSummary,
|
||||
type WorkflowRequiredInputIssue,
|
||||
} from "@toolctrl/foundation";
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import WorkflowUtils from "./WorkflowUtils";
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto> = {}): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "log",
|
||||
variableName: "log",
|
||||
description: "",
|
||||
input: {},
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
appliesToAllTenants: false,
|
||||
blocks: [block()],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowUtils.canRun", () => {
|
||||
it("rejects non-live workflows even when references are satisfied", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
status: "draft",
|
||||
$variables: ["recipient"],
|
||||
$credentials: ["mailer"],
|
||||
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects live workflows with missing variable or credential references", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
|
||||
$variables: ["recipient"],
|
||||
$credentials: [],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects live workflows with missing required block inputs", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
blocks: [
|
||||
block({
|
||||
input: { message: "{{$vars.recipient}}" },
|
||||
type: "email",
|
||||
}),
|
||||
],
|
||||
$variables: ["recipient"],
|
||||
$credentials: ["mailer"],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows live workflows when referenced variables and credentials are available", () => {
|
||||
expect(
|
||||
WorkflowUtils.canRun(
|
||||
workflow({
|
||||
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
|
||||
$variables: ["recipient"],
|
||||
$credentials: ["mailer"],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import WorkflowBlockUtils from "./WorkflowBlockUtils";
|
||||
import { preflightWorkflow } from "./WorkflowReferenceUtils";
|
||||
import { getWorkflowBlockName, getWorkflowBlockOutputLabel, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
|
||||
|
||||
function canRun(workflow: WorkflowDto) {
|
||||
const preflight = preflightWorkflow({
|
||||
workflow,
|
||||
variables: workflow.$variables ?? [],
|
||||
credentials: workflow.$credentials ?? [],
|
||||
});
|
||||
return (
|
||||
workflow.status === "live" &&
|
||||
preflight.missingVariables.length === 0 &&
|
||||
preflight.missingCredentials.length === 0 &&
|
||||
preflight.missingRequiredInputs.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
function canRunManually(workflow: WorkflowDto) {
|
||||
return workflow.blocks.find((f) => f.type === "manual");
|
||||
}
|
||||
|
||||
function getStatusColor(workflow: WorkflowDto): Colors {
|
||||
switch (workflow.status) {
|
||||
case "draft":
|
||||
return Colors.SLATE;
|
||||
case "live":
|
||||
return Colors.GREEN;
|
||||
case "archived":
|
||||
return Colors.GRAY;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTriggerNode(workflow: WorkflowDto) {
|
||||
return workflow.blocks.find((f) => f.isTrigger);
|
||||
}
|
||||
|
||||
function getBlocksErrors(workflow: WorkflowDto): {
|
||||
block: WorkflowBlockDto;
|
||||
errors: string[];
|
||||
}[] {
|
||||
const blockErrors: { block: WorkflowBlockDto; errors: string[] }[] = [];
|
||||
const validationContext = { incomingBlockIds: WorkflowBlockUtils.getIncomingBlockIds(workflow) };
|
||||
|
||||
workflow.blocks.forEach((block) => {
|
||||
const errors = WorkflowBlockUtils.getBlockErrors({ workflow, block, validationContext });
|
||||
if (errors.length > 0) {
|
||||
blockErrors.push({ block, errors });
|
||||
}
|
||||
});
|
||||
|
||||
return blockErrors;
|
||||
}
|
||||
|
||||
function getBlocksWithoutCallers(workflow: WorkflowDto): WorkflowBlockDto[] {
|
||||
const blocksWithoutCallers: WorkflowBlockDto[] = [];
|
||||
const incomingBlockIds = WorkflowBlockUtils.getIncomingBlockIds(workflow);
|
||||
|
||||
workflow.blocks.forEach((block) => {
|
||||
if (block.isBlock) {
|
||||
if (!incomingBlockIds.has(block.id)) {
|
||||
blocksWithoutCallers.push(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return blocksWithoutCallers;
|
||||
}
|
||||
|
||||
function isReady(workflow: WorkflowDto) {
|
||||
const errors = getBlocksErrors(workflow);
|
||||
const blocksWithoutCallers = getBlocksWithoutCallers(workflow);
|
||||
const hasTrigger = hasTriggerNode(workflow);
|
||||
|
||||
return errors.length === 0 && blocksWithoutCallers.length === 0 && hasTrigger;
|
||||
}
|
||||
|
||||
function getVariables({ workflow, currentBlock, onlyInBlock }: { workflow: WorkflowDto; currentBlock: WorkflowBlockDto; onlyInBlock?: boolean }) {
|
||||
const triggerNode = workflow.blocks.find((f) => f.isTrigger);
|
||||
if (!triggerNode) return [];
|
||||
// const paths = buildPaths(workflow, triggerNode.id, [], {});
|
||||
// const currentPath = paths[currentBlock.id] || [];
|
||||
|
||||
const variables: { group: string; name: string; label: string }[] = [];
|
||||
|
||||
if (!onlyInBlock) {
|
||||
variables.push(
|
||||
{ group: i18nT("workflows.variables.groups.params"), name: "{{$params.}}", label: i18nT("workflows.variables.suggestions.paramsValue") },
|
||||
{ group: i18nT("workflows.variables.groups.session"), name: "{{$session.tenant.id}}", label: i18nT("workflows.variables.suggestions.sessionTenantId") },
|
||||
{ group: i18nT("workflows.variables.groups.session"), name: "{{$session.user.email}}", label: i18nT("workflows.variables.suggestions.sessionUserEmail") }
|
||||
);
|
||||
}
|
||||
|
||||
workflow.blocks.forEach((block, idxBlock) => {
|
||||
// if (!currentPath.includes(block.id)) return;
|
||||
if (onlyInBlock && block.id !== currentBlock.id) return;
|
||||
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
|
||||
if (!workflowBlock) return;
|
||||
|
||||
workflowBlock.outputs.forEach((output) => {
|
||||
const variableName = `{{${block.variableName}.${output.name}}}`;
|
||||
let variableLabel = getWorkflowBlockOutputLabel(output, i18nT);
|
||||
if (onlyInBlock) {
|
||||
variableLabel = getWorkflowBlockOutputLabel(output, i18nT);
|
||||
}
|
||||
variables.push({
|
||||
group: `${getWorkflowBlockName(workflowBlock, i18nT)} ${idxBlock + 1}`,
|
||||
name: variableName,
|
||||
label: variableLabel,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
function getVariableName({ workflow, currentBlock }: { workflow: WorkflowDto; currentBlock: { id: string; type: string } }) {
|
||||
// Function to recursively build a list of block IDs in execution order
|
||||
function buildExecutionOrder(blockId: string, order: string[] = [], visited = new Set()) {
|
||||
if (visited.has(blockId)) return order;
|
||||
visited.add(blockId);
|
||||
|
||||
const block = workflow.blocks.find((b) => b.id === blockId);
|
||||
if (!block) return order;
|
||||
|
||||
order.push(blockId);
|
||||
|
||||
const orders = ["true", "false", "default", "case", "loopNext", "loopEnd"];
|
||||
block.toBlocks
|
||||
.sort((a, b) => {
|
||||
if (!a.condition) {
|
||||
return -1;
|
||||
}
|
||||
if (!b.condition) {
|
||||
return 1;
|
||||
}
|
||||
const aOrder = orders.findIndex((f) => a.condition?.startsWith(f));
|
||||
const bOrder = orders.findIndex((f) => b.condition?.startsWith(f));
|
||||
return aOrder - bOrder;
|
||||
})
|
||||
.forEach((toBlock) => {
|
||||
buildExecutionOrder(toBlock.toBlockId, order, visited);
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
// Get execution order starting from the first block (assuming it's a trigger)
|
||||
const executionOrder = buildExecutionOrder(workflow.blocks.find((b) => b.isTrigger)?.id || "");
|
||||
|
||||
// Count occurrences of block types in execution order
|
||||
const blockTypeCounts: { [key: string]: number } = {};
|
||||
executionOrder.forEach((blockId) => {
|
||||
const block = workflow.blocks.find((b) => b.id === blockId);
|
||||
if (block) {
|
||||
blockTypeCounts[block.type] = (blockTypeCounts[block.type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Determine the index of the current block in its type group
|
||||
const currentBlockIndex = executionOrder.filter((id) => workflow.blocks.find((b) => b.id === id)?.type === currentBlock.type).indexOf(currentBlock.id) + 1;
|
||||
|
||||
// Append index if there are multiple blocks of the same type
|
||||
return {
|
||||
variableName: blockTypeCounts[currentBlock.type] > 1 ? `${currentBlock.type}${currentBlockIndex}` : currentBlock.type,
|
||||
index: currentBlockIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function getBlockTypeName(workflowBlock: { type: string }) {
|
||||
const workflowBlockType = WorkflowBlockTypes.find((f) => f.value === workflowBlock.type);
|
||||
if (!workflowBlockType) return "";
|
||||
return getWorkflowBlockName(workflowBlockType, i18nT);
|
||||
}
|
||||
|
||||
export default {
|
||||
canRun,
|
||||
canRunManually,
|
||||
getStatusColor,
|
||||
hasTriggerNode,
|
||||
getBlocksErrors,
|
||||
getBlocksWithoutCallers,
|
||||
isReady,
|
||||
getVariables,
|
||||
getVariableName,
|
||||
getBlockTypeName,
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { featureKeys } from "@toolctrl/entitlements";
|
||||
import { requireTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
|
||||
|
||||
export namespace WorkflowEngineApi {
|
||||
type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
if (params.tenant) {
|
||||
await requireTenantProductAccess({ request, params, featureKey: featureKeys.workflowEngine });
|
||||
}
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.index", { value0: process.env.APP_NAME }) }],
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import IncreaseIcon from "~/components/ui/icons/crm/IncreaseIcon";
|
||||
import IncreaseIconFilled from "~/components/ui/icons/crm/IncreaseIconFilled";
|
||||
import WorkflowCredentialIcon from "~/modules/workflowEngine/components/icons/WorkflowCredentialIcon";
|
||||
import WorkflowCredentialIconFilled from "~/modules/workflowEngine/components/icons/WorkflowCredentialIconFilled";
|
||||
import WorkflowExecutionIcon from "~/modules/workflowEngine/components/icons/WorkflowExecutionIcon";
|
||||
import WorkflowExecutionIconFilled from "~/modules/workflowEngine/components/icons/WorkflowExecutionIconFilled";
|
||||
import WorkflowIcon from "~/modules/workflowEngine/components/icons/WorkflowIcon";
|
||||
import WorkflowIconFilled from "~/modules/workflowEngine/components/icons/WorkflowIconFilled";
|
||||
import WorkflowVariableIcon from "~/modules/workflowEngine/components/icons/WorkflowVariableIcon";
|
||||
import WorkflowVariableIconFilled from "~/modules/workflowEngine/components/icons/WorkflowVariableIconFilled";
|
||||
import SidebarIconsLayout from "~/components/ui/layouts/SidebarIconsLayout";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import TemplateIcon from "~/components/ui/icons/entities/TemplateIcon";
|
||||
import TemplateIconFilled from "~/components/ui/icons/entities/TemplateIconFilled";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangleIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function WorkflowEngineView() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
return (
|
||||
<SidebarIconsLayout
|
||||
label={{ align: "right" }}
|
||||
items={[
|
||||
{
|
||||
name: t("shared.overview"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine`),
|
||||
exact: true,
|
||||
icon: <IncreaseIcon className="h-5 w-5" />,
|
||||
iconSelected: <IncreaseIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/workflows`),
|
||||
icon: <WorkflowIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.variables.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/variables`),
|
||||
icon: <WorkflowVariableIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowVariableIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.credentials.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/credentials`),
|
||||
icon: <WorkflowCredentialIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowCredentialIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.executions.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/executions`),
|
||||
icon: <WorkflowExecutionIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowExecutionIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.templates.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/templates`),
|
||||
icon: <TemplateIcon className="h-5 w-5" />,
|
||||
iconSelected: <TemplateIconFilled className="h-5 w-5" />,
|
||||
bottom: true,
|
||||
},
|
||||
{
|
||||
name: t("workflows.nav.danger"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/danger`),
|
||||
bottom: true,
|
||||
icon: <AlertTriangleIcon className="h-5 w-5" />,
|
||||
iconSelected: <AlertTriangleIcon className="h-5 w-5" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Outlet />
|
||||
</SidebarIconsLayout>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export namespace WorkflowsIdExecutionsApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
item: WorkflowDto;
|
||||
executions: WorkflowExecutionDto[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const item = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!item) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const executions = await WorkflowsService.getExecutions(item, { tenantId });
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.executions", { value0: item.name, value1: webConfig.appName }) }],
|
||||
item,
|
||||
executions,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { Link, useLoaderData, useParams, useSearchParams } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
|
||||
import WorkflowExecutionsSidebar from "~/modules/workflowEngine/components/workflows/WorkflowExecutionsSidebar";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdExecutionsApi } from "./workflows.$id.executions.api.server";
|
||||
|
||||
export default function WorkflowsIdExecutionsView() {
|
||||
const data = useLoaderData<WorkflowsIdExecutionsApi.LoaderData>();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [workflow] = useState<WorkflowDto>(data.item);
|
||||
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecutionDto | null>(null);
|
||||
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const executionId = searchParams.get("executionId");
|
||||
if (executionId) {
|
||||
const execution = data.executions.find((x) => x.id === executionId);
|
||||
if (execution) {
|
||||
setSelectedExecution(execution);
|
||||
}
|
||||
} else {
|
||||
setSelectedExecution(null);
|
||||
}
|
||||
}, [data.executions, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
|
||||
</div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}`)}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToEditor")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={workflow} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
|
||||
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
|
||||
height. On mobile the outer flex container only has min-height, so a
|
||||
flex-1 child stays indefinite and the canvas renders hidden/collapsed.
|
||||
Use a fixed vh height on mobile (always definite); flex sizing only on
|
||||
lg. Mirrors the builder index view. */}
|
||||
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
|
||||
<LazyWorkflowBuilder
|
||||
key={selectedExecution?.id}
|
||||
readOnly={true}
|
||||
workflow={workflow}
|
||||
selectedBlock={selectedBlock}
|
||||
workflowExecution={selectedExecution}
|
||||
onSelectedBlock={setSelectedBlock}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
|
||||
<WorkflowExecutionsSidebar
|
||||
key={selectedExecution?.id}
|
||||
workflow={workflow}
|
||||
selectedBlock={selectedBlock}
|
||||
selectedExecution={selectedExecution}
|
||||
executions={data.executions}
|
||||
onSelectedBlock={setSelectedBlock}
|
||||
onSelectedExecution={setSelectedExecution}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
requireWorkflowMutationAccess: vi.fn(),
|
||||
validateCSRFToken: vi.fn(),
|
||||
getWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: mocks.getTranslations,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("../workflowAccess.server", () => ({
|
||||
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/session.server", () => ({
|
||||
validateCSRFToken: mocks.validateCSRFToken,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
|
||||
default: {
|
||||
get: mocks.getWorkflow,
|
||||
update: mocks.updateWorkflow,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Signup flow",
|
||||
description: "",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsIdIndexApi.action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mocks.getTranslations.mockResolvedValue({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
"workflows.errors.workflowBuilderActionFailed": "Workflow builder action failed. Check your changes and try again.",
|
||||
"workflows.errors.workflowBuilderInvalidPayload": "Workflow builder payload is not valid JSON.",
|
||||
};
|
||||
return translations[key] ?? String(options?.defaultValue ?? key);
|
||||
},
|
||||
});
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
|
||||
mocks.validateCSRFToken.mockResolvedValue(undefined);
|
||||
mocks.getWorkflow.mockResolvedValue(workflow());
|
||||
mocks.updateWorkflow.mockResolvedValue(workflow());
|
||||
});
|
||||
|
||||
it("neutralizes external save redirect targets and keeps the workflow tenant-scoped", async () => {
|
||||
const savedWorkflow = workflow({ name: "Updated flow" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "save");
|
||||
form.set("workflow", JSON.stringify(savedWorkflow));
|
||||
form.set("redirectTo", "https://evil.example/phish");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1?tab=builder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdIndexApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
|
||||
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
|
||||
"workflow_1",
|
||||
expect.objectContaining({ id: savedWorkflow.id, name: savedWorkflow.name }),
|
||||
expect.objectContaining({ tenantId: "tenant_1", userId: "user_1" })
|
||||
);
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/acme/workflow-engine/workflows/workflow_1?tab=builder");
|
||||
expect(response.headers.get("Location")).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
it("returns a generic translated error for unknown save failures", async () => {
|
||||
mocks.updateWorkflow.mockRejectedValueOnce(new Error("database connection leaked-secret"));
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "save");
|
||||
form.set("workflow", JSON.stringify(workflow({ name: "Updated flow" })));
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdIndexApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Workflow builder action failed. Check your changes and try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a translated invalid payload error instead of the JSON parser message", async () => {
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "save");
|
||||
form.set("workflow", "{");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdIndexApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Workflow builder payload is not valid JSON.",
|
||||
});
|
||||
expect(mocks.updateWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
|
||||
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
|
||||
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT, type TFunction } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowBuilderActionError } from "../workflowRunActionErrors.server";
|
||||
|
||||
function actionText(t: TFunction, key: string, fallback: string, values?: Record<string, unknown>) {
|
||||
const translated = t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
return translated === key ? fallback : translated;
|
||||
}
|
||||
|
||||
export namespace WorkflowsIdIndexApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
item: WorkflowDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const item = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!item) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.build", { value0: item.name, value1: webConfig.appName }) }],
|
||||
item,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const auth = await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
const item = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!item) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
if (action === "save") {
|
||||
try {
|
||||
const workflow = JSON.parse(form.get("workflow")?.toString() ?? "{}") as WorkflowDto;
|
||||
const redirectTo = form.get("redirectTo")?.toString();
|
||||
await WorkflowsService.update(params.id!, workflow, { tenantId, userId: auth.userId ?? null });
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo));
|
||||
}
|
||||
return Response.json({ success: actionText(t, "workflows.responses.workflowSaved", "Workflow saved: {{value0}}", { value0: workflow.name }) });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "update-block") {
|
||||
const id = form.get("id")?.toString();
|
||||
const block = item.blocks.find((x) => x.id === id);
|
||||
if (!block) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const blockData = JSON.parse(form.get("block")?.toString() ?? "{}") as WorkflowBlockDto;
|
||||
const redirectTo = form.get("redirectTo")?.toString();
|
||||
await WorkflowsService.updateBlock(block.id, blockData, { workflow: item, userId: auth.userId ?? null });
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo));
|
||||
}
|
||||
return Response.json({ success: actionText(t, "workflows.responses.blockUpdated", "Block updated: {{value0}}", { value0: blockData.type }) });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "add-block") {
|
||||
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
|
||||
const condition = form.get("condition")?.toString() ?? "";
|
||||
const type = form.get("type")?.toString() as WorkflowBlockType;
|
||||
if (fromBlockId) {
|
||||
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
|
||||
if (!fromBlock) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.fromBlockNotFound", "From block not found") }, { status: 404 });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const newBlock = await WorkflowsService.addBlock({
|
||||
workflow: item,
|
||||
fromBlockId,
|
||||
type,
|
||||
condition,
|
||||
userId: auth.userId ?? null,
|
||||
});
|
||||
return Response.json({
|
||||
success: actionText(t, "workflows.responses.blockAdded", "Block added"),
|
||||
newBlockId: newBlock.id,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "delete-block") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const block = item.blocks.find((x) => x.id === id);
|
||||
if (!block) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
await WorkflowsService.deleteBlock(block.id, { workflow: item, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.blockDeleted", "Block deleted") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "delete-connection") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
|
||||
const toBlockId = form.get("toBlockId")?.toString() ?? "";
|
||||
try {
|
||||
if (fromBlockId && toBlockId) {
|
||||
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
|
||||
const toBlock = item.blocks.find((x) => x.id === toBlockId);
|
||||
if (!fromBlock || !toBlock) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.deleteConnection({ fromBlockId, toBlockId }, { workflow: item, userId: auth.userId ?? null });
|
||||
} else {
|
||||
const connection = item.blocks.flatMap((block) => block.toBlocks).find((candidate) => candidate.id === id);
|
||||
if (!connection) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.connectionNotFound", "Connection not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.deleteConnection({ id: connection.id }, { workflow: item, userId: auth.userId ?? null });
|
||||
}
|
||||
return Response.json({ success: actionText(t, "workflows.responses.connectionDeleted", "Connection deleted") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "connect-blocks") {
|
||||
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
|
||||
const toBlockId = form.get("toBlockId")?.toString() ?? "";
|
||||
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
|
||||
const toBlock = item.blocks.find((x) => x.id === toBlockId);
|
||||
const condition = form.get("condition")?.toString() ?? "";
|
||||
if (!fromBlock || !toBlock) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
await WorkflowsService.connectBlocks({ fromBlockId, toBlockId, condition }, { workflow: item, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.blocksConnected", "Blocks connected") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "update-conditions-groups") {
|
||||
const blockId = form.get("blockId")?.toString() ?? "";
|
||||
const block = item.blocks.find((x) => x.id === blockId);
|
||||
if (!block) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const conditionsGroups = JSON.parse(form.get("conditionsGroups")?.toString() ?? "[]") as WorkflowConditionsGroupDto[];
|
||||
await WorkflowsService.updateConditionsGroups(block, conditionsGroups, { workflow: item, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.conditionsGroupsUpdated", "Condition groups updated") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "create-input-example") {
|
||||
const title = form.get("title")?.toString() ?? "";
|
||||
const input = form.get("input")?.toString() ?? "";
|
||||
await WorkflowsService.createInputExample({
|
||||
workflowId: item.id,
|
||||
tenantId: item.tenantId,
|
||||
title,
|
||||
input,
|
||||
});
|
||||
return Response.json({ success: actionText(t, "workflows.responses.inputExampleCreated", "Input example created") });
|
||||
} else if (action === "update-input-example") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const title = form.get("title")?.toString() ?? "";
|
||||
const input = form.get("input")?.toString() ?? "";
|
||||
const inputExample = item.inputExamples.find((x) => x.id === id);
|
||||
if (!inputExample) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.updateInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId }, {
|
||||
title,
|
||||
input,
|
||||
});
|
||||
return Response.json({ success: actionText(t, "workflows.responses.inputExampleUpdated", "Input example updated") });
|
||||
} else if (action === "delete-input-example") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const inputExample = item.inputExamples.find((x) => x.id === id);
|
||||
if (!inputExample) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.deleteInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.inputExampleDeleted", "Input example deleted") });
|
||||
} else if (action === "delete") {
|
||||
try {
|
||||
await WorkflowsService.del(item.id, { tenantId });
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "toggle") {
|
||||
const enabled = form.get("enabled")?.toString() === "true";
|
||||
|
||||
if (enabled && item.status === "draft") {
|
||||
await WorkflowsService.update(item.id, { status: "live" }, { tenantId, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.workflowLive", "Workflow is now live") });
|
||||
} else if (!enabled && item.status === "live") {
|
||||
await WorkflowsService.update(
|
||||
item.id,
|
||||
{
|
||||
status: "draft",
|
||||
},
|
||||
{ tenantId, userId: auth.userId ?? null }
|
||||
);
|
||||
return Response.json({ success: actionText(t, "workflows.responses.workflowDraft", "Workflow is now a draft") });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: actionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { toast } from "sonner";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
|
||||
import WorkflowEditorSidebar from "~/modules/workflowEngine/components/workflows/WorkflowEditorSidebar";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import InputCheckbox from "~/components/ui/input/InputCheckbox";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
|
||||
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
|
||||
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export default function WorkflowsIdIndexView() {
|
||||
const data = useLoaderData<WorkflowsIdIndexApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdIndexApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
|
||||
const [workflow, setWorkflow] = useState<WorkflowDto>(data.item);
|
||||
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
|
||||
const [addingNextBlockFrom, setAddingNextBlockFrom] = useState<{ fromBlock: WorkflowBlockDto; condition: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
} else if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflow(data.item);
|
||||
if (selectedBlock) {
|
||||
setSelectedBlock(data.item.blocks.find((x) => x.id === selectedBlock.id) ?? null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBlock === null && addingNextBlockFrom !== null) {
|
||||
setAddingNextBlockFrom(null);
|
||||
}
|
||||
}, [addingNextBlockFrom, selectedBlock]);
|
||||
|
||||
function onSave(workflow: WorkflowDto) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "save");
|
||||
form.append("workflow", JSON.stringify(workflow));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onSaveBlock(block: WorkflowBlockDto) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "update-block");
|
||||
form.set("id", block.id);
|
||||
form.append("block", JSON.stringify(block));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onAddBlock({ type, from, condition }: { type: WorkflowBlockType; from: WorkflowBlockDto | undefined; condition: string | null }) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "add-block");
|
||||
form.set("type", type);
|
||||
if (condition) {
|
||||
form.set("condition", condition);
|
||||
}
|
||||
if (from) {
|
||||
form.set("fromBlockId", from.id);
|
||||
}
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onConnectBlocks(params: { fromBlockId: string; toBlockId: string; condition: string | null }) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "connect-blocks");
|
||||
form.set("fromBlockId", params.fromBlockId);
|
||||
form.set("toBlockId", params.toBlockId);
|
||||
if (params.condition) {
|
||||
form.set("condition", params.condition);
|
||||
}
|
||||
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onDeleteBlock(id: string) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "delete-block");
|
||||
form.set("id", id);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onDeleteConnection(params: { fromBlockId: string; toBlockId: string } | { id: string }) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "delete-connection");
|
||||
if ("id" in params) {
|
||||
form.set("id", params.id);
|
||||
} else {
|
||||
form.set("fromBlockId", params.fromBlockId);
|
||||
form.set("toBlockId", params.toBlockId);
|
||||
}
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onUpdateConditionsGroups(blockId: string, conditionsGroups: WorkflowConditionsGroupDto[]) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "update-conditions-groups");
|
||||
form.set("blockId", blockId);
|
||||
form.set("conditionsGroups", JSON.stringify(conditionsGroups));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onToggle(enabled: boolean) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "toggle");
|
||||
form.set("enabled", enabled ? "true" : "false");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
|
||||
</div>
|
||||
{/* relative z-50 gives the toolbar its own stacking context above the
|
||||
ReactFlow canvas below. ReactFlow's root sets no position/z-index, so
|
||||
its internal positioned layers (pane/renderer/controls, z-index ≤ 5)
|
||||
otherwise paint over this static toolbar where they overlap at mobile
|
||||
width — stealing pointer/keyboard from the run dropdown. */}
|
||||
<div className="border-border bg-background relative z-50 w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div data-ui-id="ui.workflows.builder.statusToggle">
|
||||
<InputCheckbox
|
||||
disabled={!WorkflowUtils.isReady(workflow) && workflow.status !== "live"}
|
||||
asToggle
|
||||
withLabel={false}
|
||||
title={i18nT("workflows.list.toggleStatus", { name: workflow.name })}
|
||||
value={workflow.status === "live"}
|
||||
setValue={(checked) => onToggle(Boolean(checked))}
|
||||
/>
|
||||
</div>
|
||||
<SimpleBadge title={i18nT(`workflows.status.${workflow.status}`)} color={WorkflowUtils.getStatusColor(workflow)} />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
to={`executions`}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
|
||||
>
|
||||
{i18nT("workflows.navigation.viewAllExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={workflow} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
|
||||
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
|
||||
height. On mobile the outer flex container only has min-height, so a
|
||||
flex-1 child stays indefinite and the canvas collapses to 0. Use a
|
||||
fixed vh height on mobile (always definite); flex sizing only on lg. */}
|
||||
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
|
||||
<LazyWorkflowBuilder
|
||||
workflow={workflow}
|
||||
selectedBlock={selectedBlock}
|
||||
workflowExecution={null}
|
||||
onSelectedBlock={setSelectedBlock}
|
||||
onConnectBlocks={onConnectBlocks}
|
||||
onDeleteConnection={(id) => onDeleteConnection({ id })}
|
||||
onDeleteBlock={onDeleteBlock}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
|
||||
<WorkflowEditorSidebar
|
||||
workflow={workflow}
|
||||
onSave={onSave}
|
||||
selectedBlock={selectedBlock}
|
||||
addingNextBlockFrom={addingNextBlockFrom}
|
||||
onSelectedBlock={(block) => {
|
||||
setSelectedBlock(block);
|
||||
}}
|
||||
onSaveBlock={onSaveBlock}
|
||||
onSetTrigger={(type) => {
|
||||
onAddBlock({ type: type, from: undefined, condition: null });
|
||||
setAddingNextBlockFrom(null);
|
||||
}}
|
||||
onAddBlock={({ from, type, condition }) => {
|
||||
onAddBlock({ type: type, from: from, condition });
|
||||
setAddingNextBlockFrom(null);
|
||||
}}
|
||||
onAddingNextBlock={(data) => {
|
||||
setAddingNextBlockFrom(data);
|
||||
}}
|
||||
onDeleteBlock={onDeleteBlock}
|
||||
onDeleteConnection={onDeleteConnection}
|
||||
onUpdateConditionsGroups={onUpdateConditionsGroups}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import { apiPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getFirstApiKey } from "~/utils/db/apiKeys.db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { getAbsoluteApiUrl } from "~/utils/url.server";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
|
||||
|
||||
export namespace WorkflowsIdRunApiApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
workflow: WorkflowDto;
|
||||
apiRunUrl: string;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const workflow = await WorkflowsService.get(params.id!, {
|
||||
tenantId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.runApi", { value0: workflow.name, value1: webConfig.appName }) }],
|
||||
workflow,
|
||||
apiRunUrl: getAbsoluteApiUrl(request, apiPath(`workflows/run/${workflow.id}`, { mode: "host-aware" })),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
errorCode?: "noApiKey" | "noValidApiKey";
|
||||
execution?: WorkflowExecutionDto;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
|
||||
let apiKey: string | null = null;
|
||||
if (tenantId === null) {
|
||||
apiKey = loadWebConfig().config.apiAccessToken || null;
|
||||
if (!apiKey) {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.noApiKey", "No API key found"), errorCode: "noApiKey" }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
const validApiKey = await getFirstApiKey(tenantId);
|
||||
if (!validApiKey) {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.noValidApiKey", "No valid API key found"), errorCode: "noValidApiKey" }, { status: 400 });
|
||||
}
|
||||
apiKey = validApiKey.key;
|
||||
}
|
||||
|
||||
if (action === "execute") {
|
||||
try {
|
||||
const input = form.get("input")?.toString() ?? "{}";
|
||||
const url = getAbsoluteApiUrl(request, apiPath(`workflows/run/${params.id}`, { mode: "host-aware" }));
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: input,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
return Response.json({ execution: data.execution });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
|
||||
}
|
||||
} else if (action === "continue-execution") {
|
||||
try {
|
||||
const executionId = form.get("executionId")?.toString() ?? "";
|
||||
const input = form.get("input")?.toString() ?? "{}";
|
||||
const url = getAbsoluteApiUrl(request, apiPath(`workflows/executions/${executionId}/continue`, { mode: "host-aware" }));
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: input,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
return Response.json({ execution: data.execution });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdRunApiApi } from "./workflows.$id.run.api.server";
|
||||
import clsx from "clsx";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
|
||||
import { appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
import { SendIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function WorkflowsIdRunApiApiView() {
|
||||
const data = useLoaderData<WorkflowsIdRunApiApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdRunApiApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state === "submitting";
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
|
||||
const [inputData, setInputData] = useState("{}");
|
||||
const [waitingBlockInput, setWaitingBlockInput] = useState("");
|
||||
const waitingBlockInputId = useId();
|
||||
|
||||
const waitingForInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
waitingForInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [execution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.workflow.inputExamples.length > 0) {
|
||||
setSelectedTemplate(data.workflow.inputExamples[0].title);
|
||||
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
|
||||
if (actionData?.execution) {
|
||||
setExecution(actionData.execution);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onExecute() {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "execute");
|
||||
form.append("input", inputData);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", execution?.id ?? "");
|
||||
form.set("input", JSON.stringify({ input: waitingBlockInput }));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
setWaitingBlockInput("");
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple
|
||||
menu={[
|
||||
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
|
||||
{ title: data.workflow.name },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={data.workflow} />
|
||||
</div>
|
||||
</div>
|
||||
<div data-ui-id="ui.workflows.run.api.form" className="mx-auto max-w-2xl space-y-2 p-4">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div data-ui-id="ui.workflows.run.api.title" className="text-lg font-semibold">
|
||||
{i18nT("workflows.run.apiTitle")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!execution ? (
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.body")}</div>
|
||||
{data.workflow.inputExamples.length > 0 && (
|
||||
<div data-ui-id="ui.workflows.run.api.inputExampleSelect">
|
||||
<WorkflowInputExamplesDropdown
|
||||
workflow={data.workflow}
|
||||
onSelected={(item) => {
|
||||
setSelectedTemplate(item.title);
|
||||
setInputData(JSON.stringify(item.input, null, 2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-ui-id="ui.workflows.run.api.inputEditor" className="border-border overflow-hidden rounded-md border">
|
||||
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
<InputText dataUiId="ui.workflows.run.api.field.url" title={i18nT("workflows.run.url")} defaultValue={data.apiRunUrl} readOnly />
|
||||
<InputText dataUiId="ui.workflows.run.api.field.method" title={i18nT("workflows.run.method")} defaultValue="POST" readOnly />
|
||||
<InputText
|
||||
dataUiId="ui.workflows.run.api.field.apiKey"
|
||||
title={i18nT("workflows.run.apiKeyHeader")}
|
||||
defaultValue={i18nT("workflows.run.apiKeyPlaceholder")}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<LoadingButton dataUiId="ui.workflows.run.api.submit" actionName="execute" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)}>
|
||||
{i18nT("workflows.actions.run")}
|
||||
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" data-ui-id="ui.workflows.run.api.executionResult">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
target="_blank"
|
||||
data-ui-id="ui.workflows.run.api.viewExecutionFlow"
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
|
||||
</div>
|
||||
</>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
data-ui-id="ui.workflows.run.api.runAgain"
|
||||
onClick={() => {
|
||||
setExecution(null);
|
||||
}}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-border overflow-hidden rounded-md border">
|
||||
<MonacoEditor className="h-40" theme="vs-dark" value={JSON.stringify({ execution }, null, 2)} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
|
||||
{execution.waitingBlock && (
|
||||
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.api.waitingInput.form">
|
||||
<div className="space-y-1">
|
||||
{execution.waitingBlock.input.title && (
|
||||
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
|
||||
{execution.waitingBlock.input.title}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={waitingForInputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
name="input"
|
||||
id={waitingBlockInputId}
|
||||
data-ui-id="ui.workflows.run.api.waitingInput.input"
|
||||
autoComplete="off"
|
||||
value={waitingBlockInput}
|
||||
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
|
||||
className={clsx(
|
||||
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
|
||||
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
|
||||
)}
|
||||
placeholder={execution.waitingBlock.input.placeholder}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
data-ui-id="ui.workflows.run.api.waitingInput.submit"
|
||||
aria-label={i18nT("shared.submit")}
|
||||
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
<ErrorBanner title={i18nT("workflows.executionStatus.error")}>
|
||||
<div>{actionData.error}</div>
|
||||
{actionData.errorCode === "noValidApiKey" && params.tenant && (
|
||||
<div>
|
||||
<Link className="underline" target="_blank" to={appPath({ tenantSlug: params.tenant, path: "settings/api/keys", mode: "host-aware" })}>
|
||||
{i18nT("workflows.run.createApiKeyLink")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
requireWorkflowMutationAccess: vi.fn(),
|
||||
validateCSRFToken: vi.fn(),
|
||||
getWorkflow: vi.fn(),
|
||||
queueManualExecution: vi.fn(),
|
||||
queueContinuationExecution: vi.fn(),
|
||||
continueExecution: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: mocks.getTranslations,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/session.server", () => ({
|
||||
getUserInfo: mocks.getUserInfo,
|
||||
validateCSRFToken: mocks.validateCSRFToken,
|
||||
}));
|
||||
|
||||
vi.mock("../workflowAccess.server", () => ({
|
||||
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
|
||||
default: {
|
||||
get: mocks.getWorkflow,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
|
||||
default: {
|
||||
queueManualExecution: mocks.queueManualExecution,
|
||||
queueContinuationExecution: mocks.queueContinuationExecution,
|
||||
continueExecution: mocks.continueExecution,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Signup flow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsIdRunManualApi.action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue(undefined);
|
||||
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
|
||||
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
|
||||
mocks.validateCSRFToken.mockResolvedValue(undefined);
|
||||
mocks.getWorkflow.mockResolvedValue(workflow());
|
||||
});
|
||||
|
||||
it("rejects invalid execute input JSON before queueing and keeps the workflow lookup tenant-scoped", async () => {
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "execute");
|
||||
form.set("input", "{");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunManualApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({ error: "Input data is not valid JSON" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
|
||||
expect(mocks.queueManualExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues manual executions instead of executing inline", async () => {
|
||||
mocks.queueManualExecution.mockResolvedValue({ id: "execution_1", status: "running" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "execute");
|
||||
form.set("input", JSON.stringify({ name: "Ada" }));
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunManualApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: "Workflow queued",
|
||||
execution: { id: "execution_1", status: "running" },
|
||||
});
|
||||
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
|
||||
expect(mocks.queueManualExecution).toHaveBeenCalledWith("workflow_1", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
expect(mocks.queueContinuationExecution).not.toHaveBeenCalled();
|
||||
expect(mocks.continueExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues manual continuations instead of executing inline", async () => {
|
||||
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waiting" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", "execution_1");
|
||||
form.set("input", "approved");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunManualApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: "Workflow queued",
|
||||
execution: { id: "execution_1", status: "waiting" },
|
||||
});
|
||||
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
|
||||
type: "manual",
|
||||
input: { input: "approved" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
expect(mocks.continueExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
|
||||
|
||||
export namespace WorkflowsIdRunManualApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
workflow: WorkflowDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const workflow = await WorkflowsService.get(params.id!, {
|
||||
tenantId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.runManual", { value0: workflow.name, value1: webConfig.appName }) }],
|
||||
workflow,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
execution?: WorkflowExecutionDto;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await getUserInfo(request);
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const workflow = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!workflow || workflow.status !== "live") {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
|
||||
}
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "execute") {
|
||||
try {
|
||||
let input = form.get("input")?.toString() ?? "{}";
|
||||
let inputData: { [key: string]: any } | null = null;
|
||||
if (!input.trim()) {
|
||||
input = "{}";
|
||||
}
|
||||
if (input) {
|
||||
try {
|
||||
inputData = JSON.parse(input);
|
||||
} catch {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const execution = await WorkflowsExecutionsService.queueManualExecution(params.id!, {
|
||||
input: inputData,
|
||||
session: {
|
||||
tenantId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
|
||||
}
|
||||
} else if (action === "continue-execution") {
|
||||
try {
|
||||
const executionId = form.get("executionId")?.toString() ?? "";
|
||||
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
|
||||
type: "manual",
|
||||
input: { input: form.get("input")?.toString() },
|
||||
session: { tenantId, userId },
|
||||
});
|
||||
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import { WorkflowBlockExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowBlockExecutionDto";
|
||||
import { getWorkflowBlockName, WorkflowBlockTypes } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
|
||||
import { WorkflowDto } from "../../../dtos/WorkflowDto";
|
||||
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
import { formatWorkflowExecutionErrorText } from "~/modules/workflowEngine/utils/workflowExecutionErrorText";
|
||||
import { SendIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function WorkflowsIdRunManualView() {
|
||||
const data = useLoaderData<WorkflowsIdRunManualApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdRunManualApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state === "submitting";
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
|
||||
const [inputData, setInputData] = useState("{}");
|
||||
const [waitingBlockInput, setWaitingBlockInput] = useState("");
|
||||
const waitingBlockInputId = useId();
|
||||
|
||||
const waitingForInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
waitingForInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [execution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.workflow.inputExamples.length > 0) {
|
||||
setSelectedTemplate(data.workflow.inputExamples[0].title);
|
||||
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.workflow.inputExamples.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
|
||||
if (actionData?.execution) {
|
||||
setExecution(actionData.execution);
|
||||
actionData?.execution?.executionAlerts.forEach((executionAlert) => {
|
||||
if (executionAlert.type === "error") {
|
||||
toast.error(executionAlert.message, {
|
||||
position: "bottom-right",
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
toast.success(executionAlert.message, {
|
||||
position: "bottom-right",
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onExecute() {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "execute");
|
||||
form.append("input", inputData);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", execution?.id ?? "");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
setWaitingBlockInput("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple
|
||||
menu={[
|
||||
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
|
||||
{ title: data.workflow.name },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={data.workflow} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl space-y-2 p-4">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div className="text-lg font-semibold" data-ui-id="ui.workflows.run.manual.title">
|
||||
{i18nT("workflows.run.manualTitle")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!execution ? (
|
||||
<div data-ui-id="ui.workflows.run.manual.form">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
|
||||
{data.workflow.inputExamples.length > 0 && (
|
||||
<div data-ui-id="ui.workflows.run.manual.inputExampleSelect">
|
||||
<WorkflowInputExamplesDropdown
|
||||
workflow={data.workflow}
|
||||
onSelected={(item) => {
|
||||
setSelectedTemplate(item.title);
|
||||
setInputData(JSON.stringify(item.input, null, 2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-border overflow-hidden rounded-md border" data-ui-id="ui.workflows.run.manual.inputEditor">
|
||||
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<LoadingButton
|
||||
actionName="execute"
|
||||
onClick={onExecute}
|
||||
disabled={!WorkflowUtils.canRun(data.workflow)}
|
||||
className="ui-workflows-run-manual-submit"
|
||||
dataUiId="ui.workflows.run.manual.submit"
|
||||
>
|
||||
{i18nT("workflows.actions.run")}
|
||||
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" data-ui-id="ui.workflows.run.manual.executionResult">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
target="_blank"
|
||||
data-ui-id="ui.workflows.run.manual.viewExecutionFlow"
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
|
||||
</div>
|
||||
</>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
data-ui-id="ui.workflows.run.manual.runAgain"
|
||||
onClick={() => {
|
||||
setExecution(null);
|
||||
}}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<WorkflowRun workflow={execution} />
|
||||
<div className="text-foreground font-medium">{i18nT("workflows.executions.blocksExecuted", { count: execution.blockRuns.length })}</div>
|
||||
<div className="w-full space-y-1">
|
||||
{execution.blockRuns.map((blockRun) => {
|
||||
return <BlockRun key={blockRun.id} workflow={data.workflow} blockRun={blockRun} />;
|
||||
})}
|
||||
</div>
|
||||
{execution.waitingBlock && (
|
||||
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.manual.waitingInput.form">
|
||||
<div className="space-y-1">
|
||||
{execution.waitingBlock.input.title && (
|
||||
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
|
||||
{execution.waitingBlock.input.title}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={waitingForInputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
name="input"
|
||||
id={waitingBlockInputId}
|
||||
data-ui-id="ui.workflows.run.manual.waitingInput.input"
|
||||
autoComplete="off"
|
||||
value={waitingBlockInput}
|
||||
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
|
||||
className={clsx(
|
||||
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
|
||||
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
|
||||
)}
|
||||
placeholder={execution.waitingBlock.input.placeholder}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
data-ui-id="ui.workflows.run.manual.waitingInput.submit"
|
||||
aria-label={i18nT("shared.submit")}
|
||||
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData?.error ? (
|
||||
<div role="alert" data-ui-id="ui.workflows.run.manual.error">
|
||||
<ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockRun({ workflow, blockRun }: { workflow: WorkflowDto; blockRun: WorkflowBlockExecutionDto }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const outputPreview = useMemo(() => (blockRun.output ? JSON.stringify({ output: blockRun.output }) : null), [blockRun.output]);
|
||||
const block = workflow.blocks.find((x) => x.id === blockRun.workflowBlockId);
|
||||
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block?.type);
|
||||
if (!block || !workflowBlock) {
|
||||
return null;
|
||||
}
|
||||
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-expanded={isExpanded}
|
||||
className={clsx(
|
||||
"hover:bg-secondary border-border bg-background block w-full cursor-pointer overflow-hidden rounded-md border p-2 text-left select-none",
|
||||
isExpanded ? "rounded-b-none" : "rounded-md"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
<div className="flex items-center space-x-2 truncate">
|
||||
<workflowBlock.icon aria-hidden="true" className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<div className="text-foreground/80 shrink-0 truncate text-xs font-medium">[{workflowBlockName}]</div>
|
||||
{blockRun.error ? (
|
||||
<div className="truncate text-xs text-red-600">{formatWorkflowExecutionErrorText(blockRun.error, i18nT)}</div>
|
||||
) : outputPreview ? (
|
||||
<div className="text-muted-foreground truncate text-xs">{outputPreview}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">{block.description || ""}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{block.type === "if" ? (
|
||||
<div>
|
||||
{blockRun.output?.condition ? (
|
||||
<SimpleBadge title={i18nT("workflows.boolean.true")} color={Colors.BLUE} />
|
||||
) : (
|
||||
<SimpleBadge title={i18nT("workflows.boolean.false")} color={Colors.ORANGE} />
|
||||
)}
|
||||
</div>
|
||||
) : block.type === "switch" ? (
|
||||
<SimpleBadge title={blockRun.output?.condition?.toString()} color={Colors.BLUE} />
|
||||
) : (
|
||||
<div>
|
||||
{blockRun.status === "running" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.running")} color={Colors.YELLOW} />
|
||||
) : blockRun.status === "error" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.error")} color={Colors.RED} />
|
||||
) : blockRun.status === "success" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.success")} color={Colors.GREEN} />
|
||||
) : (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.unknown")} color={Colors.GRAY} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-border bg-secondary/90 overflow-hidden rounded-md rounded-t-none border p-2">
|
||||
{blockRun.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={formatWorkflowExecutionErrorText(blockRun.error, i18nT)} />}
|
||||
<InputOutput input={blockRun.input} output={blockRun.output} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowRun({ workflow }: { workflow: WorkflowExecutionDto }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<div className="flex justify-center">{i18nT("workflows.run.inputOutput")}</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-border bg-secondary/90 overflow-hidden rounded-md border p-2">
|
||||
<InputOutput input={workflow.input} output={workflow.output} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOutput({ input, output }: { input?: any; output?: any }) {
|
||||
const compactInput = useMemo(() => (input ? JSON.stringify(input) : ""), [input]);
|
||||
const compactOutput = useMemo(() => (output ? JSON.stringify(output) : ""), [output]);
|
||||
const formattedInput = useMemo(() => (input ? JSON.stringify(input, null, 2) : ""), [input]);
|
||||
const formattedOutput = useMemo(() => (output ? JSON.stringify(output, null, 2) : ""), [output]);
|
||||
const hasInput = Boolean(input && compactInput !== "{}");
|
||||
const hasOutput = Boolean(output && compactOutput !== "{}");
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{!hasInput ? (
|
||||
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
|
||||
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noInput")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.input")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
|
||||
onClick={() => navigator.clipboard.writeText(compactInput)}
|
||||
>
|
||||
{i18nT("workflows.actions.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose max-h-24 overflow-auto">
|
||||
<pre>{formattedInput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasOutput ? (
|
||||
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
|
||||
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noOutput")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.outputs")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
|
||||
onClick={() => navigator.clipboard.writeText(compactOutput)}
|
||||
>
|
||||
{i18nT("workflows.actions.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose max-h-24 overflow-auto">
|
||||
<pre>{formattedOutput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { emitter } from "~/utils/stream/emitter.server";
|
||||
import { eventStream } from "~/utils/stream/event-stream";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export const workflowExecutionStreamLoader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const executionId = params.executionId;
|
||||
|
||||
if (!executionId) {
|
||||
return Response.json({ error: t("workflows.errors.executionIdRequired") }, { status: 400 });
|
||||
}
|
||||
|
||||
const execution = await WorkflowsService.getExecution(executionId, { tenantId });
|
||||
if (!execution) {
|
||||
return Response.json({ error: t("workflows.errors.executionNotFound") }, { status: 404 });
|
||||
}
|
||||
|
||||
const eventName = `workflowExecutionUpdate:${executionId}`;
|
||||
return eventStream(request.signal, (send) => {
|
||||
const listener = (data: string) => send({ data });
|
||||
emitter.on(eventName, listener);
|
||||
// Emit the current execution snapshot immediately (after subscribing, so no
|
||||
// concurrent update is dropped). The executor only emits on UPDATES, so a
|
||||
// subscriber that connects after the run already reached its state — a slow
|
||||
// connect under load, a reload, or a late mount — would otherwise see an
|
||||
// empty stream until the next change (which, for a waitingBlock, never comes
|
||||
// until the user continues). Same wire shape as the emitter's updates.
|
||||
send({ data: JSON.stringify(execution) });
|
||||
return () => {
|
||||
emitter.off(eventName, listener);
|
||||
};
|
||||
});
|
||||
};
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
requireWorkflowMutationAccess: vi.fn(),
|
||||
validateCSRFToken: vi.fn(),
|
||||
getWorkflow: vi.fn(),
|
||||
stream: vi.fn(),
|
||||
queueContinuationExecution: vi.fn(),
|
||||
continueExecution: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/config", () => ({
|
||||
loadWebConfig: () => ({ config: { appName: "Test App" } }),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: mocks.getTranslations,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/session.server", () => ({
|
||||
getUserInfo: mocks.getUserInfo,
|
||||
validateCSRFToken: mocks.validateCSRFToken,
|
||||
}));
|
||||
|
||||
vi.mock("../workflowAccess.server", () => ({
|
||||
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
|
||||
default: {
|
||||
get: mocks.getWorkflow,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
|
||||
default: {
|
||||
stream: mocks.stream,
|
||||
queueContinuationExecution: mocks.queueContinuationExecution,
|
||||
continueExecution: mocks.continueExecution,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Signup flow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsIdRunStreamApi.action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue(undefined);
|
||||
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
|
||||
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
|
||||
mocks.validateCSRFToken.mockResolvedValue(undefined);
|
||||
mocks.getWorkflow.mockResolvedValue(workflow());
|
||||
});
|
||||
|
||||
it("continues stream wait blocks through the workflow outbox", async () => {
|
||||
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waitingBlock" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", "execution_1");
|
||||
form.set("input", "approved");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunStreamApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: "Workflow queued",
|
||||
execution: { id: "execution_1", status: "waitingBlock" },
|
||||
});
|
||||
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
|
||||
type: "manual",
|
||||
input: { input: "approved" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
expect(mocks.continueExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
|
||||
|
||||
export namespace WorkflowsIdRunStreamApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
workflow: WorkflowDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const workflow = await WorkflowsService.get(params.id!, {
|
||||
tenantId,
|
||||
});
|
||||
if (!workflow) {
|
||||
return redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.runStream", { value0: workflow.name, value1: webConfig.appName }) }],
|
||||
workflow,
|
||||
};
|
||||
return Response.json(data);
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
executionId: string;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await getUserInfo(request);
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const workflow = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!workflow || workflow.status !== "live") {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
|
||||
}
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "execute") {
|
||||
try {
|
||||
let input = form.get("input")?.toString() ?? "{}";
|
||||
let inputData: { [key: string]: any } | null = null;
|
||||
if (!input.trim()) {
|
||||
input = "{}";
|
||||
}
|
||||
if (input) {
|
||||
try {
|
||||
inputData = JSON.parse(input);
|
||||
} catch {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const execution = await WorkflowsExecutionsService.stream(params.id!, {
|
||||
input: inputData,
|
||||
session: {
|
||||
tenantId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return Response.json({ executionId: execution.id });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
|
||||
}
|
||||
} else if (action === "continue-execution") {
|
||||
try {
|
||||
const executionId = form.get("executionId")?.toString() ?? "";
|
||||
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
|
||||
type: "manual",
|
||||
input: { input: form.get("input")?.toString() },
|
||||
session: { tenantId, userId },
|
||||
});
|
||||
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import WorkflowStreamProgress from "~/modules/workflowEngine/components/workflows/stream/WorkflowStreamProgress";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
|
||||
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export default function WorkflowsIdRunStreamView() {
|
||||
const data = useLoaderData<WorkflowsIdRunStreamApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdRunStreamApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
|
||||
const [executionId, setExecutionId] = useState<string | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [inputData, setInputData] = useState("{}");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.workflow.inputExamples.length > 0) {
|
||||
setSelectedTemplate(data.workflow.inputExamples[0].title);
|
||||
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
if (actionData?.executionId) {
|
||||
setExecutionId(actionData.executionId);
|
||||
setIsRunning(true);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onExecute() {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "execute");
|
||||
form.append("input", inputData);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
|
||||
<BreadcrumbSimple
|
||||
menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: data.workflow.name }]}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
|
||||
className="text-muted-foreground rounded bg-white px-2 py-1 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={data.workflow} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl space-y-2 p-4">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div data-ui-id="ui.workflows.run.stream.title" className="text-lg font-semibold">
|
||||
{i18nT("workflows.run.streamTitle")}
|
||||
</div>
|
||||
</div>
|
||||
<div data-ui-id="ui.workflows.run.stream.form">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
|
||||
{data.workflow.inputExamples.length > 0 && (
|
||||
<div data-ui-id="ui.workflows.run.stream.inputExampleSelect">
|
||||
<WorkflowInputExamplesDropdown
|
||||
workflow={data.workflow}
|
||||
onSelected={(item) => {
|
||||
setSelectedTemplate(item.title);
|
||||
setInputData(JSON.stringify(item.input, null, 2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div data-ui-id="ui.workflows.run.stream.inputEditor" className="border-border overflow-hidden rounded-md border">
|
||||
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<LoadingButton dataUiId="ui.workflows.run.stream.submit" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)} isLoading={isRunning}>
|
||||
{i18nT("workflows.actions.run")}
|
||||
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{executionId && (
|
||||
<WorkflowStreamProgress
|
||||
key={executionId}
|
||||
streamUrl={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/run/stream/${executionId}`)}
|
||||
onCompleted={() => setIsRunning(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{actionData?.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { deleteWorkflowCredential, getAllWorkflowCredentials } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export namespace WorkflowsCredentialsApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
items: WorkflowCredential[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
const items = await getAllWorkflowCredentials({ tenantId });
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.credentials", { value0: process.env.APP_NAME }) }],
|
||||
items,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "delete") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
await deleteWorkflowCredential(id, { tenantId });
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
|
||||
} else {
|
||||
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { MetaFunction, useLoaderData } from "react-router";
|
||||
import { Outlet, useSubmit } from "react-router";
|
||||
import { useRef } from "react";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import DateCell from "~/components/ui/dates/DateCell";
|
||||
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
||||
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowsCredentialsApi } from "./credentials.api.server";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export const meta: MetaFunction = () => [{ title: i18nT("workflows.credentials.title") }];
|
||||
|
||||
export default function WorkflowsCredentialsView() {
|
||||
const data = useLoaderData<WorkflowsCredentialsApi.LoaderData>();
|
||||
const submit = useSubmit();
|
||||
|
||||
const confirmDelete = useRef<RefConfirmModal>(null);
|
||||
|
||||
function onDelete(item: WorkflowCredential) {
|
||||
confirmDelete.current?.setValue(item);
|
||||
confirmDelete.current?.show(
|
||||
i18nT("workflows.credentials.confirmDelete.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.credentials.confirmDelete.description")
|
||||
);
|
||||
}
|
||||
function onDeleteConfirm(item: WorkflowDto) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "delete");
|
||||
form.set("id", item.id.toString() ?? "");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<EditPageLayout
|
||||
title={i18nT("workflows.credentials.title")}
|
||||
buttons={
|
||||
<>
|
||||
<ButtonPrimary to="new">{i18nT("workflows.actions.new")}</ButtonPrimary>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<TableSimple
|
||||
headers={[
|
||||
{
|
||||
title: i18nT("workflows.columns.name"),
|
||||
name: "name",
|
||||
value: (item) => <div className="select-all">{`{{$credentials.${item.name}}}`}</div>,
|
||||
},
|
||||
{
|
||||
title: i18nT("workflows.columns.value"),
|
||||
name: "value",
|
||||
className: "w-full",
|
||||
value: (item) => <div className="max-w-sm truncate">{"".padEnd(36, "*")}</div>,
|
||||
},
|
||||
{
|
||||
title: i18nT("shared.createdAt"),
|
||||
name: "createdAt",
|
||||
value: (item) => <DateCell date={item.createdAt} />,
|
||||
},
|
||||
]}
|
||||
items={data.items}
|
||||
actions={[
|
||||
{
|
||||
title: i18nT("workflows.actions.delete"),
|
||||
onClick: (_, i) => onDelete(i),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Outlet />
|
||||
|
||||
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
|
||||
</EditPageLayout>
|
||||
);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { createWorkflowCredential, getWorkflowCredentialByName } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import CryptoApi from "~/utils/api/.server/CryptoApi";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export namespace WorkflowsCredentialsNewApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.newCredential", { value0: process.env.APP_NAME }) }],
|
||||
};
|
||||
return data;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const userInfo = await getUserInfo(request);
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const name = form.get("credential-name")?.toString() ?? "";
|
||||
let value = form.get("credential-value")?.toString() ?? "";
|
||||
|
||||
if (action === "create") {
|
||||
try {
|
||||
const existing = await getWorkflowCredentialByName(name, { tenantId });
|
||||
if (existing) {
|
||||
throw Error(
|
||||
t("workflows.errors.credentialAlreadyExists", {
|
||||
value0: name,
|
||||
})
|
||||
);
|
||||
}
|
||||
value = CryptoApi.encrypt(value);
|
||||
await createWorkflowCredential({
|
||||
tenantId,
|
||||
createdByUserId: userInfo.userId,
|
||||
name,
|
||||
value,
|
||||
});
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: t("workflows.errors.invalidAction") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import WorkflowCredentialForm from "~/modules/workflowEngine/components/workflowVariables/WorkflowCredentialForm";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
|
||||
export default function WorkflowsCredentialsNewView() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
function close() {
|
||||
navigate(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
|
||||
}
|
||||
return (
|
||||
<SlideOverWideEmpty title={i18nT("workflows.credentials.newTitle")} className="sm:max-w-sm" open={true} onClose={close}>
|
||||
<WorkflowCredentialForm />
|
||||
</SlideOverWideEmpty>
|
||||
);
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
workflowCount: vi.fn(),
|
||||
credentialCount: vi.fn(),
|
||||
variableCount: vi.fn(),
|
||||
executionCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflow: { count: mocks.workflowCount },
|
||||
workflowCredential: { count: mocks.credentialCount },
|
||||
workflowVariable: { count: mocks.variableCount },
|
||||
workflowExecution: { count: mocks.executionCount },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsDangerApi } from "./danger.api.server";
|
||||
|
||||
describe("WorkflowsDangerApi.loader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue(undefined);
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
});
|
||||
|
||||
it("loads destructive-scope summary counts concurrently", async () => {
|
||||
const workflows = deferred<number>();
|
||||
const credentials = deferred<number>();
|
||||
const variables = deferred<number>();
|
||||
const executions = deferred<number>();
|
||||
|
||||
mocks.workflowCount.mockReturnValue(workflows.promise);
|
||||
mocks.credentialCount.mockReturnValue(credentials.promise);
|
||||
mocks.variableCount.mockReturnValue(variables.promise);
|
||||
mocks.executionCount.mockReturnValue(executions.promise);
|
||||
|
||||
const loaderPromise = WorkflowsDangerApi.loader({
|
||||
request: new Request("https://app.example.test/acme/workflow-engine/danger"),
|
||||
params: { tenant: "acme" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.workflowCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
expect(mocks.credentialCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
expect(mocks.variableCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
expect(mocks.executionCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
|
||||
workflows.resolve(2);
|
||||
credentials.resolve(3);
|
||||
variables.resolve(5);
|
||||
executions.resolve(8);
|
||||
|
||||
await expect(loaderPromise).resolves.toMatchObject({
|
||||
summary: {
|
||||
workflows: 2,
|
||||
credentials: 3,
|
||||
variables: 5,
|
||||
executions: 8,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export namespace WorkflowsDangerApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
summary: {
|
||||
workflows: number;
|
||||
variables: number;
|
||||
credentials: number;
|
||||
executions: number;
|
||||
};
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const [workflows, credentials, variables, executions] = await Promise.all([
|
||||
db.workflow.count({ where: { tenantId } }),
|
||||
db.workflowCredential.count({ where: { tenantId } }),
|
||||
db.workflowVariable.count({ where: { tenantId } }),
|
||||
db.workflowExecution.count({ where: { tenantId } }),
|
||||
]);
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.danger", { value0: process.env.APP_NAME }) }],
|
||||
summary: {
|
||||
workflows,
|
||||
credentials,
|
||||
variables,
|
||||
executions,
|
||||
},
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "reset-all-data") {
|
||||
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
|
||||
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
|
||||
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.workflowDataReset", {
|
||||
value0: workflows.count,
|
||||
value1: variables.count,
|
||||
value2: credentials.count,
|
||||
}),
|
||||
});
|
||||
} else if (action === "delete-all-executions") {
|
||||
const executions = await db.workflowExecution.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.executionsDeleted", { count: executions.count }),
|
||||
});
|
||||
} else if (action === "delete-all-credentials") {
|
||||
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.credentialsDeleted", { count: credentials.count }),
|
||||
});
|
||||
} else if (action === "delete-all-variables") {
|
||||
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.variablesDeleted", { count: variables.count }),
|
||||
});
|
||||
} else if (action === "delete-all-workflows") {
|
||||
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.workflowsDeleted", { count: workflows.count }),
|
||||
});
|
||||
} else {
|
||||
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { useActionData, useLoaderData, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { WorkflowsDangerApi } from "./danger.api.server";
|
||||
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
||||
import DropdownOptions from "~/components/ui/dropdowns/DropdownOptions";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export default function WorkflowsDangerView() {
|
||||
const data = useLoaderData<WorkflowsDangerApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsDangerApi.ActionData>();
|
||||
const submit = useSubmit();
|
||||
|
||||
const confirmReset = useRef<RefConfirmModal>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onReset(type: string) {
|
||||
confirmReset.current?.setValue(type);
|
||||
if (type === "reset-all-data") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmResetAll.title"),
|
||||
i18nT("workflows.danger.actions.reset"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmResetAll.description")
|
||||
);
|
||||
} else if (type === "delete-all-executions") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteExecutions.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteExecutions.description")
|
||||
);
|
||||
} else if (type === "delete-all-variables") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteVariables.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteVariables.description")
|
||||
);
|
||||
} else if (type === "delete-all-credentials") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteCredentials.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteCredentials.description")
|
||||
);
|
||||
} else if (type === "delete-all-workflows") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteWorkflows.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteWorkflows.description")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onResetConfirm(type: string) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", type);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 overflow-x-auto xl:overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl px-4 py-4 sm:px-6 lg:px-8 lg:py-12">
|
||||
<h1 className="text-foreground text-3xl font-bold tracking-tight">{i18nT("workflows.danger.title")}</h1>
|
||||
|
||||
<div className="divide-y-gray-200 mt-6 space-y-8 divide-y">
|
||||
<div className="grid grid-cols-1 gap-y-6 sm:grid-cols-6 sm:gap-x-6">
|
||||
<div className="sm:col-span-6">
|
||||
<h2 className="text-foreground text-xl font-medium">{i18nT("workflows.danger.confirmResetAll.title")}</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{i18nT("workflows.danger.confirmResetAll.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-8">
|
||||
<DropdownOptions
|
||||
width="w-80"
|
||||
button={
|
||||
<div className="hover:bg-secondary border-border bg-background rounded-md border px-2 py-2 text-sm font-medium text-red-600">
|
||||
{i18nT("workflows.danger.actions.resetData")}
|
||||
</div>
|
||||
}
|
||||
options={
|
||||
<div>
|
||||
{[
|
||||
{
|
||||
action: "delete-all-credentials",
|
||||
title: i18nT("workflows.danger.options.deleteAllCredentials", { count: data.summary.credentials }),
|
||||
},
|
||||
{
|
||||
action: "delete-all-variables",
|
||||
title: i18nT("workflows.danger.options.deleteAllVariables", { count: data.summary.variables }),
|
||||
},
|
||||
{
|
||||
action: "delete-all-executions",
|
||||
title: i18nT("workflows.danger.options.deleteAllExecutions", { count: data.summary.executions }),
|
||||
},
|
||||
{
|
||||
action: "delete-all-workflows",
|
||||
title: i18nT("workflows.danger.options.deleteAllWorkflows", { count: data.summary.workflows }),
|
||||
},
|
||||
{
|
||||
action: "reset-all-data",
|
||||
title: i18nT("workflows.danger.confirmResetAll.title"),
|
||||
className: "text-red-600 font-medium",
|
||||
},
|
||||
].map((option) => {
|
||||
return (
|
||||
<Menu.Item key={option.action}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReset(option.action)}
|
||||
className={clsx(
|
||||
option.className,
|
||||
"w-full truncate text-left",
|
||||
active ? "text-foreground bg-secondary/90" : "text-foreground/80",
|
||||
"block px-4 py-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{option.title}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
></DropdownOptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal ref={confirmReset} onYes={onResetConfirm} destructive />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user