Skip to content

Commit d2ecce5

Browse files
authored
feat: add dedicated criteria detail page at /heritages/criteria/:code (#400)
* feat(i18n): add ui-text keys for criteria detail page Adds exampleSites/viewAllResults/unescoCriteriaSource keys ahead of the new /heritages/criteria/:code page. * feat(criteria): add CriteriaExampleCard Card for a single heritage site example, modeled on pamon.sekaken.jp's layout: name, description, region/country/ category/year metadata line, criteria tags, and a detail link. * feat(criteria): add CriteriaDetailPage Renders the criteria title/description, UNESCO source link, and a grid of CriteriaExampleCard items with a "view all results" action. * feat(criteria): add CriteriaDetailContainer Resolves the :code route param, redirects invalid criteria codes back to /heritages, and fetches up to 6 example sites for that criterion via the existing heritage search query hook. * feat(routes): wire /heritages/criteria/:code into AppRoutes * refactor(criteria-tags): link criteria badges to internal detail page Replaces the external UNESCO criteria link with an in-app Link to /heritages/criteria/:code so users can see example sites without leaving the SPA.
1 parent f2e4666 commit d2ecce5

10 files changed

Lines changed: 455 additions & 13 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { WorldHeritageVm } from "../../../../../../domain/types.ts";
2+
import { CriteriaExampleCard } from "./CriteriaExampleCard";
3+
import { useText } from "@shared/locale/ui-text.ts";
4+
5+
export function CriteriaDetailPage({
6+
code,
7+
title,
8+
description,
9+
sourceUrl,
10+
items,
11+
onClickItem,
12+
onViewAllResults,
13+
}: {
14+
code: string;
15+
title: string;
16+
description: string;
17+
sourceUrl: string;
18+
items: ReadonlyArray<WorldHeritageVm>;
19+
onClickItem: (id: number) => void;
20+
onViewAllResults: () => void;
21+
}) {
22+
const text = useText();
23+
24+
return (
25+
<main className="mx-auto max-w-7xl px-4 py-12">
26+
<header className="mb-8">
27+
<p className="text-sm font-medium uppercase tracking-wide text-zinc-500">
28+
{text.criteria} ({code})
29+
</p>
30+
<h1 className="mt-1 text-2xl font-semibold">{title}</h1>
31+
<p className="mt-3 max-w-2xl text-zinc-700">{description}</p>
32+
<a
33+
href={sourceUrl}
34+
referrerPolicy="no-referrer"
35+
className="mt-2 inline-block text-sm text-blue-600 hover:underline"
36+
>
37+
{text.unescoCriteriaSource}
38+
</a>
39+
</header>
40+
41+
<section>
42+
<div className="mb-4 flex items-center justify-between">
43+
<h2 className="text-lg font-medium">{text.exampleSites}</h2>
44+
<button onClick={onViewAllResults} className="text-sm text-blue-600 hover:underline">
45+
{text.viewAllResults}
46+
</button>
47+
</div>
48+
49+
{items.length === 0 ? (
50+
<p className="text-sm text-zinc-600">{text.noSitesFound}</p>
51+
) : (
52+
<ul className="grid list-none grid-cols-1 gap-6 p-0 md:grid-cols-2 lg:grid-cols-3">
53+
{items.map((item) => (
54+
<CriteriaExampleCard key={item.id} item={item} onClickItem={onClickItem} />
55+
))}
56+
</ul>
57+
)}
58+
</section>
59+
</main>
60+
);
61+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { WorldHeritageVm } from "../../../../../../domain/types.ts";
2+
import { CriteriaTags } from "@shared/uis/CriteriaTags.tsx";
3+
import { useText } from "@shared/locale/ui-text.ts";
4+
import { useLocale } from "@shared/locale/LocaleHooks.ts";
5+
6+
export function CriteriaExampleCard({
7+
item,
8+
onClickItem,
9+
}: {
10+
item: WorldHeritageVm;
11+
onClickItem: (id: number) => void;
12+
}) {
13+
const text = useText();
14+
const { locale } = useLocale();
15+
16+
const metadataLine = [
17+
text.region,
18+
text.regionLabels[item.region] ?? "—",
19+
"/",
20+
text.country,
21+
item.country,
22+
"/",
23+
text.category,
24+
text.categoryLabels[item.category] ?? "—",
25+
"/",
26+
text.yearInscribed,
27+
item.yearInscribed,
28+
].join(" ");
29+
30+
return (
31+
<li className="list-none rounded-2xl border border-zinc-200 bg-white/80 p-5 shadow-sm backdrop-blur">
32+
<h3 className="text-lg font-semibold text-zinc-900">{item.title}</h3>
33+
<p className="mt-2 line-clamp-3 text-sm text-zinc-600">{item.displayDescription}</p>
34+
<p className="mt-3 text-xs text-zinc-500">{metadataLine}</p>
35+
<div className="mt-3">
36+
<CriteriaTags criteria={item.criteria} locale={locale} />
37+
</div>
38+
<button
39+
onClick={() => onClickItem(item.id)}
40+
className="mt-4 text-sm font-medium text-blue-600 hover:underline"
41+
>
42+
{text.viewDetails}
43+
</button>
44+
</li>
45+
);
46+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/** @jest-environment jsdom */
2+
3+
import { render, screen, fireEvent } from "@testing-library/react";
4+
import { MemoryRouter } from "react-router-dom";
5+
import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx";
6+
import { CriteriaDetailPage } from "../CriteriaDetailPage";
7+
import type { WorldHeritageVm } from "../../../../../../../domain/types.ts";
8+
9+
const buildVm = (overrides: Partial<WorldHeritageVm> = {}): WorldHeritageVm => ({
10+
id: 1,
11+
officialName: "Official",
12+
name: "Tanbaly",
13+
heritageNameJp: "タムガリ",
14+
country: "Kazakhstan",
15+
countryNameJp: "カザフスタン",
16+
region: "Asia",
17+
stateParty: "Kazakhstan",
18+
category: "Cultural",
19+
criteria: ["iii"],
20+
yearInscribed: 2004,
21+
areaHectares: null,
22+
bufferZoneHectares: null,
23+
isEndangered: false,
24+
latitude: null,
25+
longitude: null,
26+
shortDescription: "dummy",
27+
shortDescriptionJp: null,
28+
unescoSiteUrl: null,
29+
statePartyCodes: [],
30+
statePartiesMeta: {},
31+
primaryStatePartyCode: null,
32+
thumbnailUrl: null,
33+
title: "Tanbaly",
34+
subtitle: "Kazakhstan · Asia",
35+
displaySubName: null,
36+
displayDescription: "Petroglyphs of the Archaeological Landscape of Tanbaly.",
37+
areaText: "—",
38+
bufferText: "—",
39+
criteriaText: "(iii)",
40+
images: [],
41+
...overrides,
42+
});
43+
44+
const renderPage = (items: WorldHeritageVm[], onViewAllResults = jest.fn()) =>
45+
render(
46+
<MemoryRouter>
47+
<LocaleProvider>
48+
<CriteriaDetailPage
49+
code="iii"
50+
title="文明の証拠"
51+
description="現存する、または消滅した文化的伝統や文明の独自または稀有な証拠であること。"
52+
sourceUrl="https://whc.unesco.org/en/criteria/#iii"
53+
items={items}
54+
onClickItem={jest.fn()}
55+
onViewAllResults={onViewAllResults}
56+
/>
57+
</LocaleProvider>
58+
</MemoryRouter>,
59+
);
60+
61+
describe("CriteriaDetailPage", () => {
62+
test("基準コード・タイトル・説明・UNESCO公式リンクを表示する", () => {
63+
renderPage([buildVm()]);
64+
65+
expect(screen.getByText("Criteria (iii)")).toBeInTheDocument();
66+
expect(screen.getByRole("heading", { name: "文明の証拠" })).toBeInTheDocument();
67+
expect(
68+
screen.getByText(
69+
"現存する、または消滅した文化的伝統や文明の独自または稀有な証拠であること。",
70+
),
71+
).toBeInTheDocument();
72+
expect(screen.getByRole("link", { name: "UNESCO official criteria page" })).toHaveAttribute(
73+
"href",
74+
"https://whc.unesco.org/en/criteria/#iii",
75+
);
76+
});
77+
78+
test("実例がある場合は遺産カードを表示する", () => {
79+
renderPage([buildVm({ id: 1, title: "Tanbaly" }), buildVm({ id: 2, title: "Other Site" })]);
80+
81+
expect(screen.getByText("Tanbaly")).toBeInTheDocument();
82+
expect(screen.getByText("Other Site")).toBeInTheDocument();
83+
});
84+
85+
test("実例が0件の場合は該当なしメッセージを表示する", () => {
86+
renderPage([]);
87+
88+
expect(screen.getByText("No sites found.")).toBeInTheDocument();
89+
});
90+
91+
test("「すべての結果を見る」ボタンを押すと onViewAllResults が呼ばれる", () => {
92+
const onViewAllResults = jest.fn();
93+
renderPage([buildVm()], onViewAllResults);
94+
95+
fireEvent.click(screen.getByRole("button", { name: "View all results" }));
96+
97+
expect(onViewAllResults).toHaveBeenCalledTimes(1);
98+
});
99+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/** @jest-environment jsdom */
2+
3+
import { render, screen, fireEvent } from "@testing-library/react";
4+
import { MemoryRouter } from "react-router-dom";
5+
import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx";
6+
import { CriteriaExampleCard } from "../CriteriaExampleCard";
7+
import type { WorldHeritageVm } from "../../../../../../../domain/types.ts";
8+
9+
const buildVm = (overrides: Partial<WorldHeritageVm> = {}): WorldHeritageVm => ({
10+
id: 1,
11+
officialName: "Official",
12+
name: "Tanbaly",
13+
heritageNameJp: "タムガリ",
14+
country: "Kazakhstan",
15+
countryNameJp: "カザフスタン",
16+
region: "Asia",
17+
stateParty: "Kazakhstan",
18+
category: "Cultural",
19+
criteria: ["iii"],
20+
yearInscribed: 2004,
21+
areaHectares: null,
22+
bufferZoneHectares: null,
23+
isEndangered: false,
24+
latitude: null,
25+
longitude: null,
26+
shortDescription: "dummy",
27+
shortDescriptionJp: null,
28+
unescoSiteUrl: null,
29+
statePartyCodes: [],
30+
statePartiesMeta: {},
31+
primaryStatePartyCode: null,
32+
thumbnailUrl: null,
33+
title: "Tanbaly",
34+
subtitle: "Kazakhstan · Asia",
35+
displaySubName: null,
36+
displayDescription: "Petroglyphs of the Archaeological Landscape of Tanbaly.",
37+
areaText: "—",
38+
bufferText: "—",
39+
criteriaText: "(iii)",
40+
images: [],
41+
...overrides,
42+
});
43+
44+
const renderCard = (item: WorldHeritageVm, onClickItem = jest.fn()) =>
45+
render(
46+
<MemoryRouter>
47+
<LocaleProvider>
48+
<ul>
49+
<CriteriaExampleCard item={item} onClickItem={onClickItem} />
50+
</ul>
51+
</LocaleProvider>
52+
</MemoryRouter>,
53+
);
54+
55+
describe("CriteriaExampleCard", () => {
56+
test("名称・説明・メタ情報・基準タグを表示する", () => {
57+
renderCard(buildVm());
58+
59+
expect(screen.getByText("Tanbaly")).toBeInTheDocument();
60+
expect(
61+
screen.getByText("Petroglyphs of the Archaeological Landscape of Tanbaly."),
62+
).toBeInTheDocument();
63+
expect(screen.getByText(/Asia/)).toBeInTheDocument();
64+
expect(screen.getByText(/Kazakhstan/)).toBeInTheDocument();
65+
expect(screen.getByText(/Cultural/)).toBeInTheDocument();
66+
expect(screen.getByText(/2004/)).toBeInTheDocument();
67+
expect(screen.getByRole("link", { name: /iii/ })).toBeInTheDocument();
68+
});
69+
70+
test("詳細を見るボタンを押すと item.id で onClickItem が呼ばれる", () => {
71+
const onClickItem = jest.fn();
72+
renderCard(buildVm({ id: 42 }), onClickItem);
73+
74+
fireEvent.click(screen.getByRole("button", { name: "View details" }));
75+
76+
expect(onClickItem).toHaveBeenCalledWith(42);
77+
});
78+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/** @jest-environment jsdom */
2+
3+
import { render, screen } from "@testing-library/react";
4+
import { MemoryRouter, Route, Routes } from "react-router-dom";
5+
import { CriteriaDetailContainer } from "../criteria-detail-container";
6+
import { useHeritageSearchQuery } from "@features/search/hooks/use-search-heritage-query";
7+
import type { ApiWorldHeritageDto, ListResult } from "../../../../../domain/types";
8+
9+
jest.mock("@shared/locale/LocaleHooks", () => ({
10+
__esModule: true,
11+
useLocale: () => ({ locale: "en", setLocale: jest.fn(), toggleLocale: jest.fn() }),
12+
}));
13+
14+
jest.mock("@features/search/hooks/use-search-heritage-query", () => ({
15+
useHeritageSearchQuery: jest.fn(),
16+
}));
17+
18+
jest.mock("../../components/heritage-detail/criteria/CriteriaDetailPage", () => ({
19+
CriteriaDetailPage: ({ code, title }: { code: string; title: string }) => (
20+
<div data-testid="criteria-detail-page">
21+
Criteria page: {code} / {title}
22+
</div>
23+
),
24+
}));
25+
26+
const useHeritageSearchQueryMock = useHeritageSearchQuery as jest.MockedFunction<
27+
typeof useHeritageSearchQuery
28+
>;
29+
30+
type UseHeritageSearchQueryReturn = {
31+
data: ListResult<ApiWorldHeritageDto> | null;
32+
isLoading: boolean;
33+
error: unknown;
34+
refetch: () => void;
35+
};
36+
37+
const mockReturn = (overrides: Partial<UseHeritageSearchQueryReturn> = {}) =>
38+
useHeritageSearchQueryMock.mockReturnValue({
39+
data: null,
40+
isLoading: false,
41+
error: null,
42+
refetch: jest.fn(),
43+
...overrides,
44+
} as ReturnType<typeof useHeritageSearchQuery>);
45+
46+
const renderWithRoute = (initialEntry: string) =>
47+
render(
48+
<MemoryRouter initialEntries={[initialEntry]}>
49+
<Routes>
50+
<Route path="/heritages" element={<div>Index Page</div>} />
51+
<Route path="/heritages/criteria/:code" element={<CriteriaDetailContainer />} />
52+
</Routes>
53+
</MemoryRouter>,
54+
);
55+
56+
describe("CriteriaDetailContainer", () => {
57+
beforeEach(() => {
58+
jest.clearAllMocks();
59+
mockReturn();
60+
});
61+
62+
test("不正な基準コードの場合は一覧ページへリダイレクトする", async () => {
63+
renderWithRoute("/heritages/criteria/zzz");
64+
65+
expect(await screen.findByText("Index Page")).toBeInTheDocument();
66+
});
67+
68+
test("読み込み中は Spinner を表示する", () => {
69+
mockReturn({ isLoading: true });
70+
71+
renderWithRoute("/heritages/criteria/iii");
72+
73+
expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
74+
});
75+
76+
test("エラー時は ErrorPanel を表示する", () => {
77+
mockReturn({ error: new Error("boom") });
78+
79+
renderWithRoute("/heritages/criteria/iii");
80+
81+
expect(screen.getByText("boom")).toBeInTheDocument();
82+
});
83+
84+
test("成功時には criteria を渡して検索フックを呼び、CriteriaDetailPage を表示する", () => {
85+
mockReturn({ data: { items: [], pagination: {} as never } });
86+
87+
renderWithRoute("/heritages/criteria/iii");
88+
89+
expect(screen.getByTestId("criteria-detail-page")).toHaveTextContent("Criteria page: iii");
90+
expect(useHeritageSearchQueryMock).toHaveBeenCalledWith(
91+
expect.objectContaining({ criteria: ["iii"], per_page: 6 }),
92+
expect.objectContaining({ enabled: true }),
93+
);
94+
});
95+
});

0 commit comments

Comments
 (0)