Skip to content

Commit bca730f

Browse files
committed
Added documentation type filtering to search.
Thank you to Paulo Melchiorre, Tom Carrick and Marijke Luttekes for the reviews.
1 parent 8402c38 commit bca730f

File tree

6 files changed

+159
-5
lines changed

6 files changed

+159
-5
lines changed

Diff for: djangoproject/scss/_style.scss

+34
Original file line numberDiff line numberDiff line change
@@ -2563,6 +2563,40 @@ table.docutils th {
25632563
}
25642564
}
25652565

2566+
search.filters {
2567+
@include sans-serif;
2568+
2569+
display: flex;
2570+
gap: 10px;
2571+
border-bottom: 2px solid var(--hairline-color);
2572+
overflow-x: auto;
2573+
white-space: nowrap;
2574+
padding-bottom: 0;
2575+
position: relative;
2576+
2577+
a {
2578+
padding: 10px 20px;
2579+
text-decoration: none;
2580+
border-bottom: 3px solid transparent;
2581+
transition: color 0.3s ease, border-bottom 0.3s ease;
2582+
color: var(--text-light);
2583+
flex-shrink: 0;
2584+
2585+
&:not([href]) {
2586+
color: var(--body-fg);
2587+
font-weight: bold;
2588+
border-bottom: 3px solid var(--primary);
2589+
}
2590+
2591+
&[href]:focus,
2592+
&[href]:active,
2593+
&[href]:hover {
2594+
outline: none;
2595+
border-bottom: 3px solid var(--hairline-color);
2596+
}
2597+
}
2598+
}
2599+
25662600
.search-links {
25672601
@extend .list-links;
25682602

Diff for: docs/models.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,19 @@ def breadcrumbs(self, document):
244244
else:
245245
return self.none()
246246

247-
def search(self, query_text, release):
247+
def search(self, query_text, release, document_type=None):
248248
"""Use full-text search to return documents matching query_text."""
249249
query_text = query_text.strip()
250250
if query_text:
251251
search_query = SearchQuery(
252252
query_text, config=models.F("config"), search_type="websearch"
253253
)
254254
search_rank = SearchRank(models.F("search"), search_query)
255+
base_filter = Q(release_id=release.id)
256+
if document_type:
257+
base_filter = base_filter & Q(
258+
metadata__parents__startswith=document_type
259+
)
255260
base_qs = (
256261
self.prefetch_related(
257262
Prefetch(
@@ -262,7 +267,7 @@ def search(self, query_text, release):
262267
"release__release", queryset=Release.objects.only("version")
263268
),
264269
)
265-
.filter(release_id=release.id)
270+
.filter(base_filter)
266271
.annotate(
267272
headline=SearchHeadline(
268273
"title",

Diff for: docs/search.py

+8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib.postgres.search import SearchVector
22
from django.db.models import F
33
from django.db.models.fields.json import KeyTextTransform
4+
from django.utils.translation import gettext_lazy as _
45

56
# Imported from
67
# https://github.com/postgres/postgres/blob/REL_14_STABLE/src/bin/initdb/initdb.c#L659
@@ -51,3 +52,10 @@
5152

5253
START_SEL = "<mark>"
5354
STOP_SEL = "</mark>"
55+
56+
DOCUMENT_TYPES = {
57+
"ref": _("API Reference"),
58+
"topics": _("Using Django"),
59+
"howto": _("How-to guides"),
60+
"releases": _("Release notes"),
61+
}

Diff for: docs/templates/docs/search_results.html

+16
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111

1212
{% block body %}
1313
{% if query %}
14+
<search class="filters">
15+
<span id="search-filters" class="visually-hidden">{% translate "Filter the current search results by documentation type" %}</span>
16+
<a{% if not active_type %} aria-current="page"{% else %} href="{% querystring type=None page=None %}"{% endif %}>{% translate "All" %}</a>
17+
{% for type, type_title in document_types.items %}
18+
<a{% if active_type == type %} aria-current="page"{% else %} href="{% querystring type=type page=None %}"{% endif %}>{{ type_title }}</a>
19+
{% endfor %}
20+
</search>
1421
<h2>
1522
{% if release.is_dev %}
1623
{% blocktranslate count num_results=paginator.count trimmed %}
@@ -49,6 +56,15 @@ <h2 class="result-title">
4956
…&nbsp;{{ result.highlight|cut:"¶"|safe }}&nbsp;…
5057
{% endif %}
5158
</dd>
59+
{% empty %}
60+
{% if active_type %}
61+
<dt>
62+
<p>
63+
{% querystring type=None page=None as all_search %}
64+
{% blocktranslate trimmed %}Please try searching <a href="{{ all_search }}">all documentation results</a>.{% endblocktranslate %}
65+
</p>
66+
</dt>
67+
{% endif %}
5268
{% endfor %}
5369
</dl>
5470
</div>

Diff for: docs/tests.py

+87-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from releases.models import Release
1919

2020
from .models import DOCUMENT_SEARCH_VECTOR, Document, DocumentRelease
21+
from .search import DOCUMENT_TYPES
2122
from .sitemaps import DocsSitemap
2223
from .templatetags.docs import generate_scroll_to_text_fragment, get_all_doc_versions
2324
from .utils import get_doc_path, sanitize_for_trigram
@@ -179,9 +180,32 @@ def test_internals_team(self):
179180
class SearchFormTestCase(TestCase):
180181
fixtures = ["doc_test_fixtures"]
181182

182-
def setUp(self):
183+
@classmethod
184+
def setUpTestData(cls):
183185
# We need to create an extra Site because docs have SITE_ID=2
184186
Site.objects.create(name="Django test", domain="example2.com")
187+
cls.release = Release.objects.create(version="5.1")
188+
cls.doc_release = DocumentRelease.objects.create(release=cls.release)
189+
cls.active_filter = '<a aria-current="page">'
190+
191+
for doc_type, title in DOCUMENT_TYPES.items():
192+
Document.objects.create(
193+
**{
194+
"metadata": {
195+
"body": "Generic Views",
196+
"breadcrumbs": [
197+
{"path": doc_type, "title": str(title)},
198+
],
199+
"parents": doc_type,
200+
"slug": "generic-views",
201+
"title": "Generic views",
202+
"toc": '<ul>\n<li><a class="reference internal" href="#">Generic views</a></li>\n</ul>\n',
203+
},
204+
"path": f"{doc_type}/generic-views",
205+
"release": cls.doc_release,
206+
"title": "Generic views",
207+
}
208+
)
185209

186210
@classmethod
187211
def tearDownClass(cls):
@@ -195,6 +219,68 @@ def test_empty_get(self):
195219
)
196220
self.assertEqual(response.status_code, 200)
197221

222+
def test_search_type_filter_all(self):
223+
response = self.client.get(
224+
"/en/5.1/search/?q=generic",
225+
headers={"host": "docs.djangoproject.localhost:8000"},
226+
)
227+
self.assertEqual(response.status_code, 200)
228+
self.assertContains(
229+
response, "4 results for <em>generic</em> in version 5.1", html=True
230+
)
231+
self.assertContains(response, self.active_filter, count=1)
232+
self.assertContains(response, f"{self.active_filter}All</a>", html=True)
233+
234+
def test_search_type_filter_by_doc_types(self):
235+
for doc_type, title in DOCUMENT_TYPES.items():
236+
with self.subTest(type=doc_type):
237+
response = self.client.get(
238+
f"/en/5.1/search/?q=generic&type={doc_type}",
239+
headers={"host": "docs.djangoproject.localhost:8000"},
240+
)
241+
self.assertEqual(response.status_code, 200)
242+
self.assertContains(
243+
response,
244+
"Only 1 result for <em>generic</em> in version 5.1",
245+
html=True,
246+
)
247+
self.assertContains(response, self.active_filter, count=1)
248+
self.assertContains(
249+
response, f"{self.active_filter}{title}</a>", html=True
250+
)
251+
self.assertContains(response, '<a href="?q=generic">All</a>', html=True)
252+
253+
def test_search_type_filter_invalid_doc_types(self):
254+
response = self.client.get(
255+
"/en/5.1/search/?q=generic&type=invalid-so-ignored",
256+
headers={"host": "docs.djangoproject.localhost:8000"},
257+
)
258+
self.assertEqual(response.status_code, 200)
259+
self.assertContains(
260+
response, "4 results for <em>generic</em> in version 5.1", html=True
261+
)
262+
self.assertContains(response, self.active_filter, count=1)
263+
self.assertContains(response, f"{self.active_filter}All</a>", html=True)
264+
265+
def test_search_type_filter_no_results(self):
266+
response = self.client.get(
267+
"/en/5.1/search/?q=potato&type=ref",
268+
headers={"host": "docs.djangoproject.localhost:8000"},
269+
)
270+
self.assertEqual(response.status_code, 200)
271+
self.assertContains(response, self.active_filter, count=1)
272+
self.assertContains(
273+
response, f"{self.active_filter}API Reference</a>", html=True
274+
)
275+
self.assertContains(
276+
response, "0 results for <em>potato</em> in version 5.1", html=True
277+
)
278+
self.assertContains(
279+
response,
280+
'Please try searching <a href="?q=potato">all documentation results</a>.',
281+
html=True,
282+
)
283+
198284

199285
class TemplateTagTests(TestCase):
200286
fixtures = ["doc_test_fixtures"]

Diff for: docs/views.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from .forms import DocSearchForm
1818
from .models import Document, DocumentRelease
19-
from .search import START_SEL
19+
from .search import DOCUMENT_TYPES, START_SEL
2020
from .utils import get_doc_path_or_404, get_doc_root_or_404
2121

2222
SIMPLE_SEARCH_OPERATORS = ["+", "|", "-", '"', "*", "(", ")", "~"]
@@ -188,7 +188,9 @@ def search_results(request, lang, version, per_page=10, orphans=3):
188188
if exact is not None:
189189
return redirect(exact)
190190

191-
results = Document.objects.search(q, release)
191+
type_key = request.GET.get("type")
192+
type_key = type_key if type_key in DOCUMENT_TYPES.keys() else None
193+
results = Document.objects.search(q, release, document_type=type_key)
192194

193195
page_number = request.GET.get("page") or 1
194196
paginator = Paginator(results, per_page=per_page, orphans=orphans)
@@ -217,6 +219,9 @@ def search_results(request, lang, version, per_page=10, orphans=3):
217219
"page": page,
218220
"paginator": paginator,
219221
"start_sel": START_SEL,
222+
"active_type": type_key,
223+
"active_type_title": DOCUMENT_TYPES.get(type_key),
224+
"document_types": DOCUMENT_TYPES,
220225
}
221226
)
222227

0 commit comments

Comments
 (0)