From da419a030b8e9ccc313fef6be4980c5762c89f02 Mon Sep 17 00:00:00 2001 From: jcserv Date: Mon, 8 Jun 2026 09:29:14 -0400 Subject: [PATCH 1/2] fix(deck): show card modal paging arrows and traverse whole deck The detail modal's Prev/Next controls never appeared. `hasSiblings` read the ordered-cards ref during render, but ref writes from the sync effect don't re-render the modal. On the dynamic(ssr:false) owner path the list mounts after the provider, so the modal kept reading an empty ref. The fix: - Track the ordered-list length as reactive state (orderedCount) in DeckPreviewProvider; gate the arrows on it instead of the ref read so the modal re-renders when the list populates. The ref still backs cycle. - Include sorted sideboard + considering cards in the paging list so paging walks the whole deck (commander -> mainboard -> sideboard -> considering); those rows were clickable but previously excluded. - Add integration tests covering arrow visibility, sorted wrap-around paging, the async-mounted list path, and considering-zone cards. --- app/_components/builder/decklist-dnd.tsx | 13 +- app/_components/builder/decklist.tsx | 31 ++- .../deck/deck-detail-sheet.test.tsx | 258 ++++++++++++++++++ app/_components/deck/deck-preview-pane.tsx | 18 +- 4 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 app/_components/deck/deck-detail-sheet.test.tsx diff --git a/app/_components/builder/decklist-dnd.tsx b/app/_components/builder/decklist-dnd.tsx index 573806f..1546b26 100644 --- a/app/_components/builder/decklist-dnd.tsx +++ b/app/_components/builder/decklist-dnd.tsx @@ -167,6 +167,8 @@ export function DecklistDnd({ justMoved, collapsed, commanderCards, + sideboardCards, + consideringCards, hasAnyCards, hasMainboardSections, sortableSections, @@ -176,7 +178,16 @@ export function DecklistDnd({ handleRename, } = useDecklistState(deck, cards); - useDecklistPreviewSync(deck, commanderCards, sortableSections, otherSections, sortKey, sortDir); + useDecklistPreviewSync( + deck, + commanderCards, + sortableSections, + otherSections, + sortKey, + sortDir, + sideboardCards, + consideringCards, + ); const { options: viewOptions } = useDeckViewOptions(deck.id); const commanderSet = commanderCards.length > 0; diff --git a/app/_components/builder/decklist.tsx b/app/_components/builder/decklist.tsx index 86dc0e5..a6665a2 100644 --- a/app/_components/builder/decklist.tsx +++ b/app/_components/builder/decklist.tsx @@ -185,6 +185,16 @@ export function useDecklistState(deck: Deck, cards: DeckCard[]) { const commanderCards = displayCards.filter((dc) => dc.zone === "COMMANDER"); const mainboardCards = displayCards.filter((dc) => dc.zone === "MAINBOARD"); + const sideboardCards = sortCards( + displayCards.filter((dc) => dc.zone === "SIDEBOARD"), + sortKey, + sortDir, + ); + const consideringCards = sortCards( + displayCards.filter((dc) => dc.zone === "CONSIDERING"), + sortKey, + sortDir, + ); const sections = groupCards(mainboardCards, group, displaySubcategoryNames).map( (section) => ({ @@ -222,6 +232,8 @@ export function useDecklistState(deck: Deck, cards: DeckCard[]) { justMoved, collapsed, commanderCards, + sideboardCards, + consideringCards, sections, hasAnyCards, hasMainboardSections, @@ -240,14 +252,20 @@ export function useDecklistPreviewSync( otherSections: Array<{ label: string; key: string; cards: DeckCard[] }>, sortKey: ReturnType, sortDir: ReturnType, + sideboardCards: DeckCard[] = [], + consideringCards: DeckCard[] = [], ) { const preview = useDeckPreview(); const setOrderedCards = preview?.setOrderedCards; + // Paging walks the whole deck: commander -> mainboard -> sideboard -> considering. + // Sideboard/considering rows are clickable too, so they must be in the ordered list. const orderedPreviewFlat: DeckCard[] = [ ...sortCards(commanderCards, sortKey, sortDir), ...sortableSections.flatMap((s) => s.cards), ...otherSections.flatMap((s) => s.cards), + ...sideboardCards, + ...consideringCards, ]; const orderedPreviewCards: PreviewCard[] = []; const seenPreviewKeys = new Set(); @@ -308,6 +326,8 @@ export function Decklist({ justMoved, collapsed, commanderCards, + sideboardCards, + consideringCards, hasAnyCards, hasMainboardSections, sortableSections, @@ -317,7 +337,16 @@ export function Decklist({ handleRename, } = useDecklistState(deck, cards); - useDecklistPreviewSync(deck, commanderCards, sortableSections, otherSections, sortKey, sortDir); + useDecklistPreviewSync( + deck, + commanderCards, + sortableSections, + otherSections, + sortKey, + sortDir, + sideboardCards, + consideringCards, + ); const items: ColumnItem[] = []; diff --git a/app/_components/deck/deck-detail-sheet.test.tsx b/app/_components/deck/deck-detail-sheet.test.tsx new file mode 100644 index 0000000..88de093 --- /dev/null +++ b/app/_components/deck/deck-detail-sheet.test.tsx @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DndContext } from "@dnd-kit/core"; +import type { Deck, DeckCard } from "@/lib/deck/zone-view"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }), + // Sort by name ascending so the rendered/paged order differs from insertion order. + useSearchParams: () => new URLSearchParams("group=category&sort=name&dir=asc"), + usePathname: () => "/deck/deck-1", +})); + +vi.mock("@/app/_actions/deck/categories", () => ({ + renameCategory: vi.fn(), + deleteCategory: vi.fn(), + reorderCategories: vi.fn(), + moveCardTo: vi.fn(), +})); + +vi.mock("@/app/_components/header-search/header-search-context", () => ({ + useHeaderSearch: () => ({ focus: vi.fn() }), +})); + +import { DecklistDnd } from "@/app/_components/builder/decklist-dnd"; +import { SideboardConsideringDnd } from "@/app/_components/builder/sideboard-considering-dnd"; +import { DeckPreviewProvider } from "./deck-preview-pane"; + +const DECK_ID = "deck-1"; + +function makeDeck(): Deck { + return { + id: DECK_ID, + name: "Test Deck", + format: "STANDARD", + visibility: "PRIVATE", + description: null, + userId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + cards: [], + user: { id: "user-1", name: "Tester", image: null }, + categories: [], + } as unknown as Deck; +} + +function zoneCard( + id: string, + name: string, + zone: DeckCard["zone"], +): DeckCard { + return { + id, + deckId: DECK_ID, + cardId: id, + quantity: 1, + zone, + category: null, + printingId: null, + isFoil: false, + createdAt: new Date(), + updatedAt: new Date(), + card: { id, name, mainType: "Creature", printings: [] }, + printing: null, + } as unknown as DeckCard; +} + +function mainboardCard(id: string, name: string): DeckCard { + return zoneCard(id, name, "MAINBOARD"); +} + +function consideringCard(id: string, name: string): DeckCard { + return zoneCard(id, name, "CONSIDERING"); +} + +function renderBuilder(cards: DeckCard[]) { + const deck = makeDeck(); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("DeckDetailSheet paging", () => { + // Insertion order: Zoo, Alpha. Sorted asc by name: Alpha, Zoo. + const cards = [ + mainboardCard("dc-zoo", "Zoo"), + mainboardCard("dc-alpha", "Alpha"), + ]; + + it("shows paging controls when the deck has multiple cards", async () => { + const user = userEvent.setup(); + renderBuilder(cards); + + await user.click(screen.getByRole("button", { name: "Alpha" })); + + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByRole("button", { name: /next card/i }), + ).toBeInTheDocument(); + expect( + within(dialog).getByRole("button", { name: /previous card/i }), + ).toBeInTheDocument(); + }); + + it("pages through cards in the current sort order, wrapping at the end", async () => { + const user = userEvent.setup(); + renderBuilder(cards); + + // Open the first card in sort order (Alpha), even though Zoo was inserted first. + await user.click(screen.getByRole("button", { name: "Alpha" })); + expect(await screen.findByRole("dialog", { name: "Alpha" })).toBeInTheDocument(); + + const next = () => + within(screen.getByRole("dialog")).getByRole("button", { + name: /next card/i, + }); + + // Alpha -> Zoo (sorted order, not insertion order) + await user.click(next()); + expect(screen.getByRole("dialog", { name: "Zoo" })).toBeInTheDocument(); + + // Zoo -> Alpha (wrap-around) + await user.click(next()); + expect(screen.getByRole("dialog", { name: "Alpha" })).toBeInTheDocument(); + }); +}); + +function renderBuilderWithSideboard(cards: DeckCard[]) { + const deck = makeDeck(); + return render( + + + + + + , + ); +} + +describe("DeckDetailSheet paging — considering/sideboard cards", () => { + // Cards live only in CONSIDERING. Insertion order Counter, Bolt; sorted asc: Bolt, Counter. + const cards = [ + consideringCard("dc-counter", "Counter"), + consideringCard("dc-bolt", "Bolt"), + ]; + + it("shows paging controls when opening a Considering card", async () => { + const user = userEvent.setup(); + renderBuilderWithSideboard(cards); + + await user.click(screen.getByRole("button", { name: "Bolt" })); + + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByRole("button", { name: /next card/i }), + ).toBeInTheDocument(); + expect( + within(dialog).getByRole("button", { name: /previous card/i }), + ).toBeInTheDocument(); + }); + + it("pages through Considering cards in sort order, wrapping", async () => { + const user = userEvent.setup(); + renderBuilderWithSideboard(cards); + + await user.click(screen.getByRole("button", { name: "Bolt" })); + expect(await screen.findByRole("dialog", { name: "Bolt" })).toBeInTheDocument(); + + const next = () => + within(screen.getByRole("dialog")).getByRole("button", { + name: /next card/i, + }); + + // Bolt -> Counter (sorted) + await user.click(next()); + expect(screen.getByRole("dialog", { name: "Counter" })).toBeInTheDocument(); + + // Counter -> Bolt (wrap) + await user.click(next()); + expect(screen.getByRole("dialog", { name: "Bolt" })).toBeInTheDocument(); + }); +}); + +import { lazy, Suspense } from "react"; + +describe("DeckDetailSheet paging — async-mounted list (dynamic ssr:false owner path)", () => { + it("shows paging controls even when the list mounts after the provider/modal", async () => { + const user = userEvent.setup(); + const deck = makeDeck(); + const cards = [ + mainboardCard("dc-zoo", "Zoo"), + mainboardCard("dc-alpha", "Alpha"), + ]; + + // Mirror deck-builder.tsx: the list (DecklistDnd) is behind a lazy boundary, + // while DeckPreviewProvider (and its DeckDetailSheet) mount eagerly. + const LazyList = lazy(() => + Promise.resolve({ + default: () => ( + + + + ), + }), + ); + + render( + + loading}> + + + , + ); + + await user.click(await screen.findByRole("button", { name: "Alpha" })); + + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByRole("button", { name: /next card/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/app/_components/deck/deck-preview-pane.tsx b/app/_components/deck/deck-preview-pane.tsx index a9e938a..3396037 100644 --- a/app/_components/deck/deck-preview-pane.tsx +++ b/app/_components/deck/deck-preview-pane.tsx @@ -74,6 +74,10 @@ interface PreviewStateValue { current: PreviewCard | null; sheetCard: PreviewCard | null; detailCard: PreviewCard | null; + // Reactive length of the ordered paging list. The list itself lives in a ref + // (so hover doesn't re-render rows), but the detail modal needs to re-render + // when the list populates so the Prev/Next controls appear. + orderedCount: number; } const PreviewActionsContext = createContext(null); @@ -94,9 +98,14 @@ export function DeckPreviewProvider({ children }: { children: ReactNode }) { const hoverTimer = useRef | null>(null); const returnFocusRef = useRef(null); const orderedCardsRef = useRef([]); + const [orderedCount, setOrderedCount] = useState(0); const setOrderedCards = useCallback((cards: PreviewCard[]) => { orderedCardsRef.current = cards; + // Track length as state so the detail modal re-renders when the list + // populates. The sync effect runs every render with the same-length list, + // so the guard keeps this from looping — it only fires on real changes. + setOrderedCount((prev) => (prev === cards.length ? prev : cards.length)); }, []); const getOrderedCards = useCallback(() => orderedCardsRef.current, []); @@ -162,8 +171,8 @@ export function DeckPreviewProvider({ children }: { children: ReactNode }) { ); const state = useMemo( - () => ({ current, sheetCard, detailCard }), - [current, sheetCard, detailCard], + () => ({ current, sheetCard, detailCard, orderedCount }), + [current, sheetCard, detailCard, orderedCount], ); useEffect(() => { @@ -339,9 +348,10 @@ function DetailCardBody({ card }: { card: PreviewCard }) { function DeckDetailSheet() { const actions = useDeckPreview(); const pathname = usePathname(); - const card = usePreviewState()?.detailCard ?? null; + const previewState = usePreviewState(); + const card = previewState?.detailCard ?? null; const open = card !== null; - const hasSiblings = (actions?.getOrderedCards().length ?? 0) >= 2; + const hasSiblings = (previewState?.orderedCount ?? 0) >= 2; const cycle = useCallback( (delta: 1 | -1) => { From 1855f563c6c09985252aecc2808521190e73e8a1 Mon Sep 17 00:00:00 2001 From: jcserv Date: Mon, 8 Jun 2026 10:08:18 -0400 Subject: [PATCH 2/2] test(deck): complete categories mock and reserve fallback height DecklistDnd imports moveCategoryCards from the categories actions, but the deck-detail-sheet test mock omitted it, leaving the binding undefined and risking a runtime error if a DnD move fired. Changes: - Add moveCategoryCards to the categories vi.mock - Give the lazy-list Suspense fallback an explicit h-[20px] to satisfy the reserved-layout-space convention --- app/_components/deck/deck-detail-sheet.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/_components/deck/deck-detail-sheet.test.tsx b/app/_components/deck/deck-detail-sheet.test.tsx index 88de093..1d7f11d 100644 --- a/app/_components/deck/deck-detail-sheet.test.tsx +++ b/app/_components/deck/deck-detail-sheet.test.tsx @@ -16,6 +16,7 @@ vi.mock("@/app/_actions/deck/categories", () => ({ deleteCategory: vi.fn(), reorderCategories: vi.fn(), moveCardTo: vi.fn(), + moveCategoryCards: vi.fn(), })); vi.mock("@/app/_components/header-search/header-search-context", () => ({ @@ -242,7 +243,7 @@ describe("DeckDetailSheet paging — async-mounted list (dynamic ssr:false owner render( - loading}> + loading}> ,