Skip to content

Commit de4f1a1

Browse files
authored
feat: replace plain-text 500 error states with an ErrorPanel component (#395)
* feat: add ErrorPanel component for API error states Add a shared icon + heading + retry-action panel to replace the plain-text error states currently shown on the top, detail, and search results pages. * feat: use ErrorPanel for detail page 500 error state Replace the plain-text error message with the ErrorPanel component, wired to the existing reload function for the retry action. * feat: use ErrorPanel for top page 500 error state Replace the red text + underlined retry link with the ErrorPanel component for consistency with the detail and search results pages. * feat: add refetch to useHeritageSearchQuery Expose a refetch function so callers can retry a failed search request without changing search params. * feat: use ErrorPanel for search results 500 error state Render the ErrorPanel standalone (alongside the search header) instead of passing errorMessage into SearchResultsPage's small inline text, and wire the retry action to the new refetch function. * refactor: use ErrorPanel for invalid-response state and drop dead baseProps The malformed-response branch still used the old inline-text error styling; switch it to ErrorPanel for consistency with the other error branches, and remove the now-unused baseProps/WorldHeritageVm import. * chore: remove dead errorMessage prop from SearchResultsPage No callers pass errorMessage anymore now that error states render ErrorPanel standalone instead. * test: update loading/error assertions for Spinner and ErrorPanel These tests still asserted on the old plain-text "Loading…" and "Failed to load." strings, left stale by the prior Spinner migration and this PR's ErrorPanel/message changes.
1 parent fcf472b commit de4f1a1

8 files changed

Lines changed: 64 additions & 45 deletions

File tree

client/src/app/features/search/components/SearchResultsPage.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ type Props = {
1717
onClickItem?: (id: number) => void;
1818
onPrev?: () => void;
1919
onNext?: () => void;
20-
errorMessage?: string;
2120
onPageChange?: (page: number) => void;
2221
onBackToAllSites?: () => void;
2322
};
@@ -28,7 +27,6 @@ export default function SearchResultsPage({
2827
pagination,
2928
rangeText,
3029
onClickItem,
31-
errorMessage,
3230
onPageChange,
3331
onBackToAllSites,
3432
}: Props) {
@@ -56,10 +54,6 @@ export default function SearchResultsPage({
5654
<p className="mt-1 text-sm text-zinc-600">
5755
Use filters to narrow down sites for World Heritage exam study.
5856
</p>
59-
60-
{errorMessage ? (
61-
<div className="mt-1 text-sm font-semibold text-red-600">{errorMessage}</div>
62-
) : null}
6357
</div>
6458

6559
<div className="flex flex-wrap items-center gap-2">

client/src/app/features/search/containers/search-heritage-result-container.tsx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,13 @@ import {
99

1010
import { useHeritageSearchQuery } from "../../search/hooks/use-search-heritage-query";
1111
import SearchResultsPage from "../components/SearchResultsPage";
12-
import type {
13-
ApiWorldHeritageDto,
14-
Pagination,
15-
SearchValues,
16-
WorldHeritageVm,
17-
} from "../../../../domain/types";
12+
import type { ApiWorldHeritageDto, Pagination, SearchValues } from "../../../../domain/types";
1813
import { toWorldHeritageListVm } from "@features/heritages/mappers/to-world-heritage-vm";
1914
import { HeritageSubHeader } from "@features/top/components/HeritageSubHeader";
2015
import { DEFAULT_HERITAGE_SEARCH_PARAMS as SEARCH_PARAMS } from "../mapper/search-heritage.types";
2116
import { useLocale } from "@shared/locale/LocaleHooks";
2217
import { Spinner } from "@shared/uis/Spinner.tsx";
18+
import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx";
2319

2420
const fmtRangeText = (pagination: Pagination, count: number): string => {
2521
if (count === 0) {
@@ -135,7 +131,9 @@ export function SearchHeritageResultsContainer(): React.ReactElement {
135131

136132
const { draft, setDraft, handleChange } = useHeritageSearchDraft(params);
137133

138-
const { data, isLoading, error } = useHeritageSearchQuery(params, { enabled: isSearchMode });
134+
const { data, isLoading, error, refetch } = useHeritageSearchQuery(params, {
135+
enabled: isSearchMode,
136+
});
139137

140138
const handleClickItem = React.useCallback(
141139
(id: number) => {
@@ -194,13 +192,6 @@ export function SearchHeritageResultsContainer(): React.ReactElement {
194192
<HeritageSubHeader value={draft} onChange={handleChange} onSubmit={handleSubmit} />
195193
);
196194

197-
const baseProps = {
198-
header,
199-
onBackToAllSites: handleBackToAllSites,
200-
items: [] as WorldHeritageVm[],
201-
pagination: null,
202-
};
203-
204195
if (isLoading) {
205196
return (
206197
<main className="mx-auto max-w-7xl px-4 py-12">
@@ -211,18 +202,27 @@ export function SearchHeritageResultsContainer(): React.ReactElement {
211202
}
212203

213204
if (error) {
214-
const message = error instanceof Error ? error.message : "Failed";
205+
const message = error instanceof Error ? error.message : "Failed to load search results.";
215206

216-
return <SearchResultsPage {...baseProps} rangeText="Failed to load." errorMessage={message} />;
207+
return (
208+
<main className="mx-auto max-w-7xl px-4 py-12">
209+
{header}
210+
<ErrorPanel message={message} onRetry={refetch} />
211+
</main>
212+
);
217213
}
218214

219215
if (!data || !isValidListResult(data)) {
220216
return (
221-
<SearchResultsPage
222-
{...baseProps}
223-
rangeText="Unexpected response."
224-
errorMessage={!data ? undefined : "Invalid data structure: items or pagination missing."}
225-
/>
217+
<main className="mx-auto max-w-7xl px-4 py-12">
218+
{header}
219+
<ErrorPanel
220+
message={
221+
!data ? "Unexpected response." : "Invalid data structure: items or pagination missing."
222+
}
223+
onRetry={refetch}
224+
/>
225+
</main>
226226
);
227227
}
228228

client/src/app/features/search/hooks/use-search-heritage-query.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import type { HeritageSearchParams } from "../../../../domain/types.ts";
33
import { fetchSearchHeritagesResult } from "../apis";
44
import type { SearchParams } from "../apis/search-api";
@@ -32,8 +32,10 @@ export function useHeritageSearchQuery(
3232
const [data, setData] = useState<ListResult<ApiWorldHeritageDto> | null>(null);
3333
const [isLoading, setLoading] = useState(false);
3434
const [error, setError] = useState<unknown>(null);
35+
const [reloadToken, setReloadToken] = useState(0);
3536

3637
const request: SearchParams = useMemo(() => toSearchParams(params), [params]);
38+
const refetch = useCallback(() => setReloadToken((t) => t + 1), []);
3739

3840
useEffect(() => {
3941
if (!enabled) {
@@ -59,7 +61,7 @@ export function useHeritageSearchQuery(
5961
.finally(() => setLoading(false));
6062

6163
return () => abortController.abort();
62-
}, [enabled, request]);
64+
}, [enabled, request, reloadToken]);
6365

64-
return { data, isLoading, error };
66+
return { data, isLoading, error, refetch };
6567
}

client/src/app/features/top/containers/__tests__/top-page-container.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe("TopPageContainer", () => {
113113
navigateMock.mockReset();
114114
});
115115

116-
it("loading のときは Loading… を表示する", () => {
116+
it("loading のときは Spinner を表示する", () => {
117117
useTopPageMock.mockReturnValue(
118118
mkHookState({
119119
isLoading: true,
@@ -127,7 +127,7 @@ describe("TopPageContainer", () => {
127127
</MemoryRouter>,
128128
);
129129

130-
expect(screen.getByText("Loading…")).toBeInTheDocument();
130+
expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
131131
expect(screen.getByTestId("subheader")).toBeInTheDocument();
132132
});
133133

client/src/app/features/top/containers/__tests__/world-heritage-detail-container.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe("WorldHeritageDetailContainer", () => {
8383
});
8484
});
8585

86-
test("loading の場合 'Loading…' を表示する", () => {
86+
test("loading の場合 Spinner を表示する", () => {
8787
useWorldHeritageDetailMock.mockReturnValue({
8888
item: null,
8989
isLoading: true,
@@ -93,10 +93,10 @@ describe("WorldHeritageDetailContainer", () => {
9393
});
9494

9595
renderWithRoute("/heritages/:id", "/heritages/1");
96-
expect(screen.getByText("Loading…")).toBeInTheDocument();
96+
expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
9797
});
9898

99-
test("エラーの場合 'Failed to load.' と Back ボタンを表示する", () => {
99+
test("エラーの場合 ErrorPanel と Back ボタンを表示する", () => {
100100
useWorldHeritageDetailMock.mockReturnValue({
101101
item: null,
102102
isLoading: false,
@@ -107,7 +107,7 @@ describe("WorldHeritageDetailContainer", () => {
107107

108108
renderWithRoute("/heritages/:id", "/heritages/1");
109109

110-
expect(screen.getByText("Failed to load.")).toBeInTheDocument();
110+
expect(screen.getByText("Failed to load this site.")).toBeInTheDocument();
111111
expect(screen.getByRole("button", { name: "Back to World Heritages" })).toBeInTheDocument();
112112
});
113113

client/src/app/features/top/containers/top-page-container.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { IdSortOption } from "../../../../domain/types";
1111
import { parseHeritageSearchParams } from "@features/search/mapper/search-heritages.params";
1212
import { DEFAULT_HERITAGE_SEARCH_PARAMS as SEARCH_PARAMS } from "@features/search/mapper/search-heritage.types";
1313
import { Spinner } from "@shared/uis/Spinner.tsx";
14+
import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx";
1415

1516
const DEFAULT_TOP_PER_PAGE = 30;
1617
const DEFAULT_ORDER: IdSortOption = "asc";
@@ -105,11 +106,8 @@ export default function TopPageContainer(): React.ReactElement {
105106
return (
106107
<>
107108
{header}
108-
<main className="space-y-3 p-6">
109-
<div className="text-red-700">Failed to load.</div>
110-
<button type="button" onClick={reload} className="underline">
111-
Retry
112-
</button>
109+
<main className="mx-auto max-w-2xl px-4 py-12">
110+
<ErrorPanel message="Failed to load World Heritage sites." onRetry={reload} />
113111
</main>
114112
</>
115113
);

client/src/app/features/top/containers/world-heritage-detail-container.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useWorldHeritageDetail } from "../hooks/use-world-heritage-detail";
44
import { HeritageDetailLayout } from "../components/heritage-detail/HeritageDetailLayout";
55
import BreadcrumbContext from "@features/breadcrumbs/BreadCrumbProvider";
66
import { Spinner } from "@shared/uis/Spinner.tsx";
7+
import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx";
78

89
export function WorldHeritageDetailContainer() {
910
const { id } = useParams<{ id: string }>();
@@ -13,7 +14,7 @@ export function WorldHeritageDetailContainer() {
1314
if (!id) navigate("/heritages", { replace: true });
1415
}, [id, navigate]);
1516

16-
const { item, isLoading, isError } = useWorldHeritageDetail(id);
17+
const { item, reload, isLoading, isError } = useWorldHeritageDetail(id);
1718
const breadcrumbCtx = useContext(BreadcrumbContext);
1819
if (!breadcrumbCtx) throw new Error("BreadcrumbProvider is missing");
1920

@@ -28,9 +29,11 @@ export function WorldHeritageDetailContainer() {
2829

2930
if (isError) {
3031
return (
31-
<div>
32-
<p>Failed to load.</p>
33-
<button onClick={() => navigate("/heritages")}>Back to World Heritages</button>
32+
<div className="mx-auto max-w-2xl px-4 py-12">
33+
<ErrorPanel message="Failed to load this site." onRetry={reload} />
34+
<div className="mt-4 text-center">
35+
<button onClick={() => navigate("/heritages")}>Back to World Heritages</button>
36+
</div>
3437
</div>
3538
);
3639
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
2+
import { Button } from "./Button.tsx";
3+
4+
export function ErrorPanel({
5+
message = "Something went wrong.",
6+
onRetry,
7+
}: {
8+
message?: string;
9+
onRetry?: () => void;
10+
}) {
11+
return (
12+
<div className="flex flex-col items-center gap-3 rounded-2xl border border-red-100 bg-red-50/60 px-6 py-12 text-center">
13+
<ErrorOutlineIcon className="!text-4xl !text-red-500" />
14+
<p className="text-sm font-semibold text-zinc-700">{message}</p>
15+
{onRetry && (
16+
<Button type="button" variant="secondary" size="sm" onClick={onRetry}>
17+
Retry
18+
</Button>
19+
)}
20+
</div>
21+
);
22+
}

0 commit comments

Comments
 (0)