Skip to content

Commit 32bebe8

Browse files
Merge pull request #64 from objectstack-ai/feat/wire-flow-viewer
feat(flows): surface automation flows (list + diagram)
2 parents e141ce5 + 6a0146c commit 32bebe8

6 files changed

Lines changed: 410 additions & 0 deletions

File tree

__tests__/hooks/useFlows.test.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Tests for useFlows — validates flow metadata fetch + normalization
3+
* (label fallback, node/edge/variable mapping) and the error path.
4+
*/
5+
import React from "react";
6+
import { renderHook, waitFor } from "@testing-library/react-native";
7+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
8+
9+
/* ---- Mock the authenticated fetch from lib/objectstack ---- */
10+
const mockApiFetch = jest.fn();
11+
jest.mock("~/lib/objectstack", () => ({
12+
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
13+
}));
14+
15+
import { useFlows, useFlow } from "~/hooks/useFlows";
16+
17+
function wrapper({ children }: { children: React.ReactNode }) {
18+
const client = new QueryClient({
19+
defaultOptions: { queries: { retry: false } },
20+
});
21+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
22+
}
23+
24+
function jsonResponse(body: unknown, ok = true, status = 200) {
25+
return { ok, status, json: async () => body } as unknown as Response;
26+
}
27+
28+
beforeEach(() => {
29+
mockApiFetch.mockReset();
30+
});
31+
32+
describe("useFlows", () => {
33+
it("fetches and normalizes flow definitions", async () => {
34+
mockApiFetch.mockResolvedValue(
35+
jsonResponse({
36+
type: "flow",
37+
items: [
38+
{
39+
name: "lead_conversion",
40+
label: "Lead Conversion Process",
41+
description: "Convert leads",
42+
version: 1,
43+
status: "draft",
44+
type: "screen",
45+
variables: [{ name: "leadId", type: "text", isInput: true }],
46+
nodes: [
47+
{ id: "start", type: "start", label: "Start" },
48+
{ id: "create", type: "create_record", label: "Create Account" },
49+
],
50+
edges: [{ id: "e1", source: "start", target: "create" }],
51+
},
52+
// Missing label → falls back to name; missing arrays → empty.
53+
{ name: "bare_flow" },
54+
// No name → filtered out.
55+
{ label: "ghost" },
56+
],
57+
}),
58+
);
59+
60+
const { result } = renderHook(() => useFlows(), { wrapper });
61+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
62+
63+
expect(mockApiFetch).toHaveBeenCalledWith("/api/v1/meta/flow");
64+
const flows = result.current.data!;
65+
expect(flows).toHaveLength(2);
66+
67+
const lead = flows[0];
68+
expect(lead.label).toBe("Lead Conversion Process");
69+
expect(lead.nodes).toHaveLength(2);
70+
expect(lead.edges[0]).toMatchObject({ source: "start", target: "create" });
71+
expect(lead.variables[0]).toMatchObject({ name: "leadId", isInput: true });
72+
73+
const bare = flows[1];
74+
expect(bare.label).toBe("bare_flow"); // label fallback
75+
expect(bare.nodes).toEqual([]);
76+
expect(bare.edges).toEqual([]);
77+
});
78+
79+
it("surfaces an error when the request fails", async () => {
80+
mockApiFetch.mockResolvedValue(jsonResponse(null, false, 500));
81+
82+
const { result } = renderHook(() => useFlows(), { wrapper });
83+
await waitFor(() => expect(result.current.isError).toBe(true));
84+
expect(result.current.error?.message).toMatch(/HTTP 500/);
85+
});
86+
});
87+
88+
describe("useFlow", () => {
89+
it("selects a single flow by name from the cached list", async () => {
90+
mockApiFetch.mockResolvedValue(
91+
jsonResponse({
92+
items: [
93+
{ name: "a", label: "Alpha", nodes: [], edges: [] },
94+
{ name: "b", label: "Beta", nodes: [], edges: [] },
95+
],
96+
}),
97+
);
98+
99+
const { result } = renderHook(() => useFlow("b"), { wrapper });
100+
await waitFor(() => expect(result.current.flow).toBeDefined());
101+
expect(result.current.flow?.label).toBe("Beta");
102+
});
103+
});

