Skip to content

Commit ad9ed9e

Browse files
feat: implement the state lifting & colocation pattern
1 parent 2130cb1 commit ad9ed9e

File tree

14 files changed

+932
-0
lines changed

14 files changed

+932
-0
lines changed

public/pokemon-battleground.webp

73 KB
Binary file not shown.

public/pokemon-logo.png

116 KB
Loading
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Skeleton } from '@shared/components/Skeleton/Skeleton.component';
2+
import {
3+
TPokemonTypesApiResponse,
4+
usePokedex
5+
} from '@shared/hooks/usePokedex';
6+
import classNames from 'classnames';
7+
import { FormEvent } from 'react';
8+
9+
// 🧑🏻‍💻 1.f: we need to pass a prop called onPokemonTypesUpdate which will take a string[] setup the interface and suppky the parameter to the Form component.
10+
11+
export const Form = () => {
12+
// 🧑🏻‍💻 1.c: Setup a useState<string[]> state colocation variable called selectedPokemonTypes, setSelectedPokemonTypes which will have a default of []
13+
14+
// ✍🏻 This is already done for you. Feel free to have a look how it works in shared/hooks/usePokedex
15+
const { data, isLoading } = usePokedex<TPokemonTypesApiResponse[]>({
16+
path: 'types',
17+
queryParams: 'pageSize=8'
18+
});
19+
20+
const handleSubmit = (event: FormEvent) => {
21+
event.preventDefault();
22+
23+
// 🧑🏻‍💻 1.g: now we want to validate whether the selectedPokemonTypes have 4 items in the array before we call onPokemonTypesUpdate(selectedPokemonTypes).
24+
25+
// Once completed, head over to Screen.tsx as the Form component will be complaining about a missing prop.
26+
};
27+
28+
// 💣 We can get rid of the eslint line once we start using the type param.
29+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
30+
const onPokemonTypeSelection = (type: string) => {
31+
// 🧑🏻‍💻 1.e: we need to check IF the selectedPokemonTypes already has the selectedType
32+
// because we need to toggle it on and off. If it is selected, we just setSelectedPokemonTypes with the filtered out type
33+
// if it's not in there then we set the type [...selectedPokemonTypes, type];
34+
};
35+
36+
return (
37+
<section className="text-center">
38+
<h2 className="text-3xl font-bold mb-4 block text-yellow-400">
39+
Select you favorite pokemon types (max 4)
40+
</h2>
41+
<form onSubmit={handleSubmit} noValidate>
42+
{isLoading && (
43+
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
44+
<Skeleton height="h-[48px]" />
45+
<Skeleton height="h-[48px]" />
46+
<Skeleton height="h-[48px]" />
47+
<Skeleton height="h-[48px]" />
48+
<Skeleton height="h-[48px]" />
49+
<Skeleton height="h-[48px]" />
50+
<Skeleton height="h-[48px]" />
51+
<Skeleton height="h-[48px]" />
52+
<Skeleton height="h-[48px]" />
53+
<Skeleton height="h-[48px]" />
54+
<Skeleton height="h-[48px]" />
55+
<Skeleton height="h-[48px]" />
56+
</div>
57+
)}
58+
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
59+
{data &&
60+
data.length &&
61+
data.map((pokemonType) => {
62+
const isSelected =
63+
// 🧑🏻‍💻 1.d: replace the empty array with the selectedPokemonTypes state variable.
64+
// 💣 We can remove these ignores when we apply the code
65+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
66+
// @ts-expect-error
67+
[].includes(pokemonType);
68+
const hasSelectedAllOptions = [].length === 4;
69+
70+
return (
71+
<label
72+
key={pokemonType}
73+
className={classNames(
74+
'bg-white p-3 font-bold rounded-md cursor-pointer relative',
75+
!hasSelectedAllOptions &&
76+
!isSelected &&
77+
'hover:bg-slate-200 focus-within:bg-slate-200',
78+
isSelected &&
79+
'bg-blue-600 text-white focus-within:bg-blue-700 hover:bg-blue-700',
80+
hasSelectedAllOptions &&
81+
!isSelected &&
82+
'opacity-80 cursor-not-allowed'
83+
)}
84+
>
85+
{pokemonType}
86+
<input
87+
type="checkbox"
88+
className="overflow-hidden h-0 w-0 absolute right-0 top-0 -z-10"
89+
id={pokemonType}
90+
name={pokemonType}
91+
value={pokemonType}
92+
onChange={() =>
93+
onPokemonTypeSelection(pokemonType)
94+
}
95+
disabled={hasSelectedAllOptions && !isSelected}
96+
/>
97+
</label>
98+
);
99+
})}
100+
</div>
101+
102+
<button
103+
type="submit"
104+
className="mt-6 rounded-sm py-3 px-12 text-white font-bold bg-blue-900 hover:bg-blue-950 focus-within:bg-blue-950"
105+
>
106+
Catch them all
107+
</button>
108+
</form>
109+
</section>
110+
);
111+
};
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Skeleton } from '@shared/components/Skeleton/Skeleton.component';
2+
import {
3+
TPokemonCardsApiResponse,
4+
usePokedex
5+
} from '@shared/hooks/usePokedex';
6+
import classNames from 'classnames';
7+
import { FormEvent } from 'react';
8+
9+
interface IPokemonOptions {
10+
type: string;
11+
// 🧑🏻‍💻 2.h: Add two new props called onPokemonSelection which takes a string[] and string as params and another
12+
// variable called defaultSelectedPokemon which is an optional string[].
13+
}
14+
15+
export const PokemonOptions = ({ type }: IPokemonOptions) => {
16+
// 🧑🏻‍💻 2.d: Create a selectedPokemon useState<string[]> variable. Default value to be []
17+
18+
// 🧑🏻‍💻 2.i: Replace the default of selectedPokemon from [] to the defaultSelectedPokemon. This will remember which cards were selected when the component re-renders.
19+
20+
// ✍🏻 This is already done for you. Feel free to have a look how it works in shared/hooks/usePokedex
21+
const { data, isLoading, isError } = usePokedex<
22+
TPokemonCardsApiResponse[]
23+
>({
24+
path: 'cards',
25+
queryParams: `pageSize=4&q=types:${type}&supertype:pokemon`,
26+
skip: type === undefined
27+
});
28+
29+
const handleSubmit = (event: FormEvent) => {
30+
event.preventDefault();
31+
32+
// 🧑🏻‍💻 2.j: call onPokemonSelection(selectedPokemon, type);
33+
};
34+
35+
// 💣 Can remove this comment once the code has been written
36+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
37+
const togglePokemonSelection = (pokemonId: string) => {
38+
// 🧑🏻‍💻 2.g: We need to now update the state for when a pokemon card is selected or not.
39+
// IF selectedPokemon includes the pokemonId then we need to de-select that pokemon card.
40+
// ELSE we add the pokemon id to the array of pokemon cards [...selectedPokemon, pokemonId] and then setSelectedPokemon(newValues) (make this a variable as we will need it for later.)
41+
// You should now start to see the pokemon being selected and de-selected. But the next thing we need to do is update the state within the screen. Search for 2.h
42+
// 🧑🏻‍💻 2.k: Inside the ELSE, check if the newlySelectedPokemon has the length of 2. IF it does, call onPokemonSelection(newlySelectedPokemon, type);. Head over to the Screen.tsx component to finish it off.
43+
};
44+
45+
return (
46+
<section className="mt-8">
47+
<h2 className="text-[32px] mb-4 font-bold block text-yellow-400 text-shadow-lg text-shadow-blue-600">
48+
{type} Pokemon
49+
</h2>
50+
<form onSubmit={handleSubmit} noValidate>
51+
<fieldset>
52+
{isLoading && (
53+
<div className="grid grid-cols-2 md:grid-cols-4 gap-1 max-w-[768px]">
54+
<Skeleton height="h-[207px]" width="w-full" />
55+
<Skeleton height="h-[207px]" width="w-full" />
56+
<Skeleton height="h-[207px]" width="w-full" />
57+
<Skeleton height="h-[207px]" width="w-full" />
58+
</div>
59+
)}
60+
<div
61+
className={classNames(
62+
'grid grid-cols-2 md:grid-cols-4 gap-1 max-w-[768px] transition-opacity',
63+
isLoading ? 'opacity-0' : 'opacity-100'
64+
)}
65+
>
66+
{data &&
67+
data.length > 0 &&
68+
data.map((pokemonCard) => {
69+
// 🧑🏻‍💻 2.e: Replace the empty arrays with the selectedPokemon variable we created.
70+
const isSelected = [].find(
71+
(pokemonId) => pokemonId === pokemonCard.id
72+
);
73+
const allPokemonSelected = [].length === 2;
74+
75+
return (
76+
<label
77+
key={pokemonCard.id}
78+
className={classNames(
79+
'border-solid border-[6px] focus-within:border-blue-600 rounded-lg relative',
80+
isSelected
81+
? 'border-yellow-600'
82+
: 'border-transparent',
83+
!isSelected && allPokemonSelected
84+
? 'cursor-default'
85+
: 'cursor-pointer'
86+
)}
87+
>
88+
<img
89+
src={pokemonCard.images.small}
90+
alt={pokemonCard.name}
91+
className={classNames(
92+
!isSelected && allPokemonSelected
93+
? 'opacity-70'
94+
: 'opacity-100'
95+
)}
96+
/>
97+
<input
98+
type="checkbox"
99+
value={pokemonCard.id}
100+
// 🧑🏻‍💻 2.f: Replace the empty array with the selectedPokemon variable we created.
101+
checked={[].includes(
102+
// 💣 We can get ride of this one we replace the empty array.
103+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
104+
// @ts-ignore
105+
pokemonCard.id
106+
)}
107+
onChange={() =>
108+
togglePokemonSelection(pokemonCard.id)
109+
}
110+
className="overflow-hidden h-0 w-0 absolute right-0 top-0 -z-10"
111+
disabled={!isSelected && allPokemonSelected}
112+
/>
113+
</label>
114+
);
115+
})}
116+
</div>
117+
118+
<button
119+
type="submit"
120+
className="hidden"
121+
disabled={isLoading || isError}
122+
>
123+
Select Pokemon
124+
</button>
125+
</fieldset>
126+
</form>
127+
</section>
128+
);
129+
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Exercise: 🧼 State Colocation vs 🪜 State Lifting
3+
*
4+
* 🤔 Observations of this file
5+
* So the lead developer wanted us to manage the state for the selected pokemon types & the selected pokemon at this level so those variables can be used here and within the children. Each variable will be an array of strings which represent the type or the id of the selected pokemon.
6+
*
7+
* We need to tackle this in stages...
8+
*
9+
* Stage one (follow 1.* steps) - Creating the form that returns the pokemon types and saving those selected types to the lifted state variable
10+
* Stage two (following 2.* steps) - Using those types, we will render the pokemon options component
11+
*
12+
*/
13+
14+
export const Screen = () => {
15+
// 🧑🏻‍💻 1.a: Create a useState<string[]> variable called selectedPokemonTypes, setSelectedPokemonTypes. Default to be an empty array.
16+
17+
// 🧑🏻‍💻 2.a: Create a useState<Record<string, string[]>> variable called selectedPokemon, setSelectedPokemon. Default to be an object {}.
18+
19+
// 🧑🏻‍💻 1.h: Create a function called onPokemonTypesUpdate which will take a types: string[] param. Pass that into the Form component as a prop. The function will just need setSelectedPokemonTypes for now.
20+
// 🎉 STAGE ONE COMPLETED you should now be able to see the types display, select them and then the state in the screen gets updated.
21+
22+
// 🧑🏻‍💻 2.m: You will now start to see the happy path all working fine, however when you change to different pokemon types and receive a new set of pokemon you now get some messy state where selectedPokemon is more than 8. To fix this, write the following code inside 1.h function (before the setSelectedPokemonTypes)
23+
/*
24+
const newlyUpdatedPokemon = { ...selectedPokemon };
25+
26+
selectedPokemonTypes
27+
.filter((type) => {
28+
return !types.find((selectedType) => selectedType === type);
29+
})
30+
.forEach((type) => {
31+
delete newlyUpdatedPokemon[type];
32+
});
33+
34+
setSelectedPokemon(newlyUpdatedPokemon);
35+
*/
36+
// STAGE TWO completed. You have now built the screen. BUT 🐞 there is a bug where the PokemonOptions re-renders the types that did not need to update when you change your types after one try. The reason is the "key" prop using index. The api has no identifier per type. If you enjoyed this exercise have a look into fixing it and make a pr.
37+
38+
// 🧑🏻‍💻 2.l: Create a function called onPokemonSelection which will take a pokemon: string[], type: string
39+
// Then create a newlySelectedPokemon variable which will be a copy of the current selectedPokemon {...selectedPokemon}
40+
// assign the newlySelectedPokemon[type] to equal the pokemon variable.
41+
// setSelectedPokemon(newlySelectedPokemon);
42+
43+
return (
44+
<main className="h-screen p-6">
45+
<img
46+
src="/pokemon-battleground.webp"
47+
alt="hello"
48+
className="fixed will-change-scroll left-0 right-0 top-0 bottom-0 z-0 h-full w-full object-cover"
49+
/>
50+
<div className="fixed will-change-scroll left-0 right-0 top-0 bottom-0 z-10 h-full w-full bg-black opacity-25" />
51+
<div className="relative z-20 max-w-[768px] mx-auto text-center">
52+
<div>
53+
<h1 className="text-center">
54+
<img
55+
src="/pokemon-logo.png"
56+
alt="Pokemon Battle Picker"
57+
className="inline-block w-96"
58+
/>
59+
<span className="text-[76px] font-bold mb-4 block text-yellow-400 text-shadow-lg text-shadow-blue-600">
60+
Battle Picker
61+
</span>
62+
</h1>
63+
</div>
64+
<div>
65+
{/* 🧑🏻‍💻 1.b: Render pokemon types form from ./components/Form and then head over to the form component */}
66+
</div>
67+
<div>
68+
{/* 🧑🏻‍💻 2.b: Loop through the selectedPokemonTypes and pass down the type property to the PokemonOptions (./components/PokemonOptions) component. You will also need a key to be on the component. I used `${pokemonType}-${index}` */}
69+
</div>
70+
71+
{/* 🧑🏻‍💻 2.c: We need to check if the KEYS in the selectedPokemon object equal 4 and the selectedPokemonTypes length is 4 before rendering the code snippet below. Head over to PokemonOptions when completed. */}
72+
{/*
73+
<button
74+
type="button"
75+
className="my-12 rounded-lg py-6 px-16 text-white text-2xl font-bold bg-blue-900 hover:bg-blue-950 focus-within:bg-blue-950"
76+
onClick={() => alert('Ready for battle!')}
77+
>
78+
Begin Battle
79+
</button>
80+
*/}
81+
</div>
82+
</main>
83+
);
84+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { Exercise } from './exercise';
4+
5+
const meta: Meta<typeof Exercise> = {
6+
title:
7+
'Lessons/🥉 Bronze/🧼 State Colocation vs 🪜 State Lifting/02-Exercise',
8+
component: Exercise,
9+
parameters: {
10+
layout: 'fullscreen'
11+
}
12+
};
13+
14+
export default meta;
15+
type Story = StoryObj<typeof Exercise>;
16+
17+
/*
18+
* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
19+
* to learn more about using the canvasElement to query the DOM
20+
*/
21+
export const Default: Story = {
22+
play: async () => {},
23+
args: {}
24+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Screen } from './components/Screen';
2+
3+
// Head over to screen to get started.
4+
export const Exercise = () => <Screen />;

0 commit comments

Comments
 (0)