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
2 changes: 2 additions & 0 deletions client/src/app/features/search/apis/search-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type SearchParams = {
category?: string;
yearInscribedFrom?: number;
yearInscribedTo?: number;
isEndangered?: boolean;
currentPage?: number;
perPage?: number;
};
Expand Down Expand Up @@ -72,6 +73,7 @@ export const createSearchApi = ({ apiBase, fetchImpl = fetch }: SearchApiDeps) =
if (category) queryParams.set("category", category);
if (yearInscribedFrom) queryParams.set("year_inscribed_from", yearInscribedFrom);
if (yearInscribedTo) queryParams.set("year_inscribed_to", yearInscribedTo);
if (params.isEndangered === true) queryParams.set("is_endangered", "true");
if (params.currentPage != null) queryParams.set("current_page", String(params.currentPage));
if (params.perPage != null) queryParams.set("per_page", String(params.perPage));

Expand Down
10 changes: 7 additions & 3 deletions client/src/app/features/search/components/SearchResultsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { HeritageCard } from "@features/top/cards/HeritageCard";
import { Pagination } from "@features/top/components/Pagination.tsx";
import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx";
import { SearchResultMapComponent } from "@features/search/components/SearchResultMapComponent.tsx";
import { LocaleToggle } from "@shared/locale/LocaleToggle.tsx";
import { useText } from "@shared/locale/ui-text.ts";

