Select all matching work for mobile batch planning #670

Merged
timmy merged 1 commits from timmy/669-select-all-matching-work into main 2026-08-12 18:25:42 +00:00
5 changed files with 110 additions and 1 deletions

View File

@ -241,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.update-selection-controls button { min-height:44px; }
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
.selection-scope-actions button { min-height:44px; max-width:100%; }
.update-selector { min-height:44px; }
.work-selector { min-height:44px; }
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }

View File

@ -2540,6 +2540,21 @@
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
qs('#cancel-work-selection').hidden = !workSelectionState.active;
const selectionScopeActions = qs('#selection-scope-actions');
selectionScopeActions.hidden = !workSelectionState.active;
qs('#select-matching-work').disabled = visible.length === 0 || workSelectionState.count >= workSelection.limit;
qs('#clear-work-selection').disabled = workSelectionState.count === 0;
qs('#select-matching-work').onclick = () => {
const result = workSelection.selectMany(visible);
qs('#my-work-action-status').textContent = result.status === 'limit' ?
result.count + ' matches selected. Selection is capped at ' + result.limit + ' items.' :
result.count + ' matches selected from the current queue and search.';
};
qs('#clear-work-selection').onclick = () => {
workSelection.clear();
qs('#my-work-action-status').textContent = 'Selection cleared. Choose new matches or individual work.';
qs('#select-matching-work').focus();
};
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
qs('#cancel-update-selection').hidden = !selection.active;
qs('#update-selection-status').textContent = workSelectionState.active ?

View File

@ -169,6 +169,10 @@
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
</div>
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
<button id="select-matching-work" type="button">Select all matches</button>
<button id="clear-work-selection" type="button">Clear selection</button>
</div>
<form class="queue-finder" id="queue-finder" role="search">
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
<div class="queue-finder-row">

View File

@ -52,6 +52,30 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
return select(item);
}
function selectMany(items) {
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
let added = 0;
let capped = false;
for (const item of items || []) {
const id = identity(item);
if (!id || selected.has(id)) continue;
if (selected.size >= maximum) {
capped = true;
break;
}
selected.add(id);
added += 1;
}
changed();
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
}
function clear() {
if (!active) return snapshot();
selected.clear();
return changed();
}
function retain(items) {
const retained = new Set((items || []).map(identity).filter(Boolean));
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
@ -62,7 +86,7 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
return { active, count: selected.size, ids: Array.from(selected) };
}
return { identity, start, cancel, select, toggle, retain, snapshot, limit: maximum };
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;

View File

@ -29,6 +29,70 @@ TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
def test_work_selection_selects_matching_items_in_order_with_an_explicit_cap():
script = f"""
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
const selection = createWorkSelection({{limit:3}});
selection.start();
selection.select({{kind:'issue', repository:'stackchain/api', number:1}});
const result = selection.selectMany([
{{kind:'issue', repository:'stackchain/api', number:1}},
{{kind:'pull', repository:'stackchain/web', number:2}},
{{kind:'issue', repository:'stackchain/app', number:3}},
{{kind:'issue', repository:'stackchain/overflow', number:4}},
]);
process.stdout.write(JSON.stringify({{result, snapshot:selection.snapshot()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output == {
"result": {"status": "limit", "added": 2, "count": 3, "limit": 3},
"snapshot": {
"active": True,
"count": 3,
"ids": [
"issue:stackchain/api:1:",
"pull:stackchain/web:2:",
"issue:stackchain/app:3:",
],
},
}
def test_work_selection_clears_matches_without_leaving_selection_mode():
script = f"""
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
const selection = createWorkSelection();
selection.start();
selection.selectMany([
{{kind:'issue', repository:'stackchain/api', number:1}},
{{kind:'pull', repository:'stackchain/web', number:2}},
]);
process.stdout.write(JSON.stringify(selection.clear()));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"active": True, "count": 0, "ids": []}
@pytest.mark.anyio
async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches():
html = await dashboard()
assert 'id="select-matching-work"' in html
assert 'id="clear-work-selection"' in html
assert "workSelection.selectMany(visible)" in html
assert "workSelection.clear()" in html
assert "matches selected" in html
assert ".selection-scope-actions button { min-height:44px;" in html
assert "max-width:100%" in html
def test_queue_finder_matches_repository_number_and_title_without_reordering():
script = f"""
const work = require({json.dumps(str(MY_WORK))});