Skip to content

Commit 26e9888

Browse files
authored
Merge pull request #8099 from freelawproject/8076-v2-pages-count-page-views
feat(views tracker): v2 pages count page views
2 parents 081919e + e109bc1 commit 26e9888

5 files changed

Lines changed: 91 additions & 1 deletion

File tree

‎FRONTEND.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,22 @@ Swapped partials live under `v2_includes/` (see [Naming & middleware](#naming--m
159159

160160
A blocked evaluation fires `htmx:evalDisallowedError` on the element. Reference: https://htmx.org/reference/#config
161161

162+
## View counting
163+
164+
`new_base.html` counts a page view for any view decorated with `track_view_counter` (`cl/favorites/decorators.py`). The decorator sets `track_events` and `event_label` in the context; the base template puts the label and the `increment-event` endpoint on `<body>` as data attributes and requires `js/alpine/composables/view_count.js`, whose `viewCount` store POSTs the label once per page load. A new v2 page needs nothing beyond the decorator on its view. Counting is per page load and per the one object the view names in `tracks`; this mechanism does not count components or secondary objects.
165+
166+
To display the count, bind to the store from any Alpine root. The store only exists on tracked pages, so the element MUST be rendered under `track_events`:
167+
168+
```html
169+
{% if track_events %}<span x-text="$store.viewCount.value"></span>{% endif %}
170+
```
171+
172+
`value` is `null` until the response arrives and stays `null` when the request fails. Failures are logged to the console and never shown to the user. The endpoint returns the count before the current view, and 0 for recognized bots, so `value` excludes the view being recorded.
173+
174+
Cotton components rendered with `only` do not see the page context, so `track_events` is false inside them: pass it explicitly (`:track_events="track_events"`) or render the element from the page template.
175+
176+
Label prefixes are validated by `EventCountSerializer` (`cl/favorites/api_serializers.py`). Tracking a new object type needs a new pattern there, otherwise every request fails with a 400 that only the console reports.
177+
162178
## Icons
163179

164180
- Use the `{% svg %}` template tag (defined in `cl/custom_filters/templatetags/svg_tags.py`)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
Counts a page view and exposes the returned count as the `viewCount` store.
3+
Loaded by `new_base.html` for views decorated with `track_view_counter`;
4+
templates never require it. Usage and caveats: FRONTEND.md, "View counting".
5+
*/
6+
7+
document.addEventListener('alpine:init', () => {
8+
Alpine.store('viewCount', {
9+
value: null,
10+
async init() {
11+
const { viewCountLabel: label, viewCountUrl } = document.body.dataset;
12+
try {
13+
const response = await fetch(viewCountUrl, {
14+
method: 'POST',
15+
headers: { 'Content-Type': 'application/json' },
16+
body: JSON.stringify({ label }),
17+
});
18+
if (!response.ok) {
19+
console.error(`Could not count view for ${label}: HTTP ${response.status}`);
20+
return;
21+
}
22+
const data = await response.json();
23+
this.value = data.value;
24+
} catch (error) {
25+
console.error(`Could not count view for ${label}:`, error);
26+
}
27+
},
28+
});
29+
});

‎cl/assets/templates/new_base.html‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ <h1>You did not supply the "private" variable to your template.
7474
</head>
7575

7676
{# htmx elements inherit hx-headers, so every hx-post on a v2 page carries the CSRF token without repeating it. #}
77-
<body class="{% block body-classes %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
77+
<body class="{% block body-classes %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'{% if track_events %} data-view-count-label="{{ event_label }}" data-view-count-url="{% url 'increment-event-list' version='v4' %}"{% endif %}>
78+
{% if track_events %}{% require_script "js/alpine/composables/view_count.js" %}{% endif %}
7879
{% corpus_search_scopes as corpus_search_scopes_data %}
7980
{{ corpus_search_scopes_data|json_script:"corpus-search-scopes" }}
8081
{% block header %}

‎cl/opinion_page/tests.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4096,6 +4096,28 @@ async def test_v2_body_carries_the_csrf_header_for_htmx(self) -> None:
40964096
self.assertEqual(len(token), CSRF_TOKEN_LENGTH)
40974097
self.assertTrue(token.isalnum())
40984098

4099+
async def test_v2_docket_page_counts_views(self) -> None:
4100+
"""A view decorated with track_view_counter hands the body the event
4101+
label and the increment endpoint, and loads the viewCount store that
4102+
POSTs them once per page load."""
4103+
r = await self.async_client.get(
4104+
reverse("view_docket", args=[self.docket.pk, self.docket.slug])
4105+
)
4106+
self.assertTemplateUsed(r, "new_base.html")
4107+
html = r.content.decode()
4108+
body = next(fromstring(html).iter("body"))
4109+
self.assertEqual(
4110+
body.get("data-view-count-label"), f"d.{self.docket.pk}:view"
4111+
)
4112+
self.assertEqual(
4113+
body.get("data-view-count-url"),
4114+
reverse("increment-event-list", kwargs={"version": "v4"}),
4115+
)
4116+
self.assertRegex(
4117+
html,
4118+
r'<script[^>]*src="[^"]*js/alpine/composables/view_count\.js"',
4119+
)
4120+
40994121

41004122
@override_settings(WAFFLE_CACHE_PREFIX="test_docket_alert_toggle_v2_waffle")
41014123
@override_flag("use_new_design", active=True)

‎cl/simple_pages/tests.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,28 @@ def test_v2_pages_without_the_flag(self) -> None:
445445
)
446446

447447

448+
@override_flag("use_new_design", True)
449+
@override_settings(WAFFLE_CACHE_PREFIX="test_new_base_view_count_waffle")
450+
class NewBaseViewCountTest(TestCase):
451+
"""new_base.html only wires view counting for decorated views.
452+
453+
The tracked side is covered on the docket page in
454+
cl/opinion_page/tests.py; this checks the untracked side on a page
455+
whose view has no track_view_counter.
456+
"""
457+
458+
async def test_untracked_page_sends_no_view_count(self) -> None:
459+
"""A v2 page whose view is not decorated carries no label, no endpoint
460+
and no store, so the browser makes no request."""
461+
r = await self.async_client.get(reverse("help_home"))
462+
self.assertTemplateUsed(r, "new_base.html")
463+
html = r.content.decode()
464+
body = next(fromstring(html).iter("body"))
465+
self.assertIsNone(body.get("data-view-count-label"))
466+
self.assertIsNone(body.get("data-view-count-url"))
467+
self.assertNotIn("view_count.js", html)
468+
469+
448470
@patch("hcaptcha.fields.hCaptchaField.validate", return_value=True)
449471
class SealingOrderDetectionTest(SimpleTestCase):
450472
def _make_form(

0 commit comments

Comments
 (0)