From 6a0146c92a93486efed00d698ab66de3e9e45fdd Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:55:06 +0800 Subject: [PATCH] feat(flows): surface automation flows with a list + diagram screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FlowViewer component existed and was unit-tested but had no route into the app. Wire it up: - useFlows / useFlow hooks fetch flow definitions from /api/v1/meta/flow (react-query), normalizing nodes/edges/variables into the FlowViewer shape. - app/flows/index.tsx: lists every flow with trigger-type / status / step badges; loading, error (retry), and empty states. - app/flows/[name].tsx: renders the read-only FlowViewer diagram for one flow plus its trigger/status/version/input metadata. - More tab gains an "Automation → Flows" entry; route registered in the Stack. Verified in-browser against a local 7.5.0 server (15 flows listed; Lead Conversion Process diagram renders Start → … → Create Contact with edges). Adds useFlows unit tests; typecheck clean; full jest suite unchanged. Co-Authored-By: Claude Opus 4.8 --- __tests__/hooks/useFlows.test.tsx | 103 ++++++++++++++++++++++++ app/(tabs)/more.tsx | 9 +++ app/_layout.tsx | 1 + app/flows/[name].tsx | 79 ++++++++++++++++++ app/flows/index.tsx | 90 +++++++++++++++++++++ hooks/useFlows.ts | 128 ++++++++++++++++++++++++++++++ 6 files changed, 410 insertions(+) create mode 100644 __tests__/hooks/useFlows.test.tsx create mode 100644 app/flows/[name].tsx create mode 100644 app/flows/index.tsx create mode 100644 hooks/useFlows.ts diff --git a/__tests__/hooks/useFlows.test.tsx b/__tests__/hooks/useFlows.test.tsx new file mode 100644 index 0000000..d1faa77 --- /dev/null +++ b/__tests__/hooks/useFlows.test.tsx @@ -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 {children}; +} + +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"); + }); +}); diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx index e873984..1f42d40 100644 --- a/app/(tabs)/more.tsx +++ b/app/(tabs)/more.tsx @@ -6,6 +6,7 @@ import { Globe, LogOut, ChevronRight, + Workflow, } from "lucide-react-native"; import { useRouter } from "expo-router"; import { authClient } from "~/lib/auth-client"; @@ -104,6 +105,14 @@ export default function MoreScreen() { onPress={() => router.push("/(tabs)/notifications")} /> + {/* Automation */} + + } + label="Flows" + onPress={() => router.push("/flows")} + /> + {/* Preferences */} + diff --git a/app/flows/[name].tsx b/app/flows/[name].tsx new file mode 100644 index 0000000..f1b48e9 --- /dev/null +++ b/app/flows/[name].tsx @@ -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 ( + + + + + ); + } + + if (error || !flow) { + return ( + + + + + ); + } + + const inputs = flow.variables.filter((v) => v.isInput); + + return ( + + + + {/* Metadata summary */} + + {flow.description ? ( + {flow.description} + ) : null} + + {flow.type ? {humanize(flow.type)} : null} + + {flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"} + + {typeof flow.version === "number" ? ( + {`v${flow.version}`} + ) : null} + {inputs.length > 0 ? ( + {`${inputs.length} input${inputs.length === 1 ? "" : "s"}`} + ) : null} + + + + {/* Flow diagram */} + + + + + ); +} diff --git a/app/flows/index.tsx b/app/flows/index.tsx new file mode 100644 index 0000000..a50e2d0 --- /dev/null +++ b/app/flows/index.tsx @@ -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 ( + + {flow.label} + {flow.description ? ( + + {flow.description} + + ) : null} + + {flow.type ? {humanize(flow.type)} : null} + + {flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"} + + {`${stepCount} step${stepCount === 1 ? "" : "s"}`} + + + ); +} + +/** + * 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 ( + + 0 ? `${count} flow${count === 1 ? "" : "s"}` : undefined} + /> + {isLoading ? ( + + ) : error ? ( + void refetch()} + actionLoading={isRefetching} + /> + ) : count === 0 ? ( + + ) : ( + + {flows!.map((flow) => ( + router.push(`/flows/${encodeURIComponent(flow.name)}`)} + /> + ))} + + )} + + ); +} diff --git a/hooks/useFlows.ts b/hooks/useFlows.ts new file mode 100644 index 0000000..67ed85e --- /dev/null +++ b/hooks/useFlows.ts @@ -0,0 +1,128 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { apiFetch } from "~/lib/objectstack"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface FlowNode { + id: string; + type: string; + label: string; + config?: Record; +} + +export interface FlowEdge { + id: string; + source: string; + target: string; + label?: string; +} + +export interface FlowVariable { + name: string; + type?: string; + isInput?: boolean; + isOutput?: boolean; +} + +/** + * A flow definition as returned by the metadata API (`/api/v1/meta/flow`). + * Shape mirrors `@objectstack/spec` flow metadata: a list of `nodes` plus the + * `edges` connecting them, which map directly onto the `FlowViewer` diagram. + */ +export interface FlowDefinition { + name: string; + label: string; + description?: string; + version?: number; + status?: string; + /** Trigger kind: `schedule`, `record`, `manual`, … */ + type?: string; + active?: boolean; + variables: FlowVariable[]; + nodes: FlowNode[]; + edges: FlowEdge[]; +} + +/* ------------------------------------------------------------------ */ +/* Fetch + normalize */ +/* ------------------------------------------------------------------ */ + +function normalizeFlow(raw: Record): FlowDefinition { + const nodes = Array.isArray(raw.nodes) ? (raw.nodes as Record[]) : []; + const edges = Array.isArray(raw.edges) ? (raw.edges as Record[]) : []; + const variables = Array.isArray(raw.variables) + ? (raw.variables as Record[]) + : []; + const name = String(raw.name ?? ""); + return { + name, + label: String(raw.label ?? name), + description: raw.description ? String(raw.description) : undefined, + version: typeof raw.version === "number" ? raw.version : undefined, + status: raw.status ? String(raw.status) : undefined, + type: raw.type ? String(raw.type) : undefined, + active: typeof raw.active === "boolean" ? raw.active : undefined, + variables: variables.map((v) => ({ + name: String(v.name ?? ""), + type: v.type ? String(v.type) : undefined, + isInput: v.isInput === true, + isOutput: v.isOutput === true, + })), + nodes: nodes.map((n, i) => ({ + id: String(n.id ?? `node-${i}`), + type: String(n.type ?? "step"), + label: String(n.label ?? n.id ?? `Step ${i + 1}`), + config: (n.config as Record) ?? undefined, + })), + edges: edges.map((e, i) => ({ + id: String(e.id ?? `edge-${i}`), + source: String(e.source ?? ""), + target: String(e.target ?? ""), + label: e.label ? String(e.label) : undefined, + })), + }; +} + +async function fetchFlows(): Promise { + const res = await apiFetch("/api/v1/meta/flow"); + if (!res.ok) { + throw new Error(`Failed to load flows (HTTP ${res.status})`); + } + const json = (await res.json()) as { items?: unknown }; + const items = Array.isArray(json?.items) ? (json.items as Record[]) : []; + return items.map(normalizeFlow).filter((f) => f.name.length > 0); +} + +/* ------------------------------------------------------------------ */ +/* Hooks */ +/* ------------------------------------------------------------------ */ + +/** + * Load all automation flow definitions exposed by the connected server. + * Backed by react-query so the list and any detail view share one fetch. + */ +export function useFlows(): UseQueryResult { + return useQuery({ + queryKey: ["flows"], + queryFn: fetchFlows, + staleTime: 60_000, + }); +} + +/** + * Convenience selector for a single flow by name, reusing the cached list. + */ +export function useFlow(name: string | undefined): { + flow: FlowDefinition | undefined; + isLoading: boolean; + error: Error | null; +} { + const { data, isLoading, error } = useFlows(); + return { + flow: name ? data?.find((f) => f.name === name) : undefined, + isLoading, + error: error ?? null, + }; +}