diff --git a/react-app/src/hooks/useFetch.ts b/react-app/src/hooks/useFetch.ts new file mode 100644 index 00000000..4f32517b --- /dev/null +++ b/react-app/src/hooks/useFetch.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +export interface FetchState { + data: T | null; + error: Error | null; + loading: boolean; +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError'; +} + +export function useFetch( + fetcher: (signal: AbortSignal) => Promise, + deps: unknown[] +): FetchState { + const [state, setState] = useState>({ + data: null, + error: null, + loading: true, + }); + + // oxlint-disable-next-line react-hooks/exhaustive-deps -- deps are supplied by the caller + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + setState({ data: null, error: null, loading: true }); + + fetcher(controller.signal) + .then((data) => { + if (!cancelled) { + setState({ data, error: null, loading: false }); + } + }) + .catch((error: unknown) => { + if (cancelled || isAbortError(error)) { + return; + } + setState({ + data: null, + error: error instanceof Error ? error : new Error(String(error)), + loading: false, + }); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, deps); + + return state; +} diff --git a/react-app/src/services/hackernews-api.test.ts b/react-app/src/services/hackernews-api.test.ts new file mode 100644 index 00000000..c834d53c --- /dev/null +++ b/react-app/src/services/hackernews-api.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { fetchFeed, fetchItemContent } from './hackernews-api'; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + json: () => Promise.resolve(body), + } as Response; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('fetchItemContent', () => { + it('aggregates poll options and votes before resolving', async () => { + const story = { + id: 100, + type: 'poll', + poll: [ + { points: 0, content: '' }, + { points: 0, content: '' }, + ], + }; + const fetchMock = vi.fn((input: string) => { + if (input.endsWith('/item/100')) { + return Promise.resolve(jsonResponse(story)); + } + if (input.endsWith('/item/101')) { + return Promise.resolve(jsonResponse({ points: 5, content: 'first' })); + } + if (input.endsWith('/item/102')) { + return Promise.resolve(jsonResponse({ points: 7, content: 'second' })); + } + throw new Error(`unexpected url ${input}`); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchItemContent(100); + + expect(result.poll_votes_count).toBe(12); + expect(result.poll).toEqual([ + { points: 5, content: 'first' }, + { points: 7, content: 'second' }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('leaves non-poll stories untouched', async () => { + const fetchMock = vi.fn(() => + Promise.resolve(jsonResponse({ id: 1, type: 'link', title: 'hello' })) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchItemContent(1); + + expect(result.poll_votes_count).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('fetchFeed', () => { + it('throws on a non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(jsonResponse(null, false, 500)))); + + await expect(fetchFeed('news', 1)).rejects.toThrow('500'); + }); + + it('passes the abort signal through', async () => { + const fetchMock = vi.fn(() => Promise.resolve(jsonResponse([]))); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + + await fetchFeed('news', 2, controller.signal); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://node-hnapi.herokuapp.com/news?page=2', + { signal: controller.signal } + ); + }); +}); diff --git a/react-app/src/services/hackernews-api.ts b/react-app/src/services/hackernews-api.ts new file mode 100644 index 00000000..ec2fcedc --- /dev/null +++ b/react-app/src/services/hackernews-api.ts @@ -0,0 +1,41 @@ +import type { PollResult } from '../models/poll-result'; +import type { Story } from '../models/story'; +import type { User } from '../models/user'; + +const BASE_URL = 'https://node-hnapi.herokuapp.com'; + +async function getJson(url: string, signal?: AbortSignal): Promise { + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`Request to ${url} failed with status ${response.status}`); + } + return (await response.json()) as T; +} + +export function fetchFeed(feedType: string, page: number, signal?: AbortSignal): Promise { + return getJson(`${BASE_URL}/${feedType}?page=${page}`, signal); +} + +export function fetchPollContent(id: number, signal?: AbortSignal): Promise { + return getJson(`${BASE_URL}/item/${id}`, signal); +} + +export function fetchUser(id: string, signal?: AbortSignal): Promise { + return getJson(`${BASE_URL}/user/${id}`, signal); +} + +export async function fetchItemContent(id: number, signal?: AbortSignal): Promise { + const story = await getJson(`${BASE_URL}/item/${id}`, signal); + if (story.type === 'poll' && story.poll) { + const poll = story.poll; + story.poll_votes_count = 0; + const results = await Promise.all( + poll.map((_, index) => fetchPollContent(story.id + index + 1, signal)) + ); + results.forEach((pollResults, index) => { + poll[index] = pollResults; + story.poll_votes_count = (story.poll_votes_count ?? 0) + pollResults.points; + }); + } + return story; +} diff --git a/react-app/src/utils/format-comment-count.test.ts b/react-app/src/utils/format-comment-count.test.ts new file mode 100644 index 00000000..a4896e92 --- /dev/null +++ b/react-app/src/utils/format-comment-count.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { formatCommentCount } from './format-comment-count'; + +describe('formatCommentCount', () => { + it('returns discuss for zero or negative counts', () => { + expect(formatCommentCount(0)).toBe('discuss'); + expect(formatCommentCount(-3)).toBe('discuss'); + }); + + it('uses the singular form for a single comment', () => { + expect(formatCommentCount(1)).toBe('1 comment'); + }); + + it('uses the plural form for several comments', () => { + expect(formatCommentCount(42)).toBe('42 comments'); + }); +}); diff --git a/react-app/src/utils/format-comment-count.ts b/react-app/src/utils/format-comment-count.ts new file mode 100644 index 00000000..e4aa49a1 --- /dev/null +++ b/react-app/src/utils/format-comment-count.ts @@ -0,0 +1,6 @@ +export function formatCommentCount(count: number): string { + if (count > 0) { + return `${count} ${count === 1 ? 'comment' : 'comments'}`; + } + return 'discuss'; +}