Skip to content

Commit 5cfbd2c

Browse files
authored
Story 2305: Implement Version Dropdown On V3 Nav Bar (boostorg#2349)
1 parent 570c3ba commit 5cfbd2c

13 files changed

Lines changed: 621 additions & 40 deletions

File tree

config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
"django.contrib.auth.context_processors.auth",
180180
"django.contrib.messages.context_processors.messages",
181181
"core.context_processors.current_version",
182+
"core.context_processors.selected_version",
182183
"core.context_processors.active_nav_item",
183184
"core.context_processors.header_context",
184185
"core.context_processors.debug",

config/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
VersionDetail,
102102
ReportPreviewView,
103103
ReportPreviewGenerateView,
104+
set_version,
104105
)
105106

106107
djdt_urls = []
@@ -114,7 +115,7 @@
114115
"DEBUG_TOOLBAR enabled but Django Debug Toolbar not installed. Run `just build`"
115116
)
116117

117-
register_converter(BoostVersionSlugConverter, "boostversionslug")
118+
register_converter(BoostVersionSlugConverter, BoostVersionSlugConverter.URL_TYPE_NAME)
118119

119120
router = routers.SimpleRouter()
120121

@@ -218,6 +219,7 @@
218219
name="boost-development",
219220
),
220221
# Boost versions views
222+
path("set-version/", set_version, name="set-version"),
221223
path("releases/", VersionDetail.as_view(), name="releases-most-recent"),
222224
path(
223225
"releases/boost-in-progress/",

core/context_processors.py

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,171 @@
22
from enum import StrEnum
33

44
from django.conf import settings
5-
from django.urls import reverse
5+
from django.urls import NoReverseMatch, reverse
66

7+
from libraries.constants import LATEST_RELEASE_URL_PATH_STR
8+
from libraries.utils import get_version_from_cookie
9+
from versions.converters import BoostVersionSlugConverter
710
from versions.models import Version
811

912

13+
_BOOST_VERSION_SLUG_ROUTE_TOKEN = (
14+
f"<{BoostVersionSlugConverter.URL_TYPE_NAME}:version_slug>"
15+
)
16+
17+
18+
def _get_header_version_data(request):
19+
"""Per-request shared accessor so `current_version` and `selected_version`
20+
share one DB fetch within a single request."""
21+
cached = getattr(request, "_header_version_data", None)
22+
if cached is not None:
23+
return cached
24+
data = Version.objects.get_header_dropdown_data()
25+
request._header_version_data = data
26+
return data
27+
28+
29+
def _is_non_latest_version(url_version_slug, cookie_slug):
30+
"""Whether the user's active version is a specific, non-latest one.
31+
32+
Drives the dropdown button label: non-latest → show version number,
33+
otherwise → show "Latest". URL wins over cookie.
34+
"""
35+
if url_version_slug:
36+
return url_version_slug != LATEST_RELEASE_URL_PATH_STR
37+
if cookie_slug:
38+
return cookie_slug != LATEST_RELEASE_URL_PATH_STR
39+
return False
40+
41+
1042
def current_version(request):
1143
"""Custom context processor that adds the current release to the context"""
12-
return {"current_version": Version.objects.most_recent()}
44+
return {"current_version": _get_header_version_data(request).most_recent}
45+
46+
47+
def selected_version(request):
48+
"""User's active Boost version + data to render the navbar version dropdown.
49+
50+
Resolution priority for `selected_version`:
51+
1. URL `version_slug` kwarg (on `/releases/`, `/libraries/`, `/library/` routes)
52+
2. `boost_version` cookie
53+
3. Most recent release (fallback)
54+
55+
When the version comes from the URL, the dropdown renders anchor links that
56+
swap the version segment of the current path — shareable. Otherwise it
57+
renders POST forms that write the cookie without navigating.
58+
59+
Examples
60+
--------
61+
GET /releases/1.88.0/ (no cookie)
62+
selected_version -> Version(slug="boost-1-88-0")
63+
selected_version_is_url_driven -> True (URL mode: render <a>s)
64+
selected_version_is_non_latest -> True (button reads "1.88.0")
65+
selected_version_label -> "1.88.0"
66+
version_dropdown_options[i] -> Version with `.href` attached,
67+
e.g. "/releases/1.89.0/"
68+
latest_href -> "/releases/latest/"
69+
70+
GET / (cookie boost_version="boost-1-87-0")
71+
selected_version -> Version(slug="boost-1-87-0")
72+
selected_version_is_url_driven -> False (cookie mode: render <form>s)
73+
selected_version_is_non_latest -> True (button reads "1.87.0")
74+
selected_version_label -> "1.87.0"
75+
version_dropdown_options[i] -> Version (no `.href` needed)
76+
latest_href -> ""
77+
78+
GET / (no cookie)
79+
selected_version -> Version.objects.most_recent()
80+
selected_version_is_url_driven -> False
81+
selected_version_is_non_latest -> False (button reads "Latest")
82+
selected_version_label -> "Latest"
83+
84+
GET /library/1.88.0/foo/ (target version "boost-1-70-0" predates "foo")
85+
version_dropdown_options[…].href for that version
86+
-> "/library/1.70.0/foo/"
87+
(plain version swap; target view
88+
renders the missing-version state)
89+
"""
90+
url_version_slug = None
91+
resolver_match = getattr(request, "resolver_match", None)
92+
if resolver_match and _BOOST_VERSION_SLUG_ROUTE_TOKEN in (
93+
resolver_match.route or ""
94+
):
95+
url_version_slug = resolver_match.kwargs.get("version_slug")
96+
97+
is_url_driven = bool(url_version_slug)
98+
cookie_slug = get_version_from_cookie(request)
99+
100+
header_data = _get_header_version_data(request)
101+
options = list(header_data.options)
102+
103+
resolved_slug = url_version_slug or cookie_slug
104+
version = None
105+
if resolved_slug and resolved_slug != LATEST_RELEASE_URL_PATH_STR:
106+
version = next((v for v in options if v.slug == resolved_slug), None)
107+
if version is None:
108+
version = Version.objects.filter(slug=resolved_slug).first()
109+
if version is None:
110+
version = header_data.most_recent
111+
112+
is_non_latest = _is_non_latest_version(url_version_slug, cookie_slug)
113+
label = version.display_name if (is_non_latest and version) else "Latest"
114+
115+
latest_href = ""
116+
if is_url_driven and resolver_match and resolver_match.view_name:
117+
latest_href = _annotate_option_hrefs(
118+
view_name=resolver_match.view_name,
119+
url_kwargs=dict(resolver_match.kwargs),
120+
options=options,
121+
)
122+
123+
return {
124+
"selected_version": version,
125+
"selected_version_is_url_driven": is_url_driven,
126+
"selected_version_is_non_latest": is_non_latest,
127+
"selected_version_label": label,
128+
"version_dropdown_options": options,
129+
"latest_href": latest_href,
130+
}
131+
132+
133+
def _annotate_option_hrefs(*, view_name, url_kwargs, options):
134+
"""Attach `.href` to each option for the current page; return the latest's.
135+
136+
The swap is a plain `reverse()` with the option's version substituted in —
137+
on a library detail page at `/library/1.88.0/foo/`, picking 1.70.0 yields
138+
`/library/1.70.0/foo/` even if that library didn't exist in 1.70.0. The
139+
target view is responsible for rendering the empty / "not available" state.
140+
141+
Examples
142+
--------
143+
Current URL /releases/1.88.0/
144+
view_name = "release-detail"
145+
url_kwargs = {"version_slug": "boost-1-88-0"}
146+
→ each option gets .href = "/releases/<that version>/"
147+
→ returns "/releases/latest/"
148+
149+
Current URL /libraries/1.88.0/grid/containers/
150+
view_name = "libraries-list"
151+
url_kwargs = {"version_slug": "boost-1-88-0",
152+
"library_view_str": "grid",
153+
"category_slug": "containers"}
154+
→ each option gets .href = "/libraries/<that version>/grid/containers/"
155+
→ returns "/libraries/latest/grid/containers/"
156+
"""
157+
for v in options:
158+
try:
159+
v.href = reverse(view_name, kwargs={**url_kwargs, "version_slug": v.slug})
160+
except NoReverseMatch:
161+
v.href = ""
162+
163+
try:
164+
return reverse(
165+
view_name,
166+
kwargs={**url_kwargs, "version_slug": LATEST_RELEASE_URL_PATH_STR},
167+
)
168+
except NoReverseMatch:
169+
return ""
13170

14171

15172
class NavItem(StrEnum):

core/tests/test_context_processors.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
from types import SimpleNamespace
2+
13
import pytest
24
from django.test import RequestFactory
5+
from django.urls import ResolverMatch
36

4-
from core.context_processors import current_version, active_nav_item
7+
from core.context_processors import (
8+
active_nav_item,
9+
current_version,
10+
selected_version,
11+
)
12+
from libraries.constants import SELECTED_BOOST_VERSION_COOKIE_NAME
13+
from versions.managers import HeaderVersionData
514

615

716
def test_current_version_context(
@@ -42,3 +51,99 @@ def test_active_nav_item(path, expected_nav_item):
4251
request = RequestFactory().get(path)
4352
context = active_nav_item(request)
4453
assert context["active_nav_item"] == expected_nav_item
54+
55+
56+
def _stub_header_data(request):
57+
most_recent = SimpleNamespace(slug="boost-1-88-0", display_name="1.88.0")
58+
older = SimpleNamespace(slug="boost-1-87-0", display_name="1.87.0")
59+
request._header_version_data = HeaderVersionData(
60+
options=[most_recent, older],
61+
most_recent=most_recent,
62+
most_recent_beta=None,
63+
)
64+
return most_recent, older
65+
66+
67+
def _attach_resolver(request, *, route, kwargs, view_name=""):
68+
request.resolver_match = ResolverMatch(
69+
func=lambda r: None,
70+
args=(),
71+
kwargs=kwargs,
72+
url_name=view_name or None,
73+
route=route,
74+
)
75+
76+
77+
def test_selected_version_url_driven_for_boost_route(rf):
78+
request = rf.get("/releases/1.88.0/")
79+
most_recent, older = _stub_header_data(request)
80+
_attach_resolver(
81+
request,
82+
route="releases/<boostversionslug:version_slug>/",
83+
kwargs={"version_slug": "boost-1-88-0"},
84+
view_name="release-detail",
85+
)
86+
ctx = selected_version(request)
87+
assert ctx["selected_version_is_url_driven"] is True
88+
assert ctx["selected_version_is_non_latest"] is True
89+
assert ctx["selected_version_label"] == "1.88.0"
90+
assert ctx["selected_version"] is most_recent
91+
assert ctx["latest_href"] == "/releases/latest/"
92+
assert most_recent.href == "/releases/1.88.0/"
93+
assert older.href == "/releases/1.87.0/"
94+
95+
96+
def test_selected_version_url_driven_with_latest_slug(rf):
97+
"""`/releases/latest/` is URL-driven but the label should still read
98+
'Latest' and the version should fall back to most_recent."""
99+
request = rf.get("/releases/latest/")
100+
most_recent, _ = _stub_header_data(request)
101+
_attach_resolver(
102+
request,
103+
route="releases/<boostversionslug:version_slug>/",
104+
kwargs={"version_slug": "latest"},
105+
view_name="release-detail",
106+
)
107+
ctx = selected_version(request)
108+
assert ctx["selected_version_is_url_driven"] is True
109+
assert ctx["selected_version_is_non_latest"] is False
110+
assert ctx["selected_version_label"] == "Latest"
111+
assert ctx["selected_version"] is most_recent
112+
113+
114+
def test_selected_version_cookie_driven(rf):
115+
"""Cookie mode: no URL slug, cookie picks the version, dropdown renders
116+
POST forms (no `latest_href`, no per-option `.href`)."""
117+
request = rf.get("/")
118+
request.COOKIES[SELECTED_BOOST_VERSION_COOKIE_NAME] = "boost-1-87-0"
119+
_, older = _stub_header_data(request)
120+
ctx = selected_version(request)
121+
assert ctx["selected_version_is_url_driven"] is False
122+
assert ctx["selected_version_is_non_latest"] is True
123+
assert ctx["selected_version_label"] == "1.87.0"
124+
assert ctx["selected_version"] is older
125+
assert ctx["latest_href"] == ""
126+
127+
128+
def test_selected_version_ignores_foreign_route_with_same_kwarg(rf):
129+
"""A route declaring `version_slug` via a different converter must NOT be
130+
treated as URL-driven — the guard against silent coupling."""
131+
request = rf.get("/elsewhere/anything/")
132+
_stub_header_data(request)
133+
_attach_resolver(
134+
request,
135+
route="elsewhere/<str:version_slug>/",
136+
kwargs={"version_slug": "anything"},
137+
view_name="some-other-view",
138+
)
139+
ctx = selected_version(request)
140+
assert ctx["selected_version_is_url_driven"] is False
141+
assert ctx["selected_version_label"] == "Latest"
142+
143+
144+
def test_selected_version_no_resolver_match(rf):
145+
request = rf.get("/")
146+
_stub_header_data(request)
147+
ctx = selected_version(request)
148+
assert ctx["selected_version_is_url_driven"] is False
149+
assert ctx["selected_version_label"] == "Latest"

news/tests/test_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ def test_news_create_get(tp, regular_user, url_name, form_class):
419419
# assertGoodView expects a resolved URL
420420
# see https://github.com/revsys/django-test-plus/issues/202
421421
url = tp.reverse(url_name)
422-
tp.assertGoodView(url, test_query_count=4, verbose=True)
422+
tp.assertGoodView(url, test_query_count=5, verbose=True)
423423

424424
form = tp.get_context("form")
425425
assert isinstance(form, form_class)

0 commit comments

Comments
 (0)