diff --git a/web/package.json b/web/package.json --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest", - "check": "tsc --noEmit", + "check": "tsc --noEmit --project tsconfig.app.json", "lint": "eslint ." }, "dependencies": { diff --git a/web/src/components/StudySession.test.tsx b/web/src/components/StudySession.test.tsx --- a/web/src/components/StudySession.test.tsx +++ b/web/src/components/StudySession.test.tsx @@ -1,4 +1,4 @@ -import type { ReviewCard } from "$lib/store"; +import type { ReviewCard } from "$lib/model"; import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; import { afterEach, describe, expect, it, vi } from "vitest"; import { StudySession } from "./StudySession"; diff --git a/web/src/pages/DeckView.test.tsx b/web/src/pages/DeckView.test.tsx --- a/web/src/pages/DeckView.test.tsx +++ b/web/src/pages/DeckView.test.tsx @@ -1,46 +1,55 @@ import { api } from "$lib/api"; -import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; +import { toast } from "$lib/toast"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; import { JSX } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; import DeckView from "./DeckView"; -vi.mock("$lib/api", () => ({ api: { get: vi.fn() } })); +const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); + +vi.mock( + "$lib/api", + () => ({ + api: { getDeck: vi.fn(), getDeckCards: vi.fn(), forkDeck: vi.fn(), getComments: vi.fn(), addComment: vi.fn() }, + }), +); + +vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock( "@solidjs/router", () => ({ useParams: () => ({ id: "123" }), + useNavigate: () => mockNavigate, A: (props: { href: string; children: JSX.Element }) => {props.children}, }), ); describe("DeckView", () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockDeck = { + id: "123", + title: "Test Deck", + description: "A test deck", + tags: ["test"], + visibility: { type: "Public" }, + owner_did: "did:test", + }; + + const mockCards = [{ id: "c1", front: "Front 1", back: "Back 1" }, { id: "c2", front: "Front 2", back: "Back 2" }]; it("renders deck details and cards", async () => { - const deck = { - id: "123", - title: "Test Deck", - description: "A test deck", - tags: ["test"], - visibility: { type: "Public" }, - owner_did: "did:test", - }; - - const cards = [{ id: "c1", front: "Front 1", back: "Back 1" }, { id: "c2", front: "Front 2", back: "Back 2" }]; - - vi.mocked(api.get).mockImplementation( - ((path: string) => { - if (path === "/decks/123") { - return Promise.resolve({ ok: true, json: () => Promise.resolve(deck) }); - } - if (path === "/decks/123/cards") { - return Promise.resolve({ ok: true, json: () => Promise.resolve(cards) }); - } - return Promise.reject(new Error(`Unexpected path: ${path}`)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, + vi.mocked(api.getDeck).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, ); + vi.mocked(api.getDeckCards).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, + ); + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); render(() => ); @@ -48,20 +57,71 @@ expect(screen.getByText("A test deck")).toBeInTheDocument(); expect(screen.getByText("#test")).toBeInTheDocument(); expect(screen.getByText("Front 1")).toBeInTheDocument(); - expect(screen.getByText("Front 2")).toBeInTheDocument(); - expect(screen.getByText("Back 1")).toBeInTheDocument(); }); - it("renders not found state when deck returns error", async () => { - vi.mocked(api.get).mockImplementation( - (() => { - return Promise.resolve({ ok: false }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, + it("handles deck fork flow successfully", async () => { + vi.mocked(api.getDeck).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, ); + vi.mocked(api.getDeckCards).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, + ); + vi.mocked(api.forkDeck).mockResolvedValue( + { ok: true, json: () => Promise.resolve({ id: "456" }) } as unknown as Response, + ); + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); render(() => ); + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); + + const forkButton = screen.getByText("Fork Deck", { selector: "button" }); + fireEvent.click(forkButton); + + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText(/Are you sure you want to fork/)).toBeInTheDocument(); + + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(api.forkDeck).toHaveBeenCalledWith("123"); + expect(toast.success).toHaveBeenCalledWith("Deck forked successfully!"); + expect(mockNavigate).toHaveBeenCalledWith("/decks/456"); + }); + }); + + it("handles deck fork failure", async () => { + vi.mocked(api.getDeck).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, + ); + vi.mocked(api.getDeckCards).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, + ); + vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response); + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); + + const forkButton = screen.getByText("Fork Deck", { selector: "button" }); + fireEvent.click(forkButton); + + const dialog = screen.getByRole("dialog"); + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(api.forkDeck).toHaveBeenCalledWith("123"); + expect(toast.error).toHaveBeenCalledWith("Failed to fork deck."); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + it("renders not found state when deck returns error", async () => { + vi.mocked(api.getDeck).mockResolvedValue({ ok: false } as unknown as Response); + render(() => ); await waitFor(() => expect(screen.getByText(/Deck not found/i)).toBeInTheDocument()); }); }); diff --git a/web/src/pages/DeckView.tsx b/web/src/pages/DeckView.tsx --- a/web/src/pages/DeckView.tsx +++ b/web/src/pages/DeckView.tsx @@ -1,14 +1,18 @@ import { CommentSection } from "$components/social/CommentSection"; import { FollowButton } from "$components/social/FollowButton"; import { Button } from "$components/ui/Button"; +import { Dialog } from "$components/ui/Dialog"; import { api } from "$lib/api"; import type { Card, Deck } from "$lib/model"; -import { A, useParams } from "@solidjs/router"; +import { toast } from "$lib/toast"; +import { A, useNavigate, useParams } from "@solidjs/router"; import type { Component } from "solid-js"; -import { createResource, For, Show } from "solid-js"; +import { createResource, createSignal, For, Show } from "solid-js"; const DeckView: Component = () => { const params = useParams(); + const navigate = useNavigate(); + const [showForkDialog, setShowForkDialog] = createSignal(false); const [deck] = createResource(() => params.id, async (id) => { const res = await api.getDeck(id); return res.ok ? (await res.json() as Deck) : null; @@ -19,26 +23,21 @@ }); const handleFork = async () => { - if (!deck()) return; - // TODO: use modal - if (confirm(`Fork "${deck()?.title}"?`)) { + if (deck()) { try { const res = await api.forkDeck(deck()!.id); if (res.ok) { const newDeck = await res.json(); - // TODO: use toast - alert("Deck forked successfully!"); - // TODO: useNavigate - // navigate(`/decks/${newDeck.id}`); - window.location.href = `/decks/${newDeck.id}`; + toast.success("Deck forked successfully!"); + navigate(`/decks/${newDeck.id}`); } else { - // TODO: use toast - alert("Failed to fork deck."); + toast.error("Failed to fork deck."); } } catch (e) { console.error(e); - // TODO: use toast - alert("Error forking deck."); + toast.error("Error forking deck."); + } finally { + setShowForkDialog(false); } } }; @@ -89,7 +88,7 @@ Study Deck (Coming Soon) + + + }> +

