Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions __tests__/components/FlowRunDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<FlowRunDialog
open
flowLabel="Lead Conversion"
inputs={inputs}
onCancel={jest.fn()}
onRun={jest.fn()}
/>,
);
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(
<FlowRunDialog open flowLabel="f" inputs={inputs} onCancel={jest.fn()} onRun={onRun} />,
);
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(
<FlowRunDialog open flowLabel="f" inputs={inputs} onCancel={onCancel} onRun={jest.fn()} />,
);
fireEvent.press(getByText("Cancel"));
expect(onCancel).toHaveBeenCalled();
});
});
39 changes: 32 additions & 7 deletions app/flows/[name]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string, unknown>) => {
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) {
Expand Down Expand Up @@ -149,6 +162,18 @@ export default function FlowDetailScreen() {
)}
</View>
</ScrollView>

<FlowRunDialog
open={runDialogOpen}
flowLabel={flow.label}
inputs={inputs}
isRunning={isTriggering}
onCancel={() => setRunDialogOpen(false)}
onRun={async (params) => {
setRunDialogOpen(false);
await runFlow(params);
}}
/>
</SafeAreaView>
);
}
105 changes: 105 additions & 0 deletions components/automation/FlowRunDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => 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<Record<string, unknown>>({});

// Reset collected values whenever the dialog (re)opens.
React.useEffect(() => {
if (open) setValues({});
}, [open]);

const handleRun = () => {
const params: Record<string, unknown> = {};
for (const v of inputs) {
params[v.name] = normalizeValue(v.type, values[v.name]);
}
onRun(params);
};

return (
<Dialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)} title={t("workflow.runFlow")}>
<Text className="mb-3 text-sm text-muted-foreground">{flowLabel}</Text>
<ScrollView className="max-h-80" keyboardShouldPersistTaps="handled">
<View className="gap-4">
{inputs.map((v) => {
const field: FieldDefinition = {
name: v.name,
label: humanize(v.name),
type: (v.type ?? "text") as FieldDefinition["type"],
};
return (
<FieldRenderer
key={v.name}
field={field}
value={values[v.name]}
onChange={(val) => setValues((prev) => ({ ...prev, [v.name]: val }))}
/>
);
})}
</View>
</ScrollView>
<View className="mt-4 flex-row justify-end gap-3">
<Button variant="outline" onPress={onCancel} disabled={isRunning} className="flex-1">
{t("common.cancel")}
</Button>
<Button onPress={handleRun} loading={isRunning} className="flex-1">
{t("workflow.runLabel")}
</Button>
</View>
</Dialog>
);
}
Loading