Skip to content

Commit af4023d

Browse files
authored
Merge pull request #304 from zigzagdev/chore/create-language-context
feat:Create LanguageContext to eliminate prop drilling for locale switching
2 parents aa248ea + 4e696f2 commit af4023d

38 files changed

Lines changed: 702 additions & 373 deletions

client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm-test.ts renamed to client/src/app/features/heritages/mappers/__tests__/to-world-heritage-detail-vm.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const baseDto = (
5353
});
5454

5555
describe("toWorldHeritageDetailVm", () => {
56-
it("maps base fields and attaches sorted image VMs", () => {
56+
it("en: maps base fields and attaches sorted image VMs", () => {
5757
const dto = baseDto({
5858
images: [
5959
img({
@@ -68,13 +68,15 @@ describe("toWorldHeritageDetailVm", () => {
6868
],
6969
});
7070

71-
const vm: WorldHeritageDetailVm = toWorldHeritageDetailVm(dto);
71+
const vm: WorldHeritageDetailVm = toWorldHeritageDetailVm(dto, "en");
7272

7373
expect(vm.id).toBe(dto.id);
74-
expect(vm.title).toBe(dto.official_name);
74+
expect(vm.title).toBe(dto.name);
7575
expect(vm.country).toBe(dto.country);
7676
expect(vm.countryNameJp).toBe(dto.country_name_jp);
7777
expect(vm.region).toBe(dto.region);
78+
expect(vm.displayDescription).toBe(dto.short_description);
79+
expect(vm.displaySubName).toBeNull();
7880

7981
// detail
8082
expect(vm.primaryStatePartyCode).toBe("JPN");
@@ -97,12 +99,23 @@ describe("toWorldHeritageDetailVm", () => {
9799
expect(vm.images[1].alt).toBe("custom alt 2");
98100
});
99101

102+
it("ja: title falls back to heritage_name_jp and displaySubName carries the English name", () => {
103+
const dto = baseDto({ images: [] });
104+
105+
const vm = toWorldHeritageDetailVm(dto, "ja");
106+
107+
expect(vm.title).toBe(dto.heritage_name_jp);
108+
expect(vm.displaySubName).toBe(dto.name);
109+
expect(vm.displayDescription).toBe(dto.short_description_jp);
110+
expect(vm.country).toBe(dto.country_name_jp);
111+
});
112+
100113
it("is stable when images is empty array", () => {
101114
const dto = baseDto({ images: [] });
102115

103-
const vm = toWorldHeritageDetailVm(dto);
116+
const vm = toWorldHeritageDetailVm(dto, "en");
104117

105118
expect(vm.images).toEqual([]);
106-
expect(vm.title).toBe(dto.official_name);
119+
expect(vm.title).toBe(dto.name);
107120
});
108121
});

client/src/app/features/heritages/mappers/__tests__/to-world-heritage-vm.test.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ const base: ApiWorldHeritageDto = {
2828
};
2929

3030
describe("toWorldHeritageVm", () => {
31-
it("maps core fields + derived values correctly (DTO shape: thumbnail string)", () => {
32-
const vm = toWorldHeritageVm(base);
31+
it("ja: maps core fields + derived values using Japanese labels", () => {
32+
const vm = toWorldHeritageVm(base, "ja");
3333

3434
expect(vm).toMatchObject({
3535
id: 663,
@@ -54,6 +54,7 @@ describe("toWorldHeritageVm", () => {
5454
criteriaText: "ix, x",
5555
title: "白神山地",
5656
subtitle: "日本 · Asia",
57+
displayDescription: "ダミー",
5758
areaText: "442 ha",
5859
bufferText: "320 ha",
5960
statePartyCodes: ["日本"],
@@ -63,48 +64,68 @@ describe("toWorldHeritageVm", () => {
6364
});
6465

6566
expect(vm.criteria).toStrictEqual(["ix", "x"]);
67+
// 英名 (Shirakami-Sanchi) は日本語タイトル (白神山地) と異なるので併記対象
68+
expect(vm.displaySubName).toBe("Shirakami-Sanchi");
6669
});
6770

68-
it("if official_name is empty, uses name as title", () => {
69-
const vm = toWorldHeritageVm({ ...base, official_name: "" });
71+
it("en: title/country/description fall back to English fields", () => {
72+
const vm = toWorldHeritageVm(base, "en");
73+
74+
expect(vm.title).toBe("Shirakami-Sanchi");
75+
expect(vm.country).toBe("Japan");
76+
expect(vm.subtitle).toBe("Japan · Asia");
77+
expect(vm.displayDescription).toBe("desc");
78+
expect(vm.displaySubName).toBeNull();
79+
});
80+
81+
it("ja: if official_name is empty, falls back to heritage_name_jp for title", () => {
82+
const vm = toWorldHeritageVm({ ...base, official_name: "" }, "ja");
7083
expect(vm.title).toBe("白神山地");
7184
});
7285

86+
it("ja: if Japanese description is missing, falls back to English short_description", () => {
87+
const vm = toWorldHeritageVm({ ...base, short_description_jp: null }, "ja");
88+
expect(vm.displayDescription).toBe("desc");
89+
});
90+
7391
it("when area/buffer are null, text becomes —", () => {
74-
const vm = toWorldHeritageVm({
75-
...base,
76-
area_hectares: null,
77-
buffer_zone_hectares: null,
78-
});
92+
const vm = toWorldHeritageVm(
93+
{
94+
...base,
95+
area_hectares: null,
96+
buffer_zone_hectares: null,
97+
},
98+
"ja",
99+
);
79100

80101
expect(vm.areaText).toBe("—");
81102
expect(vm.bufferText).toBe("—");
82103
});
83104

84105
it("when thumbnail is null, thumbnailUrl is null", () => {
85-
const vm = toWorldHeritageVm({ ...base, thumbnail: null });
106+
const vm = toWorldHeritageVm({ ...base, thumbnail: null }, "ja");
86107
expect(vm.thumbnailUrl).toBeNull();
87108
});
88109

89110
it("input dto is immutable (criteria not mutated)", () => {
90111
const dto: ApiWorldHeritageDto = { ...base, criteria: ["x", "ix", "ix"] };
91112
const snapshot = [...dto.criteria];
92113

93-
const vm = toWorldHeritageVm(dto);
114+
const vm = toWorldHeritageVm(dto, "ja");
94115

95116
expect(dto.criteria).toStrictEqual(snapshot);
96117
expect(vm.criteria).toStrictEqual(["ix", "x"]);
97118
});
98119

99120
it("unesco_site_url can be null (mapper should not crash)", () => {
100-
const vm = toWorldHeritageVm({ ...base, unesco_site_url: null });
121+
const vm = toWorldHeritageVm({ ...base, unesco_site_url: null }, "ja");
101122
expect(vm.unescoSiteUrl).toBeNull();
102123
});
103124
});
104125

105126
describe("toWorldHeritageListVm", () => {
106127
it("maps dto array into view model array", () => {
107-
const vms = toWorldHeritageListVm([base, { ...base, id: 661 }]);
128+
const vms = toWorldHeritageListVm([base, { ...base, id: 661 }], "ja");
108129
expect(vms).toHaveLength(2);
109130
expect(vms[0].id).toBe(663);
110131
expect(vms[1].id).toBe(661);

client/src/app/features/heritages/mappers/to-world-heritage-detail-vm.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import type {
55
WorldHeritageImageVm,
66
WorldHeritageVm,
77
} from "../../../../domain/types.ts";
8+
import type { Locale } from "../../../../domain/criteria.ts";
89
import { toWorldHeritageVm } from "./to-world-heritage-vm.ts";
910

10-
export function toWorldHeritageDetailVm(dto: ApiWorldHeritageDetailDto): WorldHeritageDetailVm {
11+
export function toWorldHeritageDetailVm(
12+
dto: ApiWorldHeritageDetailDto,
13+
locale: Locale,
14+
): WorldHeritageDetailVm {
1115
const listDto = {
1216
id: dto.id,
1317
official_name: dto.official_name,
@@ -25,16 +29,15 @@ export function toWorldHeritageDetailVm(dto: ApiWorldHeritageDetailDto): WorldHe
2529
area_hectares: dto.area_hectares,
2630
buffer_zone_hectares: dto.buffer_zone_hectares,
2731
short_description: dto.short_description,
32+
short_description_jp: dto.short_description_jp,
2833
unesco_site_url: dto.unesco_site_url,
2934
state_party: dto.state_party,
3035
state_party_codes: dto.state_party_codes,
3136
state_parties_meta: dto.state_parties_meta,
3237
thumbnail: dto.thumbnail_url,
3338
} satisfies import("../../../../domain/types.ts").ApiWorldHeritageDto;
3439

35-
// Ensure the reshaped object satisfies ApiWorldHeritageDto at compile time
36-
37-
const base: WorldHeritageVm = toWorldHeritageVm(listDto);
40+
const base: WorldHeritageVm = toWorldHeritageVm(listDto, locale);
3841

3942
const images: WorldHeritageImageVm[] = dto.images
4043
.slice()

client/src/app/features/heritages/mappers/to-world-heritage-vm.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type WorldHeritageVm,
44
type CriteriaCode,
55
} from "../../../../domain/types.ts";
6+
import type { Locale } from "../../../../domain/criteria.ts";
67
import { statePartyLabels } from "@features/constants/state-party-labels.ts";
78
import { CRITERIA } from "../../../../domain/types.ts";
89

@@ -19,14 +20,33 @@ const normalizeCriteria = (values: readonly (string | CriteriaCode)[]): Criteria
1920
const fmtHa = (value: number | null): string =>
2021
value == null ? "—" : `${Number(value).toLocaleString("en-CA")} ha`;
2122

22-
const titleOf = (data: ApiWorldHeritageDto): string =>
23-
data.heritage_name_jp || data.official_name || data.name;
24-
25-
const countryLabelOf = (data: ApiWorldHeritageDto): string | null =>
26-
data.country_name_jp || data.country || null;
23+
const titleOf = (data: ApiWorldHeritageDto, locale: Locale): string =>
24+
locale === "ja"
25+
? data.heritage_name_jp || data.official_name || data.name
26+
: data.name || data.official_name || data.heritage_name_jp;
27+
28+
const countryLabelOf = (data: ApiWorldHeritageDto, locale: Locale): string | null =>
29+
locale === "ja"
30+
? data.country_name_jp || data.country || null
31+
: data.country || data.country_name_jp || null;
32+
33+
const subtitleOf = (data: ApiWorldHeritageDto, locale: Locale): string =>
34+
[countryLabelOf(data, locale), data.region].filter(Boolean).join(" · ");
35+
36+
// ja の時だけ、日本語名の脇に併記する英名を返す(一致や不在なら null)
37+
const subNameOf = (data: ApiWorldHeritageDto, locale: Locale): string | null => {
38+
if (locale !== "ja") return null;
39+
if (!data.heritage_name_jp || !data.name) return null;
40+
if (data.heritage_name_jp === data.name) return null;
41+
return data.name;
42+
};
2743

28-
const subtitleOf = (data: ApiWorldHeritageDto): string =>
29-
[countryLabelOf(data), data.region].filter(Boolean).join(" · ");
44+
const descriptionOf = (data: ApiWorldHeritageDto, locale: Locale): string => {
45+
if (locale === "ja") {
46+
return data.short_description_jp || data.short_description || "";
47+
}
48+
return data.short_description || "";
49+
};
3050

3151
const toStatePartyLabelsJp = (codes: readonly string[]): string[] =>
3252
codes.map((code) => statePartyLabels[code]).filter((label): label is string => Boolean(label));
@@ -49,7 +69,7 @@ const normalizeStatePartiesMeta = (
4969
);
5070
};
5171

52-
export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm {
72+
export function toWorldHeritageVm(data: ApiWorldHeritageDto, locale: Locale): WorldHeritageVm {
5373
const criteriaCodes = normalizeCriteria(data.criteria);
5474
const statePartyCodesRaw = data.state_party_codes ?? [];
5575
const statePartyLabelsJp = toStatePartyLabelsJp(statePartyCodesRaw);
@@ -66,7 +86,7 @@ export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm {
6686
name: data.name,
6787
heritageNameJp: data.heritage_name_jp,
6888

69-
country: data.country_name_jp,
89+
country: countryLabelOf(data, locale) ?? "",
7090
countryNameJp: data.country_name_jp,
7191
region: data.region,
7292
stateParty,
@@ -86,8 +106,10 @@ export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm {
86106
statePartiesMeta: normalizeStatePartiesMeta(data.state_parties_meta),
87107
primaryStatePartyCode: null,
88108

89-
title: titleOf(data),
90-
subtitle: subtitleOf(data),
109+
title: titleOf(data, locale),
110+
subtitle: subtitleOf(data, locale),
111+
displaySubName: subNameOf(data, locale),
112+
displayDescription: descriptionOf(data, locale),
91113
areaText: fmtHa(data.area_hectares),
92114
bufferText: fmtHa(data.buffer_zone_hectares),
93115
criteriaText: criteriaCodes.join(", "),
@@ -97,5 +119,7 @@ export function toWorldHeritageVm(data: ApiWorldHeritageDto): WorldHeritageVm {
97119
};
98120
}
99121

100-
export const toWorldHeritageListVm = (list: ApiWorldHeritageDto[]): WorldHeritageVm[] =>
101-
list.map(toWorldHeritageVm);
122+
export const toWorldHeritageListVm = (
123+
list: ApiWorldHeritageDto[],
124+
locale: Locale,
125+
): WorldHeritageVm[] => list.map((data) => toWorldHeritageVm(data, locale));

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Pagination } from "@features/top/components/Pagination.tsx";
88
import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx";
99
import { SearchResultMapComponent } from "@features/search/components/SearchResultMapComponent.tsx";
1010

11-
export type SearchResultsPageProps = {
11+
type Props = {
1212
header?: ReactNode;
1313
items: WorldHeritageVm[];
1414
pagination: SearchResultsPagination | null;
@@ -30,7 +30,7 @@ export default function SearchResultsPage({
3030
errorMessage,
3131
onPageChange,
3232
onBackToAllSites,
33-
}: SearchResultsPageProps) {
33+
}: Props) {
3434
return (
3535
<main className="mx-auto max-w-7xl px-4 py-12">
3636
<div className="sticky top-0 z-20 -mx-4 border-b border-zinc-200 bg-white/95 px-4 py-3 backdrop-blur">

0 commit comments

Comments
 (0)