Building a Full MySQL React Express Node (MERN) Stack: A Pokemon App
Table of Contents
Note that this writeup is designed for CS411 Project FA24. Each section maps the the branch of the repo that contains the entire code. The code is avaliable at: https://github.com/a2975667/gonnaCatchEmAll.
A high level walkthrough: WebApp #
I see full stack as three components – the frontend, the backend, and the interaction between both. Here is a high level framework adopted from CS409 slides. In this high level graph, the frontend (or client) presents and receives input from the “client” user. In this case, creating a blog post, and creating an interface to “Add Post.” It also shows the added post. The backend, or server, has a database that stores the content, “interfaced” through the API, or Application programming interface, by telling others what can the server manage. The interaction between them are HTTP(S) protocols for clients to send requests and receive requests to and from the server.
In this demo, I will be focusing on RESTful APIs, which are a type of interface that focus on the CRUD (Create, Read, Update, Delete) data. Other APIs include GraphQL, WebSockets, or Webhook will not be covered in this tutorial. We will work over these APIs in more details in the coming demo, for now, have a high level concept that we map GET to Read, POST to Create, PUT to Update, and DELETE as Delete. Think of URLs as an endpoint that can potentially do all four operations. And we return data in the form of JSON. (image taken from mannhowie.com).
PreWorkshop: Setup and Goal #
We will create a Pokédex application that supports full CRUD (Create, Read, Update, Delete) operations using MySQL as the database, hosted on Google Cloud Platform (GCP). By the end of this tutorial, you will have a web app where users can manage a list of Pokémon in a database and their spawn location. See a Youtube video demo of the end product: https://youtu.be/RHT-hADp6NY
Setting up the database #
I am skipping this section for CS411 students since the concept was covered in the previous workshop. Here are the data and code:
Data Prep: #
- SF Bay Area Pokemon Go Spawns
- Pokemon with stats Note that to the app, we dropped a few columns from these data sources, check the SQL statements below to identify those.
Database Design #
We create a Many to One relationship between the two dataset. A Pokemon spawns at multiple locations. We upload the files using GCP bucket (uploading the csv file to the bucket) and then import it to the MySQL database (using import)
-- Create Database
CREATE DATABASE pokemon;
USE pokemon;
CREATE TABLE pokemon (
pokemonID INT PRIMARY KEY,
pokemonName VARCHAR(50) NOT NULL,
type1 VARCHAR(20) NOT NULL,
type2 VARCHAR(20),
total INT NOT NULL,
hp INT NOT NULL,
attack INT NOT NULL,
defense INT NOT NULL,
spAtk INT NOT NULL,
spDef INT NOT NULL,
speed INT NOT NULL,
generation INT NOT NULL
);
CREATE TABLE pokemon_spawn (
spawnID INT PRIMARY KEY AUTO_INCREMENT,
num INT NOT NULL,
name VARCHAR(50) NOT NULL,
lat DECIMAL(10, 7) NOT NULL,
lng DECIMAL(10, 7) NOT NULL,
encounter_ms BIGINT NOT NULL,
disappear_ms BIGINT NOT NULL,
FOREIGN KEY (num) REFERENCES pokemon(pokemonID)
);
Features #
Here is our goal: (R)ead:
- Users can search for Pokémon(s) based on Pokémon names.
- Users can click on the Pokémon to read Pokémon stats.
- Under each Pokémon, users can see the most recent several spawn time and location (C)reate
- Users can add a spawn entry (U)pdate
- Users can update a spawn entry (D)elete
- Users can delete a spawn entry
Content #
Section 0, we will be setting up the folders and completing the prerequisites. Section 1, we will focus on the backend development Section 2, we will focus on the frontend development Section 3, we focus on putting frontend and backend together Section 4, we connect to the database Section 5, serving all with one server Section 6, I note down some other resources and notes for this demo.
Section 1: Setting up the project and demo sections. #
This project use express.js (node.js), MySQL, and React. We use tailwind.css and wind-ui to template the website.
.
├── client
├── data
└── server
In case you do not have node installed, here is how to install and manage node versions and node.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
nvm install node
Let’s create a small mock data that we will use for the demo. Under data/mockData.ts
export const pokemonData = [{"pokemonID":1,"pokemonName":"Bulbasaur","type1":"Grass","type2":"Poison","total":318,"hp":45,"attack":49,"defense":49,"spAtk":65,"spDef":65,"speed":45,"generation":1},{ pokemonID: 13, pokemonName: "Weedle", type1: "Bug", total: 195, hp: 40, attack: 35, defense: 30, spAtk: 20, spDef: 20, speed: 50, generation: 1 }, { pokemonID: 16, pokemonName: "Pidgey", type1: "Normal", type2: "Flying", total: 251, hp: 40, attack: 45, defense: 40, spAtk: 35, spDef: 35, speed: 56, generation: 1 }, { pokemonID: 41, pokemonName: "Zubat", type1: "Poison", type2: "Flying", total: 245, hp: 40, attack: 45, defense: 35, spAtk: 30, spDef: 40, speed: 55, generation: 1 }, { pokemonID: 60, pokemonName: "Poliwag", type1: "Water", total: 300, hp: 40, attack: 50, defense: 40, spAtk: 40, spDef: 40, speed: 90, generation: 1 }, { pokemonID: 50, pokemonName: "Diglett", type1: "Ground", total: 265, hp: 10, attack: 55, defense: 25, spAtk: 35, spDef: 45, speed: 95, generation: 1 }, { pokemonID: 23, pokemonName: "Ekans", type1: "Poison", total: 288, hp: 35, attack: 60, defense: 44, spAtk: 40, spDef: 54, speed: 55, generation: 1 }, { pokemonID: 46, pokemonName: "Paras", type1: "Bug", type2: "Grass", total: 285, hp: 35, attack: 70, defense: 55, spAtk: 45, spDef: 55, speed: 25, generation: 1 }, { pokemonID: 25, pokemonName: "Pikachu", type1: "Electric", total: 320, hp: 35, attack: 55, defense: 40, spAtk: 50, spDef: 50, speed: 90, generation: 1 }, { pokemonID: 66, pokemonName: "Machop", type1: "Fighting", total: 305, hp: 70, attack: 80, defense: 50, spAtk: 35, spDef: 35, speed: 35, generation: 1 }, { pokemonID: 19, pokemonName: "Rattata", type1: "Normal", total: 253, hp: 30, attack: 56, defense: 35, spAtk: 25, spDef: 35, speed: 72, generation: 1 }];
export const pokemonSpawnData = [ { spawnID: 1, num: 13, name: "Weedle", lat: 37.79359158, lng: -122.4087206, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 2, num: 16, name: "Pidgey", lat: 37.79474554, lng: -122.4064196, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 3, num: 41, name: "Zubat", lat: 37.79499907, lng: -122.4043841, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 4, num: 16, name: "Pidgey", lat: 37.79564441, lng: -122.4071276, encounter_ms: -1, disappear_ms: 1.46952e12 }, { spawnID: 5, num: 60, name: "Poliwag", lat: 37.79559153, lng: -122.4063311, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 6, num: 50, name: "Diglett", lat: 37.3011287, lng: -122.0484534, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 7, num: 23, name: "Ekans", lat: 37.30075739, lng: -122.0457006, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 8, num: 41, name: "Zubat", lat: 37.30346279, lng: -122.048187, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 9, num: 46, name: "Paras", lat: 37.75946719, lng: -122.4262419, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 10, num: 41, name: "Zubat", lat: 37.75949883, lng: -122.4232334, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 11, num: 25, name: "Pikachu", lat: 37.75991268, lng: -122.422614, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 12, num: 41, name: "Zubat", lat: 37.76101949, lng: -122.4248261, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 13, num: 66, name: "Machop", lat: 37.7611184, lng: -122.422083, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 14, num: 46, name: "Paras", lat: 37.30200206, lng: -122.0089315, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 15, num: 41, name: "Zubat", lat: 37.30201854, lng: -122.0028909, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 16, num: 19, name: "Rattata", lat: 37.30360876, lng: -122.0113299, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 17, num: 19, name: "Rattata", lat: 37.30367585, lng: -122.0078655, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 18, num: 19, name: "Rattata", lat: 37.30367585, lng: -122.0078655, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }, { spawnID: 19, num: 19, name: "Rattata", lat: 37.30128426, lng: -121.9980938, encounter_ms: -1, disappear_ms: 1.46952e12 }, { spawnID: 20, num: 16, name: "Pidgey", lat: 37.30172261, lng: -121.9961393, encounter_ms: 1.46952e12, disappear_ms: 1.46952e12 }];
Section 1: Backend development #
When developing the backend, I recommend NOT connecting to the database right away. The only thing to make sure is to have a consistent data shape. Let’s develop the server using the mockdata in the data folder. Our goal is to complete implementation of the functionalities.
Setup the project #
cd server
npm init -y
npm install express body-parser @types/express typescript ts-node nodemon --save-dev
under package.json
, let’s add the commands to start the server so we do not need to restart it every time, since we have nodemon
that watches for code changes and restart the server for us.
...,
"main": "index.ts",
"scripts": {
"start": "ts-node index.ts",
"dev": "nodemon --exec ts-node index.ts"
},
Since we are writing in typescript, we would need to configure it
{
"compilerOptions": {
"target": "ES6", // Target modern JavaScript
"module": "commonjs", // Node.js module system
"strict": true, // Enable strict type-checking
"esModuleInterop": true // Enable interoperability between CommonJS and ES Modules
}
}
Now, we can execute: npm run dev
And yes, it will throw an error, because we do not have code for it to start the server.
Let’s begin from doing a ‘simple’ server setup, we import and setup express router under index.ts
.
import express, { Request, Response } from 'express';
const app = express();
const PORT = 3007;
app.use(express.json());
then, we create our first get route and start up the server
app.get('/api/', (req:Request, res:Response) => {
res.send('Homepage of my Pokedex.');
});
app.listen(PORT, () => {
console.log(`Pokedex is running on http://localhost:${PORT}`);
});
With this saved, we can now navigate to the server at localhost:3007/api/. For simplicity, let’s open the browser and you should see the response.
Using Mock Data #
pokemon.ts
export interface Pokemon {
pokemonID: number;
pokemonName: string;
type1: string;
type2?: string; // Optional
total: number;
hp: number;
attack: number;
defense: number;
spAtk: number;
spDef: number;
speed: number;
generation: number;
}
pokemonSpawn.ts
export interface PokemonSpawn {
spawnID: number;
num: number;
name: string;
lat: number;
lng: number;
encounter_ms: number;
disappear_ms: number;
}
Now we can create a database.ts
file under a new src\services
folder to mimic pulling data from the database:
import { Pokemon } from "../models/pokemon";
import { PokemonSpawn } from "../models/pokemonSpawn";
import { pokemonData, pokemonSpawnData} from "../../../data/mockData";
const pd: Pokemon[] = pokemonData
const psd: PokemonSpawn[] = pokemonSpawnData
export async function getAllPokemon(): Promise<Pokemon[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(pd);
}, 300);
});
}
export async function getPokemonByPokemonName(pokemonName: string): Promise<Pokemon[]> {
const queryName = pokemonName.toLowerCase();
return new Promise((resolve) => {
setTimeout(() => {
resolve(pd.filter(p => p.pokemonName.toLowerCase().includes(queryName)));
}, 300);
});
}
export async function getPokemonByID(pokemonID: number): Promise<Pokemon | undefined> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(pd.find(p => p.pokemonID === pokemonID));
}, 300);
});
}
export async function getAllPokemonSpawns(): Promise<PokemonSpawn[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(psd);
}, 300);
});
}
export async function getPokemonSpawnBySpawnID(spawnID: number): Promise<PokemonSpawn | undefined> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(psd.find(p => p.spawnID === spawnID));
}, 300);
});
}
export async function getPokemonSpawnByPokemonID(pokemonID: number): Promise<PokemonSpawn[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(psd.filter(p => p.num === pokemonID));
}, 300);
});
}
export async function addPokemonSpawn(spawn: Omit<PokemonSpawn, 'spawnID'>): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
const maxSpawnID = psd.length > 0 ? Math.max(...psd.map(p => p.spawnID)) : 0;
const newSpawn: PokemonSpawn = {
spawnID: maxSpawnID + 1,
...spawn,
};
psd.push(newSpawn);
resolve();
}, 300);
});
}
export async function updatePokemonSpawn(spawn: PokemonSpawn): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
const index = psd.findIndex(p => p.spawnID === spawn.spawnID);
if (index >= 0) {
psd[index] = spawn;
}
resolve();
}, 300);
});
}
export async function deletePokemonSpawnbyID(spawnID: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
const index = psd.findIndex(p => p.spawnID === spawnID);
if (index >= 0) {
psd.splice(index, 1);
}
resolve();
}, 300);
});
}
Great, now this seems like we have all the “database” operations mocked up. Now we complete our API calls, let’s begin to define the routes: src/routes/pokemonRoutes.ts
and src/routes/pokemonSpawnRoutes.ts
.
Before we start implementing, we should explain what “routes” are and how to think about them. Recall Restful APIs: (images from: https://www.realisable.co.uk/support/documentation/iman-user-guide/DataConcepts/WebRequestAnatomy.htm)
The URL, or Uniform Resource Locator, contains the route, it tells the server where an incoming request is going to. In this example, its going to /api/orders
. Sometimes, aa route is appended with some query string. Other times, the request comes with a body.
I like to think of this as handling a package. Say Alex and Bob lives in the same apartment, while you put the address outside the package. You can specify if the package is to Alex outside the box (in this case query parameter) or inside the box with a letter to Alex (body).
import { Router, Request, Response } from "express";
import { getAllPokemon, getPokemonByID, getPokemonByPokemonName } from "../services/database";
import { Pokemon } from "../models/pokemon";
const router = Router();
// api/pokemon?search=pikachu
router.get("/", async (req: Request, res: Response) => {
// if there is no query parameter, return all Pokémon
if (!req.query.search) {
try {
const allPokemon: Pokemon[] = await getAllPokemon();
res.status(200).json(allPokemon);
} catch (error) {
res.status(500).json({ message: "Error fetching Pokémon" });
}
} else {
const query = req.query.search as string;
try {
const pokemon = await getPokemonByPokemonName(query);
res.status(200).json(pokemon);
} catch (error) {
res.status(500).json({ message: "Error fetching Pokémon" });
}
}
});
// api/pokemon/1
router.get("/:id", async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
try {
const pokemon = await getPokemonByID(id);
if (pokemon) {
res.status(200).json(pokemon);
} else {
res.status(404).json({ message: `No Pokémon found with ID ${id}` });
}
} catch (error) {
res.status(500).json({ message: "Error fetching Pokémon" });
}
});
export default router;
import { Router, Request, Response } from "express";
import { getAllPokemonSpawns, getPokemonSpawnByPokemonID, addPokemonSpawn, deletePokemonSpawnbyID, updatePokemonSpawn, getPokemonSpawnBySpawnID } from "../services/database";
import { PokemonSpawn } from "../models/pokemonSpawn";
const router = Router();
router.get("/", async (req: Request, res: Response) => {
try {
const allPokemonSpawns: PokemonSpawn[] = await getAllPokemonSpawns();
res.status(200).json(allPokemonSpawns);
} catch (error) {
res.status(500).json({ message: "Error fetching Pokémon spawns" });
}
});
router.get("/:id", async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
try {
const pokemonSpawn = await getPokemonSpawnBySpawnID(id);
if (pokemonSpawn) {
res.status(200).json(pokemonSpawn);
} else {
res.status(404).json({ message: `No Pokémon spawn found with ID ${id}` });
}
} catch (error) {
res.status(500).json({ message: "Error fetching Pokémon spawn" });
}
});
router.get("/pokemon/:id", async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
try {
const pokemonSpawns = await getPokemonSpawnByPokemonID(id);
res.status(200).json(pokemonSpawns);
} catch (error) {
res.status(500).json({ message: "Error fetching Pokémon spawns" });
}
});
router.post("/", async (req: Request, res: Response) => {
const newSpawn: Omit<PokemonSpawn, 'spawnID'> = req.body; // Do not expect spawnID in the request body
try {
const spawn = await addPokemonSpawn(newSpawn);
res.status(201).json(spawn);
} catch (error) {
res.status(500).json({ message: "Error adding Pokémon spawn" });
}
});
router.put("/:id", async (req: Request, res: Response) => {
const updatedSpawn: PokemonSpawn = req.body;
try {
await updatePokemonSpawn(updatedSpawn);
res.status(204).end();
} catch (error) {
res.status(500).json({ message: "Error updating Pokémon spawn" });
}
});
router.delete("/:id", async (req: Request, res: Response) => {
const id = parseInt(req.params.id);
try {
await deletePokemonSpawnbyID(id);
res.status(204).end();
} catch (error) {
res.status(500).json({ message: "Error deleting Pokémon spawn" });
}
});
export default router;
Let’s update our index.ts
import express, { Request, Response } from 'express';
import pokemonRoutes from './src/routes/pokemonRoutes';
import pokemonSpawnRoutes from './src/routes/pokemonSpawnRoutes';
const app = express();
const PORT = 3007;
app.use(express.json());
app.get('/', (req:Request, res:Response) => {
res.send('Homepage of my Pokedex.');
});
app.use("/pokemon", pokemonRoutes);
app.use("/pokemon-spawns", pokemonSpawnRoutes);
app.listen(PORT, () => {
console.log(`Pokedex is running on http://localhost:${PORT}`);
});
Now we can test it CURL on the terminal:
curl -X GET http://localhost:3007/api/pokemon
– you should see all the pokemons
curl -X GET http://localhost:3007/api/pokemon/1
– you should see the pokemon with ID 1
curl -X GET http://localhost:3007/api/pokemon-spawns
– you should see all the spawn records
curl -X GET http://localhost:3007/api/pokemon-spawns/1
– you should see the record for spawn id 1
curl -X PUT http://localhost:3007/api/pokemon-spawns/1 \
-H "Content-Type: application/json" \
-d '{
"spawnID": 1,
"num": 1,
"name": "Bulbasaur",
"lat": 50,
"lng": 100,
"encounter_ms": 1632878400000,
"disappear_ms": 1632878500000
}'
{"message":"Pokemon spawn with ID 1 has been updated.","updatedSpawn":{"spawnID":1,"num":1,"name":"Bulbasaur","lat":50,"lng":100,"encounter_ms":1632878400000,"disappear_ms":1632878500000}}
curl -X POST http://localhost:3007/api/pokemon-spawns \
-H "Content-Type: application/json" \
-d '{
"spawnID": 99,
"num": 1,
"name": "Bulbasaur",
"lat": 50,
"lng": 100,
"encounter_ms": 1632878400000,
"disappear_ms": 1632878500000
}'
curl -X DELETE http://localhost:3007/api/pokemon-spawns/99
Remember we are passing the data into the mock list we have, so when the server restarts, it will revert.
Section 2: Setting up the frontend #
Here is a simple, low fidelity prototype:We will use React.js to build the frontend. First, we move to the client folder and install the relevant components
npx create-react-app . --template typescript
rm -rf .git
npm install react-router-dom
npm start
Now you will see a react app. One thing we will need to change is the PORT
of the app, let us create a .env
with PORT=3009
.
We will also, again, use the same mock data to build our front end. This showcases how teams can work on different components and bring them together later.
Setting Up Mock Data in the FrontEnd #
Given that react app’s does not support importing external files, we copy the mock data.
cp ../data/mockData.ts src/services/.
We write the following to the services.ts
file under src/services
. This file serves will become where we connect the frontend with the backend, for now, we will use it to “serve” the mock data.
import { pokemonData, pokemonSpawnData } from "./mockData";
export interface Pokemon {
pokemonID: number;
pokemonName: string;
type1: string;
type2?: string; // Optional
total: number;
hp: number;
attack: number;
defense: number;
spAtk: number;
spDef: number;
speed: number;
generation: number;
}
export interface PokemonSpawn {
spawnID: number;
num: number;
name: string;
lat: number;
lng: number;
encounter_ms: number;
disappear_ms: number;
}
export const searchPokemonData = (query: string): Promise<Pokemon[]> => {
return new Promise((resolve) => {
setTimeout(() => {
// Simulate a backend search by filtering based on the query
const filtered = pokemonData.filter((pokemon) =>
pokemon.pokemonName.toLowerCase().includes(query.toLowerCase())
);
resolve(filtered);
}, 500); // Simulate a 500ms delay
});
};
// Simulate fetching Pokémon spawn data with delay
export const searchPokemonSpawnData = (query: number): Promise<PokemonSpawn[]> => {
return new Promise((resolve) => {
setTimeout(() => {
// Simulate a backend search by filtering based on the query
const filtered = pokemonSpawnData.filter((pokemon) =>
pokemon.num === Number(query)
);
resolve(filtered);
}, 500); // Simulate a 500ms delay
});
}
The last two functions serve as the mock functions serving data. With these in place, we can now begin building out interface. Before we begin, let’s install the libraries we will use, TailwindCSS (and wind-ui):
npm install -D tailwindcss
npm install wind-ui
npx tailwindcss init
within tailwind.config.js
, add './src/**/*.{js,jsx,ts,tsx}',
inside content. Also, under index.css
, put:
@tailwind base;
@tailwind components;
@tailwind utilities;
We now have the project setup for React. Before I dive into react code, here is a quick highlight. React is a frontend framework that uses states, props and effect to manage the different components. Frontend is loaded using HTML, which composite a DOM tree:
(image taken from https://info343.github.io/dom.html) React allows the composition and rendering of parts of the DOM as components. Each component has read-only properties (props) and internal state. At a high level, props determine how a component (or part of the DOM tree) looks and behaves, while state governs data changes within the component and triggers re-renders when updated. The useEffect hook manages side effects, specifying what should happen when state or props change, or when components mount or unmount. React has great documentations to learn from. I shall begin the demo.
Based in the low fidelity prototype, I’m going to create two pages – corresponding to the two mockup pages. This of pages as giant components. I am going to break front end development into three sections:
- 2.1 Page 1: Search Bar with stacked List then
- 2.2 Page 2: Individual Pokémon Info Page then
- 2.3 Adding Component – Spawn History with edits – to page 2 then
- 2.4 Adding Component – Popup with input fields – to page 2
- 2.5 Adding other fancy stuffs
2.1 Page 1: Search Bar with stacked List #
We begin from routing users from the App to the the page showing all Pokémon. Let’s create the page for it. We create pokedexPage.tsx
under src/pages
.
import React from 'react';
const PokedexPage: React.FC = () => {
return (
<div>
<h1>Pokédex</h1>
</div>
);
};
export default PokedexPage;
Now we update the root App.tsx
.
import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import './App.css';
import PokedexPage from './pages/pokedexPage';
const App: React.FC = () => {
return (
<Router>
<Routes>
<Route path="/" element={<PokedexPage />} />
</Routes>
</Router>
)
}
export default App;
Now we should see the home page contains the “Pokédex” word. Now we can start to update the layout of the page. which mostly are cosmetic classes adopted from tailwind.
import React from "react";
const PokedexPage: React.FC = () => {
return (
<>
<div className="overflow-hidden bg-white py-24 sm:py-32">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
<div className="lg:pr-8 lg:pt-4">
<h1 className="mt-2 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> My Pokédex </h1>
<h2 className="text-base font-semibold leading-7 text-indigo-600"> Gotta Catch 'Em All </h2>
</div>
</div>
</div>
</div>
</>
);
};
export default PokedexPage;
Now let’s add the components for the search bar and the list:
searchBar.tsx
under src/components/searchBar
const SearchBar = () => {
return <div>Pokedex Search</div>;
};
export default SearchBar;
pokemonList.tsx
under src/components/pokemonList
const PokemonList = () => {
return <div>Pokedex List</div>;
};
export default PokemonList;
and update the pokedexPage.tsx
import React from "react";
import SearchBar from "../components/searchBar/searchBar";
import PokemonList from "../components/pokemonList/pokemonList";
const PokedexPage: React.FC = () => {
return (
<>
<div className="overflow-hidden bg-white py-12 sm:py-16">
<div className="mx-auto max-w-7xl px-5 lg:px-7">
<div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
<div className="lg:pr-8 lg:pt-4">
<h1 className="mt-2 mb-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
My Pokédex
</h1>
<h2 className="text-base font-semibold leading-7 text-indigo-600">
Gotta Catch 'Em All
</h2>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<SearchBar />
<div className="mt-6 py-10 sm:py-15">
<PokemonList />
</div>
</div>
</>
);
};
export default PokedexPage;
This was we see where the search bar is going to be placed as well as the list. Here is the updated search:
import React, { useState } from "react";
interface SearchBarProps {
onSearch: (searchTerm: string) => void;
}
const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
const [searchTerm, setSearchTerm] = useState("");
const handleSearch = () => {
onSearch(searchTerm);
};
return (
<div className="flex items-center space-x-4 w-full max-w-lg">
<div className="relative w-full">
<input
type="search"
placeholder="Search Pokémon..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full py-2 pl-10 pr-4 border border-gray-300 rounded-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
className="absolute top-2.5 h-5 w-5 cursor-pointer stroke-slate-400 peer-disabled:cursor-not-allowed"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
aria-label="Search icon"
role="graphics-symbol"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
</div>
</div>
{/* Search button */}
<button
onClick={handleSearch}
className="px-4 py-2 text-white bg-indigo-600 rounded-lg hover:bg-indigo-700"
>
Search
</button>
</div>
);
};
export default SearchBar;
So what happens begins when you start typing into the input box. for each keystroke, it updates (onchange
) the text inside the target value, reflected as the searchTerm
. When the user clicks the search button, it triggers the onClick
event, which is the handleSearch
operation. Now we define the handle search operation at the top, which triggers the
here is that we create the properties of the search bar (SearchBarProps
) which we define an event onSearch
. We need to use onSearch to pass the query back to the parent component. Thus, we add to the Props:
(part of `pokedexPage.tsx)
...
const PokedexPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState("");
const handleSearch = (query: string) => {
setSearchQuery(query);
};
return (...
<SearchBar onSearch={handleSearch}/>
...
the setQueryState
then updates the state of the searchQuery
, that triggers an Effect. We continue to pokedexPage.tsx
, here we console log to see if it really works!
...
const [searchQuery, setSearchQuery] = useState("");
const handleSearch = (query: string) => {
setSearchQuery(query);
};
// useEffect to fetch data based on search query
useEffect(() => {
const fetchData = async () => {
const data = await searchPokemonData(searchQuery);
console.log(data);
};
fetchData();
}, [searchQuery]); // Trigger effect whenever the search query changes
return (
...
Great! now instead of console logging the data, we need to pass to the lists showing all the Pokémon. we want the Pokémon list to update whenever this list of results changes:
so now the full pokedexPage.tsx
looks like
import React, {useState, useEffect} from "react";
import SearchBar from "../components/searchBar/searchBar";
import PokemonList from "../components/pokemonList/pokemonList";
import { Pokemon, searchPokemonData } from "../services/services";
const PokedexPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState("");
const [pokemonData, setPokemonData] = useState<Pokemon[]>([]);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
// useEffect to fetch data based on search query
useEffect(() => {
const fetchData = async () => {
setPokemonData([]);
const data = await searchPokemonData(searchQuery);
setPokemonData(data);
};
fetchData();
}, [searchQuery]); // Trigger effect whenever the search query changes
return (
<>
<div className="overflow-hidden bg-white py-12 sm:py-16">
<div className="mx-auto max-w-7xl px-5 lg:px-7">
<div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
<div className="lg:pr-8 lg:pt-4">
<h1 className="mt-2 mb-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
My Pokédex
</h1>
<h2 className="text-base font-semibold leading-7 text-indigo-600">
Gotta Catch 'Em All
</h2>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<SearchBar onSearch={handleSearch}/>
<div className="mt-6 py-10 sm:py-15">
<PokemonList pokemonData={pokemonData}/>
</div>
</div>
</>
);
};
export default PokedexPage;
With the pokemonData
, we can now render the pokemonList
component.
import React from "react";
import { Pokemon } from "../../services/services";
import { Link } from "react-router-dom";
interface PokemonListProps {
pokemonData: Pokemon[];
}
const PokemonList: React.FC<PokemonListProps> = ({ pokemonData }) => {
return (
<ul className="divide-y divide-gray-200">
{pokemonData.map((pokemon) => (
<li key={pokemon.pokemonID} className="flex py-4">
<div className="ml-3 py-5">
<p className="text-xl font-medium text-gray-900">
{pokemon.pokemonName}
</p>
<p className="text-xl text-gray-500">
{pokemon.type1} {pokemon.type2 ? `/ ${pokemon.type2}` : ""}
</p>
</div>
<div className="ml-auto py-8">
<Link className="text-indigo-600 hover:text-indigo-900 flex items-center" to={`/searchPokemon/${pokemon.pokemonID}`}>
See stats and Spawns
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 ml-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
</div>
</li>
))}
</ul>
);
};
export default PokemonList;
Now, you should see the list and the search working seamlessly. But the list is so boring… I want to be fancy and get images using APIs. We can use the “useEffect” hook to get images using external APIs. We first design the getPokemonImage
(I know I can use axios but let’s keep it simple for now.
import React, { useState, useEffect } from "react";
import { Pokemon } from '../services/services';
interface PokemonListProps {
pokemonData: Pokemon[];
}
const getPokemonImage = async (pokemonName: string): Promise<string | null> => {
try {
const response = await fetch(
`https://pokeapi.co/api/v2/pokemon/${pokemonName.toLowerCase()}`
);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
return data.sprites.front_default;
} catch (error) {
console.error("Failed to fetch Pokemon image:", error);
return null;
}
};
const PokemonList: React.FC<PokemonListProps> = ({ pokemonData }) => {
const [pokemonImages, setPokemonImages] = useState<{
[key: number]: string | null;
}>({});
useEffect(() => {
const fetchImages = async () => {
const images: { [key: number]: string | null } = {};
for (const pokemon of pokemonData) {
const imageUrl = await getPokemonImage(pokemon.pokemonName);
images[pokemon.pokemonID] = imageUrl;
}
setPokemonImages(images); // Set the images once all have been fetched
};
fetchImages();
}, [pokemonData]); // Runs whenever pokemonData changes
return (
<ul className="divide-y divide-gray-200">
{pokemonData.map((pokemon) => (
<li key={pokemon.pokemonID} className="flex py-4">
<img
className="h-13 w-13 rounded-full"
src={
pokemonImages[pokemon.pokemonID] ||
"https://archives.bulbagarden.net/media/upload/thumb/0/00/Bag_Pok%C3%A9_Ball_SV_Sprite.png/80px-Bag_Pok%C3%A9_Ball_SV_Sprite.png"
}
alt={pokemon.pokemonName}
/>
<div className="ml-3 py-5">
<p className="text-xl font-medium text-gray-900">
{pokemon.pokemonName}
</p>
<p className="text-xl text-gray-500">
{pokemon.type1} {pokemon.type2 ? `/ ${pokemon.type2}` : ""}
</p>
</div>
<div className="ml-auto py-8">
<button className="text-indigo-600 hover:text-indigo-900 flex items-center">
See stats and Spawns
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 ml-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</li>
))}
</ul>
);
};
export default PokemonList;
Now we finally complete the “(R)ead”.
Page 2: individual Pokémon Info Page #
Now we can move to the second page – where we see information and allow the CUD of spawn data. Before we begin, let’s update the services to support these operations. We add the following to service.ts
.
export const getPokemonByID = (id: number): Promise<Pokemon | undefined> => {
return new Promise((resolve) => {
setTimeout(() => {
const pokemon = pokemonData.find((pokemon) => pokemon.pokemonID === id);
resolve(pokemon);
}, 500); // Simulate a 500ms delay
});
}
export const addPokemonSpawn = (newSpawn: Omit<PokemonSpawn, 'spawnID'>): Promise<PokemonSpawn> => {
return new Promise((resolve) => {
setTimeout(() => {
const newSpawnID = pokemonSpawnData.length + 1; // Auto-incremented ID
const createdSpawn = { ...newSpawn, spawnID: newSpawnID };
pokemonSpawnData.push(createdSpawn);
resolve(createdSpawn); // Return the created spawn with ID
}, 500); // Simulate a 500ms delay
});
};
export const updatePokemonSpawn = (updatedSpawn: PokemonSpawn): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
const index = pokemonSpawnData.findIndex((spawn) => spawn.spawnID === updatedSpawn.spawnID);
if (index !== -1) {
pokemonSpawnData[index] = updatedSpawn;
}
resolve();
}, 500); // Simulate a 500ms delay
});
}
export const deletePokemonSpawn = (spawnID: number): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
const index = pokemonSpawnData.findIndex((spawn) => spawn.spawnID === spawnID);
if (index !== -1) {
pokemonSpawnData.splice(index, 1);
}
resolve();
}, 500); // Simulate a 500ms delay
});
}
Now, we can begin by creating the pokemon page, but this time it takes in a parameter from the URL. First we update the “See stats and Spawns” above into a Link
<Link className="text-indigo-600 hover:text-indigo-900 flex items-center" to={`/searchPokemon/${pokemon.pokemonID}`}>
See stats and Spawns
<svg
...
</svg>
</Link>
to support this path, we create the page pokemonPage.tsx
under src/pages
.
import { useParams } from 'react-router-dom';
const PokemonPage: React.FC = () => {
const { pokemonID } = useParams(); // Access pokemonID from the URL
return (
<div>
<h1>Details for Pokémon ID: {pokemonID}</h1>
</div>
);
};
export default PokemonPage;
and update App.tsx
correspondingly:
...
<Route path="/" element={<PokedexPage />} />
<Route path="/searchPokemon/:pokemonID" element={<PokemonPage />} />
...
Now when we click on the interface, a new page shows up. Now we update the stats, given it’s similarity to the previous steps, I am going to provide the full set of code here.
We create the pokemonDataCard.tsx
under src/components/pokemonDataCard
import React, { useEffect } from "react";
import { Pokemon } from "../../services/services";
const getPokemonImage = async (pokemonName: string): Promise<string | null> => {
try {
const response = await fetch(
`https://pokeapi.co/api/v2/pokemon/${pokemonName.toLowerCase()}`
);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
return data.sprites.other["official-artwork"].front_default;
} catch (error) {
console.error("Failed to fetch Pokemon image:", error);
return null;
}
};
const PokemonDataCard: React.FC<{ pokemon: Pokemon }> = ({ pokemon }) => {
const [pokemonImageURL, setPokemonImageURL] = React.useState<string | null>(null);
useEffect(() => {
const fetchImage = async () => {
const imageUrl = await getPokemonImage(pokemon.pokemonName);
setPokemonImageURL(imageUrl);
};
fetchImage();
}, [pokemon.pokemonName]);
const pokemonStats = [
{ label: 'Total Stats', value: pokemon.total },
{ label: 'HP', value: pokemon.hp },
{ label: 'Attack', value: pokemon.attack },
{ label: 'Defense', value: pokemon.defense },
{ label: 'Sp. Attack', value: pokemon.spAtk },
{ label: 'Sp. Defense', value: pokemon.spDef },
{ label: 'Speed', value: pokemon.speed },
{ label: 'Generation', value: pokemon.generation }
];
// Split stats array into two halves
const half = Math.ceil(pokemonStats.length / 2);
const firstColumnStats = pokemonStats.slice(0, half);
const secondColumnStats = pokemonStats.slice(half);
return (
<section>
<div className="container px-6 m-auto">
<div className="grid grid-cols-4 gap-6 md:grid-cols-8 lg:grid-cols-12">
{/* Left side (3/12): Pokémon Image */}
<div className="col-span-4 lg:col-span-4">
{/* Placeholder for the Pokémon image */}
<img
src={pokemonImageURL || "https://archives.bulbagarden.net/media/upload/thumb/0/00/Bag_Pok%C3%A9_Ball_SV_Sprite.png/80px-Bag_Pok%C3%A9_Ball_SV_Sprite.png"}
alt={pokemon.pokemonName}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
{/* Right side (9/12): Pokémon Details */}
<div className="col-span-8 lg:col-span-8 lg:px-10 py-20">
<div className="px-4 sm:px-0">
<h3 className="text-xl font-semibold leading-7 text-gray-900">
{pokemon.pokemonName}
</h3>
</div>
{/* Pokémon Details List in Two Columns */}
<div className="mt-6 border-t border-gray-200">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* First Column */}
<dl className="divide-y divide-gray-100">
{firstColumnStats.map((stat, index) => (
<div key={index} className="py-2 sm:grid sm:grid-cols-3 sm:gap-3">
<dt className="text-regular font-medium text-gray-900 px-2">{stat.label}</dt>
<dd className="mt-1 text-medium text-gray-700 sm:col-span-2">{stat.value}</dd>
</div>
))}
</dl>
{/* Second Column */}
<dl className="divide-y divide-gray-100">
{secondColumnStats.map((stat, index) => (
<div key={index} className="py-2 sm:grid sm:grid-cols-3 sm:gap-3">
<dt className="text-regular font-medium text-gray-900 px-2">{stat.label}</dt>
<dd className="mt-1 text-medium text-gray-700 sm:col-span-2">{stat.value}</dd>
</div>
))}
</dl>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default PokemonDataCard;
and subsequently the pokemonPage.tsx
import React, {useState, useEffect} from 'react';
import { Link, useParams } from 'react-router-dom';
import { getPokemonByID, Pokemon } from '../services/services';
import PokemonDataCard from '../components/pokemonDataCard/pokemonDataCard';
const PokemonPage: React.FC = () => {
const { pokemonID } = useParams();
const [pokemon, setPokemon] = useState<Pokemon | undefined>(undefined);
useEffect(() => {
const fetchPokemon = async () => {
// Fetch the Pokémon data based on the ID
const data = await getPokemonByID(Number(pokemonID));
setPokemon(data);
};
fetchPokemon();
}, [pokemonID]);
if (!pokemon) { return <></>; }
return (
<>
<div className="overflow-hidden bg-white py-12 sm:py-16">
<div className="mx-auto max-w-7xl px-5 lg:px-7">
<div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
<div className="lg:pr-8 lg:pt-4">
<Link to="/" className="text-grey-600 hover:underline">← Back to Pokédex</Link>
<h1 className="mt-2 mb-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
Pokédex: #{pokemonID} {pokemon?.pokemonName}
</h1>
<h2 className="text-base font-semibold leading-7 text-indigo-600">
Gotta Catch 'Em All
</h2>
</div>
</div>
</div>
</div>
{pokemon && (
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<PokemonDataCard pokemon={pokemon} />
</div>
)}
</>
);
};
export default PokemonPage;
This would generate a info for a specific Pokémon. Let’s see how the app works.
2.3 Component: Spawn History with edits/delete #
We create a timeline component and get spawn data in the Pokémon page
Create the pokemonSpawnTimeline.tsx
in src/pokemonSpawnTimeline
import React from "react";
import { PokemonSpawn } from "../../services/services";
// Helper function to format time from Unix timestamp
const formatDate = (timestamp: number) => {
if (timestamp === -1) return "Unknown";
const date = new Date(timestamp);
return date.toLocaleString();
};
interface PokemonSpawnTimelineProps {
spawns: PokemonSpawn[];
pokemonNum: number;
pokemonName: string;
}
const PokemonSpawnTimeline: React.FC<PokemonSpawnTimelineProps> = ({ spawns, pokemonName, pokemonNum }) => {
return (
<div className="container mx-auto py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Spawn Timeline</h1>
{/* Button to add a new spawn */}
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
+ New
</button>
</div>
{/* Timeline List */}
<ul
aria-label="Spawn history feed"
role="feed"
className="relative flex flex-col gap-12 py-12 pl-6 text-sm before:absolute before:top-0 before:left-6 before:h-full before:-translate-x-1/2 before:border before:border-dashed before:border-slate-200 after:absolute after:top-6 after:left-6 after:bottom-6 after:-translate-x-1/2 after:border after:border-slate-200"
>
{spawns.map((spawn) => (
<li
key={spawn.spawnID}
role="article"
className="relative pl-6 before:absolute before:left-0 before:top-2 before:z-10 before:h-2 before:w-2 before:-translate-x-1/2 before:rounded-full before:bg-emerald-500 before:ring-2 before:ring-white"
>
<div className="flex flex-col flex-1 gap-2">
<h4 className="text-base font-medium leading-7 text-emerald-500">
{formatDate(spawn.encounter_ms)}
</h4>
<p className="text-slate-500">
Showed up at latitude {spawn.lat}, longitude {spawn.lng}.
</p>
{/* Edit and Delete buttons */}
<div className="flex space-x-4">
<button className="text-indigo-600 hover:text-indigo-900">
Edit
</button>
<button className="text-red-600 hover:text-red-900">
Delete
</button>
</div>
</div>
</li>
))}
</ul>
</div>
);
}
export default PokemonSpawnTimeline;
and then update the pokemonPage.tsx
import React, {useState, useEffect} from 'react';
import { Link, useParams } from 'react-router-dom';
import { getPokemonByID, Pokemon, PokemonSpawn, searchPokemonSpawnData } from '../services/services';
import PokemonDataCard from '../components/pokemonDataCard/pokemonDataCard';
import PokemonSpawnTimeline from '../components/pokemonSpawnTimeline/pokemonSpawnTimeline';
const PokemonPage: React.FC = () => {
const { pokemonID } = useParams();
const [pokemon, setPokemon] = useState<Pokemon | undefined>(undefined);
const [pokemonSpawnData, setPokemonSpawnData] = useState<PokemonSpawn[]>([]);
useEffect(() => {
const fetchPokemon = async () => {
// Fetch the Pokémon data based on the ID
const data = await getPokemonByID(Number(pokemonID));
setPokemon(data);
};
fetchPokemon();
}, [pokemonID]);
useEffect(() => {
const fetchSpawnData = async () => {
// Fetch the Pokémon spawn data based on the ID
const data = await searchPokemonSpawnData(Number(pokemonID));
setPokemonSpawnData(data);
};
fetchSpawnData();
}, [pokemonID]);
if (!pokemon) { return <></>; }
return (
<>
<div className="overflow-hidden bg-white py-12 sm:py-16">
<div className="mx-auto max-w-7xl px-5 lg:px-7">
<div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
<div className="lg:pr-8 lg:pt-4">
<Link to="/" className="text-grey-600 hover:underline">← Back to Pokédex</Link>
<h1 className="mt-2 mb-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
Pokédex: #{pokemonID} {pokemon?.pokemonName}
</h1>
<h2 className="text-base font-semibold leading-7 text-indigo-600">
Gotta Catch 'Em All
</h2>
</div>
</div>
</div>
</div>
{pokemon && (
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<PokemonDataCard pokemon={pokemon} />
</div>
)}
{pokemonSpawnData.length > 0 && (
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<PokemonSpawnTimeline spawns={pokemonSpawnData} pokemonName={pokemon!.pokemonName} pokemonNum={pokemon!.pokemonID}/>
</div>
)}
{pokemonSpawnData.length === 0 && (
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<p className="text-lg text-gray-600">No spawn data available for this Pokémon.</p>
</div>
)}
</>
);
}
export default PokemonPage;
Let’s first work on delete, for the delete button, let’s update pokemonSpawnTimeline.tsx
:
...
<button
onClick={() => handleDelete(spawn.spawnID)}
className="text-red-600 hover:text-red-900">
Delete
</button>
...
now, we need to define the handleDelete
process, and also add a prop for this component so that the deletion “state” is propagated to the page to update the data source:
interface PokemonSpawnTimelineProps {
spawns: PokemonSpawn[];
onDelete: (spawnID: number) => void;
pokemonNum: number;
pokemonName: string;
}
const PokemonSpawnTimeline: React.FC<PokemonSpawnTimelineProps> = ({ spawns, onDelete, pokemonName, pokemonNum }) => {
const handleDelete = async (spawnID: number) => {
await deletePokemonSpawn(spawnID);
onDelete(spawnID);
};
...
hence, we update the parent page:
pokemonPage.tsx
{pokemonSpawnData.length > 0 && (
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<PokemonSpawnTimeline spawns={pokemonSpawnData}
onDelete={handleDeleteSpawn}
pokemonName={pokemon!.pokemonName}
pokemonNum={pokemon!.pokemonID}/>
</div>
)}
what this should do is to update a state that triggers the previous useEffect
we wrote that fetches the timeline data.
const PokemonPage: React.FC = () => {
const { pokemonID } = useParams();
const [pokemon, setPokemon] = useState<Pokemon | undefined>(undefined);
const [pokemonSpawnData, setPokemonSpawnData] = useState<PokemonSpawn[]>([]);
const [fetchNewSpawnData, setFetchNewSpawnData] = useState<boolean>(false);
...
useEffect(() => {
const fetchSpawnData = async () => {
// Fetch the Pokémon spawn data based on the ID
const data = await searchPokemonSpawnData(Number(pokemonID));
setPokemonSpawnData(data);
};
fetchSpawnData();
}, [pokemonID, fetchNewSpawnData]);
const handleDeleteSpawn = async (spawnID: number) => {
setFetchNewSpawnData((prevState) => !prevState);
};
return (...
Now we can see that the data can be removed. Remember, when you refresh, the data will be back!
2.4 Component: Popup with input fields #
Now we work on the input form for the insert and update. The design I am going to implement is to have a popup when the trainer clicks on new or update with the fields they can input. Let’s begin from the pop-up modal.
spawnForm.ts
in src/components/spawnForm
import React from 'react';
interface spawnFormProps {
onClose: () => void;
}
const SpawnForm: React.FC<spawnFormProps> = ({ onClose }) => {
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-lg w-full">
<h2 className="text-lg font-bold mb-4">Popup Modal</h2>
{/* Placeholder content */}
<p className="text-sm text-gray-600">This is a basic popup modal. No content yet!</p>
{/* Close Button */}
<div className="mt-4 flex justify-end">
<button
onClick={onClose}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"
>
Close
</button>
</div>
</div>
</div>
);
};
export default SpawnForm;
in the timeline component, we now update the Edit and New Button
<button
onClick={handleAddNewSpawn}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
+ New
</button>
...
<button
onClick={handleEditSpawn(spawn)}
className="text-indigo-600 hover:text-indigo-900">
Edit
</button>
We then add support for these onClick
behaviors. We want to keep track of the state of the component and the content of the form, we also add the component and control its visibility based on the state.
const PokemonSpawnTimeline: React.FC<PokemonSpawnTimelineProps> = ({ spawns, onDelete, pokemonName, pokemonNum }) => {
const [isFormVistible, setIsFormVisible] = React.useState(false);
const [spawnInformationToEdit, setSpawnInformationToEdit] = React.useState<PokemonSpawn | null>(null);
const handleAddNewSpawn = () => {
setSpawnInformationToEdit(null);
setIsFormVisible(true);
};
const handleEditSpawn = (spawn: PokemonSpawn) => () => {
setSpawnInformationToEdit(spawn);
setIsFormVisible(true);
};
...
</li>
))}
</ul>
{
isFormVistible && (
<SpawnForm onClose={() => setIsFormVisible(false)} />
)
}
</div>
Now the modal pops up when you click edit or new. Finally, we can add the fields in the form. What we want the forms to do is to collect the data that we want to send to the backend. So here is what spawnForm.tsx
looks like:
First we update the Props of the component. We do not need the spawnID, name and number since these are information that the parent holds.
interface spawnFormProps {
onClose: () => void;
onSubmit: (spawnData: Omit<PokemonSpawn, 'spawnID'| 'name' | 'num'> ) => void;
defaultSpawnData?: Omit<PokemonSpawn, 'spawnID' | 'name' | 'num'>;
}
then, we update the form itself:
const SpawnForm: React.FC<spawnFormProps> = ({ onClose, onSubmit, defaultSpawnData }) => {
const [lat, setLat] = React.useState<number>(defaultSpawnData?.lat || 0);
const [lng, setLng] = React.useState<number>(defaultSpawnData?.lng || 0);
const [encounterTime, setEncounterTime] = React.useState<number>(defaultSpawnData?.encounter_ms || 0);
const [disappearTime, setDisappearTime] = React.useState<number>(defaultSpawnData?.disappear_ms || 0);
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-lg w-full">
<h2 className="text-lg font-bold mb-4">
{defaultSpawnData ? 'Edit Pokémon Spawn' : 'Add Pokémon Spawn'}
</h2>
<form onSubmit={handleSubmit}>
{/* Latitude Field */}
<div>
<label htmlFor="lat" className="block text-sm font-medium text-gray-900">
Latitude
</label>
<input
id="lat"
type="number"
value={lat}
onChange={(e) => setLat(Number(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</div>
{/* Longitude Field */}
<div className="mt-4">
<label htmlFor="lng" className="block text-sm font-medium text-gray-900">
Longitude
</label>
<input
id="lng"
type="number"
value={lng}
onChange={(e) => setLng(Number(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</div>
{/* Encounter Time Field */}
<div className="mt-4">
<label htmlFor="encounterTime" className="block text-sm font-medium text-gray-900">
Encounter Timestamp (ms)
</label>
<input
id="encounterTime"
type="number"
value={encounterTime}
onChange={(e) => setEncounterTime(Number(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</div>
{/* Disappear Time Field */}
<div className="mt-4">
<label htmlFor="disappearTime" className="block text-sm font-medium text-gray-900">
Disappear Timestamp (ms)
</label>
<input
id="disappearTime"
type="number"
value={disappearTime}
onChange={(e) => setDisappearTime(Number(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</div>
{/* Buttons */}
<div className="mt-6 flex justify-end gap-x-2">
<button type="button" className="text-gray-900 font-semibold" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="bg-indigo-600 text-white font-semibold px-4 py-2 rounded-md hover:bg-indigo-700"
>
Save
</button>
</div>
</form>
</div>
</div>
);
};
export default SpawnForm;
Notice here that the form has the handleSubmit
function added, so let’s add that too:
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
lat, lng, encounter_ms: encounterTime, disappear_ms: disappearTime,
});
onClose();
};
Finally, we can head back to the Timeline component and let’s console.log the results.
... {
isFormVistible && (
<SpawnForm onClose={() => setIsFormVisible(false)} onSubmit={( //let's console.log the values
{ lat, lng, encounter_ms, disappear_ms }) => {
console.log({ lat, lng, encounter_ms, disappear_ms });
setIsFormVisible(false);
}
} defaultSpawnData={spawnInformationToEdit ? { lat: spawnInformationToEdit.lat, lng: spawnInformationToEdit.lng, encounter_ms: spawnInformationToEdit.encounter_ms, disappear_ms: spawnInformationToEdit.disappear_ms } : undefined} />
)
}
</div> ...
Let’s try it out, in the console, you should now see the results in the console.log when you submit a new form or when you edit a new form.
Finally, let’s hook up the mock APIs with the form in pokemonSpawnTimeline.tsx
...
import { PokemonSpawn, deletePokemonSpawn, updatePokemonSpawn, addPokemonSpawn } from '../services/services';
...
next, we define how to handle the form submission in the same file:
const handleFormSubmit = async (spawnData: Omit<PokemonSpawn, 'spawnID' | 'name' | 'num'>) => {
const completeSpawnData = {
...spawnData,
name: pokemonName,
num: pokemonNum
};
if (spawnInformationToEdit) {
await updatePokemonSpawn({ ...spawnInformationToEdit, ...completeSpawnData }); // right overwrites left
} else {
await addPokemonSpawn(completeSpawnData);
}
setSpawnInformationToEdit(null);
setIsFormVisible(false);
};
and finally the component:
... {
isFormVistible && (
<SpawnForm onClose={() => setIsFormVisible(false)}
onSubmit={handleFormSubmit}
defaultSpawnData={spawnInformationToEdit || undefined} />
)
}
</div>
);
}
export default PokemonSpawnTimeline;
Last but not lease, we need to remember to trigger the useEffect
after updating or inserting:
pokemonSpawnTimeline.tsx
interface PokemonSpawnTimelineProps {
onUpdate: () => void;
...
}
...
const PokemonSpawnTimeline: React.FC<PokemonSpawnTimelineProps> = ({ spawns, onDelete, onUpdate, pokemonName, pokemonNum }) => {
...
const handleFormSubmit = async (spawnData: Omit<PokemonSpawn, 'spawnID' | 'name' | 'num'>) => {
...
onUpdate();
};
In the pokemonPage.tsx
...
const handleUpdateSpawn = async () => {
setFetchNewSpawnData((prevState) => !prevState);
}
...
{pokemonSpawnData.length > 0 && (
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<PokemonSpawnTimeline spawns={pokemonSpawnData}
onDelete={handleDeleteSpawn}
onUpdate={handleUpdateSpawn}
pokemonName={pokemon!.pokemonName}
pokemonNum={pokemon!.pokemonID}/>
</div>
)}
Now we notice one problem here, if the Pokémon has no spawn data, then the component won’t even render. So let’s move the render decision into the component. Thus, the final component would look like the following:
pokemonSpawnTimeline.tsx
import React from "react";
import { deletePokemonSpawn, PokemonSpawn, updatePokemonSpawn, addPokemonSpawn} from "../../services/services";
import SpawnForm from "../spawnForm/spawnForm";
import { spawn } from "child_process";
// Helper function to format time from Unix timestamp
const formatDate = (timestamp: number) => {
if (timestamp === -1) return "Unknown";
const date = new Date(timestamp);
return date.toLocaleString();
};
interface PokemonSpawnTimelineProps {
spawns: PokemonSpawn[];
onDelete: (spawnID: number) => void;
onUpdate: () => void;
pokemonNum: number;
pokemonName: string;
}
const PokemonSpawnTimeline: React.FC<PokemonSpawnTimelineProps> = ({ spawns, onDelete, onUpdate, pokemonName, pokemonNum }) => {
const [isFormVistible, setIsFormVisible] = React.useState(false);
const [spawnInformationToEdit, setSpawnInformationToEdit] = React.useState<PokemonSpawn | null>(null);
const handleAddNewSpawn = () => {
setSpawnInformationToEdit(null);
setIsFormVisible(true);
};
const handleEditSpawn = (spawn: PokemonSpawn) => () => {
setSpawnInformationToEdit(spawn);
setIsFormVisible(true);
};
const handleDelete = async (spawnID: number) => {
await deletePokemonSpawn(spawnID);
onDelete(spawnID);
};
const handleFormSubmit = async (spawnData: Omit<PokemonSpawn, 'spawnID' | 'name' | 'num'>) => {
const completeSpawnData = {
...spawnData,
name: pokemonName,
num: pokemonNum
};
if (spawnInformationToEdit) {
await updatePokemonSpawn({ ...spawnInformationToEdit, ...completeSpawnData }); // right overwrites left
} else {
await addPokemonSpawn(completeSpawnData);
}
setSpawnInformationToEdit(null);
setIsFormVisible(false);
onUpdate();
};
return (
<div className="container mx-auto py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Spawn Timeline</h1>
{/* Button to add a new spawn */}
<button
onClick={handleAddNewSpawn}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
+ New
</button>
</div>
{/* Timeline List */}
{spawns.length >0 && (<ul
aria-label="Spawn history feed"
role="feed"
className="relative flex flex-col gap-12 py-12 pl-6 text-sm before:absolute before:top-0 before:left-6 before:h-full before:-translate-x-1/2 before:border before:border-dashed before:border-slate-200 after:absolute after:top-6 after:left-6 after:bottom-6 after:-translate-x-1/2 after:border after:border-slate-200"
>
{spawns.map((spawn) => (
<li
key={spawn.spawnID}
role="article"
className="relative pl-6 before:absolute before:left-0 before:top-2 before:z-10 before:h-2 before:w-2 before:-translate-x-1/2 before:rounded-full before:bg-emerald-500 before:ring-2 before:ring-white"
>
<div className="flex flex-col flex-1 gap-2">
<h4 className="text-base font-medium leading-7 text-emerald-500">
{formatDate(spawn.encounter_ms)}
</h4>
<p className="text-slate-500">
Showed up at latitude {spawn.lat}, longitude {spawn.lng}.
</p>
{/* Edit and Delete buttons */}
<div className="flex space-x-4">
<button
onClick={handleEditSpawn(spawn)}
className="text-indigo-600 hover:text-indigo-900">
Edit
</button>
<button
onClick={() => handleDelete(spawn.spawnID)}
className="text-red-600 hover:text-red-900">
Delete
</button>
</div>
</div>
</li>
))}
</ul>)}
{
spawns.length === 0 && (
<p className="text-lg text-slate-500">No spawn data available for this Pokémon.</p>
)
}
{
isFormVistible && (
<SpawnForm onClose={() => setIsFormVisible(false)}
onSubmit={handleFormSubmit}
defaultSpawnData={spawnInformationToEdit || undefined} />
)
}
</div>
);
}
export default PokemonSpawnTimeline;
pokemonPage.tsx
import React, {useState, useEffect} from 'react';
import { Link, useParams } from 'react-router-dom';
import { getPokemonByID, Pokemon, PokemonSpawn, searchPokemonSpawnData } from '../services/services';
import PokemonDataCard from '../components/pokemonDataCard/pokemonDataCard';
import PokemonSpawnTimeline from '../components/pokemonSpawnTimeline/pokemonSpawnTimeline';
const PokemonPage: React.FC = () => {
const { pokemonID } = useParams();
const [pokemon, setPokemon] = useState<Pokemon | undefined>(undefined);
const [pokemonSpawnData, setPokemonSpawnData] = useState<PokemonSpawn[]>([]);
const [fetchNewSpawnData, setFetchNewSpawnData] = useState<boolean>(false);
useEffect(() => {
const fetchPokemon = async () => {
// Fetch the Pokémon data based on the ID
const data = await getPokemonByID(Number(pokemonID));
setPokemon(data);
};
fetchPokemon();
}, [pokemonID]);
useEffect(() => {
const fetchSpawnData = async () => {
// Fetch the Pokémon spawn data based on the ID
const data = await searchPokemonSpawnData(Number(pokemonID));
setPokemonSpawnData(data);
};
fetchSpawnData();
}, [pokemonID, fetchNewSpawnData]);
const handleDeleteSpawn = async (spawnID: number) => {
setFetchNewSpawnData((prevState) => !prevState);
};
const handleUpdateSpawn = async () => {
setFetchNewSpawnData((prevState) => !prevState);
}
if (!pokemon) { return <></>; }
return (
<>
<div className="overflow-hidden bg-white py-12 sm:py-16">
<div className="mx-auto max-w-7xl px-5 lg:px-7">
<div className="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
<div className="lg:pr-8 lg:pt-4">
<Link to="/" className="text-grey-600 hover:underline">← Back to Pokédex</Link>
<h1 className="mt-2 mb-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
Pokédex: #{pokemonID} {pokemon?.pokemonName}
</h1>
<h2 className="text-base font-semibold leading-7 text-indigo-600">
Gotta Catch 'Em All
</h2>
</div>
</div>
</div>
</div>
{pokemon && (
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<PokemonDataCard pokemon={pokemon} />
</div>
)}
<div className="mx-auto max-w-7xl px-4 sm:px-12 lg:px-8">
<PokemonSpawnTimeline spawns={pokemonSpawnData}
onDelete={handleDeleteSpawn}
onUpdate={handleUpdateSpawn}
pokemonName={pokemon!.pokemonName}
pokemonNum={pokemon!.pokemonID}/>
</div>
</>
);
}
export default PokemonPage;
Now we have everything setup.
2.5 Some fancy stuffs (might skip in class) #
We notice that one of the issue with the input form is that it takes in any values. Also, human does not read in “ms” times. So here we add fancy libraries to our app. This is an example of what are consider as ‘small’ but not sophisticated creative feature.
- Time input Typing in ms and understanding it is super confusing, so let’s manage this. Let’s update the form component using existing libraries:
npm install react-datepicker
npm install --save date-fns
Then we replace the way we handle time with Date Objects:
import DatePicker from 'react-datepicker';
import "react-datepicker/dist/react-datepicker.css";
...
// const [encounterTime, setEncounterTime] = React.useState<number>(defaultSpawnData?.encounter_ms || 0);
// const [disappearTime, setDisappearTime] = React.useState<number>(defaultSpawnData?.disappear_ms || 0);
const [encounterDate, setEncounterDate] = React.useState<Date | null>(
defaultSpawnData ? new Date(defaultSpawnData.encounter_ms) : new Date()
);
const [disappearDate, setDisappearDate] = React.useState<Date | null>(
defaultSpawnData ? new Date(defaultSpawnData.disappear_ms) : new Date()
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!encounterDate || !disappearDate) {
alert('Please select valid dates.');
return;
}
onSubmit({
lat, lng, encounter_ms: encounterDate.getTime(), disappear_ms: disappearDate.getTime()
});
onClose();
};
finally in the form:
{/* Encounter Date Field */}
<div className="mt-4">
<label htmlFor="encounterDate" className="block text-sm font-medium text-gray-900">
Encounter Date and Time
</label>
<DatePicker
selected={encounterDate}
onChange={(date: Date | null) => setEncounterDate(date)}
showTimeSelect
dateFormat="Pp"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</div>
{/* Disappear Date Field */}
<div className="mt-4">
<label htmlFor="disappearDate" className="block text-sm font-medium text-gray-900">
Disappear Date and Time
</label>
<DatePicker//
selected={disappearDate}
onChange={(date: Date | null) => setDisappearDate(date)}
showTimeSelect
dateFormat="Pp"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</div>
Now we can select the date time using the picker.
- Manually putting in the long/lat is also a pain, next let’s write some functions that takes care of the long, lat.
We need axios here, which we will introduce later.
npm install axios
Next, we write ageoTransform.ts
undersrc/utils
import axios from 'axios';
// read the API key from an environment variable
const GOOGLE_MAPS_API_KEY = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
/**
* Converts an address into latitude and longitude using the Google Geocoding API.
* @param address The address to geocode.
* @returns An object containing latitude and longitude.
*/
export const getLatLngFromAddress = async (address: string): Promise<{ lat: number; lng: number }> => {
try {
const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json`, {
params: {
address,
key: GOOGLE_MAPS_API_KEY,
},
});
if (response.data.status === 'OK') {
const { lat, lng } = response.data.results[0].geometry.location;
return { lat, lng };
} else {
throw new Error(`Geocoding failed: ${response.data.status}`);
}
} catch (error) {
console.error('Error fetching latitude and longitude:', error);
throw error;
}
};
/**
* Converts latitude and longitude into a human-readable address using the Google Geocoding API.
* @param lat The latitude.
* @param lng The longitude.
* @returns The address as a string.
*/
export const getAddressFromLatLng = async (lat: number, lng: number): Promise<string> => {
try {
const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json`, {
params: {
latlng: `${lat},${lng}`,
key: GOOGLE_MAPS_API_KEY,
},
});
if (response.data.status === 'OK') {
const address = response.data.results[0].formatted_address;
return address;
} else {
throw new Error(`Reverse Geocoding failed: ${response.data.status}`);
}
} catch (error) {
console.error('Error fetching address:', error);
throw error;
}
};
Finally, we update the form:
import { getLatLngFromAddress } from "../../utils/geoTransform";
...
const SpawnForm: React.FC<spawnFormProps> = ({ onClose,
onSubmit,
defaultSpawnData,
}) => {
const [address, setAddress] = React.useState<string>("");
const [isResolving, setIsResolving] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | null>(null);
...
const handleAddressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAddress(e.target.value);
setError(null);
};
const handleAddressBlur = async () => {
if (address) {
setIsResolving(true);
try {
const { lat, lng } = await getLatLngFromAddress(address);
setLat(lat);
setLng(lng);
} catch (error) {
setError("Failed to fetch coordinates from address. Please check the address.");
setLat(-1);
setLng(-1);
} finally {
setIsResolving(false);
}
}
};
...
<form onSubmit={handleSubmit}>
{/* Address Field */}
<div>
<label
htmlFor="address"
className="block text-sm font-medium text-gray-900"
>
Address
</label>
<input
id="address"
type="text"
value={address}
onChange={handleAddressChange}
onBlur={handleAddressBlur}
placeholder="Enter the address"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
{isResolving && <p className="text-sm text-gray-500 mt-1">Resolving address...</p>}
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
{/* Latitude and Longitude */}
<div className="mt-4">
<p className="text-sm text-gray-600">
Latitude: {lat}, Longitude: {lng}
</p>
</div>
...
What this does is that when the input field onBlurs
, the resolver is called to try fetch the coordinates.
Let’s try that now!
Finally, we update the timeline, we create a list to hold the addresses and a resolver:
const PokemonSpawnTimeline: React.FC<PokemonSpawnTimelineProps> = ({ spawns, onDelete, onUpdate, pokemonName, pokemonNum }) => {
...
const [resolvedAddresses, setResolvedAddresses] = React.useState<{ [key: number]: string }>({}); // Holds resolved addresses
...
useEffect(() => {
const resolveAddresses = async () => {
const newResolvedAddresses: { [key: number]: string } = {};
for (const spawn of spawns) {
try {
const address = await getAddressFromLatLng(spawn.lat, spawn.lng);
newResolvedAddresses[spawn.spawnID] = address;
} catch {
newResolvedAddresses[spawn.spawnID] = `Lat: ${spawn.lat}, Lng: ${spawn.lng}`; // Fallback to coordinates
}
}
setResolvedAddresses(newResolvedAddresses);
};
resolveAddresses();
}, [spawns]);
now for the original component:
<p className="text-slate-500">
{resolvedAddresses[spawn.spawnID] || `Lat: ${spawn.lat}, Lng: ${spawn.lng}`} {/* Show address or fallback */}
</p>
Now we can see the addresses instead of the long/lat!
Section 3: Linking up the frontend and backend #
Then, because now we will be calling a different port, we need to add CORS to our server.
cd ../server && npm install cors && npm install --save-dev @types/cors
we update the index.ts
inside server
...
import cors from 'cors';
const app = express();
const PORT = process.env.PORT || 3007;
app.use(cors());
app.use(express.json());
Great, now we can head back to the frontend. Let’s first update the .env to specify our baseURL in the env file.
REACT_APP_API_BASE_URL=http://localhost:3007
We install and add axios if you haven’t: npm install axios
Next, we add the following wrapper inside services.ts
import axios from "axios";
...
const BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:3007';
export const httpClient = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
With this, we can replace the first service with the following:
export const searchPokemonData = (query: string): Promise<Pokemon[]> => {
return httpClient
.get(`/pokemon`, {
params: { search: query },
})
.then((response) => response.data);
};
Now, you will see that the page still works! We will finish the rest of the conversion and the final service.ts
then becomes
import axios from 'axios';
import { add } from 'date-fns';
export interface Pokemon {
pokemonID: number;
pokemonName: string;
type1: string;
type2?: string; // Optional
total: number;
hp: number;
attack: number;
defense: number;
spAtk: number;
spDef: number;
speed: number;
generation: number;
}
export interface PokemonSpawn {
spawnID: number;
num: number;
name: string;
lat: number;
lng: number;
encounter_ms: number;
disappear_ms: number;
}
const BASE_URL = process.env.REACT_APP_API_BASE_URL || "http://localhost:3007";
export const httpClient = axios.create({
baseURL: BASE_URL,
headers: {
"Content-Type": "application/json",
},
});
// all APIs related to Pokémon
export const searchPokemonData = (query: string): Promise<Pokemon[]> => {
return httpClient
.get(`/pokemon`, {
params: { search: query },
})
.then((response) => response.data);
};
export const getPokemonByID = (id: number): Promise<Pokemon | undefined> => {
return httpClient
.get(`/pokemon/${id}`)
.then((response) => response.data);
};
// all APIs related to Pokémon spawns
export const searchPokemonSpawnData = (
query: number
): Promise<PokemonSpawn[]> => {
return httpClient
.get(`/pokemon-spawns/pokemon/${query}`)
.then((response) => response.data);
};
export const addPokemonSpawn = (newSpawn: Omit<PokemonSpawn, 'spawnID'>): Promise<PokemonSpawn> => {
return httpClient
.post(`/pokemon-spawns`, newSpawn)
.then((response) => response.data);
};
export const updatePokemonSpawn = (updatedSpawn: PokemonSpawn): Promise<void> => {
return httpClient
.put(`/pokemon-spawns/${updatedSpawn.spawnID}`, updatedSpawn)
.then((response) => response.data);
};
export const deletePokemonSpawn = (spawnID: number): Promise<void> => {
return httpClient
.delete(`/pokemon-spawns/${spawnID}`)
.then((response) => response.data);
};
now we can remove the import from mockData and also the mockData.ts. And the App should be persistent with data as long as you do not restart your server!
Section 4: Connecting to the database #
Last, we will move back to the server and connect the backend to the database so it consumes real data. First we connect to the database.
npm install mysql2
npm install --save-dev @types/node
npm install dotenv
We create the .env
. I am going to connect directly with GCP but you can definitely use localhost.
DB_HOST="34.16.***.***"
DB_USER="database"
DB_PASSWORD="*********"
DB_NAME="pokemon"
First, we create a connector under src/services/connection.ts
import mysql from 'mysql2/promise';
import * as dotenv from 'dotenv';
dotenv.config();
if (!process.env.DB_HOST || !process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_NAME) {
console.error('Environment variables not set!');
process.exit(1);
}
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
console.log('Connected to MySQL database');
export default pool;
Now let’s update the first route with MySQL, database.ts
import pool from './connection';
import { RowDataPacket } from "mysql2";
...
export async function getAllPokemon(): Promise<Pokemon[]> {
const [rows] = await pool.query('SELECT * FROM pokemon.pokemon LIMIT 20;');
return rows as Pokemon[];
}
export async function getPokemonByPokemonName(pokemonName: string): Promise<Pokemon[]> {
const queryName = pokemonName.toLowerCase();
const sqlQuery = `SELECT * FROM pokemon.pokemon WHERE pokemonName LIKE '%${queryName}%';`;
const [rows] = await pool.query(sqlQuery);
return rows as Pokemon[];
}
Now we can test and see that everything is working!
Let’s finish updating the rest of the functions. The final database.ts
would look like:
import { RowDataPacket } from "mysql2";
import { Pokemon } from "../models/pokemon";
import { PokemonSpawn } from "../models/pokemonSpawn";
import pool from './connection';
export async function getAllPokemon(): Promise<Pokemon[]> {
const [rows] = await pool.query('SELECT * FROM pokemon.pokemon LIMIT 20;');
return rows as Pokemon[];
}
export async function getPokemonByPokemonName(pokemonName: string): Promise<Pokemon[]> {
const queryName = pokemonName.toLowerCase();
const sqlQuery = `SELECT * FROM pokemon.pokemon WHERE pokemonName LIKE '%${queryName}%';`;
const [rows] = await pool.query(sqlQuery);
return rows as Pokemon[];
}
export async function getPokemonByID(pokemonID: number): Promise<Pokemon | undefined> {
const sqlQuery = `SELECT * FROM pokemon WHERE pokemonID = ${pokemonID};`;
const [rows] = await pool.query<RowDataPacket[]>(sqlQuery);
return rows[0] as Pokemon;
}
export async function getAllPokemonSpawns(): Promise<PokemonSpawn[]> {
const [rows] = await pool.query('SELECT * FROM pokemon.pokemon_spawn LIMIT 10;');
return rows as PokemonSpawn[];
}
export async function getPokemonSpawnBySpawnID(spawnID: number): Promise<PokemonSpawn | undefined> {
const sqlQuery = `SELECT * FROM pokemon_spawn WHERE spawnID = ${spawnID};`;
const [rows] = await pool.query<RowDataPacket[]>(sqlQuery);
return rows[0] as PokemonSpawn;
}
export async function getPokemonSpawnByPokemonID(pokemonID: number): Promise<PokemonSpawn[]> {
const sqlQuery = `SELECT * FROM pokemon_spawn WHERE pokemonID = ${pokemonID} order by encounter_ms desc limit 10;`;
const [rows] = await pool.query(sqlQuery);
return rows as PokemonSpawn[];
}
export async function addPokemonSpawn(spawn: Omit<PokemonSpawn, 'spawnID'>): Promise<void> {
const sqlQuery = `INSERT INTO pokemon_spawn (pokemonID, pokemonName, lat, lng, encounter_ms, disappear_ms) VALUES (${spawn.num}, '${spawn.name}', ${spawn.lat}, ${spawn.lng}, ${spawn.encounter_ms}, ${spawn.disappear_ms});`;
await pool.query(sqlQuery);
}
export async function updatePokemonSpawn(spawn: PokemonSpawn): Promise<void> {
const sqlQuery = `UPDATE pokemon_spawn SET pokemonID = ${spawn.num}, pokemonName = '${spawn.name}', lat = ${spawn.lat}, lng = ${spawn.lng}, encounter_ms = ${spawn.encounter_ms}, disappear_ms = ${spawn.disappear_ms} WHERE spawnID = ${spawn.spawnID};`;
await pool.query(sqlQuery);
}
export async function deletePokemonSpawnbyID(spawnID: number): Promise<void> {
const sqlQuery = `DELETE FROM pokemon_spawn WHERE spawnID = ${spawnID};`;
await pool.query(sqlQuery);
}
Now everything should all work! so let’s remove the mockData.ts import!
Section 5: Serving it with one server #
Before we ship it, lets put them into one server. This time, we will add a .env.production
file under the client folder. We also add a base URL for React: REACT_APP_BASE_URL=/
for App.tsx:
import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import './App.css';
import PokedexPage from './pages/pokedexPage';
import PokemonPage from './pages/pokemonPage';
const App: React.FC = () => {
return (
<Router basename={process.env.REACT_APP_BASE_URL || "/"}>
<Routes>
<Route path="/" element={<PokedexPage />} />
<Route path="/searchPokemon/:pokemonID" element={<PokemonPage />} />
</Routes>
</Router>
)
}
export default App;
we then run: npm run build
Next, we move to server and install: npm install serve-static
and update the index.ts
in server once again:
import express, { Request, Response } from 'express';
import pokemonRoutes from './src/routes/pokemonRoutes';
import pokemonSpawnRoutes from './src/routes/pokemonSpawnRoutes';
import cors from 'cors';
import path from 'path';
const app = express();
const PORT = process.env.PORT || 3007;
app.use(express.static(path.join(__dirname, '../client/build')));
app.use(cors());
app.use(express.json());
app.get('/api/', (req:Request, res:Response) => {
res.send('Homepage of my Pokedex API.');
});
app.use("/api/pokemon", pokemonRoutes);
app.use("/api/pokemon-spawns", pokemonSpawnRoutes);
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../client/build', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Pokedex is running on http://localhost:${PORT}`);
});
Here the app now serves the bundled html generated by react build. The app can now be served.
Other resources and notes #
Full stack app development is not a linear process. This note was revised multiple time so that it seems like it is a well though linear process. That is not true. Due to the limited time during demo, I will skip through several details but these should be good pointers to get you to the resources. Prior to building, here were the content that I use to plan and also things that did not end up in the app.
Static Components #
- Pokemons are shown using a stacked list: Tailwind CSS Stacked Lists - Official Tailwind UI Components
- A search field is above using: Tailwind CSS Input Groups - Official Tailwind UI Components, Tailwind CSS Search Inputs - WindUI Component Library
- Each time, the grab can use pagination: Tailwind CSS Pagination - Official Tailwind UI Components
- Once clicked into it, use description lists to show the stats: Tailwind CSS Description Lists - Official Tailwind UI Components
- Within the stats: can also use pagination to “show more”: Tailwind CSS Feeds - WindUI Component Library
FORMs (shared across edit and Create) #
- Tailwind CSS Form Layouts - Official Tailwind UI Components
- When saved: Tailwind CSS Alerts - WindUI Component Library
- This gets the long,lat, form input allows address. Let’s use some random location: https://maps.googleapis.com/maps/api/geocode/json?address=1600+Amphitheatre+Parkway,+Mountain+View,+CA&key=AIzaSyAPPW-iZUis******************
Other things to consider #
- MySQL 8.4+ supports spatial queries: MySQL :: MySQL 8.4 Reference Manual :: 14.16.13 Spatial Convenience Functions
- Maybe we can gets the image based on pokemon name: https://stackoverflow.com/a/74828121