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
5 changes: 5 additions & 0 deletions client/src/app/features/search/apis/search-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ApiWorldHeritageDto,
CriteriaCode,
ListResult,
Pagination,
StudyRegion,
Expand All @@ -17,6 +18,7 @@ export type SearchParams = {
yearInscribedFrom?: number;
yearInscribedTo?: number;
isEndangered?: boolean;
criteria?: readonly CriteriaCode[];
currentPage?: number;
perPage?: number;
};
Expand Down Expand Up @@ -74,6 +76,9 @@ export const createSearchApi = ({ apiBase, fetchImpl = fetch }: SearchApiDeps) =
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.criteria && params.criteria.length > 0) {
queryParams.set("criteria", params.criteria.join(","));
}
if (params.currentPage != null) queryParams.set("current_page", String(params.currentPage));
if (params.perPage != null) queryParams.set("per_page", String(params.perPage));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type SearchValues = {
yearInscribedFrom: string;
yearInscribedTo: string;
isEndangered: boolean;
criteria: string[];
};

type HeritageSubHeaderProps = {
Expand Down Expand Up @@ -146,6 +147,7 @@ const makeParsedParams = (overrides: Partial<HeritageSearchParams> = {}): Herita
year_inscribed_from: null,
year_inscribed_to: null,
is_endangered: null,
criteria: [],
current_page: 1,
per_page: 30,
order: "asc",
Expand Down Expand Up @@ -206,6 +208,7 @@ describe("TopPageContainer", () => {
yearInscribedFrom: "",
yearInscribedTo: "",
isEndangered: false,
criteria: [],
});
});

