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
443 changes: 0 additions & 443 deletions country-explorer/package-lock.json

Large diffs are not rendered by default.

8 changes: 3 additions & 5 deletions country-explorer/src/api/countries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import type { CountriesApiResponse, Country, CountryApiResponse } from '../types

const BASE_URL = 'https://countries.dev';

// Thrown for a failed request: a non-2xx response (status holds the real
// HTTP status) or a network-level failure (status 0).
export class ApiError extends Error {
status: number;

Expand Down Expand Up @@ -49,15 +47,15 @@ async function fetchJson<T>(url: string): Promise<T> {
message = String((body as { error: unknown }).error);
}
} catch {
// Response body wasn't JSON; fall back to the status text above.
// ignore, use the status text above
}
throw new ApiError(response.status, message);
}

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

// GET /region/{region} — every country in a region, e.g. "europe".
// GET /region/{region}
export async function getCountriesByRegion(region: string): Promise<Country[]> {
const raw = await fetchJson<CountriesApiResponse>(
`${BASE_URL}/region/${encodeURIComponent(region)}`,
Expand All @@ -66,7 +64,7 @@ export async function getCountriesByRegion(region: string): Promise<Country[]> {
return raw.map(toCountry);
}

// GET /alpha/{code} — a single country by ISO alpha-2 or alpha-3 code.
// GET /alpha/{code}
export async function getCountryByAlpha(code: string): Promise<Country> {
const raw = await fetchJson<CountryApiResponse>(`${BASE_URL}/alpha/${encodeURIComponent(code)}`);

Expand Down
25 changes: 7 additions & 18 deletions country-explorer/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,16 @@ import ErrorMessage from './ErrorMessage';
import NavigationControls from './NavigationControls';
import FavoritesList from './FavoritesList';

// sessionStorage keys for the display choices this component persists.
const REGION_STORAGE_KEY = 'selectedRegion';
const SORT_STORAGE_KEY = 'sortOption';

function App() {
const { countries, isLoading, isError, error, refetch } = useCountries();
const { isFavorite, addFavorite, removeFavorite } = useFavorites();
// Lazily read any saved choice on first render, so the page opens already
// showing what the user had before reloading within the same session.
const [selectedRegion, setSelectedRegion] = useState(
() => readSessionValue(REGION_STORAGE_KEY) ?? '',
);
// The fetched countries are European, so filter by their geographic subregion.
// countries are all European, so "region" here means subregion
const getRegion = (country: (typeof countries)[number]) =>
country.subregion?.trim() || country.region?.trim() || 'Unknown region';
const regions = [...new Set(countries.map(getRegion))].sort();
Expand All @@ -38,15 +35,11 @@ function App() {
: countries;
const [sortOption, setSortOption] = useState<SortOption>(() => {
const stored = readSessionValue(SORT_STORAGE_KEY);
// A missing or no-longer-valid stored value falls back to the default.
return isSortOption(stored) ? stored : 'name-asc';
});
// A fresh, sorted copy — filteredCountries (and the cached query data it
// derives from) is left untouched.
const sortedCountries = sortCountries(filteredCountries, sortOption);
// Favorites are drawn from the full, unfiltered country list (not
// filteredCountries/sortedCountries) so a favorite from another region
// still shows up here even while a region filter is active.
// favorites are drawn from the full list, not the filtered one, so a
// favorite from another region still shows up here
const favoriteCountries = sortCountries(
countries.filter((country) => isFavorite(country.code)),
sortOption,
Expand All @@ -59,8 +52,6 @@ function App() {
(country) => country.code === activeCountryCode,
);

// Persist the display choices (never the fetched country/API data) so a
// reload within the same browser session restores them.
useEffect(() => {
writeSessionValue(REGION_STORAGE_KEY, selectedRegion);
}, [selectedRegion]);
Expand All @@ -86,17 +77,15 @@ function App() {
}
}

// Selecting a favorite clears the region filter first: the favorite may
// belong to a region other than the one currently filtered to, and
// selectedCountry/CountrySelector/NavigationControls are all derived from
// sortedCountries, so the code has to be present there to actually show up.
function selectFavorite(code: string) {
// clear the region filter first, since the favorite might be in a
// different region than the one currently filtered to
setSelectedRegion('');
setSelectedCountryCode(code);
}

// Keep the selection valid when data arrives or the available countries change.
// Store the fallback so a removed country is not reselected if it returns later.
// correct the selection during render if it's no longer valid (e.g. the
// country list just changed), instead of flashing the old selection first
if (selectedCountryCode !== activeCountryCode) {
setSelectedCountryCode(activeCountryCode);
}
Expand Down
10 changes: 0 additions & 10 deletions country-explorer/src/components/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
interface ErrorMessageProps {
/** User-friendly explanation of what went wrong (e.g. an ApiError message). */
message: string;
/** Called when the user asks to try the request again. */
onRetry: () => void;
}

/**
* Generic error state for a failed API request. Rendered as an assertive
* live region so assistive technology announces the failure immediately,
* and always offers a way to retry instead of leaving the user stuck.
*
* Intended usage: pass `error instanceof ApiError ? error.message : '...'`
* from `useCountries` as `message`, and its `refetch` as `onRetry`.
*/
function ErrorMessage({ message, onRetry }: ErrorMessageProps) {
return (
<div role="alert" aria-live="assertive">
Expand Down
6 changes: 0 additions & 6 deletions country-explorer/src/components/Loading.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
interface LoadingProps {
/** Accessible status text; defaults to a generic loading message. */
message?: string;
}

/**
* Generic loading indicator for any async data fetch (e.g. `useCountries`).
* Rendered as a polite live region so assistive technology announces the
* status without interrupting whatever the user is doing.
*/
function Loading({ message = 'Loading…' }: LoadingProps) {
return (
<p role="status" aria-live="polite">
Expand Down
59 changes: 59 additions & 0 deletions country-explorer/src/hooks/useCountries.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ApiError, getCountriesByRegion } from '../api/countries';
import { mockCountries } from '../test/fixtures';
import { useCountries } from './useCountries';

// Mock only getCountriesByRegion, keep the real ApiError export
vi.mock('../api/countries', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/countries')>();
return {
...actual,
getCountriesByRegion: vi.fn(),
};
});

const mockedGetCountriesByRegion = vi.mocked(getCountriesByRegion);

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}

describe('useCountries', () => {
it('reports a loading state before the request resolves', () => {
mockedGetCountriesByRegion.mockReturnValue(new Promise(() => {}));

const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() });

expect(result.current.isLoading).toBe(true);
expect(result.current.countries).toEqual([]);
});

it('returns the countries on a successful response', async () => {
mockedGetCountriesByRegion.mockResolvedValue(mockCountries);

const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.countries).toEqual(mockCountries);
expect(result.current.isError).toBe(false);
});

it('reports an error when the request fails', async () => {
mockedGetCountriesByRegion.mockRejectedValue(new ApiError(500, 'Server error'));

const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true));

expect(result.current.countries).toEqual([]);
expect(result.current.error).toBeInstanceOf(ApiError);
});
});
5 changes: 0 additions & 5 deletions country-explorer/src/hooks/useCountries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import { getCountriesByRegion } from '../api/countries';

const DEFAULT_REGION = 'europe';

// Centralizes the query key so it's built the same way everywhere it's
// needed (e.g. a future manual refetch/invalidate call), instead of a
// duplicated inline array.
export const countriesQueryKey = (region: string) => ['countries', region] as const;

export function useCountries(region: string = DEFAULT_REGION) {
Expand All @@ -19,8 +16,6 @@ export function useCountries(region: string = DEFAULT_REGION) {
isLoading,
isError,
error,
// Exposed so a caller can wire it up as the retry action for an
// ErrorMessage component when a request fails.
refetch,
};
}
20 changes: 0 additions & 20 deletions country-explorer/src/hooks/useFavorites.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { readLocalValue, writeLocalValue } from '../utils/localStorage';

// sessionStorage/localStorage keys in this app are all namespaced by the
// wrapper in utils/localStorage.ts; this is just the per-value suffix.
const FAVORITES_STORAGE_KEY = 'favoriteCountryCodes';

// A stored value is only trusted if it parses as a JSON array of non-empty
// strings. Anything else — nothing saved yet, corrupted JSON, or a value
// that isn't an array of strings — is treated as "no favorites saved" so a
// broken or tampered-with value never crashes the app.
function readStoredFavorites(): string[] {
const stored = readLocalValue(FAVORITES_STORAGE_KEY);
if (!stored) return [];
Expand All @@ -23,28 +17,14 @@ function readStoredFavorites(): string[] {
}
}

/**
* Tracks which countries the user has marked as favorites, identified by
* their stable country code (the alpha-3 code already used as `Country.code`
* everywhere else in the app).
*
* Only the country codes are persisted to localStorage — never the fetched
* country data itself — so favorites are retained after a reload and after
* the browser is closed and reopened.
*/
export function useFavorites() {
// Lazily read any saved favorites on first render, so the app opens
// already showing what the user had saved before.
const [favoriteCodes, setFavoriteCodes] = useState<string[]>(() => readStoredFavorites());

// Re-persist whenever the favorite list changes (add, remove, or the
// initial load being replaced by a correction).
useEffect(() => {
writeLocalValue(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteCodes));
}, [favoriteCodes]);

const addFavorite = useCallback((code: string) => {
// Guard against duplicates instead of relying on callers to check first.
setFavoriteCodes((current) => (current.includes(code) ? current : [...current, code]));
}, []);

Expand Down
28 changes: 28 additions & 0 deletions country-explorer/src/test/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Country } from '../types/country';