Are you sure you want to fork "{deck()?.title}"?

+

+ This will create a copy of this deck in your library that you can study and edit. +

+ ); }; diff --git a/web/src/pages/Feed.test.tsx b/web/src/pages/Feed.test.tsx new file mode 100644 --- /dev/null +++ b/web/src/pages/Feed.test.tsx @@ -0,0 +1,145 @@ +import { api } from "$lib/api"; +import { toast } from "$lib/toast"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; +import { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Feed from "./Feed"; + +const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); + +vi.mock( + "$lib/api", + () => ({ + api: { + getFeedFollows: vi.fn(), + getFeedTrending: vi.fn(), + forkDeck: vi.fn(), + follow: vi.fn(), + unfollow: vi.fn(), + getFollowers: vi.fn(), + }, + }), +); + +vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock( + "@solidjs/router", + () => ({ + useNavigate: () => mockNavigate, + A: (props: { href: string; children: JSX.Element }) => {props.children}, + }), +); + +describe("Feed", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockDecks = [{ + id: "deck1", + title: "Test Deck 1", + description: "A test deck", + tags: ["test"], + visibility: { type: "Public" }, + owner_did: "did:test:1", + published_at: "2024-01-01T00:00:00Z", + }, { + id: "deck2", + title: "Test Deck 2", + description: "Another test deck", + tags: ["demo"], + visibility: { type: "Public" }, + owner_did: "did:test:2", + published_at: null, + }]; + + it("renders feed with decks from followed users", async () => { + vi.mocked(api.getFeedFollows).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response, + ); + vi.mocked(api.getFeedTrending).mockResolvedValue( + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, + ); + vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument()); + expect(screen.getByText("Test Deck 2")).toBeInTheDocument(); + }); + + it("shows empty state when no followed decks", async () => { + vi.mocked(api.getFeedFollows).mockResolvedValue( + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, + ); + vi.mocked(api.getFeedTrending).mockResolvedValue( + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => expect(screen.getByText(/No updates from followed users/i)).toBeInTheDocument()); + }); + + it("handles fork flow successfully", async () => { + vi.mocked(api.getFeedFollows).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response, + ); + vi.mocked(api.getFeedTrending).mockResolvedValue( + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, + ); + vi.mocked(api.forkDeck).mockResolvedValue( + { ok: true, json: () => Promise.resolve({ id: "forked-deck" }) } as unknown as Response, + ); + vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument()); + + const forkButtons = screen.getAllByText("Fork"); + fireEvent.click(forkButtons[0]); + + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText(/Are you sure you want to fork/i)).toBeInTheDocument(); + + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(api.forkDeck).toHaveBeenCalledWith("deck1"); + expect(toast.success).toHaveBeenCalledWith("Deck forked successfully!"); + expect(mockNavigate).toHaveBeenCalledWith("/decks/forked-deck"); + }); + }); + + it("handles fork failure", async () => { + vi.mocked(api.getFeedFollows).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response, + ); + vi.mocked(api.getFeedTrending).mockResolvedValue( + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, + ); + vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response); + vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument()); + + const forkButtons = screen.getAllByText("Fork"); + fireEvent.click(forkButtons[0]); + + const dialog = screen.getByRole("dialog"); + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(api.forkDeck).toHaveBeenCalledWith("deck1"); + expect(toast.error).toHaveBeenCalledWith("Failed to fork deck."); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/pages/Feed.tsx b/web/src/pages/Feed.tsx --- a/web/src/pages/Feed.tsx +++ b/web/src/pages/Feed.tsx @@ -1,13 +1,18 @@ import { FollowButton } from "$components/social/FollowButton"; import { Button } from "$components/ui/Button"; import { Card } from "$components/ui/Card"; +import { Dialog } from "$components/ui/Dialog"; import { Tabs } from "$components/ui/Tabs"; import { api } from "$lib/api"; import type { Deck } from "$lib/model"; -import { A } from "@solidjs/router"; -import { createResource, For, Match, Show, Switch } from "solid-js"; +import { toast } from "$lib/toast"; +import { A, useNavigate } from "@solidjs/router"; +import { createResource, createSignal, For, Match, Show, Switch } from "solid-js"; export default function Feed() { + const navigate = useNavigate(); + const [forkDialogDeck, setForkDialogDeck] = createSignal(null); + const [followsFeed] = createResource(async () => { const res = await api.getFeedFollows(); return res.ok ? (await res.json() as Deck[]) : []; @@ -17,6 +22,26 @@ const res = await api.getFeedTrending(); return res.ok ? (await res.json() as Deck[]) : []; }); + + const handleFork = async () => { + const deck = forkDialogDeck(); + if (!deck) return; + try { + const res = await api.forkDeck(deck.id); + if (res.ok) { + const newDeck = await res.json(); + toast.success("Deck forked successfully!"); + navigate(`/decks/${newDeck.id}`); + } else { + toast.error("Failed to fork deck."); + } + } catch (e) { + console.error(e); + toast.error("Error forking deck."); + } finally { + setForkDialogDeck(null); + } + }; const DeckItem = (props: { deck: Deck }) => ( @@ -44,17 +69,7 @@ - + ); @@ -92,6 +107,20 @@ )} + + setForkDialogDeck(null)} + title="Fork Deck" + actions={ + <> + + + + }> +

Are you sure you want to fork "{forkDialogDeck()?.title}"?

+

This will create a copy of this deck in your library.

+
); } diff --git a/web/src/components/social/CommentSection.tsx b/web/src/components/social/CommentSection.tsx --- a/web/src/components/social/CommentSection.tsx +++ b/web/src/components/social/CommentSection.tsx @@ -11,7 +11,6 @@ function buildTree(comments: Comment[]): CommentNode[] { const map = new Map(); const roots: CommentNode[] = []; - for (const c of comments) { map.set(c.id, { comment: c, children: [] }); } @@ -95,7 +94,7 @@ Loading comments...}> {(data) => { - const list = data as unknown as Comment[]; + const list = (Array.isArray(data) ? data : []) as Comment[]; return (
{(node) => }