diff --git a/src/app/services/pokeApi.test.ts b/src/app/services/pokeApi.test.ts index a24bd88..30318db 100644 --- a/src/app/services/pokeApi.test.ts +++ b/src/app/services/pokeApi.test.ts @@ -59,6 +59,24 @@ describe('pokeApi', () => { }); }); + describe('test getPokemonSpecies query', () => { + test('visit https://pokeapi.co/api/v2/pokemon-species/6/', async () => { + await store.dispatch( + pokeApi.endpoints.getPokemonSpeciesFromUrl.initiate( + 'https://pokeapi.co/api/v2/pokemon-species/6/', + ), + ); + + const pokemonSpecies = + pokeApi.endpoints.getPokemonSpeciesFromUrl.select( + 'https://pokeapi.co/api/v2/pokemon-species/6/', + )(store.getState()).data as PokemonSpeciesResponseData; + expect(pokemonSpecies?.evolution_chain.url).toBe( + 'https://pokeapi.co/api/v2/evolution-chain/2/', + ); + }); + }); + describe('test getTypeList query', () => { test('visit https://pokeapi.co/api/v2/type should return correct data in list', async () => { await store.dispatch(pokeApi.endpoints.getTypeList.initiate()); @@ -88,6 +106,30 @@ describe('pokeApi', () => { ).toBe('https://pokeapi.co/api/v2/pokemon-species/6/'); }); }); + + describe('test getEvolutionChainFromUrl query', () => { + test('visit https://pokeapi.co/api/v2/evolution-chain/2/', async () => { + await store.dispatch( + pokeApi.endpoints.getEvolutionChainFromUrl.initiate( + 'https://pokeapi.co/api/v2/evolution-chain/2/', + ), + ); + + const evolutionChainData = + pokeApi.endpoints.getEvolutionChainFromUrl.select( + 'https://pokeapi.co/api/v2/evolution-chain/2/', + )(store.getState()).data as EvolutionChainResponseData; + expect(evolutionChainData?.chain.species.url).toBe( + 'https://pokeapi.co/api/v2/pokemon-species/4/', + ); + expect(evolutionChainData?.chain.evolves_to[0].species.url).toBe( + 'https://pokeapi.co/api/v2/pokemon-species/5/', + ); + expect( + evolutionChainData?.chain.evolves_to[0].evolves_to[0].species.url, + ).toBe('https://pokeapi.co/api/v2/pokemon-species/6/'); + }); + }); }); describe('test helper functions', () => { diff --git a/src/app/services/pokeApi.ts b/src/app/services/pokeApi.ts index 1000c1e..7d31fd7 100644 --- a/src/app/services/pokeApi.ts +++ b/src/app/services/pokeApi.ts @@ -119,40 +119,25 @@ export const pokeApi = createApi({ }; }, }), - getPokemon: builder.query({ - query: Id => ({ url: `pokemon/${Id}` }), + getPokemon: builder.query({ + query: IdOrName => ({ url: `pokemon/${IdOrName}` }), }), getPokemonSpecies: builder.query({ query: Id => ({ url: `pokemon-species/${Id}` }), }), + getPokemonSpeciesFromUrl: builder.query( + { + query: url => ({ url: `pokemon-species/${getIdFromUrl(url)}` }), + }, + ), getEvolutionChain: builder.query({ query: Id => ({ url: `evolution-chain/${Id}` }), }), - getPokemonInfo: builder.query({ - async queryFn(pokemonId, queryApi) { - const pokemon: PokemonResponseData = await queryApi - .dispatch(pokeApi.endpoints.getPokemon.initiate(pokemonId)) - .unwrap(); - - const pokemonSpecies: PokemonSpeciesResponseData = await queryApi - .dispatch( - pokeApi.endpoints.getPokemonSpecies.initiate( - getIdFromUrl(pokemon.species.url), - ), - ) - .unwrap(); - - const evolutionChain: EvolutionChainResponseData = await queryApi - .dispatch( - pokeApi.endpoints.getEvolutionChain.initiate( - getIdFromUrl(pokemonSpecies.evolution_chain.url), - ), - ) - .unwrap(); - - return { data: 'test' }; + getEvolutionChainFromUrl: builder.query( + { + query: url => ({ url: `evolution-chain/${getIdFromUrl(url)}` }), }, - }), + ), }), }); @@ -160,5 +145,7 @@ export const { useGetTypeListQuery, useGetPokemonQuery, useGetPokemonSpeciesQuery, + useGetPokemonSpeciesFromUrlQuery, useGetEvolutionChainQuery, + useGetEvolutionChainFromUrlQuery, } = pokeApi; diff --git a/src/components/InfoDialogComponent/InfoDialogComponent.tsx b/src/components/InfoDialogComponent/InfoDialogComponent.tsx index 1b2ef17..8204f25 100644 --- a/src/components/InfoDialogComponent/InfoDialogComponent.tsx +++ b/src/components/InfoDialogComponent/InfoDialogComponent.tsx @@ -11,7 +11,7 @@ import EvolutionSpecies, { EvolutionSpeciesProps, } from 'components/EvolutionSpecies'; -interface Stat { +export interface Stat { stat__name: string; stat__value: number; } diff --git a/src/components/InfoDialogComponent/index.ts b/src/components/InfoDialogComponent/index.ts index 26b8473..13d023a 100644 --- a/src/components/InfoDialogComponent/index.ts +++ b/src/components/InfoDialogComponent/index.ts @@ -1,2 +1,2 @@ export { default } from './InfoDialogComponent'; -export type { InfoDialogComponentProps } from './InfoDialogComponent'; +export * from './InfoDialogComponent'; diff --git a/src/components/PokemonCard/PokemonCard.tsx b/src/components/PokemonCard/PokemonCard.tsx index 5821dc2..b26f052 100644 --- a/src/components/PokemonCard/PokemonCard.tsx +++ b/src/components/PokemonCard/PokemonCard.tsx @@ -13,11 +13,21 @@ export interface PokemonCardProps { types: string[]; } +type PokemonCardPropsActionable = PokemonCardProps & { + onClickAction: () => void; +}; + export function formatNumber(num: number) { return '#' + num.toString().padStart(3, '0'); } -const PokemonCard = ({ id, name, image, types }: PokemonCardProps) => { +const PokemonCard = ({ + id, + name, + image, + types, + onClickAction, +}: PokemonCardPropsActionable) => { const finalColor = colorTypeGradients(types); return ( @@ -38,6 +48,7 @@ const PokemonCard = ({ id, name, image, types }: PokemonCardProps) => { height="1em" width="1em" xmlns="http://www.w3.org/2000/svg" + onClick={onClickAction} > diff --git a/src/features/InfoDialog/InfoDialog.tsx b/src/features/InfoDialog/InfoDialog.tsx index f04dae0..f536555 100644 --- a/src/features/InfoDialog/InfoDialog.tsx +++ b/src/features/InfoDialog/InfoDialog.tsx @@ -19,32 +19,26 @@ const InfoDialog = ({ pokemonId }: InfoDialogProps) => { const dispatch = useAppDispatch(); const isOpen = useAppSelector(state => state.infoDialog.isOpen); - const skipGetPokemonSpeciesQuery = useAppSelector( - state => state.infoDialog.skipGetPokemonSpeciesQuery, - ); - const skipGetEvolutionChainQuery = useAppSelector( - state => state.infoDialog.skipGetEvolutionChainQuery, - ); - const selectedPokemonId = useAppSelector( - state => state.infoDialog.selectedPokemonId, + const selectedInfoDialogDetails = useAppSelector( + state => state.infoDialog.InfoDialogDetails, ); return ( <> ); diff --git a/src/features/InfoDialog/infoDialogSlice.test.ts b/src/features/InfoDialog/infoDialogSlice.test.ts new file mode 100644 index 0000000..2036327 --- /dev/null +++ b/src/features/InfoDialog/infoDialogSlice.test.ts @@ -0,0 +1,193 @@ +import { + constructEvolutionChainFromResponse, + findEnglishGenera, + findFirstEnglishFlavorText, +} from './infoDialogSlice'; + +const bulbasaurEvolutionChainResponseData = { + chain: { + evolves_to: [ + { + species: { + name: 'ivysaur', + url: 'https://pokeapi.co/api/v2/pokemon-species/2/', + }, + evolves_to: [ + { + species: { + name: 'venusaur', + url: 'https://pokeapi.co/api/v2/pokemon-species/3/', + }, + evolves_to: [], + }, + ], + }, + ], + species: { + name: 'bulbasaur', + url: 'https://pokeapi.co/api/v2/pokemon-species/1/', + }, + }, +}; +const charmanderEvolutionChainResponseData = { + chain: { + evolves_to: [ + { + species: { + name: 'charmeleon', + url: 'https://pokeapi.co/api/v2/pokemon-species/5/', + }, + evolves_to: [ + { + species: { + name: 'charizard', + url: 'https://pokeapi.co/api/v2/pokemon-species/6/', + }, + evolves_to: [], + }, + ], + }, + ], + species: { + name: 'charmander', + url: 'https://pokeapi.co/api/v2/pokemon-species/4/', + }, + }, +}; +const bulbasaurGenera = { + genera: [ + { + genus: 'たねポケモン', + language: { + name: 'ja-Hrkt', + url: 'https://pokeapi.co/api/v2/language/1/', + }, + }, + { + genus: '씨앗포켓몬', + language: { + name: 'ko', + url: 'https://pokeapi.co/api/v2/language/3/', + }, + }, + { + genus: 'Seed Pokémon', + language: { + name: 'en', + url: 'https://pokeapi.co/api/v2/language/9/', + }, + }, + { + genus: 'たねポケモン', + language: { + name: 'ja', + url: 'https://pokeapi.co/api/v2/language/11/', + }, + }, + { + genus: '种子宝可梦', + language: { + name: 'zh-Hans', + url: 'https://pokeapi.co/api/v2/language/12/', + }, + }, + ], +}; +const flavor_text_entries = [ + { + flavor_text: + 'A strange seed was\nplanted on its\nback at birth.\fThe plant sprouts\nand grows with\nthis POKéMON.', + language: { + name: 'en', + url: 'https://pokeapi.co/api/v2/language/9/', + }, + version: { + name: 'red', + url: 'https://pokeapi.co/api/v2/version/1/', + }, + }, + { + flavor_text: + 'A strange seed was\nplanted on its\nback at birth.\fThe plant sprouts\nand grows with\nthis POKéMON.', + language: { + name: 'en', + url: 'https://pokeapi.co/api/v2/language/9/', + }, + version: { + name: 'blue', + url: 'https://pokeapi.co/api/v2/version/2/', + }, + }, + { + flavor_text: + 'It can go for days\nwithout eating a\nsingle morsel.\fIn the bulb on\nits back, it\nstores energy.', + language: { + name: 'en', + url: 'https://pokeapi.co/api/v2/language/9/', + }, + version: { + name: 'yellow', + url: 'https://pokeapi.co/api/v2/version/3/', + }, + }, + { + flavor_text: + 'うまれたときから せなかに\nふしぎな タネが うえてあって\nからだと ともに そだつという。', + language: { + name: 'ja-Hrkt', + url: 'https://pokeapi.co/api/v2/language/1/', + }, + version: { + name: 'x', + url: 'https://pokeapi.co/api/v2/version/23/', + }, + }, + { + flavor_text: + '태어났을 때부터 등에\n이상한 씨앗이 심어져 있으며\n몸과 함께 자란다고 한다.', + language: { + name: 'ko', + url: 'https://pokeapi.co/api/v2/language/3/', + }, + version: { + name: 'x', + url: 'https://pokeapi.co/api/v2/version/23/', + }, + }, +]; + +describe('test infoDialogSlice', () => { + describe('test utility functions', () => { + test('constructEvolutionChainFromResponse works correctly for bulbasaur', () => { + const evolutionChain = constructEvolutionChainFromResponse( + bulbasaurEvolutionChainResponseData, + ); + expect(evolutionChain.length).toBe(3); + expect(evolutionChain[0]).toBe('bulbasaur'); + expect(evolutionChain[1]).toBe('ivysaur'); + expect(evolutionChain[2]).toBe('venusaur'); + }); + + test('constructEvolutionChainFromResponse works correctly for charmander', () => { + const evolutionChain = constructEvolutionChainFromResponse( + charmanderEvolutionChainResponseData, + ); + expect(evolutionChain.length).toBe(3); + expect(evolutionChain[0]).toBe('charmander'); + expect(evolutionChain[1]).toBe('charmeleon'); + expect(evolutionChain[2]).toBe('charizard'); + }); + + test('findEnglishGenera works correctly for bulbasaur', () => { + const englishGenera = findEnglishGenera(bulbasaurGenera.genera); + expect(englishGenera?.genus).toBe('Seed Pokémon'); + }); + + test('findEnglishFlavorTextForRed works correctly for species 1', () => { + const englishFlavorText = findFirstEnglishFlavorText(flavor_text_entries); + expect(englishFlavorText).toBe( + 'A strange seed was\nplanted on its\nback at birth.\fThe plant sprouts\nand grows with\nthis POKéMON.', + ); + }); + }); +}); diff --git a/src/features/InfoDialog/infoDialogSlice.ts b/src/features/InfoDialog/infoDialogSlice.ts index da2347b..3b0c459 100644 --- a/src/features/InfoDialog/infoDialogSlice.ts +++ b/src/features/InfoDialog/infoDialogSlice.ts @@ -1,28 +1,167 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import type { Slice, PayloadAction } from '@reduxjs/toolkit'; -import { buildDevCheckHandler } from '@reduxjs/toolkit/dist/query/core/buildMiddleware/devMiddleware'; -import { useAppSelector } from '../../app/hooks'; -import { pokeApi } from '../../app/services/pokeApi'; -import { PokemonResponseData } from '../../types/api'; + +import { pokeApi } from 'app/services/pokeApi'; +import { EvolutionSpeciesProps } from 'components/EvolutionSpecies'; +import { Stat } from 'components/InfoDialogComponent'; +import { + EvolutionChain, + EvolutionChainResponseData, + FlavorTextEntry, + generaItem, + PokemonResponseData, + PokemonSpeciesResponseData, +} from 'types/api'; + +type InfoDiaglogDetails = { + id: number; + name: string; + genera: string; + image: string; + types: string[]; + height: number; + weight: number; + genderRatio: number; + description: string; + abilities: string[]; + stats: Stat[]; + evolutionChain: EvolutionSpeciesProps[]; +}; + +const initialInfoDialogDetails: InfoDiaglogDetails = { + id: 0, + name: '', + genera: '', + image: '', + types: [], + height: 0, + weight: 0, + genderRatio: 0, + description: '', + abilities: [], + stats: [], + evolutionChain: [], +}; export type InfoDialogStateProps = { isOpen: boolean; - skipGetPokemonSpeciesQuery: boolean; - skipGetEvolutionChainQuery: boolean; - selectedPokemonId: number; - pokemonSpeciesIdToFetch: number; - evolutionChainIdToFetch: number; + InfoDialogDetails: InfoDiaglogDetails; }; export const initialState: InfoDialogStateProps = { isOpen: false, - skipGetPokemonSpeciesQuery: false, - skipGetEvolutionChainQuery: false, - selectedPokemonId: 0, - pokemonSpeciesIdToFetch: 0, - evolutionChainIdToFetch: 0, + InfoDialogDetails: initialInfoDialogDetails, }; +// create a function named constructNameOfEvolutionChainFromResponse to +// iterate though EvolutionChainResponseData recursively and add name to result +export const constructEvolutionChainFromResponse = ( + response: EvolutionChainResponseData, +) => { + const result: string[] = []; + const addEvolutionSpeciesProps = (evo: EvolutionChain) => { + result.push(evo.species.name); + evo.evolves_to.forEach(evo => { + addEvolutionSpeciesProps(evo); + }); + }; + + addEvolutionSpeciesProps(response.chain); + return result; +}; + +export const findEnglishGenera = (generaItem: generaItem[]) => + generaItem.find(genera => genera.language.name === 'en'); + +export const findFirstEnglishFlavorText = ( + flavorTextEntries: FlavorTextEntry[], +): string => { + const englishFlavorTextItems = flavorTextEntries.filter( + flavorText => flavorText.language.name === 'en', + ); + if (englishFlavorTextItems) { + return englishFlavorTextItems[0].flavor_text; + } else { + return 'Error'; + } +}; + +export const constructPokemonInfoFromResponses = ( + fetchedPokemon: PokemonResponseData, + fetchedPokemonSpecies: PokemonSpeciesResponseData, + evolutionChain: EvolutionSpeciesProps[], +): InfoDiaglogDetails => { + return { + id: fetchedPokemon.id, + name: fetchedPokemon.name, + genera: findEnglishGenera(fetchedPokemonSpecies.genera)?.genus || '', + image: fetchedPokemon.sprites.other.dream_world.front_default, + types: fetchedPokemon.types.map(type => type.type.name), + height: fetchedPokemon.height, + weight: fetchedPokemon.weight, + genderRatio: fetchedPokemonSpecies.gender_rate - 1, + description: findFirstEnglishFlavorText( + fetchedPokemonSpecies.flavor_text_entries, + ), + abilities: fetchedPokemon.abilities.map(ability => ability.ability.name), + stats: fetchedPokemon.stats.map(stat => ({ + stat__name: stat.stat.name, + stat__value: stat.base_stat, + })), + evolutionChain: evolutionChain, + }; +}; + +export const fetchSelectedPokemonInfo = createAsyncThunk( + 'infoDialog/fetchSelectedPokemonInfo', + async (pokemonId: number, thunkAPI) => { + const { data: selectedPokemon } = await pokeApi.useGetPokemonQuery( + pokemonId, + ); + if (selectedPokemon && selectedPokemon.species) { + const { data: selectedPokemonSpecies } = + await pokeApi.useGetPokemonSpeciesFromUrlQuery( + selectedPokemon.species.url, + ); + if (selectedPokemonSpecies && selectedPokemonSpecies.evolution_chain) { + const { data: selectedPokemonEvolutionChain } = + await pokeApi.useGetEvolutionChainFromUrlQuery( + selectedPokemonSpecies.evolution_chain.url, + ); + if (selectedPokemonEvolutionChain) { + const evolutionChainNames = constructEvolutionChainFromResponse( + selectedPokemonEvolutionChain, + ); + // for each name in evolutionChain, fetch the pokemon + const evolutionChain: EvolutionSpeciesProps[] = []; + evolutionChainNames.map(async name => { + const { data: evolutionChainPokemon } = + await pokeApi.useGetPokemonQuery(name); + if (evolutionChainPokemon) { + evolutionChain.push({ + types: evolutionChainPokemon.types.map(type => type.type.name), + name: evolutionChainPokemon.name, + image_url: + evolutionChainPokemon.sprites.other.dream_world.front_default, + }); + } + }); + + const selectedPokemonInfo = constructPokemonInfoFromResponses( + selectedPokemon, + selectedPokemonSpecies, + evolutionChain, + ); + + return selectedPokemonInfo; + } + } + } + + return null; + }, +); + export const infoDialogSlice: Slice = createSlice({ name: 'infoDialog', initialState, @@ -30,38 +169,17 @@ export const infoDialogSlice: Slice = createSlice({ setIsOpen: (state, action: PayloadAction) => { state.isOpen = action.payload; }, - setSkipGetPokemonSpeciesQuery: (state, action: PayloadAction) => { - state.skipGetPokemonSpeciesQuery = action.payload; - }, - setSkipGetEvolutionChainQuery: (state, action: PayloadAction) => { - state.skipGetEvolutionChainQuery = action.payload; - }, - setSelectedPokemonId: (state, action: PayloadAction) => { - state.selectedPokemonId = action.payload; - }, - setPokemonSpeciesIdToFetch: (state, action: PayloadAction) => { - state.pokemonSpeciesIdToFetch = action.payload; - }, - setEvolutionChainIdToFetch: (state, action: PayloadAction) => { - state.evolutionChainIdToFetch = action.payload; - }, + }, + extraReducers: builder => { + builder.addCase(fetchSelectedPokemonInfo.fulfilled, (state, action) => { + if (action.payload) { + state.InfoDialogDetails = action.payload; + state.isOpen = true; + } + }); }, }); -export const { - setIsOpen, - setSkipGetPokemonSpeciesQuery, - setSkipGetEvolutionChainQuery, - setSelectedPokemonId, - setPokemonSpeciesIdToFetch, - setEvolutionChainIdToFetch, -} = infoDialogSlice.actions; +export const { setIsOpen } = infoDialogSlice.actions; export default infoDialogSlice.reducer; - -const fetchSelectedPokemonInfo = () => async (dispatch: any, getState: any) => { - dispatch(setIsOpen(true)); - const selectedPokemonId = getState().InfoDialog.selectedPokemonId; - const { data: selectedPokemon } = - pokeApi.useGetPokemonQuery(selectedPokemonId); -}; diff --git a/src/features/Pokedex/Pokedex.tsx b/src/features/Pokedex/Pokedex.tsx index 679fa35..274f74d 100644 --- a/src/features/Pokedex/Pokedex.tsx +++ b/src/features/Pokedex/Pokedex.tsx @@ -4,6 +4,7 @@ import Loading from 'components/Loading'; import { useAppSelector, useAppDispatch } from 'app/hooks'; import { fetchPokemonsInTheRegion } from './pokedexSlice'; +import { fetchSelectedPokemonInfo } from '../InfoDialog/infoDialogSlice'; export const filterPokemonCardsByType = ( pokemonList: PokemonCardProps[], @@ -90,6 +91,9 @@ const Pokedex = ({ name={pokemonCard.name} image={pokemonCard.image} types={pokemonCard.types} + onClickAction={() => + dispatch(fetchSelectedPokemonInfo(pokemonCard.id)) + } /> ))} diff --git a/src/types/api.ts b/src/types/api.ts index 5a5e3e7..85818c0 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -61,11 +61,25 @@ export interface PokemonResponseData { }; } +export type generaItem = { + genus: string; + language: nameUrlPair; +}; + +export type FlavorTextEntry = { + flavor_text: string; + language: nameUrlPair; + version: nameUrlPair; +}; + export type PokemonSpeciesResponseData = { // many fields are ignored + genera: generaItem[]; evolution_chain: { url: string; }; + gender_rate: number; + flavor_text_entries: FlavorTextEntry[]; }; export type EvolutionChain = {