Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions client/__tests__/AddGameModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,70 @@ describe("AddGameModal", () => {
expect(searchCallsAfter).toBe(searchCallsBefore);
});

it("seeds a single discovered platform and persists its IGDB pair", async () => {
setupFetch({
searchResults: [
{
...makeSearchResult("God of War", "2005-03-22"),
platforms: ["PlayStation 2"],
platformOptions: [{ id: 8, name: "PlayStation 2" }],
},
],
});
renderModal({ initialQuery: "God of War" });
fireEvent.click(screen.getByTestId("open-btn"));

expect(await screen.findByLabelText("Target platform for God of War")).toHaveTextContent(
"PlayStation 2"
);
fireEvent.click(screen.getByTestId("button-add-igdb-1"));

await waitFor(() => {
const postCall = vi
.mocked(globalThis.fetch)
.mock.calls.find(([url, init]) => String(url) === "/api/games" && init?.method === "POST");
expect(postCall).toBeDefined();
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
targetPlatformId: 8,
targetPlatformName: "PlayStation 2",
});
});
});

it("keeps an explicit 'Account default' selection sticky for a single-platform result", async () => {
setupFetch({
searchResults: [
{
...makeSearchResult("God of War", "2005-03-22"),
platforms: ["PlayStation 2"],
platformOptions: [{ id: 8, name: "PlayStation 2" }],
},
],
});
renderModal({ initialQuery: "God of War" });
fireEvent.click(screen.getByTestId("open-btn"));

const trigger = await screen.findByLabelText("Target platform for God of War");
expect(trigger).toHaveTextContent("PlayStation 2");

fireEvent.click(trigger);
fireEvent.click(await screen.findByRole("option", { name: "Account default" }));

expect(trigger).toHaveTextContent("Account default");
fireEvent.click(screen.getByTestId("button-add-igdb-1"));

await waitFor(() => {
const postCall = vi
.mocked(globalThis.fetch)
.mock.calls.find(([url, init]) => String(url) === "/api/games" && init?.method === "POST");
expect(postCall).toBeDefined();
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
targetPlatformId: null,
targetPlatformName: null,
});
});
});

it("shows the mobile configuration prompt when IGDB is not configured", async () => {
mockIsMobile = true;
setupFetch({ configured: false });
Expand Down
81 changes: 81 additions & 0 deletions client/__tests__/GameDetailsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,47 @@ vi.mock("../src/components/GameDownloadDialog", () => ({
open ? <div data-testid="game-download-dialog">Download Dialog</div> : null,
}));

vi.mock("@/components/ui/select", () => {
const SelectTrigger = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
const Select = ({
value,
onValueChange,
disabled,
children,
}: {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
children: React.ReactNode;
}) => {
let id: string | undefined;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === SelectTrigger) {
id = (child.props as { id?: string }).id;
}
});
return (
<select
id={id}
value={value}
disabled={disabled}
onChange={(event) => onValueChange(event.target.value)}
>
{children}
</select>
);
};
return {
Select,
SelectTrigger,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
<option value={value}>{children}</option>
),
};
});

vi.mock("lucide-react", () => ({
Calendar: (props: Record<string, unknown>) => <div data-testid="icon-calendar" {...props} />,
Star: (props: Record<string, unknown>) => <div data-testid="icon-star" {...props} />,
Expand Down Expand Up @@ -190,6 +231,46 @@ describe("GameDetailsModal", () => {
expect(screen.getByTestId("badge-platform-ps5")).toBeInTheDocument();
});

it("updates and clears the automatic download target", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(
makeFetchMock({
"/api/igdb/platforms": [
{ id: 8, name: "PlayStation 2" },
{ id: 48, name: "PlayStation 4" },
],
})
);
renderComponent();

const targetSelect = await screen.findByLabelText("Automatic download target");
await screen.findByRole("option", { name: "PlayStation 2" });
fireEvent.change(targetSelect, { target: { value: "8" } });

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/games/1/target-platform",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
targetPlatformId: 8,
targetPlatformName: "PlayStation 2",
}),
})
);
});

fireEvent.change(targetSelect, { target: { value: "default" } });
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/games/1/target-platform",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ targetPlatformId: null, targetPlatformName: null }),
})
);
});
});

