From 1d542ca7235728b6dc0559278060805b16a1381d Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 30 Jul 2026 07:35:39 +0100 Subject: [PATCH] fix: persist create-stream form draft to sessionStorage across page reloads --- .../__tests__/create-stream-content.test.tsx | 242 ++++++++++++++++++ .../streams/create/create-stream-content.tsx | 148 ++++++++++- 2 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx diff --git a/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx b/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx new file mode 100644 index 00000000..543cbadd --- /dev/null +++ b/frontend/src/app/streams/create/__tests__/create-stream-content.test.tsx @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; +import React from "react"; + +// ─── Session storage mock ─────────────────────────────────────────────────── +const sessionStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + get length() { + return Object.keys(store).length; + }, + }; +})(); + +vi.stubGlobal("sessionStorage", sessionStorageMock); +if (typeof window !== "undefined") { + Object.defineProperty(window, "sessionStorage", { + value: sessionStorageMock, + configurable: true, + writable: true, + }); +} + +const searchParamsMock = { get: vi.fn() }; +const push = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + useSearchParams: () => searchParamsMock, +})); + +vi.mock("react-hot-toast", () => ({ + toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() }, +})); + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ status: "disconnected", session: null }), +})); + +vi.mock("@/lib/soroban", () => ({ + createStream: vi.fn(), + toBaseUnits: vi.fn(), + toDurationSeconds: vi.fn(), + getTokenAddress: vi.fn(), + toSorobanErrorMessage: vi.fn(), + TOKEN_ADDRESSES: { XLM: "xlm-address", USDC: "usdc-address" }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +import CreateStreamContent from "../create-stream-content"; + +function getRecipientInput() { + return screen.getByLabelText(/recipient/i) as HTMLInputElement; +} + +function getAmountInput() { + return screen.getByLabelText(/total amount/i) as HTMLInputElement; +} + +function getDurationInput() { + return screen.getByLabelText(/duration/i) as HTMLInputElement; +} + +describe("CreateStreamContent sessionStorage draft persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorageMock.clear(); + searchParamsMock.get.mockReturnValue(null); + }); + + it("saves form data to sessionStorage when fields change", async () => { + render(); + + const recipientInput = getRecipientInput(); + const amountInput = getAmountInput(); + + await act(async () => { + fireEvent.change(recipientInput, { + target: { value: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" }, + }); + }); + + await act(async () => { + fireEvent.change(amountInput, { target: { value: "100" } }); + }); + + // Wait for debounced save + await waitFor( + () => { + expect(sessionStorageMock.setItem).toHaveBeenCalled(); + }, + { timeout: 1000 } + ); + + const lastCallArgs = + sessionStorageMock.setItem.mock.calls[ + sessionStorageMock.setItem.mock.calls.length - 1 + ]; + expect(lastCallArgs[0]).toBe("flowfi.create-stream.draft.v1"); + + const savedData = JSON.parse(lastCallArgs[1]); + expect(savedData.recipient).toBe( + "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + ); + expect(savedData.amount).toBe("100"); + expect(savedData.token).toBe("XLM"); + expect(typeof savedData.savedAt).toBe("number"); + }); + + it("restores draft from sessionStorage on mount", async () => { + const draft = { + recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + token: "USDC", + amount: "250", + duration: "60", + savedAt: Date.now() - 30000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + + render(); + + await waitFor(() => { + expect(getRecipientInput().value).toBe( + "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + ); + }); + + expect(getAmountInput().value).toBe("250"); + expect(getDurationInput().value).toBe("60"); + + // Should show the resume draft banner + expect(screen.getByText(/resumed a saved draft/i)).toBeInTheDocument(); + }); + + it("shows resume draft banner when draft is restored", async () => { + const draft = { + recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + token: "XLM", + amount: "50", + duration: "30", + savedAt: Date.now() - 60000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + + render(); + + await waitFor(() => { + expect(screen.getByText(/resumed a saved draft/i)).toBeInTheDocument(); + }); + + expect(screen.getByText(/discard draft/i)).toBeInTheDocument(); + }); + + it("clears form and removes draft when 'Discard Draft' is clicked", async () => { + const draft = { + recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7", + token: "XLM", + amount: "50", + duration: "30", + savedAt: Date.now() - 60000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + + render(); + + await waitFor(() => { + expect(screen.getByText(/discard draft/i)).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByText(/discard draft/i)); + }); + + expect(sessionStorageMock.removeItem).toHaveBeenCalledWith( + "flowfi.create-stream.draft.v1" + ); + + // The banner should be gone + expect(screen.queryByText(/resumed a saved draft/i)).not.toBeInTheDocument(); + }); + + it("does not prefill from query params when a draft is restored", async () => { + const draft = { + recipient: "GDRAFT_RESTORED_ADDRESS_12345678901234567890123456789012345678901", + token: "XLM", + amount: "10", + duration: "30", + savedAt: Date.now() - 30000, + }; + sessionStorageMock.getItem.mockReturnValue(JSON.stringify(draft)); + searchParamsMock.get.mockImplementation( + (key: string) => + key === "recipient" + ? "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + : null + ); + + render(); + + await waitFor(() => { + expect(getRecipientInput().value).toBe( + "GDRAFT_RESTORED_ADDRESS_12345678901234567890123456789012345678901" + ); + }); + + // The draft value should take precedence, not the query param + expect(getRecipientInput().value).not.toBe( + "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7" + ); + }); + + it("does not show draft banner if no draft exists", () => { + sessionStorageMock.getItem.mockReturnValue(null); + + render(); + + expect(screen.queryByText(/resumed a saved draft/i)).not.toBeInTheDocument(); + }); + + it("shows default empty form when no draft exists", () => { + sessionStorageMock.getItem.mockReturnValue(null); + + render(); + + expect(getRecipientInput().value).toBe(""); + expect(getAmountInput().value).toBe(""); + expect(getDurationInput().value).toBe("30"); + }); +}); diff --git a/frontend/src/app/streams/create/create-stream-content.tsx b/frontend/src/app/streams/create/create-stream-content.tsx index 2ddfef17..f367aa94 100644 --- a/frontend/src/app/streams/create/create-stream-content.tsx +++ b/frontend/src/app/streams/create/create-stream-content.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { logger } from "@/lib/logger"; import { createStream, @@ -14,10 +14,52 @@ import { hasValidPrecision, validateAmountInput } from "@/utils/amount"; import { toast } from "react-hot-toast"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, FileText, X } from "lucide-react"; import { useWallet } from "@/context/wallet-context"; const TOKEN_DECIMALS = 7; +const DRAFT_STORAGE_KEY = "flowfi.create-stream.draft.v1"; + +interface StreamDraft { + recipient: string; + token: string; + amount: string; + duration: string; + savedAt: number; +} + +function saveDraftToSession(data: StreamDraft): void { + try { + if (typeof window === "undefined") return; + sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data)); + } catch { + // sessionStorage may be full or unavailable + } +} + +function loadDraftFromSession(): StreamDraft | null { + try { + if (typeof window === "undefined") return null; + const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as StreamDraft; + if (parsed && typeof parsed.recipient === "string") { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clearDraft(): void { + try { + if (typeof window === "undefined") return; + sessionStorage.removeItem(DRAFT_STORAGE_KEY); + } catch { + // ignore + } +} export default function CreateStreamContent() { const { status, session } = useWallet(); @@ -26,16 +68,59 @@ export default function CreateStreamContent() { const [nowTimestamp] = useState(() => Date.now()); const [loading, setLoading] = useState(false); const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle"); - const [formData, setFormData] = useState({ - recipient: "", - token: "XLM", - amount: "", - duration: "30", + const [draftRestored, setDraftRestored] = useState(false); + const [dismissedDraftBanner, setDismissedDraftBanner] = useState(false); + + const [formData, setFormData] = useState<{ + recipient: string; + token: string; + amount: string; + duration: string; + }>(() => { + // Restore draft from sessionStorage on initial mount + const draft = loadDraftFromSession(); + if (draft) { + return { + recipient: draft.recipient, + token: draft.token, + amount: draft.amount, + duration: draft.duration, + }; + } + return { + recipient: "", + token: "XLM", + amount: "", + duration: "30", + }; }); + // Set flag if a draft was restored + useEffect(() => { + const draft = loadDraftFromSession(); + if (draft) { + setDraftRestored(true); + } + }, []); + + // Persist form data to sessionStorage whenever it changes + useEffect(() => { + const timer = setTimeout(() => { + saveDraftToSession({ + recipient: formData.recipient, + token: formData.token, + amount: formData.amount, + duration: formData.duration, + savedAt: Date.now(), + }); + }, 500); // debounce writes + return () => clearTimeout(timer); + }, [formData]); + + // Handle recipient prefill from search params — but only if no draft is restored useEffect(() => { const recipientParam = searchParams.get("recipient"); - if (!recipientParam) return; + if (!recipientParam || draftRestored) return; import("@stellar/stellar-sdk").then(({ StrKey }) => { if (StrKey.isValidEd25519PublicKey(recipientParam)) { @@ -44,7 +129,7 @@ export default function CreateStreamContent() { logger.warn("Ignoring malformed recipient query param", { recipientParam }); } }); - }, [searchParams]); + }, [searchParams, draftRestored]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -76,6 +161,7 @@ export default function CreateStreamContent() { if (result.success) { setTxState("confirming"); + clearDraft(); // Clear draft on successful submission toast.success("Stream created successfully!"); setTimeout(() => { router.push("/dashboard"); @@ -104,15 +190,53 @@ export default function CreateStreamContent() { ? validateAmountInput(formData.amount, TOKEN_DECIMALS) : null; + const handleDismissDraft = useCallback(() => { + clearDraft(); + setDraftRestored(false); + setDismissedDraftBanner(true); + setFormData({ + recipient: "", + token: "XLM", + amount: "", + duration: "30", + }); + }, []); + + const handleBack = useCallback(() => { + // Navigate back without clearing the draft so it persists across navigation + router.push("/dashboard"); + }, [router]); + return (
- Back to Dashboard - + + + {/* Resume draft banner */} + {draftRestored && !dismissedDraftBanner && ( +
+ + + Resumed a saved draft from{" "} + {new Date( + loadDraftFromSession()?.savedAt ?? Date.now() + ).toLocaleTimeString()} + . You can continue editing or start fresh. + + +
+ )}

Create New Stream