Skip to content

Commit a2b06c4

Browse files
authored
Merge pull request #1048 from Doezer/feat/per-game-target-platform-synced
feat: add per-game automatic-download target platforms
2 parents 60b8384 + 6c1724a commit a2b06c4

21 files changed

Lines changed: 2980 additions & 63 deletions

‎client/__tests__/AddGameModal.test.tsx‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,70 @@ describe("AddGameModal", () => {
251251
expect(searchCallsAfter).toBe(searchCallsBefore);
252252
});
253253

254+
it("seeds a single discovered platform and persists its IGDB pair", async () => {
255+
setupFetch({
256+
searchResults: [
257+
{
258+
...makeSearchResult("God of War", "2005-03-22"),
259+
platforms: ["PlayStation 2"],
260+
platformOptions: [{ id: 8, name: "PlayStation 2" }],
261+
},
262+
],
263+
});
264+
renderModal({ initialQuery: "God of War" });
265+
fireEvent.click(screen.getByTestId("open-btn"));
266+
267+
expect(await screen.findByLabelText("Target platform for God of War")).toHaveTextContent(
268+
"PlayStation 2"
269+
);
270+
fireEvent.click(screen.getByTestId("button-add-igdb-1"));
271+
272+
await waitFor(() => {
273+
const postCall = vi
274+
.mocked(globalThis.fetch)
275+
.mock.calls.find(([url, init]) => String(url) === "/api/games" && init?.method === "POST");
276+
expect(postCall).toBeDefined();
277+
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
278+
targetPlatformId: 8,
279+
targetPlatformName: "PlayStation 2",
280+
});
281+
});
282+
});
283+
284+
it("keeps an explicit 'Account default' selection sticky for a single-platform result", async () => {
285+
setupFetch({
286+
searchResults: [
287+
{
288+
...makeSearchResult("God of War", "2005-03-22"),
289+
platforms: ["PlayStation 2"],
290+
platformOptions: [{ id: 8, name: "PlayStation 2" }],
291+
},
292+
],
293+
});
294+
renderModal({ initialQuery: "God of War" });
295+
fireEvent.click(screen.getByTestId("open-btn"));
296+
297+
const trigger = await screen.findByLabelText("Target platform for God of War");
298+
expect(trigger).toHaveTextContent("PlayStation 2");
299+
300+
fireEvent.click(trigger);
301+
fireEvent.click(await screen.findByRole("option", { name: "Account default" }));
302+
303+
expect(trigger).toHaveTextContent("Account default");
304+
fireEvent.click(screen.getByTestId("button-add-igdb-1"));
305+
306+
await waitFor(() => {
307+
const postCall = vi
308+
.mocked(globalThis.fetch)
309+
.mock.calls.find(([url, init]) => String(url) === "/api/games" && init?.method === "POST");
310+
expect(postCall).toBeDefined();
311+
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
312+
targetPlatformId: null,
313+
targetPlatformName: null,
314+
});
315+
});
316+
});
317+
254318
it("shows the mobile configuration prompt when IGDB is not configured", async () => {
255319
mockIsMobile = true;
256320
setupFetch({ configured: false });

‎client/__tests__/GameDetailsModal.test.tsx‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,47 @@ vi.mock("../src/components/GameDownloadDialog", () => ({
3131
open ? <div data-testid="game-download-dialog">Download Dialog</div> : null,
3232
}));
3333

34+
vi.mock("@/components/ui/select", () => {
35+
const SelectTrigger = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
36+
const Select = ({
37+
value,
38+
onValueChange,
39+
disabled,
40+
children,
41+
}: {
42+
value: string;
43+
onValueChange: (value: string) => void;
44+
disabled?: boolean;
45+
children: React.ReactNode;
46+
}) => {
47+
let id: string | undefined;
48+
React.Children.forEach(children, (child) => {
49+
if (React.isValidElement(child) && child.type === SelectTrigger) {
50+
id = (child.props as { id?: string }).id;
51+
}
52+
});
53+
return (
54+
<select
55+
id={id}
56+
value={value}
57+
disabled={disabled}
58+
onChange={(event) => onValueChange(event.target.value)}
59+
>
60+
{children}
61+
</select>
62+
);
63+
};
64+
return {
65+
Select,
66+
SelectTrigger,
67+
SelectValue: () => null,
68+
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
69+
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
70+
<option value={value}>{children}</option>
71+
),
72+
};
73+
});
74+
3475
vi.mock("lucide-react", () => ({
3576
Calendar: (props: Record<string, unknown>) => <div data-testid="icon-calendar" {...props} />,
3677
Star: (props: Record<string, unknown>) => <div data-testid="icon-star" {...props} />,
@@ -190,6 +231,46 @@ describe("GameDetailsModal", () => {
190231
expect(screen.getByTestId("badge-platform-ps5")).toBeInTheDocument();
191232
});
192233

234+
it("updates and clears the automatic download target", async () => {
235+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(
236+
makeFetchMock({
237+
"/api/igdb/platforms": [
238+
{ id: 8, name: "PlayStation 2" },
239+
{ id: 48, name: "PlayStation 4" },
240+
],
241+
})
242+
);
243+
renderComponent();
244+
245+
const targetSelect = await screen.findByLabelText("Automatic download target");
246+
await screen.findByRole("option", { name: "PlayStation 2" });
247+
fireEvent.change(targetSelect, { target: { value: "8" } });
248+
249+
await waitFor(() => {
250+
expect(global.fetch).toHaveBeenCalledWith(
251+
"/api/games/1/target-platform",
252+
expect.objectContaining({
253+
method: "PATCH",
254+
body: JSON.stringify({
255+
targetPlatformId: 8,
256+
targetPlatformName: "PlayStation 2",
257+
}),
258+
})
259+
);
260+
});
261+
262+
fireEvent.change(targetSelect, { target: { value: "default" } });
263+
await waitFor(() => {
264+
expect(global.fetch).toHaveBeenCalledWith(
265+
"/api/games/1/target-platform",
266+
expect.objectContaining({
267+
method: "PATCH",
268+
body: JSON.stringify({ targetPlatformId: null, targetPlatformName: null }),
269+
})
270+
);
271+
});
272+
});
273+
193274
it("renders screenshots in Media tab", () => {
194275
renderComponent();
195276
// Media tab uses forceMount so screenshots are always in the DOM (hidden until tab activated)

‎client/src/components/AddGameModal.tsx‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ import { Link } from "wouter";
3636
import { apiFetch, apiRequest } from "@/lib/queryClient";
3737
import { getAddGamePendingQuery, clearAddGamePendingQuery } from "@/lib/add-game-store";
3838
import { useIsMobile } from "@/hooks/use-mobile";
39+
import { resolveTargetPlatform } from "@shared/title-utils";
3940

4041
interface SearchResult extends Game {
4142
inCollection?: boolean;
43+
platformOptions?: IGDBPlatform[];
4244
}
4345

4446
interface AddGameModalProps {
@@ -58,6 +60,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
5860
const [showUndatedGames, setShowUndatedGames] = useState(false);
5961
const [selectedPlatform, setSelectedPlatform] = useState("all");
6062
const [releaseYear, setReleaseYear] = useState("");
63+
const [targetPlatforms, setTargetPlatforms] = useState<Record<string, string>>({});
6164
const { toast } = useToast();
6265
const queryClient = useQueryClient();
6366
const isMobile = useIsMobile();
@@ -86,6 +89,11 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
8689
return () => clearTimeout(timer);
8790
}, [searchQuery]);
8891

92+
// Per-result target overrides only apply to the current discovery result set.
93+
useEffect(() => {
94+
setTargetPlatforms({});
95+
}, [searchQuery, selectedPlatform, releaseYear, showUndatedGames]);
96+
8997
// Pre-fill search when modal opens (from prop or from the dashboard store)
9098
useEffect(() => {
9199
if (open) {
@@ -102,6 +110,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
102110
setShowUndatedGames(false);
103111
setSelectedPlatform("all");
104112
setReleaseYear("");
113+
setTargetPlatforms({});
105114
}
106115
}, [open, initialQuery]);
107116

@@ -225,10 +234,65 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
225234
</div>
226235
</div>
227236
);
237+
const getSupportedTargetOptions = (game: SearchResult) =>
238+
game.platformOptions?.filter(({ id, name }) => resolveTargetPlatform(id, name)) ?? [];
239+
240+
const getTargetPlatformValue = (game: SearchResult) => {
241+
const supportedOptions = getSupportedTargetOptions(game);
242+
const key = String(game.igdbId ?? game.id);
243+
const explicitValue = targetPlatforms[key];
244+
if (
245+
explicitValue &&
246+
(explicitValue === "default" ||
247+
supportedOptions.some(({ id }) => String(id) === explicitValue))
248+
) {
249+
return explicitValue;
250+
}
251+
if (
252+
selectedPlatform !== "all" &&
253+
supportedOptions.some(({ id }) => String(id) === selectedPlatform)
254+
) {
255+
return selectedPlatform;
256+
}
257+
return supportedOptions.length === 1 ? String(supportedOptions[0].id) : "default";
258+
};
228259

260+
const renderTargetPlatformSelect = (game: SearchResult) => {
261+
const supportedOptions = getSupportedTargetOptions(game);
262+
if (game.inCollection || !supportedOptions?.length) return null;
263+
const key = String(game.igdbId ?? game.id);
264+
return (
265+
<Select
266+
value={getTargetPlatformValue(game)}
267+
onValueChange={(value) => setTargetPlatforms((current) => ({ ...current, [key]: value }))}
268+
>
269+
<SelectTrigger
270+
className="h-8 w-full max-w-64 text-xs"
271+
aria-label={`Target platform for ${game.title}`}
272+
>
273+
<SelectValue placeholder="Account default" />
274+
</SelectTrigger>
275+
<SelectContent>
276+
<SelectItem value="default">Account default</SelectItem>
277+
{supportedOptions.map((platform) => (
278+
<SelectItem key={platform.id} value={String(platform.id)}>
279+
{platform.name}
280+
</SelectItem>
281+
))}
282+
</SelectContent>
283+
</Select>
284+
);
285+
};
229286
const handleAddGame = (searchResult: SearchResult) => {
230-
// Map to InsertGame to filter out client-only fields before sending to server
231-
const gameData = mapGameToInsertGame(searchResult);
287+
const selectedTarget = getTargetPlatformValue(searchResult);
288+
const targetPlatform = searchResult.platformOptions?.find(
289+
({ id }) => String(id) === selectedTarget
290+
);
291+
const gameData = mapGameToInsertGame({
292+
...searchResult,
293+
targetPlatformId: targetPlatform?.id ?? null,
294+
targetPlatformName: targetPlatform?.name ?? null,
295+
});
232296
addGameMutation.mutate(gameData);
233297
};
234298

@@ -397,6 +461,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
397461
{game.summary}
398462
</p>
399463
)}
464+
465+
{renderTargetPlatformSelect(game)}
400466
</div>
401467
</div>
402468
))}
@@ -527,6 +593,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
527593
))}
528594
</div>
529595

596+
{renderTargetPlatformSelect(game)}
597+
530598
<div className="flex items-center justify-between">
531599
<div className="flex flex-wrap gap-1">
532600
{game.platforms?.slice(0, 3).map((platform) => (

0 commit comments

Comments
 (0)