Skip to content
Open
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
63 changes: 13 additions & 50 deletions frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import toast from "react-hot-toast";
*/

import { Skeleton } from "@/components/ui/Skeleton";
import { useQueryClient } from "@tanstack/react-query";
import {
getDashboardAnalytics,
fetchDashboardData,
useDashboard,
dashboardQueryKey,
type DashboardSnapshot,
type Stream,
} from "@/lib/dashboard";
Expand Down Expand Up @@ -440,13 +443,13 @@ function renderRecentActivity(snapshot: DashboardSnapshot | null, onCreateStream
// ─── Main Component ───────────────────────────────────────────────────────────

export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = React.useState("overview");
const [showWizard, setShowWizard] = React.useState(false);
const [modal, setModal] = React.useState<ModalState>(null);

const [snapshot, setSnapshot] = React.useState<DashboardSnapshot | null>(null);
const [isSnapshotLoading, setIsSnapshotLoading] = React.useState(true);
const [snapshotError, setSnapshotError] = React.useState<string | null>(null);
const { data: snapshot = null, isLoading: isSnapshotLoading, error: queryError, refetch } = useDashboard(session.publicKey);
const snapshotError = queryError ? (queryError as Error).message : null;

const { events: streamEvents, connected, reconnecting, error } = useStreamEvents({
userPublicKeys: [session.publicKey],
Expand All @@ -459,15 +462,11 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
if (latestEvent) {
const relevantTypes = ["created", "topped_up", "withdrawn", "cancelled", "completed", "paused", "resumed"];
if (relevantTypes.includes(latestEvent.type)) {
fetchDashboardData(session.publicKey)
.then(setSnapshot)
.catch((err) => {
setSnapshotError(err instanceof Error ? err.message : "Failed to refresh dashboard");
});
queryClient.invalidateQueries({ queryKey: dashboardQueryKey(session.publicKey) });
}
}
}
}, [streamEvents, session.publicKey]);
}, [streamEvents, session.publicKey, queryClient]);

const [streamForm, setStreamForm] = React.useState<StreamFormValues>(EMPTY_STREAM_FORM);
const [templates, setTemplates] = React.useState<StreamTemplate[]>([]);
Expand Down Expand Up @@ -524,42 +523,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
persistTemplates(templates);
}, [templates, templatesHydrated]);

// ── Load dashboard snapshot ───────────────────────────────────────────────

const loadSnapshot = React.useCallback(async () => {
setIsSnapshotLoading(true);
setSnapshotError(null);
try {
const next = await fetchDashboardData(session.publicKey);
setSnapshot(next);
} catch (err) {
setSnapshot(null);
setSnapshotError(err instanceof Error ? err.message : "Failed to fetch dashboard data.");
} finally {
setIsSnapshotLoading(false);
}
}, [session.publicKey, setIsSnapshotLoading, setSnapshotError, setSnapshot]);