app/(tabs)/more.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Globe,
77
LogOut,
88
ChevronRight,
9+
Workflow,
910
} from "lucide-react-native";
1011
import { useRouter } from "expo-router";
1112
import { authClient } from "~/lib/auth-client";
@@ -104,6 +105,14 @@ export default function MoreScreen() {
104105
onPress={() => router.push("/(tabs)/notifications")}
105106
/>
106107

108+
{/* Automation */}
109+
<SectionHeader title="Automation" />
110+
<MenuItem
111+
icon={<Workflow size={20} color="#64748b" />}
112+
label="Flows"
113+
onPress={() => router.push("/flows")}
114+
/>
115+
107116
{/* Preferences */}
108117
<SectionHeader title="Preferences" />
109118
<MenuItem

app/_layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export default function RootLayout() {
105105
<Stack.Screen name="(tabs)" />
106106
<Stack.Screen name="(app)" />
107107
<Stack.Screen name="account" />
108+
<Stack.Screen name="flows" />
108109
</Stack>
109110
</ToastProvider>
110111
</SafeAreaProvider>

app/flows/[name].tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { View, Text, ScrollView } from "react-native";
2+
import { SafeAreaView } from "react-native-safe-area-context";
3+
import { useLocalSearchParams } from "expo-router";
4+
import { Workflow } from "lucide-react-native";
5+
import { ScreenHeader } from "~/components/common/ScreenHeader";
6+
import { Badge } from "~/components/ui/Badge";
7+
import { EmptyState } from "~/components/ui/EmptyState";
8+
import { ListSkeleton } from "~/components/ui/ListSkeleton";
9+
import { FlowViewer } from "~/components/automation/FlowViewer";
10+
import { useFlow } from "~/hooks/useFlows";
11+
12+
function humanize(token: string): string {
13+
return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
14+
}
15+
16+
/**
17+
* Flow detail — renders a read-only `FlowViewer` diagram of a single automation
18+
* flow's nodes and edges, plus its trigger/status metadata.
19+
*/
20+
export default function FlowDetailScreen() {
21+
const { name } = useLocalSearchParams<{ name: string }>();
22+
const flowName = Array.isArray(name) ? name[0] : name;
23+
const { flow, isLoading, error } = useFlow(flowName);
24+
25+
if (isLoading) {
26+
return (
27+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
28+
<ScreenHeader title="Flow" backFallback="/flows" />
29+
<ListSkeleton count={5} />
30+
</SafeAreaView>
31+
);
32+
}
33+
34+
if (error || !flow) {
35+
return (
36+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
37+
<ScreenHeader title="Flow" backFallback="/flows" />
38+
<EmptyState
39+
icon={Workflow}
40+
variant={error ? "error" : "default"}
41+
title={error ? "Couldn't load flow" : "Flow not found"}
42+
description={error ? error.message : `No flow named "${flowName}".`}
43+
/>
44+
</SafeAreaView>
45+
);
46+
}
47+
48+
const inputs = flow.variables.filter((v) => v.isInput);
49+
50+
return (
51+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
52+
<ScreenHeader title={flow.label} subtitle={flow.name} backFallback="/flows" />
53+
54+
{/* Metadata summary */}
55+
<View className="border-b border-border/40 px-4 py-3">
56+
{flow.description ? (
57+
<Text className="mb-2 text-sm text-muted-foreground">{flow.description}</Text>
58+
) : null}
59+
<View className="flex-row flex-wrap gap-2">
60+
{flow.type ? <Badge variant="secondary">{humanize(flow.type)}</Badge> : null}
61+
<Badge variant={flow.status === "active" || flow.active ? "default" : "outline"}>
62+
{flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"}
63+
</Badge>
64+
{typeof flow.version === "number" ? (
65+
<Badge variant="outline">{`v${flow.version}`}</Badge>
66+
) : null}
67+
{inputs.length > 0 ? (
68+
<Badge variant="outline">{`${inputs.length} input${inputs.length === 1 ? "" : "s"}`}</Badge>
69+
) : null}
70+
</View>
71+
</View>
72+
73+
{/* Flow diagram */}
74+
<ScrollView className="flex-1" contentContainerClassName="pb-8">
75+
<FlowViewer nodes={flow.nodes} edges={flow.edges} />
76+
</ScrollView>
77+
</SafeAreaView>
78+
);
79+
}

app/flows/index.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { View, Text, ScrollView } from "react-native";
2+
import { SafeAreaView } from "react-native-safe-area-context";
3+
import { useRouter } from "expo-router";
4+
import { Workflow } from "lucide-react-native";
5+
import { ScreenHeader } from "~/components/common/ScreenHeader";
6+
import { PressableCard } from "~/components/ui/PressableCard";
7+
import { Badge } from "~/components/ui/Badge";
8+
import { EmptyState } from "~/components/ui/EmptyState";
9+
import { ListSkeleton } from "~/components/ui/ListSkeleton";
10+
import { useFlows, type FlowDefinition } from "~/hooks/useFlows";
11+
12+
/** Title-case a flow trigger type / status token (`record_change` → "Record Change"). */
13+
function humanize(token: string): string {
14+
return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
15+
}
16+
17+
function FlowCard({ flow, onPress }: { flow: FlowDefinition; onPress: () => void }) {
18+
const stepCount = flow.nodes.length;
19+
return (
20+
<PressableCard
21+
onPress={onPress}
22+
className="mb-3 rounded-xl border border-border bg-card p-4"
23+
accessibilityLabel={`Open flow ${flow.label}`}
24+
>
25+
<Text className="text-base font-semibold text-foreground">{flow.label}</Text>
26+
{flow.description ? (
27+
<Text className="mt-0.5 text-sm text-muted-foreground" numberOfLines={2}>
28+
{flow.description}
29+
</Text>
30+
) : null}
31+
<View className="mt-2 flex-row flex-wrap gap-2">
32+
{flow.type ? <Badge variant="secondary">{humanize(flow.type)}</Badge> : null}
33+
<Badge variant={flow.status === "active" || flow.active ? "default" : "outline"}>
34+
{flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"}
35+
</Badge>
36+
<Badge variant="outline">{`${stepCount} step${stepCount === 1 ? "" : "s"}`}</Badge>
37+
</View>
38+
</PressableCard>
39+
);
40+
}
41+
42+
/**
43+
* Automation Flows — lists every flow the connected server exposes and links to
44+
* a read-only diagram of each. Surfaces the `FlowViewer` that previously had no
45+
* route into the app.
46+
*/
47+
export default function FlowsScreen() {
48+
const router = useRouter();
49+
const { data: flows, isLoading, error, refetch, isRefetching } = useFlows();
50+
51+
const count = flows?.length ?? 0;
52+
53+
return (
54+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
55+
<ScreenHeader
56+
title="Automation Flows"
57+
subtitle={count > 0 ? `${count} flow${count === 1 ? "" : "s"}` : undefined}
58+
/>
59+
{isLoading ? (
60+
<ListSkeleton count={6} />
61+
) : error ? (
62+
<EmptyState
63+
icon={Workflow}
64+
variant="error"
65+
title="Couldn't load flows"
66+
description={error.message}
67+
actionLabel="Retry"
68+
onAction={() => void refetch()}
69+
actionLoading={isRefetching}
70+
/>
71+
) : count === 0 ? (
72+
<EmptyState
73+
icon={Workflow}
74+
title="No flows defined"
75+
description="This server has no automation flows yet."
76+
/>
77+
) : (
78+
<ScrollView className="flex-1" contentContainerClassName="px-4 pt-4 pb-8">
79+
{flows!.map((flow) => (
80+
<FlowCard
81+
key={flow.name}
82+
flow={flow}
83+
onPress={() => router.push(`/flows/${encodeURIComponent(flow.name)}`)}
84+
/>
85+
))}
86+
</ScrollView>
87+
)}
88+
</SafeAreaView>
89+
);
90+
}

0 commit comments

Comments
 (0)