From b4b13a7db11e9a4d6a57bc811895129ea1c7b288 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 08:22:43 +0000 Subject: [PATCH] feat: preview issue details before claiming (#179) --- frontend/index.html | 34 +++++++++++++++++++++----- frontend/pick-work.js | 19 +++++++++++++++ tests/test_my_work.py | 56 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 2534446..2e45fb1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -150,11 +150,14 @@ textarea { resize: vertical; min-height: 120px; } .find-work-sheet.open { display:flex; } .find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } .find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } -.find-work-header button, .find-work-card button, .find-work-more { min-height:44px; } +.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; } .find-work-list { display:grid; gap:10px; } .find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; } .find-work-card button { width:100%; font-weight:700; } -@media(max-width:320px) { .find-work-panel { padding:12px; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } +.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; } +.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } +.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } .create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .create-issue-sheet.open { display:flex; } .create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } @@ -1139,12 +1142,31 @@ textarea { resize: vertical; min-height: 120px; } function renderAvailableIssues(items) { const list = qs('#find-work-list'); - list.innerHTML = items.length ? items.map((item, index) => - '
' + escapeHtml(item.repository) + '#' + + list.innerHTML = items.length ? items.map((item, index) => { + const expanded = findWorkController.isPreviewed(item); + const detailId = 'find-work-detail-' + index; + const detail = '

' + escapeHtml(item.body || 'No description provided.') + '

' + + (item.url ? 'Open in Gitea' : '') + + '
'; + return '
' + escapeHtml(item.repository) + '#' + Number(item.number) + '
' + escapeHtml(item.title || 'Untitled issue') + '' + '
' + (item.labels || []).map(label => '' + escapeHtml(label) + '').join(' ') + - '
' - ).join('') : '
No unassigned issues are available on this page.
'; + '
' + + detail + '
'; + }).join('') : '
No unassigned issues are available on this page.
'; + list.querySelectorAll('[data-preview-index]').forEach(button => { + button.addEventListener('click', () => { + const item = findWorkController.items()[Number(button.dataset.previewIndex)]; + if (!item) return; + const expanded = findWorkController.togglePreview(item); + const detail = document.getElementById(button.getAttribute('aria-controls')); + button.setAttribute('aria-expanded', String(expanded)); + button.textContent = expanded ? 'Hide details' : 'View details'; + if (detail) detail.hidden = !expanded; + }); + }); list.querySelectorAll('[data-claim-index]').forEach(button => { button.addEventListener('click', async () => { const item = findWorkController.items()[Number(button.dataset.claimIndex)]; diff --git a/frontend/pick-work.js b/frontend/pick-work.js index 2231f0a..f7be839 100644 --- a/frontend/pick-work.js +++ b/frontend/pick-work.js @@ -3,6 +3,11 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) { let pagination = { page: 1, total: 0, has_more: false }; let loadRequest = null; let claimRequest = null; + const previewed = new Set(); + + function itemKey(item) { + return String(item?.repository || '') + '#' + String(item?.number || ''); + } function apply(result, append) { const incoming = Array.isArray(result?.items) ? result.items : []; @@ -15,6 +20,10 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) { } else { available = incoming.map(item => ({ ...item })); } + const availableKeys = new Set(available.map(itemKey)); + previewed.forEach(key => { + if (!availableKeys.has(key)) previewed.delete(key); + }); pagination = { page: Number(result?.page) || 1, total: Number(result?.total) || available.length, @@ -49,6 +58,15 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) { items() { return available.slice(); }, + togglePreview(item) { + const key = itemKey(item); + if (previewed.has(key)) previewed.delete(key); + else previewed.add(key); + return previewed.has(key); + }, + isPreviewed(item) { + return previewed.has(itemKey(item)); + }, claim(item) { if (claimRequest) return claimRequest; const key = item.repository + '#' + item.number; @@ -65,6 +83,7 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) { available = available.filter(candidate => candidate.repository !== item.repository || candidate.number !== item.number ); + previewed.delete(itemKey(item)); onItems(available.slice()); onStatus('Assigned ' + key + ' to you.'); return confirmed; diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 59faeda..c0596f4 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -317,6 +317,47 @@ controller.load().then(() => controller.loadMore()).then(() => assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False} +def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed(): + script = f""" +const createFindWork = require({json.dumps(str(PICK_WORK))}); +const states = []; +const controller = createFindWork({{ + fetchJson: (url, options) => options?.method === 'PATCH' + ? Promise.resolve({{number:7,repository:'stackchain/api',assignees:['timmy']}}) + : Promise.resolve({{ + items:[ + {{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}}, + {{id:18,number:8,title:'Next',repository:'stackchain/web',body:''}} + ],page:2,total:2,has_more:false + }}), + onItems: items => states.push(items), + onPagination: () => {{}}, + onStatus: () => {{}}, +}}); +controller.reset({{ + items:[{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}}], + page:1,total:2,has_more:true +}}); +const target = controller.items()[0]; +const opened = controller.togglePreview(target); +controller.loadMore().then(() => {{ + const afterPagination = controller.isPreviewed(controller.items()[0]); + return controller.claim(controller.items()[0]).then(() => process.stdout.write(JSON.stringify({{ + opened, afterPagination, afterClaim:controller.isPreviewed(target), states + }}))); +}}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["opened"] is True + assert output["afterPagination"] is True + assert output["afterClaim"] is False + assert [item["number"] for item in output["states"][-1]] == [8] + + @pytest.mark.anyio async def test_mobile_my_work_exposes_truthful_work_pagination_control(): html = await dashboard() @@ -342,6 +383,21 @@ async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe assert '@media(max-width:320px)' in html +@pytest.mark.anyio +async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requests(): + html = await dashboard() + + assert 'data-preview-index=' in html + assert 'aria-expanded="' in html + assert 'aria-controls="' in html + assert "const detailId = 'find-work-detail-' + index" in html + assert 'class="find-work-detail"' in html + assert "escapeHtml(item.body || 'No description provided.')" in html + assert '>Open in Gitea' in html + assert '.find-work-card a, .find-work-more { min-height:44px;' in html + assert '.find-work-detail { min-width:0;' in html + + def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success(): script = f""" const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});