JS: pill-popover interactive module + Choices.js CDN in report.html

Main interactive layer for the inline-filters feature. Appends two
blocks to report.html (inside {% block content %}, before the final
{% endblock %}):

1. Choices.js CDN <link> + <script> (admin-only gated, SRI-hashed) —
   moved here because Task 5 will delete _report_config_modal.html,
   which previously loaded the CDN. Keeping this on the report page
   directly means the pills stay functional after modal retirement.

2. A scoped IIFE that wires up the three filter pills into an
   interactive, state-managed UI:
   - Click pill -> open popover (lazy-inits Choices.js on first open)
   - Click outside / Esc / other pill -> close
   - OK commits popover's local edits into pending state; dirty pills
     get the orange outline + pulsing dot; Apply button slides in
   - Cross-filter: picking projects auto-removes now-invalid teams
     with toast notice ("Removed Team X — no logs on selected
     projects"), and vice versa. Scope = entire history.
   - Apply -> rebuilds querystring from pending state + navigates
     (full page reload, same URL scheme as the retired modal)
   - Reset -> reverts all pills to URL-current values

XSS-safe throughout: textContent and createElement; no innerHTML with
user data. Matches the pattern in base.html's work-log-payroll modal
from the Work-Log Payroll feature.

Graceful fallback: if Choices.js CDN fails to load, the module bails
early with a console warning; native <select multiple> still works
inside the popovers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-23 09:56:13 +02:00
parent b52ae47257
commit 6d2c72f6d1

View File

@ -525,4 +525,442 @@
"New Report" here opens the familiar config screen without
navigating away. -->
{% include 'core/_report_config_modal.html' %}
{# === CHOICES.JS CDN — admin-only === #}
{# Loaded here because the pill popovers enhance their <select multiple> #}
{# elements with Choices.js. Task 5 will delete _report_config_modal.html #}
{# which currently also loads this CDN; having it here keeps the pills #}
{# functional after retirement. Falls back to native multi-select if CDN #}
{# fails. SRI hashes match the ones used in the retired modal. #}
{% if user.is_staff or user.is_superuser %}
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css"
integrity="sha384-9oHz8X4XgvL+WkhPjPTMHviP0FM/eWUHWFmAVXKJ3PnbIK8Vi2ranPMgb0LZhaeQ"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"
integrity="sha384-9r5e85TmdjVjyjYzZAV3TG5A6tcrmD7JjNBGfT2r1wp9txUPttent/DMiMuOwRNG"
crossorigin="anonymous"
defer></script>
{% endif %}
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
{# Scoped IIFE; runs once on DOMContentLoaded. Manages popover open/close, #}
{# dirty state, cross-filter, Apply submission. XSS-safe: createElement + #}
{# textContent only (no innerHTML with user data). Matches CLAUDE.md pattern. #}
{% if user.is_staff or user.is_superuser %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- Bail if Choices.js isn't loaded (graceful fallback) ---
if (typeof Choices === 'undefined') {
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
return;
}
// --- Context from json_script tags (safe-decoded by browser JSON parser) ---
var pairsEl = document.getElementById('projectTeamPairs');
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
// URL state (strings from template) — cast to ints for comparison
function toIntArr(s) { return (JSON.parse(s || '[]') || []).map(function(v) { return parseInt(v, 10); }).filter(function(n) { return !isNaN(n); }); }
var urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
// --- Cross-filter lookup indices ---
var projectToTeams = {}, teamToProjects = {};
pairs.forEach(function(pair) {
var pid = pair.project_id, tid = pair.team_id;
if (!projectToTeams[pid]) projectToTeams[pid] = new Set();
if (!teamToProjects[tid]) teamToProjects[tid] = new Set();
projectToTeams[pid].add(tid);
teamToProjects[tid].add(pid);
});
// --- Pending state (starts = URL state; becomes dirty when user edits) ---
var pending = {
dateMode: null, fromMonth: null, toMonth: null, startDate: null, endDate: null,
projects: urlProjects.slice(),
teams: urlTeams.slice(),
};
var fromMonthInput = document.getElementById('popoverFromMonth');
var toMonthInput = document.getElementById('popoverToMonth');
var startDateInput = document.getElementById('popoverStartDate');
var endDateInput = document.getElementById('popoverEndDate');
// Initialise input values from URL query params
var qs = new URLSearchParams(window.location.search);
fromMonthInput.value = qs.get('from_month') || '';
toMonthInput.value = qs.get('to_month') || '';
startDateInput.value = qs.get('start_date') || '';
endDateInput.value = qs.get('end_date') || '';
var initialDateMode = (qs.get('start_date') || qs.get('end_date')) ? 'custom' : 'month';
document.getElementById('popDateModeMonth').checked = (initialDateMode === 'month');
document.getElementById('popDateModeCustom').checked = (initialDateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', initialDateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', initialDateMode !== 'custom');
pending.dateMode = initialDateMode;
pending.fromMonth = fromMonthInput.value;
pending.toMonth = toMonthInput.value;
pending.startDate = startDateInput.value;
pending.endDate = endDateInput.value;
var urlDate = {
mode: pending.dateMode,
fromMonth: pending.fromMonth, toMonth: pending.toMonth,
startDate: pending.startDate, endDate: pending.endDate,
};
// --- Choices.js instances (lazy init on first open) ---
var projectsChoices = null, teamsChoices = null;
// --- DOM refs ---
var pills = {
date: document.getElementById('filter-pill-date'),
projects: document.getElementById('filter-pill-projects'),
teams: document.getElementById('filter-pill-teams'),
};
var popovers = {
date: document.getElementById('popover-date'),
projects: document.getElementById('popover-projects'),
teams: document.getElementById('popover-teams'),
};
var labels = {
date: pills.date.querySelector('.filter-pill__label'),
projects: pills.projects.querySelector('.filter-pill__label'),
teams: pills.teams.querySelector('.filter-pill__label'),
};
var applyGroup = document.getElementById('apply-filters-group');
var applyBtn = document.getElementById('apply-filters-btn');
var resetBtn = document.getElementById('cancel-filters-btn');
var toastContainer = document.getElementById('filter-toast-container');
// === POPOVER OPEN / CLOSE ===
function closeAllPopovers(except) {
Object.keys(popovers).forEach(function(key) {
if (key !== except) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
}
});
}
function openPopover(key) {
closeAllPopovers(key);
var pop = popovers[key];
pop.hidden = false;
pills[key].setAttribute('aria-expanded', 'true');
// Lazy-init Choices.js
if (key === 'projects' && !projectsChoices) {
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (key === 'teams' && !teamsChoices) {
teamsChoices = new Choices(document.getElementById('popoverTeams'), {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
// Apply cross-filter to dropdown options (disable invalid ones)
if (key === 'projects') applyCrossFilter('projects');
if (key === 'teams') applyCrossFilter('teams');
}
// --- Pill click handlers ---
Object.keys(pills).forEach(function(key) {
pills[key].addEventListener('click', function(ev) {
ev.stopPropagation();
var isOpen = !popovers[key].hidden;
if (isOpen) {
popovers[key].hidden = true;
pills[key].setAttribute('aria-expanded', 'false');
} else {
openPopover(key);
}
});
});
// --- Click outside closes all ---
document.addEventListener('click', function(ev) {
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
});
// --- Esc closes all ---
document.addEventListener('keydown', function(ev) {
if (ev.key === 'Escape') closeAllPopovers();
});
// === POPOVER OK / CANCEL HANDLERS ===
document.querySelectorAll('.filter-popover').forEach(function(pop) {
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
if (pop.id === 'popover-date') commitDateFromInputs();
if (pop.id === 'popover-projects') commitProjectsFromChoices();
if (pop.id === 'popover-teams') commitTeamsFromChoices();
updateAllPillsDirty();
closeAllPopovers();
});
cancelBtn.addEventListener('click', function() {
if (pop.id === 'popover-date') revertDateInputs();
if (pop.id === 'popover-projects') revertProjectsChoices();
if (pop.id === 'popover-teams') revertTeamsChoices();
closeAllPopovers();
});
});
// === DATE MODE TOGGLE inside the date popover ===
document.getElementById('popDateModeMonth').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.remove('d-none');
document.getElementById('popoverCustomFields').classList.add('d-none');
});
document.getElementById('popDateModeCustom').addEventListener('change', function() {
document.getElementById('popoverMonthFields').classList.add('d-none');
document.getElementById('popoverCustomFields').classList.remove('d-none');
});
// === COMMIT / REVERT HELPERS ===
function commitDateFromInputs() {
pending.dateMode = document.getElementById('popDateModeCustom').checked ? 'custom' : 'month';
pending.fromMonth = fromMonthInput.value;
pending.toMonth = toMonthInput.value;
pending.startDate = startDateInput.value;
pending.endDate = endDateInput.value;
if (pending.dateMode === 'month' && pending.fromMonth && pending.toMonth) {
labels.date.textContent = humanMonth(pending.fromMonth) + ' ' + humanMonth(pending.toMonth);
} else if (pending.dateMode === 'custom' && pending.startDate && pending.endDate) {
labels.date.textContent = humanDate(pending.startDate) + ' ' + humanDate(pending.endDate);
}
}
function revertDateInputs() {
document.getElementById('popDateModeMonth').checked = (pending.dateMode === 'month');
document.getElementById('popDateModeCustom').checked = (pending.dateMode === 'custom');
document.getElementById('popoverMonthFields').classList.toggle('d-none', pending.dateMode !== 'month');
document.getElementById('popoverCustomFields').classList.toggle('d-none', pending.dateMode !== 'custom');
fromMonthInput.value = pending.fromMonth || '';
toMonthInput.value = pending.toMonth || '';
startDateInput.value = pending.startDate || '';
endDateInput.value = pending.endDate || '';
}
function commitProjectsFromChoices() {
if (!projectsChoices) return;
pending.projects = projectsChoices.getValue(true).map(function(v) { return parseInt(v, 10); });
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
// Cross-filter: any team that's no longer valid gets auto-removed
var invalidTeams = pending.teams.filter(function(tid) {
return pending.projects.length > 0 && !pending.projects.some(function(pid) {
return projectToTeams[pid] && projectToTeams[pid].has(tid);
});
});
if (invalidTeams.length > 0) {
pending.teams = pending.teams.filter(function(tid) { return invalidTeams.indexOf(tid) < 0; });
if (teamsChoices) rebuildChoicesSelection(teamsChoices, pending.teams);
invalidTeams.forEach(function(tid) {
var name = teamNameById(tid);
showToast('Removed ' + name + ' — no logs on selected projects');
});
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
}
}
function revertProjectsChoices() {
if (!projectsChoices) return;
rebuildChoicesSelection(projectsChoices, pending.projects);
}
function commitTeamsFromChoices() {
if (!teamsChoices) return;
pending.teams = teamsChoices.getValue(true).map(function(v) { return parseInt(v, 10); });
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
var invalidProjects = pending.projects.filter(function(pid) {
return pending.teams.length > 0 && !pending.teams.some(function(tid) {
return teamToProjects[tid] && teamToProjects[tid].has(pid);
});
});
if (invalidProjects.length > 0) {
pending.projects = pending.projects.filter(function(pid) { return invalidProjects.indexOf(pid) < 0; });
if (projectsChoices) rebuildChoicesSelection(projectsChoices, pending.projects);
invalidProjects.forEach(function(pid) {
var name = projectNameById(pid);
showToast('Removed ' + name + ' — no logs for selected teams');
});
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
}
}
function revertTeamsChoices() {
if (!teamsChoices) return;
rebuildChoicesSelection(teamsChoices, pending.teams);
}
// --- Rebuild a Choices.js widget's selection from a list of IDs ---
function rebuildChoicesSelection(instance, ids) {
instance.removeActiveItems();
var idStrs = ids.map(String);
if (idStrs.length > 0) {
instance.setChoiceByValue(idStrs);
}
}
// --- Update a pill's label text based on selected options ---
function updatePillLabel(key, allText, selectEl) {
var ids = (key === 'projects') ? pending.projects : pending.teams;
if (ids.length === 0) {
labels[key].textContent = allText;
return;
}
var names = ids.map(function(id) {
var opt = selectEl.querySelector('option[value="' + id + '"]');
return opt ? opt.textContent : String(id);
});
if (names.length === 1) labels[key].textContent = names[0];
else if (names.length === 2) labels[key].textContent = names.join(', ');
else labels[key].textContent = names[0] + ' + ' + (names.length - 1) + ' more';
}
// --- Apply cross-filter: disable invalid options in the just-opened popover ---
function applyCrossFilter(justOpened) {
if (justOpened === 'projects' && projectsChoices) {
if (pending.teams.length === 0) return; // no constraint
var validPids = new Set();
pending.teams.forEach(function(tid) {
if (teamToProjects[tid]) {
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
}
});
var sel = document.getElementById('popoverProjects');
Array.from(sel.options).forEach(function(opt) {
var pid = parseInt(opt.value, 10);
opt.disabled = !validPids.has(pid) && !pending.projects.includes(pid);
});
projectsChoices.destroy();
projectsChoices = new Choices(sel, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All projects (leave empty for all)',
});
}
if (justOpened === 'teams' && teamsChoices) {
if (pending.projects.length === 0) return;
var validTids = new Set();
pending.projects.forEach(function(pid) {
if (projectToTeams[pid]) {
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
}
});
var selT = document.getElementById('popoverTeams');
Array.from(selT.options).forEach(function(opt) {
var tid = parseInt(opt.value, 10);
opt.disabled = !validTids.has(tid) && !pending.teams.includes(tid);
});
teamsChoices.destroy();
teamsChoices = new Choices(selT, {
removeItemButton: true, shouldSort: false, placeholder: true,
placeholderValue: 'All teams (leave empty for all)',
});
}
}
// === DIRTY STATE ===
function isDateDirty() {
return pending.dateMode !== urlDate.mode ||
pending.fromMonth !== urlDate.fromMonth ||
pending.toMonth !== urlDate.toMonth ||
pending.startDate !== urlDate.startDate ||
pending.endDate !== urlDate.endDate;
}
function isProjectsDirty() {
return !sameIntSet(pending.projects, urlProjects);
}
function isTeamsDirty() {
return !sameIntSet(pending.teams, urlTeams);
}
function sameIntSet(a, b) {
if (a.length !== b.length) return false;
var sA = new Set(a), sB = new Set(b);
for (var v of sA) { if (!sB.has(v)) return false; }
return true;
}
function updateAllPillsDirty() {
pills.date.classList.toggle('filter-pill--dirty', isDateDirty());
pills.projects.classList.toggle('filter-pill--dirty', isProjectsDirty());
pills.teams.classList.toggle('filter-pill--dirty', isTeamsDirty());
var anyDirty = isDateDirty() || isProjectsDirty() || isTeamsDirty();
applyGroup.hidden = !anyDirty;
}
// === APPLY: rebuild querystring + navigate ===
applyBtn.addEventListener('click', function() {
var params = new URLSearchParams();
if (pending.dateMode === 'month') {
if (pending.fromMonth) params.append('from_month', pending.fromMonth);
if (pending.toMonth) params.append('to_month', pending.toMonth);
} else {
if (pending.startDate) params.append('start_date', pending.startDate);
if (pending.endDate) params.append('end_date', pending.endDate);
}
pending.projects.forEach(function(pid) { params.append('project', pid); });
pending.teams.forEach(function(tid) { params.append('team', tid); });
window.location = window.location.pathname + '?' + params.toString();
});
// === RESET: revert to URL state ===
resetBtn.addEventListener('click', function() {
pending.dateMode = urlDate.mode;
pending.fromMonth = urlDate.fromMonth;
pending.toMonth = urlDate.toMonth;
pending.startDate = urlDate.startDate;
pending.endDate = urlDate.endDate;
pending.projects = urlProjects.slice();
pending.teams = urlTeams.slice();
revertDateInputs();
if (projectsChoices) rebuildChoicesSelection(projectsChoices, pending.projects);
if (teamsChoices) rebuildChoicesSelection(teamsChoices, pending.teams);
if (pending.dateMode === 'month' && pending.fromMonth && pending.toMonth) {
labels.date.textContent = humanMonth(pending.fromMonth) + ' ' + humanMonth(pending.toMonth);
} else if (pending.dateMode === 'custom' && pending.startDate && pending.endDate) {
labels.date.textContent = humanDate(pending.startDate) + ' ' + humanDate(pending.endDate);
}
updatePillLabel('projects', 'All Projects', document.getElementById('popoverProjects'));
updatePillLabel('teams', 'All Teams', document.getElementById('popoverTeams'));
updateAllPillsDirty();
});
// === TOAST ===
function showToast(message) {
var toast = document.createElement('div');
toast.className = 'filter-toast';
toast.textContent = message; // textContent: XSS-safe
toastContainer.appendChild(toast);
setTimeout(function() {
toast.classList.add('filter-toast--exiting');
setTimeout(function() {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 220);
}, 4000);
}
// === LABEL HELPERS ===
function humanMonth(ym) {
if (!ym || ym.length < 7) return ym || '';
var parts = ym.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function humanDate(ymd) {
if (!ymd || ymd.length < 10) return ymd || '';
var parts = ymd.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function projectNameById(pid) {
var opt = document.getElementById('popoverProjects').querySelector('option[value="' + pid + '"]');
return opt ? opt.textContent : 'Project ' + pid;
}
function teamNameById(tid) {
var opt = document.getElementById('popoverTeams').querySelector('option[value="' + tid + '"]');
return opt ? opt.textContent : 'Team ' + tid;
}
// --- Initial render ---
updateAllPillsDirty();
});
</script>
{% endif %}
{% endblock %}