feat: select matching mobile work in one step (Closes #669)
This commit is contained in:
parent
0fbe047e03
commit
8edd542379
|
|
@ -241,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
.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 { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||||
.update-selection-controls button { min-height:44px; }
|
.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; }
|
.update-selector { min-height:44px; }
|
||||||
.work-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; }
|
.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);
|
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
|
||||||
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
|
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
|
||||||
qs('#cancel-work-selection').hidden = !workSelectionState.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('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
|
||||||
qs('#cancel-update-selection').hidden = !selection.active;
|
qs('#cancel-update-selection').hidden = !selection.active;
|
||||||
qs('#update-selection-status').textContent = workSelectionState.active ?
|
qs('#update-selection-status').textContent = workSelectionState.active ?
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,10 @@
|
||||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||||
</div>
|
</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">
|
<form class="queue-finder" id="queue-finder" role="search">
|
||||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||||
<div class="queue-finder-row">
|
<div class="queue-finder-row">
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,30 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||||
return select(item);
|
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) {
|
function retain(items) {
|
||||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
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 { 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;
|
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"
|
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():
|
def test_queue_finder_matches_repository_number_and_title_without_reordering():
|
||||||
script = f"""
|
script = f"""
|
||||||
const work = require({json.dumps(str(MY_WORK))});
|
const work = require({json.dumps(str(MY_WORK))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user