it("renders screenshots in Media tab", () => {
renderComponent();
// Media tab uses forceMount so screenshots are always in the DOM (hidden until tab activated)
Expand Down
72 changes: 70 additions & 2 deletions client/src/components/AddGameModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ import { Link } from "wouter";
import { apiFetch, apiRequest } from "@/lib/queryClient";
import { getAddGamePendingQuery, clearAddGamePendingQuery } from "@/lib/add-game-store";
import { useIsMobile } from "@/hooks/use-mobile";
import { resolveTargetPlatform } from "@shared/title-utils";

interface SearchResult extends Game {
inCollection?: boolean;
platformOptions?: IGDBPlatform[];
}

interface AddGameModalProps {
Expand All @@ -58,6 +60,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
const [showUndatedGames, setShowUndatedGames] = useState(false);
const [selectedPlatform, setSelectedPlatform] = useState("all");
const [releaseYear, setReleaseYear] = useState("");
const [targetPlatforms, setTargetPlatforms] = useState<Record<string, string>>({});
const { toast } = useToast();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
Expand Down Expand Up @@ -86,6 +89,11 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
return () => clearTimeout(timer);
}, [searchQuery]);

// Per-result target overrides only apply to the current discovery result set.
useEffect(() => {
setTargetPlatforms({});
}, [searchQuery, selectedPlatform, releaseYear, showUndatedGames]);

// Pre-fill search when modal opens (from prop or from the dashboard store)
useEffect(() => {
if (open) {
Expand All @@ -102,6 +110,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
setShowUndatedGames(false);
setSelectedPlatform("all");
setReleaseYear("");
setTargetPlatforms({});
}
}, [open, initialQuery]);

Expand Down Expand Up @@ -225,10 +234,65 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
</div>
</div>
);
const getSupportedTargetOptions = (game: SearchResult) =>
game.platformOptions?.filter(({ id, name }) => resolveTargetPlatform(id, name)) ?? [];

const getTargetPlatformValue = (game: SearchResult) => {
const supportedOptions = getSupportedTargetOptions(game);
const key = String(game.igdbId ?? game.id);
const explicitValue = targetPlatforms[key];
if (
explicitValue &&
(explicitValue === "default" ||
supportedOptions.some(({ id }) => String(id) === explicitValue))
) {
return explicitValue;
}
if (
selectedPlatform !== "all" &&
supportedOptions.some(({ id }) => String(id) === selectedPlatform)
) {
return selectedPlatform;
}
return supportedOptions.length === 1 ? String(supportedOptions[0].id) : "default";
};

const renderTargetPlatformSelect = (game: SearchResult) => {
const supportedOptions = getSupportedTargetOptions(game);
if (game.inCollection || !supportedOptions?.length) return null;
const key = String(game.igdbId ?? game.id);
return (
<Select
value={getTargetPlatformValue(game)}
onValueChange={(value) => setTargetPlatforms((current) => ({ ...current, [key]: value }))}
>
<SelectTrigger
className="h-8 w-full max-w-64 text-xs"
aria-label={`Target platform for ${game.title}`}
>
<SelectValue placeholder="Account default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Account default</SelectItem>
{supportedOptions.map((platform) => (
<SelectItem key={platform.id} value={String(platform.id)}>
{platform.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
const handleAddGame = (searchResult: SearchResult) => {
// Map to InsertGame to filter out client-only fields before sending to server
const gameData = mapGameToInsertGame(searchResult);
const selectedTarget = getTargetPlatformValue(searchResult);
const targetPlatform = searchResult.platformOptions?.find(
({ id }) => String(id) === selectedTarget
);
const gameData = mapGameToInsertGame({
...searchResult,
targetPlatformId: targetPlatform?.id ?? null,
targetPlatformName: targetPlatform?.name ?? null,
});
addGameMutation.mutate(gameData);
};

Expand Down Expand Up @@ -397,6 +461,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
{game.summary}
</p>
)}

{renderTargetPlatformSelect(game)}
</div>
</div>
))}
Expand Down Expand Up @@ -527,6 +593,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
))}
</div>

{renderTargetPlatformSelect(game)}

<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{game.platforms?.slice(0, 3).map((platform) => (
Expand Down
Loading