Skip to content

Commit ddac05c

Browse files
committed
feat(content/adjacent): fetch adjacent articles in each content page
1 parent 69f677d commit ddac05c

10 files changed

Lines changed: 331 additions & 9 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test.describe("Article Adjacent Content", () => {
4+
test.describe("Both next and previous articles exist", () => {
5+
test.beforeEach(async ({ page }) => {
6+
await page.goto(
7+
"http://localhost:3000/articles/nested/adjacent-prev-next/"
8+
);
9+
});
10+
11+
test("should display next article", async ({ page }) => {
12+
await page.evaluate(() => {
13+
window.scrollTo(0, document.body.scrollHeight);
14+
});
15+
16+
const nextArticle = page.locator("#next-article");
17+
await expect(nextArticle).toBeVisible();
18+
});
19+
20+
test("should display previous article", async ({ page }) => {
21+
await page.evaluate(() => {
22+
window.scrollTo(0, document.body.scrollHeight);
23+
});
24+
25+
const previousArticle = page.locator("#previous-article");
26+
await expect(previousArticle).toBeVisible();
27+
});
28+
29+
test("should take screenshot of adjacent content", async ({
30+
page
31+
}, testInfo) => {
32+
await page.evaluate(() => {
33+
window.scrollTo(0, document.body.scrollHeight);
34+
});
35+
36+
await page.waitForSelector("#adjacent-content");
37+
38+
const screenshot = await page.screenshot({ fullPage: true });
39+
await testInfo.attach("adjacent-content-screenshot", {
40+
body: screenshot,
41+
contentType: "image/png"
42+
});
43+
});
44+
});
45+
46+
test.describe("Only next article exists", () => {
47+
test.beforeEach(async ({ page }) => {
48+
await page.goto(
49+
"http://localhost:3000/articles/nested/adjacent-next-only/"
50+
);
51+
});
52+
53+
test("should display only next article button on the left", async ({
54+
page
55+
}) => {
56+
await page.evaluate(() => {
57+
window.scrollTo(0, document.body.scrollHeight);
58+
});
59+
60+
const nextArticle = page.locator("#next-article");
61+
const previousArticle = page.locator("#previous-article");
62+
63+
await expect(nextArticle).toBeVisible();
64+
await expect(previousArticle).toBeHidden();
65+
});
66+
});
67+
68+
test.describe("Only previous article exists", () => {
69+
test.beforeEach(async ({ page }) => {
70+
await page.goto(
71+
"http://localhost:3000/articles/nested/adjacent-prev-only/"
72+
);
73+
});
74+
75+
test("should display only previous article button on the right", async ({
76+
page
77+
}) => {
78+
await page.evaluate(() => {
79+
window.scrollTo(0, document.body.scrollHeight);
80+
});
81+
82+
const nextArticle = page.locator("#next-article");
83+
const previousArticle = page.locator("#previous-article");
84+
85+
await expect(previousArticle).toBeVisible();
86+
await expect(nextArticle).toBeHidden();
87+
});
88+
});
89+
90+
test.describe("No adjacent articles", () => {
91+
test.beforeEach(async ({ page }) => {
92+
await page.goto("http://localhost:3000/articles/nested/standard/");
93+
});
94+
95+
test("should not display adjacent content navigation", async ({ page }) => {
96+
await page.evaluate(() => {
97+
window.scrollTo(0, document.body.scrollHeight);
98+
});
99+
const adjacentNav = page.locator("#adjacent-content");
100+
await expect(adjacentNav).toBeHidden();
101+
});
102+
});
103+
});

docker-compose.mock.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
qualtet_mock:
3-
image: ghcr.io/yoshinorin/docker-qualtet-mock:v2.18.0.0
3+
image: ghcr.io/yoshinorin/docker-qualtet-mock:v2.19.0.0
44
expose:
55
- "9002"
66
ports:

src/api/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ export function fetchContent(
5050
return fetchFromApi(url, options);
5151
}
5252

