From 3535b310323ba72bbdc619ef1a989e677f87db77 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 3 May 2026 22:26:21 +0900 Subject: [PATCH 1/3] feat: thread criteria multi-select through search domain (types, URL, round-trip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add criteria: CriteriaCode[] to HeritageSearchParams and SearchValues, default it to [], and parse/serialize the URL as a comma-separated list (criteria=ii,iv) — invalid codes are dropped, duplicates collapsed, and the resulting array is sorted in CRITERIA canonical order. The form / result containers, detail layout, both HeritageSubHeaders, and the test helpers are updated to satisfy the new field. Empty array stays out of the URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../search-heritage-container.test.tsx | 4 ++ .../search-heritage-form-container.tsx | 3 + .../search-heritage-result-container.tsx | 6 +- .../use-search-heritage-query-test.ts | 1 + .../__tests__/search-heritages.params.test.ts | 59 +++++++++++++++++++ .../search/mapper/search-heritage.types.ts | 1 + .../search/mapper/search-heritages.params.ts | 25 +++++++- .../top/components/HeritageSearchForm.tsx | 1 + .../heritage-detail/HeritageDetailLayout.tsx | 2 + .../heritage-detail/HeritageSubHeader.tsx | 1 + client/src/domain/types.ts | 2 + 11 files changed, 103 insertions(+), 2 deletions(-) diff --git a/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx b/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx index 266f0c5..88eb303 100644 --- a/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx +++ b/client/src/app/features/search/containers/__tests__/search-heritage-container.test.tsx @@ -40,6 +40,7 @@ type SearchValues = { yearInscribedFrom: string; yearInscribedTo: string; isEndangered: boolean; + criteria: string[]; }; type HeritageSubHeaderProps = { @@ -146,6 +147,7 @@ const makeParsedParams = (overrides: Partial = {}): Herita year_inscribed_from: null, year_inscribed_to: null, is_endangered: null, + criteria: [], current_page: 1, per_page: 30, order: "asc", @@ -206,6 +208,7 @@ describe("TopPageContainer", () => { yearInscribedFrom: "", yearInscribedTo: "", isEndangered: false, + criteria: [], }); }); @@ -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", diff --git a/client/src/app/features/search/containers/search-heritage-form-container.tsx b/client/src/app/features/search/containers/search-heritage-form-container.tsx index 27c1e48..1d1ee6c 100644 --- a/client/src/app/features/search/containers/search-heritage-form-container.tsx +++ b/client/src/app/features/search/containers/search-heritage-form-container.tsx @@ -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() { @@ -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 = { @@ -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, diff --git a/client/src/app/features/search/containers/search-heritage-result-container.tsx b/client/src/app/features/search/containers/search-heritage-result-container.tsx index de23a68..41a5c52 100644 --- a/client/src/app/features/search/containers/search-heritage-result-container.tsx +++ b/client/src/app/features/search/containers/search-heritage-result-container.tsx @@ -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 ?? "", @@ -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 => ({ @@ -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, }); @@ -80,6 +83,7 @@ const mergeDraft = (currentDraft: SearchValues, partial: Partial): 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 はデフォルトなので付けない) diff --git a/client/src/app/features/search/hooks/__tests__/use-search-heritage-query-test.ts b/client/src/app/features/search/hooks/__tests__/use-search-heritage-query-test.ts index a0cb8c6..e38eb43 100644 --- a/client/src/app/features/search/hooks/__tests__/use-search-heritage-query-test.ts +++ b/client/src/app/features/search/hooks/__tests__/use-search-heritage-query-test.ts @@ -102,6 +102,7 @@ const makeParams = (overrides: Partial = {}): HeritageSear year_inscribed_from: null, year_inscribed_to: null, is_endangered: null, + criteria: [], current_page: 1, per_page: 30, order: null, diff --git a/client/src/app/features/search/mapper/__tests__/search-heritages.params.test.ts b/client/src/app/features/search/mapper/__tests__/search-heritages.params.test.ts index 59aa2c2..a86d26b 100644 --- a/client/src/app/features/search/mapper/__tests__/search-heritages.params.test.ts +++ b/client/src/app/features/search/mapper/__tests__/search-heritages.params.test.ts @@ -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([]); + }); +}); diff --git a/client/src/app/features/search/mapper/search-heritage.types.ts b/client/src/app/features/search/mapper/search-heritage.types.ts index 67ed585..a8daff6 100644 --- a/client/src/app/features/search/mapper/search-heritage.types.ts +++ b/client/src/app/features/search/mapper/search-heritage.types.ts @@ -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, diff --git a/client/src/app/features/search/mapper/search-heritages.params.ts b/client/src/app/features/search/mapper/search-heritages.params.ts index 10ce251..0750718 100644 --- a/client/src/app/features/search/mapper/search-heritages.params.ts +++ b/client/src/app/features/search/mapper/search-heritages.params.ts @@ -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 => { @@ -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); @@ -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, @@ -106,6 +123,7 @@ export function parseHeritageSearchParams(search: string): HeritageSearchParams year_inscribed_from, year_inscribed_to, is_endangered, + criteria, current_page, per_page, order, @@ -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}` : ""; } diff --git a/client/src/app/features/top/components/HeritageSearchForm.tsx b/client/src/app/features/top/components/HeritageSearchForm.tsx index 78fd169..8c7bedb 100644 --- a/client/src/app/features/top/components/HeritageSearchForm.tsx +++ b/client/src/app/features/top/components/HeritageSearchForm.tsx @@ -42,6 +42,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; diff --git a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx index 71b0eb1..5ed6de8 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx @@ -20,6 +20,7 @@ const DEFAULT_SEARCH: SearchValues = { yearInscribedFrom: "", yearInscribedTo: "", isEndangered: false, + criteria: [], }; const formatCriteriaInline = (criteria: string[] | undefined) => @@ -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()}`); }; diff --git a/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx b/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx index 223f731..5fea2bb 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageSubHeader.tsx @@ -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; diff --git a/client/src/domain/types.ts b/client/src/domain/types.ts index 65ae770..17e8179 100644 --- a/client/src/domain/types.ts +++ b/client/src/domain/types.ts @@ -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; @@ -200,4 +201,5 @@ export type SearchValues = { yearInscribedFrom: string; yearInscribedTo: string; isEndangered: boolean; + criteria: CriteriaCode[]; }; From 8a080af1846aae7fa09506a3a4375ce5c005669c Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 3 May 2026 22:28:47 +0900 Subject: [PATCH 2/3] feat: thread criteria through search API and query hook Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/app/features/search/apis/search-api.ts | 5 +++++ .../app/features/search/hooks/use-search-heritage-query.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/client/src/app/features/search/apis/search-api.ts b/client/src/app/features/search/apis/search-api.ts index 11f66c9..14912aa 100644 --- a/client/src/app/features/search/apis/search-api.ts +++ b/client/src/app/features/search/apis/search-api.ts @@ -1,5 +1,6 @@ import type { ApiWorldHeritageDto, + CriteriaCode, ListResult, Pagination, StudyRegion, @@ -17,6 +18,7 @@ export type SearchParams = { yearInscribedFrom?: number; yearInscribedTo?: number; isEndangered?: boolean; + criteria?: readonly CriteriaCode[]; currentPage?: number; perPage?: number; }; @@ -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)); diff --git a/client/src/app/features/search/hooks/use-search-heritage-query.ts b/client/src/app/features/search/hooks/use-search-heritage-query.ts index f5748d6..d9d1b28 100644 --- a/client/src/app/features/search/hooks/use-search-heritage-query.ts +++ b/client/src/app/features/search/hooks/use-search-heritage-query.ts @@ -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, }); From bd2c92ca02c32b6880b940bb6c4b4a81680b4087 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 3 May 2026 22:30:28 +0900 Subject: [PATCH 3/3] feat: add Criteria chip group to HeritageSearchForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the i–x criteria as multi-toggle chips between the Category row and the Endangered checkbox. Clicking a chip adds or removes the code from the selection (kept in CRITERIA canonical order); the heading reuses the existing text.criteria key. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../top/components/HeritageSearchForm.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/client/src/app/features/top/components/HeritageSearchForm.tsx b/client/src/app/features/top/components/HeritageSearchForm.tsx index 8c7bedb..1c2e454 100644 --- a/client/src/app/features/top/components/HeritageSearchForm.tsx +++ b/client/src/app/features/top/components/HeritageSearchForm.tsx @@ -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"; @@ -19,6 +21,7 @@ type Props = { yearInscribedFrom?: string; yearInscribedTo?: string; isEndangered?: boolean; + criteria?: CriteriaCode[]; }) => void; }; @@ -53,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, @@ -61,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, }); }; @@ -126,6 +138,34 @@ export function HeritageSearchForm({ value, onChange, onSubmit }: Props) { + {/* Criteria チップ (multi-select) */} +
+
{text.criteria}
+
+ {CRITERIA.map((code) => { + const isActive = searchValues.criteria.includes(code); + return ( + + ); + })} +
+
+ {/* 危機遺産フラグ */}