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
19 changes: 17 additions & 2 deletions t29-project-1/server/catApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,23 @@ export async function fetchCatFromApi(breedIds) {
return data[0] || {};
}

export async function fetchBreedsFromApi() {
const DEFAULT_BREEDS_PAGE = 0;
const DEFAULT_BREEDS_LIMIT = 10;

function parseNonNegativeInt(value, fallback) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
}

export async function fetchBreedsFromApi({ page, limit } = {}) {
const breedsUrl = new URL(CAT_BREEDS_URL);
breedsUrl.searchParams.set('limit', '10');
breedsUrl.searchParams.set(
'limit',
String(parseNonNegativeInt(limit, DEFAULT_BREEDS_LIMIT))
);
breedsUrl.searchParams.set(
'page',
String(parseNonNegativeInt(page, DEFAULT_BREEDS_PAGE))
);
return fetchApiResponse(breedsUrl);
}
23 changes: 22 additions & 1 deletion t29-project-1/server/catApi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('fetchCatFromApi', () => {
});

describe('fetchBreedsFromApi', () => {
it('requests up to ten breeds', async () => {
it('defaults to the first page of ten breeds', async () => {
const breeds = [{ id: 'abys', name: 'Abyssinian' }];
fetchMock.mockResolvedValue(mockResponse(breeds));

Expand All @@ -79,5 +79,26 @@ describe('fetchBreedsFromApi', () => {
const requestUrl = new URL(String(fetchMock.mock.calls[0][0]));
expect(requestUrl.pathname).toBe('/v1/breeds');
expect(requestUrl.searchParams.get('limit')).toBe('10');
expect(requestUrl.searchParams.get('page')).toBe('0');
});

it('requests the given page and limit', async () => {
fetchMock.mockResolvedValue(mockResponse([]));

await fetchBreedsFromApi({ page: 2, limit: 5 });

const requestUrl = new URL(String(fetchMock.mock.calls[0][0]));
expect(requestUrl.searchParams.get('limit')).toBe('5');
expect(requestUrl.searchParams.get('page')).toBe('2');
});

it('falls back to the defaults for invalid page or limit', async () => {
fetchMock.mockResolvedValue(mockResponse([]));

await fetchBreedsFromApi({ page: 'not-a-number', limit: -1 });

const requestUrl = new URL(String(fetchMock.mock.calls[0][0]));
expect(requestUrl.searchParams.get('limit')).toBe('10');
expect(requestUrl.searchParams.get('page')).toBe('0');
});
});
7 changes: 6 additions & 1 deletion t29-project-1/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ app.use(cors());

app.get('/api/breeds', async (req, res) => {
try {
res.json(await fetchBreedsFromApi());
res.json(
await fetchBreedsFromApi({
page: req.query.page,
limit: req.query.limit,
})
);
} catch (err) {
console.error(err);
res.status(err.statusCode || 500).send(err.message);
Expand Down
13 changes: 12 additions & 1 deletion t29-project-1/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
import { useState } from 'react';
import { useCatNavigation } from './hooks/useCatNavigation';
import { useFavoriteCat } from './hooks/useFavoriteCat';
import { NavPrevious, NavNext } from './components/ActionButtons';
import { BreedDropdown } from './components/BreedDropdown';
import { CatCard } from './components/CatCard';
import { Link } from 'react-router-dom';
import './App.css';

export default function App() {
const [selectedBreedId, setSelectedBreedId] = useState<string | null>(null);

const {
currentCat,
isLoading,
error,
canGoPrevious,
handleNext,
handlePrevious,
} = useCatNavigation();
} = useCatNavigation(selectedBreedId);
const { isFavorite, toggleFavorite } = useFavoriteCat(currentCat);

return (
<main className="app-container">
<Link to="/favorite">Favorite</Link>

<BreedDropdown
selectedBreedId={selectedBreedId}
onSelectBreed={setSelectedBreedId}
/>

<nav className="nav-layout" aria-label="Main content navigation">
<NavPrevious
onClick={handlePrevious}
Expand Down
32 changes: 32 additions & 0 deletions t29-project-1/src/api/catApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export type CatFilters = {
breedId?: string;
};

export type Breed = {
id: string;
name: string;
};

export async function fetchCat({ breedId }: CatFilters = {}): Promise<CatData> {
const API_BASE =
(import.meta.env.VITE_API_BASE as string) || 'http://localhost:3001';
Expand All @@ -28,3 +33,30 @@ export async function fetchCat({ breedId }: CatFilters = {}): Promise<CatData> {

return response.json() as Promise<CatData>;
}

export type BreedsPage = {
page?: number;
limit?: number;
};

export async function fetchBreeds({ page, limit }: BreedsPage = {}): Promise<
Breed[]
> {
const API_BASE =
(import.meta.env.VITE_API_BASE as string) || 'http://localhost:3001';
const params = new URLSearchParams();

if (page !== undefined) params.set('page', String(page));
if (limit !== undefined) params.set('limit', String(limit));

const query = params.toString();
const response = await fetch(
`${API_BASE}/api/breeds${query ? `?${query}` : ''}`
);

if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}

return response.json() as Promise<Breed[]>;
}
107 changes: 107 additions & 0 deletions t29-project-1/src/components/BreedDropdown.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* Breed filter dropdown, sized relative to the CatCard's own max-width so
both scale together and collapse to full-width on small screens */
.breed-dropdown {
position: relative;
display: flex;
flex-direction: column;
gap: 0.4rem;
width: 100%;
max-width: calc(var(--card-max-width) * 0.75);
margin: 0 auto;
}

.breed-dropdown-label {
color: var(--text);
}

.breed-dropdown-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 10px;
background-color: #ffffff;
color: var(--text);
font: inherit;
font-weight: bold;
text-align: left;
cursor: pointer;
}

.breed-dropdown-trigger:hover {
border-color: #cbd5e1;
}

.breed-dropdown-trigger:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}

.breed-dropdown-trigger:disabled {
cursor: not-allowed;
opacity: 0.6;
}

.breed-dropdown-arrow {
width: 8px;
height: 8px;
margin-left: 0.5rem;
flex-shrink: 0;
border-right: 2px solid #7d5d75;
border-bottom: 2px solid #7d5d75;
transform: rotate(45deg);
}

/* Always opens downward, anchored to the trigger, regardless of which
option is selected */
.breed-dropdown-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
margin: 0.25rem 0 0;
padding: 0.25rem;
list-style: none;
max-height: 260px;
overflow-y: auto;
background-color: #ffffff;
border: 2px solid #e2e8f0;
border-radius: 10px;
box-shadow: var(--shadow);
}

.breed-dropdown-list:focus-visible {
outline: none;
}

.breed-dropdown-option {
padding: 0.4rem 0.6rem;
border-radius: 6px;
color: var(--text);
cursor: pointer;
}

.breed-dropdown-option.is-highlighted {
background-color: #f1f5f9;
}

.breed-dropdown-option.is-selected {
color: #7d5d75;
}

.breed-dropdown-option.is-load-more {
margin-top: 0.25rem;
border-top: 1px solid #e2e8f0;
border-radius: 0;
color: #7d5d75;
text-align: center;
}

.breed-dropdown-error {
margin: 0;
font-size: 0.85rem;
color: red;
}
Loading