web: load the news list on /aktuelt a page at a time
This commit is contained in:
6 files changed
+194
-16
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryMock = vi.fn();
|
||||
// mirrors cmsQuery: returns the data, throws on a GraphQL error
|
||||
vi.mock("@/app/client", () => ({
|
||||
cmsQuery: async (...args: unknown[]) => {
|
||||
const { data, error } = await queryMock(...args);
|
||||
if (error) throw new Error(error.message);
|
||||
return data;
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "./route.ts";
|
||||
|
||||
const request = (query = "") =>
|
||||
new NextRequest(`http://localhost/api/v1/news${query}`);
|
||||
|
||||
const articles = (count: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({ id: String(i), slug: `nyhet-${i}` }));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/v1/news", () => {
|
||||
it("returns a page of articles and the total", async () => {
|
||||
queryMock.mockResolvedValue({ data: { index: {}, news: articles(40) } });
|
||||
|
||||
const res = await GET(request("?offset=17&limit=16"));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.total).toBe(40);
|
||||
expect(body.news.map((n: { id: string }) => n.id)).toEqual(
|
||||
Array.from({ length: 16 }, (_, i) => String(17 + i))
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to the first page", async () => {
|
||||
queryMock.mockResolvedValue({ data: { index: {}, news: articles(40) } });
|
||||
|
||||
const body = await (await GET(request())).json();
|
||||
|
||||
expect(body.news).toHaveLength(16);
|
||||
expect(body.news[0].id).toBe("0");
|
||||
});
|
||||
|
||||
it("returns what is left at the end, and nothing past it", async () => {
|
||||
queryMock.mockResolvedValue({ data: { index: {}, news: articles(20) } });
|
||||
|
||||
expect((await (await GET(request("?offset=17"))).json()).news).toHaveLength(3);
|
||||
expect((await (await GET(request("?offset=500"))).json()).news).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["?offset=-1", "?offset=abc", "?limit=0", "?limit=49", "?limit=1.5"])(
|
||||
"rejects %s",
|
||||
async (query) => {
|
||||
const res = await GET(request(query));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(queryMock).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { loadAllNews } from "@/components/news/NewsIndexView";
|
||||
import { NEWS_PAGE_SIZE, NewsListItemFragment } from "@/lib/news";
|
||||
|
||||
const MAX_LIMIT = 48;
|
||||
|
||||
export type NewsPageResponse = {
|
||||
news: NewsListItemFragment[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
function nonNegativeInt(value: string | null, fallback: number): number | null {
|
||||
if (value === null) return fallback;
|
||||
return /^\d+$/.test(value) ? Number(value) : null;
|
||||
}
|
||||
|
||||
// GET /api/v1/news?offset=17&limit=16: the next articles for "load more" on
|
||||
// /aktuelt, newest first. Reads the same cached list as the page
|
||||
export async function GET(req: NextRequest) {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const offset = nonNegativeInt(searchParams.get("offset"), 0);
|
||||
const limit = nonNegativeInt(searchParams.get("limit"), NEWS_PAGE_SIZE);
|
||||
|
||||
if (offset === null || limit === null || limit < 1 || limit > MAX_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: `offset must be >= 0 and limit between 1 and ${MAX_LIMIT}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { news } = await loadAllNews();
|
||||
return NextResponse.json({
|
||||
news: news.slice(offset, offset + limit),
|
||||
total: news.length,
|
||||
} satisfies NewsPageResponse);
|
||||
}
|
||||
@@ -1,28 +1,48 @@
|
||||
import { cmsQuery } from "@/app/client";
|
||||
import { PageHeader } from "@/components/general/PageHeader";
|
||||
import { NewsList } from "@/components/news/NewsList";
|
||||
import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news";
|
||||
import {
|
||||
NEWS_FIRST_PAGE,
|
||||
NewsIndexFragment,
|
||||
NewsListItemFragment,
|
||||
newsQuery,
|
||||
} from "@/lib/news";
|
||||
|
||||
export type NewsIndexViewProps = {
|
||||
index: NewsIndexFragment;
|
||||
// the first page only; NewsList fetches the rest from /api/v1/news
|
||||
news: NewsListItemFragment[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
// All articles, newest first. One cached CMS query serves both the page and
|
||||
// the API that pages through the rest
|
||||
export async function loadAllNews() {
|
||||
const data = await cmsQuery(newsQuery, {});
|
||||
return {
|
||||
index: data?.index as NewsIndexFragment | undefined,
|
||||
news: (data?.news ?? []) as NewsListItemFragment[],
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadNewsIndexProps(overrides?: {
|
||||
indexOverride?: NewsIndexFragment;
|
||||
}): Promise<NewsIndexViewProps> {
|
||||
const data = await cmsQuery(newsQuery, {});
|
||||
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
|
||||
const all = await loadAllNews();
|
||||
const index = overrides?.indexOverride ?? all.index;
|
||||
if (!index) throw new Error("Failed to load /aktuelt");
|
||||
const news = (data?.news ?? []) as NewsListItemFragment[];
|
||||
return { index, news };
|
||||
return {
|
||||
index,
|
||||
news: all.news.slice(0, NEWS_FIRST_PAGE),
|
||||
total: all.news.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function NewsIndexView({ index, news }: NewsIndexViewProps) {
|
||||
export function NewsIndexView({ index, news, total }: NewsIndexViewProps) {
|
||||
return (
|
||||
<main className="site-main" id="main">
|
||||
<PageHeader heading={index.title} lead={index.lead} align="left" />
|
||||
<NewsList news={news} />
|
||||
<NewsList news={news} total={total} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,22 +3,51 @@ import { useState } from "react";
|
||||
import { SectionHeader } from "../general/SectionHeader";
|
||||
import { NewsItem } from "./NewsItem";
|
||||
import styles from "./newsList.module.scss";
|
||||
import { NewsFragment, NewsListItemFragment } from "@/lib/news";
|
||||
import { NEWS_PAGE_SIZE, NewsFragment, NewsListItemFragment } from "@/lib/news";
|
||||
import { SectionFooter } from "../general/SectionFooter";
|
||||
import type { NewsPageResponse } from "@/app/api/v1/news/route";
|
||||
|
||||
type NewsListItem = NewsFragment | NewsListItemFragment;
|
||||
|
||||
export const NewsList = ({
|
||||
news,
|
||||
total,
|
||||
heading,
|
||||
featured
|
||||
}: {
|
||||
news: (NewsFragment | NewsListItemFragment)[];
|
||||
news: NewsListItem[];
|
||||
// how many articles exist; more than `news` holds means the rest can be
|
||||
// fetched with "load more"
|
||||
total?: number;
|
||||
heading?: string;
|
||||
featured?: boolean;
|
||||
}) => {
|
||||
const [limit, setLimit] = useState(featured ? 4 : 17);
|
||||
function increaseLimit() {
|
||||
setLimit(limit + 16);
|
||||
const [items, setItems] = useState(news);
|
||||
const [knownTotal, setKnownTotal] = useState(total ?? news.length);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
async function loadMore() {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/news?offset=${items.length}&limit=${NEWS_PAGE_SIZE}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const page = (await res.json()) as NewsPageResponse;
|
||||
// an article published since the page was rendered shifts the offsets
|
||||
const seen = new Set(items.map((item) => item.id));
|
||||
setItems([...items, ...page.news.filter((item) => !seen.has(item.id))]);
|
||||
setKnownTotal(page.total);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shown = featured ? items.slice(0, 4) : items;
|
||||
|
||||
return (
|
||||
<section className={styles.newsWrapper}>
|
||||
@@ -26,14 +55,18 @@ export const NewsList = ({
|
||||
<SectionHeader heading={heading} link="/aktuelt" linkText="Se flere artikler" />
|
||||
)}
|
||||
<ul className={`${styles.newsList} ${featured && styles.featured}`}>
|
||||
{news.slice(0, limit).map((singleNews) => (
|
||||
{shown.map((singleNews) => (
|
||||
<NewsItem key={singleNews.id} news={singleNews} />
|
||||
))}
|
||||
</ul>
|
||||
{!featured && limit && news.length > limit && (
|
||||
{!featured && knownTotal > items.length && (
|
||||
<div className={styles.showMore}>
|
||||
<button onClick={increaseLimit}>
|
||||
<span>Last inn flere artikler</span>
|
||||
<button onClick={loadMore} disabled={loading} aria-busy={loading}>
|
||||
<span>
|
||||
{failed
|
||||
? "Noe gikk galt. Prøv igjen"
|
||||
: "Last inn flere artikler"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,12 @@ const NewsIndexFragmentDefinition = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
// How many articles /aktuelt shows at first, and how many each "load more"
|
||||
// adds. With around 850 articles, sending them all made the page (and every
|
||||
// prefetch of it from the header) several hundred kilobytes
|
||||
export const NEWS_FIRST_PAGE = 17;
|
||||
export const NEWS_PAGE_SIZE = 16;
|
||||
|
||||
export const newsIndexMetadataQuery = graphql(`
|
||||
query newsIndexMetadata {
|
||||
index: newsIndex {
|
||||
|
||||
@@ -167,6 +167,24 @@ describe("events api", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("news api", () => {
|
||||
test("pages through the articles for \"load more\" on /aktuelt", async () => {
|
||||
const res = await fetch(app("/api/v1/news?offset=0&limit=5"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
news: Array<{ slug: string }>;
|
||||
total: number;
|
||||
};
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.news.map((n) => n.slug)).toEqual([SLUGS.news]);
|
||||
});
|
||||
|
||||
test("rejects a limit that is too large", async () => {
|
||||
const res = await fetch(app("/api/v1/news?limit=1000"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("image optimizer", () => {
|
||||
test("optimizes a CMS-hosted image", async () => {
|
||||
const src = encodeURIComponent(mock("/media/pig.png"));
|
||||
|
||||
Reference in new issue
Block a user