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
103 changes: 103 additions & 0 deletions __tests__/hooks/useFlows.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Tests for useFlows — validates flow metadata fetch + normalization
* (label fallback, node/edge/variable mapping) and the error path.
*/
import React from "react";
import { renderHook, waitFor } from "@testing-library/react-native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

/* ---- Mock the authenticated fetch from lib/objectstack ---- */
const mockApiFetch = jest.fn();
jest.mock("~/lib/objectstack", () => ({
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
}));

import { useFlows, useFlow } from "~/hooks/useFlows";

function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

function jsonResponse(body: unknown, ok = true, status = 200) {
return { ok, status, json: async () => body } as unknown as Response;
}

beforeEach(() => {
mockApiFetch.mockReset();
});

describe("useFlows", () => {
it("fetches and normalizes flow definitions", async () => {
mockApiFetch.mockResolvedValue(
jsonResponse({
type: "flow",
items: [
{
name: "lead_conversion",
label: "Lead Conversion Process",
description: "Convert leads",
version: 1,
status: "draft",
type: "screen",
variables: [{ name: "leadId", type: "text", isInput: true }],
nodes: [
{ id: "start", type: "start", label: "Start" },
{ id: "create", type: "create_record", label: "Create Account" },
],
edges: [{ id: "e1", source: "start", target: "create" }],
},
// Missing label → falls back to name; missing arrays → empty.
{ name: "bare_flow" },
// No name → filtered out.
{ label: "ghost" },
],
}),
);

const { result } = renderHook(() => useFlows(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(mockApiFetch).toHaveBeenCalledWith("/api/v1/meta/flow");
const flows = result.current.data!;
expect(flows).toHaveLength(2);

const lead = flows[0];
expect(lead.label).toBe("Lead Conversion Process");
expect(lead.nodes).toHaveLength(2);
expect(lead.edges[0]).toMatchObject({ source: "start", target: "create" });
expect(lead.variables[0]).toMatchObject({ name: "leadId", isInput: true });

const bare = flows[1];
expect(bare.label).toBe("bare_flow"); // label fallback
expect(bare.nodes).toEqual([]);
expect(bare.edges).toEqual([]);
});

it("surfaces an error when the request fails", async () => {
mockApiFetch.mockResolvedValue(jsonResponse(null, false, 500));

const { result } = renderHook(() => useFlows(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toMatch(/HTTP 500/);
});
});

describe("useFlow", () => {
it("selects a single flow by name from the cached list", async () => {
mockApiFetch.mockResolvedValue(
jsonResponse({
items: [
{ name: "a", label: "Alpha", nodes: [], edges: [] },
{ name: "b", label: "Beta", nodes: [], edges: [] },
],
}),
);

const { result } = renderHook(() => useFlow("b"), { wrapper });
await waitFor(() => expect(result.current.flow).toBeDefined());
expect(result.current.flow?.label).toBe("Beta");
});
});
9 changes: 9 additions & 0 deletions app/(tabs)/more.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Globe,
LogOut,
ChevronRight,
Workflow,
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { authClient } from "~/lib/auth-client";
Expand Down Expand Up @@ -104,6 +105,14 @@ export default function MoreScreen() {
onPress={() => router.push("/(tabs)/notifications")}
/>

{/* Automation */}
<SectionHeader title="Automation" />
<MenuItem
icon={<Workflow size={20} color="#64748b" />}
label="Flows"
onPress={() => router.push("/flows")}
/>

{/* Preferences */}
<SectionHeader title="Preferences" />
<MenuItem
Expand Down
1 change: 1 addition & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default function RootLayout() {
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(app)" />
<Stack.Screen name="account" />
<Stack.Screen name="flows" />
</Stack>
</ToastProvider>
</SafeAreaProvider>
Expand Down
79 changes: 79 additions & 0 deletions app/flows/[name].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { View, Text, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams } from "expo-router";
import { Workflow } from "lucide-react-native";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { Badge } from "~/components/ui/Badge";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { FlowViewer } from "~/components/automation/FlowViewer";
import { useFlow } from "~/hooks/useFlows";

function humanize(token: string): string {
return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

/**
* Flow detail — renders a read-only `FlowViewer` diagram of a single automation
* flow's nodes and edges, plus its trigger/status metadata.
*/
export default function FlowDetailScreen() {
const { name } = useLocalSearchParams<{ name: string }>();
const flowName = Array.isArray(name) ? name[0] : name;
const { flow, isLoading, error } = useFlow(flowName);

if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title="Flow" backFallback="/flows" />
<ListSkeleton count={5} />
</SafeAreaView>
);
}

if (error || !flow) {
return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title="Flow" backFallback="/flows" />
<EmptyState
icon={Workflow}
variant={error ? "error" : "default"}
title={error ? "Couldn't load flow" : "Flow not found"}
description={error ? error.message : `No flow named "${flowName}".`}
/>
</SafeAreaView>
);
}

const inputs = flow.variables.filter((v) => v.isInput);

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={flow.label} subtitle={flow.name} backFallback="/flows" />

{/* Metadata summary */}
<View className="border-b border-border/40 px-4 py-3">
{flow.description ? (
<Text className="mb-2 text-sm text-muted-foreground">{flow.description}</Text>
) : null}
<View className="flex-row flex-wrap gap-2">
{flow.type ? <Badge variant="secondary">{humanize(flow.type)}</Badge> : null}
<Badge variant={flow.status === "active" || flow.active ? "default" : "outline"}>
{flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"}
</Badge>
{typeof flow.version === "number" ? (
<Badge variant="outline">{`v${flow.version}`}</Badge>
) : null}
{inputs.length > 0 ? (
<Badge variant="outline">{`${inputs.length} input${inputs.length === 1 ? "" : "s"}`}</Badge>
) : null}
</View>
</View>

{/* Flow diagram */}
<ScrollView className="flex-1" contentContainerClassName="pb-8">
<FlowViewer nodes={flow.nodes} edges={flow.edges} />
</ScrollView>
</SafeAreaView>
);
}
90 changes: 90 additions & 0 deletions app/flows/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { View, Text, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { Workflow } from "lucide-react-native";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { PressableCard } from "~/components/ui/PressableCard";
import { Badge } from "~/components/ui/Badge";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useFlows, type FlowDefinition } from "~/hooks/useFlows";

/** Title-case a flow trigger type / status token (`record_change` → "Record Change"). */
function humanize(token: string): string {
return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

function FlowCard({ flow, onPress }: { flow: FlowDefinition; onPress: () => void }) {
const stepCount = flow.nodes.length;
return (
<PressableCard
onPress={onPress}
className="mb-3 rounded-xl border border-border bg-card p-4"
accessibilityLabel={`Open flow ${flow.label}`}
>
<Text className="text-base font-semibold text-foreground">{flow.label}</Text>
{flow.description ? (
<Text className="mt-0.5 text-sm text-muted-foreground" numberOfLines={2}>
{flow.description}
</Text>
) : null}
<View className="mt-2 flex-row flex-wrap gap-2">
{flow.type ? <Badge variant="secondary">{humanize(flow.type)}</Badge> : null}
<Badge variant={flow.status === "active" || flow.active ? "default" : "outline"}>
{flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"}
</Badge>
<Badge variant="outline">{`${stepCount} step${stepCount === 1 ? "" : "s"}`}</Badge>
</View>
</PressableCard>
);
}

/**
* Automation Flows — lists every flow the connected server exposes and links to
* a read-only diagram of each. Surfaces the `FlowViewer` that previously had no
* route into the app.
*/
export default function FlowsScreen() {
const router = useRouter();
const { data: flows, isLoading, error, refetch, isRefetching } = useFlows();

const count = flows?.length ?? 0;

return (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader
title="Automation Flows"
subtitle={count > 0 ? `${count} flow${count === 1 ? "" : "s"}` : undefined}
/>
{isLoading ? (
<ListSkeleton count={6} />
) : error ? (
<EmptyState
icon={Workflow}
variant="error"
title="Couldn't load flows"
description={error.message}
actionLabel="Retry"
onAction={() => void refetch()}
actionLoading={isRefetching}
/>
) : count === 0 ? (
<EmptyState
icon={Workflow}
title="No flows defined"
description="This server has no automation flows yet."
/>
) : (
<ScrollView className="flex-1" contentContainerClassName="px-4 pt-4 pb-8">
{flows!.map((flow) => (
<FlowCard
key={flow.name}
flow={flow}
onPress={() => router.push(`/flows/${encodeURIComponent(flow.name)}`)}
/>
))}
</ScrollView>
)}
</SafeAreaView>
);
}
Loading
Loading