Compare commits

...

9 Commits

38 changed files with 181 additions and 62 deletions

View File

@ -1,6 +1,6 @@
# Getting Started with Create React App
A simple Pokemon catalogue app, build with React, Redux-Toolkit, Material-UI and PokeAPI.
A simple PokemonCard catalogue app, build with React, Redux-Toolkit, Material-UI and PokeAPI.
It's a learning project following [pokedex](https://github.com/s1varam/pokedex). In this project, I practised following skills:
* Use React to create View for FE

View File

@ -9,6 +9,7 @@
"@reduxjs/toolkit": "^1.9.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-lazy-load-image-component": "^1.5.6",
"react-redux": "^8.0.5",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
@ -76,6 +77,7 @@
"@types/node": "^16.18.14",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-lazy-load-image-component": "^1.5.2",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"babel-plugin-named-exports-order": "^0.0.2",

View File

@ -8,6 +8,7 @@ import {
setRegionOptions,
setSortOptions,
setTypeOptions,
setSearchInput,
} from 'features/Pokedex/pokedexSlice';
import { RegionPokemonRange } from 'features/Pokedex/types/slice';
import { useAppDispatch, useAppSelector } from 'app/hooks';
@ -141,6 +142,15 @@ const Filters = () => {
</select>
</div>
</div>
<div className="filter__items">
<div>
<div>SEARCH</div>
<input
type="text"
onChange={e => dispatch(setSearchInput(e.target.value))}
/>
</div>
</div>
</div>
</>
);

View File

