Skip to content

Commit ec36ad2

Browse files
committed
Enhances work landing page display.
Improves the work landing page with better text wrapping, linking, and map controls for a better user experience. Ensures long titles and metadata wrap properly on smaller screens. Adds clickable links for DOIs and source homepages. Introduces a "Zoom to All Features" button and fullscreen control for the map. Also adds a link to view the raw JSON from the API. Adds tests for the new features on the landing page.
1 parent 8b2115b commit ec36ad2

File tree

4 files changed

+297
-21
lines changed

4 files changed

+297
-21
lines changed

publications/static/css/main.css

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,75 @@ main {
181181
word-wrap: break-word;
182182
overflow-wrap: break-word;
183183
}
184+
185+
/* Work landing page styles */
186+
h1.page-title {
187+
margin-top: 2rem;
188+
margin-bottom: .5rem;
189+
word-wrap: break-word;
190+
overflow-wrap: break-word;
191+
hyphens: auto;
192+
}
193+
194+
.muted {
195+
color: #666;
196+
font-size: .92rem;
197+
word-wrap: break-word;
198+
overflow-wrap: break-word;
199+
}
200+
201+
.meta {
202+
margin: 1rem 0 1.5rem;
203+
word-wrap: break-word;
204+
overflow-wrap: break-word;
205+
}
206+
207+
.meta a {
208+
word-wrap: break-word;
209+
overflow-wrap: break-word;
210+
word-break: break-all;
211+
}
212+
213+
#mini-map {
214+
height: 280px;
215+
border-radius: 8px;
216+
margin: 1.5rem 0;
217+
}
218+
219+
/* Custom zoom to all features button */
220+
.leaflet-control-zoom-all {
221+
background-color: white;
222+
border: 2px solid rgba(0,0,0,0.2);
223+
border-radius: 4px;
224+
width: 26px;
225+
height: 26px;
226+
line-height: 26px;
227+
text-align: center;
228+
cursor: pointer;
229+
font-size: 18px;
230+
font-weight: bold;
231+
}
232+
233+
.leaflet-control-zoom-all:hover {
234+
background-color: #f4f4f4;
235+
}
236+
237+
/* Works page styles */
238+
.works-page h2 {
239+
word-wrap: break-word;
240+
overflow-wrap: break-word;
241+
hyphens: auto;
242+
}
243+
244+
.works-page ul li {
245+
word-wrap: break-word;
246+
overflow-wrap: break-word;
247+
hyphens: auto;
248+
}
249+
250+
/* General text wrapping for paragraphs in content pages */
251+
.work-landing-page p {
252+
word-wrap: break-word;
253+
overflow-wrap: break-word;
254+
hyphens: auto;
255+
}

publications/templates/work_landing_page.html

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,7 @@
1414
{% endblock %}
1515

1616
{% block content %}
17-
18-
<style>
19-
/* More separation between top logo bar and headline */
20-
h1.page-title {
21-
margin-top: 2rem; /* increase top margin */
22-
margin-bottom: .5rem;
23-
}
24-
.muted { color: #666; font-size: .92rem; }
25-
.meta {
26-
margin: 1rem 0 1.5rem; /* add more vertical spacing around authors/time period */
27-
}
28-
#mini-map {
29-
height: 280px;
30-
border-radius: 8px;
31-
margin: 1.5rem 0;
32-
}
33-
</style>
17+
<div class="work-landing-page">
3418

3519
<h1 class="page-title">{{ pub.title }}</h1>
3620

@@ -41,9 +25,16 @@ <h1 class="page-title">{{ pub.title }}</h1>
4125
{% if timeperiod_label %}
4226
<strong>Time period:</strong> {{ timeperiod_label }} ·
4327
{% endif %}
44-
{% if pub.doi %}<strong>DOI:</strong> {{ pub.doi }} ·{% endif %}
28+
{% if pub.doi %}<strong>DOI:</strong> <a href="https://doi.org/{{ pub.doi }}" target="_blank" rel="noopener">{{ pub.doi }}</a> ·{% endif %}
4529
{% if pub.publicationDate %}<strong>Published:</strong> {{ pub.publicationDate }} ·{% endif %}
46-
{% if pub.source %}<strong>Source:</strong> {{ pub.source.name }}{% endif %}
30+
{% if pub.source %}
31+
<strong>Source:</strong>
32+
{% if pub.source.homepage_url %}
33+
<a href="{{ pub.source.homepage_url }}" target="_blank" rel="noopener">{{ pub.source.name }}</a>
34+
{% else %}
35+
{{ pub.source.name }}
36+
{% endif %}
37+
{% endif %}
4738
</div>
4839

