refactor(report): auto-submit on OK + sticky footer + optional until-month
Checkpoint 1 UX feedback (Konrad, 2026-04-23) surfaced three friction points
that all traced back to the same over-engineered "multi-stage commit" model:
1. When Choices.js opened its dropdown, it covered the popover's OK button.
User had to click in a thin strip "outside the multi-select but inside
the dropdown pane" to close Choices.js before OK became reachable.
2. Changing only a project/team didn't light up the global Apply button
(dirty-state diff bug on multi-selects), and even when it did, clicking
Apply didn't actually update the report tables. Also the Apply button
sat at the far right of the pill strip — easy to miss on desktop.
3. Single-month reports required changing BOTH From and To pickers; for a
low-frequency admin tool, that's a tax on the most common flow.
Instead of patching three bugs, collapsed the entire pending/dirty/Apply
model. Each popover's OK now:
- Rebuilds the URL from its OWN inputs only (keeping other filters intact)
- Navigates → full SSR page reload → report re-renders
The user reads the result of their change immediately; there's no "did I
remember to click Apply?" step.
Side-effect wins:
- 'dirty state', 'pending state', 'updateAllPillsDirty', 'revert...',
cross-filter auto-removal, and the toast system all become unnecessary.
Net -187 lines across template + CSS.
- The bug from (2) self-disappears because there's no dirty-diff step.
- Sticky popover footer (position: sticky; bottom: 0; z-index: 2) pins
OK to the popover edge even when Choices.js expands — solves (1).
- The To month picker is labelled "Until (optional)" with "Leave blank
for a single month" hint. Blank on submit → to_month = from_month.
Single-month URLs round-trip with a blank To input (so the form and
the data agree).
Cross-filter preserved: on popover open, the OTHER pill's URL selection
still disables invalid dropdown options. Just no runtime auto-remove —
unnecessary because the next OK submits and the server takes over.
Tested in the browser via preview MCP:
- All three pills open popovers on click
- Range URL shows both month pickers filled
- Single-month URL shows To blank
- OK with blank To → navigates to from_month=X&to_month=X
- Sticky footer keeps OK in viewport when Choices.js is open
- 45/45 tests still pass (no backend contract change)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c4162d2eb
commit
ffb3ef6800
@ -61,14 +61,24 @@
|
||||
<i class="fas fa-calendar-week me-1"></i>Custom Dates
|
||||
</label>
|
||||
</div>
|
||||
{# --- Month-mode pickers --- #}
|
||||
{# "To" is optional: leave it blank for a single-month report. #}
|
||||
{# If blank on OK, the JS submits `to_month = from_month` so the #}
|
||||
{# report window collapses to just that month. Reduces the common #}
|
||||
{# "I want only this month" flow from two picks to one. #}
|
||||
<div class="row g-2" id="popoverMonthFields">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">From</label>
|
||||
<label class="form-label small">Month</label>
|
||||
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">To</label>
|
||||
<label class="form-label small">
|
||||
Until <span class="text-muted">(optional)</span>
|
||||
</label>
|
||||
<input type="month" id="popoverToMonth" class="form-control form-control-sm">
|
||||
<div class="form-text small" style="font-size: 0.72rem; opacity: 0.75;">
|
||||
Leave blank for a single month
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 d-none" id="popoverCustomFields">
|
||||
@ -157,22 +167,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- Apply / Cancel buttons (shown only when dirty) --- #}
|
||||
<div class="apply-filters-group ms-auto" id="apply-filters-group" hidden>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="cancel-filters-btn">Reset</button>
|
||||
<button type="button" class="btn btn-sm btn-accent ms-1" id="apply-filters-btn">
|
||||
<i class="fas fa-check me-1"></i>Apply
|
||||
</button>
|
||||
</div>
|
||||
{# No global Apply button — each popover's OK commits + reloads directly. #}
|
||||
</div>
|
||||
|
||||
{# --- Toast container (for cross-filter auto-removal notices) --- #}
|
||||
<div id="filter-toast-container" class="filter-toast-container d-print-none" aria-live="polite"></div>
|
||||
|
||||
{# --- Cross-filter data for the JS module --- #}
|
||||
{{ project_team_pairs_json|json_script:"projectTeamPairs" }}
|
||||
|
||||
{# --- Expose current URL filter state for JS reset + dirty diffing --- #}
|
||||
{# --- Expose current URL filter state so the cross-filter can disable #}
|
||||
{# dropdown options that are invalid given the OTHER pill's selection. #}
|
||||
{{ selected_project_ids|json_script:"urlSelectedProjectIds" }}
|
||||
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
|
||||
|
||||
@ -544,25 +546,34 @@
|
||||
{% 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. #}
|
||||
{# Scoped IIFE; runs once on DOMContentLoaded. #}
|
||||
{# #}
|
||||
{# Flow: each pill opens a popover; popover's OK button rebuilds the URL #}
|
||||
{# (keeping other filters intact) and navigates → full SSR page reload. #}
|
||||
{# Cancel just closes the popover. No "dirty" state, no global Apply. #}
|
||||
{# #}
|
||||
{# XSS-safe: textContent only; we never write user strings via innerHTML. #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Bail if Choices.js isn't loaded (graceful fallback) ---
|
||||
// --- Bail gracefully if Choices.js failed to load ---
|
||||
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) ---
|
||||
// === CONTEXT from json_script tags ===
|
||||
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); }); }
|
||||
function toIntArr(s) {
|
||||
return (JSON.parse(s || '[]') || [])
|
||||
.map(function(v) { return parseInt(v, 10); })
|
||||
.filter(function(n) { return !isNaN(n); });
|
||||
}
|
||||
// URL state used only for cross-filter option-disabling on popover open.
|
||||
// (No pending-state diffing because OK always submits immediately.)
|
||||
var urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
|
||||
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
|
||||
|
||||
@ -576,40 +587,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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;
|
||||
|
||||
@ -624,15 +601,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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');
|
||||
var fromMonthInput = document.getElementById('popoverFromMonth');
|
||||
var toMonthInput = document.getElementById('popoverToMonth');
|
||||
var startDateInput = document.getElementById('popoverStartDate');
|
||||
var endDateInput = document.getElementById('popoverEndDate');
|
||||
|
||||
// === INITIALISE DATE INPUTS from URL ===
|
||||
var qs = new URLSearchParams(window.location.search);
|
||||
var urlFromMonth = qs.get('from_month') || '';
|
||||
var urlToMonth = qs.get('to_month') || '';
|
||||
var urlStartDate = qs.get('start_date') || '';
|
||||
var urlEndDate = qs.get('end_date') || '';
|
||||
|
||||
fromMonthInput.value = urlFromMonth;
|
||||
// Single-month URLs (from == to) show the "To" picker blank so the UI
|
||||
// naturally reflects the optional-until semantics. Range URLs show both.
|
||||
toMonthInput.value = (urlFromMonth && urlFromMonth === urlToMonth) ? '' : urlToMonth;
|
||||
startDateInput.value = urlStartDate;
|
||||
endDateInput.value = urlEndDate;
|
||||
|
||||
var initialDateMode = (urlStartDate || urlEndDate) ? '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');
|
||||
|
||||
// === POPOVER OPEN / CLOSE ===
|
||||
function closeAllPopovers(except) {
|
||||
@ -645,11 +637,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
function openPopover(key) {
|
||||
closeAllPopovers(key);
|
||||
var pop = popovers[key];
|
||||
pop.hidden = false;
|
||||
popovers[key].hidden = false;
|
||||
pills[key].setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Lazy-init Choices.js
|
||||
// Lazy-init Choices.js the first time each multi-select is shown
|
||||
if (key === 'projects' && !projectsChoices) {
|
||||
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
|
||||
removeItemButton: true, shouldSort: false, placeholder: true,
|
||||
@ -662,12 +653,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
placeholderValue: 'All teams (leave empty for all)',
|
||||
});
|
||||
}
|
||||
// Apply cross-filter to dropdown options (disable invalid ones)
|
||||
// Cross-filter: disable options that are invalid given the OTHER pill's
|
||||
// current URL selection. (e.g., open Teams with Project X in URL → hide
|
||||
// teams that never logged on Project X.)
|
||||
if (key === 'projects') applyCrossFilter('projects');
|
||||
if (key === 'teams') applyCrossFilter('teams');
|
||||
}
|
||||
|
||||
// --- Pill click handlers ---
|
||||
// --- Pill click: toggle popover ---
|
||||
Object.keys(pills).forEach(function(key) {
|
||||
pills[key].addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
@ -680,31 +673,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Click outside closes all ---
|
||||
// Click outside the pill group closes all popovers
|
||||
document.addEventListener('click', function(ev) {
|
||||
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
|
||||
});
|
||||
// --- Esc closes all ---
|
||||
// Esc closes popovers
|
||||
document.addEventListener('keydown', function(ev) {
|
||||
if (ev.key === 'Escape') closeAllPopovers();
|
||||
});
|
||||
|
||||
// === POPOVER OK / CANCEL HANDLERS ===
|
||||
// OK submits immediately via navigation. Cancel just closes the popover;
|
||||
// any Choices.js changes the user made are reset to URL state so the next
|
||||
// open starts fresh.
|
||||
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();
|
||||
if (pop.id === 'popover-date') submitDateFilter();
|
||||
else if (pop.id === 'popover-projects') submitProjectsFilter();
|
||||
else if (pop.id === 'popover-teams') submitTeamsFilter();
|
||||
// Navigation happens inside submit functions — nothing else to do.
|
||||
});
|
||||
cancelBtn.addEventListener('click', function() {
|
||||
if (pop.id === 'popover-date') revertDateInputs();
|
||||
if (pop.id === 'popover-projects') revertProjectsChoices();
|
||||
if (pop.id === 'popover-teams') revertTeamsChoices();
|
||||
// Reset Choices.js widgets to URL state in case the user had
|
||||
// selected something. Date inputs reset on the next open via
|
||||
// URL reload (no in-page state to revert otherwise).
|
||||
if (pop.id === 'popover-projects' && projectsChoices) {
|
||||
rebuildChoicesSelection(projectsChoices, urlProjects);
|
||||
}
|
||||
if (pop.id === 'popover-teams' && teamsChoices) {
|
||||
rebuildChoicesSelection(teamsChoices, urlTeams);
|
||||
}
|
||||
closeAllPopovers();
|
||||
});
|
||||
});
|
||||
@ -719,108 +719,64 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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);
|
||||
// === SUBMIT HANDLERS ===
|
||||
// Each rebuilds the URL using the current popover's inputs (keeping the
|
||||
// other filters intact) and navigates → full SSR page reload. Matches
|
||||
// the original modal's contract; the report re-renders server-side.
|
||||
function submitDateFilter() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
// Clear all date-family params (current + legacy modal-form params)
|
||||
params.delete('from_month');
|
||||
params.delete('to_month');
|
||||
params.delete('start_date');
|
||||
params.delete('end_date');
|
||||
params.delete('date_mode');
|
||||
params.delete('search_terms');
|
||||
|
||||
var isCustom = document.getElementById('popDateModeCustom').checked;
|
||||
if (isCustom) {
|
||||
if (startDateInput.value) params.set('start_date', startDateInput.value);
|
||||
if (endDateInput.value) params.set('end_date', endDateInput.value);
|
||||
} else {
|
||||
var from = fromMonthInput.value;
|
||||
// Blank "To" = single-month report (use From for both ends).
|
||||
var to = toMonthInput.value || from;
|
||||
if (from) params.set('from_month', from);
|
||||
if (to) params.set('to_month', to);
|
||||
}
|
||||
navigateTo(params);
|
||||
}
|
||||
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);
|
||||
});
|
||||
function submitProjectsFilter() {
|
||||
if (!projectsChoices) { closeAllPopovers(); return; }
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
params.delete('project');
|
||||
projectsChoices.getValue(true).forEach(function(id) {
|
||||
params.append('project', id);
|
||||
});
|
||||
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'));
|
||||
}
|
||||
navigateTo(params);
|
||||
}
|
||||
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);
|
||||
});
|
||||
function submitTeamsFilter() {
|
||||
if (!teamsChoices) { closeAllPopovers(); return; }
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
params.delete('team');
|
||||
teamsChoices.getValue(true).forEach(function(id) {
|
||||
params.append('team', id);
|
||||
});
|
||||
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'));
|
||||
}
|
||||
navigateTo(params);
|
||||
}
|
||||
function revertTeamsChoices() {
|
||||
if (!teamsChoices) return;
|
||||
rebuildChoicesSelection(teamsChoices, pending.teams);
|
||||
function navigateTo(params) {
|
||||
window.location = window.location.pathname + '?' + params.toString();
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
// === CROSS-FILTER ===
|
||||
// Read-time only: when a popover opens, disable options that are invalid
|
||||
// given the OTHER pill's current URL selection. Since each OK submits to
|
||||
// URL directly, we don't need runtime auto-removal or pending-state sync.
|
||||
function applyCrossFilter(justOpened) {
|
||||
if (justOpened === 'projects' && projectsChoices) {
|
||||
if (pending.teams.length === 0) return; // no constraint
|
||||
if (urlTeams.length === 0) return; // no constraint
|
||||
var validPids = new Set();
|
||||
pending.teams.forEach(function(tid) {
|
||||
urlTeams.forEach(function(tid) {
|
||||
if (teamToProjects[tid]) {
|
||||
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
|
||||
}
|
||||
@ -828,7 +784,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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);
|
||||
// Always leave currently-URL-selected items enabled so they
|
||||
// remain visible as removable chips — user can unpick them.
|
||||
opt.disabled = !validPids.has(pid) && !urlProjects.includes(pid);
|
||||
});
|
||||
projectsChoices.destroy();
|
||||
projectsChoices = new Choices(sel, {
|
||||
@ -837,9 +795,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
if (justOpened === 'teams' && teamsChoices) {
|
||||
if (pending.projects.length === 0) return;
|
||||
if (urlProjects.length === 0) return;
|
||||
var validTids = new Set();
|
||||
pending.projects.forEach(function(pid) {
|
||||
urlProjects.forEach(function(pid) {
|
||||
if (projectToTeams[pid]) {
|
||||
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
|
||||
}
|
||||
@ -847,7 +805,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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);
|
||||
opt.disabled = !validTids.has(tid) && !urlTeams.includes(tid);
|
||||
});
|
||||
teamsChoices.destroy();
|
||||
teamsChoices = new Choices(selT, {
|
||||
@ -857,109 +815,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
// === 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);
|
||||
// --- Helper: 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);
|
||||
}
|
||||
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 %}
|
||||
|
||||
@ -1737,12 +1737,16 @@ body, .card, .modal-content, .form-control, .form-select,
|
||||
/* === Inline Filters (pill-as-dropdown) on the report page === */
|
||||
/*
|
||||
Layered on top of the existing .filter-pill rules (lines ~1496–1524).
|
||||
Five components:
|
||||
1. .filter-pill--editable: pointer cursor, hover tint, chevron
|
||||
2. .filter-pill--dirty: accent outline + small pulsing dot when uncommitted
|
||||
3. .filter-popover: absolute-positioned dropdown beneath the pill
|
||||
4. .apply-filters-group: slide-in Apply/Reset buttons when any pill dirty
|
||||
5. .filter-toast-container: cross-filter auto-removal notices
|
||||
Three components:
|
||||
1. .filter-pill--editable: pointer cursor, hover tint, rotating chevron
|
||||
2. .filter-popover: absolute-positioned dropdown anchored under the pill
|
||||
3. .filter-popover__footer: sticky bottom bar so the OK button stays
|
||||
visible even when Choices.js expands its dropdown list over the body
|
||||
|
||||
There is intentionally NO dirty-state indicator and NO global Apply button —
|
||||
each popover's OK commits and reloads the page immediately. Simpler model,
|
||||
less state to reason about. (Earlier revision had both; removed 2026-04-23
|
||||
after UX feedback.)
|
||||
*/
|
||||
|
||||
/* --- Wrapper keeps the popover anchored to its pill --- */
|
||||
@ -1775,27 +1779,9 @@ body, .card, .modal-content, .form-control, .form-select,
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* --- Dirty state: pulsing accent dot + outline --- */
|
||||
.filter-pill--dirty {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(232, 133, 26, 0.18);
|
||||
}
|
||||
.filter-pill--dirty::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
margin-right: 0.4rem;
|
||||
animation: filter-pill-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes filter-pill-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* --- Popover positioned under the pill --- */
|
||||
/* max-height + flex column keeps the sticky footer visible even when the
|
||||
popover body has to scroll (Choices.js can render a long list of options). */
|
||||
.filter-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
@ -1803,18 +1789,26 @@ body, .card, .modal-content, .form-control, .form-select,
|
||||
z-index: 1040; /* below Bootstrap modal (1055) but above everything else */
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
max-height: min(70vh, 520px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
padding: 0;
|
||||
overflow: hidden; /* clip Choices.js dropdown so the sticky footer wins */
|
||||
}
|
||||
.filter-popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.filter-popover__body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto; /* body scrolls when content exceeds max-height */
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* Footer is sticky at the bottom of the popover so the OK button is always
|
||||
reachable — fixes the "Choices.js dropdown hides the OK button" complaint. */
|
||||
.filter-popover__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@ -1823,6 +1817,10 @@ body, .card, .modal-content, .form-control, .form-select,
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-inset);
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* --- Mobile: popovers stretch full-width below the pill strip --- */
|
||||
@ -1835,56 +1833,8 @@ body, .card, .modal-content, .form-control, .form-select,
|
||||
right: 0;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
z-index: 1050;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Apply / Reset button group --- */
|
||||
.apply-filters-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
animation: apply-slide-in 180ms ease-out;
|
||||
}
|
||||
.apply-filters-group[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@keyframes apply-slide-in {
|
||||
from { opacity: 0; transform: translateX(8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* --- Toast container for cross-filter auto-remove notices --- */
|
||||
.filter-toast-container {
|
||||
position: fixed;
|
||||
top: 4.5rem; /* below topbar */
|
||||
right: 1rem;
|
||||
z-index: 1060;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
max-width: 320px;
|
||||
}
|
||||
.filter-toast {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
|
||||
animation: filter-toast-in 200ms ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.filter-toast--exiting {
|
||||
animation: filter-toast-out 200ms ease-in forwards;
|
||||
}
|
||||
@keyframes filter-toast-in {
|
||||
from { opacity: 0; transform: translateX(12px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes filter-toast-out {
|
||||
to { opacity: 0; transform: translateX(12px); }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user