Preview issue details before claiming mobile work #180

Merged
rockachopa merged 1 commits from timmy/179-preview-issue-details-before-claiming into main 2026-08-07 08:23:29 +00:00
3 changed files with 103 additions and 6 deletions

View File

@ -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) =>
'<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
list.innerHTML = items.length ? items.map((item, index) => {
const expanded = findWorkController.isPreviewed(item);
const detailId = 'find-work-detail-' + index;
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
'><p class="find-work-description">' + escapeHtml(item.body || 'No description provided.') + '</p>' +
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
'</div>';
return '<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
'</div><button type="button" data-claim-index="' + index + '">Assign to me</button></article>'
).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
detail + '<button type="button" data-claim-index="' + index + '">Assign to me</button></article>';
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
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)];

View File

@ -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;

View File

@ -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</a>' 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))});