diff options
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | src/client/components/SyncButton.test.tsx | 145 | ||||
| -rw-r--r-- | src/client/components/SyncButton.tsx | 39 | ||||
| -rw-r--r-- | src/client/components/index.ts | 1 | ||||
| -rw-r--r-- | src/client/pages/HomePage.tsx | 2 |
5 files changed, 188 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index e775ae2..ac0760e 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -164,7 +164,7 @@ Smaller features first to enable early MVP validation. ### Sync UI - [x] Sync status indicator -- [ ] Manual sync button +- [x] Manual sync button - [ ] Offline mode indicator **✅ Milestone**: Study offline and sync when back online diff --git a/src/client/components/SyncButton.test.tsx b/src/client/components/SyncButton.test.tsx new file mode 100644 index 0000000..c399284 --- /dev/null +++ b/src/client/components/SyncButton.test.tsx @@ -0,0 +1,145 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SyncButton } from "./SyncButton"; + +// Mock the useSync hook +const mockSync = vi.fn(); +const mockUseSync = vi.fn(); +vi.mock("../stores", () => ({ + useSync: () => mockUseSync(), +})); + +describe("SyncButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSync.mockResolvedValue({ success: true }); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders sync button", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + sync: mockSync, + }); + + render(<SyncButton />); + + expect(screen.getByTestId("sync-button")).toBeDefined(); + expect(screen.getByText("Sync")).toBeDefined(); + }); + + it("displays 'Syncing...' when syncing", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: true, + sync: mockSync, + }); + + render(<SyncButton />); + + expect(screen.getByText("Syncing...")).toBeDefined(); + }); + + it("is disabled when offline", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + isSyncing: false, + sync: mockSync, + }); + + render(<SyncButton />); + + const button = screen.getByTestId("sync-button"); + expect(button).toHaveProperty("disabled", true); + }); + + it("is disabled when syncing", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: true, + sync: mockSync, + }); + + render(<SyncButton />); + + const button = screen.getByTestId("sync-button"); + expect(button).toHaveProperty("disabled", true); + }); + + it("is enabled when online and not syncing", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + sync: mockSync, + }); + + render(<SyncButton />); + + const button = screen.getByTestId("sync-button"); + expect(button).toHaveProperty("disabled", false); + }); + + it("calls sync when clicked", async () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + sync: mockSync, + }); + + render(<SyncButton />); + + const button = screen.getByTestId("sync-button"); + fireEvent.click(button); + + expect(mockSync).toHaveBeenCalledTimes(1); + }); + + it("does not call sync when clicked while disabled", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + isSyncing: false, + sync: mockSync, + }); + + render(<SyncButton />); + + const button = screen.getByTestId("sync-button"); + fireEvent.click(button); + + expect(mockSync).not.toHaveBeenCalled(); + }); + + it("shows tooltip when offline", () => { + mockUseSync.mockReturnValue({ + isOnline: false, + isSyncing: false, + sync: mockSync, + }); + + render(<SyncButton />); + + const button = screen.getByTestId("sync-button"); + expect(button.getAttribute("title")).toBe("Cannot sync while offline"); + }); + + it("does not show tooltip when online", () => { + mockUseSync.mockReturnValue({ + isOnline: true, + isSyncing: false, + sync: mockSync, + }); + + render(<SyncButton />); + + const button = screen.getByTestId("sync-button"); + expect(button.getAttribute("title")).toBeNull(); + }); +}); diff --git a/src/client/components/SyncButton.tsx b/src/client/components/SyncButton.tsx new file mode 100644 index 0000000..1ebfa2e --- /dev/null +++ b/src/client/components/SyncButton.tsx @@ -0,0 +1,39 @@ +import { useSync } from "../stores"; + +export function SyncButton() { + const { isOnline, isSyncing, sync } = useSync(); + + const handleSync = async () => { + await sync(); + }; + + const isDisabled = !isOnline || isSyncing; + + const getButtonText = (): string => { + if (isSyncing) { + return "Syncing..."; + } + return "Sync"; + }; + + return ( + <button + type="button" + data-testid="sync-button" + onClick={handleSync} + disabled={isDisabled} + title={!isOnline ? "Cannot sync while offline" : undefined} + style={{ + padding: "0.25rem 0.5rem", + borderRadius: "4px", + border: "1px solid #dee2e6", + backgroundColor: isDisabled ? "#e9ecef" : "#007bff", + color: isDisabled ? "#6c757d" : "white", + cursor: isDisabled ? "not-allowed" : "pointer", + fontSize: "0.875rem", + }} + > + {getButtonText()} + </button> + ); +} diff --git a/src/client/components/index.ts b/src/client/components/index.ts index 12a09a9..31ebe1f 100644 --- a/src/client/components/index.ts +++ b/src/client/components/index.ts @@ -1,2 +1,3 @@ export { ProtectedRoute } from "./ProtectedRoute"; +export { SyncButton } from "./SyncButton"; export { SyncStatusIndicator } from "./SyncStatusIndicator"; diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx index debc935..783e623 100644 --- a/src/client/pages/HomePage.tsx +++ b/src/client/pages/HomePage.tsx @@ -4,6 +4,7 @@ import { ApiClientError, apiClient } from "../api"; import { CreateDeckModal } from "../components/CreateDeckModal"; import { DeleteDeckModal } from "../components/DeleteDeckModal"; import { EditDeckModal } from "../components/EditDeckModal"; +import { SyncButton } from "../components/SyncButton"; import { SyncStatusIndicator } from "../components/SyncStatusIndicator"; import { useAuth } from "../stores"; @@ -73,6 +74,7 @@ export function HomePage() { <h1>Kioku</h1> <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> <SyncStatusIndicator /> + <SyncButton /> <button type="button" onClick={logout}> Logout </button> |
