Skip to content

Added documentation type filtering to search. #1935

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions djangoproject/scss/_style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2578,6 +2578,40 @@ table.docutils th {
}
}

search.filters {
@include sans-serif;

display: flex;
gap: 10px;
border-bottom: 2px solid var(--hairline-color);
overflow-x: auto;
white-space: nowrap;
padding-bottom: 0;
position: relative;

a {
padding: 10px 20px;
text-decoration: none;
border-bottom: 3px solid transparent;
transition: color 0.3s ease, border-bottom 0.3s ease;
color: var(--text-light);
flex-shrink: 0;

&:not([href]) {
color: var(--body-fg);
font-weight: bold;
border-bottom: 3px solid var(--primary);
}

&[href]:focus,
&[href]:active,
&[href]:hover {
outline: none;
border-bottom: 3px solid var(--hairline-color);
}
}
}

.search-links {
@extend .list-links;

Expand Down
7 changes: 5 additions & 2 deletions docs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def breadcrumbs(self, document):
else:
return self.none()

def search(self, query_text, release):
def search(self, query_text, release, document_category=None):
"""Use full-text search to return documents matching query_text."""
query_text = query_text.strip()
if query_text:
Expand All @@ -268,9 +268,12 @@ def search(self, query_text, release):
stop_sel=STOP_SEL,
config=models.F("config"),
)
base_filter = Q(release_id=release.id)
if document_category:
base_filter &= Q(metadata__parents__startswith=document_category)
base_qs = (
self.select_related("release__release")
.filter(release_id=release.id)
.filter(base_filter)
.annotate(
headline=search("title", search_query),
highlight=search(
Expand Down
23 changes: 22 additions & 1 deletion docs/search.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.contrib.postgres.search import SearchVector
from django.db.models import F
from django.db.models import F, TextChoices
from django.db.models.fields.json import KeyTextTransform
from django.utils.translation import gettext_lazy as _

# Imported from
# https://github.com/postgres/postgres/blob/REL_14_STABLE/src/bin/initdb/initdb.c#L659
Expand Down Expand Up @@ -51,3 +52,23 @@

START_SEL = "<mark>"
STOP_SEL = "</mark>"


class DocumentationCategory(TextChoices):
"""
Categories used to filter the documentation search.
The value must match a folder name within django/docs.
"""

# Diátaxis folders.
REFERENCE = "ref", _("API Reference")
TOPICS = "topics", _("Using Django")
HOWTO = "howto", _("How-to guides")
RELEASE_NOTES = "releases", _("Release notes")

@classmethod
def parse(cls, value, default=None):
try:
return cls(value)
except ValueError:
return None
19 changes: 10 additions & 9 deletions docs/templates/docs/search_form.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{% load i18n %}
<search class="search form-input" aria-labelledby="docs-search-label">
<form action="{% url 'document-search' version=version lang=lang host 'docs' %}">
<label id="docs-search-label" class="visuallyhidden" for="{{ form.q.id_for_label }}">{{ form.q.field.widget.attrs.placeholder }}</label>
{{ form.q }}

<form action="{% url 'document-search' version=version lang=lang host 'docs' %}" class="search form-input" role="search">
<label class="visuallyhidden" for="id_q">Search:</label>
{{ form.q }}

<button type="submit">
<i class="icon icon-search"></i>
<span class="visuallyhidden">{% trans 'Search' %}</span>
</button>
</form>
<button type="submit">
<i class="icon icon-search" aria-hidden="true"></i>
<span class="visuallyhidden">{% translate "Submit" %}</span>
</button>
</form>
</search>
40 changes: 28 additions & 12 deletions docs/templates/docs/search_results.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% extends "docs/doc.html" %}
{% load i18n docs %}

{% block title %}{% trans "Search | Django documentation" %}{% endblock %}
{% block title %}{% translate "Search | Django documentation" %}{% endblock %}

{% block toc-wrapper %}{% endblock %}
{% block breadcrumbs-wrapper %}{% endblock %}
Expand All @@ -11,23 +11,30 @@

{% block body %}
{% if query %}
<search class="filters">
<span id="search-filters" class="visually-hidden">{% translate "Filter the current search results by documentation category" %}</span>
<a{% if not active_category %} aria-current="page"{% else %} href="{% querystring category=None page=None %}"{% endif %}>{% translate "All" context "all documentation categories" %}</a>
{% for category in DocumentationCategory %}
<a{% if active_category == category %} aria-current="page"{% else %} href="{% querystring category=category.value page=None %}"{% endif %}>{{ category.label }}</a>
{% endfor %}
</search>
<h2>
{% if release.is_dev %}
{% blocktrans count num_results=paginator.count trimmed %}
{% blocktranslate count num_results=paginator.count trimmed %}
Only 1 result for <em>{{ query }}</em> in the development version
{% plural %}
{{ num_results }} results for <em>{{ query }}</em> in the development version
{% endblocktrans %}
{% endblocktranslate %}
{% else %}
{% blocktrans count num_results=paginator.count trimmed %}
{% blocktranslate count num_results=paginator.count trimmed %}
Only 1 result for <em>{{ query }}</em> in version {{ version }}
{% plural %}
{{ num_results }} results for <em>{{ query }}</em> in version {{ version }}
{% endblocktrans %}
{% endblocktranslate %}
{% endif %}
</h2>
{% else %}
<h2>{% trans "No search query given" %}</h2>
<h2>{% translate "No search query given" %}</h2>
{% endif %}

{% if query %}
Expand Down Expand Up @@ -64,6 +71,15 @@ <h2 class="result-title">
</ul>
{% endif %}
</dd>
{% empty %}
{% if active_category %}
<dt>
<p>
{% querystring category=None page=None as all_search %}
{% blocktranslate trimmed %}Please try searching <a href="{{ all_search }}">all documentation results</a>.{% endblocktranslate %}
</p>
</dt>
{% endif %}
{% endfor %}
</dl>
</div>
Expand All @@ -72,20 +88,20 @@ <h2 class="result-title">
<div class="pagination">
<ul class="nav-pagination" role="navigation">
{% if page.has_previous %}
<li><a rel="prev" class="previous" href="?q={{ query }}&amp;release={{ release.version }}&amp;page={{ page.previous_page_number }}">
<li><a rel="prev" class="previous" href="{% querystring page=page.previous_page_number %}">
<i class="icon icon-chevron-left"></i>
<span class="visuallyhidden">{% trans "Previous" %}</span>
<span class="visuallyhidden">{% translate "Previous" context "pagination" %}</span>
</a></li>
{% endif %}
<span class="page-current">
{% blocktrans with page_number=page.number num_pages=page.paginator.num_pages trimmed %}
{% blocktranslate with page_number=page.number num_pages=page.paginator.num_pages trimmed %}
Page {{ page_number }} of {{ num_pages }}
{% endblocktrans %}
{% endblocktranslate %}
</span>
{% if page.has_next %}
<li><a rel="next" class="next" href="?q={{ query }}&amp;release={{ release.version }}&amp;page={{ page.next_page_number }}">
<li><a rel="next" class="next" href="{% querystring page=page.next_page_number %}">
<i class="icon icon-chevron-right"></i>
<span class="visuallyhidden">{% trans "Next" %}</span>
<span class="visuallyhidden">{% translate "Next" context "pagination" %}</span>
</a></li>
{% endif %}
</ul>
Expand Down
86 changes: 86 additions & 0 deletions docs/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from releases.models import Release

from ..models import Document, DocumentRelease
from ..search import DocumentationCategory
from ..sitemaps import DocsSitemap


Expand Down Expand Up @@ -47,6 +48,29 @@ def setUpTestData(cls):
Site.objects.create(name="Django test", domain="example2.com")
cls.release = Release.objects.create(version="5.1")
cls.doc_release = DocumentRelease.objects.create(release=cls.release)
cls.active_filter = '<a aria-current="page">'

for category in DocumentationCategory:
Document.objects.create(
**{
"metadata": {
"body": "Generic Views",
"breadcrumbs": [
{"path": category.value, "title": str(category.label)},
],
"parents": category.value,
"slug": "generic-views",
"title": "Generic views",
"toc": (
'<ul>\n<li><a class="reference internal" href="#">'
"Generic views</a></li>\n</ul>\n"
),
},
"path": f"{category.value}/generic-views",
"release": cls.doc_release,
"title": "Generic views",
}
)

@classmethod
def tearDownClass(cls):
Expand All @@ -60,6 +84,68 @@ def test_empty_get(self):
)
self.assertEqual(response.status_code, 200)

def test_search_type_filter_all(self):
response = self.client.get(
"/en/5.1/search/?q=generic",
headers={"host": "docs.djangoproject.localhost:8000"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(
response, "4 results for <em>generic</em> in version 5.1", html=True
)
self.assertContains(response, self.active_filter, count=1)
self.assertContains(response, f"{self.active_filter}All</a>", html=True)

def test_search_type_filter_by_doc_types(self):
for category in DocumentationCategory:
with self.subTest(category=category):
response = self.client.get(
f"/en/5.1/search/?q=generic&category={category.value}",
headers={"host": "docs.djangoproject.localhost:8000"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(
response,
"Only 1 result for <em>generic</em> in version 5.1",
html=True,
)
self.assertContains(response, self.active_filter, count=1)
self.assertContains(
response, f"{self.active_filter}{category.label}</a>", html=True
)
self.assertContains(response, '<a href="?q=generic">All</a>', html=True)

def test_search_category_filter_invalid_doc_categories(self):
response = self.client.get(
"/en/5.1/search/?q=generic&category=invalid-so-ignored",
headers={"host": "docs.djangoproject.localhost:8000"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(
response, "4 results for <em>generic</em> in version 5.1", html=True
)
self.assertContains(response, self.active_filter, count=1)
self.assertContains(response, f"{self.active_filter}All</a>", html=True)

def test_search_category_filter_no_results(self):
response = self.client.get(
"/en/5.1/search/?q=potato&category=ref",
headers={"host": "docs.djangoproject.localhost:8000"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.active_filter, count=1)
self.assertContains(
response, f"{self.active_filter}API Reference</a>", html=True
)
self.assertContains(
response, "0 results for <em>potato</em> in version 5.1", html=True
)
self.assertContains(
response,
'Please try searching <a href="?q=potato">all documentation results</a>.',
html=True,
)

def test_code_links(self):
queryset_data = {
"metadata": {
Expand Down
9 changes: 7 additions & 2 deletions docs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from .forms import DocSearchForm
from .models import Document, DocumentRelease
from .search import START_SEL
from .search import START_SEL, DocumentationCategory
from .utils import get_doc_path_or_404, get_doc_root_or_404

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

results = Document.objects.search(q, release)
doc_category = DocumentationCategory.parse(request.GET.get("category"))
results = Document.objects.search(
q, release, document_category=doc_category
)

page_number = request.GET.get("page") or 1
paginator = Paginator(results, per_page=per_page, orphans=orphans)
Expand Down Expand Up @@ -192,6 +195,8 @@ def search_results(request, lang, version, per_page=10, orphans=3):
"page": page,
"paginator": paginator,
"start_sel": START_SEL,
"active_category": doc_category,
"DocumentationCategory": DocumentationCategory,
}
)

Expand Down