export const mockCountries: Country[] = [
{
code: 'NOR',
name: 'Norway',
capital: 'Oslo',
region: 'Europe',
subregion: 'Northern Europe',
population: 5379475,
area: 323802,
flagUrl: 'https://example.com/flags/no.svg',
currencies: ['Norwegian krone (NOK)'],
languages: ['Norwegian', 'Sámi'],
},
{
code: 'SWE',
name: 'Sweden',
capital: 'Stockholm',
region: 'Europe',
subregion: 'Northern Europe',
population: 10353442,
area: 450295,
flagUrl: 'https://example.com/flags/se.svg',
currencies: ['Swedish krona (SEK)'],
languages: ['Swedish'],
},
];
11 changes: 3 additions & 8 deletions country-explorer/src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
// Extends Vitest's `expect` with jest-dom's DOM matchers (toBeInTheDocument,
// toHaveTextContent, etc.) for every test file, via the `setupFiles` entry
// in vite.config.ts.
import '@testing-library/jest-dom/vitest';

import { afterEach } from 'vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';

// React Testing Library only auto-unmounts between tests when it detects a
// global `afterEach` (e.g. Vitest's `globals: true`). Globals are off here,
// so register cleanup explicitly — otherwise elements from an earlier test's
// render are still in the DOM when the next test queries it.
afterEach(() => {
// no globals: true, so RTL won't auto-cleanup between tests
cleanup();
vi.resetAllMocks();
});
1 change: 0 additions & 1 deletion country-explorer/src/types/country.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export interface CountryApiResponse {
// Successful response from /region/{region}.
export type CountriesApiResponse = CountryApiResponse[];

// Internal country model used by application components.
export interface Country {
code: string;
name: string;
Expand Down
10 changes: 1 addition & 9 deletions country-explorer/src/utils/localStorage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
/**
* Thin, defensive wrapper around localStorage for small, user-created state
* that should persist across browser sessions (e.g. favorites) — never the
* fetched country data itself. Keys are namespaced so they don't collide
* with anything else on the page, and every call is wrapped in a try/catch
* since storage can be unavailable or full (e.g. private browsing, quota
* exceeded) — a failed read/write should never crash the app.
*/
const NAMESPACE = 'country-explorer';

function storageKey(key: string): string {
Expand All @@ -24,6 +16,6 @@ export function writeLocalValue(key: string, value: string): void {
try {
window.localStorage.setItem(storageKey(key), value);
} catch {
// Storage unavailable or full — the choice just won't persist.
// storage unavailable or full
}
}
10 changes: 1 addition & 9 deletions country-explorer/src/utils/sessionStorage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
/**
* Thin, defensive wrapper around sessionStorage for small UI display
* choices (region filter, sort option) — never the fetched country data
* itself. Keys are namespaced so they don't collide with anything else on
* the page, and every call is wrapped in a try/catch since storage can be
* unavailable or full (e.g. private browsing, quota exceeded) — a failed
* read/write should never crash the app.
*/
const NAMESPACE = 'country-explorer';

function storageKey(key: string): string {
Expand All @@ -24,6 +16,6 @@ export function writeSessionValue(key: string, value: string): void {
try {
window.sessionStorage.setItem(storageKey(key), value);
} catch {
// Storage unavailable or full — the choice just won't persist.
// storage unavailable or full
}
}
10 changes: 1 addition & 9 deletions country-explorer/src/utils/sortCountries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,21 @@ import type { Country } from '../types/country';

export type SortOption = 'name-asc' | 'population-desc';

// Drives both the <select> in SortSelector and the switch below, so the
// two can never drift out of sync.
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'name-asc', label: 'Name (A–Z)' },
{ value: 'population-desc', label: 'Population (high to low)' },
];

// Used to validate a sort option loaded from sessionStorage — a stored
// value that isn't one of these is treated as missing/invalid.
export function isSortOption(value: string | null): value is SortOption {
return SORT_OPTIONS.some((option) => option.value === value);
}

/**
* Returns a new, sorted array. The input list (React Query's cached data)
* is never mutated — callers get a fresh copy to display.
*/
export function sortCountries(countries: Country[], sortOption: SortOption): Country[] {
const sorted = [...countries];

switch (sortOption) {
case 'population-desc':
// Countries with unknown population sort last either way.
// unknown population sorts last
sorted.sort((a, b) => (b.population ?? -Infinity) - (a.population ?? -Infinity));
break;
case 'name-asc':
Expand Down