diff --git a/frontend/src/app/activity/activity-content.tsx b/frontend/src/app/activity/activity-content.tsx index 2cd7492c..a202be34 100644 --- a/frontend/src/app/activity/activity-content.tsx +++ b/frontend/src/app/activity/activity-content.tsx @@ -10,6 +10,7 @@ import { formatAmount } from "@/utils/amount"; import { downloadCSV } from "@/utils/csvExport"; import { getApiBaseUrl } from "@/lib/api/_shared"; import { logger } from "@/lib/logger"; +import { useAsyncPageData } from "@/hooks/useAsyncPageData"; const PAGE_SIZE = 10; const API_BASE_URL = getApiBaseUrl(); @@ -31,6 +32,11 @@ export default function ActivityContent() { const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); + const pageState = useAsyncPageData({ + isLoading: loading && events.length === 0, + data: events, + }); + const fetchActivity = useCallback( async (pageNum: number, tab: string, append: boolean = false, signal?: AbortSignal) => { if (!session?.publicKey) return; @@ -89,18 +95,18 @@ export default function ActivityContent() { }; const handleExportCSV = () => { - const csvData = events.map(event => ({ - 'Stream ID': event.streamId, - 'Event Type': event.eventType, - 'Amount': event.amount ? formatAmount(BigInt(event.amount), 7) : '0', - 'Timestamp': new Date(event.timestamp * 1000).toLocaleString(), - 'Transaction Hash': event.transactionHash, - 'Ledger': event.ledgerSequence, + const csvData = events.map((event) => ({ + "Stream ID": event.streamId, + "Event Type": event.eventType, + Amount: event.amount ? formatAmount(BigInt(event.amount), 7) : "0", + Timestamp: new Date(event.timestamp * 1000).toLocaleString(), + "Transaction Hash": event.transactionHash, + Ledger: event.ledgerSequence, })); downloadCSV(csvData, `flowfi-activity-${Date.now()}.csv`); }; - if (status !== "connected") { + if (!pageState.isConnected) { return (

Access Denied

@@ -152,7 +158,7 @@ export default function ActivityContent() { ))}
- + {hasMore && (
diff --git a/frontend/src/app/incoming/incoming-content.tsx b/frontend/src/app/incoming/incoming-content.tsx index a5af7ede..9186c835 100644 --- a/frontend/src/app/incoming/incoming-content.tsx +++ b/frontend/src/app/incoming/incoming-content.tsx @@ -16,6 +16,7 @@ import { useIncomingStreams, useWithdrawIncomingStream, } from "@/hooks/useIncomingStreams"; +import { useAsyncPageData } from "@/hooks/useAsyncPageData"; interface TrackerState { status: TransactionStatus; @@ -40,12 +41,20 @@ function LoadingCard() { } export default function IncomingContent() { - const { session, status, isHydrated } = useWallet(); + const { session, status } = useWallet(); const [tracker, setTracker] = React.useState({ status: "idle", }); const incomingStreamsQuery = useIncomingStreams(session?.publicKey); + + const pageState = useAsyncPageData({ + isLoading: incomingStreamsQuery.isLoading, + isError: incomingStreamsQuery.isError, + error: incomingStreamsQuery.error, + data: incomingStreamsQuery.data ?? [], + }); + const withdrawMutation = useWithdrawIncomingStream( session, session?.publicKey, @@ -91,9 +100,6 @@ export default function IncomingContent() { } }; - const isLoading = - !isHydrated || - (status === "connected" && incomingStreamsQuery.isLoading); const streams = incomingStreamsQuery.data ?? []; return ( @@ -124,13 +130,13 @@ export default function IncomingContent() {
- {!isHydrated ? ( + {!pageState.isHydrated ? (
- ) : status !== "connected" ? ( + ) : !pageState.isConnected ? (

Connect a wallet to view incoming streams @@ -140,24 +146,22 @@ export default function IncomingContent() { where you are the recipient and keep the claimable balance fresh.

- ) : incomingStreamsQuery.isError ? ( + ) : pageState.isError ? (

We couldn't load your incoming streams

- {incomingStreamsQuery.error instanceof Error - ? incomingStreamsQuery.error.message - : "Please try again in a moment."} + {pageState.errorMessage || "Please try again in a moment."}

- ) : isLoading ? ( + ) : pageState.isLoading ? (
- ) : streams.length === 0 ? ( + ) : pageState.isEmpty ? (

No incoming streams yet diff --git a/frontend/src/hooks/useAsyncPageData.test.ts b/frontend/src/hooks/useAsyncPageData.test.ts new file mode 100644 index 00000000..57931f03 --- /dev/null +++ b/frontend/src/hooks/useAsyncPageData.test.ts @@ -0,0 +1,97 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useAsyncPageData } from "./useAsyncPageData"; +import { useWallet } from "@/context/wallet-context"; + +vi.mock("@/context/wallet-context", () => ({ + useWallet: vi.fn(), +})); + +describe("useAsyncPageData", () => { + const mockUseWallet = vi.mocked(useWallet); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns isLoading true when not hydrated", () => { + mockUseWallet.mockReturnValue({ + session: null, + status: "disconnected", + isHydrated: false, + } as any); + + const { result } = renderHook(() => useAsyncPageData({ data: [] })); + + expect(result.current.isHydrated).toBe(false); + expect(result.current.isConnected).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isEmpty).toBe(false); + }); + + it("returns isConnected false when status is disconnected", () => { + mockUseWallet.mockReturnValue({ + session: null, + status: "disconnected", + isHydrated: true, + } as any); + + const { result } = renderHook(() => useAsyncPageData({ data: [] })); + + expect(result.current.isConnected).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isEmpty).toBe(false); + }); + + it("handles loading state when wallet is connected", () => { + mockUseWallet.mockReturnValue({ + session: { publicKey: "G123" }, + status: "connected", + isHydrated: true, + } as any); + + const { result } = renderHook(() => + useAsyncPageData({ isLoading: true, data: undefined }) + ); + + expect(result.current.isConnected).toBe(true); + expect(result.current.isLoading).toBe(true); + expect(result.current.isEmpty).toBe(false); + }); + + it("evaluates empty state when data is empty array", () => { + mockUseWallet.mockReturnValue({ + session: { publicKey: "G123" }, + status: "connected", + isHydrated: true, + } as any); + + const { result } = renderHook(() => + useAsyncPageData({ isLoading: false, data: [] }) + ); + + expect(result.current.isConnected).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.isEmpty).toBe(true); + }); + + it("parses Error message on error state", () => { + mockUseWallet.mockReturnValue({ + session: { publicKey: "G123" }, + status: "connected", + isHydrated: true, + } as any); + + const { result } = renderHook(() => + useAsyncPageData({ + isLoading: false, + isError: true, + error: new Error("Network failure"), + }) + ); + + expect(result.current.isError).toBe(true); + expect(result.current.errorMessage).toBe("Network failure"); + expect(result.current.isEmpty).toBe(false); + }); +}); diff --git a/frontend/src/hooks/useAsyncPageData.ts b/frontend/src/hooks/useAsyncPageData.ts new file mode 100644 index 00000000..8c496a9c --- /dev/null +++ b/frontend/src/hooks/useAsyncPageData.ts @@ -0,0 +1,51 @@ +import { useWallet } from "@/context/wallet-context"; + +export interface AsyncPageDataOptions { + isLoading?: boolean; + isError?: boolean; + error?: unknown; + data?: T[]; +} + +export interface AsyncPageDataState { + isHydrated: boolean; + isConnected: boolean; + isLoading: boolean; + isError: boolean; + errorMessage?: string; + isEmpty: boolean; +} + +/** + * Shared hook to encapsulate async page state (hydration, wallet connection, + * loading state, error parsing, and empty state evaluation). + */ +export function useAsyncPageData(options: AsyncPageDataOptions): AsyncPageDataState { + const { session, status, isHydrated } = useWallet(); + + const isConnected = status === "connected" && Boolean(session?.publicKey); + const isLoading = !isHydrated || (isConnected && Boolean(options.isLoading)); + const isError = isConnected && Boolean(options.isError); + const isEmpty = + isConnected && + !isLoading && + !isError && + Array.isArray(options.data) && + options.data.length === 0; + + const errorMessage = + options.error instanceof Error + ? options.error.message + : typeof options.error === "string" + ? options.error + : undefined; + + return { + isHydrated, + isConnected, + isLoading, + isError, + errorMessage, + isEmpty, + }; +}