Select all matching work for mobile batch planning #670
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 ?
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user