From ffb3ef680023c2639a12a21ee5a5f0624addfd24 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 23 Apr 2026 10:48:53 +0200 Subject: [PATCH] refactor(report): auto-submit on OK + sticky footer + optional until-month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/templates/core/report.html | 413 +++++++++++--------------------- static/css/custom.css | 100 ++------ 2 files changed, 163 insertions(+), 350 deletions(-) diff --git a/core/templates/core/report.html b/core/templates/core/report.html index d12b0f9..e4fa618 100644 --- a/core/templates/core/report.html +++ b/core/templates/core/report.html @@ -61,14 +61,24 @@ Custom Dates + {# --- 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. #}
- +
- + +
+ Leave blank for a single month +
@@ -157,22 +167,14 @@
- {# --- Apply / Cancel buttons (shown only when dirty) --- #} - + {# No global Apply button — each popover's OK commits + reloads directly. #} -{# --- Toast container (for cross-filter auto-removal notices) --- #} -
- {# --- 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 %} {% endif %} diff --git a/static/css/custom.css b/static/css/custom.css index 71f3840..ff72085 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -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); } -}