Track HiveGrid Cloud planning reference

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

No files matched your search

@@ -0,0 +1,21 @@
import { LoaderFunctionArgs } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { t as i18nT } from "i18next";
import { featureKeys } from "@toolctrl/entitlements";
import { requireTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
export namespace WorkflowEngineApi {
type LoaderData = {
metatags: MetaTagsDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
if (params.tenant) {
await requireTenantProductAccess({ request, params, featureKey: featureKeys.workflowEngine });
}
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.index", { value0: process.env.APP_NAME }) }],
};
return data;
};
}
@@ -0,0 +1,76 @@
import { Outlet, useParams } from "react-router";
import IncreaseIcon from "~/components/ui/icons/crm/IncreaseIcon";
import IncreaseIconFilled from "~/components/ui/icons/crm/IncreaseIconFilled";
import WorkflowCredentialIcon from "~/modules/workflowEngine/components/icons/WorkflowCredentialIcon";
import WorkflowCredentialIconFilled from "~/modules/workflowEngine/components/icons/WorkflowCredentialIconFilled";
import WorkflowExecutionIcon from "~/modules/workflowEngine/components/icons/WorkflowExecutionIcon";
import WorkflowExecutionIconFilled from "~/modules/workflowEngine/components/icons/WorkflowExecutionIconFilled";
import WorkflowIcon from "~/modules/workflowEngine/components/icons/WorkflowIcon";
import WorkflowIconFilled from "~/modules/workflowEngine/components/icons/WorkflowIconFilled";
import WorkflowVariableIcon from "~/modules/workflowEngine/components/icons/WorkflowVariableIcon";
import WorkflowVariableIconFilled from "~/modules/workflowEngine/components/icons/WorkflowVariableIconFilled";
import SidebarIconsLayout from "~/components/ui/layouts/SidebarIconsLayout";
import UrlUtils from "~/utils/app/UrlUtils";
import TemplateIcon from "~/components/ui/icons/entities/TemplateIcon";
import TemplateIconFilled from "~/components/ui/icons/entities/TemplateIconFilled";
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon } from "~/components/ui/icons/vendor";
export default function WorkflowEngineView() {
const { t } = useTranslation();
const params = useParams();
return (
<SidebarIconsLayout
label={{ align: "right" }}
items={[
{
name: t("shared.overview"),
href: UrlUtils.getModulePath(params, `workflow-engine`),
exact: true,
icon: <IncreaseIcon className="h-5 w-5" />,
iconSelected: <IncreaseIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/workflows`),
icon: <WorkflowIcon className="h-5 w-5" />,
iconSelected: <WorkflowIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.variables.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/variables`),
icon: <WorkflowVariableIcon className="h-5 w-5" />,
iconSelected: <WorkflowVariableIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.credentials.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/credentials`),
icon: <WorkflowCredentialIcon className="h-5 w-5" />,
iconSelected: <WorkflowCredentialIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.executions.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/executions`),
icon: <WorkflowExecutionIcon className="h-5 w-5" />,
iconSelected: <WorkflowExecutionIconFilled className="h-5 w-5" />,
},
{
name: t("workflows.templates.title"),
href: UrlUtils.getModulePath(params, `workflow-engine/templates`),
icon: <TemplateIcon className="h-5 w-5" />,
iconSelected: <TemplateIconFilled className="h-5 w-5" />,
bottom: true,
},
{
name: t("workflows.nav.danger"),
href: UrlUtils.getModulePath(params, `workflow-engine/danger`),
bottom: true,
icon: <AlertTriangleIcon className="h-5 w-5" />,
iconSelected: <AlertTriangleIcon className="h-5 w-5" />,
},
]}
>
<Outlet />
</SidebarIconsLayout>
);
}
@@ -0,0 +1,34 @@
import { LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import UrlUtils from "~/utils/app/UrlUtils";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { t as i18nT } from "i18next";
export namespace WorkflowsIdExecutionsApi {
export type LoaderData = {
metatags: MetaTagsDto;
item: WorkflowDto;
executions: WorkflowExecutionDto[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const item = await WorkflowsService.get(params.id!, { tenantId });
if (!item) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const executions = await WorkflowsService.getExecutions(item, { tenantId });
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.executions", { value0: item.name, value1: webConfig.appName }) }],
item,
executions,
};
return data;
};
}
@@ -0,0 +1,83 @@
import { Link, useLoaderData, useParams, useSearchParams } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useState } from "react";
import "@xyflow/react/dist/style.css";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
import WorkflowExecutionsSidebar from "~/modules/workflowEngine/components/workflows/WorkflowExecutionsSidebar";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdExecutionsApi } from "./workflows.$id.executions.api.server";
export default function WorkflowsIdExecutionsView() {
const data = useLoaderData<WorkflowsIdExecutionsApi.LoaderData>();
const params = useParams();
const [searchParams] = useSearchParams();
const [workflow] = useState<WorkflowDto>(data.item);
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecutionDto | null>(null);
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
useEffect(() => {
const executionId = searchParams.get("executionId");
if (executionId) {
const execution = data.executions.find((x) => x.id === executionId);
if (execution) {
setSelectedExecution(execution);
}
} else {
setSelectedExecution(null);
}
}, [data.executions, searchParams]);
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
</div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}`)}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToEditor")}
</Link>
<WorkflowRunDropdown workflow={workflow} />
</div>
</div>
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
height. On mobile the outer flex container only has min-height, so a
flex-1 child stays indefinite and the canvas renders hidden/collapsed.
Use a fixed vh height on mobile (always definite); flex sizing only on
lg. Mirrors the builder index view. */}
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
<LazyWorkflowBuilder
key={selectedExecution?.id}
readOnly={true}
workflow={workflow}
selectedBlock={selectedBlock}
workflowExecution={selectedExecution}
onSelectedBlock={setSelectedBlock}
/>
</div>
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
<WorkflowExecutionsSidebar
key={selectedExecution?.id}
workflow={workflow}
selectedBlock={selectedBlock}
selectedExecution={selectedExecution}
executions={data.executions}
onSelectedBlock={setSelectedBlock}
onSelectedExecution={setSelectedExecution}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTranslations: vi.fn(),
getTenantIdOrNull: vi.fn(),
requireWorkflowMutationAccess: vi.fn(),
validateCSRFToken: vi.fn(),
getWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: mocks.getTranslations,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("../workflowAccess.server", () => ({
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
}));
vi.mock("~/utils/session.server", () => ({
validateCSRFToken: mocks.validateCSRFToken,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
default: {
get: mocks.getWorkflow,
update: mocks.updateWorkflow,
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Signup flow",
description: "",
status: "draft",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowsIdIndexApi.action", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue({ userId: "user_1" });
mocks.getTranslations.mockResolvedValue({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
"workflows.errors.workflowBuilderActionFailed": "Workflow builder action failed. Check your changes and try again.",
"workflows.errors.workflowBuilderInvalidPayload": "Workflow builder payload is not valid JSON.",
};
return translations[key] ?? String(options?.defaultValue ?? key);
},
});
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
mocks.validateCSRFToken.mockResolvedValue(undefined);
mocks.getWorkflow.mockResolvedValue(workflow());
mocks.updateWorkflow.mockResolvedValue(workflow());
});
it("neutralizes external save redirect targets and keeps the workflow tenant-scoped", async () => {
const savedWorkflow = workflow({ name: "Updated flow" });
const form = new URLSearchParams();
form.set("action", "save");
form.set("workflow", JSON.stringify(savedWorkflow));
form.set("redirectTo", "https://evil.example/phish");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1?tab=builder", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdIndexApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
"workflow_1",
expect.objectContaining({ id: savedWorkflow.id, name: savedWorkflow.name }),
expect.objectContaining({ tenantId: "tenant_1", userId: "user_1" })
);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/acme/workflow-engine/workflows/workflow_1?tab=builder");
expect(response.headers.get("Location")).not.toContain("evil.example");
});
it("returns a generic translated error for unknown save failures", async () => {
mocks.updateWorkflow.mockRejectedValueOnce(new Error("database connection leaked-secret"));
const form = new URLSearchParams();
form.set("action", "save");
form.set("workflow", JSON.stringify(workflow({ name: "Updated flow" })));
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdIndexApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workflow builder action failed. Check your changes and try again.",
});
});
it("returns a translated invalid payload error instead of the JSON parser message", async () => {
const form = new URLSearchParams();
form.set("action", "save");
form.set("workflow", "{");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdIndexApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Workflow builder payload is not valid JSON.",
});
expect(mocks.updateWorkflow).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,234 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
import UrlUtils from "~/utils/app/UrlUtils";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { requireAuth } from "~/utils/loaders.middleware";
import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server";
import { validateCSRFToken } from "~/utils/session.server";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT, type TFunction } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowBuilderActionError } from "../workflowRunActionErrors.server";
function actionText(t: TFunction, key: string, fallback: string, values?: Record<string, unknown>) {
const translated = t(key, { defaultValue: fallback, ...(values ?? {}) });
return translated === key ? fallback : translated;
}
export namespace WorkflowsIdIndexApi {
export type LoaderData = {
metatags: MetaTagsDto;
item: WorkflowDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const item = await WorkflowsService.get(params.id!, { tenantId });
if (!item) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.build", { value0: item.name, value1: webConfig.appName }) }],
item,
};
return data;
};
export type ActionData = {
success?: string;
error?: string;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const auth = await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
const item = await WorkflowsService.get(params.id!, { tenantId });
if (!item) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
if (action === "save") {
try {
const workflow = JSON.parse(form.get("workflow")?.toString() ?? "{}") as WorkflowDto;
const redirectTo = form.get("redirectTo")?.toString();
await WorkflowsService.update(params.id!, workflow, { tenantId, userId: auth.userId ?? null });
if (redirectTo) {
return redirect(safeRedirectPathFromRequest(request, redirectTo));
}
return Response.json({ success: actionText(t, "workflows.responses.workflowSaved", "Workflow saved: {{value0}}", { value0: workflow.name }) });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "update-block") {
const id = form.get("id")?.toString();
const block = item.blocks.find((x) => x.id === id);
if (!block) {
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
}
try {
const blockData = JSON.parse(form.get("block")?.toString() ?? "{}") as WorkflowBlockDto;
const redirectTo = form.get("redirectTo")?.toString();
await WorkflowsService.updateBlock(block.id, blockData, { workflow: item, userId: auth.userId ?? null });
if (redirectTo) {
return redirect(safeRedirectPathFromRequest(request, redirectTo));
}
return Response.json({ success: actionText(t, "workflows.responses.blockUpdated", "Block updated: {{value0}}", { value0: blockData.type }) });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "add-block") {
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
const condition = form.get("condition")?.toString() ?? "";
const type = form.get("type")?.toString() as WorkflowBlockType;
if (fromBlockId) {
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
if (!fromBlock) {
return Response.json({ error: actionText(t, "workflows.errors.fromBlockNotFound", "From block not found") }, { status: 404 });
}
}
try {
const newBlock = await WorkflowsService.addBlock({
workflow: item,
fromBlockId,
type,
condition,
userId: auth.userId ?? null,
});
return Response.json({
success: actionText(t, "workflows.responses.blockAdded", "Block added"),
newBlockId: newBlock.id,
});
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "delete-block") {
const id = form.get("id")?.toString() ?? "";
const block = item.blocks.find((x) => x.id === id);
if (!block) {
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
}
try {
await WorkflowsService.deleteBlock(block.id, { workflow: item, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.blockDeleted", "Block deleted") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "delete-connection") {
const id = form.get("id")?.toString() ?? "";
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
const toBlockId = form.get("toBlockId")?.toString() ?? "";
try {
if (fromBlockId && toBlockId) {
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
const toBlock = item.blocks.find((x) => x.id === toBlockId);
if (!fromBlock || !toBlock) {
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
}
await WorkflowsService.deleteConnection({ fromBlockId, toBlockId }, { workflow: item, userId: auth.userId ?? null });
} else {
const connection = item.blocks.flatMap((block) => block.toBlocks).find((candidate) => candidate.id === id);
if (!connection) {
return Response.json({ error: actionText(t, "workflows.errors.connectionNotFound", "Connection not found") }, { status: 404 });
}
await WorkflowsService.deleteConnection({ id: connection.id }, { workflow: item, userId: auth.userId ?? null });
}
return Response.json({ success: actionText(t, "workflows.responses.connectionDeleted", "Connection deleted") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "connect-blocks") {
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
const toBlockId = form.get("toBlockId")?.toString() ?? "";
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
const toBlock = item.blocks.find((x) => x.id === toBlockId);
const condition = form.get("condition")?.toString() ?? "";
if (!fromBlock || !toBlock) {
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
}
try {
await WorkflowsService.connectBlocks({ fromBlockId, toBlockId, condition }, { workflow: item, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.blocksConnected", "Blocks connected") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "update-conditions-groups") {
const blockId = form.get("blockId")?.toString() ?? "";
const block = item.blocks.find((x) => x.id === blockId);
if (!block) {
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
}
try {
const conditionsGroups = JSON.parse(form.get("conditionsGroups")?.toString() ?? "[]") as WorkflowConditionsGroupDto[];
await WorkflowsService.updateConditionsGroups(block, conditionsGroups, { workflow: item, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.conditionsGroupsUpdated", "Condition groups updated") });
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "create-input-example") {
const title = form.get("title")?.toString() ?? "";
const input = form.get("input")?.toString() ?? "";
await WorkflowsService.createInputExample({
workflowId: item.id,
tenantId: item.tenantId,
title,
input,
});
return Response.json({ success: actionText(t, "workflows.responses.inputExampleCreated", "Input example created") });
} else if (action === "update-input-example") {
const id = form.get("id")?.toString() ?? "";
const title = form.get("title")?.toString() ?? "";
const input = form.get("input")?.toString() ?? "";
const inputExample = item.inputExamples.find((x) => x.id === id);
if (!inputExample) {
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
}
await WorkflowsService.updateInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId }, {
title,
input,
});
return Response.json({ success: actionText(t, "workflows.responses.inputExampleUpdated", "Input example updated") });
} else if (action === "delete-input-example") {
const id = form.get("id")?.toString() ?? "";
const inputExample = item.inputExamples.find((x) => x.id === id);
if (!inputExample) {
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
}
await WorkflowsService.deleteInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId });
return Response.json({ success: actionText(t, "workflows.responses.inputExampleDeleted", "Input example deleted") });
} else if (action === "delete") {
try {
await WorkflowsService.del(item.id, { tenantId });
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
} catch (e: any) {
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
}
} else if (action === "toggle") {
const enabled = form.get("enabled")?.toString() === "true";
if (enabled && item.status === "draft") {
await WorkflowsService.update(item.id, { status: "live" }, { tenantId, userId: auth.userId ?? null });
return Response.json({ success: actionText(t, "workflows.responses.workflowLive", "Workflow is now live") });
} else if (!enabled && item.status === "live") {
await WorkflowsService.update(
item.id,
{
status: "draft",
},
{ tenantId, userId: auth.userId ?? null }
);
return Response.json({ success: actionText(t, "workflows.responses.workflowDraft", "Workflow is now a draft") });
}
}
return Response.json({ error: actionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,233 @@
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useState } from "react";
import "@xyflow/react/dist/style.css";
import { toast } from "sonner";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
import WorkflowEditorSidebar from "~/modules/workflowEngine/components/workflows/WorkflowEditorSidebar";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import InputCheckbox from "~/components/ui/input/InputCheckbox";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
import UrlUtils from "~/utils/app/UrlUtils";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
import { appendCSRFToken } from "~/utils/csrf";
export default function WorkflowsIdIndexView() {
const data = useLoaderData<WorkflowsIdIndexApi.LoaderData>();
const actionData = useActionData<WorkflowsIdIndexApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const [workflow, setWorkflow] = useState<WorkflowDto>(data.item);
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
const [addingNextBlockFrom, setAddingNextBlockFrom] = useState<{ fromBlock: WorkflowBlockDto; condition: string | null } | null>(null);
useEffect(() => {
if (actionData?.success) {
toast.success(actionData.success);
} else if (actionData?.error) {
toast.error(actionData.error);
}
}, [actionData]);
useEffect(() => {
setWorkflow(data.item);
if (selectedBlock) {
setSelectedBlock(data.item.blocks.find((x) => x.id === selectedBlock.id) ?? null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.item]);
useEffect(() => {
if (selectedBlock === null && addingNextBlockFrom !== null) {
setAddingNextBlockFrom(null);
}
}, [addingNextBlockFrom, selectedBlock]);
function onSave(workflow: WorkflowDto) {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "save");
form.append("workflow", JSON.stringify(workflow));
submit(form, {
method: "post",
});
}
function onSaveBlock(block: WorkflowBlockDto) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "update-block");
form.set("id", block.id);
form.append("block", JSON.stringify(block));
submit(form, {
method: "post",
});
}
function onAddBlock({ type, from, condition }: { type: WorkflowBlockType; from: WorkflowBlockDto | undefined; condition: string | null }) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "add-block");
form.set("type", type);
if (condition) {
form.set("condition", condition);
}
if (from) {
form.set("fromBlockId", from.id);
}
submit(form, {
method: "post",
});
}
function onConnectBlocks(params: { fromBlockId: string; toBlockId: string; condition: string | null }) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "connect-blocks");
form.set("fromBlockId", params.fromBlockId);
form.set("toBlockId", params.toBlockId);
if (params.condition) {
form.set("condition", params.condition);
}
submit(form, {
method: "post",
});
}
function onDeleteBlock(id: string) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete-block");
form.set("id", id);
submit(form, {
method: "post",
});
}
function onDeleteConnection(params: { fromBlockId: string; toBlockId: string } | { id: string }) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete-connection");
if ("id" in params) {
form.set("id", params.id);
} else {
form.set("fromBlockId", params.fromBlockId);
form.set("toBlockId", params.toBlockId);
}
submit(form, {
method: "post",
});
}
function onUpdateConditionsGroups(blockId: string, conditionsGroups: WorkflowConditionsGroupDto[]) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "update-conditions-groups");
form.set("blockId", blockId);
form.set("conditionsGroups", JSON.stringify(conditionsGroups));
submit(form, {
method: "post",
});
}
function onToggle(enabled: boolean) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "toggle");
form.set("enabled", enabled ? "true" : "false");
submit(form, {
method: "post",
});
}
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
</div>
{/* relative z-50 gives the toolbar its own stacking context above the
ReactFlow canvas below. ReactFlow's root sets no position/z-index, so
its internal positioned layers (pane/renderer/controls, z-index ≤ 5)
otherwise paint over this static toolbar where they overlap at mobile
width — stealing pointer/keyboard from the run dropdown. */}
<div className="border-border bg-background relative z-50 w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between space-x-2">
<div className="flex items-center space-x-2">
<div data-ui-id="ui.workflows.builder.statusToggle">
<InputCheckbox
disabled={!WorkflowUtils.isReady(workflow) && workflow.status !== "live"}
asToggle
withLabel={false}
title={i18nT("workflows.list.toggleStatus", { name: workflow.name })}
value={workflow.status === "live"}
setValue={(checked) => onToggle(Boolean(checked))}
/>
</div>
<SimpleBadge title={i18nT(`workflows.status.${workflow.status}`)} color={WorkflowUtils.getStatusColor(workflow)} />
</div>
<div className="flex items-center space-x-2">
<Link
to={`executions`}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
>
{i18nT("workflows.navigation.viewAllExecutions")}
</Link>
<WorkflowRunDropdown workflow={workflow} />
</div>
</div>
</div>
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
height. On mobile the outer flex container only has min-height, so a
flex-1 child stays indefinite and the canvas collapses to 0. Use a
fixed vh height on mobile (always definite); flex sizing only on lg. */}
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
<LazyWorkflowBuilder
workflow={workflow}
selectedBlock={selectedBlock}
workflowExecution={null}
onSelectedBlock={setSelectedBlock}
onConnectBlocks={onConnectBlocks}
onDeleteConnection={(id) => onDeleteConnection({ id })}
onDeleteBlock={onDeleteBlock}
/>
</div>
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
<WorkflowEditorSidebar
workflow={workflow}
onSave={onSave}
selectedBlock={selectedBlock}
addingNextBlockFrom={addingNextBlockFrom}
onSelectedBlock={(block) => {
setSelectedBlock(block);
}}
onSaveBlock={onSaveBlock}
onSetTrigger={(type) => {
onAddBlock({ type: type, from: undefined, condition: null });
setAddingNextBlockFrom(null);
}}
onAddBlock={({ from, type, condition }) => {
onAddBlock({ type: type, from: from, condition });
setAddingNextBlockFrom(null);
}}
onAddingNextBlock={(data) => {
setAddingNextBlockFrom(data);
}}
onDeleteBlock={onDeleteBlock}
onDeleteConnection={onDeleteConnection}
onUpdateConditionsGroups={onUpdateConditionsGroups}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,116 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import { apiPath } from "~/utils/app/CanonicalUrlUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { getFirstApiKey } from "~/utils/db/apiKeys.db.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { validateCSRFToken } from "~/utils/session.server";
import { getAbsoluteApiUrl } from "~/utils/url.server";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
export namespace WorkflowsIdRunApiApi {
export type LoaderData = {
metatags: MetaTagsDto;
workflow: WorkflowDto;
apiRunUrl: string;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const workflow = await WorkflowsService.get(params.id!, {
tenantId,
});
if (!workflow) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.runApi", { value0: workflow.name, value1: webConfig.appName }) }],
workflow,
apiRunUrl: getAbsoluteApiUrl(request, apiPath(`workflows/run/${workflow.id}`, { mode: "host-aware" })),
};
return data;
};
export type ActionData = {
success?: string;
error?: string;
errorCode?: "noApiKey" | "noValidApiKey";
execution?: WorkflowExecutionDto;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
let apiKey: string | null = null;
if (tenantId === null) {
apiKey = loadWebConfig().config.apiAccessToken || null;
if (!apiKey) {
return Response.json({ error: workflowActionText(t, "workflows.errors.noApiKey", "No API key found"), errorCode: "noApiKey" }, { status: 400 });
}
} else {
const validApiKey = await getFirstApiKey(tenantId);
if (!validApiKey) {
return Response.json({ error: workflowActionText(t, "workflows.errors.noValidApiKey", "No valid API key found"), errorCode: "noValidApiKey" }, { status: 400 });
}
apiKey = validApiKey.key;
}
if (action === "execute") {
try {
const input = form.get("input")?.toString() ?? "{}";
const url = getAbsoluteApiUrl(request, apiPath(`workflows/run/${params.id}`, { mode: "host-aware" }));
const response = await fetch(url, {
method: "POST",
body: input,
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
},
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return Response.json({ execution: data.execution });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
}
} else if (action === "continue-execution") {
try {
const executionId = form.get("executionId")?.toString() ?? "";
const input = form.get("input")?.toString() ?? "{}";
const url = getAbsoluteApiUrl(request, apiPath(`workflows/executions/${executionId}/continue`, { mode: "host-aware" }));
const response = await fetch(url, {
method: "POST",
body: input,
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
},
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return Response.json({ execution: data.execution });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
}
}
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,242 @@
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useId, useRef, useState } from "react";
import toast from "react-hot-toast";
import MonacoEditor from "~/components/editors/MonacoEditor";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText from "~/components/ui/input/InputText";
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdRunApiApi } from "./workflows.$id.run.api.server";
import clsx from "clsx";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import { appendCSRFToken } from "~/utils/csrf";
import { SendIcon } from "~/components/ui/icons/vendor";
export default function WorkflowsIdRunApiApiView() {
const data = useLoaderData<WorkflowsIdRunApiApi.LoaderData>();
const actionData = useActionData<WorkflowsIdRunApiApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const navigation = useNavigation();
const isLoading = navigation.state === "submitting";
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
const [inputData, setInputData] = useState("{}");
const [waitingBlockInput, setWaitingBlockInput] = useState("");
const waitingBlockInputId = useId();
const waitingForInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
waitingForInputRef.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, [execution]);
useEffect(() => {
if (data.workflow.inputExamples.length > 0) {
setSelectedTemplate(data.workflow.inputExamples[0].title);
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
}
}, [data]);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
if (actionData?.execution) {
setExecution(actionData.execution);
}
}, [actionData]);
function onExecute() {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "execute");
form.append("input", inputData);
submit(form, {
method: "post",
});
}
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
appendCSRFToken(form);
form.set("action", "continue-execution");
form.set("executionId", execution?.id ?? "");
form.set("input", JSON.stringify({ input: waitingBlockInput }));
submit(form, {
method: "post",
});
setWaitingBlockInput("");
}
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple
menu={[
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
{ title: data.workflow.name },
]}
/>
</div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToExecutions")}
</Link>
<WorkflowRunDropdown workflow={data.workflow} />
</div>
</div>
<div data-ui-id="ui.workflows.run.api.form" className="mx-auto max-w-2xl space-y-2 p-4">
<div className="flex justify-between space-x-2">
<div data-ui-id="ui.workflows.run.api.title" className="text-lg font-semibold">
{i18nT("workflows.run.apiTitle")}
</div>
</div>
{!execution ? (
<div>
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.body")}</div>
{data.workflow.inputExamples.length > 0 && (
<div data-ui-id="ui.workflows.run.api.inputExampleSelect">
<WorkflowInputExamplesDropdown
workflow={data.workflow}
onSelected={(item) => {
setSelectedTemplate(item.title);
setInputData(JSON.stringify(item.input, null, 2));
}}
/>
</div>
)}
</div>
<div data-ui-id="ui.workflows.run.api.inputEditor" className="border-border overflow-hidden rounded-md border">
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
</div>
<InputText dataUiId="ui.workflows.run.api.field.url" title={i18nT("workflows.run.url")} defaultValue={data.apiRunUrl} readOnly />
<InputText dataUiId="ui.workflows.run.api.field.method" title={i18nT("workflows.run.method")} defaultValue="POST" readOnly />
<InputText
dataUiId="ui.workflows.run.api.field.apiKey"
title={i18nT("workflows.run.apiKeyHeader")}
defaultValue={i18nT("workflows.run.apiKeyPlaceholder")}
readOnly
/>
</div>
<div className="flex justify-end pt-2">
<LoadingButton dataUiId="ui.workflows.run.api.submit" actionName="execute" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)}>
{i18nT("workflows.actions.run")}
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
</LoadingButton>
</div>
</div>
) : (
<div className="space-y-4" data-ui-id="ui.workflows.run.api.executionResult">
<div className="grid grid-cols-2 gap-4">
<Link
target="_blank"
data-ui-id="ui.workflows.run.api.viewExecutionFlow"
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
</div>
</>
</Link>
<button
type="button"
data-ui-id="ui.workflows.run.api.runAgain"
onClick={() => {
setExecution(null);
}}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
</div>
</button>
</div>
<div className="border-border overflow-hidden rounded-md border">
<MonacoEditor className="h-40" theme="vs-dark" value={JSON.stringify({ execution }, null, 2)} hideLineNumbers tabSize={2} language="json" />
</div>
{execution.waitingBlock && (
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.api.waitingInput.form">
<div className="space-y-1">
{execution.waitingBlock.input.title && (
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
{execution.waitingBlock.input.title}
</label>
)}
<div className="relative flex items-center">
<input
ref={waitingForInputRef}
autoFocus
type="text"
name="input"
id={waitingBlockInputId}
data-ui-id="ui.workflows.run.api.waitingInput.input"
autoComplete="off"
value={waitingBlockInput}
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
className={clsx(
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
)}
placeholder={execution.waitingBlock.input.placeholder}
required
disabled={isLoading}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
<button
type="submit"
disabled={isLoading}
data-ui-id="ui.workflows.run.api.waitingInput.submit"
aria-label={i18nT("shared.submit")}
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
>
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
</button>
</div>
</div>
</div>
</Form>
)}
</div>
)}
{actionData?.error && (
<ErrorBanner title={i18nT("workflows.executionStatus.error")}>
<div>{actionData.error}</div>
{actionData.errorCode === "noValidApiKey" && params.tenant && (
<div>
<Link className="underline" target="_blank" to={appPath({ tenantSlug: params.tenant, path: "settings/api/keys", mode: "host-aware" })}>
{i18nT("workflows.run.createApiKeyLink")}
</Link>
</div>
)}
</ErrorBanner>
)}
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTranslations: vi.fn(),
getTenantIdOrNull: vi.fn(),
getUserInfo: vi.fn(),
requireWorkflowMutationAccess: vi.fn(),
validateCSRFToken: vi.fn(),
getWorkflow: vi.fn(),
queueManualExecution: vi.fn(),
queueContinuationExecution: vi.fn(),
continueExecution: vi.fn(),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: mocks.getTranslations,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("~/utils/session.server", () => ({
getUserInfo: mocks.getUserInfo,
validateCSRFToken: mocks.validateCSRFToken,
}));
vi.mock("../workflowAccess.server", () => ({
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
default: {
get: mocks.getWorkflow,
},
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
default: {
queueManualExecution: mocks.queueManualExecution,
queueContinuationExecution: mocks.queueContinuationExecution,
continueExecution: mocks.continueExecution,
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Signup flow",
description: "",
status: "live",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowsIdRunManualApi.action", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
mocks.validateCSRFToken.mockResolvedValue(undefined);
mocks.getWorkflow.mockResolvedValue(workflow());
});
it("rejects invalid execute input JSON before queueing and keeps the workflow lookup tenant-scoped", async () => {
const form = new URLSearchParams();
form.set("action", "execute");
form.set("input", "{");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunManualApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
await expect(response.json()).resolves.toEqual({ error: "Input data is not valid JSON" });
expect(response.status).toBe(400);
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
expect(mocks.queueManualExecution).not.toHaveBeenCalled();
});
it("queues manual executions instead of executing inline", async () => {
mocks.queueManualExecution.mockResolvedValue({ id: "execution_1", status: "running" });
const form = new URLSearchParams();
form.set("action", "execute");
form.set("input", JSON.stringify({ name: "Ada" }));
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunManualApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
success: "Workflow queued",
execution: { id: "execution_1", status: "running" },
});
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
expect(mocks.queueManualExecution).toHaveBeenCalledWith("workflow_1", {
input: { name: "Ada" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.queueContinuationExecution).not.toHaveBeenCalled();
expect(mocks.continueExecution).not.toHaveBeenCalled();
});
it("queues manual continuations instead of executing inline", async () => {
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waiting" });
const form = new URLSearchParams();
form.set("action", "continue-execution");
form.set("executionId", "execution_1");
form.set("input", "approved");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunManualApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({
success: "Workflow queued",
execution: { id: "execution_1", status: "waiting" },
});
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
type: "manual",
input: { input: "approved" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.continueExecution).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import UrlUtils from "~/utils/app/UrlUtils";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
export namespace WorkflowsIdRunManualApi {
export type LoaderData = {
metatags: MetaTagsDto;
workflow: WorkflowDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const workflow = await WorkflowsService.get(params.id!, {
tenantId,
});
if (!workflow) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.runManual", { value0: workflow.name, value1: webConfig.appName }) }],
workflow,
};
return data;
};
export type ActionData = {
success?: string;
error?: string;
execution?: WorkflowExecutionDto;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const { userId } = await getUserInfo(request);
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const workflow = await WorkflowsService.get(params.id!, { tenantId });
if (!workflow || workflow.status !== "live") {
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
}
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "execute") {
try {
let input = form.get("input")?.toString() ?? "{}";
let inputData: { [key: string]: any } | null = null;
if (!input.trim()) {
input = "{}";
}
if (input) {
try {
inputData = JSON.parse(input);
} catch {
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
}
}
const execution = await WorkflowsExecutionsService.queueManualExecution(params.id!, {
input: inputData,
session: {
tenantId,
userId,
},
});
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
}
} else if (action === "continue-execution") {
try {
const executionId = form.get("executionId")?.toString() ?? "";
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
type: "manual",
input: { input: form.get("input")?.toString() },
session: { tenantId, userId },
});
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
}
}
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,399 @@
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import clsx from "clsx";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Colors } from "~/application/enums/shared/Colors";
import MonacoEditor from "~/components/editors/MonacoEditor";
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import { WorkflowBlockExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowBlockExecutionDto";
import { getWorkflowBlockName, WorkflowBlockTypes } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
import { WorkflowDto } from "../../../dtos/WorkflowDto";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { appendCSRFToken } from "~/utils/csrf";
import { formatWorkflowExecutionErrorText } from "~/modules/workflowEngine/utils/workflowExecutionErrorText";
import { SendIcon } from "~/components/ui/icons/vendor";
export default function WorkflowsIdRunManualView() {
const data = useLoaderData<WorkflowsIdRunManualApi.LoaderData>();
const actionData = useActionData<WorkflowsIdRunManualApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const navigation = useNavigation();
const isLoading = navigation.state === "submitting";
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
const [inputData, setInputData] = useState("{}");
const [waitingBlockInput, setWaitingBlockInput] = useState("");
const waitingBlockInputId = useId();
const waitingForInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const focusTimer = window.setTimeout(() => {
waitingForInputRef.current?.focus();
}, 100);
return () => window.clearTimeout(focusTimer);
}, [execution]);
useEffect(() => {
if (data.workflow.inputExamples.length > 0) {
setSelectedTemplate(data.workflow.inputExamples[0].title);
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.workflow.inputExamples.length]);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
if (actionData?.execution) {
setExecution(actionData.execution);
actionData?.execution?.executionAlerts.forEach((executionAlert) => {
if (executionAlert.type === "error") {
toast.error(executionAlert.message, {
position: "bottom-right",
duration: 10000,
});
} else {
toast.success(executionAlert.message, {
position: "bottom-right",
duration: 10000,
});
}
});
}
}, [actionData]);
function onExecute() {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "execute");
form.append("input", inputData);
submit(form, {
method: "post",
});
}
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
appendCSRFToken(form);
form.set("action", "continue-execution");
form.set("executionId", execution?.id ?? "");
submit(form, {
method: "post",
});
setWaitingBlockInput("");
}
return (
<div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<BreadcrumbSimple
menu={[
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
{ title: data.workflow.name },
]}
/>
</div>
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToExecutions")}
</Link>
<WorkflowRunDropdown workflow={data.workflow} />
</div>
</div>
<div className="mx-auto max-w-2xl space-y-2 p-4">
<div className="flex justify-between space-x-2">
<div className="text-lg font-semibold" data-ui-id="ui.workflows.run.manual.title">
{i18nT("workflows.run.manualTitle")}
</div>
</div>
{!execution ? (
<div data-ui-id="ui.workflows.run.manual.form">
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
{data.workflow.inputExamples.length > 0 && (
<div data-ui-id="ui.workflows.run.manual.inputExampleSelect">
<WorkflowInputExamplesDropdown
workflow={data.workflow}
onSelected={(item) => {
setSelectedTemplate(item.title);
setInputData(JSON.stringify(item.input, null, 2));
}}
/>
</div>
)}
</div>
<div className="border-border overflow-hidden rounded-md border" data-ui-id="ui.workflows.run.manual.inputEditor">
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
</div>
</div>
<div className="flex justify-end pt-2">
<LoadingButton
actionName="execute"
onClick={onExecute}
disabled={!WorkflowUtils.canRun(data.workflow)}
className="ui-workflows-run-manual-submit"
dataUiId="ui.workflows.run.manual.submit"
>
{i18nT("workflows.actions.run")}
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
</LoadingButton>
</div>
</div>
) : (
<div className="space-y-4" data-ui-id="ui.workflows.run.manual.executionResult">
<div className="grid grid-cols-2 gap-4">
<Link
target="_blank"
data-ui-id="ui.workflows.run.manual.viewExecutionFlow"
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
</div>
</>
</Link>
<button
type="button"
data-ui-id="ui.workflows.run.manual.runAgain"
onClick={() => {
setExecution(null);
}}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<div className="flex justify-center">
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
</div>
</button>
</div>
<WorkflowRun workflow={execution} />
<div className="text-foreground font-medium">{i18nT("workflows.executions.blocksExecuted", { count: execution.blockRuns.length })}</div>
<div className="w-full space-y-1">
{execution.blockRuns.map((blockRun) => {
return <BlockRun key={blockRun.id} workflow={data.workflow} blockRun={blockRun} />;
})}
</div>
{execution.waitingBlock && (
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.manual.waitingInput.form">
<div className="space-y-1">
{execution.waitingBlock.input.title && (
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
{execution.waitingBlock.input.title}
</label>
)}
<div className="relative flex items-center">
<input
ref={waitingForInputRef}
autoFocus
type="text"
name="input"
id={waitingBlockInputId}
data-ui-id="ui.workflows.run.manual.waitingInput.input"
autoComplete="off"
value={waitingBlockInput}
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
className={clsx(
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
)}
placeholder={execution.waitingBlock.input.placeholder}
required
disabled={isLoading}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
<button
type="submit"
disabled={isLoading}
data-ui-id="ui.workflows.run.manual.waitingInput.submit"
aria-label={i18nT("shared.submit")}
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
>
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
</button>
</div>
</div>
</div>
</Form>
)}
</div>
)}
{actionData?.error ? (
<div role="alert" data-ui-id="ui.workflows.run.manual.error">
<ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />
</div>
) : null}
</div>
</div>
);
}
function BlockRun({ workflow, blockRun }: { workflow: WorkflowDto; blockRun: WorkflowBlockExecutionDto }) {
const [isExpanded, setIsExpanded] = useState(false);
const outputPreview = useMemo(() => (blockRun.output ? JSON.stringify({ output: blockRun.output }) : null), [blockRun.output]);
const block = workflow.blocks.find((x) => x.id === blockRun.workflowBlockId);
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block?.type);
if (!block || !workflowBlock) {
return null;
}
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
return (
<div className="space-y-0">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
className={clsx(
"hover:bg-secondary border-border bg-background block w-full cursor-pointer overflow-hidden rounded-md border p-2 text-left select-none",
isExpanded ? "rounded-b-none" : "rounded-md"
)}
>
<div className="flex justify-between space-x-2">
<div className="flex items-center space-x-1 truncate">
<div className="flex items-center space-x-2 truncate">
<workflowBlock.icon aria-hidden="true" className="text-muted-foreground h-4 w-4 shrink-0" />
<div className="text-foreground/80 shrink-0 truncate text-xs font-medium">[{workflowBlockName}]</div>
{blockRun.error ? (
<div className="truncate text-xs text-red-600">{formatWorkflowExecutionErrorText(blockRun.error, i18nT)}</div>
) : outputPreview ? (
<div className="text-muted-foreground truncate text-xs">{outputPreview}</div>
) : null}
</div>
<div className="text-muted-foreground text-sm">{block.description || ""}</div>
</div>
<div className="shrink-0">
{block.type === "if" ? (
<div>
{blockRun.output?.condition ? (
<SimpleBadge title={i18nT("workflows.boolean.true")} color={Colors.BLUE} />
) : (
<SimpleBadge title={i18nT("workflows.boolean.false")} color={Colors.ORANGE} />
)}
</div>
) : block.type === "switch" ? (
<SimpleBadge title={blockRun.output?.condition?.toString()} color={Colors.BLUE} />
) : (
<div>
{blockRun.status === "running" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.running")} color={Colors.YELLOW} />
) : blockRun.status === "error" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.error")} color={Colors.RED} />
) : blockRun.status === "success" ? (
<SimpleBadge title={i18nT("workflows.executionStatus.success")} color={Colors.GREEN} />
) : (
<SimpleBadge title={i18nT("workflows.executionStatus.unknown")} color={Colors.GRAY} />
)}
</div>
)}
</div>
</div>
</button>
{isExpanded && (
<div className="border-border bg-secondary/90 overflow-hidden rounded-md rounded-t-none border p-2">
{blockRun.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={formatWorkflowExecutionErrorText(blockRun.error, i18nT)} />}
<InputOutput input={blockRun.input} output={blockRun.output} />
</div>
)}
</div>
);
}
function WorkflowRun({ workflow }: { workflow: WorkflowExecutionDto }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="space-y-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
>
<div className="flex justify-center">{i18nT("workflows.run.inputOutput")}</div>
</button>
{isExpanded && (
<div className="border-border bg-secondary/90 overflow-hidden rounded-md border p-2">
<InputOutput input={workflow.input} output={workflow.output} />
</div>
)}
</div>
);
}
function InputOutput({ input, output }: { input?: any; output?: any }) {
const compactInput = useMemo(() => (input ? JSON.stringify(input) : ""), [input]);
const compactOutput = useMemo(() => (output ? JSON.stringify(output) : ""), [output]);
const formattedInput = useMemo(() => (input ? JSON.stringify(input, null, 2) : ""), [input]);
const formattedOutput = useMemo(() => (output ? JSON.stringify(output, null, 2) : ""), [output]);
const hasInput = Boolean(input && compactInput !== "{}");
const hasOutput = Boolean(output && compactOutput !== "{}");
return (
<div className="space-y-1">
{!hasInput ? (
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noInput")}</div>
</div>
) : (
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.input")}</div>
<button
type="button"
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
onClick={() => navigator.clipboard.writeText(compactInput)}
>
{i18nT("workflows.actions.copy")}
</button>
</div>
<div className="prose max-h-24 overflow-auto">
<pre>{formattedInput}</pre>
</div>
</div>
)}
{!hasOutput ? (
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noOutput")}</div>
</div>
) : (
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.outputs")}</div>
<button
type="button"
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
onClick={() => navigator.clipboard.writeText(compactOutput)}
>
{i18nT("workflows.actions.copy")}
</button>
</div>
<div className="prose max-h-24 overflow-auto">
<pre>{formattedOutput}</pre>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { LoaderFunctionArgs } from "react-router";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import { requireAuth } from "~/utils/loaders.middleware";
import { emitter } from "~/utils/stream/emitter.server";
import { eventStream } from "~/utils/stream/event-stream";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { getTranslations } from "~/locale/i18next.server";
export const workflowExecutionStreamLoader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const executionId = params.executionId;
if (!executionId) {
return Response.json({ error: t("workflows.errors.executionIdRequired") }, { status: 400 });
}
const execution = await WorkflowsService.getExecution(executionId, { tenantId });
if (!execution) {
return Response.json({ error: t("workflows.errors.executionNotFound") }, { status: 404 });
}
const eventName = `workflowExecutionUpdate:${executionId}`;
return eventStream(request.signal, (send) => {
const listener = (data: string) => send({ data });
emitter.on(eventName, listener);
// Emit the current execution snapshot immediately (after subscribing, so no
// concurrent update is dropped). The executor only emits on UPDATES, so a
// subscriber that connects after the run already reached its state — a slow
// connect under load, a reload, or a late mount — would otherwise see an
// empty stream until the next change (which, for a waitingBlock, never comes
// until the user continues). Same wire shape as the emitter's updates.
send({ data: JSON.stringify(execution) });
return () => {
emitter.off(eventName, listener);
};
});
};
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTranslations: vi.fn(),
getTenantIdOrNull: vi.fn(),
getUserInfo: vi.fn(),
requireWorkflowMutationAccess: vi.fn(),
validateCSRFToken: vi.fn(),
getWorkflow: vi.fn(),
stream: vi.fn(),
queueContinuationExecution: vi.fn(),
continueExecution: vi.fn(),
}));
vi.mock("@toolctrl/config", () => ({
loadWebConfig: () => ({ config: { appName: "Test App" } }),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: mocks.getTranslations,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("~/utils/session.server", () => ({
getUserInfo: mocks.getUserInfo,
validateCSRFToken: mocks.validateCSRFToken,
}));
vi.mock("../workflowAccess.server", () => ({
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
default: {
get: mocks.getWorkflow,
},
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
default: {
stream: mocks.stream,
queueContinuationExecution: mocks.queueContinuationExecution,
continueExecution: mocks.continueExecution,
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Signup flow",
description: "",
status: "live",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks: [],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowsIdRunStreamApi.action", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
mocks.validateCSRFToken.mockResolvedValue(undefined);
mocks.getWorkflow.mockResolvedValue(workflow());
});
it("continues stream wait blocks through the workflow outbox", async () => {
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waitingBlock" });
const form = new URLSearchParams();
form.set("action", "continue-execution");
form.set("executionId", "execution_1");
form.set("input", "approved");
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/stream", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const response = await WorkflowsIdRunStreamApi.action({
request,
params: { tenant: "acme", id: "workflow_1" },
context: {},
});
expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({
success: "Workflow queued",
execution: { id: "execution_1", status: "waitingBlock" },
});
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
type: "manual",
input: { input: "approved" },
session: { tenantId: "tenant_1", userId: "user_1" },
});
expect(mocks.continueExecution).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { loadWebConfig } from "@toolctrl/config";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import UrlUtils from "~/utils/app/UrlUtils";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
export namespace WorkflowsIdRunStreamApi {
export type LoaderData = {
metatags: MetaTagsDto;
workflow: WorkflowDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const workflow = await WorkflowsService.get(params.id!, {
tenantId,
});
if (!workflow) {
return redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
}
const webConfig = loadWebConfig().config;
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.runStream", { value0: workflow.name, value1: webConfig.appName }) }],
workflow,
};
return Response.json(data);
};
export type ActionData = {
success?: string;
error?: string;
executionId: string;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const { userId } = await getUserInfo(request);
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const workflow = await WorkflowsService.get(params.id!, { tenantId });
if (!workflow || workflow.status !== "live") {
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
}
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "execute") {
try {
let input = form.get("input")?.toString() ?? "{}";
let inputData: { [key: string]: any } | null = null;
if (!input.trim()) {
input = "{}";
}
if (input) {
try {
inputData = JSON.parse(input);
} catch {
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
}
}
const execution = await WorkflowsExecutionsService.stream(params.id!, {
input: inputData,
session: {
tenantId,
userId,
},
});
return Response.json({ executionId: execution.id });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
}
} else if (action === "continue-execution") {
try {
const executionId = form.get("executionId")?.toString() ?? "";
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
type: "manual",
input: { input: form.get("input")?.toString() },
session: { tenantId, userId },
});
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
} catch (e: any) {
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
}
}
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
};
}
@@ -0,0 +1,120 @@
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import MonacoEditor from "~/components/editors/MonacoEditor";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
import WorkflowStreamProgress from "~/modules/workflowEngine/components/workflows/stream/WorkflowStreamProgress";
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
import { appendCSRFToken } from "~/utils/csrf";
export default function WorkflowsIdRunStreamView() {
const data = useLoaderData<WorkflowsIdRunStreamApi.LoaderData>();
const actionData = useActionData<WorkflowsIdRunStreamApi.ActionData>();
const params = useParams();
const submit = useSubmit();
const [executionId, setExecutionId] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [inputData, setInputData] = useState("{}");
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (data.workflow.inputExamples.length > 0) {
setSelectedTemplate(data.workflow.inputExamples[0].title);
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
}
}, [data]);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
if (actionData?.executionId) {
setExecutionId(actionData.executionId);
setIsRunning(true);
}
}, [actionData]);
function onExecute() {
const form = new FormData();
appendCSRFToken(form);
form.append("action", "execute");
form.append("input", inputData);
submit(form, {
method: "post",
});
}
return (
<div>
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
<BreadcrumbSimple
menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: data.workflow.name }]}
/>
</div>
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
<div className="flex justify-between">
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
className="text-muted-foreground rounded bg-white px-2 py-1 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
<span className="mr-1">&larr;</span> {i18nT("workflows.navigation.backToExecutions")}
</Link>
<WorkflowRunDropdown workflow={data.workflow} />
</div>
</div>
<div className="mx-auto max-w-2xl space-y-2 p-4">
<div className="flex justify-between space-x-2">
<div data-ui-id="ui.workflows.run.stream.title" className="text-lg font-semibold">
{i18nT("workflows.run.streamTitle")}
</div>
</div>
<div data-ui-id="ui.workflows.run.stream.form">
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
{data.workflow.inputExamples.length > 0 && (
<div data-ui-id="ui.workflows.run.stream.inputExampleSelect">
<WorkflowInputExamplesDropdown
workflow={data.workflow}
onSelected={(item) => {
setSelectedTemplate(item.title);
setInputData(JSON.stringify(item.input, null, 2));
}}
/>
</div>
)}
</div>
<div data-ui-id="ui.workflows.run.stream.inputEditor" className="border-border overflow-hidden rounded-md border">
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
</div>
</div>
<div className="flex justify-end pt-2">
<LoadingButton dataUiId="ui.workflows.run.stream.submit" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)} isLoading={isRunning}>
{i18nT("workflows.actions.run")}
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
</LoadingButton>
</div>
</div>
{executionId && (
<WorkflowStreamProgress
key={executionId}
streamUrl={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/run/stream/${executionId}`)}
onCompleted={() => setIsRunning(false)}
/>
)}
{actionData?.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />}
</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { deleteWorkflowCredential, getAllWorkflowCredentials } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
import UrlUtils from "~/utils/app/UrlUtils";
import { requireAuth } from "~/utils/loaders.middleware";
import { validateCSRFToken } from "~/utils/session.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsCredentialsApi {
export type LoaderData = {
metatags: MetaTagsDto;
items: WorkflowCredential[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
const items = await getAllWorkflowCredentials({ tenantId });
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.credentials", { value0: process.env.APP_NAME }) }],
items,
};
return data;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "delete") {
const id = form.get("id")?.toString() ?? "";
await deleteWorkflowCredential(id, { tenantId });
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
} else {
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
}
};
}
@@ -0,0 +1,85 @@
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
import { t as i18nT } from "i18next";
import { MetaFunction, useLoaderData } from "react-router";
import { Outlet, useSubmit } from "react-router";
import { useRef } from "react";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import DateCell from "~/components/ui/dates/DateCell";
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import TableSimple from "~/components/ui/tables/TableSimple";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowsCredentialsApi } from "./credentials.api.server";
import { appendCSRFToken } from "~/utils/csrf";
export const meta: MetaFunction = () => [{ title: i18nT("workflows.credentials.title") }];
export default function WorkflowsCredentialsView() {
const data = useLoaderData<WorkflowsCredentialsApi.LoaderData>();
const submit = useSubmit();
const confirmDelete = useRef<RefConfirmModal>(null);
function onDelete(item: WorkflowCredential) {
confirmDelete.current?.setValue(item);
confirmDelete.current?.show(
i18nT("workflows.credentials.confirmDelete.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.credentials.confirmDelete.description")
);
}
function onDeleteConfirm(item: WorkflowDto) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete");
form.set("id", item.id.toString() ?? "");
submit(form, {
method: "post",
});
}
return (
<EditPageLayout
title={i18nT("workflows.credentials.title")}
buttons={
<>
<ButtonPrimary to="new">{i18nT("workflows.actions.new")}</ButtonPrimary>
</>
}
>
<div className="space-y-3">
<TableSimple
headers={[
{
title: i18nT("workflows.columns.name"),
name: "name",
value: (item) => <div className="select-all">{`{{$credentials.${item.name}}}`}</div>,
},
{
title: i18nT("workflows.columns.value"),
name: "value",
className: "w-full",
value: (item) => <div className="max-w-sm truncate">{"".padEnd(36, "*")}</div>,
},
{
title: i18nT("shared.createdAt"),
name: "createdAt",
value: (item) => <DateCell date={item.createdAt} />,
},
]}
items={data.items}
actions={[
{
title: i18nT("workflows.actions.delete"),
onClick: (_, i) => onDelete(i),
destructive: true,
},
]}
/>
</div>
<Outlet />
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
</EditPageLayout>
);
}
@@ -0,0 +1,61 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import UrlUtils from "~/utils/app/UrlUtils";
import { createWorkflowCredential, getWorkflowCredentialByName } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import CryptoApi from "~/utils/api/.server/CryptoApi";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsCredentialsNewApi {
export type LoaderData = {
metatags: MetaTagsDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.newCredential", { value0: process.env.APP_NAME }) }],
};
return data;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
const userInfo = await getUserInfo(request);
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString() ?? "";
const name = form.get("credential-name")?.toString() ?? "";
let value = form.get("credential-value")?.toString() ?? "";
if (action === "create") {
try {
const existing = await getWorkflowCredentialByName(name, { tenantId });
if (existing) {
throw Error(
t("workflows.errors.credentialAlreadyExists", {
value0: name,
})
);
}
value = CryptoApi.encrypt(value);
await createWorkflowCredential({
tenantId,
createdByUserId: userInfo.userId,
name,
value,
});
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400 });
}
}
return Response.json({ error: t("workflows.errors.invalidAction") }, { status: 400 });
};
}
@@ -0,0 +1,18 @@
import { useNavigate, useParams } from "react-router";
import { t as i18nT } from "i18next";
import WorkflowCredentialForm from "~/modules/workflowEngine/components/workflowVariables/WorkflowCredentialForm";
import UrlUtils from "~/utils/app/UrlUtils";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
export default function WorkflowsCredentialsNewView() {
const params = useParams();
const navigate = useNavigate();
function close() {
navigate(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
}
return (
<SlideOverWideEmpty title={i18nT("workflows.credentials.newTitle")} className="sm:max-w-sm" open={true} onClose={close}>
<WorkflowCredentialForm />
</SlideOverWideEmpty>
);
}
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
getTenantIdOrNull: vi.fn(),
workflowCount: vi.fn(),
credentialCount: vi.fn(),
variableCount: vi.fn(),
executionCount: vi.fn(),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("~/utils/db.server", () => ({
db: {
workflow: { count: mocks.workflowCount },
workflowCredential: { count: mocks.credentialCount },
workflowVariable: { count: mocks.variableCount },
workflowExecution: { count: mocks.executionCount },
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsDangerApi } from "./danger.api.server";
describe("WorkflowsDangerApi.loader", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
});
it("loads destructive-scope summary counts concurrently", async () => {
const workflows = deferred<number>();
const credentials = deferred<number>();
const variables = deferred<number>();
const executions = deferred<number>();
mocks.workflowCount.mockReturnValue(workflows.promise);
mocks.credentialCount.mockReturnValue(credentials.promise);
mocks.variableCount.mockReturnValue(variables.promise);
mocks.executionCount.mockReturnValue(executions.promise);
const loaderPromise = WorkflowsDangerApi.loader({
request: new Request("https://app.example.test/acme/workflow-engine/danger"),
params: { tenant: "acme" },
context: {},
});
await Promise.resolve();
await Promise.resolve();
expect(mocks.workflowCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
expect(mocks.credentialCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
expect(mocks.variableCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
expect(mocks.executionCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
workflows.resolve(2);
credentials.resolve(3);
variables.resolve(5);
executions.resolve(8);
await expect(loaderPromise).resolves.toMatchObject({
summary: {
workflows: 2,
credentials: 3,
variables: 5,
executions: 8,
},
});
});
});
@@ -0,0 +1,89 @@
import { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { db } from "~/utils/db.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { validateCSRFToken } from "~/utils/session.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsDangerApi {
export type LoaderData = {
metatags: MetaTagsDto;
summary: {
workflows: number;
variables: number;
credentials: number;
executions: number;
};
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const [workflows, credentials, variables, executions] = await Promise.all([
db.workflow.count({ where: { tenantId } }),
db.workflowCredential.count({ where: { tenantId } }),
db.workflowVariable.count({ where: { tenantId } }),
db.workflowExecution.count({ where: { tenantId } }),
]);
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.danger", { value0: process.env.APP_NAME }) }],
summary: {
workflows,
credentials,
variables,
executions,
},
};
return data;
};
export type ActionData = {
error?: string;
success?: string;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await requireAuth({ request, params });
const { t } = await getTranslations(request);
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString();
if (action === "reset-all-data") {
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.workflowDataReset", {
value0: workflows.count,
value1: variables.count,
value2: credentials.count,
}),
});
} else if (action === "delete-all-executions") {
const executions = await db.workflowExecution.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.executionsDeleted", { count: executions.count }),
});
} else if (action === "delete-all-credentials") {
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.credentialsDeleted", { count: credentials.count }),
});
} else if (action === "delete-all-variables") {
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.variablesDeleted", { count: variables.count }),
});
} else if (action === "delete-all-workflows") {
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
return Response.json({
success: t("workflows.responses.workflowsDeleted", { count: workflows.count }),
});
} else {
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
}
};
}
@@ -0,0 +1,151 @@
import { useActionData, useLoaderData, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { WorkflowsDangerApi } from "./danger.api.server";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import DropdownOptions from "~/components/ui/dropdowns/DropdownOptions";
import { Menu } from "@headlessui/react";
import clsx from "clsx";
import { appendCSRFToken } from "~/utils/csrf";
export default function WorkflowsDangerView() {
const data = useLoaderData<WorkflowsDangerApi.LoaderData>();
const actionData = useActionData<WorkflowsDangerApi.ActionData>();
const submit = useSubmit();
const confirmReset = useRef<RefConfirmModal>(null);
useEffect(() => {
if (actionData?.error) {
toast.error(actionData.error);
} else if (actionData?.success) {
toast.success(actionData.success);
}
}, [actionData]);
function onReset(type: string) {
confirmReset.current?.setValue(type);
if (type === "reset-all-data") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmResetAll.title"),
i18nT("workflows.danger.actions.reset"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmResetAll.description")
);
} else if (type === "delete-all-executions") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteExecutions.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteExecutions.description")
);
} else if (type === "delete-all-variables") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteVariables.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteVariables.description")
);
} else if (type === "delete-all-credentials") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteCredentials.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteCredentials.description")
);
} else if (type === "delete-all-workflows") {
confirmReset.current?.show(
i18nT("workflows.danger.confirmDeleteWorkflows.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.danger.confirmDeleteWorkflows.description")
);
}
}
function onResetConfirm(type: string) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", type);
submit(form, {
method: "post",
});
}
return (
<div className="h-screen flex-1 overflow-x-auto xl:overflow-y-auto">
<div className="mx-auto max-w-5xl px-4 py-4 sm:px-6 lg:px-8 lg:py-12">
<h1 className="text-foreground text-3xl font-bold tracking-tight">{i18nT("workflows.danger.title")}</h1>
<div className="divide-y-gray-200 mt-6 space-y-8 divide-y">
<div className="grid grid-cols-1 gap-y-6 sm:grid-cols-6 sm:gap-x-6">
<div className="sm:col-span-6">
<h2 className="text-foreground text-xl font-medium">{i18nT("workflows.danger.confirmResetAll.title")}</h2>
<p className="text-muted-foreground mt-1 text-sm">{i18nT("workflows.danger.confirmResetAll.description")}</p>
</div>
</div>
<div className="flex justify-end space-x-2 pt-8">
<DropdownOptions
width="w-80"
button={
<div className="hover:bg-secondary border-border bg-background rounded-md border px-2 py-2 text-sm font-medium text-red-600">
{i18nT("workflows.danger.actions.resetData")}
</div>
}
options={
<div>
{[
{
action: "delete-all-credentials",
title: i18nT("workflows.danger.options.deleteAllCredentials", { count: data.summary.credentials }),
},
{
action: "delete-all-variables",
title: i18nT("workflows.danger.options.deleteAllVariables", { count: data.summary.variables }),
},
{
action: "delete-all-executions",
title: i18nT("workflows.danger.options.deleteAllExecutions", { count: data.summary.executions }),
},
{
action: "delete-all-workflows",
title: i18nT("workflows.danger.options.deleteAllWorkflows", { count: data.summary.workflows }),
},
{
action: "reset-all-data",
title: i18nT("workflows.danger.confirmResetAll.title"),
className: "text-red-600 font-medium",
},
].map((option) => {
return (
<Menu.Item key={option.action}>
{({ active }) => (
<button
type="button"
onClick={() => onReset(option.action)}
className={clsx(
option.className,
"w-full truncate text-left",
active ? "text-foreground bg-secondary/90" : "text-foreground/80",
"block px-4 py-2 text-sm"
)}
>
{option.title}
</button>
)}
</Menu.Item>
);
})}
</div>
}
></DropdownOptions>
</div>
</div>
</div>
<ConfirmModal ref={confirmReset} onYes={onResetConfirm} destructive />
</div>
);
}
@@ -0,0 +1,97 @@
import { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import { getFiltersFromCurrentUrl, getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
import {
deleteWorkflowExecution,
getAllWorkflowExecutions,
getWorkflowExecution,
WorkflowExecutionListItem,
} from "~/modules/workflowEngine/db/workflowExecutions.db.server";
import { getWorkflowsIdsAndNames } from "~/modules/workflowEngine/db/workflows.db.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { requireAuth } from "~/utils/loaders.middleware";
import { validateCSRFToken } from "~/utils/session.server";
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsExecutionsApi {
export type LoaderData = {
metatags: MetaTagsDto;
items: WorkflowExecutionListItem[];
pagination: PaginationDto;
filterableProperties: FilterablePropertyDto[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const filterableProperties: FilterablePropertyDto[] = [
{
name: "workflowId",
title: i18nT("workflows.columns.workflow"),
manual: true,
options: (await getWorkflowsIdsAndNames({ tenantId })).map((item) => {
return {
value: item.id,
name: item.name,
};
}),
},
{
name: "status",
title: i18nT("shared.status"),
manual: true,
options: ["running", "success", "error"].map((item) => {
return { value: item, name: i18nT(`workflows.executionStatus.${item}`, { defaultValue: item }) };
}),
},
{
name: "type",
title: i18nT("shared.type"),
manual: true,
options: ["manual", "api", "stream"].map((item) => {
return { value: item, name: i18nT(`workflows.run.modes.${item}`, { defaultValue: item }) };
}),
},
];
const filters = getFiltersFromCurrentUrl(request, filterableProperties);
const urlSearchParams = new URL(request.url).searchParams;
const currentPagination = getPaginationFromCurrentUrl(urlSearchParams);
const { items, pagination } = await getAllWorkflowExecutions({
tenantId,
pagination: currentPagination,
filters,
});
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.executionsIndex", { value0: process.env.APP_NAME }) }],
items,
pagination,
filterableProperties,
};
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() ?? "";
const execution = await getWorkflowExecution(id, { tenantId });
if (!execution) {
return Response.json({ error: t("workflows.errors.executionNotFound") }, { status: 404 });
}
await deleteWorkflowExecution(id, { tenantId });
return Response.json({ success: true });
} else {
return Response.json({ error: t("workflows.errors.invalidAction") }, { status: 400 });
}
};
}
@@ -0,0 +1,141 @@
import { Link, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useRef } from "react";
import TenantBadge from "~/components/core/tenants/TenantBadge";
import DateCell from "~/components/ui/dates/DateCell";
import InputFilters from "~/components/ui/input/InputFilters";
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import TableSimple from "~/components/ui/tables/TableSimple";
import WorkflowResultBadge from "~/modules/workflowEngine/components/executions/WorkflowResultBadge";
import { WorkflowExecutionListItem } from "~/modules/workflowEngine/db/workflowExecutions.db.server";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsExecutionsApi } from "./executions.api.server";
import { useTranslation } from "react-i18next";
import { appendCSRFToken } from "~/utils/csrf";
import { useLocaleFormatters } from "~/locale/format";
export default function WorkflowsExecutionsView() {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const data = useLoaderData<WorkflowsExecutionsApi.LoaderData>();
const submit = useSubmit();
const navigation = useNavigation();
const params = useParams();
const confirmDelete = useRef<RefConfirmModal>(null);
function onDelete(item: WorkflowExecutionListItem) {
confirmDelete.current?.setValue(item);
confirmDelete.current?.show(
i18nT("workflows.executions.confirmDelete.title"),
i18nT("workflows.actions.delete"),
i18nT("workflows.actions.cancel"),
i18nT("workflows.executions.confirmDelete.description")
);
}
function onDeleteConfirm(item: WorkflowExecutionListItem) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete");
form.set("id", item.id);
submit(form, {
method: "post",
});
}
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),
});
}
return (
<EditPageLayout
title={i18nT("workflows.executions.title")}
withHome={false}
buttons={
<>
<InputFilters filters={data.filterableProperties} />
</>
}
>
<TableSimple
items={data.items}
actions={[
{
title: i18nT("shared.delete"),
onClick: (_idx, i) => (onDelete ? onDelete(i) : undefined),
destructive: true,
disabled: (i) => navigation.state === "submitting" && navigation.formData?.get("id") === i.id,
hidden: () => !!params.tenant,
},
{
title: i18nT("shared.details"),
onClickRoute: (_idx, i) => UrlUtils.getModulePath(params, `workflow-engine/workflows/${i.workflowId}/executions?executionId=${i.id}`),
firstColumn: true,
},
]}
headers={[
{
name: "type",
title: i18nT("shared.type"),
value: (i) => <div>{i18nT(`workflows.run.modes.${i.type}`, { defaultValue: i.type })}</div>,
},
{
name: "status",
title: i18nT("shared.status"),
value: (i) => (
<div className="flex max-w-xs flex-col truncate">
<WorkflowResultBadge createdAt={i.createdAt} startedAt={i.createdAt} completedAt={i.endedAt} status={i.status} error={i.error} />
</div>
),
},
{
name: "tenant",
title: t("models.tenant.object"),
value: (i) => (i.tenant ? <TenantBadge item={i.tenant} /> : <span className="text-muted-foreground italic">{i18nT("shared.admin")}</span>),
},
{
name: "trigger",
title: i18nT("workflows.columns.trigger"),
value: (i) => <div>{i.blockRuns.length === 0 ? <span></span> : <span>{i.blockRuns[0].workflowBlock.type}</span>}</div>,
},
{
name: "blockRuns",
title: i18nT("workflows.columns.blockRuns"),
value: (i) => <div>{i18nT("workflows.executions.blockRuns", { count: i._count.blockRuns })}</div>,
},
{
name: "createdAt",
title: i18nT("shared.createdAt"),
value: (i) => <DateCell date={i.createdAt} />,
},
{
name: "duration",
title: i18nT("workflows.executions.duration"),
value: (i) => formatDuration(i.createdAt, i.endedAt),
},
{
name: "workflow",
title: i18nT("workflows.columns.workflow"),
value: (i) => (
<Link className="truncate font-medium hover:underline" to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${i.workflow.id}`)}>
{i.workflow.name}
</Link>
),
},
]}
/>
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
</EditPageLayout>
);
}
@@ -0,0 +1,97 @@
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 },
},
}));
vi.mock("../../db/workflowExecutions.db.server", () => ({
countWorkflowExecutions: mocks.executionCount,
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowEngineIndexApi } from "./index.api.server";
describe("WorkflowEngineIndexApi.loader", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
});
it("loads summary counts concurrently for the tenant", 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 = WorkflowEngineIndexApi.loader({
request: new Request("https://app.example.test/acme/workflow-engine"),
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({ tenantId: "tenant_1" });
workflows.resolve(2);
credentials.resolve(3);
variables.resolve(5);
executions.resolve(8);
await expect(loaderPromise).resolves.toMatchObject({
summary: {
workflowsTotal: 2,
credentialsTotal: 3,
variablesTotal: 5,
executionsTotal: 8,
},
});
});
});
@@ -0,0 +1,39 @@
import type { LoaderFunctionArgs } from "react-router";
import type { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { db } from "~/utils/db.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { countWorkflowExecutions } from "../../db/workflowExecutions.db.server";
import { t as i18nT } from "i18next";
export namespace WorkflowEngineIndexApi {
export type LoaderData = {
metatags: MetaTagsDto;
summary: {
workflowsTotal: number;
credentialsTotal: number;
variablesTotal: number;
executionsTotal: number;
};
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const [workflowsTotal, credentialsTotal, variablesTotal, executionsTotal] = await Promise.all([
db.workflow.count({ where: { tenantId } }),
db.workflowCredential.count({ where: { tenantId } }),
db.workflowVariable.count({ where: { tenantId } }),
countWorkflowExecutions({ tenantId }),
]);
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.index", { value0: process.env.APP_NAME }) }],
summary: {
workflowsTotal,
credentialsTotal,
variablesTotal,
executionsTotal,
},
};
return data;
};
}
@@ -0,0 +1,34 @@
import { useLoaderData } from "react-router";
import { t as i18nT } from "i18next";
import { useLocaleFormatters } from "~/locale/format";
import { WorkflowEngineIndexApi } from "./index.api.server";
export default function WorkflowEngineIndexView() {
const data = useLoaderData<WorkflowEngineIndexApi.LoaderData>();
return (
<div className="mx-auto mb-12 max-w-5xl space-y-5 px-4 py-4 sm:px-6 lg:px-8 xl:max-w-7xl">
<div className="border-border border-b pb-5">
<h3 className="text-foreground text-lg leading-6 font-medium">{i18nT("shared.overview")}</h3>
</div>
<dl className="grid gap-2 sm:grid-cols-2">
<SummaryCard title={i18nT("workflows.title")} value={data.summary.workflowsTotal} />
<SummaryCard title={i18nT("workflows.executions.title")} value={data.summary.executionsTotal} />
<SummaryCard title={i18nT("workflows.credentials.title")} value={data.summary.credentialsTotal} />
<SummaryCard title={i18nT("workflows.variables.title")} value={data.summary.variablesTotal} />
</dl>
</div>
);
}
function SummaryCard({ title, value }: { title: string; value: number }) {
const formatters = useLocaleFormatters();
return (
<div className="bg-card border-border overflow-hidden rounded-lg border px-4 py-3 shadow-xs">
<dt className="text-muted-foreground truncate text-xs font-medium uppercase">
<div>{title}</div>
</dt>
<dd className="text-foreground mt-1 truncate text-2xl font-semibold">{formatters.number(value, { maximumFractionDigits: 0 })}</dd>
</div>
);
}
@@ -0,0 +1,103 @@
import { ActionFunction, LoaderFunctionArgs, redirect } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { getAllWorkflows } from "~/modules/workflowEngine/db/workflows.db.server";
import { WorkflowsTemplateDto } from "~/modules/workflowEngine/dtos/WorkflowsTemplateDto";
import WorkflowEngineTemplatesService, { getWorkflowTemplateImportErrorMessage } from "~/modules/workflowEngine/services/WorkflowsTemplatesService";
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 { localizeWorkflowTemplate } from "../../utils/workflowTemplateI18n";
export namespace WorkflowsTemplatesApi {
export type LoaderData = {
metatags: MetaTagsDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.templates", { value0: process.env.APP_NAME }) }],
};
return data;
};
export type ActionData = {
previewTemplate?: WorkflowsTemplateDto;
success?: string;
error?: string;
};
export const action: ActionFunction = async ({ request, params }) => {
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 form = await request.formData();
const action = form.get("action")?.toString();
if (action === "preview") {
try {
const template = localizeWorkflowTemplate(JSON.parse(form.get("configuration")?.toString() ?? "{}") as WorkflowsTemplateDto, t);
const allWorkflows = await getAllWorkflows({ tenantId });
await Promise.all(
template.workflows.map(async (workflow) => {
const existing = allWorkflows.find((w) => w.name === workflow.name);
if (existing) {
throw Error(
t("workflows.errors.workflowAlreadyExists", {
value0: workflow.name,
})
);
}
})
);
const workflows = await WorkflowEngineTemplatesService.importWorkflows(template, {
tenantId,
userId,
});
if (workflows.length === 0) {
throw Error(t("workflows.errors.couldNotCreateWorkflow"));
}
if (workflows.length === 1) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflows[0].id}`));
}
return Response.json({
success: t("workflows.responses.workflowsCreated", { count: workflows.length }),
});
} catch (error: unknown) {
if (error instanceof Response) {
throw error;
}
return Response.json({ error: getWorkflowTemplateImportErrorMessage(error, t) }, { status: 400 });
}
} else if (action === "create") {
try {
const template = localizeWorkflowTemplate(JSON.parse(form.get("configuration")?.toString() ?? "{}") as WorkflowsTemplateDto, t);
const workflows = await WorkflowEngineTemplatesService.importWorkflows(template, {
userId,
tenantId,
});
if (workflows.length === 0) {
throw Error(t("workflows.errors.couldNotCreateWorkflow"));
}
return Response.json({
success: workflows.map((workflow) => {
return {
title: t("workflows.templates.importedWorkflowSummary", { name: workflow.name, count: workflow.blocks.length }),
href: UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}`),
};
}),
});
} catch (error: unknown) {
if (error instanceof Response) {
throw error;
}
return Response.json({ error: getWorkflowTemplateImportErrorMessage(error, t) }, { status: 400 });
}
} else {
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
}
};
}
@@ -0,0 +1,204 @@
import { Form, Link, useActionData, useParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import { useEffect, useState } from "react";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText from "~/components/ui/input/InputText";
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
import { WorkflowsTemplateDto } from "~/modules/workflowEngine/dtos/WorkflowsTemplateDto";
import DefaultWorkflowTemplates from "~/modules/workflowEngine/utils/DefaultWorkflowTemplates";
import UrlUtils from "~/utils/app/UrlUtils";
import { WorkflowsTemplatesApi } from "./templates.api.server";
import InputSearch from "~/components/ui/input/InputSearch";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import WorkflowUtils from "../../helpers/WorkflowUtils";
import EmptyState from "~/components/ui/emptyState/EmptyState";
import { appendCSRFToken } from "~/utils/csrf";
import { getTemplateTitle, getTemplateWorkflowName, localizeWorkflowTemplate } from "../../utils/workflowTemplateI18n";
export default function WorkflowsTemplatesView() {
const actionData = useActionData<WorkflowsTemplatesApi.ActionData>();
const submit = useSubmit();
const params = useParams();
const allTemplates = DefaultWorkflowTemplates.filter((f) => !f.adminOnly || !params.tenant);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<"json" | "templates">("templates");
const [searchInput, setSearchInput] = useState<string>("");
const [configuration, setConfiguration] = useState<string>(allTemplates.length > 0 ? JSON.stringify(allTemplates[0], null, "\t") : "{}");
const [filteredItems, setFilteredItems] = useState<WorkflowsTemplateDto[]>(allTemplates);
useEffect(() => {
if (actionData?.error) {
setError(actionData.error);
}
}, [actionData]);
useEffect(() => {
let items = allTemplates;
if (searchInput) {
items = allTemplates.filter((item) => {
return (
getTemplateTitle(item, i18nT).toLowerCase().includes(searchInput.toLowerCase()) ||
item.workflows
.map((workflow) => getTemplateWorkflowName(item, workflow, i18nT))
.join(", ")
.toLowerCase()
.includes(searchInput.toLowerCase()) ||
getBlockTypesUsed(item.workflows)
.map((f) => `[${f.value}] ${f.name}`)
.join(", ")
.toLowerCase()
.includes(searchInput.toLowerCase())
);
});
}
setFilteredItems(items);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
function getBlockTypesUsed(workflows: WorkflowsTemplateDto["workflows"]) {
const types = workflows.flatMap((workflow) => workflow.blocks.map((block) => block.type));
const uniqueTypes = [...new Set(types)];
let blockTypes: { name: string; value: string }[] = [];
uniqueTypes.forEach((type) => {
blockTypes.push({
name: WorkflowUtils.getBlockTypeName({ type }),
value: type,
});
});
return blockTypes;
}
return (
<EditPageLayout
title={i18nT("workflows.templates.title")}
buttons={
<>
{mode === "templates" && <InputSearch className="w-64" value={searchInput} setValue={setSearchInput} />}
<ButtonSecondary onClick={() => setMode(mode === "json" ? "templates" : "json")}>
{mode === "json" ? i18nT("workflows.templates.browse") : i18nT("workflows.templates.importJson")}
</ButtonSecondary>
</>
}
>
<div className="md:border-border md:border-t md:py-2">
{error ? (
<div className="space-y-1">
<p id="form-error-message" className="py-2 text-sm text-rose-500" role="alert">
{error}
</p>
<button type="button" className="hover:text-muted-foreground text-muted-foreground text-sm font-medium underline" onClick={() => setError(null)}>
{i18nT("workflows.templates.tryAgain")}
</button>
</div>
) : actionData?.success ? (
<>
<div id="form-success-message" className="text-foreground space-y-1 py-2 text-sm">
{actionData.success}
</div>
<Link
to={UrlUtils.getModulePath(params, `workflow-engine/workflows`)}
className="text-theme-600 hover:text-theme-500 text-sm font-medium underline"
>
{i18nT("workflows.navigation.viewAllWorkflows")}
</Link>
</>
) : (
<div>
{mode === "templates" && (
<div className="space-y-3">
{filteredItems.length === 0 && (
<div>
<EmptyState
className="bg-background"
captions={{
thereAreNo: i18nT("workflows.list.empty.title"),
}}
/>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{filteredItems.map((item) => {
return (
<button
type="button"
key={item.key ?? item.title}
className="hover:border-theme-300 hover:bg-theme-50 focus:ring-ring border-border bg-background flex w-full flex-col items-start overflow-hidden rounded-md border text-left shadow-2xs hover:cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-offset-2"
onClick={() => {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "preview");
form.set("configuration", JSON.stringify(localizeWorkflowTemplate(item, i18nT), null, "\t"));
submit(form, {
method: "post",
});
}}
>
<div className="flex flex-col items-start space-y-2 px-3 py-3">
<div className="text-foreground font-medium">{getTemplateTitle(item, i18nT)}</div>
<div className="space-y-0.5">
<div className="text-muted-foreground block text-xs font-medium uppercase">
{i18nT("workflows.templates.workflowsCount", { count: item.workflows.length })}
</div>
<ul className="text-muted-foreground text-sm">
{item.workflows.map((f) => {
return <li key={f.name}>{getTemplateWorkflowName(item, f, i18nT)}</li>;
})}
</ul>
</div>
<div className="space-y-0.5">
<div className="text-muted-foreground block text-xs font-medium uppercase">{i18nT("workflows.templates.blockTypes")}</div>
<div className="text-muted-foreground text-sm">
{getBlockTypesUsed(item.workflows)
.map((f) => f.name)
.join(", ")}
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
)}
{mode === "json" && (
<Form method="post">
<input type="hidden" name="action" value="preview" hidden readOnly />
<div className="space-y-3">
<div className="flex space-x-2">
<div className="flex space-x-2">
{allTemplates.map((t) => (
<button
key={t.title}
type="button"
onClick={() => setConfiguration(JSON.stringify(localizeWorkflowTemplate(t, i18nT), null, "\t"))}
className="bg-theme-100 text-theme-700 hover:bg-theme-200 focus:ring-ring inline-flex items-center rounded border border-transparent px-2.5 py-1.5 text-xs font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2"
>
{getTemplateTitle(t, i18nT)}
</button>
))}
</div>
</div>
<div>
<InputText
name="configuration"
title={i18nT("workflows.templates.configuration")}
editor="monaco"
editorLanguage="json"
value={configuration}
setValue={setConfiguration}
editorSize="lg"
/>
</div>
<div className="flex justify-end">
<LoadingButton type="submit">{i18nT("workflows.actions.import")}</LoadingButton>
</div>
</div>
</Form>
)}
</div>
)}
</div>
</EditPageLayout>
);
}
@@ -0,0 +1,26 @@
import { WorkflowVariable } from "@toolctrl/db/prisma-browser";
import { LoaderFunctionArgs } from "react-router";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { getAllWorkflowVariables } from "~/modules/workflowEngine/db/workflowVariable.db.server";
import { requireAuth } from "~/utils/loaders.middleware";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
import { t as i18nT } from "i18next";
export namespace WorkflowsVariablesApi {
export type LoaderData = {
metatags: MetaTagsDto;
items: WorkflowVariable[];
};
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 getAllWorkflowVariables({ tenantId });
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.variables", { value0: process.env.APP_NAME }) }],
items,
};
return data;
};
}
@@ -0,0 +1,53 @@
import { Outlet, useLoaderData } from "react-router";
import { t as i18nT } from "i18next";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import DateCell from "~/components/ui/dates/DateCell";
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
import TableSimple from "~/components/ui/tables/TableSimple";
import { WorkflowsVariablesApi } from "./variables.api.server";
export default function WorkflowsVariablesView() {
const data = useLoaderData<WorkflowsVariablesApi.LoaderData>();
return (
<EditPageLayout
title={i18nT("workflows.variables.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) => `{{$vars.${item.name}}}`,
},
{
title: i18nT("workflows.columns.value"),
name: "value",
className: "w-full",
value: (item) => item.value,
},
{
title: i18nT("shared.createdAt"),
name: "createdAt",
value: (item) => <DateCell date={item.createdAt} />,
},
]}
items={data.items}
actions={[
{
title: i18nT("workflows.actions.edit"),
onClickRoute: (idx, item) => item.id,
},
]}
/>
</div>
<Outlet />
</EditPageLayout>
);
}
@@ -0,0 +1,64 @@
import { WorkflowVariable } from "@toolctrl/db/prisma-browser";
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import UrlUtils from "~/utils/app/UrlUtils";
import { deleteWorkflowVariable, getWorkflowVariableById, updateWorkflowVariable } from "~/modules/workflowEngine/db/workflowVariable.db.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { requireAuth } from "~/utils/loaders.middleware";
import { validateCSRFToken } from "~/utils/session.server";
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
import { t as i18nT } from "i18next";
import { getTranslations } from "~/locale/i18next.server";
export namespace WorkflowsVariablesIdApi {
export type LoaderData = {
metatags: MetaTagsDto;
item: WorkflowVariable;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
await requireWorkflowMutationAccess({ request, params, tenantId });
const item = await getWorkflowVariableById(params.id ?? "", { tenantId });
if (!item) {
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/variables`));
}
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.editVariable", { value0: process.env.APP_NAME }) }],
item,
};
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 });
const existing = await getWorkflowVariableById(params.id ?? "", { tenantId });
if (!existing) {
return Response.json({ error: t("workflows.errors.variableNotFound") }, { status: 404 });
}
await validateCSRFToken(request);
const form = await request.formData();
const action = form.get("action")?.toString() ?? "";
const value = form.get("value")?.toString() ?? "";
if (action === "edit") {
try {
await updateWorkflowVariable(params.id ?? "", {
tenantId,
value,
});
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/variables`));
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400 });
}
} else if (action === "delete") {
await deleteWorkflowVariable(existing.id, { tenantId });
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/variables`));
}
return Response.json({ error: t("workflows.errors.invalidAction") }, { status: 400 });
};
}
@@ -0,0 +1,20 @@
import { useLoaderData, useNavigate, useParams } from "react-router";
import { t as i18nT } from "i18next";
import WorkflowVariableForm from "~/modules/workflowEngine/components/workflowVariables/WorkflowVariableForm";
import UrlUtils from "~/utils/app/UrlUtils";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
import { WorkflowsVariablesIdApi } from "./variables.$id.api.server";
export default function WorkflowsVariablesIdView() {
const data = useLoaderData<WorkflowsVariablesIdApi.LoaderData>();
const params = useParams();
const navigate = useNavigate();
function close() {
navigate(UrlUtils.getModulePath(params, `workflow-engine/variables`));
}
return (
<SlideOverWideEmpty title={i18nT("workflows.variables.editTitle")} className="sm:max-w-sm" open={true} onClose={close}>
<WorkflowVariableForm item={data.item} />
</SlideOverWideEmpty>
);
}
@@ -0,0 +1,59 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import UrlUtils from "~/utils/app/UrlUtils";
import { createWorkflowVariable, getWorkflowVariableByName } from "~/modules/workflowEngine/db/workflowVariable.db.server";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
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 WorkflowsVariablesNewApi {
export type LoaderData = {
metatags: MetaTagsDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.newVariable", { 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("name")?.toString() ?? "";
const value = form.get("value")?.toString() ?? "";
if (action === "create") {
try {
const existing = await getWorkflowVariableByName(name, { tenantId });
if (existing) {
throw Error(
t("workflows.errors.variableAlreadyExists", {
value0: name,
})
);
}
await createWorkflowVariable({
tenantId,
createdByUserId: userInfo.userId,
name,
value,
});
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/variables`));
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400 });
}
}
return Response.json({ error: t("workflows.errors.invalidAction") }, { status: 400 });
};
}
@@ -0,0 +1,18 @@
import { useNavigate, useParams } from "react-router";
import { t as i18nT } from "i18next";
import WorkflowVariableForm from "~/modules/workflowEngine/components/workflowVariables/WorkflowVariableForm";
import UrlUtils from "~/utils/app/UrlUtils";
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
export default function WorkflowsVariablesNewView() {
const params = useParams();
const navigate = useNavigate();
function close() {
navigate(UrlUtils.getModulePath(params, `workflow-engine/variables`));
}
return (
<SlideOverWideEmpty title={i18nT("workflows.variables.newTitle")} className="sm:max-w-sm" open={true} onClose={close}>
<WorkflowVariableForm />
</SlideOverWideEmpty>
);
}
@@ -0,0 +1,92 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { resolveRepoPath } from "~/utils/testing/repoPaths";
const appRoot = resolveRepoPath("apps/web/app");
const workflowMutationHandlers = [
"modules/workflowEngine/routes/workflow-engine/workflows.index.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.index.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/credentials.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/credentials/credentials.new.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/danger.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/executions.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/templates.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/variables/variables.$id.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/variables/variables.new.api.server.tsx",
];
const workflowSecretLoaders = [
{
file: "modules/workflowEngine/routes/workflow-engine/credentials.api.server.tsx",
read: "const items = await getAllWorkflowCredentials",
},
{
file: "modules/workflowEngine/routes/workflow-engine/variables.api.server.tsx",
read: "const items = await getAllWorkflowVariables",
},
{
file: "modules/workflowEngine/routes/workflow-engine/variables/variables.$id.api.server.tsx",
read: "const item = await getWorkflowVariableById",
},
];
describe("workflow route authorization boundary", () => {
test("requires super-admin access for global workflow mutations and product-gated tenant admin access for tenant mutations", () => {
const source = fs.readFileSync(path.join(appRoot, "modules/workflowEngine/routes/workflow-engine/workflowAccess.server.ts"), "utf8");
expect(source).toContain("requireSuperAdmin");
expect(source).toContain("requireTenantProductAccess");
expect(source).toContain("featureKeys.workflowEngine");
expect(source).toContain("if (!tenantId)");
expect(source.indexOf("await requireSuperAdmin({ request })")).toBeLessThan(source.indexOf("await requireSuperUserOrAdmin({ request, params })"));
expect(source.indexOf("await requireTenantProductAccess({ request, params, featureKey: featureKeys.workflowEngine })")).toBeLessThan(
source.indexOf("await requireSuperUserOrAdmin({ request, params })")
);
});
test("requires tenant admin access before parsing mutating workflow forms", () => {
for (const relativePath of workflowMutationHandlers) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source.indexOf("await requireWorkflowMutationAccess({ request, params, tenantId })"), relativePath).toBeGreaterThanOrEqual(0);
expect(source.indexOf("await requireWorkflowMutationAccess({ request, params, tenantId })"), relativePath).toBeLessThan(
source.indexOf("await request.formData()")
);
}
});
test("requires tenant admin access before reading workflow secrets", () => {
for (const { file, read } of workflowSecretLoaders) {
const source = fs.readFileSync(path.join(appRoot, file), "utf8");
expect(source.indexOf("await requireWorkflowMutationAccess({ request, params, tenantId })"), file).toBeGreaterThanOrEqual(0);
expect(source.indexOf("await requireWorkflowMutationAccess({ request, params, tenantId })"), file).toBeLessThan(source.indexOf(read));
}
});
test("direct workflow run actions re-check tenant-scoped workflow visibility before execution", () => {
for (const relativePath of [
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.api.server.tsx",
"modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.api.server.tsx",
]) {
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
expect(source.indexOf("const workflow = await WorkflowsService.get(params.id!, { tenantId })"), relativePath).toBeGreaterThanOrEqual(0);
expect(source.indexOf("const workflow = await WorkflowsService.get(params.id!, { tenantId })"), relativePath).toBeLessThan(
source.indexOf("WorkflowsExecutionsService.")
);
expect(source, relativePath).toContain('workflow.status !== "live"');
}
});
test("workflow API run examples use the canonical API host URL from the loader", () => {
const serverSource = fs.readFileSync(path.join(appRoot, "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.api.server.tsx"), "utf8");
const viewSource = fs.readFileSync(path.join(appRoot, "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.api.view.tsx"), "utf8");
expect(serverSource).toContain("apiRunUrl: getAbsoluteApiUrl(request, apiPath(`workflows/run/${workflow.id}`, { mode: \"host-aware\" }))");
expect(viewSource).toContain("defaultValue={data.apiRunUrl}");
expect(viewSource).not.toContain("defaultValue={`/v1/workflows/run/");
});
});
@@ -0,0 +1,614 @@
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { resolveRepoPath } from "~/utils/testing/repoPaths";
const repoRoot = resolveRepoPath(".");
const appRoot = resolveRepoPath("apps/web/app");
const repoSourceRoots = ["apps", "packages", "scripts"].map((directory) => resolveRepoPath(directory));
const allowedExecuteFiles = new Set(["apps/web/app/routes/internal/api/__workflows/workflows.trigger.$id.tsx"]);
const allowedExecuteExistingFiles = new Set(["apps/web/app/routes/internal/api/__workflows/workflows.executions.$id.start.tsx"]);
const allowedStreamFiles = new Set(["apps/web/app/modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.api.server.tsx"]);
const allowedContinueFiles = new Set(["apps/web/app/routes/internal/api/__workflows/workflows.executions.$id.continue.start.tsx"]);
const allowedWorkflowDefinitionWriteFiles = new Set([
"apps/web/app/modules/workflowEngine/db/workflows.db.server.ts",
"apps/web/app/modules/workflowEngine/routes/workflow-engine/danger.api.server.tsx",
"apps/web/app/modules/workflowEngine/services/WorkflowsService.ts",
"apps/web/app/modules/workflowEngine/services/WorkflowsTemplatesService.ts",
]);
const allowedWorkflowExecutionCreateFiles = new Set([
"apps/web/app/modules/workflowEngine/services/WorkflowsExecutionsService.ts",
"apps/worker/src/jobs/workflow-trigger-native-executor.ts",
"scripts/seed-e2e-readiness.ts",
]);
const sourceCache = new Map<string, string>();
function walkSourceFiles(directory: string): string[] {
const entries = fs.readdirSync(directory, { withFileTypes: true });
return entries.flatMap((entry) => {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (["node_modules", "build", "dist", ".react-router", "generated", "coverage", ".turbo"].includes(entry.name)) {
return [];
}
return walkSourceFiles(absolute);
}
return /\.(?:ts|tsx)$/u.test(entry.name) && !entry.name.includes(".test.") && !entry.name.includes(".spec.") ? [absolute] : [];
});
}
function relativeToRepo(file: string): string {
return path.relative(repoRoot, file).split(path.sep).join("/");
}
function readSource(file: string): string {
const cached = sourceCache.get(file);
if (cached !== undefined) {
return cached;
}
const source = fs.readFileSync(file, "utf8");
sourceCache.set(file, source);
return source;
}
const repoSourceFiles = repoSourceRoots.flatMap((root) => walkSourceFiles(root));
function getRepoSourceFiles(): string[] {
return repoSourceFiles;
}
function hasRawWorkflowExecutionCreate(source: string): boolean {
const sourceFile = ts.createSourceFile("workflow-execution-create-inventory-source.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
function isWorkflowExecutionAccess(node: ts.Node): boolean {
if (ts.isPropertyAccessExpression(node) || ts.isPropertyAccessChain(node)) {
return node.name.text === "workflowExecution";
}
if (ts.isElementAccessExpression(node) || ts.isElementAccessChain(node)) {
const argument = node.argumentExpression;
return argument !== undefined && ts.isStringLiteralLike(argument) && argument.text === "workflowExecution";
}
return false;
}
function hasCreateCall(node: ts.Node): boolean {
if (ts.isCallExpression(node)) {
const expression = node.expression;
if (ts.isPropertyAccessExpression(expression) || ts.isPropertyAccessChain(expression)) {
return ["create", "createMany", "upsert"].includes(expression.name.text) && isWorkflowExecutionAccess(expression.expression);
}
if (ts.isElementAccessExpression(expression) || ts.isElementAccessChain(expression)) {
const argument = expression.argumentExpression;
return (
argument !== undefined &&
ts.isStringLiteralLike(argument) &&
["create", "createMany", "upsert"].includes(argument.text) &&
isWorkflowExecutionAccess(expression.expression)
);
}
}
return ts.forEachChild(node, hasCreateCall) === true;
}
return hasCreateCall(sourceFile);
}
function isWorkflowsExecutionsServiceModule(moduleName: string): boolean {
return /(?:^|\/)WorkflowsExecutionsService$/u.test(moduleName);
}
function isLegacyWorkflowEngineModule(moduleName: string): boolean {
return /(?:^|\/)(?:WorkflowEngine|WorkflowEngineService)$/u.test(moduleName);
}
function getStaticPropertyName(name: ts.Node): string | null {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
function isIdentifierNamed(node: ts.Node, names: Set<string>): node is ts.Identifier {
return ts.isIdentifier(node) && names.has(node.text);
}
function isExecuteMemberAccess(expression: ts.Node, serviceNames: Set<string>): boolean {
if (ts.isPropertyAccessExpression(expression) || ts.isPropertyAccessChain(expression)) {
return expression.name.text === "execute" && isIdentifierNamed(expression.expression, serviceNames);
}
if (ts.isElementAccessExpression(expression) || ts.isElementAccessChain(expression)) {
const argument = expression.argumentExpression;
return argument !== undefined && ts.isStringLiteralLike(argument) && argument.text === "execute" && isIdentifierNamed(expression.expression, serviceNames);
}
return false;
}
function isTriggerMemberAccess(expression: ts.Node, engineNames: Set<string>): boolean {
if (ts.isPropertyAccessExpression(expression) || ts.isPropertyAccessChain(expression)) {
return expression.name.text === "trigger" && isIdentifierNamed(expression.expression, engineNames);
}
if (ts.isElementAccessExpression(expression) || ts.isElementAccessChain(expression)) {
const argument = expression.argumentExpression;
return argument !== undefined && ts.isStringLiteralLike(argument) && argument.text === "trigger" && isIdentifierNamed(expression.expression, engineNames);
}
return false;
}
function collectExecuteAliasesFromBindingName(bindingName: ts.BindingName, executeNames: Set<string>): void {
if (ts.isIdentifier(bindingName)) {
executeNames.add(bindingName.text);
return;
}
bindingName.elements.forEach((element) => {
if (!ts.isOmittedExpression(element)) {
collectExecuteAliasesFromBindingName(element.name, executeNames);
}
});
}
function collectTriggerAliasesFromBindingName(bindingName: ts.BindingName, triggerNames: Set<string>): void {
if (ts.isIdentifier(bindingName)) {
triggerNames.add(bindingName.text);
return;
}
bindingName.elements.forEach((element) => {
if (!ts.isOmittedExpression(element)) {
collectTriggerAliasesFromBindingName(element.name, triggerNames);
}
});
}
function collectServiceDestructureAliases(bindingName: ts.BindingName, executeNames: Set<string>): void {
if (!ts.isObjectBindingPattern(bindingName)) {
return;
}
bindingName.elements.forEach((element) => {
const propertyName = element.propertyName === undefined ? getStaticPropertyName(element.name) : getStaticPropertyName(element.propertyName);
if (propertyName === "execute") {
collectExecuteAliasesFromBindingName(element.name, executeNames);
}
});
}
function collectEngineDestructureAliases(bindingName: ts.BindingName, triggerNames: Set<string>): void {
if (!ts.isObjectBindingPattern(bindingName)) {
return;
}
bindingName.elements.forEach((element) => {
const propertyName = element.propertyName === undefined ? getStaticPropertyName(element.name) : getStaticPropertyName(element.propertyName);
if (propertyName === "trigger") {
collectTriggerAliasesFromBindingName(element.name, triggerNames);
}
});
}
function hasLegacyDirectWorkflowTrigger(source: string): boolean {
const sourceFile = ts.createSourceFile("workflow-trigger-inventory-source.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const engineNames = new Set<string>(["WorkflowEngine"]);
const triggerNames = new Set<string>();
function collectImports(node: ts.Node): void {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
isLegacyWorkflowEngineModule(node.moduleSpecifier.text) &&
node.importClause !== undefined
) {
const { name, namedBindings } = node.importClause;
if (name !== undefined) {
engineNames.add(name.text);
}
if (namedBindings !== undefined) {
if (ts.isNamespaceImport(namedBindings)) {
engineNames.add(namedBindings.name.text);
} else {
namedBindings.elements.forEach((element) => {
const importedName = element.propertyName?.text ?? element.name.text;
if (importedName === "default" || importedName === "WorkflowEngine") {
engineNames.add(element.name.text);
}
if (importedName === "trigger") {
triggerNames.add(element.name.text);
}
});
}
}
}
ts.forEachChild(node, collectImports);
}
collectImports(sourceFile);
let changed = true;
while (changed) {
changed = false;
const addEngineName = (name: string) => {
if (!engineNames.has(name)) {
engineNames.add(name);
changed = true;
}
};
const addTriggerName = (name: string) => {
if (!triggerNames.has(name)) {
triggerNames.add(name);
changed = true;
}
};
function collectAliases(node: ts.Node): void {
if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
if (ts.isIdentifier(node.name) && isIdentifierNamed(node.initializer, engineNames)) {
addEngineName(node.name.text);
}
if (isTriggerMemberAccess(node.initializer, engineNames)) {
collectTriggerAliasesFromBindingName(node.name, triggerNames);
}
if (isIdentifierNamed(node.initializer, engineNames)) {
collectEngineDestructureAliases(node.name, triggerNames);
}
}
if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isIdentifier(node.left) &&
(isTriggerMemberAccess(node.right, engineNames) || isIdentifierNamed(node.right, triggerNames))
) {
addTriggerName(node.left.text);
}
ts.forEachChild(node, collectAliases);
}
collectAliases(sourceFile);
}
function hasTriggerCall(node: ts.Node): boolean {
if (
ts.isCallExpression(node) &&
(isTriggerMemberAccess(node.expression, engineNames) || isIdentifierNamed(node.expression, triggerNames))
) {
return true;
}
return ts.forEachChild(node, hasTriggerCall) === true;
}
return hasTriggerCall(sourceFile);
}
function hasDirectWorkflowExecution(source: string): boolean {
const sourceFile = ts.createSourceFile("workflow-producer-inventory-source.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const serviceNames = new Set<string>();
const executeNames = new Set<string>();
function collectImports(node: ts.Node): void {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
isWorkflowsExecutionsServiceModule(node.moduleSpecifier.text) &&
node.importClause !== undefined
) {
const { name, namedBindings } = node.importClause;
if (name !== undefined) {
serviceNames.add(name.text);
}
if (namedBindings !== undefined) {
if (ts.isNamespaceImport(namedBindings)) {
serviceNames.add(namedBindings.name.text);
} else {
namedBindings.elements.forEach((element) => {
const importedName = element.propertyName?.text ?? element.name.text;
if (importedName === "default" || importedName === "WorkflowsExecutionsService") {
serviceNames.add(element.name.text);
}
});
}
}
}
ts.forEachChild(node, collectImports);
}
collectImports(sourceFile);
let changed = true;
while (changed) {
changed = false;
const addServiceName = (name: string) => {
if (!serviceNames.has(name)) {
serviceNames.add(name);
changed = true;
}
};
const addExecuteName = (name: string) => {
if (!executeNames.has(name)) {
executeNames.add(name);
changed = true;
}
};
function collectAliases(node: ts.Node): void {
if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
if (ts.isIdentifier(node.name) && isIdentifierNamed(node.initializer, serviceNames)) {
addServiceName(node.name.text);
}
if (isExecuteMemberAccess(node.initializer, serviceNames)) {
collectExecuteAliasesFromBindingName(node.name, executeNames);
}
if (isIdentifierNamed(node.initializer, serviceNames)) {
collectServiceDestructureAliases(node.name, executeNames);
}
}
if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isIdentifier(node.left) &&
(isExecuteMemberAccess(node.right, serviceNames) || isIdentifierNamed(node.right, executeNames))
) {
addExecuteName(node.left.text);
}
ts.forEachChild(node, collectAliases);
}
collectAliases(sourceFile);
}
function hasExecuteCall(node: ts.Node): boolean {
if (
ts.isCallExpression(node) &&
(isExecuteMemberAccess(node.expression, serviceNames) || isIdentifierNamed(node.expression, executeNames))
) {
return true;
}
return ts.forEachChild(node, hasExecuteCall) === true;
}
return hasExecuteCall(sourceFile);
}
describe("workflow producer inventory", () => {
it("keeps direct workflow execution call sites explicit and reviewed", () => {
const executeCallSites = getRepoSourceFiles()
.filter((file) => hasDirectWorkflowExecution(readSource(file)))
.map(relativeToRepo)
.sort();
expect(executeCallSites).toEqual([...allowedExecuteFiles].sort());
});
it("keeps raw workflow execution writes explicit and reviewed", () => {
const createCallSites = getRepoSourceFiles()
.filter((file) => hasRawWorkflowExecutionCreate(readSource(file)))
.map(relativeToRepo)
.sort();
expect(createCallSites).toEqual([...allowedWorkflowExecutionCreateFiles].sort());
});
it("keeps trigger fanout behind the internal workflow bridge", () => {
const triggerExecuteCallSites = getRepoSourceFiles()
.filter((file) => {
const source = readSource(file);
return hasDirectWorkflowExecution(source) && /type:\s*"trigger"/u.test(source);
})
.map(relativeToRepo)
.sort();
expect(triggerExecuteCallSites).toEqual(["apps/web/app/routes/internal/api/__workflows/workflows.trigger.$id.tsx"]);
});
it("keeps manual workflow runs behind the outbox execution request producer", () => {
const manualRunSource = readSource(path.join(appRoot, "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.api.server.tsx"));
const executionServiceSource = readSource(path.join(appRoot, "modules/workflowEngine/services/WorkflowsExecutionsService.ts"));
expect(manualRunSource).toContain("WorkflowsExecutionsService.queueManualExecution");
expect(manualRunSource).not.toContain("WorkflowsExecutionsService.execute(");
expect(executionServiceSource).toContain("buildWorkflowExecutionRequestedOutboxEvent");
expect(executionServiceSource).toContain("await publishOutboxEvent");
const queueRequestedExecutionSource = executionServiceSource.slice(
executionServiceSource.indexOf("async function queueRequestedExecution"),
executionServiceSource.indexOf("async function executeExisting")
);
expect(queueRequestedExecutionSource.indexOf("await tx.workflowExecution.create")).toBeLessThan(
queueRequestedExecutionSource.indexOf("await publishOutboxEvent")
);
});
it("keeps tenant API workflow runs behind the outbox execution request producer", () => {
const apiRunSource = readSource(path.join(appRoot, "routes/internal/api/__workflows/workflows.run.$id.tsx"));
const executionServiceSource = readSource(path.join(appRoot, "modules/workflowEngine/services/WorkflowsExecutionsService.ts"));
expect(apiRunSource).toContain("WorkflowsExecutionsService.queueApiExecution");
expect(apiRunSource).not.toContain("WorkflowsExecutionsService.execute(");
expect(executionServiceSource).toContain("queueApiExecution");
expect(executionServiceSource).toContain('type: "api"');
expect(executionServiceSource).toContain("buildWorkflowExecutionRequestedOutboxEvent");
});
it("keeps non-stream workflow continuations behind the outbox continuation request producer", () => {
const manualRunSource = readSource(path.join(appRoot, "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.api.server.tsx"));
const apiContinueSource = readSource(path.join(appRoot, "routes/internal/api/__workflows/workflows.executions.$id.continue.tsx"));
const executionServiceSource = readSource(path.join(appRoot, "modules/workflowEngine/services/WorkflowsExecutionsService.ts"));
expect(manualRunSource).toContain("WorkflowsExecutionsService.queueContinuationExecution");
expect(apiContinueSource).toContain("WorkflowsExecutionsService.queueContinuationExecution");
expect(manualRunSource).not.toContain("WorkflowsExecutionsService.continueExecution(");
expect(apiContinueSource).not.toContain("WorkflowsExecutionsService.continueExecution(");
expect(executionServiceSource).toContain("queueContinuationExecution");
expect(executionServiceSource).toContain("buildWorkflowExecutionContinueRequestedOutboxEvent");
});
it("keeps stream continuations behind the outbox continuation request producer", () => {
const streamRunSource = readSource(path.join(appRoot, "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.api.server.tsx"));
expect(streamRunSource).toContain("WorkflowsExecutionsService.queueContinuationExecution");
expect(streamRunSource).not.toContain("WorkflowsExecutionsService.continueExecution(");
});
it("keeps queued manual execution starts behind the signed internal start bridge", () => {
const executeExistingCallSites = getRepoSourceFiles()
.filter((file) => readSource(file).includes("WorkflowsExecutionsService.executeExisting("))
.map(relativeToRepo)
.sort();
expect(executeExistingCallSites).toEqual([...allowedExecuteExistingFiles].sort());
});
it("keeps stream and continuation execution call sites explicit and reviewed", () => {
const streamCallSites = getRepoSourceFiles()
.filter((file) => readSource(file).includes("WorkflowsExecutionsService.stream("))
.map(relativeToRepo)
.sort();
const continueCallSites = getRepoSourceFiles()
.filter((file) => readSource(file).includes("WorkflowsExecutionsService.continueExecution("))
.map(relativeToRepo)
.sort();
expect(streamCallSites).toEqual([...allowedStreamFiles].sort());
expect(continueCallSites).toEqual([...allowedContinueFiles].sort());
});
it("documents stream starts as the only non-durable workflow execution exception", () => {
const streamRunSource = readSource(path.join(appRoot, "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.api.server.tsx"));
const executionServiceSource = readSource(path.join(appRoot, "modules/workflowEngine/services/WorkflowsExecutionsService.ts"));
const outboxDoc = readSource(path.join(repoRoot, "docs/architecture/outbox-pattern.md"));
const streamStart = executionServiceSource.indexOf("async function stream");
const streamImplementationSource = executionServiceSource.slice(streamStart, executionServiceSource.indexOf("export default", streamStart));
expect(streamRunSource).toContain("WorkflowsExecutionsService.stream(");
expect(streamImplementationSource).toContain("void execute(");
expect(streamImplementationSource).not.toContain("buildWorkflowExecutionRequestedOutboxEvent");
expect(outboxDoc).toContain("Non-durable exception: stream runs");
expect(outboxDoc).toMatch(/Web restarts can\s+interrupt an in-flight stream run/u);
});
it("publishes application events through the workflow outbox producer", () => {
const eventsServiceSource = readSource(path.join(appRoot, "modules/events/services/.server/EventsService.ts"));
const rowsApiSource = readSource(path.join(appRoot, "utils/api/.server/RowsApi.ts"));
const rowsEditSource = readSource(path.join(appRoot, "modules/rows/routes/Rows_Edit.server.ts"));
expect(eventsServiceSource).toContain("buildWorkflowTriggerOutboxEvent");
expect(eventsServiceSource).toContain("sourceId: item.id");
expect(eventsServiceSource).not.toContain('type: "workflow.trigger.event"');
expect(eventsServiceSource.indexOf("const item = await tx.event.create")).toBeLessThan(
eventsServiceSource.indexOf("await publishOutboxEvent(tx, buildWorkflowTriggerOutboxEvent")
);
expect(rowsApiSource).toContain("EventsService.createInTransaction");
expect(rowsApiSource).toContain("EventsService.dispatchPostCommitSideEffects");
expect(rowsApiSource.indexOf("EventsService.createInTransaction")).toBeLessThan(rowsApiSource.indexOf("EventsService.dispatchPostCommitSideEffects"));
expect(rowsApiSource).toContain('event: "row.created"');
expect(rowsApiSource).toContain('event: "row.updated"');
expect(rowsApiSource).toContain('event: "row.deleted"');
expect(rowsEditSource).not.toContain('event: "row.updated"');
expect(rowsEditSource).not.toContain('event: "row.deleted"');
});
it("keeps the workflow outbox documentation honest about row producer coverage", () => {
const outboxDoc = readSource(path.join(repoRoot, "docs/architecture/outbox-pattern.md"));
const workerQueuesDoc = readSource(path.join(repoRoot, "docs/ops/worker-queues.md"));
const rowsApiSource = readSource(path.join(repoRoot, "apps/web/app/utils/api/.server/RowsApi.ts"));
const workflowBlockSource = readSource(path.join(repoRoot, "apps/web/app/modules/workflowEngine/services/blocks/WorkflowBlockProService.ts"));
expect(outboxDoc).toContain("row.created");
expect(outboxDoc).toContain("row.updated");
expect(outboxDoc).toContain("row.deleted");
expect(outboxDoc).toMatch(/`row\.created`, `row\.updated`, and `row\.deleted`/u);
expect(outboxDoc).toMatch(/in the same transaction/u);
expect(outboxDoc).toContain("Workflow row blocks pass a `workflow` event context");
expect(workerQueuesDoc).toContain("only the signed internal trigger bridge may");
expect(workerQueuesDoc).toMatch(/`row\.created`, `row\.updated`,\s+and `row\.deleted` route-backed and workflow-context producers/u);
expect(rowsApiSource).toContain("type RowEventContext =");
expect(rowsApiSource).toContain('source: "request" | "workflow"');
expect(rowsApiSource).toContain("shouldCreateRowEvent(options, request, eventContext)");
expect(workflowBlockSource.match(/eventContext: \{ source: "workflow" \}/gu)).toHaveLength(3);
});
it("does not reintroduce the legacy direct trigger facade", () => {
const directTriggerRefs = getRepoSourceFiles()
.filter((file) => hasLegacyDirectWorkflowTrigger(readSource(file)))
.map(relativeToRepo);
expect(directTriggerRefs).toEqual([]);
});
it("detects common spellings of the legacy direct trigger facade", () => {
expect(hasLegacyDirectWorkflowTrigger("await WorkflowEngine.trigger(input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("await WorkflowEngine . trigger (input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("await WorkflowEngine?.trigger(input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("await WorkflowEngine['trigger'](input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger('await WorkflowEngine["trigger"](input)')).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("const Engine = WorkflowEngine; await Engine.trigger(input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("const fire = WorkflowEngine.trigger; await fire(input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger('const fire = WorkflowEngine["trigger"]; await fire(input)')).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("const { trigger: fire } = WorkflowEngine; await fire(input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("import { WorkflowEngine as Engine } from './WorkflowEngine'; await Engine.trigger(input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("import { trigger as fire } from './WorkflowEngine'; await fire(input)")).toBe(true);
expect(hasLegacyDirectWorkflowTrigger("await WorkflowEngine.queueTrigger(input)")).toBe(false);
expect(hasLegacyDirectWorkflowTrigger("const trigger = () => {}; trigger(input)")).toBe(false);
});
it("detects aliased direct workflow execution service calls", () => {
expect(
hasDirectWorkflowExecution(`
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
await WorkflowsExecutionsService.execute("workflow", {});
`)
).toBe(true);
expect(
hasDirectWorkflowExecution(`
import WorkflowExecutions from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
const service = WorkflowExecutions;
await service["execute"]("workflow", {});
`)
).toBe(true);
expect(
hasDirectWorkflowExecution(`
import WorkflowExecutions from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
const { execute: runWorkflow } = WorkflowExecutions;
await runWorkflow("workflow", {});
`)
).toBe(true);
expect(
hasDirectWorkflowExecution(`
import WorkflowExecutions from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
const runWorkflow = WorkflowExecutions["execute"];
await runWorkflow("workflow", {});
`)
).toBe(true);
expect(
hasDirectWorkflowExecution(`
import WorkflowExecutions from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
await WorkflowExecutions.queueManualExecution("workflow", {});
`)
).toBe(false);
});
it("keeps direct workflow definition writes explicit and reviewed for hook coverage", () => {
const definitionWritePattern =
/\b(?:(?:db|client|tx)\.workflow\.(?:create|update|delete|deleteMany|upsert)|createWorkflow|updateWorkflow|deleteWorkflow)\b/u;
const definitionWriteFiles = walkSourceFiles(appRoot)
.filter((file) => definitionWritePattern.test(readSource(file)))
.map(relativeToRepo)
.sort();
expect(definitionWriteFiles).toEqual([...allowedWorkflowDefinitionWriteFiles].sort());
});
});
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveRepoPath } from "~/utils/testing/repoPaths";
import { formatWorkflowBuilderActionError, formatWorkflowRunActionError, workflowActionText } from "./workflowRunActionErrors.server";
function t(key: string, options?: Record<string, unknown>) {
const translations: Record<string, string> = {
"workflows.errors.missingRequiredInputOnBlock": "Missing required input on block {{blockId}}: {{name}}",
"workflows.errors.missingWorkflowCredential": "Missing workflow credential: {{name}}",
"workflows.errors.missingWorkflowVariable": "Missing workflow variable: {{name}}",
"workflows.errors.invalidManualValidationSchema": "Invalid manual trigger validation schema on block {{blockId}}: {{error}}",
"workflows.errors.nativeCapabilityUnsupported": "Worker-native execution does not support this workflow yet: {{error}}",
"workflows.errors.preflightFailed": "Workflow preflight failed: {{errors}}",
"workflows.errors.workflowContinueFailed": "Workflow continuation failed. Check the execution input and try again.",
"workflows.errors.workflowRunFailed": "Workflow run failed. Check the workflow setup and try again.",
"workflows.errors.inputDataInvalidJson": "Input data is not valid JSON",
"workflows.errors.cannotDeleteWorkflow": "You cannot delete this workflow.",
"workflows.errors.invalidBlockType": "Invalid workflow block type: {{type}}",
"workflows.errors.workflowBuilderActionFailed": "Workflow builder action failed. Check your changes and try again.",
"workflows.errors.workflowBuilderInvalidPayload": "Workflow builder payload is not valid JSON.",
};
const template = translations[key] ?? String(options?.defaultValue ?? key);
return template.replace(/\{\{(\w+)\}\}/gu, (_, name: string) => String(options?.[name] ?? ""));
}
const routeFiles = [
"__workflow/workflows.$id.run.manual.api.server.tsx",
"__workflow/workflows.$id.run.stream.api.server.tsx",
"__workflow/workflows.$id.run.api.server.tsx",
];
describe("workflow run action errors", () => {
it("rebuilds visible preflight messages from request translations", () => {
const preflightError = Object.assign(new Error("Workflow preflight failed: secret raw server locale"), {
preflight: {
missingVariables: ["recipient"],
missingCredentials: ["mail"],
missingRequiredInputs: [{ blockId: "email", blockType: "email", missingInputs: ["body"] }],
invalidValidationSchemas: [{ blockId: "manual", error: "Expected property name" }],
nativeCapabilityErrors: ['unsupported credential reference "mail" on block email'],
},
});
expect(formatWorkflowRunActionError(t, preflightError, "run")).toBe(
'Workflow preflight failed: Missing workflow variable: recipient; Missing workflow credential: mail; Missing required input on block email: body; Invalid manual trigger validation schema on block manual: Expected property name; Worker-native execution does not support this workflow yet: unsupported credential reference "mail" on block email'
);
});
it("maps unknown run and continuation errors to generic translated messages", () => {
expect(formatWorkflowRunActionError(t, new Error("secret sk_live_123"), "run")).toBe(
"Workflow run failed. Check the workflow setup and try again."
);
expect(formatWorkflowRunActionError(t, new Error("secret sk_live_123"), "continue")).toBe(
"Workflow continuation failed. Check the execution input and try again."
);
});
it("keeps explicit validation messages translated", () => {
expect(workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON")).toBe("Input data is not valid JSON");
});
it("does not return raw caught error messages from visible workflow run actions", () => {
const root = resolveRepoPath("apps/web/app/modules/workflowEngine/routes/workflow-engine");
for (const routeFile of routeFiles) {
const source = fs.readFileSync(path.join(root, routeFile), "utf8");
expect(source, routeFile).not.toContain("return Response.json({ error: e.message }");
expect(source, routeFile).toContain("formatWorkflowRunActionError(t, e");
}
});
});
describe("workflow builder action errors", () => {
it("maps known service errors and invalid JSON to translated messages", () => {
expect(formatWorkflowBuilderActionError(t, new Error("You cannot delete this workflow"))).toBe("You cannot delete this workflow.");
expect(formatWorkflowBuilderActionError(t, new Error("Invalid workflow block type: webhook"))).toBe("Invalid workflow block type: webhook");
expect(formatWorkflowBuilderActionError(t, new SyntaxError("Unexpected token s in JSON at position 0"))).toBe(
"Workflow builder payload is not valid JSON."
);
});
it("maps unknown builder errors to a generic translated message", () => {
expect(formatWorkflowBuilderActionError(t, new Error("secret database DSN"))).toBe(
"Workflow builder action failed. Check your changes and try again."
);
});
it("does not return raw caught error messages from visible workflow builder actions", () => {
const root = resolveRepoPath("apps/web/app/modules/workflowEngine/routes/workflow-engine");
const source = fs.readFileSync(path.join(root, "__workflow/workflows.$id.index.api.server.tsx"), "utf8");
expect(source).not.toContain("return Response.json({ error: e.message }");
expect(source).toContain("formatWorkflowBuilderActionError(t, e");
});
});
@@ -0,0 +1,22 @@
import type { Params } from "react-router";
import { featureKeys } from "@toolctrl/entitlements";
import { requireTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
import { requireSuperAdmin, requireSuperUserOrAdmin } from "~/utils/loaders.middleware";
export async function requireWorkflowMutationAccess({
request,
params,
tenantId,
}: {
request: Request;
params: Params;
tenantId: string | null;
}) {
if (!tenantId) {
await requireSuperAdmin({ request });
return;
}
await requireTenantProductAccess({ request, params, featureKey: featureKeys.workflowEngine });
await requireSuperUserOrAdmin({ request, params });
}
@@ -0,0 +1,175 @@
import type { TFunction } from "i18next";
import type { WorkflowPreflightResult } from "../../services/WorkflowPreflightService";
export function workflowActionText(t: TFunction, key: string, fallback: string, values?: Record<string, unknown>) {
const translated = t(key, { defaultValue: fallback, ...(values ?? {}) });
return translated === key ? fallback : translated;
}
function isPreflightError(error: unknown): error is Error & { preflight: Partial<WorkflowPreflightResult> } {
return error instanceof Error && "preflight" in error;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}
function isValidationSchemaIssueArray(value: unknown): value is Array<{ blockId: string; error: string }> {
return Array.isArray(value) && value.every((entry) => entry && typeof entry === "object" && typeof entry.blockId === "string" && typeof entry.error === "string");
}
function formatPreflightError(t: TFunction, preflight: Partial<WorkflowPreflightResult>) {
const errors = [
...(isStringArray(preflight.missingVariables)
? preflight.missingVariables.map((name) => workflowActionText(t, "workflows.errors.missingWorkflowVariable", "Missing workflow variable: {{name}}", { name }))
: []),
...(isStringArray(preflight.missingCredentials)
? preflight.missingCredentials.map((name) =>
workflowActionText(t, "workflows.errors.missingWorkflowCredential", "Missing workflow credential: {{name}}", { name })
)
: []),
...(Array.isArray(preflight.missingRequiredInputs)
? preflight.missingRequiredInputs.flatMap((issue) =>
issue.missingInputs.map((name) =>
workflowActionText(t, "workflows.errors.missingRequiredInputOnBlock", "Missing required input on block {{blockId}}: {{name}}", {
blockId: issue.blockId,
name,
})
)
)
: []),
...(isValidationSchemaIssueArray(preflight.invalidValidationSchemas)
? preflight.invalidValidationSchemas.map((issue) =>
workflowActionText(
t,
"workflows.errors.invalidManualValidationSchema",
"Invalid manual trigger validation schema on block {{blockId}}: {{error}}",
{
blockId: issue.blockId,
error: issue.error,
}
)
)
: []),
...(isStringArray(preflight.nativeCapabilityErrors)
? preflight.nativeCapabilityErrors.map((error) =>
workflowActionText(t, "workflows.errors.nativeCapabilityUnsupported", "Worker-native execution does not support this workflow yet: {{error}}", {
error,
})
)
: []),
];
const fallbackErrors = isStringArray(preflight.errors) ? preflight.errors : [];
const visibleErrors = errors.length > 0 ? errors : fallbackErrors;
return workflowActionText(t, "workflows.errors.preflightFailed", "Workflow preflight failed: {{errors}}", {
errors: visibleErrors.join("; "),
});
}
export function formatWorkflowRunActionError(t: TFunction, error: unknown, mode: "run" | "continue") {
if (isPreflightError(error)) {
return formatPreflightError(t, error.preflight);
}
if (mode === "continue") {
return workflowActionText(
t,
"workflows.errors.workflowContinueFailed",
"Workflow continuation failed. Check the execution input and try again."
);
}
return workflowActionText(
t,
"workflows.errors.workflowRunFailed",
"Workflow run failed. Check the workflow setup and try again."
);
}
type WorkflowBuilderErrorMatch = {
match: RegExp | string;
key: string;
fallback: string;
params?: (match: RegExpMatchArray) => Record<string, unknown>;
};
const workflowBuilderErrorMatches: WorkflowBuilderErrorMatch[] = [
{
match: "You cannot edit this workflow",
key: "workflows.errors.cannotEditWorkflow",
fallback: "You cannot edit this workflow.",
},
{
match: "You cannot delete this workflow",
key: "workflows.errors.cannotDeleteWorkflow",
fallback: "You cannot delete this workflow.",
},
{
match: "Too many untitled workflows",
key: "workflows.errors.tooManyUntitledWorkflows",
fallback: "Too many untitled workflows.",
},
{
match: "Workflow not found",
key: "workflows.errors.workflowNotFound",
fallback: "Workflow not found",
},
{
match: "Workflow block not found",
key: "workflows.errors.blockNotFound",
fallback: "Workflow block not found",
},
{
match: /^Invalid workflow block type: (.+)$/u,
key: "workflows.errors.invalidBlockType",
fallback: "Invalid workflow block type: {{type}}",
params: ([, type]) => ({ type }),
},
{
match: /^Workflow block (.+) not found\.?$/u,
key: "workflows.errors.workflowBlockNotFoundWithId",
fallback: "Workflow block {{id}} not found.",
params: ([, id]) => ({ id }),
},
{
match: /^Workflow connection (.+) not found\.?$/u,
key: "workflows.errors.workflowConnectionNotFoundWithId",
fallback: "Workflow connection {{id}} not found.",
params: ([, id]) => ({ id }),
},
{
match: /^Workflow input example (.+) not found\.?$/u,
key: "workflows.errors.workflowInputExampleNotFoundWithId",
fallback: "Workflow input example {{id}} not found.",
params: ([, id]) => ({ id }),
},
];
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "";
}
export function formatWorkflowBuilderActionError(t: TFunction, error: unknown) {
if (error instanceof SyntaxError) {
return workflowActionText(t, "workflows.errors.workflowBuilderInvalidPayload", "Workflow builder payload is not valid JSON.");
}
const message = getErrorMessage(error);
for (const candidate of workflowBuilderErrorMatches) {
if (typeof candidate.match === "string") {
if (message === candidate.match || message === `${candidate.match}.`) {
return workflowActionText(t, candidate.key, candidate.fallback);
}
continue;
}
const match = message.match(candidate.match);
if (match) {
return workflowActionText(t, candidate.key, candidate.fallback, candidate.params?.(match));
}
}
return workflowActionText(
t,
"workflows.errors.workflowBuilderActionFailed",
"Workflow builder action failed. Check your changes and try again."
);
}
@@ -0,0 +1,93 @@
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(),
getWorkflows: vi.fn(),
getVariables: vi.fn(),
getTemplate: vi.fn(),
}));
vi.mock("~/utils/loaders.middleware", () => ({
requireAuth: mocks.requireAuth,
}));
vi.mock("~/utils/services/.server/urlService", () => ({
getTenantIdOrNull: mocks.getTenantIdOrNull,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
default: {
getAll: mocks.getWorkflows,
},
}));
vi.mock("~/modules/workflowEngine/db/workflowVariable.db.server", () => ({
getAllWorkflowVariables: mocks.getVariables,
}));
vi.mock("~/modules/workflowEngine/services/WorkflowsTemplatesService", () => ({
default: {
getTemplate: mocks.getTemplate,
},
}));
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
}));
import { WorkflowsIndexApi } from "./workflows.index.api.server";
describe("WorkflowsIndexApi.loader", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue(undefined);
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
mocks.getTemplate.mockResolvedValue({ blocks: [] });
});
it("loads workflows and variables concurrently before building the template", async () => {
const workflows = deferred<Array<{ id: string }>>();
const variables = deferred<Array<{ id: string }>>();
mocks.getWorkflows.mockReturnValue(workflows.promise);
mocks.getVariables.mockReturnValue(variables.promise);
const loaderPromise = WorkflowsIndexApi.loader({
request: new Request("https://app.example.test/acme/workflow-engine/workflows"),
params: { tenant: "acme" },
context: {},
});
await Promise.resolve();
await Promise.resolve();
expect(mocks.getWorkflows).toHaveBeenCalledWith({ tenantId: "tenant_1" });
expect(mocks.getVariables).toHaveBeenCalledWith({ tenantId: "tenant_1" });
expect(mocks.getTemplate).not.toHaveBeenCalled();
const workflowRows = [{ id: "workflow_1" }];
const variableRows = [{ id: "variable_1" }];
workflows.resolve(workflowRows);
variables.resolve(variableRows);
await expect(loaderPromise).resolves.toMatchObject({
items: workflowRows,
template: { blocks: [] },
});
expect(mocks.getTemplate).toHaveBeenCalledWith(workflowRows, variableRows, "Workflows");
});
});
@@ -0,0 +1,81 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
import { WorkflowsTemplateDto } from "~/modules/workflowEngine/dtos/WorkflowsTemplateDto";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import WorkflowEngineTemplatesService from "~/modules/workflowEngine/services/WorkflowsTemplatesService";
import { getWorkflowById } from "~/modules/workflowEngine/db/workflows.db.server";
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import UrlUtils from "~/utils/app/UrlUtils";
import { getAllWorkflowVariables } from "~/modules/workflowEngine/db/workflowVariable.db.server";
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 WorkflowsIndexApi {
export type LoaderData = {
metatags: MetaTagsDto;
items: WorkflowDto[];
template: WorkflowsTemplateDto;
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireAuth({ request, params });
const tenantId = await getTenantIdOrNull({ request, params });
const [items, variables] = await Promise.all([WorkflowsService.getAll({ tenantId }), getAllWorkflowVariables({ tenantId })]);
const { t } = await getTranslations(request);
const data: LoaderData = {
metatags: [{ title: i18nT("workflows.meta.index", { value0: process.env.APP_NAME }) }],
items,
template: await WorkflowEngineTemplatesService.getTemplate(items, variables, t("workflows.title")),
};
return data;
};
export type ActionData = {
success?: string;
error?: 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 form = await request.formData();
const action = form.get("action")?.toString();
if (action === "toggle-status") {
const id = form.get("id")?.toString() ?? "";
const item = await getWorkflowById({ id, tenantId });
if (!item) {
return Response.json({ error: t("workflows.errors.workflowNotFound") }, { status: 404 });
}
if (item.status === "draft") {
await WorkflowsService.update(item.id, { status: "live" }, { tenantId });
return Response.json({ success: t("workflows.responses.workflowLive") });
} else {
await WorkflowsService.update(
item.id,
{
status: "draft",
},
{ tenantId }
);
return Response.json({ success: t("workflows.responses.workflowDraft") });
}
} else if (action === "create") {
const { id } = await WorkflowsService.create({ tenantId, userId });
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows/${id}`));
} else if (action === "delete") {
const id = form.get("id")?.toString() ?? "";
await WorkflowsService.del(id, { tenantId });
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
} else {
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
}
};
}
@@ -0,0 +1,235 @@
import { Link, Outlet, useActionData, useLoaderData, useParams, useSearchParams, useSubmit } from "react-router";
import { t as i18nT } from "i18next";
import TabsWithIcons from "~/components/ui/tabs/TabsWithIcons";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import PlusIcon from "~/components/ui/icons/PlusIcon";
import TableSimple from "~/components/ui/tables/TableSimple";
import InputCheckbox from "~/components/ui/input/InputCheckbox";
import DateCell from "~/components/ui/dates/DateCell";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import { useEffect, useMemo, useRef } from "react";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
import { WorkflowsIndexApi } from "./workflows.index.api.server";
import UrlUtils from "~/utils/app/UrlUtils";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import TenantBadge from "~/components/core/tenants/TenantBadge";
import { appendCSRFToken } from "~/utils/csrf";
import { useLocaleFormatters } from "~/locale/format";
export default function WorkflowsIndexView() {
const { t } = useTranslation();
const params = useParams();
const data = useLoaderData<WorkflowsIndexApi.LoaderData>();
const actionData = useActionData<WorkflowsIndexApi.ActionData>();
const submit = useSubmit();
const formatters = useLocaleFormatters();
const confirmDelete = useRef<RefConfirmModal>(null);
const [searchParams] = useSearchParams();
const activeStatus = searchParams.get("status");
const statusCounts = useMemo(() => {
return data.items.reduce(
(counts, item) => {
counts.all += 1;
counts[item.status] += 1;
return counts;
},
{ all: 0, live: 0, draft: 0, archived: 0 } satisfies Record<"all" | "live" | "draft" | "archived", number>
);
}, [data.items]);
const visibleItems = useMemo(() => {
if (activeStatus === "live" || activeStatus === "draft" || activeStatus === "archived") {
return data.items.filter((item) => item.status === activeStatus);
}
return data.items;
}, [activeStatus, data.items]);
useEffect(() => {
if (actionData?.success) {
toast.success(actionData.success, { position: "bottom-right" });
} else if (actionData?.error) {
toast.error(actionData.error, { position: "bottom-right" });
}
}, [actionData]);
function onToggle(item: WorkflowDto, type: string, enabled: boolean) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", type);
form.set("enabled", enabled ? "true" : "false");
form.set("id", item.id.toString() ?? "");
submit(form, {
method: "post",
});
}
function onNew() {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "create");
submit(form, {
method: "post",
});
}
function onDelete(item: WorkflowDto) {
confirmDelete.current?.setValue(item);
confirmDelete.current?.show(
t("workflows.list.confirmDelete.title"),
t("shared.delete"),
t("shared.no"),
t("workflows.list.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",
});
}
function onExport() {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data.template, null, "\t"));
const downloadAnchorNode = document.createElement("a");
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "workflows.json");
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
function tabName(label: string, count: number) {
return count ? `${label} (${formatters.number(count, { maximumFractionDigits: 0 })})` : label;
}
return (
<div className="mx-auto mb-12 max-w-5xl space-y-5 px-4 py-4 sm:px-6 lg:px-8 xl:max-w-7xl">
{/* Programmatic page heading: the tab bar is the only visible title, so an
sr-only h1 gives the overview an accessible heading without changing layout. */}
<h1 className="sr-only">{t("workflows.title")}</h1>
<div className="flex items-center justify-between space-x-2">
<div className="grow">
<TabsWithIcons
tabs={[
{
name: tabName(t("workflows.status.all"), statusCounts.all),
href: "?",
current: !activeStatus || activeStatus === "all",
},
{
name: tabName(t("workflows.status.live"), statusCounts.live),
href: "?status=live",
current: activeStatus === "live",
},
{
name: tabName(t("workflows.status.draft"), statusCounts.draft),
href: "?status=draft",
current: activeStatus === "draft",
},
{
name: tabName(t("workflows.status.archived"), statusCounts.archived),
href: "?status=archived",
current: activeStatus === "archived",
},
]}
/>
</div>
<div className="flex space-x-1">
<ButtonSecondary to={UrlUtils.getModulePath(params, `workflow-engine/templates`)}>{i18nT("workflows.templates.title")}</ButtonSecondary>
<ButtonSecondary onClick={onExport} disabled={data.items.length === 0}>
{t("shared.export")}
</ButtonSecondary>
<ButtonPrimary onClick={onNew}>
<div>{t("shared.new")}</div>
<PlusIcon className="h-5 w-5" />
</ButtonPrimary>
</div>
</div>
<TableSimple
items={visibleItems}
actions={[
{
title: t("shared.edit"),
onClickRoute: (_, i) => `${i.id}`,
},
{
title: t("shared.delete"),
onClick: (_, i) => onDelete(i),
destructive: true,
},
]}
headers={[
{
name: "status",
title: t("shared.status"),
value: (i) => (
<InputCheckbox
asToggle
withLabel={false}
title={t("workflows.list.toggleStatus", { name: i.name })}
value={i.status === "live"}
setValue={(checked) => onToggle(i, "toggle-status", Boolean(checked))}
/>
),
},
{
name: "tenant",
title: t("models.tenant.object"),
value: (i) => (i.tenant ? <TenantBadge item={i.tenant} /> : <span className="text-muted-foreground italic">{t("shared.admin")}</span>),
hidden: !!params.tenant,
},
{
name: "title",
title: t("shared.title"),
className: "w-full",
value: (i) => (
<div className="flex flex-col">
<div className="flex flex-col">
<Link to={`${i.id}`} className="text-base font-bold hover:underline">
{i.name} {i.appliesToAllTenants && !params.tenant && <span className="text-xs text-blue-500">({t("workflows.blocks.node.allAccounts")}) </span>}
</Link>
<div className="text-muted-foreground text-xs">{i.description || t("workflows.blocks.node.noDescription")}</div>
</div>
</div>
),
},
{
name: "blocks",
title: t("workflows.list.columns.blocks"),
value: (i) => (
<Link to={`${i.id}`} className="text-muted-foreground hover:text-foreground text-sm underline">
{formatters.number(i.blocks.length, { maximumFractionDigits: 0 })}
</Link>
),
},
{
name: "executions",
title: i18nT("workflows.executions.title"),
value: (i) => (
<Link to={`${i.id}/executions`} className="text-muted-foreground hover:text-foreground text-sm underline">
{formatters.number(i._count.executions, { maximumFractionDigits: 0 })}
</Link>
),
},
{
name: "createdAt",
title: i18nT("shared.createdAt"),
value: (i) => <DateCell date={i.createdAt ?? null} />,
},
]}
noRecords={
<div className="p-12 text-center">
<h3 className="text-foreground mt-1 text-sm font-medium">{t("workflows.list.empty.title")}</h3>
<p className="text-muted-foreground mt-1 text-sm">{t("workflows.list.empty.description")}</p>
</div>
}
/>
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
<Outlet />
</div>
);
}