React.useEffect(() => {
let cancelled = false;
const run = async () => {
setIsSnapshotLoading(true);
setSnapshotError(null);
try {
const next = await fetchDashboardData(session.publicKey);
if (!cancelled) setSnapshot(next);
} catch (err) {
if (!cancelled) {
setSnapshot(null);
setSnapshotError(err instanceof Error ? err.message : "Failed to fetch dashboard data.");
}
} finally {
if (!cancelled) setIsSnapshotLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [session.publicKey]);
const loadSnapshot = () => refetch();

// ── Template handlers ─────────────────────────────────────────────────────

Expand Down Expand Up @@ -619,19 +583,19 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
// ── Optimistic helpers ────────────────────────────────────────────────────

const removeStreamLocally = (streamId: string) => {
setSnapshot((prev) => {
queryClient.setQueryData<DashboardSnapshot | undefined>(dashboardQueryKey(session.publicKey), (prev) => {
if (!prev) return prev;
return { ...prev, outgoingStreams: prev.outgoingStreams.map((s) => s.id === streamId ? { ...s, status: "Cancelled", isActive: false } : s), activeStreamsCount: Math.max(0, prev.activeStreamsCount - 1) };
});
};

const topUpStreamLocally = (streamId: string, amount: number) => {
setSnapshot((prev) => { if (!prev) return prev; return { ...prev, outgoingStreams: prev.outgoingStreams.map((s) => s.id === streamId ? { ...s, deposited: s.deposited + amount } : s) }; });
queryClient.setQueryData<DashboardSnapshot | undefined>(dashboardQueryKey(session.publicKey), (prev) => { if (!prev) return prev; return { ...prev, outgoingStreams: prev.outgoingStreams.map((s) => s.id === streamId ? { ...s, deposited: s.deposited + amount } : s) }; });
};

const addStreamLocally = (data: StreamFormData) => {
const newStream: Stream = { id: `stream-${Date.now()}`, date: new Date().toISOString().split("T")[0] ?? "", recipient: shortenPublicKey(data.recipient), amount: parseFloat(data.amount), token: data.token, status: "Active", deposited: parseFloat(data.amount), withdrawn: 0, ratePerSecond: 0, lastUpdateTime: Math.floor(Date.now() / 1000), isActive: true };
setSnapshot((prev) => { if (!prev) return prev; return { ...prev, outgoingStreams: [newStream, ...prev.outgoingStreams], activeStreamsCount: prev.activeStreamsCount + 1 }; });
queryClient.setQueryData<DashboardSnapshot | undefined>(dashboardQueryKey(session.publicKey), (prev) => { if (!prev) return prev; return { ...prev, outgoingStreams: [newStream, ...prev.outgoingStreams], activeStreamsCount: prev.activeStreamsCount + 1 }; });
};

// ── Contract handlers ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -685,8 +649,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
setWithdrawingIncomingStreamId(stream.id);
try {
await sorobanWithdraw(session, { streamId: BigInt(stream.id.replace(/\D/g, "") || "0") });
const refreshed = await fetchDashboardData(session.publicKey);
setSnapshot(refreshed);
await queryClient.invalidateQueries({ queryKey: dashboardQueryKey(session.publicKey) });
toast.success("Withdrawal successful!", { id: toastId });
} catch (err) {
toast.error(toSorobanErrorMessage(err), { id: toastId });
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/components/stream-creation/TokenStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,26 @@ export const TokenStep: React.FC<TokenStepProps> = ({
return (
<div className="space-y-4">
<div>
<h3 className="text-xl font-semibold mb-2">Select Token</h3>
<h3 id="token-group-label" className="text-xl font-semibold mb-2">Select Token</h3>
<p className="text-sm text-slate-400 mb-4">
Choose the token you want to stream to the recipient.
</p>
</div>

<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
className="grid grid-cols-1 md:grid-cols-3 gap-4"
role="radiogroup"
aria-labelledby="token-group-label"
aria-describedby={error ? "token-error" : undefined}
>
{TOKENS.map((token) => {
const isSelected = value === token.id;
return (
<button
key={token.id}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onChange(token.id)}
className={`p-4 rounded-lg border-2 transition-all text-left ${
isSelected
Expand Down Expand Up @@ -68,6 +75,7 @@ export const TokenStep: React.FC<TokenStepProps> = ({

{error && (
<p
id="token-error"
className="mt-2 text-sm text-red-400 flex items-center gap-1"
role="alert"
>
Expand Down
113 changes: 113 additions & 0 deletions frontend/src/hooks/useIncomingStreams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { renderHook, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import React from "react";
import {
useIncomingStreams,
useWithdrawIncomingStream,
incomingStreamsQueryKey,
} from "./useIncomingStreams";
import { fetchIncomingStreams } from "@/lib/api/streams";
import { withdrawFromStream } from "@/lib/soroban";

vi.mock("@/lib/api/streams", () => ({
fetchIncomingStreams: vi.fn(),
}));

vi.mock("@/lib/soroban", () => ({
withdrawFromStream: vi.fn(),
}));

describe("useIncomingStreams hooks", () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});

afterEach(() => {
queryClient.clear();
});

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

describe("incomingStreamsQueryKey", () => {
it("returns correct shape", () => {
expect(incomingStreamsQueryKey("pubkey")).toEqual([
"incoming-streams",
"pubkey",
]);
expect(incomingStreamsQueryKey(null)).toEqual(["incoming-streams", null]);
});
});

describe("useIncomingStreams", () => {
it("stays disabled when publicKey is null/undefined", () => {
const { result, rerender } = renderHook(
(props: { publicKey: string | null | undefined }) =>
useIncomingStreams(props.publicKey),
{ wrapper, initialProps: { publicKey: null } }
);

expect(result.current.isPending).toBe(true);
expect(result.current.fetchStatus).toBe("idle");
expect(fetchIncomingStreams).not.toHaveBeenCalled();

rerender({ publicKey: undefined });
expect(result.current.fetchStatus).toBe("idle");
expect(fetchIncomingStreams).not.toHaveBeenCalled();
});
});

describe("useWithdrawIncomingStream", () => {
it("rejects when session is null", async () => {
const { result } = renderHook(
() => useWithdrawIncomingStream(null, "pubkey"),
{ wrapper }
);

await expect(
result.current.mutateAsync({} as any)
).rejects.toThrow("Please connect your wallet first");
expect(withdrawFromStream).not.toHaveBeenCalled();
});

it("invalidates incomingStreamsQueryKey(publicKey) on success", async () => {
(withdrawFromStream as any).mockResolvedValue({ status: "success" });
(fetchIncomingStreams as any).mockResolvedValue([]);

const { result } = renderHook(
() => useWithdrawIncomingStream({} as any, "pubkey"),
{ wrapper }
);

const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");

await act(async () => {
await result.current.mutateAsync({
id: 1,
streamId: 1,
withdrawn: 0,
deposited: 100,
ratePerSecond: 1,
isPaused: false,
lastUpdateTime: Date.now() / 1000,
} as any);
});

// Wait for pollIndexerForWithdraw to complete and call invalidateQueries
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: incomingStreamsQueryKey("pubkey"),
});
}, { timeout: 10000 });
});
});
});
41 changes: 40 additions & 1 deletion frontend/src/hooks/useSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
useSettings,
STORAGE_KEYS,
formatAmountWithPreference,
DEFAULT_SETTINGS
DEFAULT_SETTINGS,
_resetSharedSettings
} from './useSettings';

const localStorageMock = (() => {
Expand All @@ -29,6 +30,7 @@ describe('useSettings and formatAmountWithPreference', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.className = '';
_resetSharedSettings();
});

afterEach(() => {
Expand Down Expand Up @@ -119,6 +121,43 @@ describe('useSettings and formatAmountWithPreference', () => {
expect(result.current.amountFormat).toBe('compact');
expect(localStorage.getItem(STORAGE_KEYS.amountFormat)).toBe('compact');
});

it('syncs state across multiple consumers without remount', async () => {
const { result: consumerA } = renderHook(() => useSettings());
const { result: consumerB } = renderHook(() => useSettings());

await waitFor(() => {
expect(consumerA.current.isHydrated).toBe(true);
expect(consumerB.current.isHydrated).toBe(true);
});

act(() => {
consumerA.current.setDecimalPlaces(2);
});

expect(consumerA.current.decimalPlaces).toBe(2);
expect(consumerB.current.decimalPlaces).toBe(2);
});

it('syncs state across tabs using storage event', async () => {
const { result } = renderHook(() => useSettings());

await waitFor(() => expect(result.current.isHydrated).toBe(true));

act(() => {
// Simulate other tab changing local storage
localStorage.setItem(STORAGE_KEYS.theme, 'light');

// Dispatch storage event
const event = new StorageEvent('storage', {
key: STORAGE_KEYS.theme,
newValue: 'light'
});
window.dispatchEvent(event);
});

expect(result.current.theme).toBe('light');
});
});

describe('formatAmountWithPreference', () => {
Expand Down
Loading