diff --git a/__tests__/components/FlowRunDialog.test.tsx b/__tests__/components/FlowRunDialog.test.tsx
new file mode 100644
index 0000000..3658c67
--- /dev/null
+++ b/__tests__/components/FlowRunDialog.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * Tests for FlowRunDialog — collects an input-driven flow's variables and hands
+ * them back as a params object when Run is pressed.
+ */
+import React from "react";
+import { render, fireEvent } from "@testing-library/react-native";
+
+jest.mock("react-i18next", () => ({
+ // lib/i18n calls i18n.use(initReactI18next) on import.
+ initReactI18next: { type: "3rdParty", init: () => {} },
+ useTranslation: () => ({
+ t: (k: string) =>
+ k === "workflow.runLabel"
+ ? "Run"
+ : k === "common.cancel"
+ ? "Cancel"
+ : k === "workflow.runFlow"
+ ? "Run Flow"
+ : k,
+ }),
+}));
+
+import { FlowRunDialog } from "~/components/automation/FlowRunDialog";
+
+const inputs = [
+ { name: "leadId", type: "text", isInput: true },
+ { name: "opportunityName", type: "text", isInput: true },
+];
+
+describe("FlowRunDialog", () => {
+ it("renders a field per input and a Run/Cancel pair", () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText("Lead Id")).toBeTruthy();
+ expect(getByText("Opportunity Name")).toBeTruthy();
+ expect(getByText("Run")).toBeTruthy();
+ expect(getByText("Cancel")).toBeTruthy();
+ });
+
+ it("calls onRun with a params object keyed by variable name", () => {
+ const onRun = jest.fn();
+ const { getByText } = render(
+ ,
+ );
+ fireEvent.press(getByText("Run"));
+ expect(onRun).toHaveBeenCalledTimes(1);
+ expect(Object.keys(onRun.mock.calls[0][0]).sort()).toEqual(["leadId", "opportunityName"]);
+ });
+
+ it("calls onCancel from the Cancel button", () => {
+ const onCancel = jest.fn();
+ const { getByText } = render(
+ ,
+ );
+ fireEvent.press(getByText("Cancel"));
+ expect(onCancel).toHaveBeenCalled();
+ });
+});
diff --git a/app/flows/[name]/index.tsx b/app/flows/[name]/index.tsx
index 19cf79c..1a5e920 100644
--- a/app/flows/[name]/index.tsx
+++ b/app/flows/[name]/index.tsx
@@ -9,8 +9,10 @@ import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useToast } from "~/components/ui/Toast";
import { useConfirm } from "~/components/ui/ConfirmDialog";
+import { useState } from "react";
import { FlowViewer } from "~/components/automation/FlowViewer";
import { FlowRunList } from "~/components/automation/FlowRunList";
+import { FlowRunDialog } from "~/components/automation/FlowRunDialog";
import { useFlow } from "~/hooks/useFlows";
import { useFlowRuns, useTriggerFlow } from "~/hooks/useFlowRuns";
@@ -35,21 +37,32 @@ export default function FlowDetailScreen() {
const { trigger, isTriggering } = useTriggerFlow();
const runs = runsData?.runs ?? [];
+ const [runDialogOpen, setRunDialogOpen] = useState(false);
+
+ const runFlow = async (params?: Record) => {
+ if (!flow) return;
+ const result = await trigger(flow.name, params ? { params } : undefined);
+ if (result.ok) {
+ toastSuccess(t("workflow.runStarted"));
+ } else {
+ toastError(result.error ?? t("workflow.runFailed"));
+ }
+ };
const handleRun = async () => {
if (!flow) return;
+ // Input-driven flows collect their variables first; field-less flows just
+ // confirm and run.
+ if (flow.variables.some((v) => v.isInput)) {
+ setRunDialogOpen(true);
+ return;
+ }
const ok = await confirm({
title: t("workflow.runFlow"),
message: t("workflow.runConfirm", { flow: flow.label }),
confirmLabel: t("workflow.runLabel"),
});
- if (!ok) return;
- const result = await trigger(flow.name);
- if (result.ok) {
- toastSuccess(t("workflow.runStarted"));
- } else {
- toastError(result.error ?? t("workflow.runFailed"));
- }
+ if (ok) await runFlow();
};
if (isLoading) {
@@ -149,6 +162,18 @@ export default function FlowDetailScreen() {
)}
+
+ setRunDialogOpen(false)}
+ onRun={async (params) => {
+ setRunDialogOpen(false);
+ await runFlow(params);
+ }}
+ />
);
}
diff --git a/components/automation/FlowRunDialog.tsx b/components/automation/FlowRunDialog.tsx
new file mode 100644
index 0000000..1ab4bc6
--- /dev/null
+++ b/components/automation/FlowRunDialog.tsx
@@ -0,0 +1,105 @@
+import React from "react";
+import { View, Text, ScrollView } from "react-native";
+import { useTranslation } from "react-i18next";
+import { Dialog } from "~/components/ui/Dialog";
+import { Button } from "~/components/ui/Button";
+import { FieldRenderer } from "~/components/renderers/fields/FieldRenderer";
+import type { FieldDefinition } from "~/components/renderers";
+import type { FlowVariable } from "~/hooks/useFlows";
+
+export interface FlowRunDialogProps {
+ open: boolean;
+ flowLabel: string;
+ /** The flow's input variables to collect before running. */
+ inputs: FlowVariable[];
+ isRunning?: boolean;
+ onCancel: () => void;
+ /** Run with the collected `{ varName: value }` params. */
+ onRun: (params: Record) => void;
+}
+
+function humanize(token: string): string {
+ // Flow variables are camelCase (e.g. `opportunityName`); split those too.
+ return token
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/** Coerce a date/datetime field value (epoch-ms or Date) to ISO-8601. */
+function normalizeValue(type: string | undefined, value: unknown): unknown {
+ if (value == null || value === "") return value;
+ if (type === "date" || type === "datetime") {
+ const d =
+ value instanceof Date
+ ? value
+ : typeof value === "number" || /^\d+$/.test(String(value).trim())
+ ? new Date(Number(value))
+ : null;
+ if (d && !isNaN(d.getTime())) return d.toISOString();
+ }
+ return value;
+}
+
+/**
+ * Collects an input-driven flow's variables before triggering it. Rendered only
+ * when the flow declares `isInput` variables; field-less flows run straight from
+ * the simple confirm dialog instead.
+ */
+export function FlowRunDialog({
+ open,
+ flowLabel,
+ inputs,
+ isRunning = false,
+ onCancel,
+ onRun,
+}: FlowRunDialogProps) {
+ const { t } = useTranslation();
+ const [values, setValues] = React.useState>({});
+
+ // Reset collected values whenever the dialog (re)opens.
+ React.useEffect(() => {
+ if (open) setValues({});
+ }, [open]);
+
+ const handleRun = () => {
+ const params: Record = {};
+ for (const v of inputs) {
+ params[v.name] = normalizeValue(v.type, values[v.name]);
+ }
+ onRun(params);
+ };
+
+ return (
+
+ );
+}