web: load the news list on /aktuelt a page at a time

This commit is contained in:
ponas committed 2026-10-14 01:36:26 +02:00
1 parent 67c41e926e
commit c657afa253
6 files changed
+194 -16

No files matched your search

+65
View File
@@ -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();
}
);
});
+36
View File
@@ -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);
}
+27 -7
View File
@@ -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>
);
}
+42 -9
View File
@@ -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>
)}
+6
View File
@@ -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 {
+18
View File
@@ -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"));