Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe("AuthNavContainer", () => {
expect(screen.getByRole("link", { name: "My Page" })).toHaveAttribute("href", "/mypage");
expect(screen.getByRole("link", { name: "Favorites" })).toHaveAttribute(
"href",
"/favorites-list",
"/users/1/favorite-list",
);
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Login" })).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function AuthNavContainer() {
return (
<>
<Link
to="/favorites-list"
to={`/users/${user.id}/favorite-list`}
className="rounded-full px-3 py-1.5 text-xs font-semibold text-zinc-600 hover:bg-zinc-100"
>
{text.favorites}
Expand Down
31 changes: 31 additions & 0 deletions client/src/app/features/favorites/components/FavoriteList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { WorldHeritageVm } from "../../../../domain/types.ts";
import { HeritageCard } from "@features/top/cards/HeritageCard";
import { useText } from "@shared/locale/ui-text.ts";

export function FavoriteList({
items,
onClickItem,
}: {
items: ReadonlyArray<WorldHeritageVm>;
onClickItem?: (id: number) => void;
}) {
const text = useText();

if (items.length === 0) {
return (
<div className="py-20 text-center">
<p className="text-sm text-zinc-600">{text.favoritesEmpty}</p>
</div>
);
}

return (
<ul className="grid list-none grid-cols-1 gap-6 p-0 md:grid-cols-2 lg:grid-cols-3">
{items.map((it) => (
<li key={it.id} className="list-none">
<HeritageCard item={it} onClickItem={onClickItem} action={null} />
</li>
))}
</ul>
);
}
12 changes: 12 additions & 0 deletions client/src/app/features/favorites/components/FavoritesTitleBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useText } from "@shared/locale/ui-text.ts";

export function FavoritesTitleBar() {
const text = useText();
return (
<div className="sticky top-[49px] z-20 -mx-4 border-b border-zinc-200 bg-white/95 px-4 pb-4 pt-4 backdrop-blur">
<h1 className="text-3xl font-extrabold tracking-tight text-indigo-700">
{text.favoritesListTitle}
</h1>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** @jest-environment jsdom */

import "@testing-library/jest-dom/jest-globals";
import { jest } from "@jest/globals";
import type { WorldHeritageVm } from "../../../../../domain/types.ts";

jest.mock("@features/top/cards/HeritageCard", () => ({
__esModule: true,
HeritageCard: function MockHeritageCard(props: {
item: WorldHeritageVm;
onClickItem?: (id: number) => void;
action?: unknown;
}) {
return (
<button type="button" onClick={() => props.onClickItem?.(props.item.id)}>
{props.item.title}:{String(props.action)}
</button>
);
},
}));

import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx";
import { FavoriteList } from "../FavoriteList";

const renderList = (props: React.ComponentProps<typeof FavoriteList>) =>
render(
<MemoryRouter>
<LocaleProvider>
<FavoriteList {...props} />
</LocaleProvider>
</MemoryRouter>,
);

describe("FavoriteList", () => {
it("0件の場合は空状態メッセージを表示する", () => {
renderList({ items: [] });

expect(screen.getByText("No favorites yet.")).toBeInTheDocument();
});

it("お気に入りをHeritageCardでaction=nullとして描画し、クリックでonClickItemを呼ぶ", () => {
const onClickItem = jest.fn();
const items = [
{ id: 1, title: "Site A" },
{ id: 2, title: "Site B" },
] as unknown as WorldHeritageVm[];

renderList({ items, onClickItem });

expect(screen.getByText("Site A:null")).toBeInTheDocument();
expect(screen.getByText("Site B:null")).toBeInTheDocument();

fireEvent.click(screen.getByText("Site A:null"));
expect(onClickItem).toHaveBeenCalledWith(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** @jest-environment jsdom */

import "@testing-library/jest-dom/jest-globals";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx";
import { FavoritesTitleBar } from "../FavoritesTitleBar";

describe("FavoritesTitleBar", () => {
it("お気に入り一覧のタイトルを見出しとして表示する", () => {
render(
<MemoryRouter>
<LocaleProvider>
<FavoritesTitleBar />
</LocaleProvider>
</MemoryRouter>,
);

expect(screen.getByRole("heading", { name: "Favorites List" })).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ jest.mock("../../hooks/use-favorites", () => ({
useFavorites: () => useFavoritesMock(),
}));

jest.mock("@features/top/components/HeritageList.tsx", () => ({
jest.mock("../../components/FavoriteList", () => ({
__esModule: true,
HeritageList: function MockHeritageList(props: {
FavoriteList: function MockFavoriteList(props: {
items: ReadonlyArray<WorldHeritageVm>;
onClickItem?: (id: number) => void;
}) {
Expand Down Expand Up @@ -89,7 +89,7 @@ describe("FavoritesContainer", () => {
expect(reloadMock).toHaveBeenCalledTimes(1);
});

it("renders the favorited world heritage sites and navigates to the detail page on click", () => {
it("renders the title bar and the favorited world heritage sites, navigating to the detail page on click", () => {
const items = [{ id: 1, title: "Site A" } as unknown as WorldHeritageVm];
useFavoritesMock.mockReturnValue({
data: items,
Expand All @@ -100,6 +100,8 @@ describe("FavoritesContainer", () => {

renderContainer();

expect(screen.getByRole("heading", { name: "Favorites List" })).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Site A" }));
expect(navigateMock).toHaveBeenCalledWith("/heritages/1");
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { useFavorites } from "../hooks/use-favorites";
import { HeritageList } from "@features/top/components/HeritageList.tsx";
import { FavoriteList } from "../components/FavoriteList";
import { Spinner } from "@shared/uis/Spinner.tsx";
import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx";
import { useText } from "@shared/locale/ui-text.ts";
import { FavoritesTitleBar } from "../components/FavoritesTitleBar";

export function FavoritesContainer() {
const navigate = useNavigate();
Expand Down Expand Up @@ -35,8 +36,11 @@ export function FavoritesContainer() {
}

return (
<main className="mx-auto max-w-7xl px-4 py-8">
<HeritageList items={data} onClickItem={handleClickItem} />
<main className="mx-auto max-w-7xl px-4 py-12">
<FavoritesTitleBar />
<div className="pt-8">
<FavoriteList items={data} onClickItem={handleClickItem} />
</div>
</main>
);
}
14 changes: 11 additions & 3 deletions client/src/app/features/top/cards/HeritageCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import type { WorldHeritageVm } from "../../../../domain/types.ts";
import { BaseCard } from "@shared/uis/BaseCard.tsx";
import { useText } from "@shared/locale/ui-text.ts";
Expand All @@ -6,11 +7,20 @@ import { FavoriteButtonContainer } from "@features/favorites/containers/favorite
export function HeritageCard({
item,
onClickItem,
action,
}: {
item: WorldHeritageVm;
onClickItem?: (id: number) => void;
/** 左上に表示するアクション。未指定ならお気に入り追加ボタン、`null` なら非表示。 */
action?: ReactNode;
}) {
const text = useText();
const resolvedAction =
action === undefined ? (
<FavoriteButtonContainer heritageId={item.id} className="!text-white" />
) : (
action
);

const handleCardClick = () => {
if (onClickItem) onClickItem(item.id);
Expand Down Expand Up @@ -54,9 +64,7 @@ export function HeritageCard({

<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />

<div className="absolute left-3 top-3">
<FavoriteButtonContainer heritageId={item.id} className="!text-white" />
</div>
{resolvedAction && <div className="absolute left-3 top-3">{resolvedAction}</div>}

{item.isEndangered && (
<div className="absolute right-3 top-3">
Expand Down
74 changes: 74 additions & 0 deletions client/src/app/features/top/cards/__tests__/HeritageCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** @jest-environment jsdom */

import "@testing-library/jest-dom/jest-globals";
import { jest } from "@jest/globals";
import type { WorldHeritageVm } from "../../../../../domain/types.ts";

jest.mock("@features/favorites/containers/favorite-button-container.tsx", () => ({
__esModule: true,
FavoriteButtonContainer: function MockFavoriteButtonContainer(props: {
heritageId: number;
className?: string;
}) {
return <button type="button">favorite-button:{props.heritageId}</button>;
},
}));

import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx";
import { HeritageCard } from "../HeritageCard";

const item = {
id: 42,
title: "Site A",
thumbnailUrl: null,
country: "Country",
yearInscribed: 2000,
isEndangered: false,
} as unknown as WorldHeritageVm;

const renderCard = (props: Partial<React.ComponentProps<typeof HeritageCard>> = {}) =>
render(
<MemoryRouter>
<LocaleProvider>
<HeritageCard item={item} {...props} />
</LocaleProvider>
</MemoryRouter>,
);

describe("HeritageCard", () => {
it("action未指定時は既定のお気に入り追加ボタンを表示する", () => {
renderCard();

expect(screen.getByText("favorite-button:42")).toBeInTheDocument();
});

it("action={null}を渡すとアクション部分を表示しない", () => {
renderCard({ action: null });

expect(screen.queryByText("favorite-button:42")).not.toBeInTheDocument();
});

it("カスタムのactionを渡すとそれが表示される", () => {
renderCard({ action: <span>custom-action</span> });

expect(screen.getByText("custom-action")).toBeInTheDocument();
expect(screen.queryByText("favorite-button:42")).not.toBeInTheDocument();
});

it("カードクリックでonClickItemがitem.idを引数に呼ばれる", () => {
const onClickItem = jest.fn();
renderCard({ onClickItem });

fireEvent.click(screen.getByRole("button", { name: /Site A/ }));

expect(onClickItem).toHaveBeenCalledWith(42);
});

it("isEndangered=trueの場合はDANGERバッジが表示される", () => {
renderCard({ item: { ...item, isEndangered: true } as unknown as WorldHeritageVm });

expect(screen.getByText("DANGER")).toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion client/src/app/routes/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function AppRoutes() {
<Route path="/login" element={<LoginContainer />} />
<Route element={<RequireAuth />}>
<Route path="/mypage" element={<MyPageContainer />} />
<Route path="/favorites-list" element={<FavoritesContainer />} />
<Route path="/users/:id/favorite-list" element={<FavoritesContainer />} />
</Route>
<Route path="*" element={<Navigate to="/heritages" replace />} />
</Routes>
Expand Down
4 changes: 3 additions & 1 deletion client/src/locals/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,7 @@
"userUpgradeToPaid": "Upgrade to Paid",
"myPage": "My Page",
"favorites": "Favorites",
"favoritesError": "Failed to load favorites."
"favoritesError": "Failed to load favorites.",
"favoritesListTitle": "Favorites List",
"favoritesEmpty": "No favorites yet."
}
4 changes: 3 additions & 1 deletion client/src/locals/ja/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,7 @@
"userUpgradeToPaid": "有料プランへアップグレード",
"myPage": "マイページ",
"favorites": "お気に入り",
"favoritesError": "お気に入りの取得に失敗しました。"
"favoritesError": "お気に入りの取得に失敗しました。",
"favoritesListTitle": "お気に入り一覧",
"favoritesEmpty": "まだお気に入りがありません。"
}
Loading