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 { cmsQuery } from "@/app/client";
|
||||||
import { PageHeader } from "@/components/general/PageHeader";
|
import { PageHeader } from "@/components/general/PageHeader";
|
||||||
import { NewsList } from "@/components/news/NewsList";
|
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 = {
|
export type NewsIndexViewProps = {
|
||||||
index: NewsIndexFragment;
|
index: NewsIndexFragment;
|
||||||
|
// the first page only; NewsList fetches the rest from /api/v1/news
|
||||||
news: NewsListItemFragment[];
|
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?: {
|
export async function loadNewsIndexProps(overrides?: {
|
||||||
indexOverride?: NewsIndexFragment;
|
indexOverride?: NewsIndexFragment;
|
||||||
}): Promise<NewsIndexViewProps> {
|
}): Promise<NewsIndexViewProps> {
|
||||||
const data = await cmsQuery(newsQuery, {});
|
const all = await loadAllNews();
|
||||||
const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined);
|
const index = overrides?.indexOverride ?? all.index;
|
||||||
if (!index) throw new Error("Failed to load /aktuelt");
|
if (!index) throw new Error("Failed to load /aktuelt");
|
||||||
const news = (data?.news ?? []) as NewsListItemFragment[];
|
return {
|
||||||
return { index, news };
|
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 (
|
return (
|
||||||
<main className="site-main" id="main">
|
<main className="site-main" id="main">
|
||||||
<PageHeader heading={index.title} lead={index.lead} align="left" />
|
<PageHeader heading={index.title} lead={index.lead} align="left" />
|
||||||
<NewsList news={news} />
|
<NewsList news={news} total={total} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,22 +3,51 @@ import { useState } from "react";
|
|||||||
import { SectionHeader } from "../general/SectionHeader";
|
import { SectionHeader } from "../general/SectionHeader";
|
||||||
import { NewsItem } from "./NewsItem";
|
import { NewsItem } from "./NewsItem";
|
||||||
import styles from "./newsList.module.scss";
|
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 { SectionFooter } from "../general/SectionFooter";
|
||||||
|
import type { NewsPageResponse } from "@/app/api/v1/news/route";
|
||||||
|
|
||||||
|
type NewsListItem = NewsFragment | NewsListItemFragment;
|
||||||
|
|
||||||
export const NewsList = ({
|
export const NewsList = ({
|
||||||
news,
|
news,
|
||||||
|
total,
|
||||||
heading,
|
heading,
|
||||||
featured
|
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;
|
heading?: string;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [limit, setLimit] = useState(featured ? 4 : 17);
|
const [items, setItems] = useState(news);
|
||||||
function increaseLimit() {
|
const [knownTotal, setKnownTotal] = useState(total ?? news.length);
|
||||||
setLimit(limit + 16);
|
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 (
|
return (
|
||||||
<section className={styles.newsWrapper}>
|
<section className={styles.newsWrapper}>
|
||||||
@@ -26,14 +55,18 @@ export const NewsList = ({
|
|||||||
<SectionHeader heading={heading} link="/aktuelt" linkText="Se flere artikler" />
|
<SectionHeader heading={heading} link="/aktuelt" linkText="Se flere artikler" />
|
||||||
)}
|
)}
|
||||||
<ul className={`${styles.newsList} ${featured && styles.featured}`}>
|
<ul className={`${styles.newsList} ${featured && styles.featured}`}>
|
||||||
{news.slice(0, limit).map((singleNews) => (
|
{shown.map((singleNews) => (
|
||||||
<NewsItem key={singleNews.id} news={singleNews} />
|
<NewsItem key={singleNews.id} news={singleNews} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{!featured && limit && news.length > limit && (
|
{!featured && knownTotal > items.length && (
|
||||||
<div className={styles.showMore}>
|
<div className={styles.showMore}>
|
||||||
<button onClick={increaseLimit}>
|
<button onClick={loadMore} disabled={loading} aria-busy={loading}>
|
||||||
<span>Last inn flere artikler</span>
|
<span>
|
||||||
|
{failed
|
||||||
|
? "Noe gikk galt. Prøv igjen"
|
||||||
|
: "Last inn flere artikler"}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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(`
|
export const newsIndexMetadataQuery = graphql(`
|
||||||
query newsIndexMetadata {
|
query newsIndexMetadata {
|
||||||
index: newsIndex {
|
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", () => {
|
describe("image optimizer", () => {
|
||||||
test("optimizes a CMS-hosted image", async () => {
|
test("optimizes a CMS-hosted image", async () => {
|
||||||
const src = encodeURIComponent(mock("/media/pig.png"));
|
const src = encodeURIComponent(mock("/media/pig.png"));
|
||||||
|
|||||||
Reference in new issue
Block a user