Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
run: |
python -m pip install --upgrade pip setuptools
python -m pip install -r requirements/tests.txt
- name: Install playwright browsers
run: |
python -m playwright install --with-deps
- name: Set up databases
run: |
PGPASSWORD="postgres" createuser -U postgres -d djangoproject --superuser -h localhost
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ COPY ./requirements ./requirements
RUN apt-get update \
&& apt-get install --assume-yes --no-install-recommends ${BUILD_DEPENDENCIES} \
&& python3 -m pip install --no-cache-dir -r ${REQ_FILE} \
&& if [ "${REQ_FILE}" = "requirements/tests.txt" ]; then \
echo "Installing Playwright browsers..."; \
playwright install --with-deps; \
fi \
&& apt-get purge --assume-yes --auto-remove ${BUILD_DEPENDENCIES} \
&& apt-get distclean

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ APP_LIST ?= accounts aggregator blog contact dashboard djangoproject docs founda
SCSS = djangoproject/scss
STATIC = djangoproject/static

ci: compilemessages test
ci: compilemessages collectstatics test
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed because of the new JavaScript tests?

Copy link
Contributor Author

@sarahboyce sarahboyce Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes 👍
(I think this also mostly fixes #1831 but still need the tests to use the production STORAGES setting, can have someone check if that can be pulled into the common settings file 🤔)

@python -m coverage report

compilemessages:
Expand Down
14 changes: 12 additions & 2 deletions djangoproject/static/js/djangoproject.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,12 @@ document.querySelector('.menu-button').addEventListener('click', function () {

// Update search input placeholder text based on the user's operating system
(function () {
const el = document.getElementById('id_q');
const inputs = [
document.getElementById('id_desktop-q'),
document.getElementById('id_mobile-q'),
];

const el = inputs.find((el) => el.checkVisibility());
if (!el) {
return;
}
Expand All @@ -94,7 +98,13 @@ window.addEventListener('keydown', function (e) {

e.preventDefault();

const el = document.querySelector('#id_q');
const inputs = [
document.getElementById('id_desktop-q'),
document.getElementById('id_mobile-q'),
];

const el = inputs.find((el) => el.checkVisibility());
if (!el) return;

el.select();
el.focus();
Expand Down
5 changes: 3 additions & 2 deletions djangoproject/templates/includes/header.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% load docs %}
{% search_context as cached_search_context %}
{% if 'preview.djangoproject.com' in request.get_host %}
<div class="copy-banner" style="background: #fff78e; padding: 10px;"></div>
{% endif %}
Expand All @@ -7,7 +8,7 @@
<a class="logo" href="{% url 'homepage' %}">Django</a>
<p class="meta">The web framework for perfectionists with deadlines.</p>
<div class="header-mobile-only">
{% search_form %}
{% search_form prefix="mobile" cached_search_context=cached_search_context %}
<div class="light-dark-mode-toggle">
{% include "includes/toggle_theme.html" %}
</div>
Expand Down Expand Up @@ -47,7 +48,7 @@
<a href="{% url 'fundraising:index' %}">&#9829; Donate</a>
</li>
<li>
{% search_form %}
{% search_form prefix="desktop" cached_search_context=cached_search_context %}
</li>
<li>
{% include "includes/toggle_theme.html" %}
Expand Down
7 changes: 4 additions & 3 deletions djangoproject/templates/search_form.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{% load i18n %}
{% load i18n docs %}
<search class="search form-input" aria-labelledby="docs-search-label">
<form action="{% url 'document-search' version=version lang=lang host 'docs' %}">
<form action="{% url 'document-search' version=cached_search_context.version lang=cached_search_context.lang host 'docs' %}">
{% build_search_form cached_search_context.release prefix as form %}
<label id="docs-search-label" class="visuallyhidden" for="{{ form.q.id_for_label }}">{{ form.q.field.widget.attrs.placeholder }}</label>
{{ form.q }}
<input type="hidden" name="category" value="{{ active_category }}">
<input type="hidden" name="category" value="{{ cached_search_context.active_category }}">

<button type="submit">
<i class="icon icon-search" aria-hidden="true"></i>
Expand Down
70 changes: 70 additions & 0 deletions djangoproject/tests.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import os
from http import HTTPStatus
from io import StringIO

from django.conf import settings
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.core.management import call_command
from django.test import TestCase
from django.urls import NoReverseMatch, get_resolver
from django.utils.translation import activate, gettext as _
from django_hosts.resolvers import reverse
from playwright.sync_api import expect, sync_playwright

from docs.models import DocumentRelease, Release

Expand Down Expand Up @@ -211,3 +214,70 @@ def test_single_h1_per_page(self):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<h1", count=1)


class EndToEndTests(ReleaseMixin, StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
super().setUpClass()
cls.playwright = sync_playwright().start()
cls.browser = cls.playwright.chromium.launch()

@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.browser.close()
cls.playwright.stop()

def setUp(self):
self.setUpTestData()
self.mac_user_agent = "Mozilla/5.0 (Macintosh) AppleWebKit"
self.windows_user_agent = "Mozilla/5.0 (Windows NT 10.0)"
self.mobile_linux_user_agent = "Mozilla/5.0 (Linux; Android 10; Mobile)"

def test_search_ctrl_k_hotkey_desktop(self):
page = self.browser.new_page(user_agent=self.windows_user_agent)
page.goto(self.live_server_url)

mobile_search_bar = page.locator("#id_mobile-q")
desktop_search_bar = page.locator("#id_desktop-q")
self.assertFalse(mobile_search_bar.is_visible())
self.assertTrue(desktop_search_bar.is_visible())
expect(desktop_search_bar).to_have_attribute("placeholder", "Search (Ctrl+K)")
is_focused = page.evaluate("document.activeElement.id === 'id_desktop-q'")
self.assertFalse(is_focused)

page.keyboard.press("Control+KeyK")
is_focused = page.evaluate("document.activeElement.id === 'id_desktop-q'")
self.assertTrue(is_focused)
page.close()

def test_search_ctrl_k_hotkey_mobile(self):
page = self.browser.new_page(
user_agent=self.mobile_linux_user_agent,
viewport={"width": 375, "height": 812},
)
page.goto(self.live_server_url)

mobile_search_bar = page.locator("#id_mobile-q")
desktop_search_bar = page.locator("#id_desktop-q")
self.assertTrue(mobile_search_bar.is_visible())
expect(mobile_search_bar).to_have_attribute("placeholder", "Search (Ctrl+K)")
self.assertFalse(desktop_search_bar.is_visible())
is_focused = page.evaluate("document.activeElement.id === 'id_mobile-q'")
self.assertFalse(is_focused)

page.keyboard.press("Control+KeyK")
is_focused = page.evaluate("document.activeElement.id === 'id_mobile-q'")
self.assertTrue(is_focused)
page.close()

def test_search_placeholder_mac_mode(self):
page = self.browser.new_page(user_agent=self.mac_user_agent)
page.goto(self.live_server_url)

desktop_search_bar = page.locator("#id_desktop-q")
expect(desktop_search_bar).to_have_attribute("placeholder", "Search (⌘\u200aK)")

page.close()
16 changes: 8 additions & 8 deletions djangoproject/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@


class CachedLibrary(template.Library):
def cached_context_inclusion_tag(self, template_name, *, name=None):
def cached_context_simple_tag(self, name=None):
"""
Wraps @register.inclusion_tag(template_name, takes_context=True) to
automatically cache the returned context dictionary inside the
template's render_context for the duration of a single render pass.
Wraps @register.simple_tag(takes_context=True) to cache the returned
value inside the template's render_context during a single template
render pass.

This is useful when a tag may be rendered multiple times within the
same template and computing its context is expensive (e.g. due to
database queries).
same template and with an expensive computation (e.g. due to database
queries).
"""

def decorator(func):
tag_name = name or func.__name__

@self.inclusion_tag(template_name, takes_context=True, name=tag_name)
@self.simple_tag(takes_context=True, name=tag_name)
def wrapper(context, *args, **kwargs):
render_context = getattr(context, "render_context", None)
cache_key = f"{tag_name}_cached_context"
cache_key = f"{tag_name}_cached_value"

if render_context is not None and cache_key in render_context:
return render_context[cache_key]
Expand Down
10 changes: 10 additions & 0 deletions docs/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ def __init__(self, data=None, **kwargs):
"placeholder": search_label_placeholder,
}
)
# Give each form instance a unique HTML id for its search field, while keeping
# the name attribute the same. This allows multiple forms with different
# prefixes (e.g., mobile/desktop) to coexist on the same page without id
# collisions, but still submit the query parameter as ?q=... regardless of
# which form was used.
q_with_prefix = super().add_prefix("q")
self.fields["q"].widget.attrs["id"] = f"id_{q_with_prefix}"

def add_prefix(self, field_name):
return field_name
29 changes: 23 additions & 6 deletions docs/templatetags/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,36 @@
register = CachedLibrary()


@register.cached_context_inclusion_tag("search_form.html")
def search_form(context):
if "request" not in context:
@register.inclusion_tag("search_form.html", takes_context=True)
def search_form(context, *, prefix, cached_search_context=None):
return {
"prefix": prefix,
"request": context.get("request"),
"cached_search_context": cached_search_context,
}


@register.simple_tag(takes_context=True)
def build_search_form(context, release, prefix=None):
request = context.get("request")
return DocSearchForm(
request.GET if request else None, release=release, prefix=prefix
)


@register.cached_context_simple_tag()
def search_context(context):
if context.get("request") is None:
# Django's built-in error views (like django.views.defaults.server_error)
# render templates without attaching a RequestContext — meaning the 'request'
# variable is not present in the template context.
return {
"form": DocSearchForm(release=None),
"version": "dev",
"lang": settings.DEFAULT_LANGUAGE_CODE,
"release": None,
"active_category": "",
}

request = context["request"]
lang = context.get("lang", settings.DEFAULT_LANGUAGE_CODE)
active_category = context.get("active_category", "")

Expand All @@ -44,10 +61,10 @@ def search_form(context):
release = DocumentRelease.objects.select_related("release").current()

return {
"form": DocSearchForm(request.GET, release=release),
"version": release.version,
"lang": lang,
"active_category": active_category,
"release": release,
}


Expand Down
13 changes: 11 additions & 2 deletions docs/tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,11 @@ def test_search_form_renders_without_request_in_template(self):
Ensures the tag doesn't crash when rendered inside a template that
lacks a 'request' variable e.g. during Django's built-in error views.
"""
template = Template("{% load docs %}{% search_form %}")
template = Template(
"{% load docs %}"
"{% search_context as cached %}"
'{% search_form prefix="desktop" cached_search_context=cached %}'
)
rendered = template.render(Context({}))
self.assertIn(
'<search class="search form-input" aria-labelledby="docs-search-label">',
Expand All @@ -257,7 +261,12 @@ def test_search_form_queries_multiple_renders(self):
DocumentRelease.objects.create(
lang=settings.DEFAULT_LANGUAGE_CODE, release=r2, is_default=True
)
template = Template("{% load docs %}{% search_form %}{% search_form %}")
template = Template(
"{% load docs %}"
"{% search_context as cached %}"
'{% search_form prefix="mobile" cached_search_context=cached %}'
'{% search_form prefix="desktop" cached_search_context=cached %}'
)
with self.assertNumQueries(1):
rendered = template.render(Context({"request": Mock()}))

Expand Down
15 changes: 10 additions & 5 deletions docs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,12 @@ def search_results(request, lang, version, per_page=10, orphans=3):
activate(lang)

doc_category = DocumentationCategory.parse(request.GET.get("category"))
form = DocSearchForm(request.GET or None, release=release)

# Get available languages for the language switcher
available_languages = DocumentRelease.objects.get_available_languages_by_version(
version
)

context = {
"form": form,
"lang": release.lang,
"version": release.version,
"release": release,
Expand All @@ -162,8 +159,16 @@ def search_results(request, lang, version, per_page=10, orphans=3):
"active_category": doc_category or "",
}

if form.is_valid():
q = form.cleaned_data.get("q")
mobile_form = DocSearchForm(request.GET or None, release=release, prefix="mobile")
desktop_form = DocSearchForm(request.GET or None, release=release, prefix="desktop")
submitted_form = None
if desktop_form.is_valid():
submitted_form = desktop_form
elif mobile_form.is_valid():
submitted_form = mobile_form

if submitted_form:
q = submitted_form.cleaned_data.get("q")

if q:
# catch queries that are coming from browser search bars
Expand Down
1 change: 1 addition & 0 deletions requirements/tests.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
-r dev.txt
coverage==7.13.0
playwright==1.57.0
requests-mock==1.12.1
tblib>=3.0.0