Trying to implement InfoDialogSlice and related api endpoints (app is running but infodialog is not showing)

develop
Jason Zhu 2023-05-18 22:03:36 +10:00
parent 281eafc863
commit 8607a8f1ad
10 changed files with 457 additions and 94 deletions

View File

@ -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', () => {

View File

@ -119,40 +119,25 @@ export const pokeApi = createApi({
};
},
}),
getPokemon: builder.query<PokemonResponseData, number>({
query: Id => ({ url: `pokemon/${Id}` }),
getPokemon: builder.query<PokemonResponseData, string | number>({
query: IdOrName => ({ url: `pokemon/${IdOrName}` }),
}),
getPokemonSpecies: builder.query<PokemonSpeciesResponseData, number>({
query: Id => ({ url: `pokemon-species/${Id}` }),
}),
getPokemonSpeciesFromUrl: builder.query<PokemonSpeciesResponseData, string>(
{
query: url => ({ url: `pokemon-species/${getIdFromUrl(url)}` }),
},
),
getEvolutionChain: builder.query<EvolutionChainResponseData, number>({
query: Id => ({ url: `evolution-chain/${Id}` }),
}),
getPokemonInfo: builder.query<string, number>({
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<EvolutionChainResponseData, string>(
{
query: url => ({ url: `evolution-chain/${getIdFromUrl(url)}` }),
},
}),
),
}),
});
@ -160,5 +145,7 @@ export const {
useGetTypeListQuery,
useGetPokemonQuery,
useGetPokemonSpeciesQuery,
useGetPokemonSpeciesFromUrlQuery,
useGetEvolutionChainQuery,
useGetEvolutionChainFromUrlQuery,
} = pokeApi;

View File

@ -11,7 +11,7 @@ import EvolutionSpecies, {
EvolutionSpeciesProps,
} from 'components/EvolutionSpecies';
interface Stat {
export interface Stat {
stat__name: string;
stat__value: number;
}

View File

@ -1,2 +1,2 @@
export { default } from './InfoDialogComponent';
export type { InfoDialogComponentProps } from './InfoDialogComponent';
export * from './InfoDialogComponent';

View File

@ -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}
>
<path d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"></path>
</svg>

View File

@ -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 (
<>
<InfoDialogComponent
openDialog={isOpen}
id={selectedPokemonId}
name={}
types={}
genera={}
image={}
height={}
weight={}
genderRatio={}
description={}
abilities={}
stats={}
evolutionChain={}
id={selectedInfoDialogDetails.id}
name={selectedInfoDialogDetails.name}
types={selectedInfoDialogDetails.types}
genera={selectedInfoDialogDetails.genera}
image={selectedInfoDialogDetails.image}
height={selectedInfoDialogDetails.height}
weight={selectedInfoDialogDetails.weight}
genderRatio={selectedInfoDialogDetails.genderRatio}
description={selectedInfoDialogDetails.description}
abilities={selectedInfoDialogDetails.abilities}
stats={selectedInfoDialogDetails.stats}
evolutionChain={selectedInfoDialogDetails.evolutionChain}
/>
</>
);

View File

@ -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.',
);
});
});
});

View File

@ -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<InfoDialogStateProps> = createSlice({
name: 'infoDialog',
initialState,
@ -30,38 +169,17 @@ export const infoDialogSlice: Slice<InfoDialogStateProps> = createSlice({
setIsOpen: (state, action: PayloadAction<boolean>) => {
state.isOpen = action.payload;
},
setSkipGetPokemonSpeciesQuery: (state, action: PayloadAction<boolean>) => {
state.skipGetPokemonSpeciesQuery = action.payload;
},
setSkipGetEvolutionChainQuery: (state, action: PayloadAction<boolean>) => {
state.skipGetEvolutionChainQuery = action.payload;
},
setSelectedPokemonId: (state, action: PayloadAction<number>) => {
state.selectedPokemonId = action.payload;
},
setPokemonSpeciesIdToFetch: (state, action: PayloadAction<number>) => {
state.pokemonSpeciesIdToFetch = action.payload;
},
setEvolutionChainIdToFetch: (state, action: PayloadAction<number>) => {
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);
};

View File

@ -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))
}
/>
))}
</div>

View File

@ -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 = {