diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a0cd07..89fb7d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.484] — 2026-06-18 — Release QT (collapsible paused cron section) + +### Added + +- **Paused cron jobs now collapse into their own section in the sidebar (#4026).** When you have paused/disabled scheduled jobs, they no longer clutter the active list — they're grouped under a collapsible "Paused (N)" section. The collapsed/expanded state is remembered (localStorage), and it reuses the existing translated "paused" label so it's localized in every language out of the box. Thanks @Sanjays2402. + ## [v0.51.483] — 2026-06-18 — Release QS (virtual-scroll height + measurement scroll-jump fixes) ### Fixed diff --git a/static/panels.js b/static/panels.js index 3db726f3..d7e87bca 100644 --- a/static/panels.js +++ b/static/panels.js @@ -538,11 +538,19 @@ async function loadCrons(animate) { return; } box.innerHTML = ''; + // Partition active vs paused so paused jobs don't drown the list (#4026). + // _cronList stays the single source of truth — only the render is split, + // which keeps openCronDetail, _cronNewJobIds, and detail refresh untouched. + const _activeJobs = []; + const _pausedJobs = []; for (const job of _cronList) { + const status = _cronStatusMeta(job); + (status.state === 'paused' ? _pausedJobs : _activeJobs).push({ job, status }); + } + const _appendCronItem = (parent, { job, status }) => { const item = document.createElement('div'); item.className = 'cron-item'; item.id = 'cron-' + job.id; - const status = _cronStatusMeta(job); const isNewRun = _cronNewJobIds.has(String(job.id)); const isAgentMode = !job.no_agent; const profileLabel = _cronProfileLabel(job.profile); @@ -557,7 +565,29 @@ async function loadCrons(animate) { `; item.onclick = () => openCronDetail(job.id, item); if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active'); - box.appendChild(item); + parent.appendChild(item); + }; + for (const entry of _activeJobs) _appendCronItem(box, entry); + if (_pausedJobs.length) { + let collapsed = true; + try { collapsed = localStorage.getItem('cron-paused-collapsed') !== '0'; } catch (_e) {} + const details = document.createElement('details'); + details.className = 'cron-paused-section'; + if (!collapsed) details.open = true; + const pausedLabel = t('cron_status_paused') || 'paused'; + const headerLabel = pausedLabel.charAt(0).toUpperCase() + pausedLabel.slice(1); + const summary = document.createElement('summary'); + summary.className = 'cron-paused-summary'; + summary.textContent = `${headerLabel} (${_pausedJobs.length})`; + details.appendChild(summary); + details.addEventListener('toggle', () => { + try { localStorage.setItem('cron-paused-collapsed', details.open ? '0' : '1'); } catch (_e) {} + }); + const inner = document.createElement('div'); + inner.className = 'cron-paused-inner'; + details.appendChild(inner); + for (const entry of _pausedJobs) _appendCronItem(inner, entry); + box.appendChild(details); } // Re-render current detail with fresh data if we have one and we're not in a form if (_currentCronDetail && _cronMode !== 'create' && _cronMode !== 'edit') { diff --git a/static/style.css b/static/style.css index 64b63dbc..2b67c647 100644 --- a/static/style.css +++ b/static/style.css @@ -1505,6 +1505,13 @@ .cron-gateway-notice{margin:8px 8px 0;} .cron-item{width:100%;min-width:0;box-sizing:border-box;border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);cursor:pointer;} .cron-item:hover{border-color:var(--border2);} + .cron-paused-section{margin:8px 0 0;border-top:1px solid var(--border);padding-top:6px;} + .cron-paused-summary{cursor:pointer;font-size:11px;font-weight:600;color:var(--muted);padding:6px 4px;letter-spacing:.02em;text-transform:uppercase;list-style:none;outline:none;user-select:none;} + .cron-paused-summary::-webkit-details-marker{display:none;} + .cron-paused-summary::before{content:"▸";display:inline-block;width:12px;color:var(--muted);transition:transform .12s ease;} + .cron-paused-section[open] > .cron-paused-summary::before{transform:rotate(90deg);} + .cron-paused-summary:hover{color:var(--text);} + .cron-paused-inner{margin-top:4px;} .cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;} .cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .cron-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;flex-shrink:0;} diff --git a/tests/test_issue4026_collapsible_paused_crons.py b/tests/test_issue4026_collapsible_paused_crons.py new file mode 100644 index 00000000..ac322c0b --- /dev/null +++ b/tests/test_issue4026_collapsible_paused_crons.py @@ -0,0 +1,81 @@ +"""Pin the cron-tasks paused-jobs collapsible section wiring (#4026). + +Active vs paused cron jobs are now rendered into two buckets in `loadCrons`: +active items go straight into `#cronList`, and paused items go into a +collapsible `
` section at the bottom whose open/closed state is +persisted in `localStorage` under the `cron-paused-collapsed` key. Default +is collapsed so the active list isn't drowned by long-paused jobs. + +This is a static-string check (same pattern as `test_issue4006_*` and +`test_issue3988_*`) — there's no headless browser harness in the repo, so +we verify the code paths and class names exist in panels.js + style.css. +""" +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_load_crons_partitions_active_and_paused(): + src = _read("static/panels.js") + assert "_activeJobs" in src and "_pausedJobs" in src, ( + "loadCrons must partition jobs into active vs paused buckets" + ) + assert "status.state === 'paused' ? _pausedJobs : _activeJobs" in src, ( + "partition predicate must read state from _cronStatusMeta (status.state)" + ) + + +def test_paused_section_uses_details_element(): + src = _read("static/panels.js") + # Native
/ gives expand/collapse + a11y for free. + assert "createElement('details')" in src + assert "cron-paused-section" in src and "cron-paused-summary" in src + + +def test_paused_section_persists_collapse_state_in_localstorage(): + src = _read("static/panels.js") + assert "localStorage.getItem('cron-paused-collapsed')" in src, ( + "collapse state must be persisted across refreshes (#4026 AC#8)" + ) + assert "localStorage.setItem('cron-paused-collapsed'" in src, ( + "toggle handler must write the new collapse state" + ) + + +def test_paused_section_defaults_collapsed(): + """AC#3: section must default to collapsed on first visit.""" + src = _read("static/panels.js") + # The read uses `!== '0'` so an absent key (first visit) yields `collapsed=true`, + # and only an explicit '0' opens it. That's the default-collapsed contract. + assert "localStorage.getItem('cron-paused-collapsed') !== '0'" in src + # And the `
` element is only made open=true when collapsed===false. + assert "if (!collapsed) details.open = true;" in src + + +def test_paused_section_skipped_when_no_paused_jobs(): + """AC#9: zero-paused case must not render the section header.""" + src = _read("static/panels.js") + assert "if (_pausedJobs.length) {" in src, ( + "paused-section block must be guarded by _pausedJobs.length" + ) + + +def test_cron_list_remains_single_source_of_truth(): + """openCronDetail / _cronNewJobIds / detail-refresh still read _cronList + (not the partitioned buckets) so click + new-run-dot + refresh keep working + unchanged when grouping is introduced.""" + src = _read("static/panels.js") + # The detail-refresh lookup post-render must still scan the whole _cronList. + assert "_cronList.find(j => j.id === _currentCronDetail.id)" in src + + +def test_paused_section_styling_present(): + css = _read("static/style.css") + assert ".cron-paused-section" in css + assert ".cron-paused-summary" in css + # Native marker hidden so the custom rotating triangle takes over. + assert ".cron-paused-summary::-webkit-details-marker{display:none;}" in css