feat(#4026): collapse paused cron jobs into a collapsible sidebar section
Paused/disabled cron jobs are partitioned out of the active list into a collapsible 'Paused (N)' <details> section so they don't drown the active jobs. _cronList stays the single source of truth — only the render is split, so openCronDetail / _cronNewJobIds / detail-refresh are untouched. Reuses the already-translated cron_status_paused label (zero new i18n keys). Collapsed state persisted in localStorage. Maintainer polish on absorb: wrapped both localStorage read/write in try/catch (repo convention — a storage throw in private mode must not dump the cron sidebar into the error banner) + added the CHANGELOG entry. Co-authored-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
This commit is contained in:
parent
f5efa1f89f
commit
a195c9f589
4 changed files with 126 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>`;
|
||||
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') {
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
81
tests/test_issue4026_collapsible_paused_crons.py
Normal file
81
tests/test_issue4026_collapsible_paused_crons.py
Normal file
|
|
@ -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 `<details>` 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 <details>/<summary> 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 `<details>` 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
|
||||
Loading…
Reference in a new issue