diff --git a/client/__tests__/CollectionGamesDialog.test.tsx b/client/__tests__/CollectionGamesDialog.test.tsx new file mode 100644 index 000000000..c03e22a8e --- /dev/null +++ b/client/__tests__/CollectionGamesDialog.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import CollectionGamesDialog from "@/components/CollectionGamesDialog"; + +const apiRequest = vi.fn(); +vi.mock("@/lib/queryClient", () => ({ apiRequest: (...args: unknown[]) => apiRequest(...args) })); +vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("@/hooks/use-fresh-games", () => ({ + useFreshGames: () => ({ + games: [ + { id: "g1", title: "Alpha", coverUrl: null }, + { id: "g2", title: "Beta", coverUrl: null }, + ], + isLoading: false, + isError: false, + retry: vi.fn(), + }), +})); + +const client = new QueryClient(); + +function dialog(memberIds: string[]) { + return ( + + + + ); +} + +function renderDialog() { + return render(dialog(["g1"])); +} + +describe("CollectionGamesDialog", () => { + beforeEach(() => apiRequest.mockReset().mockResolvedValue({})); + + it("adds a game that is not yet a member", async () => { + renderDialog(); + fireEvent.click(screen.getByRole("button", { name: "Add Beta" })); + await waitFor(() => + expect(apiRequest).toHaveBeenCalledWith("POST", "/api/collections/c1/games", { + gameId: "g2", + }) + ); + }); + + it("shows members as Added without an add button", () => { + renderDialog(); + expect(screen.queryByRole("button", { name: "Add Alpha" })).toBeNull(); + expect(screen.getByRole("button", { name: "Remove Alpha" })).toBeTruthy(); + expect(screen.getByText("Added")).toBeTruthy(); + }); + + it("removes a member", async () => { + renderDialog(); + fireEvent.click(screen.getByRole("button", { name: "Remove Alpha" })); + await waitFor(() => + expect(apiRequest).toHaveBeenCalledWith("DELETE", "/api/collections/c1/games/g1") + ); + }); + + it("moves focus to the replacement control once the game becomes a member", async () => { + const { rerender } = renderDialog(); + const add = screen.getByRole("button", { name: "Add Beta" }); + add.focus(); + fireEvent.click(add); + await waitFor(() => expect(apiRequest).toHaveBeenCalled()); + rerender(dialog(["g1", "g2"])); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Remove Beta" })) + ); + }); + + it("leaves focus alone when the user moved to the search field", async () => { + const { rerender } = renderDialog(); + fireEvent.click(screen.getByRole("button", { name: "Add Beta" })); + const search = screen.getByLabelText("Search your library"); + search.focus(); + await waitFor(() => expect(apiRequest).toHaveBeenCalled()); + rerender(dialog(["g1", "g2"])); + const remove = await screen.findByRole("button", { name: "Remove Beta" }); + await waitFor(() => expect((remove as HTMLButtonElement).disabled).toBe(false)); + expect(document.activeElement).toBe(search); + }); +}); diff --git a/client/src/components/CollectionGamesDialog.tsx b/client/src/components/CollectionGamesDialog.tsx index e8f09ce4d..30904b469 100644 --- a/client/src/components/CollectionGamesDialog.tsx +++ b/client/src/components/CollectionGamesDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@/lib/queryClient"; import { useFreshGames } from "@/hooks/use-fresh-games"; @@ -8,11 +8,12 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Check, Plus } from "lucide-react"; +import { Check, Plus, X } from "lucide-react"; interface CollectionGamesDialogProps { open: boolean; @@ -23,7 +24,7 @@ interface CollectionGamesDialogProps { memberIds: ReadonlySet; } -/** Add games to, or remove them from, a manual collection by picking from the library. */ +/** Add games to a manual collection by picking from the library; members can also be removed. */ export default function CollectionGamesDialog({ open, onOpenChange, @@ -34,6 +35,9 @@ export default function CollectionGamesDialog({ const { toast } = useToast(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); + const listRef = useRef(null); + /** Row whose control just swapped (Add <-> Remove); focus moves to its replacement. */ + const focusGameId = useRef(null); const { games, isLoading, isError, retry } = useFreshGames(open); @@ -53,7 +57,10 @@ export default function CollectionGamesDialog({ } }, // Returned so the mutation stays pending until membership is refetched (no duplicate click). - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["/api/collections"] }), + onSuccess: (_data, { gameId }) => { + focusGameId.current = gameId; + return queryClient.invalidateQueries({ queryKey: ["/api/collections"] }); + }, onError: (error: Error) => { toast({ title: "Could not update the collection", @@ -63,12 +70,33 @@ export default function CollectionGamesDialog({ }, }); + useEffect(() => { + const gameId = focusGameId.current; + if (!gameId || toggleMutation.isPending) return; + focusGameId.current = null; + // Leave focus alone if the user moved on (e.g. to the search input) while the update ran. + // Radix parks focus on the dialog container when the swapped button unmounts: not a user move. + const active = document.activeElement; + const dialog = listRef.current?.closest("[role='dialog']"); + if ( + active && + active !== document.body && + active !== dialog && + !listRef.current?.contains(active) + ) + return; + listRef.current?.querySelector(`[data-game-id="${gameId}"] button`)?.focus(); + }, [memberIds, toggleMutation.isPending]); + return ( - Games in “{collectionName}” - Pick games from your library to add or remove. + Add games to “{collectionName}” + + Pick games from your library. {memberIds.size}{" "} + {memberIds.size === 1 ? "game is" : "games are"} already in this collection. +
    @@ -99,7 +128,11 @@ export default function CollectionGamesDialog({ {visible.map((game) => { const isMember = memberIds.has(game.id); return ( -
  • +
  • {game.coverUrl ? ( )} {game.title} - + {isMember ? ( + <> + + + + + ) : ( + + )}
  • ); })}
+ + + +
); diff --git a/client/src/pages/collections.tsx b/client/src/pages/collections.tsx index 2315ce403..bbf583869 100644 --- a/client/src/pages/collections.tsx +++ b/client/src/pages/collections.tsx @@ -248,12 +248,8 @@ function CollectionView({ id }: Readonly<{ id: string }>) { actions={ <> {!isSmart && ( - )}