learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: implement deck fork confirmation dialog with toast notifications

+1 -1
web/package.json
··· 8 8 "build": "tsc -b && vite build", 9 9 "preview": "vite preview", 10 10 "test": "vitest", 11 - "check": "tsc --noEmit", 11 + "check": "tsc --noEmit --project tsconfig.app.json", 12 12 "lint": "eslint ." 13 13 }, 14 14 "dependencies": {
+1 -1
web/src/components/StudySession.test.tsx
··· 1 - import type { ReviewCard } from "$lib/store"; 1 + import type { ReviewCard } from "$lib/model"; 2 2 import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 3 3 import { afterEach, describe, expect, it, vi } from "vitest"; 4 4 import { StudySession } from "./StudySession";
+1 -2
web/src/components/social/CommentSection.tsx
··· 11 11 function buildTree(comments: Comment[]): CommentNode[] { 12 12 const map = new Map<string, CommentNode>(); 13 13 const roots: CommentNode[] = []; 14 - 15 14 for (const c of comments) { 16 15 map.set(c.id, { comment: c, children: [] }); 17 16 } ··· 95 94 96 95 <Show when={comments()} fallback={<div class="animate-pulse">Loading comments...</div>}> 97 96 {(data) => { 98 - const list = data as unknown as Comment[]; 97 + const list = (Array.isArray(data) ? data : []) as Comment[]; 99 98 return ( 100 99 <div class="space-y-4"> 101 100 <For each={buildTree(list)}>{(node) => <CommentItem node={node} />}</For>
+92 -32
web/src/pages/DeckView.test.tsx
··· 1 1 import { api } from "$lib/api"; 2 - import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { toast } from "$lib/toast"; 3 + import { cleanup, fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 3 4 import { JSX } from "solid-js"; 4 5 import { afterEach, describe, expect, it, vi } from "vitest"; 5 6 import DeckView from "./DeckView"; 6 7 7 - vi.mock("$lib/api", () => ({ api: { get: vi.fn() } })); 8 + const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); 9 + 10 + vi.mock( 11 + "$lib/api", 12 + () => ({ 13 + api: { getDeck: vi.fn(), getDeckCards: vi.fn(), forkDeck: vi.fn(), getComments: vi.fn(), addComment: vi.fn() }, 14 + }), 15 + ); 16 + 17 + vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); 8 18 9 19 vi.mock( 10 20 "@solidjs/router", 11 21 () => ({ 12 22 useParams: () => ({ id: "123" }), 23 + useNavigate: () => mockNavigate, 13 24 A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 14 25 }), 15 26 ); 16 27 17 28 describe("DeckView", () => { 18 - afterEach(cleanup); 29 + afterEach(() => { 30 + cleanup(); 31 + vi.clearAllMocks(); 32 + }); 19 33 20 - it("renders deck details and cards", async () => { 21 - const deck = { 22 - id: "123", 23 - title: "Test Deck", 24 - description: "A test deck", 25 - tags: ["test"], 26 - visibility: { type: "Public" }, 27 - owner_did: "did:test", 28 - }; 34 + const mockDeck = { 35 + id: "123", 36 + title: "Test Deck", 37 + description: "A test deck", 38 + tags: ["test"], 39 + visibility: { type: "Public" }, 40 + owner_did: "did:test", 41 + }; 29 42 30 - const cards = [{ id: "c1", front: "Front 1", back: "Back 1" }, { id: "c2", front: "Front 2", back: "Back 2" }]; 43 + const mockCards = [{ id: "c1", front: "Front 1", back: "Back 1" }, { id: "c2", front: "Front 2", back: "Back 2" }]; 31 44 32 - vi.mocked(api.get).mockImplementation( 33 - ((path: string) => { 34 - if (path === "/decks/123") { 35 - return Promise.resolve({ ok: true, json: () => Promise.resolve(deck) }); 36 - } 37 - if (path === "/decks/123/cards") { 38 - return Promise.resolve({ ok: true, json: () => Promise.resolve(cards) }); 39 - } 40 - return Promise.reject(new Error(`Unexpected path: ${path}`)); 41 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 - }) as any, 45 + it("renders deck details and cards", async () => { 46 + vi.mocked(api.getDeck).mockResolvedValue( 47 + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, 48 + ); 49 + vi.mocked(api.getDeckCards).mockResolvedValue( 50 + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 43 51 ); 52 + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 44 53 45 54 render(() => <DeckView />); 46 55 ··· 48 57 expect(screen.getByText("A test deck")).toBeInTheDocument(); 49 58 expect(screen.getByText("#test")).toBeInTheDocument(); 50 59 expect(screen.getByText("Front 1")).toBeInTheDocument(); 51 - expect(screen.getByText("Front 2")).toBeInTheDocument(); 52 - expect(screen.getByText("Back 1")).toBeInTheDocument(); 53 60 }); 54 61 55 - it("renders not found state when deck returns error", async () => { 56 - vi.mocked(api.get).mockImplementation( 57 - (() => { 58 - return Promise.resolve({ ok: false }); 59 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 - }) as any, 62 + it("handles deck fork flow successfully", async () => { 63 + vi.mocked(api.getDeck).mockResolvedValue( 64 + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, 61 65 ); 66 + vi.mocked(api.getDeckCards).mockResolvedValue( 67 + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 68 + ); 69 + vi.mocked(api.forkDeck).mockResolvedValue( 70 + { ok: true, json: () => Promise.resolve({ id: "456" }) } as unknown as Response, 71 + ); 72 + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 62 73 63 74 render(() => <DeckView />); 64 75 76 + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 77 + 78 + const forkButton = screen.getByText("Fork Deck", { selector: "button" }); 79 + fireEvent.click(forkButton); 80 + 81 + const dialog = screen.getByRole("dialog"); 82 + expect(within(dialog).getByText(/Are you sure you want to fork/)).toBeInTheDocument(); 83 + 84 + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); 85 + fireEvent.click(confirmButton); 86 + 87 + await waitFor(() => { 88 + expect(api.forkDeck).toHaveBeenCalledWith("123"); 89 + expect(toast.success).toHaveBeenCalledWith("Deck forked successfully!"); 90 + expect(mockNavigate).toHaveBeenCalledWith("/decks/456"); 91 + }); 92 + }); 93 + 94 + it("handles deck fork failure", async () => { 95 + vi.mocked(api.getDeck).mockResolvedValue( 96 + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, 97 + ); 98 + vi.mocked(api.getDeckCards).mockResolvedValue( 99 + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 100 + ); 101 + vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response); 102 + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 103 + 104 + render(() => <DeckView />); 105 + 106 + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 107 + 108 + const forkButton = screen.getByText("Fork Deck", { selector: "button" }); 109 + fireEvent.click(forkButton); 110 + 111 + const dialog = screen.getByRole("dialog"); 112 + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); 113 + fireEvent.click(confirmButton); 114 + 115 + await waitFor(() => { 116 + expect(api.forkDeck).toHaveBeenCalledWith("123"); 117 + expect(toast.error).toHaveBeenCalledWith("Failed to fork deck."); 118 + expect(mockNavigate).not.toHaveBeenCalled(); 119 + }); 120 + }); 121 + 122 + it("renders not found state when deck returns error", async () => { 123 + vi.mocked(api.getDeck).mockResolvedValue({ ok: false } as unknown as Response); 124 + render(() => <DeckView />); 65 125 await waitFor(() => expect(screen.getByText(/Deck not found/i)).toBeInTheDocument()); 66 126 }); 67 127 });
+30 -15
web/src/pages/DeckView.tsx
··· 1 1 import { CommentSection } from "$components/social/CommentSection"; 2 2 import { FollowButton } from "$components/social/FollowButton"; 3 3 import { Button } from "$components/ui/Button"; 4 + import { Dialog } from "$components/ui/Dialog"; 4 5 import { api } from "$lib/api"; 5 6 import type { Card, Deck } from "$lib/model"; 6 - import { A, useParams } from "@solidjs/router"; 7 + import { toast } from "$lib/toast"; 8 + import { A, useNavigate, useParams } from "@solidjs/router"; 7 9 import type { Component } from "solid-js"; 8 - import { createResource, For, Show } from "solid-js"; 10 + import { createResource, createSignal, For, Show } from "solid-js"; 9 11 10 12 const DeckView: Component = () => { 11 13 const params = useParams(); 14 + const navigate = useNavigate(); 15 + const [showForkDialog, setShowForkDialog] = createSignal(false); 12 16 const [deck] = createResource(() => params.id, async (id) => { 13 17 const res = await api.getDeck(id); 14 18 return res.ok ? (await res.json() as Deck) : null; ··· 19 23 }); 20 24 21 25 const handleFork = async () => { 22 - if (!deck()) return; 23 - // TODO: use modal 24 - if (confirm(`Fork "${deck()?.title}"?`)) { 26 + if (deck()) { 25 27 try { 26 28 const res = await api.forkDeck(deck()!.id); 27 29 if (res.ok) { 28 30 const newDeck = await res.json(); 29 - // TODO: use toast 30 - alert("Deck forked successfully!"); 31 - // TODO: useNavigate 32 - // navigate(`/decks/${newDeck.id}`); 33 - window.location.href = `/decks/${newDeck.id}`; 31 + toast.success("Deck forked successfully!"); 32 + navigate(`/decks/${newDeck.id}`); 34 33 } else { 35 - // TODO: use toast 36 - alert("Failed to fork deck."); 34 + toast.error("Failed to fork deck."); 37 35 } 38 36 } catch (e) { 39 37 console.error(e); 40 - // TODO: use toast 41 - alert("Error forking deck."); 38 + toast.error("Error forking deck."); 39 + } finally { 40 + setShowForkDialog(false); 42 41 } 43 42 } 44 43 }; ··· 89 88 Study Deck (Coming Soon) 90 89 </button> 91 90 <Button 92 - onClick={handleFork} 91 + onClick={() => setShowForkDialog(true)} 93 92 variant="secondary" 94 93 class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors"> 95 94 Fork Deck ··· 147 146 )} 148 147 </Show> 149 148 </Show> 149 + 150 + <Dialog 151 + open={showForkDialog()} 152 + onClose={() => setShowForkDialog(false)} 153 + title="Fork Deck" 154 + actions={ 155 + <> 156 + <Button variant="ghost" onClick={() => setShowForkDialog(false)}>Cancel</Button> 157 + <Button variant="primary" onClick={handleFork}>Fork Deck</Button> 158 + </> 159 + }> 160 + <p>Are you sure you want to fork "{deck()?.title}"?</p> 161 + <p class="text-sm text-gray-400 mt-2"> 162 + This will create a copy of this deck in your library that you can study and edit. 163 + </p> 164 + </Dialog> 150 165 </div> 151 166 ); 152 167 };
+145
web/src/pages/Feed.test.tsx
··· 1 + import { api } from "$lib/api"; 2 + import { toast } from "$lib/toast"; 3 + import { cleanup, fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 4 + import { JSX } from "solid-js"; 5 + import { afterEach, describe, expect, it, vi } from "vitest"; 6 + import Feed from "./Feed"; 7 + 8 + const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); 9 + 10 + vi.mock( 11 + "$lib/api", 12 + () => ({ 13 + api: { 14 + getFeedFollows: vi.fn(), 15 + getFeedTrending: vi.fn(), 16 + forkDeck: vi.fn(), 17 + follow: vi.fn(), 18 + unfollow: vi.fn(), 19 + getFollowers: vi.fn(), 20 + }, 21 + }), 22 + ); 23 + 24 + vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); 25 + 26 + vi.mock( 27 + "@solidjs/router", 28 + () => ({ 29 + useNavigate: () => mockNavigate, 30 + A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 31 + }), 32 + ); 33 + 34 + describe("Feed", () => { 35 + afterEach(() => { 36 + cleanup(); 37 + vi.clearAllMocks(); 38 + }); 39 + 40 + const mockDecks = [{ 41 + id: "deck1", 42 + title: "Test Deck 1", 43 + description: "A test deck", 44 + tags: ["test"], 45 + visibility: { type: "Public" }, 46 + owner_did: "did:test:1", 47 + published_at: "2024-01-01T00:00:00Z", 48 + }, { 49 + id: "deck2", 50 + title: "Test Deck 2", 51 + description: "Another test deck", 52 + tags: ["demo"], 53 + visibility: { type: "Public" }, 54 + owner_did: "did:test:2", 55 + published_at: null, 56 + }]; 57 + 58 + it("renders feed with decks from followed users", async () => { 59 + vi.mocked(api.getFeedFollows).mockResolvedValue( 60 + { ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response, 61 + ); 62 + vi.mocked(api.getFeedTrending).mockResolvedValue( 63 + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, 64 + ); 65 + vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 66 + 67 + render(() => <Feed />); 68 + 69 + await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument()); 70 + expect(screen.getByText("Test Deck 2")).toBeInTheDocument(); 71 + }); 72 + 73 + it("shows empty state when no followed decks", async () => { 74 + vi.mocked(api.getFeedFollows).mockResolvedValue( 75 + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, 76 + ); 77 + vi.mocked(api.getFeedTrending).mockResolvedValue( 78 + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, 79 + ); 80 + 81 + render(() => <Feed />); 82 + 83 + await waitFor(() => expect(screen.getByText(/No updates from followed users/i)).toBeInTheDocument()); 84 + }); 85 + 86 + it("handles fork flow successfully", async () => { 87 + vi.mocked(api.getFeedFollows).mockResolvedValue( 88 + { ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response, 89 + ); 90 + vi.mocked(api.getFeedTrending).mockResolvedValue( 91 + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, 92 + ); 93 + vi.mocked(api.forkDeck).mockResolvedValue( 94 + { ok: true, json: () => Promise.resolve({ id: "forked-deck" }) } as unknown as Response, 95 + ); 96 + vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 97 + 98 + render(() => <Feed />); 99 + 100 + await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument()); 101 + 102 + const forkButtons = screen.getAllByText("Fork"); 103 + fireEvent.click(forkButtons[0]); 104 + 105 + const dialog = screen.getByRole("dialog"); 106 + expect(within(dialog).getByText(/Are you sure you want to fork/i)).toBeInTheDocument(); 107 + 108 + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); 109 + fireEvent.click(confirmButton); 110 + 111 + await waitFor(() => { 112 + expect(api.forkDeck).toHaveBeenCalledWith("deck1"); 113 + expect(toast.success).toHaveBeenCalledWith("Deck forked successfully!"); 114 + expect(mockNavigate).toHaveBeenCalledWith("/decks/forked-deck"); 115 + }); 116 + }); 117 + 118 + it("handles fork failure", async () => { 119 + vi.mocked(api.getFeedFollows).mockResolvedValue( 120 + { ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response, 121 + ); 122 + vi.mocked(api.getFeedTrending).mockResolvedValue( 123 + { ok: true, json: () => Promise.resolve([]) } as unknown as Response, 124 + ); 125 + vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response); 126 + vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 127 + 128 + render(() => <Feed />); 129 + 130 + await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument()); 131 + 132 + const forkButtons = screen.getAllByText("Fork"); 133 + fireEvent.click(forkButtons[0]); 134 + 135 + const dialog = screen.getByRole("dialog"); 136 + const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); 137 + fireEvent.click(confirmButton); 138 + 139 + await waitFor(() => { 140 + expect(api.forkDeck).toHaveBeenCalledWith("deck1"); 141 + expect(toast.error).toHaveBeenCalledWith("Failed to fork deck."); 142 + expect(mockNavigate).not.toHaveBeenCalled(); 143 + }); 144 + }); 145 + });
+42 -13
web/src/pages/Feed.tsx
··· 1 1 import { FollowButton } from "$components/social/FollowButton"; 2 2 import { Button } from "$components/ui/Button"; 3 3 import { Card } from "$components/ui/Card"; 4 + import { Dialog } from "$components/ui/Dialog"; 4 5 import { Tabs } from "$components/ui/Tabs"; 5 6 import { api } from "$lib/api"; 6 7 import type { Deck } from "$lib/model"; 7 - import { A } from "@solidjs/router"; 8 - import { createResource, For, Match, Show, Switch } from "solid-js"; 8 + import { toast } from "$lib/toast"; 9 + import { A, useNavigate } from "@solidjs/router"; 10 + import { createResource, createSignal, For, Match, Show, Switch } from "solid-js"; 9 11 10 12 export default function Feed() { 13 + const navigate = useNavigate(); 14 + const [forkDialogDeck, setForkDialogDeck] = createSignal<Deck | null>(null); 15 + 11 16 const [followsFeed] = createResource(async () => { 12 17 const res = await api.getFeedFollows(); 13 18 return res.ok ? (await res.json() as Deck[]) : []; ··· 18 23 return res.ok ? (await res.json() as Deck[]) : []; 19 24 }); 20 25 26 + const handleFork = async () => { 27 + const deck = forkDialogDeck(); 28 + if (!deck) return; 29 + try { 30 + const res = await api.forkDeck(deck.id); 31 + if (res.ok) { 32 + const newDeck = await res.json(); 33 + toast.success("Deck forked successfully!"); 34 + navigate(`/decks/${newDeck.id}`); 35 + } else { 36 + toast.error("Failed to fork deck."); 37 + } 38 + } catch (e) { 39 + console.error(e); 40 + toast.error("Error forking deck."); 41 + } finally { 42 + setForkDialogDeck(null); 43 + } 44 + }; 45 + 21 46 const DeckItem = (props: { deck: Deck }) => ( 22 47 <Card class="mb-4"> 23 48 <div class="flex justify-between items-start"> ··· 44 69 <A href={`/decks/${props.deck.id}`} class="no-underline"> 45 70 <Button variant="secondary" size="sm">View</Button> 46 71 </A> 47 - <Button 48 - variant="ghost" 49 - size="sm" 50 - onClick={() => { 51 - // TODO: use modal or toast 52 - if (confirm("Fork this deck?")) { 53 - api.forkDeck(props.deck.id).then(() => alert("Forked successfully!")); 54 - } 55 - }}> 56 - Fork 57 - </Button> 72 + <Button variant="ghost" size="sm" onClick={() => setForkDialogDeck(props.deck)}>Fork</Button> 58 73 </div> 59 74 </Card> 60 75 ); ··· 92 107 </Switch> 93 108 )} 94 109 </Tabs> 110 + 111 + <Dialog 112 + open={!!forkDialogDeck()} 113 + onClose={() => setForkDialogDeck(null)} 114 + title="Fork Deck" 115 + actions={ 116 + <> 117 + <Button variant="ghost" onClick={() => setForkDialogDeck(null)}>Cancel</Button> 118 + <Button variant="primary" onClick={handleFork}>Fork Deck</Button> 119 + </> 120 + }> 121 + <p>Are you sure you want to fork "{forkDialogDeck()?.title}"?</p> 122 + <p class="text-sm text-gray-400 mt-2">This will create a copy of this deck in your library.</p> 123 + </Dialog> 95 124 </div> 96 125 ); 97 126 }