Skip to content
Merged
92 changes: 92 additions & 0 deletions client/__tests__/CollectionGamesDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={client}>
<CollectionGamesDialog
open
onOpenChange={vi.fn()}
collectionId="c1"
collectionName="Co-op"
memberIds={new Set(memberIds)}
/>
</QueryClientProvider>
);
}

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);
});
Comment thread
gitar-bot[bot] marked this conversation as resolved.
});
99 changes: 74 additions & 25 deletions client/src/components/CollectionGamesDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -23,7 +24,7 @@ interface CollectionGamesDialogProps {
memberIds: ReadonlySet<string>;
}

/** 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,
Expand All @@ -34,6 +35,9 @@ export default function CollectionGamesDialog({
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const listRef = useRef<HTMLUListElement>(null);
/** Row whose control just swapped (Add <-> Remove); focus moves to its replacement. */
const focusGameId = useRef<string | null>(null);

const { games, isLoading, isError, retry } = useFreshGames(open);

Expand All @@ -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",
Expand All @@ -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<HTMLElement>(`[data-game-id="${gameId}"] button`)?.focus();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
gitar-bot[bot] marked this conversation as resolved.
}, [memberIds, toggleMutation.isPending]);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle>Games in “{collectionName}”</DialogTitle>
<DialogDescription>Pick games from your library to add or remove.</DialogDescription>
<DialogTitle>Add games to “{collectionName}”</DialogTitle>
<DialogDescription>
Pick games from your library. {memberIds.size}{" "}
{memberIds.size === 1 ? "game is" : "games are"} already in this collection.
</DialogDescription>
</DialogHeader>

<Input
Expand All @@ -79,6 +107,7 @@ export default function CollectionGamesDialog({
/>

<ul
ref={listRef}
className="-mx-1 min-h-0 flex-1 space-y-1 overflow-y-auto px-1"
aria-label="Library games"
>
Expand All @@ -99,7 +128,11 @@ export default function CollectionGamesDialog({
{visible.map((game) => {
const isMember = memberIds.has(game.id);
return (
<li key={game.id} className="flex items-center gap-3 rounded-md p-1.5">
<li
key={game.id}
data-game-id={game.id}
className="flex items-center gap-3 rounded-md p-1.5"
>
{game.coverUrl ? (
<img
src={game.coverUrl}
Expand All @@ -111,28 +144,44 @@ export default function CollectionGamesDialog({
<div className="h-12 w-9 shrink-0 rounded bg-muted" aria-hidden="true" />
)}
<span className="min-w-0 flex-1 truncate text-sm">{game.title}</span>
<Button
size="sm"
variant={isMember ? "secondary" : "outline"}
className="h-10 min-w-24 sm:h-9"
disabled={toggleMutation.isPending}
aria-label={`${isMember ? "Remove" : "Add"} ${game.title}`}
onClick={() => toggleMutation.mutate({ gameId: game.id, add: !isMember })}
>
{isMember ? (
<>
<Check className="mr-1 h-4 w-4" aria-hidden="true" /> Added
</>
) : (
<>
<Plus className="mr-1 h-4 w-4" aria-hidden="true" /> Add
</>
)}
</Button>
{isMember ? (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<>
<span className="inline-flex h-10 min-w-24 items-center justify-center gap-1 rounded-md bg-secondary px-3 text-sm text-secondary-foreground sm:h-9">
<Check className="h-4 w-4" aria-hidden="true" /> Added
</span>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 shrink-0 sm:h-9 sm:w-9"
disabled={toggleMutation.isPending}
aria-label={`Remove ${game.title}`}
onClick={() => toggleMutation.mutate({ gameId: game.id, add: false })}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
className="h-10 min-w-24 sm:h-9"
disabled={toggleMutation.isPending}
aria-label={`Add ${game.title}`}
onClick={() => toggleMutation.mutate({ gameId: game.id, add: true })}
>
<Plus className="mr-1 h-4 w-4" aria-hidden="true" /> Add
</Button>
)}
</li>
);
})}
</ul>

<DialogFooter>
<Button className="h-10 w-full sm:h-9 sm:w-auto" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
Expand Down
8 changes: 2 additions & 6 deletions client/src/pages/collections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,12 +248,8 @@ function CollectionView({ id }: Readonly<{ id: string }>) {
actions={
<>
{!isSmart && (
<Button
variant="outline"
className="h-10 justify-center sm:h-9"
onClick={() => setGamesOpen(true)}
>
<ListPlus className="mr-1.5 h-4 w-4" aria-hidden="true" /> Manage games
<Button className="h-10 justify-center sm:h-9" onClick={() => setGamesOpen(true)}>
<ListPlus className="mr-1.5 h-4 w-4" aria-hidden="true" /> Add games
</Button>
)}
<Button
Expand Down
Loading