type Props = {
header?: ReactNode;
Expand All @@ -31,6 +33,7 @@ export default function SearchResultsPage({
onPageChange,
onBackToAllSites,
}: Props) {
const text = useText();
return (
<main className="mx-auto max-w-7xl px-4 py-12">
<div className="sticky top-0 z-20 -mx-4 border-b border-zinc-200 bg-white/95 px-4 py-3 backdrop-blur">
Expand Down Expand Up @@ -69,11 +72,12 @@ export default function SearchResultsPage({
h-9 rounded-xl border border-zinc-200 bg-white px-3 text-xs font-semibold text-zinc-700
shadow-sm transition hover:bg-zinc-50
"
aria-label="Back to all sites"
aria-label={text.backToAllSites}
>
Back to all sites
{text.backToAllSites}
</button>
) : null}
<LocaleToggle />
</div>
</div>
</div>
Expand All @@ -87,7 +91,7 @@ export default function SearchResultsPage({

{items.length === 0 ? (
<div className="py-20 text-center">
<p className="text-sm text-zinc-600">No sites found.</p>
<p className="text-sm text-zinc-600">{text.noSitesFound}</p>
</div>
) : (
<ul className="grid list-none grid-cols-1 gap-6 p-0 md:grid-cols-2 lg:grid-cols-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type SearchValues = {
keyword: string;
yearInscribedFrom: string;
yearInscribedTo: string;
isEndangered: boolean;
};

type HeritageSubHeaderProps = {
Expand Down Expand Up @@ -144,6 +145,7 @@ const makeParsedParams = (overrides: Partial<HeritageSearchParams> = {}): Herita
category: null,
year_inscribed_from: null,
year_inscribed_to: null,
is_endangered: null,
current_page: 1,
per_page: 30,
order: "asc",
Expand Down Expand Up @@ -203,6 +205,7 @@ describe("TopPageContainer", () => {
keyword: "Kyoto",
yearInscribedFrom: "",
yearInscribedTo: "",
isEndangered: false,
});
});

Expand Down Expand Up @@ -252,6 +255,7 @@ describe("TopPageContainer", () => {
category: "Cultural",
year_inscribed_from: 1990,
year_inscribed_to: null,
is_endangered: null,
current_page: 1,
per_page: 30,
order: "asc",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const toSearchValues = (params: HeritageSearchParams): SearchValues => ({
keyword: params.search_query ?? "",
yearInscribedFrom: params.year_inscribed_from !== null ? String(params.year_inscribed_from) : "",
yearInscribedTo: params.year_inscribed_to !== null ? String(params.year_inscribed_to) : "",
isEndangered: params.is_endangered === true,
});

export function SearchHeritageFormContainer() {
Expand Down Expand Up @@ -71,6 +72,7 @@ export function SearchHeritageFormContainer() {
keyword: query.keyword ?? draft.keyword,
yearInscribedFrom: query.yearInscribedFrom ?? draft.yearInscribedFrom,
yearInscribedTo: query.yearInscribedTo ?? draft.yearInscribedTo,
isEndangered: query.isEndangered ?? draft.isEndangered,
};

const nextParams: HeritageSearchParams = {
Expand All @@ -80,17 +82,26 @@ export function SearchHeritageFormContainer() {
category: toCategoryOrNull(merged.category),
year_inscribed_from: toSearchYearOrNull(merged.yearInscribedFrom),
year_inscribed_to: toSearchYearOrNull(merged.yearInscribedTo),
is_endangered: merged.isEndangered ? true : null,
current_page: 1,
per_page: params.per_page ?? DEFAULT_TOP_PER_PAGE,
order: params.order ?? DEFAULT_ORDER,
country: null,
};

const search = serializeHeritageSearchParams(nextParams);
const baseSearch = serializeHeritageSearchParams(nextParams);
const currentLang = new URLSearchParams(location.search).get("lang");
const finalParams = new URLSearchParams(
baseSearch.startsWith("?") ? baseSearch.slice(1) : baseSearch,
);
if (currentLang === "ja") finalParams.set("lang", "ja");
const finalQueryString = finalParams.toString();
const search = finalQueryString ? `?${finalQueryString}` : "";

navigate({ pathname: "/heritages/results", search }, { replace: false });
setDraft(merged);
},
[draft, navigate, params.per_page, params.order],
[draft, navigate, params.per_page, params.order, location.search],
);

return <HeritageSubHeader value={draft} onChange={handleChange} onSubmit={handleSubmit} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,16 @@ const hasSearchParams = (params: HeritageSearchParams): boolean =>
params.region !== null ||
params.category !== null ||
params.year_inscribed_from !== null ||
params.year_inscribed_to !== null;
params.year_inscribed_to !== null ||
params.is_endangered === true;

const toDraftValues = (params: HeritageSearchParams): SearchValues => ({
region: params.region ?? "",
category: params.category ?? "",
keyword: params.search_query ?? "",
yearInscribedFrom: params.year_inscribed_from !== null ? String(params.year_inscribed_from) : "",
yearInscribedTo: params.year_inscribed_to !== null ? String(params.year_inscribed_to) : "",
isEndangered: params.is_endangered === true,
});

const toSearchParams = (draft: SearchValues): HeritageSearchParams => ({
Expand All @@ -67,6 +69,7 @@ const toSearchParams = (draft: SearchValues): HeritageSearchParams => ({
category: draft.category || null,
year_inscribed_from: draft.yearInscribedFrom ? Number(draft.yearInscribedFrom) : null,
year_inscribed_to: draft.yearInscribedTo ? Number(draft.yearInscribedTo) : null,
is_endangered: draft.isEndangered ? true : null,
current_page: 1,
});

Expand All @@ -76,8 +79,18 @@ const mergeDraft = (currentDraft: SearchValues, partial: Partial<SearchValues>):
keyword: partial.keyword ?? currentDraft.keyword,
yearInscribedFrom: partial.yearInscribedFrom ?? currentDraft.yearInscribedFrom,
yearInscribedTo: partial.yearInscribedTo ?? currentDraft.yearInscribedTo,
isEndangered: partial.isEndangered ?? currentDraft.isEndangered,
});

// 現在の URL に lang=ja があれば、遷移先 search にも持たせる (en はデフォルトなので付けない)
const preserveLang = (nextSearch: string, currentSearch: string): string => {
const currentLang = new URLSearchParams(currentSearch).get("lang");
if (currentLang !== "ja") return nextSearch;
const params = new URLSearchParams(nextSearch.startsWith("?") ? nextSearch.slice(1) : nextSearch);
params.set("lang", "ja");
return `?${params.toString()}`;
};

function useHeritageSearchDraft(params: HeritageSearchParams) {
const [draft, setDraft] = React.useState<SearchValues>(() => toDraftValues(params));

Expand Down Expand Up @@ -121,9 +134,10 @@ export function SearchHeritageResultsContainer(): React.ReactElement {

const handleClickItem = React.useCallback(
(id: number) => {
navigate(`/heritages/${id}`);
const search = preserveLang("", location.search);
navigate(`/heritages/${id}${search}`);
},
[navigate],
[navigate, location.search],
);

const handlePageChange = React.useCallback(
Expand All @@ -133,7 +147,7 @@ export function SearchHeritageResultsContainer(): React.ReactElement {
current_page: page,
};

const search = serializeHeritageSearchParams(nextParams);
const search = preserveLang(serializeHeritageSearchParams(nextParams), location.search);

navigate(
{
Expand All @@ -143,14 +157,14 @@ export function SearchHeritageResultsContainer(): React.ReactElement {
{ replace: false },
);
},
[navigate, location.pathname, params],
[navigate, location.pathname, location.search, params],
);

const handleSubmit = React.useCallback(
(partial: Partial<SearchValues>) => {
const nextDraft = mergeDraft(draft, partial);
const nextParams = toSearchParams(nextDraft);
const search = serializeHeritageSearchParams(nextParams);
const search = preserveLang(serializeHeritageSearchParams(nextParams), location.search);

navigate(
{
Expand All @@ -162,13 +176,14 @@ export function SearchHeritageResultsContainer(): React.ReactElement {

setDraft(nextDraft);
},
[draft, navigate, setDraft],
[draft, navigate, setDraft, location.search],
);

// Hooks must be called at the top level before any early returns.
const handleBackToAllSites = React.useCallback(() => {
navigate("/heritages", { replace: true });
}, [navigate]);
const search = preserveLang("", location.search);
navigate(`/heritages${search}`, { replace: true });
}, [navigate, location.search]);

const header = (
<HeritageSubHeader value={draft} onChange={handleChange} onSubmit={handleSubmit} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const makeParams = (overrides: Partial<HeritageSearchParams> = {}): HeritageSear
category: null,
year_inscribed_from: null,
year_inscribed_to: null,
is_endangered: null,
current_page: 1,
per_page: 30,
order: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const toSearchParams = (params: HeritageSearchParams): SearchParams => ({
category: params.category ?? undefined,
yearInscribedFrom: params.year_inscribed_from ?? undefined,
yearInscribedTo: params.year_inscribed_to ?? undefined,
isEndangered: params.is_endangered === true ? true : undefined,
currentPage: params.current_page,
perPage: params.per_page,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from "@jest/globals";
import {
parseHeritageSearchParams,
serializeHeritageSearchParams,
} from "../search-heritages.params.ts";
import { DEFAULT_HERITAGE_SEARCH_PARAMS } from "../search-heritage.types.ts";
import type { HeritageSearchParams } from "../../../../../domain/types.ts";

const baseParams = (overrides: Partial<HeritageSearchParams> = {}): HeritageSearchParams => ({
...DEFAULT_HERITAGE_SEARCH_PARAMS,
...overrides,
});

describe("parseHeritageSearchParams", () => {
it("is_endangered=true is parsed as true", () => {
const params = parseHeritageSearchParams("?is_endangered=true");
expect(params.is_endangered).toBe(true);
});

it("is_endangered=false is treated as no filter (null)", () => {
const params = parseHeritageSearchParams("?is_endangered=false");
expect(params.is_endangered).toBeNull();
});

it("missing is_endangered defaults to null", () => {
const params = parseHeritageSearchParams("?region=Asia");
expect(params.is_endangered).toBeNull();
});

it("garbage is_endangered values fall back to null", () => {
const params = parseHeritageSearchParams("?is_endangered=banana");
expect(params.is_endangered).toBeNull();
});
});

describe("serializeHeritageSearchParams", () => {
it("emits is_endangered=true when params.is_endangered is true", () => {
const queryString = serializeHeritageSearchParams(baseParams({ is_endangered: true }));
expect(queryString).toContain("is_endangered=true");
});

it("omits is_endangered when null", () => {
const queryString = serializeHeritageSearchParams(baseParams({ is_endangered: null }));
expect(queryString).not.toContain("is_endangered");
});

it("omits is_endangered when false", () => {
const queryString = serializeHeritageSearchParams(baseParams({ is_endangered: false }));
expect(queryString).not.toContain("is_endangered");
});
});

describe("round-trip", () => {
it("preserves is_endangered=true through serialize -> parse", () => {
const queryString = serializeHeritageSearchParams(baseParams({ is_endangered: true }));
const parsed = parseHeritageSearchParams(queryString);
expect(parsed.is_endangered).toBe(true);
});

it("preserves is_endangered=null through serialize -> parse", () => {
const queryString = serializeHeritageSearchParams(baseParams({ is_endangered: null }));
const parsed = parseHeritageSearchParams(queryString);
expect(parsed.is_endangered).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const DEFAULT_HERITAGE_SEARCH_PARAMS: HeritageSearchParams = {
category: null,
year_inscribed_from: null,
year_inscribed_to: null,
is_endangered: null,
current_page: 1,
per_page: 30,
order: ID_SORT_OPTIONS.ASC,
Expand Down
16 changes: 16 additions & 0 deletions client/src/app/features/search/mapper/search-heritages.params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ const toOrderOrNull = (value: string | null): IdSortOption | null => {
return isIdSortOption(trimmed) ? trimmed : null;
};

// "true" だけ true。それ以外 (空 / 不正値 / "false") は null = フィルタなし扱い。
const toEndangeredOrNull = (value: string | null): boolean | null => {
const trimmed = toNullIfEmpty(value);
if (trimmed == null) return null;
return trimmed === "true" ? true : null;
};

export function parseHeritageSearchParams(search: string): HeritageSearchParams {
const searchParams = new URLSearchParams(search);

Expand Down Expand Up @@ -88,13 +95,17 @@ export function parseHeritageSearchParams(search: string): HeritageSearchParams

const order = toOrderOrNull(searchParams.get("order")) ?? defaultSearchParams.order;

const is_endangered =
toEndangeredOrNull(searchParams.get("is_endangered")) ?? defaultSearchParams.is_endangered;

return {
search_query,
country,
region,
category,
year_inscribed_from,
year_inscribed_to,
is_endangered,
current_page,
per_page,
order,
Expand Down Expand Up @@ -138,6 +149,11 @@ export function serializeHeritageSearchParams(params: HeritageSearchParams): str
searchParams.set("order", params.order);
}

// 危機遺産: true のときだけ URL に乗せる (false / null は省略 = no filter)
if (params.is_endangered === true) {
searchParams.set("is_endangered", "true");
}

const queryString = searchParams.toString();
return queryString ? `?${queryString}` : "";
}
Loading
Loading