Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/* Keep desktop layout as established */
* {
text-align: center;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Standard syntax */
}

body {
overflow: hidden;
}

img {
pointer-events: none;
-webkit-user-drag: none;
user-drag: none;
}

.app-layout {
display: flex;
flex-direction: row;
align-items: stretch;
justify-self: center;
justify-content: center;
height: 100vh;
width: 90%;
box-sizing: border-box;
}

.app-layout > div {
border: 1px solid #ccc;
padding: 10px;
box-sizing: border-box;
display: flex;
min-height: 0;
}

.app-layout > #pokemonList {
order: 1;
flex: 1;
}

.app-layout > #pokemonCard {
order: 2;
flex: 2;
flex-direction: column;
}

.app-layout > #favoritePokemon {
order: 3;
flex: 1;
}

.toggle-yellow-btn {
margin-bottom: 8px;
align-self: flex-end;
cursor: pointer;
padding: 4px 8px;
}

input {
width: 25%;
}

.row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 10px;
}

.nav {
display: flex;
gap: 6px;
justify-content: center;
}

@media (max-width: 1000px) {
body {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */
overflow: scroll;
}

.app-layout {
flex-direction: column;
width: 100%;
height: auto;
align-items: stretch;
}

.app-layout > div {
flex: 0 0 auto !important;
width: 100% !important;
height: auto !important;
min-height: 0;
}

/* Explicit stack order: Card -> List -> Favorite */
#pokemonCard {
order: 1;
}

#pokemonList {
order: 2;
height: auto !important;
}

#favoritePokemon {
order: 3;
}

/* Disable inner scroll constraints and let blocks expand full vertical height */
.pokemon-list,
.favorite-list {
max-height: none !important;
overflow-y: visible !important;
flex: 0 0 auto !important;
}
}
107 changes: 107 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import App from "./App";
import * as pokemonApi from "./api/pokemon";
import { DUMMY_DATA } from "./utils/testData";
import { createWrapper } from "./utils/testWrapper";

vi.mock("./api/pokemon", () => ({
getPokemonByID: vi.fn(),
}));

// jsdom has no layout, so the scroll the list does after loading is a no-op
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});

beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
vi.clearAllMocks();

// Every id resolves to a pokemon so the list around the current one fills up
vi.spyOn(pokemonApi, "getPokemonByID").mockImplementation(async (id) => ({
...DUMMY_DATA[0],
id,
}));
});

function renderApp() {
render(<App />, { wrapper: createWrapper() });
}

describe("App", () => {
test("shows the first pokemon once it has loaded", async () => {
renderApp();

expect(screen.getByText("Loading...")).toBeInTheDocument();

await waitFor(() => {
expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(1);
});
});

test("moves to the next pokemon when the user asks for it", async () => {
renderApp();

await waitFor(() => {
expect(screen.getByRole("button", { name: /Next/ })).toBeInTheDocument();
});

fireEvent.click(screen.getByRole("button", { name: /Next/ }));

await waitFor(() => {
expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(2);
});
});

test("cannot go back past the first pokemon", async () => {
renderApp();

await waitFor(() => {
expect(screen.getByRole("button", { name: /Prev/ })).toBeDisabled();
});
});

test("keeps a favorite in local storage", async () => {
renderApp();

await waitFor(() => {
expect(
screen.getByRole("button", { name: "Add to favorites" }),
).toBeInTheDocument();
});

fireEvent.click(screen.getByRole("button", { name: "Add to favorites" }));

expect(window.localStorage.getItem("favorites")).toBe("[1]");
});

test("keeps a filter choice in session storage", async () => {
renderApp();

await waitFor(() => {
expect(screen.getByLabelText("Highest id first")).toBeInTheDocument();
});

fireEvent.click(screen.getByLabelText("Highest id first"));

await waitFor(() => {
expect(window.sessionStorage.getItem("filters")).toContain("id-desc");
});
});

test("restores the rules that were stored before", async () => {
window.sessionStorage.setItem(
"filters",
JSON.stringify({ type: ["electric"], onlyFavorites: true }),
);

renderApp();

await waitFor(() => {
expect(screen.getByLabelText("electric")).toBeChecked();
});
expect(screen.getByLabelText("Only show favorites")).toBeChecked();
});
});
108 changes: 70 additions & 38 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,101 @@
import { useState } from "react";
import "./App.css";
import { useQuery } from "@tanstack/react-query";
import { getPokemonByID } from "./api/pokemon";
import { PokemonCard } from "./components/PokemonCard/PokemonCard";
import { Favorite } from "./components/Favorite/Favorite";
import { useFavorites } from "./controllers/useFavorites";
import { useFilters } from "./controllers/useFilters";
import { PokemonList } from "./components/PokemonList/PokemonList";
import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon";
import { Filter } from "./components/Filter/Filter";