@ -1,24 +1,23 @@
import React from 'react';
import Pokemon from './Pokemon';
import PokemonCard, { PokemonCardProps } from './PokemonCard';
import Filters from './Filters';
import Loading from 'components/Loading';
import { useAppSelector } from 'app/hooks';
import { PokemonResponseData } from './types/api';
export const filterPokemonByType = (
pokemonList: PokemonResponseData[],
export const filterPokemonCardsByType = (
pokemonList: PokemonCardProps[],
selectedType: string,
) => {
return pokemonList.filter(
pokemon =>
selectedType === 'All Types' ||
pokemon.types.some(type => type.type.name === selectedType),
pokemon.types.some(type => type === selectedType),
);
};
export const sortPokemonsByIdOrName = (
pokemonList: PokemonResponseData[],
export const sortPokemonCardsByIdOrName = (
pokemonList: PokemonCardProps[],
selectedSort: string,
) => {
return pokemonList.sort((a, b) => {
@ -32,20 +31,37 @@ export const sortPokemonsByIdOrName = (
});
};
export const searchPokemonCardsByName = (
pokemonList: PokemonCardProps[],
searchInput: string,
) => {
return pokemonList.filter(pokemon =>
pokemon.name.toLowerCase().includes(searchInput.toLowerCase()),
);
};
const Pokedex = () => {
const isLoadingPokemons = useAppSelector(
state => state.pokedex.isLoadingPokemons,
);
const selectedType = useAppSelector(state => state.pokedex.selectedType);
const selectedSort = useAppSelector(state => state.pokedex.selectedSort);
const searchInput = useAppSelector(state => state.pokedex.searchInput);
const pokemonList = useAppSelector(state => state.pokedex.pokemonList);
const pokemonList = useAppSelector(state => state.pokedex.pokemonCardList);
const filteredPokemonList = filterPokemonByType(pokemonList, selectedType);
const sortedFilteredPokemonList = sortPokemonsByIdOrName(
const filteredPokemonList = filterPokemonCardsByType(
pokemonList,
selectedType,
);
const sortedFilteredPokemonCardList = sortPokemonCardsByIdOrName(
filteredPokemonList,
selectedSort,
);
const searchedPokemonCardList = searchPokemonCardsByName(
sortedFilteredPokemonCardList,
searchInput,
);
return (
<>
@ -53,15 +69,17 @@ const Pokedex = () => {
{isLoadingPokemons ? (
<Loading />
) : (
sortedFilteredPokemonList.map(pokemon => (
<Pokemon
key={pokemon.id}
name={pokemon.name}
number={pokemon.id}
image={pokemon.sprites.other.dream_world.front_default}
types={pokemon.types.map(type => type.type.name)}
/>
))
<div className="all__pokemons">
{searchedPokemonCardList.map(pokemonCard => (
<PokemonCard
key={pokemonCard.id}
id={pokemonCard.id}
name={pokemonCard.name}
image={pokemonCard.image}
types={pokemonCard.types}
/>
))}
</div>
)}
</>
);

View File

@ -1,2 +0,0 @@
export * from './Pokemon';
export { default } from './Pokemon';

View File

@ -2,33 +2,33 @@ import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Pokemon, { PokemonCardProps } from './Pokemon';
import PokemonCard, { PokemonCardProps } from './PokemonCard';
import charizard_svg from './assets/stories/charizard.svg';
import charizard_info from './assets/stories/charizard.json';
export default {
title: 'Pokedex/PokemonCard',
component: Pokemon,
} as ComponentMeta<typeof Pokemon>;
component: PokemonCard,
} as ComponentMeta<typeof PokemonCard>;
const Template: ComponentStory<typeof Pokemon> = (args: PokemonCardProps) => (
<Pokemon {...args} />
);
const Template: ComponentStory<typeof PokemonCard> = (
args: PokemonCardProps,
) => <PokemonCard {...args} />;
export const Primary = Template.bind({});
Primary.args = {
id: 6,
name: charizard_info.name,
number: 6,
image: charizard_svg,
types: ['fire', 'flying'],
};
export const Charizard = Template.bind({});
Charizard.args = {
id: 6,
name: charizard_info.name,
number: 6,
image: charizard_svg,
types: ['fire', 'flying'],
};

View File

@ -1,4 +1,4 @@
import { formatNumber } from './Pokemon';
import { formatNumber } from './PokemonCard';
describe('Test Functions', () => {
describe('formatNumber', () => {

View File

@ -1,13 +1,15 @@
import React from 'react';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import 'react-lazy-load-image-component/src/effects/blur.css';
import { Tooltip, Zoom } from '@mui/material';
import './Pokemon.css';
import './PokemonCard.css';
import * as pokeTypeAsset from './assets';
import { colorTypeGradients } from './utils';
export interface PokemonProps {
export interface PokemonCardProps {
id: number;
name: string;
number: number;
image: string;
types: string[];
}
@ -59,7 +61,12 @@ function findPokeTypeAsset(pokeType: string) {
}
}
export default function Pokemon({ name, number, image, types }: PokemonProps) {
export default function PokemonCard({
id,
name,
image,
types,
}: PokemonCardProps) {
let finalColor;
if (types.length === 2) {
@ -76,7 +83,7 @@ export default function Pokemon({ name, number, image, types }: PokemonProps) {
}}
>
<div className="card__header">
<div className="poke__number">{formatNumber(number)}</div>
<div className="poke__number">{formatNumber(id)}</div>
<div className="info__icon">
<svg
stroke="currentColor"
@ -92,7 +99,15 @@ export default function Pokemon({ name, number, image, types }: PokemonProps) {
</div>
</div>
<div className="image__container">
<img src={image} alt={name} />
<LazyLoadImage
alt={name}
height={150}
src={image}
visibleByDefault={false}
delayMethod={'debounce'}
effect="blur"
className="img_thumbnail"
/>
</div>
<div className="poke__name">
<h3>{name}</h3>

View File

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,2 @@
export * from './PokemonCard';
export { default } from './PokemonCard';

View File

@ -1,6 +1,7 @@
import {
filterPokemonByType,
sortPokemonsByIdOrName,
filterPokemonCardsByType,
sortPokemonCardsByIdOrName,
searchPokemonCardsByName,
} from 'features/Pokedex/Pokedex';
import { PokemonResponseData } from 'features/Pokedex/types/api';
import pokemon3_Venusaur from 'features/Pokedex/__test__/pokemon3_Venusaur.json';
@ -34,15 +35,15 @@ describe('pokedex Component', () => {
pokemon4_Charmander,
];
it('should return all Pokemon if the selected type is "All Types"', () => {
it('should return all PokemonCard if the selected type is "All Types"', () => {
const selectedType = 'All Types';
const filteredList = filterPokemonByType(pokemonList, selectedType);
const filteredList = filterPokemonCardsByType(pokemonList, selectedType);
expect(filteredList).toEqual(pokemonList);
});
it('should return only Pokemon of the selected type', () => {
it('should return only PokemonCard of the selected type', () => {
const selectedType = 'fire';
const filteredList = filterPokemonByType(pokemonList, selectedType);
const filteredList = filterPokemonCardsByType(pokemonList, selectedType);
const allPokemonAreOfTypeFire = filteredList.every(pokemon =>
pokemon.types.some(type => type.type.name === selectedType),
);
@ -71,14 +72,42 @@ describe('pokedex Component', () => {
];
it('should sort by id if the selected sort is "id"', () => {
const selectedSort = 'id';
const sortedList = sortPokemonsByIdOrName(pokemonList, selectedSort);
const sortedList = sortPokemonCardsByIdOrName(pokemonList, selectedSort);
expect(sortedList).toEqual([pokemon3_Venusaur, pokemon4_Charmander]);
});
it('should sort by name if the selected sort is "name"', () => {
const selectedSort = 'name';
const sortedList = sortPokemonsByIdOrName(pokemonList, selectedSort);
const sortedList = sortPokemonCardsByIdOrName(pokemonList, selectedSort);
expect(sortedList).toEqual([pokemon4_Charmander, pokemon3_Venusaur]);
});
});
describe('searchPokemonByName works correctly', () => {
beforeEach(() => {
store = configureStore({
reducer: {
pokedex: pokedexSlice.reducer,
[pokedexApi.reducerPath]: pokedexApi.reducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(
pokedexApi.middleware,
listenerMiddleware.middleware,
),
});
});
const pokemonList: PokemonResponseData[] = [
pokemon3_Venusaur,
pokemon4_Charmander,
];
it('should search by name correctly', () => {
const searchName = 'char';
const searchedList = searchPokemonCardsByName(pokemonList, searchName);
expect(searchedList).toHaveLength(1);
expect(searchedList[0]).toEqual(pokemon4_Charmander);
});
});
});

View File

@ -19,23 +19,27 @@ export const fetchPokemonsInTheRegion = createAsyncThunk<
const { dispatch, getState } = thunkAPI;
const regionOptions = getState().pokedex.regionOptions;
dispatch(setIsLoadingPokemons(true));
const { startId, endId } = getStartAndEndIdsForRegion(region, regionOptions);
const pokemonIds = Array.from(
{ length: endId - startId + 1 },
(_, i) => i + startId,
);
// use pokemonIds to fetch pokemon data using getPokemonQuery and store in state
// use pokemonIds to fetch pokemon data using fetch, which won't save the data in the cache
const pokemonList = await Promise.all(
pokemonIds.map(
id =>
dispatch(pokedexApi.endpoints.getPokemon.initiate(id)) as Promise<{
data: PokemonResponseData;
}>,
fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then(res =>
res.json(),
) as Promise<PokemonResponseData>,
),
);
const pokemonListData = pokemonList.map(
(pokemon: { data: PokemonResponseData }) => pokemon.data,
(pokemon: PokemonResponseData) => pokemon,
);
return pokemonListData;
});
@ -46,8 +50,9 @@ const initialState: PokedexState = {
selectedRegion: '',
selectedType: '',
selectedSort: '',
searchInput: '',
isLoadingPokemons: true,
pokemonList: [],
pokemonCardList: [],
};
export const pokedexSlice: Slice<PokedexState> = createSlice({
@ -56,8 +61,6 @@ export const pokedexSlice: Slice<PokedexState> = createSlice({
reducers: {
setSelectedRegion: (state, action: PayloadAction<string>) => {
state.selectedRegion = action.payload;
// call fetchPokemonsInTheRegion
fetchPokemonsInTheRegion(action.payload);
},
setSelectedType: (state, action: PayloadAction<string>) => {
state.selectedType = action.payload;
@ -77,18 +80,26 @@ export const pokedexSlice: Slice<PokedexState> = createSlice({
) => {
state.sortOptions = action.payload;
},
setSearchInput: (state, action: PayloadAction<string>) => {
state.searchInput = action.payload;
},
setIsLoadingPokemons: (state, action: PayloadAction<boolean>) => {
state.isLoadingPokemons = action.payload;
},
setPokemonList: (state, action: PayloadAction<PokemonResponseData[]>) => {
state.pokemonList = action.payload;
},
},
extraReducers: builder => {
// add fetchPokemonsInTheRegion
builder.addCase(fetchPokemonsInTheRegion.fulfilled, (state, action) => {
state.isLoadingPokemons = false;
state.pokemonList = action.payload;
// set action payload to pokemonCardList by transforming payload
state.pokemonCardList = action.payload.map(pokemon => ({
id: pokemon.id,
name: pokemon.name,
image: pokemon.sprites.other.dream_world.front_default
? pokemon.sprites.other.dream_world.front_default
: pokemon.sprites.other['official-artwork'].front_default,
types: pokemon.types.map(type => type.type.name),
}));
});
builder.addMatcher(
pokedexApi.endpoints.getTypeList.matchFulfilled,
@ -111,7 +122,8 @@ export const {
setRegionOptions,
setTypeOptions,
setSortOptions,
setPokemonList,
setSearchInput,
setIsLoadingPokemons,
} = pokedexSlice.actions;
export default pokedexSlice.reducer;

View File

@ -76,6 +76,9 @@ export interface PokemonResponseData {
dream_world: {
front_default: string;
};
'official-artwork': {
front_default: string;
};
};
};
}

View File

@ -1,14 +1,16 @@
import { PokemonResponseData } from './api';
import { PokemonCardProps } from '../PokemonCard';
export type PokedexState = {
selectedRegion: string;
regionOptions: RegionPokemonRange[];
selectedType: string;
typeOptions: string[];
selectedSort: string;
sortOptions: { name: string; value: string }[];
selectedRegion: string;
selectedType: string;
selectedSort: string;
searchInput: string;
isLoadingPokemons: boolean;
pokemonList: PokemonResponseData[];
pokemonCardList: PokemonCardProps[];
};
export type RegionPokemonRange = {

View File

@ -44,3 +44,10 @@ code {
align-items: center;
justify-content: center;
}
.all__pokemons {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}

View File

@ -3649,6 +3649,14 @@
dependencies:
"@types/react" "*"
"@types/react-lazy-load-image-component@^1.5.2":
version "1.5.2"
resolved "https://registry.yarnpkg.com/@types/react-lazy-load-image-component/-/react-lazy-load-image-component-1.5.2.tgz#b87e814b6b91853b802f04465364ff1e913dce6a"
integrity sha512-4NLJsMJVrMv18FuMIkUUBVj/PH9A+BvLKrZC75EWiEFn1IsMrZHgL1tVKw5QBfoa0Qjz6SkWIzEvwcyZ8PgnIg==
dependencies:
"@types/react" "*"
csstype "^3.0.2"
"@types/react-transition-group@^4.4.5":
version "4.4.5"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416"
@ -10230,6 +10238,11 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
lodash.uniq@4.5.0, lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@ -12636,6 +12649,14 @@ react-is@^18.0.0, react-is@^18.2.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-lazy-load-image-component@^1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/react-lazy-load-image-component/-/react-lazy-load-image-component-1.5.6.tgz#a4b84257be21b1825680b4e158d167c08aeda5ff"
integrity sha512-M0jeJtOlTHgThOfgYM9krSqYbR6ShxROy/KVankwbw9/amPKG1t5GSGN1sei6Cyu8+QJVuyAUvQ+LFtCVTTlKw==
dependencies:
lodash.debounce "^4.0.8"
lodash.throttle "^4.1.1"
react-merge-refs@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06"