4940
{% if pub.abstract %}
@@ -56,13 +47,26 @@ <h1 class="page-title">{{ pub.title }}</h1>
5647
href="https://unpkg.com/[email protected]/dist/leaflet.css"
5748
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
5849
crossorigin="">
50+
<!-- Leaflet Fullscreen plugin -->
51+
<link rel="stylesheet"
52+
href="https://unpkg.com/[email protected]/Control.FullScreen.css"
53+
crossorigin="">
5954
<div id="mini-map"></div>
6055
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"
6156
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
6257
crossorigin=""></script>
58+
<!-- Leaflet Fullscreen plugin -->
59+
<script src="https://unpkg.com/[email protected]/Control.FullScreen.js"
60+
crossorigin=""></script>
6361
<script>
6462
const publicationFeature = {{ feature_json|safe }};
65-
const map = L.map('mini-map', { scrollWheelZoom: false });
63+
const map = L.map('mini-map', {
64+
scrollWheelZoom: true,
65+
fullscreenControl: true,
66+
fullscreenControlOptions: {
67+
position: 'topleft'
68+
}
69+
});
6670

6771
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
6872
maxZoom: 19,
@@ -82,11 +86,42 @@ <h1 class="page-title">{{ pub.title }}</h1>
8286
} else {
8387
map.setView([0, 0], 2);
8488
}
89+
90+
// Add custom "Zoom to All Features" button
91+
L.Control.ZoomAll = L.Control.extend({
92+
onAdd: function(map) {
93+
const btn = L.DomUtil.create('div', 'leaflet-control-zoom-all leaflet-bar leaflet-control');
94+
btn.innerHTML = '⌖';
95+
btn.title = 'Zoom to all features';
96+
97+
L.DomEvent.on(btn, 'click', function(e) {
98+
L.DomEvent.stopPropagation(e);
99+
L.DomEvent.preventDefault(e);
100+
101+
if (bounds && bounds.isValid && bounds.isValid()) {
102+
map.fitBounds(bounds.pad(0.2));
103+
} else if (publicationFeature.geometry && publicationFeature.geometry.type === "Point") {
104+
const c = publicationFeature.geometry.coordinates;
105+
map.setView([c[1], c[0]], 10);
106+
}
107+
});
108+
109+
return btn;
110+
}
111+
});
112+
113+
// Add the zoom to all button to the map
114+
new L.Control.ZoomAll({ position: 'topleft' }).addTo(map);
85115
</script>
86116
{% endif %}
87117

118+
<p class="muted">
119+
<a href="/api/v1/publications/{{ pub.id }}/" target="_blank" rel="noopener">View raw JSON from API</a>
120+
</p>
121+
88122
<p class="muted">
89123
Found an error? Please report to <a href="mailto:[email protected]">[email protected]</a>.
90124
</p>
91125

126+
</div>
92127
{% endblock %}

publications/templates/works.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
{% endblock %}
1111

1212
{% block content %}
13-
<div class="container py-5">
13+
<div class="container py-5 works-page">
1414
<h2>All Article Links</h2>
1515
<ul>
1616
{% for item in links %}

