Skip to content

Commit 06f63a4

Browse files
committed
feat: Add index year and resource class to similar items service and UI components
- Enhanced `SimilarItemsService` to include `gbl_indexYear_im` and `gbl_resourceClass_sm` in the similar items payload. - Implemented tests to verify the inclusion of these fields in the service response and handle cases where they may be missing. - Updated frontend components (`SearchResults`, `SimilarItemsCarousel`, `GalleryView`) to display the new fields in a conjoined pill format, improving the user interface. - Ensured consistent styling and accessibility for the new metadata display across various components.
1 parent feaaf6f commit 06f63a4

9 files changed

Lines changed: 343 additions & 23 deletions

File tree

backend/app/services/similar_items_service.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,36 @@ async def get_similar_items(
8686
)
8787
# Continue without thumbnail
8888

89+
# Index year (DB: gbl_indexYear_im array of ints; support downcased column)
90+
index_year_raw = resource_dict.get("gbl_indexYear_im") or resource_dict.get(
91+
"gbl_indexyear_im"
92+
)
93+
gbl_indexYear_im = (
94+
[index_year_raw] if isinstance(index_year_raw, int) else (index_year_raw or [])
95+
)
96+
if not isinstance(gbl_indexYear_im, list):
97+
gbl_indexYear_im = []
98+
99+
# Resource class (DB: gbl_resourceClass_sm array of strings)
100+
resource_class_raw = resource_dict.get("gbl_resourceClass_sm") or resource_dict.get(
101+
"gbl_resourceclass_sm"
102+
)
103+
gbl_resourceClass_sm = (
104+
[resource_class_raw]
105+
if isinstance(resource_class_raw, str)
106+
else (resource_class_raw or [])
107+
)
108+
if not isinstance(gbl_resourceClass_sm, list):
109+
gbl_resourceClass_sm = []
110+
89111
similar_items.append(
90112
{
91113
"id": similar_id,
92114
"title": title,
93115
"temporal_coverage": temporal_coverage,
94116
"thumbnail_url": thumbnail_url,
117+
"gbl_indexYear_im": gbl_indexYear_im,
118+
"gbl_resourceClass_sm": gbl_resourceClass_sm,
95119
}
96120
)
97121

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Tests for SimilarItemsService."""
2+
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
5+
import pytest
6+
7+
from app.services.similar_items_service import SimilarItemsService
8+
9+
10+
class TestSimilarItemsServicePayload:
11+
"""Test that similar items include gbl_indexYear_im and gbl_resourceClass_sm."""
12+
13+
@pytest.mark.asyncio
14+
@pytest.mark.unit
15+
async def test_similar_items_include_index_year_and_resource_class(self):
16+
"""Similar items payload includes gbl_indexYear_im and gbl_resourceClass_sm."""
17+
18+
# Create a row-like object with _mapping (simulates SQLAlchemy Row)
19+
def make_row(resource_id: str, title: str, index_year=None, resource_class=None):
20+
row = MagicMock()
21+
row._mapping = {
22+
"id": resource_id,
23+
"dct_title_s": title,
24+
"dct_temporal_sm": [],
25+
"gbl_indexYear_im": index_year or [1929],
26+
"gbl_resourceClass_sm": resource_class or ["Maps"],
27+
}
28+
return row
29+
30+
mock_session = MagicMock()
31+
mock_result = MagicMock()
32+
mock_result.fetchall.return_value = [
33+
make_row("similar-1", "Map of France", [1929], ["Maps"]),
34+
]
35+
mock_session.execute = AsyncMock(return_value=mock_result)
36+
37+
with (
38+
patch(
39+
"app.services.similar_items_service.find_similar_resources",
40+
new_callable=AsyncMock,
41+
return_value=["similar-1"],
42+
),
43+
patch(
44+
"app.services.similar_items_service.fetch_distribution_context",
45+
new_callable=AsyncMock,
46+
return_value=None,
47+
),
48+
patch(
49+
"app.services.similar_items_service.ImageService",
50+
) as mock_image_svc,
51+
):
52+
mock_image_svc.return_value.get_thumbnail_url.return_value = (
53+
"https://example.com/thumb.jpg"
54+
)
55+
56+
items = await SimilarItemsService.get_similar_items(
57+
"source-resource-id", mock_session, limit=12
58+
)
59+
60+
assert len(items) == 1
61+
item = items[0]
62+
assert item["id"] == "similar-1"
63+
assert item["title"] == "Map of France"
64+
assert item["gbl_indexYear_im"] == [1929]
65+
assert item["gbl_resourceClass_sm"] == ["Maps"]
66+
67+
@pytest.mark.asyncio
68+
@pytest.mark.unit
69+
async def test_similar_items_handles_missing_index_year_and_resource_class(self):
70+
"""Similar items handles missing gbl_indexYear_im and gbl_resourceClass_sm."""
71+
72+
def make_row(resource_id: str, title: str):
73+
row = MagicMock()
74+
row._mapping = {
75+
"id": resource_id,
76+
"dct_title_s": title,
77+
"dct_temporal_sm": [],
78+
# No gbl_indexYear_im or gbl_resourceClass_sm
79+
}
80+
return row
81+
82+
mock_session = MagicMock()
83+
mock_result = MagicMock()
84+
mock_result.fetchall.return_value = [
85+
make_row("no-meta-1", "Untitled Resource"),
86+
]
87+
mock_session.execute = AsyncMock(return_value=mock_result)
88+
89+
with (
90+
patch(
91+
"app.services.similar_items_service.find_similar_resources",
92+
new_callable=AsyncMock,
93+
return_value=["no-meta-1"],
94+
),
95+
patch(
96+
"app.services.similar_items_service.fetch_distribution_context",
97+
new_callable=AsyncMock,
98+
return_value=None,
99+
),
100+
patch("app.services.similar_items_service.ImageService"),
101+
):
102+
items = await SimilarItemsService.get_similar_items("source-id", mock_session, limit=12)
103+
104+
assert len(items) == 1
105+
item = items[0]
106+
assert item["gbl_indexYear_im"] == []
107+
assert item["gbl_resourceClass_sm"] == []
108+
109+
@pytest.mark.asyncio
110+
@pytest.mark.unit
111+
async def test_similar_items_empty_when_no_similar_ids(self):
112+
"""Returns empty list when find_similar_resources returns no IDs."""
113+
mock_session = MagicMock()
114+
115+
with patch(
116+
"app.services.similar_items_service.find_similar_resources",
117+
new_callable=AsyncMock,
118+
return_value=[],
119+
):
120+
items = await SimilarItemsService.get_similar_items("source-id", mock_session, limit=12)
121+
122+
assert items == []
123+
mock_session.execute.assert_not_called()

frontend/src/__tests__/components/SearchResults.test.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,24 @@ describe('SearchResults Component', () => {
206206
});
207207

208208
describe('Result Rendering', () => {
209+
it('renders year and resource class in conjoined pill', () => {
210+
render(
211+
<TestWrapper>
212+
<SearchResults
213+
results={mockFixtureData}
214+
isLoading={false}
215+
totalResults={4}
216+
currentPage={1}
217+
/>
218+
</TestWrapper>
219+
);
220+
// First fixture has gbl_indexYear_im: [1950], gbl_resourceClass_sm: ['Paper Maps']
221+
const pill = screen.getByText(/1950/);
222+
expect(pill).toHaveTextContent('1950');
223+
expect(pill).toHaveTextContent('Paper Maps');
224+
expect(pill).toHaveClass('bg-brand', 'text-white');
225+
});
226+
209227
it('renders all search results', () => {
210228
render(
211229
<TestWrapper>
@@ -349,7 +367,8 @@ describe('SearchResults Component', () => {
349367
</TestWrapper>
350368
);
351369

352-
expect(screen.getByText('1950')).toBeInTheDocument();
370+
// Year appears in conjoined pill (1950 · Paper Maps)
371+
expect(screen.getByText(/1950/)).toBeInTheDocument();
353372
});
354373

355374
it('displays multiple temporal values correctly', () => {
@@ -364,7 +383,8 @@ describe('SearchResults Component', () => {
364383
</TestWrapper>
365384
);
366385

367-
expect(screen.getByText('2019')).toBeInTheDocument();
386+
// Index year appears in conjoined pill (2019 · Polygon Data)
387+
expect(screen.getByText(/2019/)).toBeInTheDocument();
368388
});
369389

370390
it('displays publisher information when available', () => {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { MemoryRouter } from 'react-router';
3+
import { vi, describe, it, expect, beforeEach } from 'vitest';
4+
import { SimilarItemsCarousel } from '../../../components/resource/SimilarItemsCarousel';
5+
6+
describe('SimilarItemsCarousel', () => {
7+
beforeEach(() => {
8+
vi.spyOn(console, 'log').mockImplementation(() => {});
9+
vi.spyOn(console, 'warn').mockImplementation(() => {});
10+
});
11+
12+
it('renders nothing when similarItems is empty', () => {
13+
render(
14+
<MemoryRouter>
15+
<SimilarItemsCarousel similarItems={[]} />
16+
</MemoryRouter>
17+
);
18+
expect(screen.queryByText('Similar Items')).not.toBeInTheDocument();
19+
});
20+
21+
it('renders nothing when similarItems is undefined', () => {
22+
render(
23+
<MemoryRouter>
24+
<SimilarItemsCarousel />
25+
</MemoryRouter>
26+
);
27+
expect(screen.queryByText('Similar Items')).not.toBeInTheDocument();
28+
});
29+
30+
it('renders year and resource class in conjoined pill for flat API shape', () => {
31+
const items = [
32+
{
33+
id: 'similar-1',
34+
title: 'Map of France',
35+
gbl_indexYear_im: [1929],
36+
gbl_resourceClass_sm: ['Maps'],
37+
},
38+
];
39+
render(
40+
<MemoryRouter>
41+
<SimilarItemsCarousel similarItems={items as never} />
42+
</MemoryRouter>
43+
);
44+
expect(screen.getByText('Similar Items')).toBeInTheDocument();
45+
// Conjoined pill: year · resource class (e.g. "1929 · MAPS")
46+
const pill = screen.getByText(/1929/);
47+
expect(pill).toHaveTextContent('1929');
48+
expect(pill).toHaveTextContent('Maps');
49+
expect(pill).toHaveClass('bg-brand', 'text-white');
50+
});
51+
52+
it('renders year and resource class from full GeoDocument attributes.ogm', () => {
53+
const items = [
54+
{
55+
id: 'doc-1',
56+
attributes: {
57+
ogm: {
58+
dct_title_s: 'Historical Map',
59+
gbl_indexYear_im: [1943],
60+
gbl_resourceClass_sm: ['Datasets'],
61+
},
62+
},
63+
},
64+
];
65+
render(
66+
<MemoryRouter>
67+
<SimilarItemsCarousel similarItems={items as never} />
68+
</MemoryRouter>
69+
);
70+
const pill = screen.getByText(/1943/);
71+
expect(pill).toHaveTextContent('1943');
72+
expect(pill).toHaveTextContent('Datasets');
73+
});
74+
75+
it('shows — when index year is missing', () => {
76+
const items = [
77+
{
78+
id: 'no-year',
79+
title: 'Undated Resource',
80+
gbl_resourceClass_sm: ['Maps'],
81+
},
82+
];
83+
render(
84+
<MemoryRouter>
85+
<SimilarItemsCarousel similarItems={items as never} />
86+
</MemoryRouter>
87+
);
88+
const pill = screen.getByText(//);
89+
expect(pill).toHaveTextContent('—');
90+
expect(pill).toHaveTextContent('Maps');
91+
});
92+
93+
it('shows Item when resource class is missing', () => {
94+
const items = [
95+
{
96+
id: 'no-class',
97+
title: 'Uncategorized',
98+
gbl_indexYear_im: [2020],
99+
},
100+
];
101+
render(
102+
<MemoryRouter>
103+
<SimilarItemsCarousel similarItems={items as never} />
104+
</MemoryRouter>
105+
);
106+
const pill = screen.getByText(/2020/);
107+
expect(pill).toHaveTextContent('2020');
108+
expect(pill).toHaveTextContent('Item');
109+
});
110+
111+
it('renders links to resource detail pages', () => {
112+
const items = [
113+
{
114+
id: 'resource-abc',
115+
title: 'Test Resource',
116+
gbl_indexYear_im: [1985],
117+
gbl_resourceClass_sm: ['Imagery'],
118+
},
119+
];
120+
render(
121+
<MemoryRouter>
122+
<SimilarItemsCarousel similarItems={items as never} />
123+
</MemoryRouter>
124+
);
125+
const link = screen.getByRole('link', { name: /Test Resource/i });
126+
expect(link).toHaveAttribute('href', '/resources/resource-abc');
127+
});
128+
});

frontend/src/__tests__/components/search/GalleryView.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,15 @@ describe('GalleryView', () => {
105105

106106
expect(onLoadMore).not.toHaveBeenCalled();
107107
});
108+
109+
it('renders year and resource class in conjoined pill', () => {
110+
renderGallery();
111+
// Mock has gbl_indexYear_im: [2020] and gbl_resourceClass_sm: ['Map'] for each result
112+
const pills = screen.getAllByText(/2020/);
113+
expect(pills.length).toBeGreaterThan(0);
114+
const pill = pills[0];
115+
expect(pill).toHaveTextContent('2020');
116+
expect(pill).toHaveTextContent('Map');
117+
expect(pill).toHaveClass('bg-brand', 'text-white');
118+
});
108119
});

frontend/src/components/SearchResults.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -383,12 +383,10 @@ export function SearchResults({
383383
{/* Year and resource type inline before description - Hide in compact mode */}
384384
{!isCompact && (
385385
<p className="text-gray-600 mb-4 line-clamp-2">
386-
<span className="text-gray-500 text-sm font-medium flex-shrink-0">
387-
{ogm?.gbl_indexYear_im?.[0] ?? '—'}
388-
<span className="mx-1.5" aria-hidden>
389-
·
390-
</span>
391-
<span className="uppercase tracking-tighter opacity-90">
386+
<span className="text-sm font-medium flex-shrink-0">
387+
<span className="inline-flex items-center text-xs uppercase tracking-tighter bg-brand text-white px-1.5 py-0.5 rounded">
388+
{ogm?.gbl_indexYear_im?.[0] ?? '—'}
389+
<span className="mx-1.5 opacity-90" aria-hidden>·</span>
392390
{resourceClass || 'Item'}
393391
</span>
394392
</span>
@@ -407,9 +405,10 @@ export function SearchResults({
407405

408406
{/* Subject and Theme tags */}
409407
{isCompact ? (
410-
<div className="flex items-center justify-between text-xs text-gray-500 mt-auto pt-2">
411-
<span>{ogm?.gbl_indexYear_im?.[0] || '-'}</span>
412-
<span className="uppercase tracking-tighter opacity-70 border border-gray-200 px-1 rounded ml-auto">
408+
<div className="mt-auto pt-2">
409+
<span className="inline-flex items-center text-xs uppercase tracking-tighter bg-brand text-white px-1.5 py-0.5 rounded">
410+
{ogm?.gbl_indexYear_im?.[0] ?? '—'}
411+
<span className="mx-1.5 opacity-90" aria-hidden>·</span>
413412
{resourceClass || 'Item'}
414413
</span>
415414
</div>

frontend/src/components/home/HomePageHexMapBackground.client.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -429,13 +429,12 @@ export function HomePageHexMapBackground() {
429429
)}
430430
</p>
431431
)}
432-
<div className="flex items-center justify-between gap-2 mt-2 text-xs text-gray-500">
433-
<span>
432+
<div className="mt-2">
433+
<span className="inline-flex items-center text-xs uppercase tracking-tighter bg-brand text-white px-1.5 py-0.5 rounded">
434434
{activeDetail.attributes?.ogm?.gbl_indexYear_im?.[0] ??
435435
activeDetail.attributes?.ogm?.gbl_indexyear_im?.[0] ??
436436
'—'}
437-
</span>
438-
<span className="uppercase tracking-tighter opacity-80 border border-gray-200 px-1.5 py-0.5 rounded">
437+
<span className="mx-1.5 opacity-90" aria-hidden>·</span>
439438
{activeDetail.attributes?.ogm?.gbl_resourceClass_sm?.[0] ??
440439
'Item'}
441440
</span>

0 commit comments

Comments
 (0)