Expand Down Expand Up @@ -256,6 +259,7 @@ describe("TopPageContainer", () => {
year_inscribed_from: 1990,
year_inscribed_to: null,
is_endangered: null,
criteria: [],
current_page: 1,
per_page: 30,
order: "asc",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const toSearchValues = (params: HeritageSearchParams): SearchValues => ({
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,
criteria: params.criteria,
});

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

const nextParams: HeritageSearchParams = {
Expand All @@ -83,6 +85,7 @@ export function SearchHeritageFormContainer() {
year_inscribed_from: toSearchYearOrNull(merged.yearInscribedFrom),
year_inscribed_to: toSearchYearOrNull(merged.yearInscribedTo),
is_endangered: merged.isEndangered ? true : null,
criteria: merged.criteria,
current_page: 1,
per_page: params.per_page ?? DEFAULT_TOP_PER_PAGE,
order: params.order ?? DEFAULT_ORDER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ const hasSearchParams = (params: HeritageSearchParams): boolean =>
params.category !== null ||
params.year_inscribed_from !== null ||
params.year_inscribed_to !== null ||
params.is_endangered === true;
params.is_endangered === true ||
params.criteria.length > 0;

const toDraftValues = (params: HeritageSearchParams): SearchValues => ({
region: params.region ?? "",
Expand All @@ -60,6 +61,7 @@ const toDraftValues = (params: HeritageSearchParams): SearchValues => ({
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,
criteria: params.criteria,
});

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

Expand All @@ -80,6 +83,7 @@ const mergeDraft = (currentDraft: SearchValues, partial: Partial<SearchValues>):
yearInscribedFrom: partial.yearInscribedFrom ?? currentDraft.yearInscribedFrom,
yearInscribedTo: partial.yearInscribedTo ?? currentDraft.yearInscribedTo,
isEndangered: partial.isEndangered ?? currentDraft.isEndangered,
criteria: partial.criteria ?? currentDraft.criteria,
});

// 現在の URL に lang=ja があれば、遷移先 search にも持たせる (en はデフォルトなので付けない)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const makeParams = (overrides: Partial<HeritageSearchParams> = {}): HeritageSear
year_inscribed_from: null,
year_inscribed_to: null,
is_endangered: null,
criteria: [],
current_page: 1,
per_page: 30,
order: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const toSearchParams = (params: HeritageSearchParams): SearchParams => ({
yearInscribedFrom: params.year_inscribed_from ?? undefined,
yearInscribedTo: params.year_inscribed_to ?? undefined,
isEndangered: params.is_endangered === true ? true : undefined,
criteria: params.criteria.length > 0 ? params.criteria : undefined,
currentPage: params.current_page,
perPage: params.per_page,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,62 @@ describe("round-trip", () => {
expect(parsed.is_endangered).toBeNull();
});
});

describe("parseHeritageSearchParams (criteria)", () => {
it("parses comma-separated criteria values", () => {
const params = parseHeritageSearchParams("?criteria=ii,iv");
expect(params.criteria).toStrictEqual(["ii", "iv"]);
});

it("missing criteria defaults to []", () => {
const params = parseHeritageSearchParams("?region=Asia");
expect(params.criteria).toStrictEqual([]);
});

it("empty criteria value defaults to []", () => {
const params = parseHeritageSearchParams("?criteria=");
expect(params.criteria).toStrictEqual([]);
});

it("filters out invalid codes", () => {
const params = parseHeritageSearchParams("?criteria=ii,xx,iv");
expect(params.criteria).toStrictEqual(["ii", "iv"]);
});

it("dedupes and sorts by CRITERIA order", () => {
const params = parseHeritageSearchParams("?criteria=v,ii,ii,i");
expect(params.criteria).toStrictEqual(["i", "ii", "v"]);
});
});

describe("serializeHeritageSearchParams (criteria)", () => {
it("emits criteria as comma-separated when non-empty", () => {
const queryString = serializeHeritageSearchParams(baseParams({ criteria: ["ii", "iv"] }));
expect(queryString).toContain("criteria=ii%2Civ");
});

it("omits criteria when empty", () => {
const queryString = serializeHeritageSearchParams(baseParams({ criteria: [] }));
expect(queryString).not.toContain("criteria");
});
});

describe("round-trip (criteria)", () => {
it("preserves single value", () => {
const queryString = serializeHeritageSearchParams(baseParams({ criteria: ["iii"] }));
const parsed = parseHeritageSearchParams(queryString);
expect(parsed.criteria).toStrictEqual(["iii"]);
});

it("preserves multiple values in canonical order", () => {
const queryString = serializeHeritageSearchParams(baseParams({ criteria: ["v", "ii", "i"] }));
const parsed = parseHeritageSearchParams(queryString);
expect(parsed.criteria).toStrictEqual(["i", "ii", "v"]);
});

it("preserves empty array", () => {
const queryString = serializeHeritageSearchParams(baseParams({ criteria: [] }));
const parsed = parseHeritageSearchParams(queryString);
expect(parsed.criteria).toStrictEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const DEFAULT_HERITAGE_SEARCH_PARAMS: HeritageSearchParams = {
year_inscribed_from: null,
year_inscribed_to: null,
is_endangered: null,
criteria: [],
current_page: 1,
per_page: 30,
order: ID_SORT_OPTIONS.ASC,
Expand Down
25 changes: 24 additions & 1 deletion client/src/app/features/search/mapper/search-heritages.params.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type {
Category,
CriteriaCode,
HeritageSearchParams,
IdSortOption,
StudyRegion,
} from "../../../../domain/types.ts";
import { CATEGORIES, STUDY_REGIONS } from "../../../../domain/types.ts";
import { CATEGORIES, CRITERIA, STUDY_REGIONS } from "../../../../domain/types.ts";
import { DEFAULT_HERITAGE_SEARCH_PARAMS as defaultSearchParams } from "./search-heritage.types.ts";

const toNullIfEmpty = (value: string | null): string | null => {
Expand Down Expand Up @@ -65,6 +66,20 @@ const toEndangeredOrNull = (value: string | null): boolean | null => {
return trimmed === "true" ? true : null;
};

const isCriteriaCode = (value: string): value is CriteriaCode =>
(CRITERIA as readonly string[]).includes(value);

// comma-separated を CriteriaCode[] にデコード。重複除去 + CRITERIA 順に正規化、不正値は捨てる。
const toCriteriaCodes = (value: string | null): CriteriaCode[] => {
if (value == null) return [];
const parts = value
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const unique = Array.from(new Set(parts)).filter(isCriteriaCode);
return unique.sort((a, b) => CRITERIA.indexOf(a) - CRITERIA.indexOf(b));
};

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

Expand Down Expand Up @@ -98,6 +113,8 @@ export function parseHeritageSearchParams(search: string): HeritageSearchParams
const is_endangered =
toEndangeredOrNull(searchParams.get("is_endangered")) ?? defaultSearchParams.is_endangered;

const criteria = toCriteriaCodes(searchParams.get("criteria"));

return {
search_query,
country,
Expand All @@ -106,6 +123,7 @@ export function parseHeritageSearchParams(search: string): HeritageSearchParams
year_inscribed_from,
year_inscribed_to,
is_endangered,
criteria,
current_page,
per_page,
order,
Expand Down Expand Up @@ -154,6 +172,11 @@ export function serializeHeritageSearchParams(params: HeritageSearchParams): str
searchParams.set("is_endangered", "true");
}

// criteria: 非空のときだけ comma-separated で URL に乗せる
if (params.criteria.length > 0) {
searchParams.set("criteria", params.criteria.join(","));
}

const queryString = searchParams.toString();
return queryString ? `?${queryString}` : "";
}
41 changes: 41 additions & 0 deletions client/src/app/features/top/components/HeritageSearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { useState } from "react";
import SearchIcon from "@mui/icons-material/Search";
import {
CATEGORIES,
CRITERIA,
STUDY_REGIONS,
type Category,
type CriteriaCode,
type SearchValues,
type StudyRegion,
} from "../../../../domain/types.ts";
Expand All @@ -19,6 +21,7 @@ type Props = {
yearInscribedFrom?: string;
yearInscribedTo?: string;
isEndangered?: boolean;
criteria?: CriteriaCode[];
}) => void;
};

Expand All @@ -42,6 +45,7 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) {
yearInscribedFrom: value?.yearInscribedFrom ?? "",
yearInscribedTo: value?.yearInscribedTo ?? "",
isEndangered: value?.isEndangered ?? false,
criteria: value?.criteria ?? [],
});

const searchValues = value ?? internal;
Expand All @@ -52,6 +56,14 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) {
onChange?.(next);
};

const toggleCriterion = (code: CriteriaCode) => {
const current = searchValues.criteria;
const next = current.includes(code)
? current.filter((c) => c !== code)
: [...current, code].sort((a, b) => CRITERIA.indexOf(a) - CRITERIA.indexOf(b));
set({ criteria: next });
};

const submit = () => {
onSubmit?.({
region: searchValues.region || undefined,
Expand All @@ -60,6 +72,7 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) {
yearInscribedFrom: searchValues.yearInscribedFrom || undefined,
yearInscribedTo: searchValues.yearInscribedTo || undefined,
isEndangered: searchValues.isEndangered || undefined,
criteria: searchValues.criteria.length > 0 ? searchValues.criteria : undefined,
});
};

Expand Down Expand Up @@ -125,6 +138,34 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) {
</div>
</div>

{/* Criteria チップ (multi-select) */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-100">
<div className="text-[11px] font-semibold text-zinc-400 mb-2">{text.criteria}</div>
<div className="flex flex-wrap gap-2">
{CRITERIA.map((code) => {
const isActive = searchValues.criteria.includes(code);
return (
<button
key={code}
type="button"
onClick={() => toggleCriterion(code)}
aria-pressed={isActive}
className={`
px-3 py-1.5 rounded-full text-xs font-semibold border transition-all
${
isActive
? "bg-zinc-900 text-white border-zinc-900"
: "bg-white text-zinc-600 border-zinc-200 hover:border-zinc-400"
}
`}
>
{code}
</button>
);
})}
</div>
</div>

{/* 危機遺産フラグ */}
<div className="px-4 py-2 border-b border-zinc-100">
<label className="inline-flex items-center gap-2 text-xs font-semibold text-zinc-700 cursor-pointer">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const DEFAULT_SEARCH: SearchValues = {
yearInscribedFrom: "",
yearInscribedTo: "",
isEndangered: false,
criteria: [],
};

const formatCriteriaInline = (criteria: string[] | undefined) =>
Expand Down Expand Up @@ -116,6 +117,7 @@ export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm })
if (next.yearInscribedFrom) params.set("year_inscribed_from", next.yearInscribedFrom);
if (next.yearInscribedTo) params.set("year_inscribed_to", next.yearInscribedTo);
if (next.isEndangered) params.set("is_endangered", "true");
if (next.criteria.length > 0) params.set("criteria", next.criteria.join(","));

navigate(`/heritages/results?${params.toString()}`);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function HeritageSubHeader({ value, onChange, onSubmit }: Props): React.J
yearInscribedFrom: value?.yearInscribedFrom ?? "",
yearInscribedTo: value?.yearInscribedTo ?? "",
isEndangered: value?.isEndangered ?? false,
criteria: value?.criteria ?? [],
});

const current = value ?? internal;
Expand Down
2 changes: 2 additions & 0 deletions client/src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export interface HeritageSearchParams {
year_inscribed_from: number | null;
year_inscribed_to: number | null;
is_endangered: boolean | null;
criteria: CriteriaCode[];
current_page: number;
per_page: number;
order: IdSortOption | null;
Expand All @@ -200,4 +201,5 @@ export type SearchValues = {
yearInscribedFrom: string;
yearInscribedTo: string;
isEndangered: boolean;
criteria: CriteriaCode[];
};
Loading