tests/test_work_landing_page.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from django.test import TestCase, Client
2+
from django.urls import reverse
3+
from publications.models import Publication, Source
4+
from django.contrib.gis.geos import Point, GeometryCollection
5+
from django.utils.timezone import now
6+
from datetime import timedelta
7+
8+
9+
class WorkLandingPageTest(TestCase):
10+
"""Tests for the work landing page view and its links."""
11+
12+
def setUp(self):
13+
self.client = Client()
14+
15+
# Create a test source
16+
self.source = Source.objects.create(
17+
name="Test Journal",
18+
url_field="https://example.com/oai",
19+
homepage_url="https://example.com/journal",
20+
issn_l="1234-5678"
21+
)
22+
23+
# Create a test publication with DOI
24+
self.pub_with_doi = Publication.objects.create(
25+
title="Test Publication with DOI",
26+
abstract="Test abstract for publication with DOI",
27+
url="https://example.com/pub1",
28+
status="p",
29+
publicationDate=now() - timedelta(days=30),
30+
doi="10.1234/test-doi",
31+
geometry=GeometryCollection(Point(12.4924, 41.8902)),
32+
source=self.source
33+
)
34+
35+
# Create a test publication without source homepage_url
36+
self.source_no_homepage = Source.objects.create(
37+
name="Test Journal No Homepage",
38+
url_field="https://example.com/oai2",
39+
homepage_url=None,
40+
issn_l="8765-4321"
41+
)
42+
43+
self.pub_no_homepage = Publication.objects.create(
44+
title="Test Publication No Homepage",
45+
abstract="Test abstract",
46+
url="https://example.com/pub2",
47+
status="p",
48+
publicationDate=now() - timedelta(days=20),
49+
doi="10.5678/another-doi",
50+
geometry=GeometryCollection(Point(13.4050, 52.5200)),
51+
source=self.source_no_homepage
52+
)
53+
54+
def test_work_landing_page_loads(self):
55+
"""Test that the work landing page loads successfully."""
56+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
57+
self.assertEqual(response.status_code, 200)
58+
self.assertContains(response, self.pub_with_doi.title)
59+
60+
def test_doi_link_is_correct(self):
61+
"""Test that the DOI link points to the correct DOI resolver URL."""
62+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
63+
self.assertEqual(response.status_code, 200)
64+
65+
# Check that the DOI link is present and correct
66+
expected_doi_url = f'https://doi.org/{self.pub_with_doi.doi}'
67+
self.assertContains(response, expected_doi_url)
68+
self.assertContains(response, f'<a href="{expected_doi_url}"')
69+
70+
def test_source_link_with_homepage_url(self):
71+
"""Test that the source link points to the homepage_url when available."""
72+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
73+
self.assertEqual(response.status_code, 200)
74+
75+
# Check that the source homepage link is present
76+
self.assertContains(response, self.source.homepage_url)
77+
self.assertContains(response, f'<a href="{self.source.homepage_url}"')
78+
self.assertContains(response, self.source.name)
79+
80+
def test_source_without_homepage_url(self):
81+
"""Test that source name is displayed as text when homepage_url is not available."""
82+
response = self.client.get(f"/work/{self.pub_no_homepage.doi}/")
83+
self.assertEqual(response.status_code, 200)
84+
85+
# Check that the source name is present but not as a link
86+
self.assertContains(response, self.source_no_homepage.name)
87+
# Should not have a link to the source since homepage_url is None
88+
self.assertNotContains(response, f'<a href="None"')
89+
90+
def test_raw_json_api_link_is_correct(self):
91+
"""Test that the raw JSON API link is correct and uses the publication ID."""
92+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
93+
self.assertEqual(response.status_code, 200)
94+
95+
# Check that the API link is present
96+
expected_api_url = f'/api/v1/publications/{self.pub_with_doi.id}/'
97+
self.assertContains(response, expected_api_url)
98+
self.assertContains(response, 'View raw JSON from API')
99+
100+
def test_raw_json_api_returns_valid_json(self):
101+
"""Test that the raw JSON API endpoint returns valid JSON data."""
102+
# First get the work landing page to ensure publication exists
103+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
104+
self.assertEqual(response.status_code, 200)
105+
106+
# Now test the API endpoint
107+
api_response = self.client.get(f'/api/v1/publications/{self.pub_with_doi.id}/')
108+
self.assertEqual(api_response.status_code, 200)
109+
self.assertIn('application/json', api_response['Content-Type'])
110+
111+
# Check that the JSON contains expected fields
112+
data = api_response.json()
113+
114+
# GeoJSON Feature format has properties
115+
if 'properties' in data:
116+
# GeoFeatureModelSerializer returns GeoJSON Feature
117+
self.assertEqual(data['type'], 'Feature')
118+
self.assertEqual(data['properties']['title'], self.pub_with_doi.title)
119+
self.assertEqual(data['properties']['doi'], self.pub_with_doi.doi)
120+
self.assertEqual(data['properties']['abstract'], self.pub_with_doi.abstract)
121+
else:
122+
# Regular serializer format
123+
self.assertEqual(data['title'], self.pub_with_doi.title)
124+
self.assertEqual(data['doi'], self.pub_with_doi.doi)
125+
self.assertEqual(data['abstract'], self.pub_with_doi.abstract)
126+
127+
def test_all_links_have_security_attributes(self):
128+
"""Test that external links have target='_blank' and rel='noopener'."""
129+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
130+
self.assertEqual(response.status_code, 200)
131+
132+
# Check DOI link
133+
self.assertContains(response, 'target="_blank"')
134+
self.assertContains(response, 'rel="noopener"')
135+
136+
def test_html_title_contains_publication_title_and_doi(self):
137+
"""Test that the HTML <title> tag contains the publication title and DOI."""
138+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
139+
self.assertEqual(response.status_code, 200)
140+
141+
# Extract the title tag content
142+
content = response.content.decode('utf-8')
143+
144+
# Check that <title> tag contains the publication title
145+
self.assertIn(f'<title>{self.pub_with_doi.title}', content)
146+
147+
# Check that <title> tag contains the DOI in parentheses
148+
self.assertIn(f'({self.pub_with_doi.doi})', content)
149+
150+
# Check that OPTIMAP is also in the title
151+
self.assertIn('OPTIMAP', content)
152+
153+
# Verify the complete expected format: "Title (DOI) - OPTIMAP"
154+
expected_title = f'<title>{self.pub_with_doi.title} ({self.pub_with_doi.doi}) - OPTIMAP</title>'
155+
self.assertIn(expected_title, content)
156+
157+
def test_html_title_format(self):
158+
"""Test that the HTML <title> tag has proper format and structure."""
159+
response = self.client.get(f"/work/{self.pub_with_doi.doi}/")
160+
self.assertEqual(response.status_code, 200)
161+
162+
# Verify title has opening and closing tags
163+
content = response.content.decode('utf-8')
164+
self.assertIn('<title>', content)
165+
self.assertIn('</title>', content)
166+
167+
# Verify the title appears in the <head> section (use re.DOTALL for multiline)
168+
import re
169+
self.assertIsNotNone(re.search(r'<head>.*<title>.*</title>.*</head>', content, re.DOTALL))

0 commit comments

Comments
 (0)