53+
export function fetchAdjacentContent(
54+
headers: Headers,
55+
id: string
56+
): Promise<Response> {
57+
const slug = sluggize(["v1", "contents", id, "adjacent"]);
58+
const url = buildUrl(api.url, slug, true);
59+
const ctx = requestContextFrom(headers);
60+
const options: RequestOptions = {
61+
headers: requestHeaderFrom(ctx),
62+
interceptIfContainsIgnorePaths: true
63+
};
64+
return fetchFromApi(url, options);
65+
}
66+
5367
export function fetchFeeds(headers: Headers): Promise<Response> {
5468
const url = buildUrl(api.url, sluggize(["v1", "feeds", "index"]), false);
5569
const ctx = requestContextFrom(headers);

src/app/articles/[...slug]/page.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { Metadata } from "next";
44
import { headers } from "next/headers";
55
import { notFound } from "next/navigation";
66
import { cache } from "react";
7-
import { fetchContent } from "../../../api";
7+
import { fetchAdjacentContent, fetchContent } from "../../../api";
88
import {
9+
AdjacentContent,
910
Content,
1011
ContentResponse,
1112
ContentResponseWithFetchResponse
@@ -29,6 +30,18 @@ const cachedFindByPath = cache(async (path: string) => {
2930
};
3031
});
3132

33+
const fetchAdjacentContentForPage = async (id: string) => {
34+
try {
35+
const response = await fetchAdjacentContent(await headers(), id);
36+
if (response.ok) {
37+
return (await response.json()) as AdjacentContent;
38+
}
39+
} catch (error) {
40+
// Ignore errors
41+
}
42+
return null;
43+
};
44+
3245
// https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching
3346
export async function generateMetadata(props: {
3447
params: Promise<{ slug: Array<string> }>;
@@ -75,10 +88,13 @@ async function handler(req: any) {
7588
updatedAt: response.body.updatedAt
7689
} as Content;
7790

91+
const adjacentContent = await fetchAdjacentContentForPage(response.body.id);
92+
7893
return {
7994
props: {
8095
content: content,
81-
insight: asInsight(response.res)
96+
insight: asInsight(response.res),
97+
adjacentContent
8298
}
8399
};
84100
}

src/app/articles/[...slug]/renderer.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@ import {
44
CoverComponent,
55
InjectScriptComponent
66
} from "../../../components/components";
7-
import { Content, InjectScript, Insight } from "../../../models/models";
7+
import {
8+
AdjacentContent,
9+
Content,
10+
InjectScript,
11+
Insight
12+
} from "../../../models/models";
813
import { getScripts } from "../../../utils/injectScript";
914

1015
export const Renderer: React.FunctionComponent<{
1116
content: Content;
1217
insight: Insight;
13-
}> = ({ content, insight }) => {
18+
adjacentContent?: AdjacentContent | null;
19+
}> = ({ content, insight, adjacentContent }) => {
1420
let externalResourceSrc: Array<InjectScript> = [];
1521
const hasExternalResources =
1622
content.externalResources && externalResourcesConfig;
@@ -30,7 +36,11 @@ export const Renderer: React.FunctionComponent<{
3036
}}
3137
/>
3238
<main>
33-
<ContentComponent content={content} insight={insight} />
39+
<ContentComponent
40+
content={content}
41+
insight={insight}
42+
adjacentContent={adjacentContent}
43+
/>
3444
{(() => {
3545
// TODO: DRY with (`/articles/[...slug]/renderer.tsx`)
3646
if (hasExternalResources) {

src/components/adjacentContent.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { AdjacentContent } from "../models/models";
2+
import style from "../styles/components/adjacentContent.module.scss";
3+
import containerStyles from "../styles/components/container.module.scss";
4+
5+
export const AdjacentContentComponent: React.FunctionComponent<{
6+
adjacentContent: AdjacentContent;
7+
}> = ({ adjacentContent }) => {
8+
const { previous, next } = adjacentContent;
9+
10+
if (!previous && !next) {
11+
return null;
12+
}
13+
14+
return (
15+
<nav className={style.adjacentContent} id="adjacent-content">
16+
<div className={containerStyles.container}>
17+
<div className={style.navigation}>
18+
<div className={`${style.navItem} ${style.next}`}>
19+
{next && (
20+
<div id="next-article">
21+
<a href={next.path} className={style.link}>
22+
<span className={style.label}>← Next</span>
23+
<span className={style.title}>{next.title}</span>
24+
</a>
25+
</div>
26+
)}
27+
</div>
28+
<div className={`${style.navItem} ${style.previous}`}>
29+
{previous && (
30+
<div id="previous-article">
31+
<a href={previous.path} className={style.link}>
32+
<span className={style.label}>Previous →</span>
33+
<span className={style.title}>{previous.title}</span>
34+
</a>
35+
</div>
36+
)}
37+
</div>
38+
</div>
39+
</div>
40+
</nav>
41+
);
42+
};

src/components/content.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useEffect, useState } from "react";
44
import { fetchSystemMetadata } from "../api";
5-
import { BackendMeta, Content, ContentMeta, Insight } from "../models/models";
5+
import {
6+
AdjacentContent,
7+
BackendMeta,
8+
Content,
9+
ContentMeta,
10+
Insight
11+
} from "../models/models";
612
import buttonStyles from "../styles/actionbutton.module.scss";
713
import containerStyles from "../styles/components/container.module.scss";
814
import contentStyles from "../styles/components/content.module.scss";
915
import { mergeBackendMeta } from "../utils/insight";
1016
import { ActionButton } from "./actionbutton";
17+
import { AdjacentContentComponent } from "./adjacentContent";
1118
import { PreContent } from "./precontent";
1219

1320
export const ContentComponent: React.FunctionComponent<{
1421
content: Content;
1522
insight: Insight | null;
16-
}> = ({ content, insight }) => {
23+
adjacentContent?: AdjacentContent | null;
24+
}> = ({ content, insight, adjacentContent }) => {
1725
const [isAttributesOpen, setIsAttributesOpen] = useState(false);
1826
const [attributes, setAttributes] = useState(null);
1927

2028
const [isMetadataOpen, setIsMetadataOpen] = useState(false);
2129
const [isFetchedBackendMeta, setIsFetchedBackendMeta] = useState(false);
2230
const [metadata, setMetaData] = useState(null);
2331

32+
const [showAdjacentContent, setShowAdjacentContent] = useState(false);
33+
2434
const formatAttributes = () => {
2535
let actualRobotsMeta = "";
2636
let maybeRobotsMeta = document.querySelector('meta[name="robots"]');
@@ -69,6 +79,33 @@ export const ContentComponent: React.FunctionComponent<{
6979
setMetaData(JSON.stringify({ insight: ins }, null, 2));
7080
};
7181

82+
useEffect(() => {
83+
if (adjacentContent) {
84+
const observer = new IntersectionObserver(
85+
(entries) => {
86+
entries.forEach((entry) => {
87+
if (entry.isIntersecting) {
88+
setShowAdjacentContent(true);
89+
observer.unobserve(entry.target);
90+
}
91+
});
92+
},
93+
{ threshold: 0.1 }
94+
);
95+
96+
const footerElement = document.querySelector("footer");
97+
if (footerElement) {
98+
observer.observe(footerElement);
99+
}
100+
101+
return () => {
102+
if (footerElement) {
103+
observer.unobserve(footerElement);
104+
}
105+
};
106+
}
107+
}, [adjacentContent]);
108+
72109
const toggleAttributes = () => {
73110
formatAttributes();
74111
setIsMetadataOpen(false);
@@ -102,6 +139,9 @@ export const ContentComponent: React.FunctionComponent<{
102139
className={containerStyles.container}
103140
dangerouslySetInnerHTML={{ __html: content.content }}
104141
/>
142+
{showAdjacentContent && adjacentContent && (
143+
<AdjacentContentComponent adjacentContent={adjacentContent} />
144+
)}
105145
</div>
106146
</article>
107147
);

src/models/adjacentContent.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface AdjacentContent {
2+
previous?: {
3+
id: string;
4+
title: string;
5+
path: string;
6+
};
7+
next?: {
8+
id: string;
9+
title: string;
10+
path: string;
11+
};
12+
}

src/models/models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./adjacentContent";
12
export * from "./archive";
23
export * from "./article";
34
export * from "./backendMeta";

0 commit comments

Comments
 (0)