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
|
<i class="fas fa-calendar-week me-1"></i>Custom Dates
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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="row g-2" id="popoverMonthFields">
|
||||||
<div class="col-6">
|
<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">
|
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<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">
|
<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>
|
</div>
|
||||||
<div class="row g-2 d-none" id="popoverCustomFields">
|
<div class="row g-2 d-none" id="popoverCustomFields">
|
||||||
@ -157,22 +167,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# --- Apply / Cancel buttons (shown only when dirty) --- #}
|
{# No global Apply button — each popover's OK commits + reloads directly. #}
|
||||||
<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>
|
|
||||||
</div>
|
</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 --- #}
|
{# --- Cross-filter data for the JS module --- #}
|
||||||
{{ project_team_pairs_json|json_script:"projectTeamPairs" }}
|
{{ 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_project_ids|json_script:"urlSelectedProjectIds" }}
|
||||||
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
|
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
|
||||||
|
|
||||||
@ -544,25 +546,34 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
|
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
|
||||||
{# Scoped IIFE; runs once on DOMContentLoaded. Manages popover open/close, #}
|
{# Scoped IIFE; runs once on DOMContentLoaded. #}
|
||||||
{# dirty state, cross-filter, Apply submission. XSS-safe: createElement + #}
|
{# #}
|
||||||
{# textContent only (no innerHTML with user data). Matches CLAUDE.md pattern. #}
|
{# 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 %}
|
{% if user.is_staff or user.is_superuser %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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') {
|
if (typeof Choices === 'undefined') {
|
||||||
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
|
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Context from json_script tags (safe-decoded by browser JSON parser) ---
|
// === CONTEXT from json_script tags ===
|
||||||
var pairsEl = document.getElementById('projectTeamPairs');
|
var pairsEl = document.getElementById('projectTeamPairs');
|
||||||
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
|
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
|
||||||
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
|
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
|
||||||
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
|
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
|
||||||
// URL state (strings from template) — cast to ints for comparison
|
function toIntArr(s) {
|
||||||
function toIntArr(s) { return (JSON.parse(s || '[]') || []).map(function(v) { return parseInt(v, 10); }).filter(function(n) { return !isNaN(n); }); }
|
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 urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
|
||||||
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
|
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
|
||||||
|
|
||||||
@ -576,40 +587,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
teamToProjects[tid].add(pid);
|
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) ---
|
// --- Choices.js instances (lazy init on first open) ---
|
||||||
var projectsChoices = null, teamsChoices = null;
|
var projectsChoices = null, teamsChoices = null;
|
||||||
|
|
||||||
@ -624,15 +601,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
projects: document.getElementById('popover-projects'),
|
projects: document.getElementById('popover-projects'),
|
||||||
teams: document.getElementById('popover-teams'),
|
teams: document.getElementById('popover-teams'),
|
||||||
};
|
};
|
||||||
var labels = {
|
var fromMonthInput = document.getElementById('popoverFromMonth');
|
||||||
date: pills.date.querySelector('.filter-pill__label'),
|
var toMonthInput = document.getElementById('popoverToMonth');
|
||||||
projects: pills.projects.querySelector('.filter-pill__label'),
|
var startDateInput = document.getElementById('popoverStartDate');
|
||||||
teams: pills.teams.querySelector('.filter-pill__label'),
|
var endDateInput = document.getElementById('popoverEndDate');
|
||||||
};
|
|
||||||
var applyGroup = document.getElementById('apply-filters-group');
|
// === INITIALISE DATE INPUTS from URL ===
|
||||||
var applyBtn = document.getElementById('apply-filters-btn');
|
var qs = new URLSearchParams(window.location.search);
|
||||||
var resetBtn = document.getElementById('cancel-filters-btn');
|
var urlFromMonth = qs.get('from_month') || '';
|
||||||
var toastContainer = document.getElementById('filter-toast-container');
|
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 ===
|
// === POPOVER OPEN / CLOSE ===
|
||||||
function closeAllPopovers(except) {
|
function closeAllPopovers(except) {
|
||||||
@ -645,11 +637,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
function openPopover(key) {
|
function openPopover(key) {
|
||||||
closeAllPopovers(key);
|
closeAllPopovers(key);
|
||||||
var pop = popovers[key];
|
popovers[key].hidden = false;
|
||||||
pop.hidden = false;
|
|
||||||
pills[key].setAttribute('aria-expanded', 'true');
|
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) {
|
if (key === 'projects' && !projectsChoices) {
|
||||||
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
|
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
|
||||||
removeItemButton: true, shouldSort: false, placeholder: true,
|
removeItemButton: true, shouldSort: false, placeholder: true,
|
||||||
@ -662,12 +653,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
placeholderValue: 'All teams (leave empty for all)',
|
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 === 'projects') applyCrossFilter('projects');
|
||||||
if (key === 'teams') applyCrossFilter('teams');
|
if (key === 'teams') applyCrossFilter('teams');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pill click handlers ---
|
// --- Pill click: toggle popover ---
|
||||||
Object.keys(pills).forEach(function(key) {
|
Object.keys(pills).forEach(function(key) {
|
||||||
pills[key].addEventListener('click', function(ev) {
|
pills[key].addEventListener('click', function(ev) {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@ -680,31 +673,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Click outside the pill group closes all popovers
|
||||||
// --- Click outside closes all ---
|
|
||||||
document.addEventListener('click', function(ev) {
|
document.addEventListener('click', function(ev) {
|
||||||
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
|
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
|
||||||
});
|
});
|
||||||
// --- Esc closes all ---
|
// Esc closes popovers
|
||||||
document.addEventListener('keydown', function(ev) {
|
document.addEventListener('keydown', function(ev) {
|
||||||
if (ev.key === 'Escape') closeAllPopovers();
|
if (ev.key === 'Escape') closeAllPopovers();
|
||||||
});
|
});
|
||||||
|
|
||||||
// === POPOVER OK / CANCEL HANDLERS ===
|
// === 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) {
|
document.querySelectorAll('.filter-popover').forEach(function(pop) {
|
||||||
var okBtn = pop.querySelector('.popover-ok');
|
var okBtn = pop.querySelector('.popover-ok');
|
||||||
var cancelBtn = pop.querySelector('.popover-cancel');
|
var cancelBtn = pop.querySelector('.popover-cancel');
|
||||||
okBtn.addEventListener('click', function() {
|
okBtn.addEventListener('click', function() {
|
||||||
if (pop.id === 'popover-date') commitDateFromInputs();
|
if (pop.id === 'popover-date') submitDateFilter();
|
||||||
if (pop.id === 'popover-projects') commitProjectsFromChoices();
|
else if (pop.id === 'popover-projects') submitProjectsFilter();
|
||||||
if (pop.id === 'popover-teams') commitTeamsFromChoices();
|
else if (pop.id === 'popover-teams') submitTeamsFilter();
|
||||||
updateAllPillsDirty();
|
// Navigation happens inside submit functions — nothing else to do.
|
||||||
closeAllPopovers();
|
|
||||||
});
|
});
|
||||||
cancelBtn.addEventListener('click', function() {
|
cancelBtn.addEventListener('click', function() {
|
||||||
if (pop.id === 'popover-date') revertDateInputs();
|
// Reset Choices.js widgets to URL state in case the user had
|
||||||
if (pop.id === 'popover-projects') revertProjectsChoices();
|
// selected something. Date inputs reset on the next open via
|
||||||
if (pop.id === 'popover-teams') revertTeamsChoices();
|
// 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();
|
closeAllPopovers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -719,108 +719,64 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('popoverCustomFields').classList.remove('d-none');
|
document.getElementById('popoverCustomFields').classList.remove('d-none');
|
||||||
});
|
});
|
||||||
|
|
||||||
// === COMMIT / REVERT HELPERS ===
|
// === SUBMIT HANDLERS ===
|
||||||
function commitDateFromInputs() {
|
// Each rebuilds the URL using the current popover's inputs (keeping the
|
||||||
pending.dateMode = document.getElementById('popDateModeCustom').checked ? 'custom' : 'month';
|
// other filters intact) and navigates → full SSR page reload. Matches
|
||||||
pending.fromMonth = fromMonthInput.value;
|
// the original modal's contract; the report re-renders server-side.
|
||||||
pending.toMonth = toMonthInput.value;
|
function submitDateFilter() {
|
||||||
pending.startDate = startDateInput.value;
|
var params = new URLSearchParams(window.location.search);
|
||||||
pending.endDate = endDateInput.value;
|
// Clear all date-family params (current + legacy modal-form params)
|
||||||
if (pending.dateMode === 'month' && pending.fromMonth && pending.toMonth) {
|
params.delete('from_month');
|
||||||
labels.date.textContent = humanMonth(pending.fromMonth) + ' – ' + humanMonth(pending.toMonth);
|
params.delete('to_month');
|
||||||
} else if (pending.dateMode === 'custom' && pending.startDate && pending.endDate) {
|
params.delete('start_date');
|
||||||
labels.date.textContent = humanDate(pending.startDate) + ' – ' + humanDate(pending.endDate);
|
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() {
|
function submitProjectsFilter() {
|
||||||
document.getElementById('popDateModeMonth').checked = (pending.dateMode === 'month');
|
if (!projectsChoices) { closeAllPopovers(); return; }
|
||||||
document.getElementById('popDateModeCustom').checked = (pending.dateMode === 'custom');
|
var params = new URLSearchParams(window.location.search);
|
||||||
document.getElementById('popoverMonthFields').classList.toggle('d-none', pending.dateMode !== 'month');
|
params.delete('project');
|
||||||
document.getElementById('popoverCustomFields').classList.toggle('d-none', pending.dateMode !== 'custom');
|
projectsChoices.getValue(true).forEach(function(id) {
|
||||||
fromMonthInput.value = pending.fromMonth || '';
|
params.append('project', id);
|
||||||
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) {
|
navigateTo(params);
|
||||||
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() {
|
function submitTeamsFilter() {
|
||||||
if (!projectsChoices) return;
|
if (!teamsChoices) { closeAllPopovers(); return; }
|
||||||
rebuildChoicesSelection(projectsChoices, pending.projects);
|
var params = new URLSearchParams(window.location.search);
|
||||||
}
|
params.delete('team');
|
||||||
function commitTeamsFromChoices() {
|
teamsChoices.getValue(true).forEach(function(id) {
|
||||||
if (!teamsChoices) return;
|
params.append('team', id);
|
||||||
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) {
|
navigateTo(params);
|
||||||
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() {
|
function navigateTo(params) {
|
||||||
if (!teamsChoices) return;
|
window.location = window.location.pathname + '?' + params.toString();
|
||||||
rebuildChoicesSelection(teamsChoices, pending.teams);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rebuild a Choices.js widget's selection from a list of IDs ---
|
// === CROSS-FILTER ===
|
||||||
function rebuildChoicesSelection(instance, ids) {
|
// Read-time only: when a popover opens, disable options that are invalid
|
||||||
instance.removeActiveItems();
|
// given the OTHER pill's current URL selection. Since each OK submits to
|
||||||
var idStrs = ids.map(String);
|
// URL directly, we don't need runtime auto-removal or pending-state sync.
|
||||||
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) {
|
function applyCrossFilter(justOpened) {
|
||||||
if (justOpened === 'projects' && projectsChoices) {
|
if (justOpened === 'projects' && projectsChoices) {
|
||||||
if (pending.teams.length === 0) return; // no constraint
|
if (urlTeams.length === 0) return; // no constraint
|
||||||
var validPids = new Set();
|
var validPids = new Set();
|
||||||
pending.teams.forEach(function(tid) {
|
urlTeams.forEach(function(tid) {
|
||||||
if (teamToProjects[tid]) {
|
if (teamToProjects[tid]) {
|
||||||
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
|
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
|
||||||
}
|
}
|
||||||
@ -828,7 +784,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var sel = document.getElementById('popoverProjects');
|
var sel = document.getElementById('popoverProjects');
|
||||||
Array.from(sel.options).forEach(function(opt) {
|
Array.from(sel.options).forEach(function(opt) {
|
||||||
var pid = parseInt(opt.value, 10);
|
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.destroy();
|
||||||
projectsChoices = new Choices(sel, {
|
projectsChoices = new Choices(sel, {
|
||||||
@ -837,9 +795,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (justOpened === 'teams' && teamsChoices) {
|
if (justOpened === 'teams' && teamsChoices) {
|
||||||
if (pending.projects.length === 0) return;
|
if (urlProjects.length === 0) return;
|
||||||
var validTids = new Set();
|
var validTids = new Set();
|
||||||
pending.projects.forEach(function(pid) {
|
urlProjects.forEach(function(pid) {
|
||||||
if (projectToTeams[pid]) {
|
if (projectToTeams[pid]) {
|
||||||
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
|
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
|
||||||
}
|
}
|
||||||
@ -847,7 +805,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var selT = document.getElementById('popoverTeams');
|
var selT = document.getElementById('popoverTeams');
|
||||||
Array.from(selT.options).forEach(function(opt) {
|
Array.from(selT.options).forEach(function(opt) {
|
||||||
var tid = parseInt(opt.value, 10);
|
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.destroy();
|
||||||
teamsChoices = new Choices(selT, {
|
teamsChoices = new Choices(selT, {
|
||||||
@ -857,109 +815,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DIRTY STATE ===
|
// --- Helper: rebuild a Choices.js widget's selection from a list of IDs ---
|
||||||
function isDateDirty() {
|
function rebuildChoicesSelection(instance, ids) {
|
||||||
return pending.dateMode !== urlDate.mode ||
|
instance.removeActiveItems();
|
||||||
pending.fromMonth !== urlDate.fromMonth ||
|
var idStrs = ids.map(String);
|
||||||
pending.toMonth !== urlDate.toMonth ||
|
if (idStrs.length > 0) {
|
||||||
pending.startDate !== urlDate.startDate ||
|
instance.setChoiceByValue(idStrs);
|
||||||
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>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1737,12 +1737,16 @@ body, .card, .modal-content, .form-control, .form-select,
|
|||||||
/* === Inline Filters (pill-as-dropdown) on the report page === */
|
/* === Inline Filters (pill-as-dropdown) on the report page === */
|
||||||
/*
|
/*
|
||||||
Layered on top of the existing .filter-pill rules (lines ~1496–1524).
|
Layered on top of the existing .filter-pill rules (lines ~1496–1524).
|
||||||
Five components:
|
Three components:
|
||||||
1. .filter-pill--editable: pointer cursor, hover tint, chevron
|
1. .filter-pill--editable: pointer cursor, hover tint, rotating chevron
|
||||||
2. .filter-pill--dirty: accent outline + small pulsing dot when uncommitted
|
2. .filter-popover: absolute-positioned dropdown anchored under the pill
|
||||||
3. .filter-popover: absolute-positioned dropdown beneath the pill
|
3. .filter-popover__footer: sticky bottom bar so the OK button stays
|
||||||
4. .apply-filters-group: slide-in Apply/Reset buttons when any pill dirty
|
visible even when Choices.js expands its dropdown list over the body
|
||||||
5. .filter-toast-container: cross-filter auto-removal notices
|
|
||||||
|
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 --- */
|
/* --- Wrapper keeps the popover anchored to its pill --- */
|
||||||
@ -1775,27 +1779,9 @@ body, .card, .modal-content, .form-control, .form-select,
|
|||||||
transform: rotate(180deg);
|
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 --- */
|
/* --- 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 {
|
.filter-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 6px);
|
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 */
|
z-index: 1040; /* below Bootstrap modal (1055) but above everything else */
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
|
max-height: min(70vh, 520px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden; /* clip Choices.js dropdown so the sticky footer wins */
|
||||||
}
|
}
|
||||||
.filter-popover[hidden] {
|
.filter-popover[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.filter-popover__body {
|
.filter-popover__body {
|
||||||
padding: 1rem;
|
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 {
|
.filter-popover__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@ -1823,6 +1817,10 @@ body, .card, .modal-content, .form-control, .form-select,
|
|||||||
border-top: 1px solid var(--border-default);
|
border-top: 1px solid var(--border-default);
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
border-radius: 0 0 0.5rem 0.5rem;
|
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 --- */
|
/* --- Mobile: popovers stretch full-width below the pill strip --- */
|
||||||
@ -1835,56 +1833,8 @@ body, .card, .modal-content, .form-control, .form-select,
|
|||||||
right: 0;
|
right: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
|
max-height: 80vh;
|
||||||
border-radius: 0.5rem 0.5rem 0 0;
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
z-index: 1050;
|
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