Skip to content
Open
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
53 changes: 53 additions & 0 deletions react-app/src/hooks/useFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';

export interface FetchState<T> {
data: T | null;
error: Error | null;
loading: boolean;
}

function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}

export function useFetch<T>(
fetcher: (signal: AbortSignal) => Promise<T>,
deps: unknown[]
): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
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;
}
83 changes: 83 additions & 0 deletions react-app/src/services/hackernews-api.test.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
});
});
41 changes: 41 additions & 0 deletions react-app/src/services/hackernews-api.ts
Original file line number Diff line number Diff line change
@@ -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<T>(url: string, signal?: AbortSignal): Promise<T> {
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<Story[]> {
return getJson<Story[]>(`${BASE_URL}/${feedType}?page=${page}`, signal);
}

export function fetchPollContent(id: number, signal?: AbortSignal): Promise<PollResult> {
return getJson<PollResult>(`${BASE_URL}/item/${id}`, signal);
}

export function fetchUser(id: string, signal?: AbortSignal): Promise<User> {
return getJson<User>(`${BASE_URL}/user/${id}`, signal);
}

export async function fetchItemContent(id: number, signal?: AbortSignal): Promise<Story> {
const story = await getJson<Story>(`${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;
}
18 changes: 18 additions & 0 deletions react-app/src/utils/format-comment-count.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
6 changes: 6 additions & 0 deletions react-app/src/utils/format-comment-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function formatCommentCount(count: number): string {
if (count > 0) {
return `${count} ${count === 1 ? 'comment' : 'comments'}`;
}
return 'discuss';
}