From b36ddb9b87614afae56e9319c1fc3309ca0d527c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:26:42 +0800 Subject: [PATCH] feat(flows): collect input variables before running an input-driven flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flows with `isInput` variables (e.g. lead_conversion's leadId / createOpportunity / opportunityName / opportunityAmount) previously triggered with an empty context. Now the Run button opens a param-collection dialog for those flows; field-less flows keep the simple confirm. - FlowRunDialog renders a FieldRenderer per input variable (typed: text/toggle/ number/date…), collects values, normalizes date/datetime to ISO-8601, and hands back a params object. - Flow detail routes Run → dialog when inputs exist, else confirm; both call the shared runFlow(). Collected values are sent as `{ params: { : value } }`, matching the engine's `context.params[varName]` binding. - camelCase variable names are humanized for labels (opportunityName → "Opportunity Name"). Verified in-browser against the local 7.5.0 server: Run on lead_conversion opens the dialog with all 4 typed inputs; filling them and running posts `{"params":{"leadId":"lead-123","opportunityName":"Acme Renewal"}}`. Adds FlowRunDialog tests; typecheck + lint clean; full suite green (1164 passed). Co-Authored-By: Claude Opus 4.8 --- __tests__/components/FlowRunDialog.test.tsx | 65 ++++++++++++ app/flows/[name]/index.tsx | 39 ++++++-- components/automation/FlowRunDialog.tsx | 105 ++++++++++++++++++++ 3 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 __tests__/components/FlowRunDialog.test.tsx create mode 100644 components/automation/FlowRunDialog.tsx 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 ( + (!o ? onCancel() : undefined)} title={t("workflow.runFlow")}> + {flowLabel} + + + {inputs.map((v) => { + const field: FieldDefinition = { + name: v.name, + label: humanize(v.name), + type: (v.type ?? "text") as FieldDefinition["type"], + }; + return ( + setValues((prev) => ({ ...prev, [v.name]: val }))} + /> + ); + })} + + + + + + + + ); +}