Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+21
@@ -0,0 +1,21 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { featureKeys } from "@toolctrl/entitlements";
|
||||
import { requireTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
|
||||
|
||||
export namespace WorkflowEngineApi {
|
||||
type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
if (params.tenant) {
|
||||
await requireTenantProductAccess({ request, params, featureKey: featureKeys.workflowEngine });
|
||||
}
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.index", { value0: process.env.APP_NAME }) }],
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import IncreaseIcon from "~/components/ui/icons/crm/IncreaseIcon";
|
||||
import IncreaseIconFilled from "~/components/ui/icons/crm/IncreaseIconFilled";
|
||||
import WorkflowCredentialIcon from "~/modules/workflowEngine/components/icons/WorkflowCredentialIcon";
|
||||
import WorkflowCredentialIconFilled from "~/modules/workflowEngine/components/icons/WorkflowCredentialIconFilled";
|
||||
import WorkflowExecutionIcon from "~/modules/workflowEngine/components/icons/WorkflowExecutionIcon";
|
||||
import WorkflowExecutionIconFilled from "~/modules/workflowEngine/components/icons/WorkflowExecutionIconFilled";
|
||||
import WorkflowIcon from "~/modules/workflowEngine/components/icons/WorkflowIcon";
|
||||
import WorkflowIconFilled from "~/modules/workflowEngine/components/icons/WorkflowIconFilled";
|
||||
import WorkflowVariableIcon from "~/modules/workflowEngine/components/icons/WorkflowVariableIcon";
|
||||
import WorkflowVariableIconFilled from "~/modules/workflowEngine/components/icons/WorkflowVariableIconFilled";
|
||||
import SidebarIconsLayout from "~/components/ui/layouts/SidebarIconsLayout";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import TemplateIcon from "~/components/ui/icons/entities/TemplateIcon";
|
||||
import TemplateIconFilled from "~/components/ui/icons/entities/TemplateIconFilled";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangleIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function WorkflowEngineView() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
return (
|
||||
<SidebarIconsLayout
|
||||
label={{ align: "right" }}
|
||||
items={[
|
||||
{
|
||||
name: t("shared.overview"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine`),
|
||||
exact: true,
|
||||
icon: <IncreaseIcon className="h-5 w-5" />,
|
||||
iconSelected: <IncreaseIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/workflows`),
|
||||
icon: <WorkflowIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.variables.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/variables`),
|
||||
icon: <WorkflowVariableIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowVariableIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.credentials.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/credentials`),
|
||||
icon: <WorkflowCredentialIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowCredentialIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.executions.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/executions`),
|
||||
icon: <WorkflowExecutionIcon className="h-5 w-5" />,
|
||||
iconSelected: <WorkflowExecutionIconFilled className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: t("workflows.templates.title"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/templates`),
|
||||
icon: <TemplateIcon className="h-5 w-5" />,
|
||||
iconSelected: <TemplateIconFilled className="h-5 w-5" />,
|
||||
bottom: true,
|
||||
},
|
||||
{
|
||||
name: t("workflows.nav.danger"),
|
||||
href: UrlUtils.getModulePath(params, `workflow-engine/danger`),
|
||||
bottom: true,
|
||||
icon: <AlertTriangleIcon className="h-5 w-5" />,
|
||||
iconSelected: <AlertTriangleIcon className="h-5 w-5" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Outlet />
|
||||
</SidebarIconsLayout>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export namespace WorkflowsIdExecutionsApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
item: WorkflowDto;
|
||||
executions: WorkflowExecutionDto[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const item = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!item) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const executions = await WorkflowsService.getExecutions(item, { tenantId });
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.executions", { value0: item.name, value1: webConfig.appName }) }],
|
||||
item,
|
||||
executions,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { Link, useLoaderData, useParams, useSearchParams } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
|
||||
import WorkflowExecutionsSidebar from "~/modules/workflowEngine/components/workflows/WorkflowExecutionsSidebar";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdExecutionsApi } from "./workflows.$id.executions.api.server";
|
||||
|
||||
export default function WorkflowsIdExecutionsView() {
|
||||
const data = useLoaderData<WorkflowsIdExecutionsApi.LoaderData>();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [workflow] = useState<WorkflowDto>(data.item);
|
||||
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecutionDto | null>(null);
|
||||
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const executionId = searchParams.get("executionId");
|
||||
if (executionId) {
|
||||
const execution = data.executions.find((x) => x.id === executionId);
|
||||
if (execution) {
|
||||
setSelectedExecution(execution);
|
||||
}
|
||||
} else {
|
||||
setSelectedExecution(null);
|
||||
}
|
||||
}, [data.executions, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
|
||||
</div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${workflow.id}`)}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToEditor")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={workflow} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
|
||||
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
|
||||
height. On mobile the outer flex container only has min-height, so a
|
||||
flex-1 child stays indefinite and the canvas renders hidden/collapsed.
|
||||
Use a fixed vh height on mobile (always definite); flex sizing only on
|
||||
lg. Mirrors the builder index view. */}
|
||||
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
|
||||
<LazyWorkflowBuilder
|
||||
key={selectedExecution?.id}
|
||||
readOnly={true}
|
||||
workflow={workflow}
|
||||
selectedBlock={selectedBlock}
|
||||
workflowExecution={selectedExecution}
|
||||
onSelectedBlock={setSelectedBlock}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
|
||||
<WorkflowExecutionsSidebar
|
||||
key={selectedExecution?.id}
|
||||
workflow={workflow}
|
||||
selectedBlock={selectedBlock}
|
||||
selectedExecution={selectedExecution}
|
||||
executions={data.executions}
|
||||
onSelectedBlock={setSelectedBlock}
|
||||
onSelectedExecution={setSelectedExecution}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
requireWorkflowMutationAccess: vi.fn(),
|
||||
validateCSRFToken: vi.fn(),
|
||||
getWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: mocks.getTranslations,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("../workflowAccess.server", () => ({
|
||||
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/session.server", () => ({
|
||||
validateCSRFToken: mocks.validateCSRFToken,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
|
||||
default: {
|
||||
get: mocks.getWorkflow,
|
||||
update: mocks.updateWorkflow,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Signup flow",
|
||||
description: "",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsIdIndexApi.action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue({ userId: "user_1" });
|
||||
mocks.getTranslations.mockResolvedValue({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
"workflows.errors.workflowBuilderActionFailed": "Workflow builder action failed. Check your changes and try again.",
|
||||
"workflows.errors.workflowBuilderInvalidPayload": "Workflow builder payload is not valid JSON.",
|
||||
};
|
||||
return translations[key] ?? String(options?.defaultValue ?? key);
|
||||
},
|
||||
});
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
|
||||
mocks.validateCSRFToken.mockResolvedValue(undefined);
|
||||
mocks.getWorkflow.mockResolvedValue(workflow());
|
||||
mocks.updateWorkflow.mockResolvedValue(workflow());
|
||||
});
|
||||
|
||||
it("neutralizes external save redirect targets and keeps the workflow tenant-scoped", async () => {
|
||||
const savedWorkflow = workflow({ name: "Updated flow" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "save");
|
||||
form.set("workflow", JSON.stringify(savedWorkflow));
|
||||
form.set("redirectTo", "https://evil.example/phish");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1?tab=builder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdIndexApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
|
||||
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
|
||||
"workflow_1",
|
||||
expect.objectContaining({ id: savedWorkflow.id, name: savedWorkflow.name }),
|
||||
expect.objectContaining({ tenantId: "tenant_1", userId: "user_1" })
|
||||
);
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/acme/workflow-engine/workflows/workflow_1?tab=builder");
|
||||
expect(response.headers.get("Location")).not.toContain("evil.example");
|
||||
});
|
||||
|
||||
it("returns a generic translated error for unknown save failures", async () => {
|
||||
mocks.updateWorkflow.mockRejectedValueOnce(new Error("database connection leaked-secret"));
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "save");
|
||||
form.set("workflow", JSON.stringify(workflow({ name: "Updated flow" })));
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdIndexApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Workflow builder action failed. Check your changes and try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a translated invalid payload error instead of the JSON parser message", async () => {
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "save");
|
||||
form.set("workflow", "{");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdIndexApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Workflow builder payload is not valid JSON.",
|
||||
});
|
||||
expect(mocks.updateWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
|
||||
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
|
||||
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT, type TFunction } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowBuilderActionError } from "../workflowRunActionErrors.server";
|
||||
|
||||
function actionText(t: TFunction, key: string, fallback: string, values?: Record<string, unknown>) {
|
||||
const translated = t(key, { defaultValue: fallback, ...(values ?? {}) });
|
||||
return translated === key ? fallback : translated;
|
||||
}
|
||||
|
||||
export namespace WorkflowsIdIndexApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
item: WorkflowDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const item = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!item) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.build", { value0: item.name, value1: webConfig.appName }) }],
|
||||
item,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const auth = await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
const item = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!item) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
if (action === "save") {
|
||||
try {
|
||||
const workflow = JSON.parse(form.get("workflow")?.toString() ?? "{}") as WorkflowDto;
|
||||
const redirectTo = form.get("redirectTo")?.toString();
|
||||
await WorkflowsService.update(params.id!, workflow, { tenantId, userId: auth.userId ?? null });
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo));
|
||||
}
|
||||
return Response.json({ success: actionText(t, "workflows.responses.workflowSaved", "Workflow saved: {{value0}}", { value0: workflow.name }) });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "update-block") {
|
||||
const id = form.get("id")?.toString();
|
||||
const block = item.blocks.find((x) => x.id === id);
|
||||
if (!block) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const blockData = JSON.parse(form.get("block")?.toString() ?? "{}") as WorkflowBlockDto;
|
||||
const redirectTo = form.get("redirectTo")?.toString();
|
||||
await WorkflowsService.updateBlock(block.id, blockData, { workflow: item, userId: auth.userId ?? null });
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo));
|
||||
}
|
||||
return Response.json({ success: actionText(t, "workflows.responses.blockUpdated", "Block updated: {{value0}}", { value0: blockData.type }) });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "add-block") {
|
||||
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
|
||||
const condition = form.get("condition")?.toString() ?? "";
|
||||
const type = form.get("type")?.toString() as WorkflowBlockType;
|
||||
if (fromBlockId) {
|
||||
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
|
||||
if (!fromBlock) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.fromBlockNotFound", "From block not found") }, { status: 404 });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const newBlock = await WorkflowsService.addBlock({
|
||||
workflow: item,
|
||||
fromBlockId,
|
||||
type,
|
||||
condition,
|
||||
userId: auth.userId ?? null,
|
||||
});
|
||||
return Response.json({
|
||||
success: actionText(t, "workflows.responses.blockAdded", "Block added"),
|
||||
newBlockId: newBlock.id,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "delete-block") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const block = item.blocks.find((x) => x.id === id);
|
||||
if (!block) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
await WorkflowsService.deleteBlock(block.id, { workflow: item, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.blockDeleted", "Block deleted") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "delete-connection") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
|
||||
const toBlockId = form.get("toBlockId")?.toString() ?? "";
|
||||
try {
|
||||
if (fromBlockId && toBlockId) {
|
||||
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
|
||||
const toBlock = item.blocks.find((x) => x.id === toBlockId);
|
||||
if (!fromBlock || !toBlock) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.deleteConnection({ fromBlockId, toBlockId }, { workflow: item, userId: auth.userId ?? null });
|
||||
} else {
|
||||
const connection = item.blocks.flatMap((block) => block.toBlocks).find((candidate) => candidate.id === id);
|
||||
if (!connection) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.connectionNotFound", "Connection not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.deleteConnection({ id: connection.id }, { workflow: item, userId: auth.userId ?? null });
|
||||
}
|
||||
return Response.json({ success: actionText(t, "workflows.responses.connectionDeleted", "Connection deleted") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "connect-blocks") {
|
||||
const fromBlockId = form.get("fromBlockId")?.toString() ?? "";
|
||||
const toBlockId = form.get("toBlockId")?.toString() ?? "";
|
||||
const fromBlock = item.blocks.find((x) => x.id === fromBlockId);
|
||||
const toBlock = item.blocks.find((x) => x.id === toBlockId);
|
||||
const condition = form.get("condition")?.toString() ?? "";
|
||||
if (!fromBlock || !toBlock) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blocksNotFound", "Blocks not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
await WorkflowsService.connectBlocks({ fromBlockId, toBlockId, condition }, { workflow: item, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.blocksConnected", "Blocks connected") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "update-conditions-groups") {
|
||||
const blockId = form.get("blockId")?.toString() ?? "";
|
||||
const block = item.blocks.find((x) => x.id === blockId);
|
||||
if (!block) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.blockNotFound", "Block not found") }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const conditionsGroups = JSON.parse(form.get("conditionsGroups")?.toString() ?? "[]") as WorkflowConditionsGroupDto[];
|
||||
await WorkflowsService.updateConditionsGroups(block, conditionsGroups, { workflow: item, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.conditionsGroupsUpdated", "Condition groups updated") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "create-input-example") {
|
||||
const title = form.get("title")?.toString() ?? "";
|
||||
const input = form.get("input")?.toString() ?? "";
|
||||
await WorkflowsService.createInputExample({
|
||||
workflowId: item.id,
|
||||
tenantId: item.tenantId,
|
||||
title,
|
||||
input,
|
||||
});
|
||||
return Response.json({ success: actionText(t, "workflows.responses.inputExampleCreated", "Input example created") });
|
||||
} else if (action === "update-input-example") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const title = form.get("title")?.toString() ?? "";
|
||||
const input = form.get("input")?.toString() ?? "";
|
||||
const inputExample = item.inputExamples.find((x) => x.id === id);
|
||||
if (!inputExample) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.updateInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId }, {
|
||||
title,
|
||||
input,
|
||||
});
|
||||
return Response.json({ success: actionText(t, "workflows.responses.inputExampleUpdated", "Input example updated") });
|
||||
} else if (action === "delete-input-example") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const inputExample = item.inputExamples.find((x) => x.id === id);
|
||||
if (!inputExample) {
|
||||
return Response.json({ error: actionText(t, "workflows.errors.inputExampleNotFound", "Input example not found") }, { status: 404 });
|
||||
}
|
||||
await WorkflowsService.deleteInputExample({ id: inputExample.id!, workflowId: item.id, tenantId: item.tenantId });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.inputExampleDeleted", "Input example deleted") });
|
||||
} else if (action === "delete") {
|
||||
try {
|
||||
await WorkflowsService.del(item.id, { tenantId });
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowBuilderActionError(t, e) }, { status: 400 });
|
||||
}
|
||||
} else if (action === "toggle") {
|
||||
const enabled = form.get("enabled")?.toString() === "true";
|
||||
|
||||
if (enabled && item.status === "draft") {
|
||||
await WorkflowsService.update(item.id, { status: "live" }, { tenantId, userId: auth.userId ?? null });
|
||||
return Response.json({ success: actionText(t, "workflows.responses.workflowLive", "Workflow is now live") });
|
||||
} else if (!enabled && item.status === "live") {
|
||||
await WorkflowsService.update(
|
||||
item.id,
|
||||
{
|
||||
status: "draft",
|
||||
},
|
||||
{ tenantId, userId: auth.userId ?? null }
|
||||
);
|
||||
return Response.json({ success: actionText(t, "workflows.responses.workflowDraft", "Workflow is now a draft") });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: actionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { toast } from "sonner";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LazyWorkflowBuilder from "~/modules/workflowEngine/components/workflows/LazyWorkflowBuilder";
|
||||
import WorkflowEditorSidebar from "~/modules/workflowEngine/components/workflows/WorkflowEditorSidebar";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import InputCheckbox from "~/components/ui/input/InputCheckbox";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { WorkflowBlockDto } from "~/modules/workflowEngine/dtos/WorkflowBlockDto";
|
||||
import { WorkflowBlockType } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
|
||||
import { WorkflowConditionsGroupDto } from "~/modules/workflowEngine/dtos/WorkflowConditionDtos";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import { WorkflowsIdIndexApi } from "./workflows.$id.index.api.server";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export default function WorkflowsIdIndexView() {
|
||||
const data = useLoaderData<WorkflowsIdIndexApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdIndexApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
|
||||
const [workflow, setWorkflow] = useState<WorkflowDto>(data.item);
|
||||
const [selectedBlock, setSelectedBlock] = useState<WorkflowBlockDto | null>(null);
|
||||
const [addingNextBlockFrom, setAddingNextBlockFrom] = useState<{ fromBlock: WorkflowBlockDto; condition: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
} else if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflow(data.item);
|
||||
if (selectedBlock) {
|
||||
setSelectedBlock(data.item.blocks.find((x) => x.id === selectedBlock.id) ?? null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBlock === null && addingNextBlockFrom !== null) {
|
||||
setAddingNextBlockFrom(null);
|
||||
}
|
||||
}, [addingNextBlockFrom, selectedBlock]);
|
||||
|
||||
function onSave(workflow: WorkflowDto) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "save");
|
||||
form.append("workflow", JSON.stringify(workflow));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onSaveBlock(block: WorkflowBlockDto) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "update-block");
|
||||
form.set("id", block.id);
|
||||
form.append("block", JSON.stringify(block));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onAddBlock({ type, from, condition }: { type: WorkflowBlockType; from: WorkflowBlockDto | undefined; condition: string | null }) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "add-block");
|
||||
form.set("type", type);
|
||||
if (condition) {
|
||||
form.set("condition", condition);
|
||||
}
|
||||
if (from) {
|
||||
form.set("fromBlockId", from.id);
|
||||
}
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onConnectBlocks(params: { fromBlockId: string; toBlockId: string; condition: string | null }) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "connect-blocks");
|
||||
form.set("fromBlockId", params.fromBlockId);
|
||||
form.set("toBlockId", params.toBlockId);
|
||||
if (params.condition) {
|
||||
form.set("condition", params.condition);
|
||||
}
|
||||
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onDeleteBlock(id: string) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "delete-block");
|
||||
form.set("id", id);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onDeleteConnection(params: { fromBlockId: string; toBlockId: string } | { id: string }) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "delete-connection");
|
||||
if ("id" in params) {
|
||||
form.set("id", params.id);
|
||||
} else {
|
||||
form.set("fromBlockId", params.fromBlockId);
|
||||
form.set("toBlockId", params.toBlockId);
|
||||
}
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onUpdateConditionsGroups(blockId: string, conditionsGroups: WorkflowConditionsGroupDto[]) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "update-conditions-groups");
|
||||
form.set("blockId", blockId);
|
||||
form.set("conditionsGroups", JSON.stringify(conditionsGroups));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onToggle(enabled: boolean) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "toggle");
|
||||
form.set("enabled", enabled ? "true" : "false");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: workflow.name }]} />
|
||||
</div>
|
||||
{/* relative z-50 gives the toolbar its own stacking context above the
|
||||
ReactFlow canvas below. ReactFlow's root sets no position/z-index, so
|
||||
its internal positioned layers (pane/renderer/controls, z-index ≤ 5)
|
||||
otherwise paint over this static toolbar where they overlap at mobile
|
||||
width — stealing pointer/keyboard from the run dropdown. */}
|
||||
<div className="border-border bg-background relative z-50 w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div data-ui-id="ui.workflows.builder.statusToggle">
|
||||
<InputCheckbox
|
||||
disabled={!WorkflowUtils.isReady(workflow) && workflow.status !== "live"}
|
||||
asToggle
|
||||
withLabel={false}
|
||||
title={i18nT("workflows.list.toggleStatus", { name: workflow.name })}
|
||||
value={workflow.status === "live"}
|
||||
setValue={(checked) => onToggle(Boolean(checked))}
|
||||
/>
|
||||
</div>
|
||||
<SimpleBadge title={i18nT(`workflows.status.${workflow.status}`)} color={WorkflowUtils.getStatusColor(workflow)} />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
to={`executions`}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-inset ring-gray-300"
|
||||
>
|
||||
{i18nT("workflows.navigation.viewAllExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={workflow} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-140px)] flex-col lg:h-[calc(100vh-140px)] lg:flex-row">
|
||||
{/* ReactFlow fills its parent's height:100%, which needs a *definite*
|
||||
height. On mobile the outer flex container only has min-height, so a
|
||||
flex-1 child stays indefinite and the canvas collapses to 0. Use a
|
||||
fixed vh height on mobile (always definite); flex sizing only on lg. */}
|
||||
<div className="h-[70vh] lg:h-auto lg:min-h-0 lg:flex-1">
|
||||
<LazyWorkflowBuilder
|
||||
workflow={workflow}
|
||||
selectedBlock={selectedBlock}
|
||||
workflowExecution={null}
|
||||
onSelectedBlock={setSelectedBlock}
|
||||
onConnectBlocks={onConnectBlocks}
|
||||
onDeleteConnection={(id) => onDeleteConnection({ id })}
|
||||
onDeleteBlock={onDeleteBlock}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full overflow-y-auto border-t lg:w-96 lg:border-l lg:border-t-0">
|
||||
<WorkflowEditorSidebar
|
||||
workflow={workflow}
|
||||
onSave={onSave}
|
||||
selectedBlock={selectedBlock}
|
||||
addingNextBlockFrom={addingNextBlockFrom}
|
||||
onSelectedBlock={(block) => {
|
||||
setSelectedBlock(block);
|
||||
}}
|
||||
onSaveBlock={onSaveBlock}
|
||||
onSetTrigger={(type) => {
|
||||
onAddBlock({ type: type, from: undefined, condition: null });
|
||||
setAddingNextBlockFrom(null);
|
||||
}}
|
||||
onAddBlock={({ from, type, condition }) => {
|
||||
onAddBlock({ type: type, from: from, condition });
|
||||
setAddingNextBlockFrom(null);
|
||||
}}
|
||||
onAddingNextBlock={(data) => {
|
||||
setAddingNextBlockFrom(data);
|
||||
}}
|
||||
onDeleteBlock={onDeleteBlock}
|
||||
onDeleteConnection={onDeleteConnection}
|
||||
onUpdateConditionsGroups={onUpdateConditionsGroups}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import { apiPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getFirstApiKey } from "~/utils/db/apiKeys.db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { getAbsoluteApiUrl } from "~/utils/url.server";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
|
||||
|
||||
export namespace WorkflowsIdRunApiApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
workflow: WorkflowDto;
|
||||
apiRunUrl: string;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const workflow = await WorkflowsService.get(params.id!, {
|
||||
tenantId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.runApi", { value0: workflow.name, value1: webConfig.appName }) }],
|
||||
workflow,
|
||||
apiRunUrl: getAbsoluteApiUrl(request, apiPath(`workflows/run/${workflow.id}`, { mode: "host-aware" })),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
errorCode?: "noApiKey" | "noValidApiKey";
|
||||
execution?: WorkflowExecutionDto;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
|
||||
let apiKey: string | null = null;
|
||||
if (tenantId === null) {
|
||||
apiKey = loadWebConfig().config.apiAccessToken || null;
|
||||
if (!apiKey) {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.noApiKey", "No API key found"), errorCode: "noApiKey" }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
const validApiKey = await getFirstApiKey(tenantId);
|
||||
if (!validApiKey) {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.noValidApiKey", "No valid API key found"), errorCode: "noValidApiKey" }, { status: 400 });
|
||||
}
|
||||
apiKey = validApiKey.key;
|
||||
}
|
||||
|
||||
if (action === "execute") {
|
||||
try {
|
||||
const input = form.get("input")?.toString() ?? "{}";
|
||||
const url = getAbsoluteApiUrl(request, apiPath(`workflows/run/${params.id}`, { mode: "host-aware" }));
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: input,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
return Response.json({ execution: data.execution });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
|
||||
}
|
||||
} else if (action === "continue-execution") {
|
||||
try {
|
||||
const executionId = form.get("executionId")?.toString() ?? "";
|
||||
const input = form.get("input")?.toString() ?? "{}";
|
||||
const url = getAbsoluteApiUrl(request, apiPath(`workflows/executions/${executionId}/continue`, { mode: "host-aware" }));
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: input,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
return Response.json({ execution: data.execution });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdRunApiApi } from "./workflows.$id.run.api.server";
|
||||
import clsx from "clsx";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
|
||||
import { appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
import { SendIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function WorkflowsIdRunApiApiView() {
|
||||
const data = useLoaderData<WorkflowsIdRunApiApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdRunApiApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state === "submitting";
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
|
||||
const [inputData, setInputData] = useState("{}");
|
||||
const [waitingBlockInput, setWaitingBlockInput] = useState("");
|
||||
const waitingBlockInputId = useId();
|
||||
|
||||
const waitingForInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
waitingForInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [execution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.workflow.inputExamples.length > 0) {
|
||||
setSelectedTemplate(data.workflow.inputExamples[0].title);
|
||||
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
|
||||
if (actionData?.execution) {
|
||||
setExecution(actionData.execution);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onExecute() {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "execute");
|
||||
form.append("input", inputData);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", execution?.id ?? "");
|
||||
form.set("input", JSON.stringify({ input: waitingBlockInput }));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
setWaitingBlockInput("");
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple
|
||||
menu={[
|
||||
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
|
||||
{ title: data.workflow.name },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={data.workflow} />
|
||||
</div>
|
||||
</div>
|
||||
<div data-ui-id="ui.workflows.run.api.form" className="mx-auto max-w-2xl space-y-2 p-4">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div data-ui-id="ui.workflows.run.api.title" className="text-lg font-semibold">
|
||||
{i18nT("workflows.run.apiTitle")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!execution ? (
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.body")}</div>
|
||||
{data.workflow.inputExamples.length > 0 && (
|
||||
<div data-ui-id="ui.workflows.run.api.inputExampleSelect">
|
||||
<WorkflowInputExamplesDropdown
|
||||
workflow={data.workflow}
|
||||
onSelected={(item) => {
|
||||
setSelectedTemplate(item.title);
|
||||
setInputData(JSON.stringify(item.input, null, 2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-ui-id="ui.workflows.run.api.inputEditor" className="border-border overflow-hidden rounded-md border">
|
||||
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
<InputText dataUiId="ui.workflows.run.api.field.url" title={i18nT("workflows.run.url")} defaultValue={data.apiRunUrl} readOnly />
|
||||
<InputText dataUiId="ui.workflows.run.api.field.method" title={i18nT("workflows.run.method")} defaultValue="POST" readOnly />
|
||||
<InputText
|
||||
dataUiId="ui.workflows.run.api.field.apiKey"
|
||||
title={i18nT("workflows.run.apiKeyHeader")}
|
||||
defaultValue={i18nT("workflows.run.apiKeyPlaceholder")}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<LoadingButton dataUiId="ui.workflows.run.api.submit" actionName="execute" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)}>
|
||||
{i18nT("workflows.actions.run")}
|
||||
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" data-ui-id="ui.workflows.run.api.executionResult">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
target="_blank"
|
||||
data-ui-id="ui.workflows.run.api.viewExecutionFlow"
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
|
||||
</div>
|
||||
</>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
data-ui-id="ui.workflows.run.api.runAgain"
|
||||
onClick={() => {
|
||||
setExecution(null);
|
||||
}}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-border overflow-hidden rounded-md border">
|
||||
<MonacoEditor className="h-40" theme="vs-dark" value={JSON.stringify({ execution }, null, 2)} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
|
||||
{execution.waitingBlock && (
|
||||
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.api.waitingInput.form">
|
||||
<div className="space-y-1">
|
||||
{execution.waitingBlock.input.title && (
|
||||
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
|
||||
{execution.waitingBlock.input.title}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={waitingForInputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
name="input"
|
||||
id={waitingBlockInputId}
|
||||
data-ui-id="ui.workflows.run.api.waitingInput.input"
|
||||
autoComplete="off"
|
||||
value={waitingBlockInput}
|
||||
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
|
||||
className={clsx(
|
||||
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
|
||||
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
|
||||
)}
|
||||
placeholder={execution.waitingBlock.input.placeholder}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
data-ui-id="ui.workflows.run.api.waitingInput.submit"
|
||||
aria-label={i18nT("shared.submit")}
|
||||
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
<ErrorBanner title={i18nT("workflows.executionStatus.error")}>
|
||||
<div>{actionData.error}</div>
|
||||
{actionData.errorCode === "noValidApiKey" && params.tenant && (
|
||||
<div>
|
||||
<Link className="underline" target="_blank" to={appPath({ tenantSlug: params.tenant, path: "settings/api/keys", mode: "host-aware" })}>
|
||||
{i18nT("workflows.run.createApiKeyLink")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
requireWorkflowMutationAccess: vi.fn(),
|
||||
validateCSRFToken: vi.fn(),
|
||||
getWorkflow: vi.fn(),
|
||||
queueManualExecution: vi.fn(),
|
||||
queueContinuationExecution: vi.fn(),
|
||||
continueExecution: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: mocks.getTranslations,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/session.server", () => ({
|
||||
getUserInfo: mocks.getUserInfo,
|
||||
validateCSRFToken: mocks.validateCSRFToken,
|
||||
}));
|
||||
|
||||
vi.mock("../workflowAccess.server", () => ({
|
||||
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
|
||||
default: {
|
||||
get: mocks.getWorkflow,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
|
||||
default: {
|
||||
queueManualExecution: mocks.queueManualExecution,
|
||||
queueContinuationExecution: mocks.queueContinuationExecution,
|
||||
continueExecution: mocks.continueExecution,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Signup flow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsIdRunManualApi.action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue(undefined);
|
||||
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
|
||||
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
|
||||
mocks.validateCSRFToken.mockResolvedValue(undefined);
|
||||
mocks.getWorkflow.mockResolvedValue(workflow());
|
||||
});
|
||||
|
||||
it("rejects invalid execute input JSON before queueing and keeps the workflow lookup tenant-scoped", async () => {
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "execute");
|
||||
form.set("input", "{");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunManualApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({ error: "Input data is not valid JSON" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
|
||||
expect(mocks.queueManualExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues manual executions instead of executing inline", async () => {
|
||||
mocks.queueManualExecution.mockResolvedValue({ id: "execution_1", status: "running" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "execute");
|
||||
form.set("input", JSON.stringify({ name: "Ada" }));
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunManualApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: "Workflow queued",
|
||||
execution: { id: "execution_1", status: "running" },
|
||||
});
|
||||
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_1", { tenantId: "tenant_1" });
|
||||
expect(mocks.queueManualExecution).toHaveBeenCalledWith("workflow_1", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
expect(mocks.queueContinuationExecution).not.toHaveBeenCalled();
|
||||
expect(mocks.continueExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues manual continuations instead of executing inline", async () => {
|
||||
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waiting" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", "execution_1");
|
||||
form.set("input", "approved");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunManualApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: "Workflow queued",
|
||||
execution: { id: "execution_1", status: "waiting" },
|
||||
});
|
||||
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
|
||||
type: "manual",
|
||||
input: { input: "approved" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
expect(mocks.continueExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
|
||||
|
||||
export namespace WorkflowsIdRunManualApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
workflow: WorkflowDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const workflow = await WorkflowsService.get(params.id!, {
|
||||
tenantId,
|
||||
});
|
||||
if (!workflow) {
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.runManual", { value0: workflow.name, value1: webConfig.appName }) }],
|
||||
workflow,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
execution?: WorkflowExecutionDto;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await getUserInfo(request);
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const workflow = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!workflow || workflow.status !== "live") {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
|
||||
}
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "execute") {
|
||||
try {
|
||||
let input = form.get("input")?.toString() ?? "{}";
|
||||
let inputData: { [key: string]: any } | null = null;
|
||||
if (!input.trim()) {
|
||||
input = "{}";
|
||||
}
|
||||
if (input) {
|
||||
try {
|
||||
inputData = JSON.parse(input);
|
||||
} catch {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const execution = await WorkflowsExecutionsService.queueManualExecution(params.id!, {
|
||||
input: inputData,
|
||||
session: {
|
||||
tenantId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
|
||||
}
|
||||
} else if (action === "continue-execution") {
|
||||
try {
|
||||
const executionId = form.get("executionId")?.toString() ?? "";
|
||||
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
|
||||
type: "manual",
|
||||
input: { input: form.get("input")?.toString() },
|
||||
session: { tenantId, userId },
|
||||
});
|
||||
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useNavigation, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import { WorkflowBlockExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowBlockExecutionDto";
|
||||
import { getWorkflowBlockName, WorkflowBlockTypes } from "~/modules/workflowEngine/dtos/WorkflowBlockTypes";
|
||||
import { WorkflowExecutionDto } from "~/modules/workflowEngine/dtos/WorkflowExecutionDto";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdRunManualApi } from "./workflows.$id.run.manual.api.server";
|
||||
import { WorkflowDto } from "../../../dtos/WorkflowDto";
|
||||
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
import { formatWorkflowExecutionErrorText } from "~/modules/workflowEngine/utils/workflowExecutionErrorText";
|
||||
import { SendIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export default function WorkflowsIdRunManualView() {
|
||||
const data = useLoaderData<WorkflowsIdRunManualApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdRunManualApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state === "submitting";
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [execution, setExecution] = useState<WorkflowExecutionDto | null>(null);
|
||||
const [inputData, setInputData] = useState("{}");
|
||||
const [waitingBlockInput, setWaitingBlockInput] = useState("");
|
||||
const waitingBlockInputId = useId();
|
||||
|
||||
const waitingForInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
waitingForInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [execution]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.workflow.inputExamples.length > 0) {
|
||||
setSelectedTemplate(data.workflow.inputExamples[0].title);
|
||||
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.workflow.inputExamples.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
|
||||
if (actionData?.execution) {
|
||||
setExecution(actionData.execution);
|
||||
actionData?.execution?.executionAlerts.forEach((executionAlert) => {
|
||||
if (executionAlert.type === "error") {
|
||||
toast.error(executionAlert.message, {
|
||||
position: "bottom-right",
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
toast.success(executionAlert.message, {
|
||||
position: "bottom-right",
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onExecute() {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "execute");
|
||||
form.append("input", inputData);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onSubmitWaitingBlock(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", execution?.id ?? "");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
setWaitingBlockInput("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<BreadcrumbSimple
|
||||
menu={[
|
||||
{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) },
|
||||
{ title: data.workflow.name },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border bg-background w-full border-b px-4 py-2 shadow-2xs">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
|
||||
className="hover:bg-secondary bg-background text-foreground/80 rounded px-2 py-1 text-sm font-semibold shadow-2xs ring-1 ring-gray-300 ring-inset"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={data.workflow} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl space-y-2 p-4">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div className="text-lg font-semibold" data-ui-id="ui.workflows.run.manual.title">
|
||||
{i18nT("workflows.run.manualTitle")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!execution ? (
|
||||
<div data-ui-id="ui.workflows.run.manual.form">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
|
||||
{data.workflow.inputExamples.length > 0 && (
|
||||
<div data-ui-id="ui.workflows.run.manual.inputExampleSelect">
|
||||
<WorkflowInputExamplesDropdown
|
||||
workflow={data.workflow}
|
||||
onSelected={(item) => {
|
||||
setSelectedTemplate(item.title);
|
||||
setInputData(JSON.stringify(item.input, null, 2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-border overflow-hidden rounded-md border" data-ui-id="ui.workflows.run.manual.inputEditor">
|
||||
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<LoadingButton
|
||||
actionName="execute"
|
||||
onClick={onExecute}
|
||||
disabled={!WorkflowUtils.canRun(data.workflow)}
|
||||
className="ui-workflows-run-manual-submit"
|
||||
dataUiId="ui.workflows.run.manual.submit"
|
||||
>
|
||||
{i18nT("workflows.actions.run")}
|
||||
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" data-ui-id="ui.workflows.run.manual.executionResult">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
target="_blank"
|
||||
data-ui-id="ui.workflows.run.manual.viewExecutionFlow"
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions?executionId=${execution.id}`)}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.viewExecutionFlow")}</div>
|
||||
</div>
|
||||
</>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
data-ui-id="ui.workflows.run.manual.runAgain"
|
||||
onClick={() => {
|
||||
setExecution(null);
|
||||
}}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<div className=" ">{i18nT("workflows.run.runAgain")}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<WorkflowRun workflow={execution} />
|
||||
<div className="text-foreground font-medium">{i18nT("workflows.executions.blocksExecuted", { count: execution.blockRuns.length })}</div>
|
||||
<div className="w-full space-y-1">
|
||||
{execution.blockRuns.map((blockRun) => {
|
||||
return <BlockRun key={blockRun.id} workflow={data.workflow} blockRun={blockRun} />;
|
||||
})}
|
||||
</div>
|
||||
{execution.waitingBlock && (
|
||||
<Form onSubmit={onSubmitWaitingBlock} data-ui-id="ui.workflows.run.manual.waitingInput.form">
|
||||
<div className="space-y-1">
|
||||
{execution.waitingBlock.input.title && (
|
||||
<label htmlFor={waitingBlockInputId} className="text-muted-foreground text-xs font-medium">
|
||||
{execution.waitingBlock.input.title}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={waitingForInputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
name="input"
|
||||
id={waitingBlockInputId}
|
||||
data-ui-id="ui.workflows.run.manual.waitingInput.input"
|
||||
autoComplete="off"
|
||||
value={waitingBlockInput}
|
||||
onChange={(e) => setWaitingBlockInput(e.currentTarget.value)}
|
||||
className={clsx(
|
||||
"focus:ring-theme-600 placeholder:text-muted-foreground block w-full rounded-md border-0 py-3 pr-14 text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6",
|
||||
isLoading ? "base-spinner bg-secondary/90" : "bg-white"
|
||||
)}
|
||||
placeholder={execution.waitingBlock.input.placeholder}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
data-ui-id="ui.workflows.run.manual.waitingInput.submit"
|
||||
aria-label={i18nT("shared.submit")}
|
||||
className="text-muted-foreground border-border focus-visible:ring-theme-600 inline-flex min-h-10 min-w-10 items-center justify-center rounded border px-2 font-sans text-xs focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<SendIcon aria-hidden="true" focusable="false" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData?.error ? (
|
||||
<div role="alert" data-ui-id="ui.workflows.run.manual.error">
|
||||
<ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockRun({ workflow, blockRun }: { workflow: WorkflowDto; blockRun: WorkflowBlockExecutionDto }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const outputPreview = useMemo(() => (blockRun.output ? JSON.stringify({ output: blockRun.output }) : null), [blockRun.output]);
|
||||
const block = workflow.blocks.find((x) => x.id === blockRun.workflowBlockId);
|
||||
const workflowBlock = WorkflowBlockTypes.find((x) => x.value === block?.type);
|
||||
if (!block || !workflowBlock) {
|
||||
return null;
|
||||
}
|
||||
const workflowBlockName = getWorkflowBlockName(workflowBlock, i18nT);
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-expanded={isExpanded}
|
||||
className={clsx(
|
||||
"hover:bg-secondary border-border bg-background block w-full cursor-pointer overflow-hidden rounded-md border p-2 text-left select-none",
|
||||
isExpanded ? "rounded-b-none" : "rounded-md"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
<div className="flex items-center space-x-2 truncate">
|
||||
<workflowBlock.icon aria-hidden="true" className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<div className="text-foreground/80 shrink-0 truncate text-xs font-medium">[{workflowBlockName}]</div>
|
||||
{blockRun.error ? (
|
||||
<div className="truncate text-xs text-red-600">{formatWorkflowExecutionErrorText(blockRun.error, i18nT)}</div>
|
||||
) : outputPreview ? (
|
||||
<div className="text-muted-foreground truncate text-xs">{outputPreview}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">{block.description || ""}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{block.type === "if" ? (
|
||||
<div>
|
||||
{blockRun.output?.condition ? (
|
||||
<SimpleBadge title={i18nT("workflows.boolean.true")} color={Colors.BLUE} />
|
||||
) : (
|
||||
<SimpleBadge title={i18nT("workflows.boolean.false")} color={Colors.ORANGE} />
|
||||
)}
|
||||
</div>
|
||||
) : block.type === "switch" ? (
|
||||
<SimpleBadge title={blockRun.output?.condition?.toString()} color={Colors.BLUE} />
|
||||
) : (
|
||||
<div>
|
||||
{blockRun.status === "running" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.running")} color={Colors.YELLOW} />
|
||||
) : blockRun.status === "error" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.error")} color={Colors.RED} />
|
||||
) : blockRun.status === "success" ? (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.success")} color={Colors.GREEN} />
|
||||
) : (
|
||||
<SimpleBadge title={i18nT("workflows.executionStatus.unknown")} color={Colors.GRAY} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-border bg-secondary/90 overflow-hidden rounded-md rounded-t-none border p-2">
|
||||
{blockRun.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={formatWorkflowExecutionErrorText(blockRun.error, i18nT)} />}
|
||||
<InputOutput input={blockRun.input} output={blockRun.output} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowRun({ workflow }: { workflow: WorkflowExecutionDto }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="border-border bg-background hover:border-border flex w-full flex-col items-center rounded-lg border-2 border-dotted p-3 text-sm font-medium hover:border-dashed"
|
||||
>
|
||||
<div className="flex justify-center">{i18nT("workflows.run.inputOutput")}</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-border bg-secondary/90 overflow-hidden rounded-md border p-2">
|
||||
<InputOutput input={workflow.input} output={workflow.output} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOutput({ input, output }: { input?: any; output?: any }) {
|
||||
const compactInput = useMemo(() => (input ? JSON.stringify(input) : ""), [input]);
|
||||
const compactOutput = useMemo(() => (output ? JSON.stringify(output) : ""), [output]);
|
||||
const formattedInput = useMemo(() => (input ? JSON.stringify(input, null, 2) : ""), [input]);
|
||||
const formattedOutput = useMemo(() => (output ? JSON.stringify(output, null, 2) : ""), [output]);
|
||||
const hasInput = Boolean(input && compactInput !== "{}");
|
||||
const hasOutput = Boolean(output && compactOutput !== "{}");
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{!hasInput ? (
|
||||
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
|
||||
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noInput")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.input")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
|
||||
onClick={() => navigator.clipboard.writeText(compactInput)}
|
||||
>
|
||||
{i18nT("workflows.actions.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose max-h-24 overflow-auto">
|
||||
<pre>{formattedInput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasOutput ? (
|
||||
<div className="border-border flex flex-col items-center border bg-gray-200 p-3">
|
||||
<div className="text-muted-foreground text-center text-xs">{i18nT("workflows.run.noOutput")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-foreground/80 text-xs font-medium">{i18nT("workflows.run.outputs")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-muted-foreground text-foreground/80 text-xs font-medium"
|
||||
onClick={() => navigator.clipboard.writeText(compactOutput)}
|
||||
>
|
||||
{i18nT("workflows.actions.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose max-h-24 overflow-auto">
|
||||
<pre>{formattedOutput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { emitter } from "~/utils/stream/emitter.server";
|
||||
import { eventStream } from "~/utils/stream/event-stream";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export const workflowExecutionStreamLoader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const executionId = params.executionId;
|
||||
|
||||
if (!executionId) {
|
||||
return Response.json({ error: t("workflows.errors.executionIdRequired") }, { status: 400 });
|
||||
}
|
||||
|
||||
const execution = await WorkflowsService.getExecution(executionId, { tenantId });
|
||||
if (!execution) {
|
||||
return Response.json({ error: t("workflows.errors.executionNotFound") }, { status: 404 });
|
||||
}
|
||||
|
||||
const eventName = `workflowExecutionUpdate:${executionId}`;
|
||||
return eventStream(request.signal, (send) => {
|
||||
const listener = (data: string) => send({ data });
|
||||
emitter.on(eventName, listener);
|
||||
// Emit the current execution snapshot immediately (after subscribing, so no
|
||||
// concurrent update is dropped). The executor only emits on UPDATES, so a
|
||||
// subscriber that connects after the run already reached its state — a slow
|
||||
// connect under load, a reload, or a late mount — would otherwise see an
|
||||
// empty stream until the next change (which, for a waitingBlock, never comes
|
||||
// until the user continues). Same wire shape as the emitter's updates.
|
||||
send({ data: JSON.stringify(execution) });
|
||||
return () => {
|
||||
emitter.off(eventName, listener);
|
||||
};
|
||||
});
|
||||
};
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
requireWorkflowMutationAccess: vi.fn(),
|
||||
validateCSRFToken: vi.fn(),
|
||||
getWorkflow: vi.fn(),
|
||||
stream: vi.fn(),
|
||||
queueContinuationExecution: vi.fn(),
|
||||
continueExecution: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/config", () => ({
|
||||
loadWebConfig: () => ({ config: { appName: "Test App" } }),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: mocks.getTranslations,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/session.server", () => ({
|
||||
getUserInfo: mocks.getUserInfo,
|
||||
validateCSRFToken: mocks.validateCSRFToken,
|
||||
}));
|
||||
|
||||
vi.mock("../workflowAccess.server", () => ({
|
||||
requireWorkflowMutationAccess: mocks.requireWorkflowMutationAccess,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsService", () => ({
|
||||
default: {
|
||||
get: mocks.getWorkflow,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/workflowEngine/services/WorkflowsExecutionsService", () => ({
|
||||
default: {
|
||||
stream: mocks.stream,
|
||||
queueContinuationExecution: mocks.queueContinuationExecution,
|
||||
continueExecution: mocks.continueExecution,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Signup flow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsIdRunStreamApi.action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue(undefined);
|
||||
mocks.getTranslations.mockResolvedValue({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key });
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
mocks.getUserInfo.mockResolvedValue({ userId: "user_1" });
|
||||
mocks.requireWorkflowMutationAccess.mockResolvedValue(undefined);
|
||||
mocks.validateCSRFToken.mockResolvedValue(undefined);
|
||||
mocks.getWorkflow.mockResolvedValue(workflow());
|
||||
});
|
||||
|
||||
it("continues stream wait blocks through the workflow outbox", async () => {
|
||||
mocks.queueContinuationExecution.mockResolvedValue({ id: "execution_1", status: "waitingBlock" });
|
||||
const form = new URLSearchParams();
|
||||
form.set("action", "continue-execution");
|
||||
form.set("executionId", "execution_1");
|
||||
form.set("input", "approved");
|
||||
|
||||
const request = new Request("https://app.example.test/acme/workflow-engine/workflows/workflow_1/run/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const response = await WorkflowsIdRunStreamApi.action({
|
||||
request,
|
||||
params: { tenant: "acme", id: "workflow_1" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: "Workflow queued",
|
||||
execution: { id: "execution_1", status: "waitingBlock" },
|
||||
});
|
||||
expect(mocks.queueContinuationExecution).toHaveBeenCalledWith("execution_1", {
|
||||
type: "manual",
|
||||
input: { input: "approved" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
expect(mocks.continueExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import WorkflowsExecutionsService from "~/modules/workflowEngine/services/WorkflowsExecutionsService";
|
||||
import WorkflowsService from "~/modules/workflowEngine/services/WorkflowsService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { formatWorkflowRunActionError, workflowActionText } from "../workflowRunActionErrors.server";
|
||||
|
||||
export namespace WorkflowsIdRunStreamApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
workflow: WorkflowDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const workflow = await WorkflowsService.get(params.id!, {
|
||||
tenantId,
|
||||
});
|
||||
if (!workflow) {
|
||||
return redirect(UrlUtils.getModulePath(params, `workflow-engine/workflows`));
|
||||
}
|
||||
const webConfig = loadWebConfig().config;
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.runStream", { value0: workflow.name, value1: webConfig.appName }) }],
|
||||
workflow,
|
||||
};
|
||||
return Response.json(data);
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
executionId: string;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await getUserInfo(request);
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const workflow = await WorkflowsService.get(params.id!, { tenantId });
|
||||
if (!workflow || workflow.status !== "live") {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.workflowNotFound", "Workflow not found") }, { status: 404 });
|
||||
}
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "execute") {
|
||||
try {
|
||||
let input = form.get("input")?.toString() ?? "{}";
|
||||
let inputData: { [key: string]: any } | null = null;
|
||||
if (!input.trim()) {
|
||||
input = "{}";
|
||||
}
|
||||
if (input) {
|
||||
try {
|
||||
inputData = JSON.parse(input);
|
||||
} catch {
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.inputDataInvalidJson", "Input data is not valid JSON") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const execution = await WorkflowsExecutionsService.stream(params.id!, {
|
||||
input: inputData,
|
||||
session: {
|
||||
tenantId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return Response.json({ executionId: execution.id });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "run") }, { status: 400 });
|
||||
}
|
||||
} else if (action === "continue-execution") {
|
||||
try {
|
||||
const executionId = form.get("executionId")?.toString() ?? "";
|
||||
const execution = await WorkflowsExecutionsService.queueContinuationExecution(executionId, {
|
||||
type: "manual",
|
||||
input: { input: form.get("input")?.toString() },
|
||||
session: { tenantId, userId },
|
||||
});
|
||||
return Response.json({ success: workflowActionText(t, "workflows.responses.workflowQueued", "Workflow queued"), execution }, { status: 202 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: formatWorkflowRunActionError(t, e, "continue") }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: workflowActionText(t, "workflows.errors.invalidAction", "Invalid action") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { Link, useActionData, useLoaderData, useParams, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
||||
import WorkflowInputExamplesDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowInputExamplesDropdown";
|
||||
import WorkflowRunDropdown from "~/modules/workflowEngine/components/workflows/buttons/WorkflowRunDropdown";
|
||||
import WorkflowStreamProgress from "~/modules/workflowEngine/components/workflows/stream/WorkflowStreamProgress";
|
||||
import WorkflowUtils from "~/modules/workflowEngine/helpers/WorkflowUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { WorkflowsIdRunStreamApi } from "./workflows.$id.run.stream.api.server";
|
||||
import ErrorBanner from "~/components/ui/banners/ErrorBanner";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export default function WorkflowsIdRunStreamView() {
|
||||
const data = useLoaderData<WorkflowsIdRunStreamApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsIdRunStreamApi.ActionData>();
|
||||
const params = useParams();
|
||||
const submit = useSubmit();
|
||||
|
||||
const [executionId, setExecutionId] = useState<string | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [inputData, setInputData] = useState("{}");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.workflow.inputExamples.length > 0) {
|
||||
setSelectedTemplate(data.workflow.inputExamples[0].title);
|
||||
setInputData(JSON.stringify(data.workflow.inputExamples[0].input, null, 2));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
if (actionData?.executionId) {
|
||||
setExecutionId(actionData.executionId);
|
||||
setIsRunning(true);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onExecute() {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.append("action", "execute");
|
||||
form.append("input", inputData);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
|
||||
<BreadcrumbSimple
|
||||
menu={[{ title: i18nT("workflows.title"), routePath: UrlUtils.getModulePath(params, `workflow-engine/workflows`) }, { title: data.workflow.name }]}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border w-full border-b bg-white px-4 py-2 shadow-sm">
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/executions`)}
|
||||
className="text-muted-foreground rounded bg-white px-2 py-1 text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<span className="mr-1">←</span> {i18nT("workflows.navigation.backToExecutions")}
|
||||
</Link>
|
||||
<WorkflowRunDropdown workflow={data.workflow} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl space-y-2 p-4">
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div data-ui-id="ui.workflows.run.stream.title" className="text-lg font-semibold">
|
||||
{i18nT("workflows.run.streamTitle")}
|
||||
</div>
|
||||
</div>
|
||||
<div data-ui-id="ui.workflows.run.stream.form">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="text-sm font-medium">{selectedTemplate || i18nT("workflows.run.inputData")}</div>
|
||||
{data.workflow.inputExamples.length > 0 && (
|
||||
<div data-ui-id="ui.workflows.run.stream.inputExampleSelect">
|
||||
<WorkflowInputExamplesDropdown
|
||||
workflow={data.workflow}
|
||||
onSelected={(item) => {
|
||||
setSelectedTemplate(item.title);
|
||||
setInputData(JSON.stringify(item.input, null, 2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div data-ui-id="ui.workflows.run.stream.inputEditor" className="border-border overflow-hidden rounded-md border">
|
||||
<MonacoEditor className="h-20" theme="light" value={inputData} onChange={setInputData} hideLineNumbers tabSize={2} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<LoadingButton dataUiId="ui.workflows.run.stream.submit" onClick={onExecute} disabled={!WorkflowUtils.canRun(data.workflow)} isLoading={isRunning}>
|
||||
{i18nT("workflows.actions.run")}
|
||||
{!WorkflowUtils.canRun(data.workflow) && <span className="ml-1 text-xs opacity-50">{i18nT("workflows.statusHints.notLiveInline")}</span>}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{executionId && (
|
||||
<WorkflowStreamProgress
|
||||
key={executionId}
|
||||
streamUrl={UrlUtils.getModulePath(params, `workflow-engine/workflows/${data.workflow.id}/run/stream/${executionId}`)}
|
||||
onCompleted={() => setIsRunning(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{actionData?.error && <ErrorBanner title={i18nT("workflows.executionStatus.error")} text={actionData.error} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { deleteWorkflowCredential, getAllWorkflowCredentials } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export namespace WorkflowsCredentialsApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
items: WorkflowCredential[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
const items = await getAllWorkflowCredentials({ tenantId });
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.credentials", { value0: process.env.APP_NAME }) }],
|
||||
items,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "delete") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
await deleteWorkflowCredential(id, { tenantId });
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
|
||||
} else {
|
||||
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { WorkflowCredential } from "@toolctrl/db/prisma-browser";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { MetaFunction, useLoaderData } from "react-router";
|
||||
import { Outlet, useSubmit } from "react-router";
|
||||
import { useRef } from "react";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import DateCell from "~/components/ui/dates/DateCell";
|
||||
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
||||
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { WorkflowDto } from "~/modules/workflowEngine/dtos/WorkflowDto";
|
||||
import { WorkflowsCredentialsApi } from "./credentials.api.server";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export const meta: MetaFunction = () => [{ title: i18nT("workflows.credentials.title") }];
|
||||
|
||||
export default function WorkflowsCredentialsView() {
|
||||
const data = useLoaderData<WorkflowsCredentialsApi.LoaderData>();
|
||||
const submit = useSubmit();
|
||||
|
||||
const confirmDelete = useRef<RefConfirmModal>(null);
|
||||
|
||||
function onDelete(item: WorkflowCredential) {
|
||||
confirmDelete.current?.setValue(item);
|
||||
confirmDelete.current?.show(
|
||||
i18nT("workflows.credentials.confirmDelete.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.credentials.confirmDelete.description")
|
||||
);
|
||||
}
|
||||
function onDeleteConfirm(item: WorkflowDto) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", "delete");
|
||||
form.set("id", item.id.toString() ?? "");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<EditPageLayout
|
||||
title={i18nT("workflows.credentials.title")}
|
||||
buttons={
|
||||
<>
|
||||
<ButtonPrimary to="new">{i18nT("workflows.actions.new")}</ButtonPrimary>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<TableSimple
|
||||
headers={[
|
||||
{
|
||||
title: i18nT("workflows.columns.name"),
|
||||
name: "name",
|
||||
value: (item) => <div className="select-all">{`{{$credentials.${item.name}}}`}</div>,
|
||||
},
|
||||
{
|
||||
title: i18nT("workflows.columns.value"),
|
||||
name: "value",
|
||||
className: "w-full",
|
||||
value: (item) => <div className="max-w-sm truncate">{"".padEnd(36, "*")}</div>,
|
||||
},
|
||||
{
|
||||
title: i18nT("shared.createdAt"),
|
||||
name: "createdAt",
|
||||
value: (item) => <DateCell date={item.createdAt} />,
|
||||
},
|
||||
]}
|
||||
items={data.items}
|
||||
actions={[
|
||||
{
|
||||
title: i18nT("workflows.actions.delete"),
|
||||
onClick: (_, i) => onDelete(i),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Outlet />
|
||||
|
||||
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
|
||||
</EditPageLayout>
|
||||
);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { createWorkflowCredential, getWorkflowCredentialByName } from "~/modules/workflowEngine/db/workflowCredentials.db.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import CryptoApi from "~/utils/api/.server/CryptoApi";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { requireWorkflowMutationAccess } from "../workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export namespace WorkflowsCredentialsNewApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.newCredential", { value0: process.env.APP_NAME }) }],
|
||||
};
|
||||
return data;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const userInfo = await getUserInfo(request);
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const name = form.get("credential-name")?.toString() ?? "";
|
||||
let value = form.get("credential-value")?.toString() ?? "";
|
||||
|
||||
if (action === "create") {
|
||||
try {
|
||||
const existing = await getWorkflowCredentialByName(name, { tenantId });
|
||||
if (existing) {
|
||||
throw Error(
|
||||
t("workflows.errors.credentialAlreadyExists", {
|
||||
value0: name,
|
||||
})
|
||||
);
|
||||
}
|
||||
value = CryptoApi.encrypt(value);
|
||||
await createWorkflowCredential({
|
||||
tenantId,
|
||||
createdByUserId: userInfo.userId,
|
||||
name,
|
||||
value,
|
||||
});
|
||||
throw redirect(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
return Response.json({ error: t("workflows.errors.invalidAction") }, { status: 400 });
|
||||
};
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import WorkflowCredentialForm from "~/modules/workflowEngine/components/workflowVariables/WorkflowCredentialForm";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
|
||||
export default function WorkflowsCredentialsNewView() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
function close() {
|
||||
navigate(UrlUtils.getModulePath(params, `workflow-engine/credentials`));
|
||||
}
|
||||
return (
|
||||
<SlideOverWideEmpty title={i18nT("workflows.credentials.newTitle")} className="sm:max-w-sm" open={true} onClose={close}>
|
||||
<WorkflowCredentialForm />
|
||||
</SlideOverWideEmpty>
|
||||
);
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
workflowCount: vi.fn(),
|
||||
credentialCount: vi.fn(),
|
||||
variableCount: vi.fn(),
|
||||
executionCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflow: { count: mocks.workflowCount },
|
||||
workflowCredential: { count: mocks.credentialCount },
|
||||
workflowVariable: { count: mocks.variableCount },
|
||||
workflowExecution: { count: mocks.executionCount },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, values?: Record<string, string | undefined>) => `${key}:${values?.value0 ?? ""}`,
|
||||
}));
|
||||
|
||||
import { WorkflowsDangerApi } from "./danger.api.server";
|
||||
|
||||
describe("WorkflowsDangerApi.loader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue(undefined);
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
});
|
||||
|
||||
it("loads destructive-scope summary counts concurrently", async () => {
|
||||
const workflows = deferred<number>();
|
||||
const credentials = deferred<number>();
|
||||
const variables = deferred<number>();
|
||||
const executions = deferred<number>();
|
||||
|
||||
mocks.workflowCount.mockReturnValue(workflows.promise);
|
||||
mocks.credentialCount.mockReturnValue(credentials.promise);
|
||||
mocks.variableCount.mockReturnValue(variables.promise);
|
||||
mocks.executionCount.mockReturnValue(executions.promise);
|
||||
|
||||
const loaderPromise = WorkflowsDangerApi.loader({
|
||||
request: new Request("https://app.example.test/acme/workflow-engine/danger"),
|
||||
params: { tenant: "acme" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.workflowCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
expect(mocks.credentialCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
expect(mocks.variableCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
expect(mocks.executionCount).toHaveBeenCalledWith({ where: { tenantId: "tenant_1" } });
|
||||
|
||||
workflows.resolve(2);
|
||||
credentials.resolve(3);
|
||||
variables.resolve(5);
|
||||
executions.resolve(8);
|
||||
|
||||
await expect(loaderPromise).resolves.toMatchObject({
|
||||
summary: {
|
||||
workflows: 2,
|
||||
credentials: 3,
|
||||
variables: 5,
|
||||
executions: 8,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { requireWorkflowMutationAccess } from "./workflowAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
|
||||
export namespace WorkflowsDangerApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
summary: {
|
||||
workflows: number;
|
||||
variables: number;
|
||||
credentials: number;
|
||||
executions: number;
|
||||
};
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const [workflows, credentials, variables, executions] = await Promise.all([
|
||||
db.workflow.count({ where: { tenantId } }),
|
||||
db.workflowCredential.count({ where: { tenantId } }),
|
||||
db.workflowVariable.count({ where: { tenantId } }),
|
||||
db.workflowExecution.count({ where: { tenantId } }),
|
||||
]);
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: i18nT("workflows.meta.danger", { value0: process.env.APP_NAME }) }],
|
||||
summary: {
|
||||
workflows,
|
||||
credentials,
|
||||
variables,
|
||||
executions,
|
||||
},
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireWorkflowMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "reset-all-data") {
|
||||
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
|
||||
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
|
||||
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.workflowDataReset", {
|
||||
value0: workflows.count,
|
||||
value1: variables.count,
|
||||
value2: credentials.count,
|
||||
}),
|
||||
});
|
||||
} else if (action === "delete-all-executions") {
|
||||
const executions = await db.workflowExecution.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.executionsDeleted", { count: executions.count }),
|
||||
});
|
||||
} else if (action === "delete-all-credentials") {
|
||||
const credentials = await db.workflowCredential.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.credentialsDeleted", { count: credentials.count }),
|
||||
});
|
||||
} else if (action === "delete-all-variables") {
|
||||
const variables = await db.workflowVariable.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.variablesDeleted", { count: variables.count }),
|
||||
});
|
||||
} else if (action === "delete-all-workflows") {
|
||||
const workflows = await db.workflow.deleteMany({ where: { tenantId } });
|
||||
return Response.json({
|
||||
success: t("workflows.responses.workflowsDeleted", { count: workflows.count }),
|
||||
});
|
||||
} else {
|
||||
return Response.json({ error: t("workflows.errors.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { useActionData, useLoaderData, useSubmit } from "react-router";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { WorkflowsDangerApi } from "./danger.api.server";
|
||||
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
||||
import DropdownOptions from "~/components/ui/dropdowns/DropdownOptions";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { appendCSRFToken } from "~/utils/csrf";
|
||||
|
||||
export default function WorkflowsDangerView() {
|
||||
const data = useLoaderData<WorkflowsDangerApi.LoaderData>();
|
||||
const actionData = useActionData<WorkflowsDangerApi.ActionData>();
|
||||
const submit = useSubmit();
|
||||
|
||||
const confirmReset = useRef<RefConfirmModal>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
} else if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function onReset(type: string) {
|
||||
confirmReset.current?.setValue(type);
|
||||
if (type === "reset-all-data") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmResetAll.title"),
|
||||
i18nT("workflows.danger.actions.reset"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmResetAll.description")
|
||||
);
|
||||
} else if (type === "delete-all-executions") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteExecutions.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteExecutions.description")
|
||||
);
|
||||
} else if (type === "delete-all-variables") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteVariables.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteVariables.description")
|
||||
);
|
||||
} else if (type === "delete-all-credentials") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteCredentials.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteCredentials.description")
|
||||
);
|
||||
} else if (type === "delete-all-workflows") {
|
||||
confirmReset.current?.show(
|
||||
i18nT("workflows.danger.confirmDeleteWorkflows.title"),
|
||||
i18nT("workflows.actions.delete"),
|
||||
i18nT("workflows.actions.cancel"),
|
||||
i18nT("workflows.danger.confirmDeleteWorkflows.description")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onResetConfirm(type: string) {
|
||||
const form = new FormData();
|
||||
appendCSRFToken(form);
|
||||
form.set("action", type);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 overflow-x-auto xl:overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl px-4 py-4 sm:px-6 lg:px-8 lg:py-12">
|
||||
<h1 className="text-foreground text-3xl font-bold tracking-tight">{i18nT("workflows.danger.title")}</h1>
|
||||
|
||||
<div className="divide-y-gray-200 mt-6 space-y-8 divide-y">
|
||||
<div className="grid grid-cols-1 gap-y-6 sm:grid-cols-6 sm:gap-x-6">
|
||||
<div className="sm:col-span-6">
|
||||
<h2 className="text-foreground text-xl font-medium">{i18nT("workflows.danger.confirmResetAll.title")}</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{i18nT("workflows.danger.confirmResetAll.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-8">
|
||||
<DropdownOptions
|
||||
width="w-80"
|
||||
button={
|
||||
<div className="hover:bg-secondary border-border bg-background rounded-md border px-2 py-2 text-sm font-medium text-red-600">
|
||||
{i18nT("workflows.danger.actions.resetData")}
|
||||
</div>
|
||||
}
|
||||
options={
|
||||
<div>
|
||||
{[
|
||||
{
|
||||
action: "delete-all-credentials",
|
||||
title: i18nT("workflows.danger.options.deleteAllCredentials", { count: data.summary.credentials }),
|
||||
},
|
||||
{
|
||||
action: "delete-all-variables",
|
||||
title: i18nT("workflows.danger.options.deleteAllVariables", { count: data.summary.variables }),
|
||||
},
|
||||
{
|
||||
action: "delete-all-executions",
|
||||
title: i18nT("workflows.danger.options.deleteAllExecutions", { count: data.summary.executions }),
|
||||
},
|
||||
{
|
||||
action: "delete-all-workflows",
|
||||
title: i18nT("workflows.danger.options.deleteAllWorkflows", { count: data.summary.workflows }),
|
||||
},
|
||||
{
|
||||
action: "reset-all-data",
|
||||
title: i18nT("workflows.danger.confirmResetAll.title"),
|
||||
className: "text-red-600 font-medium",
|
||||
},
|
||||
].map((option) => {
|
||||
return (
|
||||
<Menu.Item key={option.action}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReset(option.action)}
|
||||
className={clsx(
|
||||
option.className,
|
||||
"w-full truncate text-left",
|
||||
active ? "text-foreground bg-secondary/90" : "text-foreground/80",
|
||||
"block px-4 py-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{option.title}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
></DropdownOptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal ref={confirmReset} onYes={onResetConfirm} destructive />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+97
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+141
@@ -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>
|
||||
);
|
||||
}
|
||||
+97
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+39
@@ -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;
|
||||
};
|
||||
}
|
||||
+34
@@ -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>
|
||||
);
|
||||
}
|
||||
+103
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+204
@@ -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>
|
||||
);
|
||||
}
|
||||
+26
@@ -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;
|
||||
};
|
||||
}
|
||||
+53
@@ -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>
|
||||
);
|
||||
}
|
||||
+64
@@ -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 });
|
||||
};
|
||||
}
|
||||
+20
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -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 });
|
||||
};
|
||||
}
|
||||
+18
@@ -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>
|
||||
);
|
||||
}
|
||||
+92
@@ -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/");
|
||||
});
|
||||
});
|
||||
+614
@@ -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());
|
||||
});
|
||||
});
|
||||
+96
@@ -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");
|
||||
});
|
||||
});
|
||||
+22
@@ -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 });
|
||||
}
|
||||
+175
@@ -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."
|
||||
);
|
||||
}
|
||||
+93
@@ -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");
|
||||
});
|
||||
});
|
||||
+81
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+235
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user