Skip to content

Commit 2fb3de8

Browse files
sarahboycebmispelon
authored andcommitted
Added documentation type filtering to search.
Thank you to Paulo Melchiorre, Tom Carrick, Marijke Luttekes, and Baptiste Mispelon for the reviews.
1 parent b858e9a commit 2fb3de8

File tree

6 files changed

+170
-5
lines changed

6 files changed

+170
-5
lines changed

djangoproject/scss/_style.scss

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2578,6 +2578,40 @@ table.docutils th {
25782578
}
25792579
}
25802580

2581+
search.filters {
2582+
@include sans-serif;
2583+
2584+
display: flex;
2585+
gap: 10px;
2586+
border-bottom: 2px solid var(--hairline-color);
2587+
overflow-x: auto;
2588+
white-space: nowrap;
2589+
padding-bottom: 0;
2590+
position: relative;
2591+
2592+
a {
2593+
padding: 10px 20px;
2594+
text-decoration: none;
2595+
border-bottom: 3px solid transparent;
2596+
transition: color 0.3s ease, border-bottom 0.3s ease;
2597+
color: var(--text-light);
2598+
flex-shrink: 0;
2599+
2600+
&:not([href]) {
2601+
color: var(--body-fg);
2602+
font-weight: bold;
2603+
border-bottom: 3px solid var(--primary);
2604+
}
2605+
2606+
&[href]:focus,
2607+
&[href]:active,
2608+
&[href]:hover {
2609+
outline: none;
2610+
border-bottom: 3px solid var(--hairline-color);
2611+
}
2612+
}
2613+
}
2614+
25812615
.search-links {
25822616
@extend .list-links;
25832617

docs/models.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ def breadcrumbs(self, document):
254254
else:
255255
return self.none()
256256

257-
def search(self, query_text, release):
257+
def search(self, query_text, release, document_category=None):
258258
"""Use full-text search to return documents matching query_text."""
259259
query_text = query_text.strip()
260260
if query_text:
@@ -268,9 +268,12 @@ def search(self, query_text, release):
268268
stop_sel=STOP_SEL,
269269
config=models.F("config"),
270270
)
271+
base_filter = Q(release_id=release.id)
272+
if document_category:
273+
base_filter &= Q(metadata__parents__startswith=document_category)
271274
base_qs = (
272275
self.select_related("release__release")
273-
.filter(release_id=release.id)
276+
.filter(base_filter)
274277
.annotate(
275278
headline=search("title", search_query),
276279
highlight=search(

docs/search.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib.postgres.search import SearchVector
2-
from django.db.models import F
2+
from django.db.models import F, TextChoices
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,23 @@
5152

5253
START_SEL = "<mark>"
5354
STOP_SEL = "</mark>"
55+
56+
57+
class DocumentationCategory(TextChoices):
58+
"""
59+
Categories used to filter the documentation search.
60+
The value must match a folder name within django/docs.
61+
"""
62+
63+
# Diátaxis folders.
64+
REFERENCE = "ref", _("API Reference")
65+
TOPICS = "topics", _("Using Django")
66+
HOWTO = "howto", _("How-to guides")
67+
RELEASE_NOTES = "releases", _("Release notes")
68+
69+
@classmethod
70+
def parse(cls, value, default=None):
71+
try:
72+
return cls(value)
73+
except ValueError:
74+
return None

docs/templates/docs/search_results.html

Lines changed: 16 additions & 0 deletions
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 category" %}</span>
16+
<a{% if not active_category %} aria-current="page"{% else %} href="{% querystring category=None page=None %}"{% endif %}>{% translate "All" context "all documentation categories" %}</a>
17+
{% for category in DocumentationCategory %}
18+
<a{% if active_category == category %} aria-current="page"{% else %} href="{% querystring category=category.value page=None %}"{% endif %}>{{ category.label }}</a>
19+
{% endfor %}
20+
</search>
1421
<h2>
1522
{% if release.is_dev %}
1623
{% blocktranslate count num_results=paginator.count trimmed %}
@@ -64,6 +71,15 @@ <h2 class="result-title">
6471
</ul>
6572
{% endif %}
6673
</dd>
74+
{% empty %}
75+
{% if active_category %}
76+
<dt>
77+
<p>
78+
{% querystring category=None page=None as all_search %}
79+
{% blocktranslate trimmed %}Please try searching <a href="{{ all_search }}">all documentation results</a>.{% endblocktranslate %}
80+
</p>
81+
</dt>
82+
{% endif %}
6783
{% endfor %}
6884
</dl>
6985
</div>

docs/tests/test_views.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from releases.models import Release
1010

1111
from ..models import Document, DocumentRelease
12+
from ..search import DocumentationCategory
1213
from ..sitemaps import DocsSitemap
1314

1415

@@ -47,6 +48,29 @@ def setUpTestData(cls):
4748
Site.objects.create(name="Django test", domain="example2.com")
4849
cls.release = Release.objects.create(version="5.1")
4950
cls.doc_release = DocumentRelease.objects.create(release=cls.release)
51+
cls.active_filter = '<a aria-current="page">'
52+
53+
for category in DocumentationCategory:
54+
Document.objects.create(
55+
**{
56+
"metadata": {
57+
"body": "Generic Views",
58+
"breadcrumbs": [
59+
{"path": category.value, "title": str(category.label)},
60+
],
61+
"parents": category.value,
62+
"slug": "generic-views",
63+
"title": "Generic views",
64+
"toc": (
65+
'<ul>\n<li><a class="reference internal" href="#">'
66+
"Generic views</a></li>\n</ul>\n"
67+
),
68+
},
69+
"path": f"{category.value}/generic-views",
70+
"release": cls.doc_release,
71+
"title": "Generic views",
72+
}
73+
)
5074

5175
@classmethod
5276
def tearDownClass(cls):
@@ -60,6 +84,68 @@ def test_empty_get(self):
6084
)
6185
self.assertEqual(response.status_code, 200)
6286

87+
def test_search_type_filter_all(self):
88+
response = self.client.get(
89+
"/en/5.1/search/?q=generic",
90+
headers={"host": "docs.djangoproject.localhost:8000"},
91+
)
92+
self.assertEqual(response.status_code, 200)
93+
self.assertContains(
94+
response, "4 results for <em>generic</em> in version 5.1", html=True
95+
)
96+
self.assertContains(response, self.active_filter, count=1)
97+
self.assertContains(response, f"{self.active_filter}All</a>", html=True)
98+
99+
def test_search_type_filter_by_doc_types(self):
100+
for category in DocumentationCategory:
101+
with self.subTest(category=category):
102+
response = self.client.get(
103+
f"/en/5.1/search/?q=generic&category={category.value}",
104+
headers={"host": "docs.djangoproject.localhost:8000"},
105+
)
106+
self.assertEqual(response.status_code, 200)
107+
self.assertContains(
108+
response,
109+
"Only 1 result for <em>generic</em> in version 5.1",
110+
html=True,
111+
)
112+
self.assertContains(response, self.active_filter, count=1)
113+
self.assertContains(
114+
response, f"{self.active_filter}{category.label}</a>", html=True
115+
)
116+
self.assertContains(response, '<a href="?q=generic">All</a>', html=True)
117+
118+
def test_search_category_filter_invalid_doc_categories(self):
119+
response = self.client.get(
120+
"/en/5.1/search/?q=generic&category=invalid-so-ignored",
121+
headers={"host": "docs.djangoproject.localhost:8000"},
122+
)
123+
self.assertEqual(response.status_code, 200)
124+
self.assertContains(
125+
response, "4 results for <em>generic</em> in version 5.1", html=True
126+
)
127+
self.assertContains(response, self.active_filter, count=1)
128+
self.assertContains(response, f"{self.active_filter}All</a>", html=True)
129+
130+
def test_search_category_filter_no_results(self):
131+
response = self.client.get(
132+
"/en/5.1/search/?q=potato&category=ref",
133+
headers={"host": "docs.djangoproject.localhost:8000"},
134+
)
135+
self.assertEqual(response.status_code, 200)
136+
self.assertContains(response, self.active_filter, count=1)
137+
self.assertContains(
138+
response, f"{self.active_filter}API Reference</a>", html=True
139+
)
140+
self.assertContains(
141+
response, "0 results for <em>potato</em> in version 5.1", html=True
142+
)
143+
self.assertContains(
144+
response,
145+
'Please try searching <a href="?q=potato">all documentation results</a>.',
146+
html=True,
147+
)
148+
63149
def test_code_links(self):
64150
queryset_data = {
65151
"metadata": {

docs/views.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from .forms import DocSearchForm
1717
from .models import Document, DocumentRelease
18-
from .search import START_SEL
18+
from .search import START_SEL, DocumentationCategory
1919
from .utils import get_doc_path_or_404, get_doc_root_or_404
2020

2121
SIMPLE_SEARCH_OPERATORS = ["+", "|", "-", '"', "*", "(", ")", "~"]
@@ -163,7 +163,10 @@ def search_results(request, lang, version, per_page=10, orphans=3):
163163
if exact is not None:
164164
return redirect(exact)
165165

166-
results = Document.objects.search(q, release)
166+
doc_category = DocumentationCategory.parse(request.GET.get("category"))
167+
results = Document.objects.search(
168+
q, release, document_category=doc_category
169+
)
167170

168171
page_number = request.GET.get("page") or 1
169172
paginator = Paginator(results, per_page=per_page, orphans=orphans)
@@ -192,6 +195,8 @@ def search_results(request, lang, version, per_page=10, orphans=3):
192195
"page": page,
193196
"paginator": paginator,
194197
"start_sel": START_SEL,
198+
"active_category": doc_category,
199+
"DocumentationCategory": DocumentationCategory,
195200
}
196201
)
197202

0 commit comments

Comments
 (0)