fix(report): stop double-encoding project_team_pairs_json for pill cross-filter
Task 1 set context['project_team_pairs_json'] = json.dumps(pairs), then the template rendered it with |json_script — which also calls json.dumps on the value. Result was a JSON-encoded string-of-a-string in the <script id="projectTeamPairs"> tag, so JSON.parse() returned a string (not a list) and the pill-popover IIFE died on pairs.forEach(...). Symptom: all three filter pills clickable but unresponsive. Fix: pass the raw Python list; let |json_script own the serialisation (the established pattern for team_workers_map_json and the other *_json keys on the payroll dashboard). Tests updated to read the raw list from resp.context. Added an end-to-end regression test that extracts the rendered <script id="projectTeamPairs"> payload and asserts JSON.parse() would return a list (not a string) — catches any future regression of this class even if the test suite and the view drift apart. Verified in the browser: all three pill popovers now open on click and Choices.js lazy-initialises correctly for projects/teams. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6d2c72f6d1
commit
5c4162d2eb
@ -783,13 +783,14 @@ class InlineFiltersPairsContextTests(TestCase):
|
||||
log.workers.add(self.w)
|
||||
|
||||
def test_pairs_context_key_populated(self):
|
||||
import json as _json
|
||||
# The context value is a raw Python list of dicts; Django's
|
||||
# |json_script filter handles the single JSON serialisation at
|
||||
# template render time (no double-encoding).
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
url = reverse('generate_report')
|
||||
resp = self.client.get(url + '?from_month=2026-03&to_month=2026-04')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
pairs_json = resp.context['project_team_pairs_json']
|
||||
pairs = _json.loads(pairs_json)
|
||||
pairs = resp.context['project_team_pairs_json']
|
||||
# Each entry has both project_id and team_id
|
||||
for p in pairs:
|
||||
self.assertIn('project_id', p)
|
||||
@ -801,7 +802,6 @@ class InlineFiltersPairsContextTests(TestCase):
|
||||
|
||||
def test_pairs_excludes_null_project_or_team(self):
|
||||
"""Logs with null project or null team should not appear in pairs."""
|
||||
import json as _json
|
||||
# Add a log with team=None
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 2),
|
||||
@ -811,6 +811,39 @@ class InlineFiltersPairsContextTests(TestCase):
|
||||
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
|
||||
pairs = _json.loads(resp.context['project_team_pairs_json'])
|
||||
pairs = resp.context['project_team_pairs_json']
|
||||
# No pair should have team_id=None
|
||||
self.assertTrue(all(p['team_id'] is not None for p in pairs))
|
||||
|
||||
def test_pairs_renders_as_valid_json_in_template(self):
|
||||
"""End-to-end: the rendered HTML must contain a single, valid JSON
|
||||
array inside the <script id="projectTeamPairs"> tag — NOT a
|
||||
JSON-encoded string (which was the bug that broke all pill
|
||||
interactions before the context key was changed from
|
||||
`json.dumps(pairs)` to raw `pairs`)."""
|
||||
import json as _json
|
||||
import re
|
||||
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
|
||||
html = resp.content.decode('utf-8')
|
||||
|
||||
# Extract the JSON payload inside <script id="projectTeamPairs">...</script>
|
||||
match = re.search(
|
||||
r'<script id="projectTeamPairs"[^>]*>(.*?)</script>',
|
||||
html, re.DOTALL
|
||||
)
|
||||
self.assertIsNotNone(match, 'projectTeamPairs <script> tag missing')
|
||||
payload = match.group(1).strip()
|
||||
|
||||
# Must parse to a LIST, not a string.
|
||||
parsed = _json.loads(payload)
|
||||
self.assertIsInstance(parsed, list,
|
||||
"Double-encoded JSON regression: browser's JSON.parse "
|
||||
"would return a string here, killing pairs.forEach() in the "
|
||||
"pill-popover JS. See 2026-04-23 bugfix.")
|
||||
# And the list members must be dicts with project_id + team_id
|
||||
for entry in parsed:
|
||||
self.assertIsInstance(entry, dict)
|
||||
self.assertIn('project_id', entry)
|
||||
self.assertIn('team_id', entry)
|
||||
|
||||
@ -2404,7 +2404,14 @@ def generate_report(request):
|
||||
.values('project_id', 'team_id')
|
||||
.distinct()
|
||||
)
|
||||
context['project_team_pairs_json'] = json.dumps(pairs)
|
||||
# Pass the raw Python list — Django's |json_script filter does the single,
|
||||
# XSS-safe JSON serialization at template render time. Calling json.dumps()
|
||||
# here would double-encode: json_script would re-serialize the already-JSON
|
||||
# string, producing `"[{\"project_id\": 1, ...}]"` — a quoted string literal
|
||||
# that JSON.parse() returns as a string, breaking `pairs.forEach(...)` in
|
||||
# the pill-popover JS. Matches the pattern used for team_workers_map_json
|
||||
# and other *_json context keys on the payroll dashboard.
|
||||
context['project_team_pairs_json'] = pairs
|
||||
# Pass projects and teams so the "New Report" modal's dropdowns can
|
||||
# populate (same lists the Dashboard modal uses)
|
||||
context['projects'] = Project.objects.all().order_by('name')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user