diff --git a/frontend/src/components/OfflineBanner.test.tsx b/frontend/src/components/OfflineBanner.test.tsx index 3bb8bb0c..37133fce 100644 --- a/frontend/src/components/OfflineBanner.test.tsx +++ b/frontend/src/components/OfflineBanner.test.tsx @@ -9,71 +9,124 @@ vi.mock("../lib/queryClient", () => ({ }, })); -describe("OfflineBanner", () => { - let originalOnLine: boolean; +// Mock the hooks +vi.mock("../hooks/useNetworkStatus", () => ({ + useNetworkStatus: vi.fn(), +})); + +vi.mock("../hooks/useRetryState", () => ({ + useRetryState: vi.fn(), +})); +import { useNetworkStatus } from "../hooks/useNetworkStatus"; +import { useRetryState } from "../hooks/useRetryState"; + +describe("OfflineBanner", () => { beforeEach(() => { - originalOnLine = navigator.onLine; vi.useFakeTimers(); + // Default mocks + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); }); afterEach(() => { - Object.defineProperty(navigator, 'onLine', { - value: originalOnLine, - configurable: true, - }); vi.clearAllMocks(); vi.useRealTimers(); }); - const setOnline = (value: boolean) => { - Object.defineProperty(navigator, 'onLine', { - value, - configurable: true, - }); - }; - - it("should hide by default when online", () => { - setOnline(true); + it("should hide by default when online and not retrying", () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); - it("should show offline banner when offline event fires", () => { - setOnline(true); + it("should show offline banner when offline", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); render(); - - act(() => { - setOnline(false); - window.dispatchEvent(new Event("offline")); - }); - expect(screen.getByText(/You are currently offline/i)).toBeInTheDocument(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); // Non-dismissible + expect(screen.getByText(/You are offline/i)).toBeInTheDocument(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + // Offline banner should NOT be dismissible + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); - it("should show success banner and invalidate queries on online event, then auto-dismiss", () => { - setOnline(false); + it("should have role alert and aria-live assertive when offline", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); render(); - - expect(screen.getByText(/You are currently offline/i)).toBeInTheDocument(); - act(() => { - setOnline(true); - window.dispatchEvent(new Event("online")); - }); + const banner = screen.getByRole("alert"); + expect(banner).toHaveAttribute("aria-live", "assertive"); + expect(banner).toHaveAttribute("aria-atomic", "true"); + }); + + it("should display offline message with last known data", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); + render(); - // Should show success banner + expect(screen.getByText(/You are offline/i)).toBeInTheDocument(); + expect(screen.getByText(/TVL:.*1,000,000/i)).toBeInTheDocument(); + expect(screen.getByText(/Balance:.*100/i)).toBeInTheDocument(); + }); + + it("should show retrying state with countdown when retrying", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + render(); + + expect(screen.getByText(/Reconnecting.*retrying in 5s/i)).toBeInTheDocument(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("should have role status and aria-live polite when retrying", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + render(); + + const banner = screen.getByRole("status"); + expect(banner).toHaveAttribute("aria-live", "polite"); + expect(banner).toHaveAttribute("aria-atomic", "true"); + }); + + it("should show success banner when transitioning from offline to online", () => { + // Start offline + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); + const { rerender } = render(); + expect(screen.getByText(/You are offline/i)).toBeInTheDocument(); + + // Transition to online (simulate reconnection) + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + rerender(); + + // Should show success message expect(screen.getByText(/Connection restored/i)).toBeInTheDocument(); - - // Should invalidate queries expect(queryClient.invalidateQueries).toHaveBeenCalled(); + }); + + it("should have role status and aria-live polite for success message", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + render(); + + const banner = screen.getByRole("status"); + expect(banner).toHaveAttribute("aria-live", "polite"); + }); + + it("should show dismissible button only on success state", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + render(); - // Should be dismissible manually const dismissBtn = screen.getByRole("button", { name: /Dismiss banner/i }); expect(dismissBtn).toBeInTheDocument(); + }); + + it("should auto-dismiss success message after 4 seconds", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + render(); + + expect(screen.getByText(/Connection restored/i)).toBeInTheDocument(); - // Auto dismiss after 4 seconds act(() => { vi.advanceTimersByTime(4000); }); @@ -81,14 +134,10 @@ describe("OfflineBanner", () => { expect(screen.queryByText(/Connection restored/i)).not.toBeInTheDocument(); }); - it("can be manually dismissed when online_success", () => { - setOnline(false); + it("should manually dismiss success message", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); render(); - - act(() => { - setOnline(true); - window.dispatchEvent(new Event("online")); - }); const dismissBtn = screen.getByRole("button", { name: /Dismiss banner/i }); act(() => { @@ -97,4 +146,50 @@ describe("OfflineBanner", () => { expect(screen.queryByText(/Connection restored/i)).not.toBeInTheDocument(); }); + + it("should update countdown when secondsUntilRetry changes", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + const { rerender } = render(); + + expect(screen.getByText(/retrying in 5s/i)).toBeInTheDocument(); + + // Update countdown + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 3 }); + rerender(); + + expect(screen.getByText(/retrying in 3s/i)).toBeInTheDocument(); + }); + + it("should not render when hidden (online, not retrying, not recently reconnected)", () => { + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + const { container } = render(); + + // The component will not render the banner initially since there's no transition + expect(container.firstChild).toBeNull(); + }); + + it("should use correct icons for each state", () => { + // Offline state - warning icon + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); + const { container: offlineContainer } = render(); + expect(offlineContainer.textContent).toContain("⚠️"); + + vi.clearAllMocks(); + + // Retrying state - spinner icon + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: true, secondsUntilRetry: 5 }); + const { container: retryingContainer } = render(); + expect(retryingContainer.textContent).toContain("🔄"); + + vi.clearAllMocks(); + + // Success state - check mark icon + vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); + vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); + const { container: successContainer } = render(); + expect(successContainer.textContent).toContain("✅"); + }); }); diff --git a/frontend/src/components/OfflineBanner.tsx b/frontend/src/components/OfflineBanner.tsx index 02c5e354..3c7c7b4d 100644 --- a/frontend/src/components/OfflineBanner.tsx +++ b/frontend/src/components/OfflineBanner.tsx @@ -1,48 +1,70 @@ import { useEffect, useState, useCallback } from "react"; import { queryClient } from "../lib/queryClient"; +import { useNetworkStatus } from "../hooks/useNetworkStatus"; +import { useRetryState } from "../hooks/useRetryState"; interface OfflineBannerProps { lastKnownTvl?: number; lastKnownBalance?: number; } -type BannerState = "hidden" | "offline" | "online_success"; +type BannerState = "hidden" | "offline" | "retrying" | "online_success"; +/** + * OfflineBanner component displays connectivity state and retry information. + * - Offline state: Shows warning when device is disconnected (non-dismissible) + * - Retrying state: Shows countdown to next retry attempt when queries are retrying + * - Success state: Brief success message that auto-dismisses after 3-4 seconds + * - Hidden state: Not rendered + * + * Accessibility: + * - Uses role="alert" for offline (high urgency) and role="status" for retrying/success + * - Uses aria-live="assertive" for offline and "polite" for retrying/success + * - Countdown is screen-reader accessible via aria-live region updates + * + * @param lastKnownTvl - Last known TVL to display while offline + * @param lastKnownBalance - Last known balance to display while offline + */ export default function OfflineBanner({ lastKnownTvl, lastKnownBalance }: OfflineBannerProps) { + const { isOnline } = useNetworkStatus(); + const { isRetrying, secondsUntilRetry } = useRetryState(); + const [bannerState, setBannerState] = useState( - typeof navigator !== "undefined" && !navigator.onLine ? "offline" : "hidden" + !isOnline ? "offline" : "hidden" ); useEffect(() => { let timeoutId: number; - const handleOnline = () => { - // Transition to success state immediately + if (!isOnline) { + // Device went offline + window.clearTimeout(timeoutId); + setBannerState("offline"); + } else if (isRetrying) { + // Online but queries are retrying + setBannerState("retrying"); + } else if (bannerState === "offline") { + // Transitioned from offline to online setBannerState("online_success"); // Instantly trigger fresh HTTP requests for all active dashboard widgets queryClient.invalidateQueries(); - // Auto-fade out after 3-5 seconds + // Auto-fade out after 4 seconds timeoutId = window.setTimeout(() => { setBannerState("hidden"); }, 4000); - }; - - const handleOffline = () => { - window.clearTimeout(timeoutId); - setBannerState("offline"); - }; - - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); + } else if (bannerState === "online_success" && !isRetrying) { + // Continue showing success state if retrying ended + timeoutId = window.setTimeout(() => { + setBannerState("hidden"); + }, 4000); + } return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); window.clearTimeout(timeoutId); }; - }, []); + }, [isOnline, isRetrying, bannerState]); const dismissBanner = useCallback(() => { if (bannerState === "online_success") { @@ -53,22 +75,36 @@ export default function OfflineBanner({ lastKnownTvl, lastKnownBalance }: Offlin if (bannerState === "hidden") return null; const isOffline = bannerState === "offline"; + const isSuccess = bannerState === "online_success"; + const showRetrying = bannerState === "retrying"; return (
{isOffline - ? "You are currently offline. Attempting to reconnect..." - : "Connection restored! Updating dashboard..."} + ? "You are offline. Polling is paused." + : isSuccess + ? "Connection restored! Updating dashboard..." + : showRetrying && secondsUntilRetry !== null + ? `Reconnecting… retrying in ${secondsUntilRetry}s` + : "Reconnecting…" + } {isOffline && (lastKnownTvl !== undefined || lastKnownBalance !== undefined) && ( @@ -78,7 +114,7 @@ export default function OfflineBanner({ lastKnownTvl, lastKnownBalance }: Offlin )}
- {!isOffline && ( + {isSuccess && (