export default function App() {
// Start at a default ID
const [pokemonId, setPokemonId] = useState(1);
const { isFavorite, toggleFavorite } = useFavorites();
const [currentId, setCurrentId] = useState(1);
const { favoritePokemons, isFavorite, toggleFavorite, isLoadingFavorites } =
useFavorites();
const { filters, setFilter, resetFilters } = useFilters();

const query = useQuery({
queryKey: ["pokemon", pokemonId],
queryFn: () => getPokemonByID(pokemonId),
queryKey: ["pokemon", currentId],
queryFn: () => getPokemonByID(currentId),
});

const handlePrevious = () => {
setPokemonId((prev) => Math.max(1, prev - 1));
setCurrentId((prev) => Math.max(1, prev - 1));
};

const handleNext = () => {
setPokemonId((prev) => prev + 1);
setCurrentId((prev) => prev + 1);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
// Ensure the user cannot enter negative numbers or invalid text
if (!isNaN(value) && value > 0) {
setPokemonId(value);
setCurrentId(value);
}
};

return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<button onClick={handlePrevious} disabled={pokemonId <= 1}>
&larr; Prev
</button>
<div className="app-layout">
<div id="pokemonCard">
<div>
<div className="row">
<div id="sort">
<Filter
filters={filters}
onChange={setFilter}
onReset={resetFilters}
/>
</div>
<div className="nav">
<button onClick={handlePrevious} disabled={currentId <= 1}>
&larr; Prev
</button>

<input
type="number"
value={pokemonId}
onChange={handleInputChange}
min="1"
style={{ width: "70px", textAlign: "center", padding: "4px" }}
aria-label="Pokemon ID"
/>
<input
type="number"
value={currentId}
onChange={handleInputChange}
min="1"
aria-label="Pokemon ID"
/>

<button onClick={handleNext}>Next &rarr;</button>
</div>
<button onClick={handleNext}>Next &rarr;</button>
</div>
<div id="favorite">
<Favorite
isFavorite={isFavorite(currentId)}
onToggle={() => toggleFavorite(currentId)}
/>
</div>
</div>
</div>

{query.isPending && <h1>Loading...</h1>}
{query.isError && <h1>Failed to load Pokemon</h1>}
{query.data && <PokemonCard pokemon={query.data} />}
<Favorite
isFavorite={isFavorite(pokemonId)}
onToggle={() => toggleFavorite(pokemonId)}
/>
{query.isPending && <p role="status">Loading...</p>}
{query.isError && <p role="alert">Failed to load Pokemon</p>}
{query.data && <PokemonCard current={query.data} />}
</div>
<div id="pokemonList">
{query.data && (
<PokemonList
current={query.data}
sizeOfList={11}
filters={filters}
favoritePokemons={favoritePokemons}
onSelectPokemon={setCurrentId}
/>
)}
</div>
<div id="favoritePokemon">
{!isLoadingFavorites && (
<FavoritePokemon
pokemonList={favoritePokemons}
currentId={currentId}
onSelectPokemon={setCurrentId}
/>
)}
</div>
</div>
);
}
Loading