From 0040da24fa350fb53af06a1e9a02099d8e8e25fb Mon Sep 17 00:00:00 2001
From: joelte
Date: Wed, 16 Sep 2026 13:17:46 +0200
Subject: [PATCH] feat: Add name filter to search for specific pokemon
Added field to input names for pokemon to let users easily navigate to a spesific resource.
---
PTG/src/App.tsx | 11 ++++++---
PTG/src/ChoosePokemon.css | 6 +++++
PTG/src/ChoosePokemon.tsx | 13 +++++++++-
PTG/src/PokemonGrid.css | 3 ++-
PTG/src/PokemonGrid.tsx | 9 +++++--
PTG/src/pokemonFilters.ts | 4 ++-
PTG/src/useFilteredPokemon.ts | 11 ++++++---
PTG/src/usePokemonTeam.ts | 46 +++++++++++++++++++++++++----------
8 files changed, 78 insertions(+), 25 deletions(-)
diff --git a/PTG/src/App.tsx b/PTG/src/App.tsx
index 968af6e..7b160d6 100644
--- a/PTG/src/App.tsx
+++ b/PTG/src/App.tsx
@@ -10,12 +10,13 @@ import { usePokemonTeam } from "./usePokemonTeam.ts";
export default function App(){
const [selectedGame, setSelectedGame] = useState(() => loadStoredFilters().selectedGame);
const [selectedType, setSelectedType] = useState(() => loadStoredFilters().selectedType);
+ const [selectedName, setSelectedName] = useState(() => loadStoredFilters().selectedName);
useEffect(() => {
- saveStoredFilters({ selectedGame, selectedType });
- }, [selectedGame, selectedType]);
+ saveStoredFilters({ selectedGame, selectedType, selectedName });
+ }, [selectedGame, selectedType, selectedName]);
- const { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite } = usePokemonTeam(selectedGame, selectedType);
+ const { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite } = usePokemonTeam(selectedGame, selectedType, selectedName);
return(
@@ -23,8 +24,10 @@ export default function App(){
-
+
diff --git a/PTG/src/ChoosePokemon.css b/PTG/src/ChoosePokemon.css
index d3edf93..1d5b0b8 100644
--- a/PTG/src/ChoosePokemon.css
+++ b/PTG/src/ChoosePokemon.css
@@ -3,3 +3,9 @@
width: fit-content;
max-width: 100%;
}
+
+.chooseName {
+ field-sizing: content;
+ width: fit-content;
+ max-width: 100%;
+}
diff --git a/PTG/src/ChoosePokemon.tsx b/PTG/src/ChoosePokemon.tsx
index 40b24c8..c0c0fd9 100644
--- a/PTG/src/ChoosePokemon.tsx
+++ b/PTG/src/ChoosePokemon.tsx
@@ -13,11 +13,13 @@ const ANY_TYPE: TypeChoice = { label: 'Any Type', value: 'any' };
type ChoosePokemonProps = {
selectedGame: string;
selectedType: string;
+ selectedName: string;
onGameChange: (value: string) => void;
onTypeChange: (value: string) => void;
+ onNameChange: (value: string) => void;
}
-export default function ChoosePokemon({ selectedGame, selectedType, onGameChange, onTypeChange }: ChoosePokemonProps){
+export default function ChoosePokemon({ selectedGame, selectedType, selectedName, onGameChange, onTypeChange, onNameChange }: ChoosePokemonProps){
const { data: games = [], error: gamesError } = useQuery({
queryKey: ["games"],
queryFn: fetchGames,
@@ -27,6 +29,8 @@ export default function ChoosePokemon({ selectedGame, selectedType, onGameChange
queryFn: fetchTypes,
});
+ function handleNameChange(e: React.ChangeEvent) { onNameChange(e.target.value); }
+
function handleGameChange(e: React.ChangeEvent) { onGameChange(e.target.value); }
function handleTypeChange(e: React.ChangeEvent) { onTypeChange(e.target.value); }
@@ -38,6 +42,13 @@ export default function ChoosePokemon({ selectedGame, selectedType, onGameChange
return(
<>
+
;
if (typeError) return Failed to load type: {typeError.message}
;
if (gameError) return Failed to load game: {gameError.message}
;
+ if (!isLoading && pokemon.length === 0) {
+ return No pokèmon found for current filter
;
+ }
+
return (
{pokemon.map(({ id, name, image }) => {
diff --git a/PTG/src/pokemonFilters.ts b/PTG/src/pokemonFilters.ts
index 2a9c7fd..01d64f2 100644
--- a/PTG/src/pokemonFilters.ts
+++ b/PTG/src/pokemonFilters.ts
@@ -3,9 +3,10 @@ const STORAGE_KEY = "pokemonFilters";
export type PokemonFilters = {
selectedGame: string;
selectedType: string;
+ selectedName: string;
};
-const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedType: "any" };
+const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedType: "any", selectedName: "" };
export function loadStoredFilters(): PokemonFilters {
try {
@@ -15,6 +16,7 @@ export function loadStoredFilters(): PokemonFilters {
return {
selectedGame: typeof parsed.selectedGame === "string" ? parsed.selectedGame : DEFAULT_FILTERS.selectedGame,
selectedType: typeof parsed.selectedType === "string" ? parsed.selectedType : DEFAULT_FILTERS.selectedType,
+ selectedName: typeof parsed.selectedName === "string" ? parsed.selectedName : DEFAULT_FILTERS.selectedName,
};
} catch {
return DEFAULT_FILTERS;
diff --git a/PTG/src/useFilteredPokemon.ts b/PTG/src/useFilteredPokemon.ts
index 8ec5c10..792bb89 100644
--- a/PTG/src/useFilteredPokemon.ts
+++ b/PTG/src/useFilteredPokemon.ts
@@ -42,14 +42,15 @@ export function usePokemonList() {
export type FilteredPokemon = {
pokemon: Pokemon[];
typeMap: Map | undefined;
+ isLoading: boolean;
pokemonError: Error | null;
typeError: Error | null;
gameError: Error | null;
};
-// Resolves the current game/type filters against the full Pokémon list.
-export function useFilteredPokemon(selectedGame: string, selectedType: string): FilteredPokemon {
- const { data: pokemon = [], error: pokemonError } = usePokemonList();
+// Resolves the current game/type/name filters against the full Pokémon list.
+export function useFilteredPokemon(selectedGame: string, selectedType: string, selectedName: string = ""): FilteredPokemon {
+ const { data: pokemon = [], error: pokemonError, isLoading } = usePokemonList();
// Decorative type icons. A failure here shouldn't block the rest of the grid.
const { data: typeMap } = useQuery({
@@ -72,16 +73,20 @@ export function useFilteredPokemon(selectedGame: string, selectedType: string):
enabled: !!selectedGame,
});
+ const nameQuery = selectedName.trim().toLowerCase();
+
// Each filter is resolved to a matching-id/name set independently above. A Pokemon must satisfy all of them.
const filtered = pokemon.filter(({ id, name }) => {
if (gameIds && !gameIds.has(id)) return false;
if (typeNames && !typeNames.has(name)) return false;
+ if (nameQuery && !name.toLowerCase().startsWith(nameQuery)) return false;
return true;
});
return {
pokemon: filtered,
typeMap,
+ isLoading,
pokemonError,
typeError,
gameError,
diff --git a/PTG/src/usePokemonTeam.ts b/PTG/src/usePokemonTeam.ts
index deb003f..cb17d04 100644
--- a/PTG/src/usePokemonTeam.ts
+++ b/PTG/src/usePokemonTeam.ts
@@ -3,6 +3,15 @@ import { loadStoredTeam, saveStoredTeam, type TeamSlot } from "./pokemonTeam";
import { useFilteredPokemon, usePokemonList, type Pokemon } from "./useFilteredPokemon";
import type { PokemonType } from "./pokemonTypes";
+function shuffle(items: T[]): T[] {
+ const result = [...items];
+ for (let i = result.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [result[i], result[j]] = [result[j], result[i]];
+ }
+ return result;
+}
+
export type PokemonTeam = {
team: TeamSlot[];
pokemonById: Map;
@@ -12,13 +21,13 @@ export type PokemonTeam = {
toggleFavourite: (index: number) => void;
};
-export function usePokemonTeam(selectedGame: string, selectedType: string): PokemonTeam {
+export function usePokemonTeam(selectedGame: string, selectedType: string, selectedName: string): PokemonTeam {
const [team, setTeam] = useState(() => loadStoredTeam());
// The team can hold Pokémon that fall outside the current filter (picked before the filter changed),
// so members are looked up from the full list, while new picks are drawn from the filtered one.
const { data: allPokemon = [] } = usePokemonList();
- const { pokemon: filteredPokemon, typeMap } = useFilteredPokemon(selectedGame, selectedType);
+ const { pokemon: filteredPokemon, typeMap } = useFilteredPokemon(selectedGame, selectedType, selectedName);
useEffect(() => {
saveStoredTeam(team);
@@ -28,18 +37,29 @@ export function usePokemonTeam(selectedGame: string, selectedType: string): Poke
const allFavourited = team.every((slot) => slot.favourite);
function generateTeam() {
- const favouriteIds = new Set(team.filter((slot) => slot.favourite).map((slot) => slot.id));
- const available = filteredPokemon.filter(({ id }) => !favouriteIds.has(id));
+ // Built fresh inside the updater (rather than closed over) so nothing is mutated
+ // across React StrictMode's double-invocation of setState updaters.
+ setTeam((current) => {
+ // Exclude every Pokémon already on the team (not just favourites) so a slot can't end up duplicated.
+ const teamIds = new Set(current.filter((slot) => slot.id !== null).map((slot) => slot.id));
+ const pool = filteredPokemon.filter(({ id }) => !teamIds.has(id));
- setTeam((current) =>
- current.map((slot) => {
- if (slot.favourite) return slot;
- if (available.length === 0) return { ...slot, id: null };
- const index = Math.floor(Math.random() * available.length);
- const [picked] = available.splice(index, 1);
- return { ...slot, id: picked.id };
- })
- );
+ const nonFavouriteIndices = current
+ .map((slot, index) => (slot.favourite ? -1 : index))
+ .filter((index) => index !== -1);
+
+ // When the filter has fewer non-teammate matches than there are non-favourite slots,
+ // only that many slots get replaced - chosen at random - and the rest are left as they were.
+ const replaceCount = Math.min(pool.length, nonFavouriteIndices.length);
+ const indicesToReplace = new Set(shuffle(nonFavouriteIndices).slice(0, replaceCount));
+ const picks = shuffle(pool).slice(0, replaceCount);
+
+ let pickIndex = 0;
+ return current.map((slot, index) => {
+ if (!indicesToReplace.has(index)) return slot;
+ return { ...slot, id: picks[pickIndex++].id };
+ });
+ });
}
function toggleFavourite(index: number) {