Make mobile issue sheets read-first with on-demand planning (#216)
Closes #215
This commit is contained in:
commit
ea1f4ad837
|
|
@ -142,6 +142,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
|
|
@ -417,44 +420,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
<div class="issue-label-list" id="issue-label-list"></div>
|
||||
<div class="small" id="issue-label-status" aria-live="polite">Load an issue to edit labels.</div>
|
||||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||
</fieldset>
|
||||
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
|
||||
<legend>Due date</legend>
|
||||
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
|
||||
<input id="issue-due-date" type="date" disabled />
|
||||
<div class="issue-due-actions">
|
||||
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
|
||||
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
|
||||
</div>
|
||||
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
|
||||
</fieldset>
|
||||
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
|
||||
<legend>Milestone</legend>
|
||||
<label for="issue-milestone" class="small">Plan this issue into a release</label>
|
||||
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
|
||||
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
|
||||
<div id="issue-milestone-status" class="small" aria-live="assertive">Load an issue to plan it.</div>
|
||||
</fieldset>
|
||||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="issue-edit-body">Description
|
||||
<textarea id="issue-edit-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-issue-content" type="submit">Save issue</button>
|
||||
<button id="cancel-issue-content" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
||||
|
|
@ -465,6 +431,47 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="issue-edit-body">Description
|
||||
<textarea id="issue-edit-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-issue-content" type="submit">Save issue</button>
|
||||
<button id="cancel-issue-content" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
<div class="issue-label-list" id="issue-label-list"></div>
|
||||
<div class="small" id="issue-label-status" aria-live="polite">Expand planning controls to load labels.</div>
|
||||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||
</fieldset>
|
||||
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
|
||||
<legend>Due date</legend>
|
||||
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
|
||||
<input id="issue-due-date" type="date" disabled />
|
||||
<div class="issue-due-actions">
|
||||
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
|
||||
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
|
||||
</div>
|
||||
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
|
||||
</fieldset>
|
||||
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
|
||||
<legend>Milestone</legend>
|
||||
<label for="issue-milestone" class="small">Plan this issue into a release</label>
|
||||
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
|
||||
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
|
||||
<div id="issue-milestone-status" class="small" aria-live="assertive">Expand planning controls to load milestones.</div>
|
||||
</fieldset>
|
||||
</details>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
|
|
@ -764,6 +771,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const planningLoader = createIssueSheet.createPlanningLoader({
|
||||
loadLabels: item => issueController.loadLabels(item),
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage });
|
||||
|
|
@ -1356,29 +1367,21 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
}
|
||||
|
||||
async function loadIssueLabelEditor(item, confirmedNames) {
|
||||
function renderIssueLabelEditor(item, confirmedNames, labels) {
|
||||
const list = qs('#issue-label-list');
|
||||
const status = qs('#issue-label-status');
|
||||
list.textContent = '';
|
||||
qs('#save-issue-labels').disabled = true;
|
||||
status.textContent = 'Loading labels…';
|
||||
try {
|
||||
const labels = await issueController.loadLabels(item);
|
||||
if (selectedIssue !== item) return;
|
||||
const draftIds = issueController.loadLabelDraft(item);
|
||||
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
|
||||
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
|
||||
);
|
||||
list.innerHTML = labels.map(label =>
|
||||
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
|
||||
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||||
escapeHtml(label.name) + '</span></label>'
|
||||
).join('');
|
||||
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
|
||||
qs('#save-issue-labels').disabled = false;
|
||||
} catch (_error) {
|
||||
status.textContent = 'Labels could not be loaded. Retry by reopening this issue.';
|
||||
}
|
||||
const draftIds = issueController.loadLabelDraft(item);
|
||||
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
|
||||
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
|
||||
);
|
||||
list.innerHTML = labels.map(label =>
|
||||
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
|
||||
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||||
escapeHtml(label.name) + '</span></label>'
|
||||
).join('');
|
||||
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
|
||||
qs('#save-issue-labels').disabled = false;
|
||||
}
|
||||
|
||||
function selectedEditIssueLabelIds() {
|
||||
|
|
@ -1386,27 +1389,38 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.map(input => Number(input.value)).filter(Number.isInteger);
|
||||
}
|
||||
|
||||
async function loadIssueMilestoneEditor(item, confirmedMilestone) {
|
||||
function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) {
|
||||
const select = qs('#issue-milestone');
|
||||
const status = qs('#issue-milestone-status');
|
||||
select.innerHTML = '<option value="">No milestone</option>';
|
||||
select.disabled = true;
|
||||
qs('#save-issue-milestone').disabled = true;
|
||||
status.textContent = 'Loading milestones…';
|
||||
select.innerHTML += milestones.map(milestone =>
|
||||
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
||||
).join('');
|
||||
const draft = issueController.loadMilestoneDraft(item);
|
||||
select.value = String(draft ?? confirmedMilestone?.id ?? '');
|
||||
select.disabled = false;
|
||||
qs('#save-issue-milestone').disabled = false;
|
||||
status.textContent = confirmedMilestone ?
|
||||
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
|
||||
}
|
||||
|
||||
async function loadIssuePlanning() {
|
||||
const item = selectedIssue;
|
||||
const detail = selectedIssueDetail;
|
||||
if (!item || !detail) return;
|
||||
qs('#retry-issue-planning').hidden = true;
|
||||
qs('#issue-label-status').textContent = 'Loading labels…';
|
||||
qs('#issue-milestone-status').textContent = 'Loading milestones…';
|
||||
try {
|
||||
const milestones = await issueController.loadMilestones(item);
|
||||
const planning = await planningLoader.open(selectedIssue);
|
||||
if (selectedIssue !== item) return;
|
||||
select.innerHTML += milestones.map(milestone =>
|
||||
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
||||
).join('');
|
||||
const draft = issueController.loadMilestoneDraft(item);
|
||||
select.value = String(draft ?? confirmedMilestone?.id ?? '');
|
||||
select.disabled = false;
|
||||
qs('#save-issue-milestone').disabled = false;
|
||||
status.textContent = confirmedMilestone ?
|
||||
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
|
||||
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
|
||||
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
|
||||
} catch (_error) {
|
||||
status.textContent = 'Milestones could not be loaded. Retry by reopening this issue.';
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
|
||||
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
|
||||
qs('#retry-issue-planning').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1428,8 +1442,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
|
||||
qs('#issue-comment').value = issueController.loadDraft(item);
|
||||
qs('#issue-comment-status').textContent = '';
|
||||
qs('#issue-planning').open = false;
|
||||
qs('#retry-issue-planning').hidden = true;
|
||||
qs('#issue-label-list').textContent = '';
|
||||
qs('#issue-label-status').textContent = 'Loading labels…';
|
||||
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
|
||||
qs('#save-issue-labels').disabled = true;
|
||||
qs('#issue-due-date').value = '';
|
||||
qs('#issue-due-date').disabled = true;
|
||||
|
|
@ -1439,7 +1455,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#issue-milestone').innerHTML = '<option value="">No milestone</option>';
|
||||
qs('#issue-milestone').disabled = true;
|
||||
qs('#save-issue-milestone').disabled = true;
|
||||
qs('#issue-milestone-status').textContent = 'Loading milestones…';
|
||||
qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.';
|
||||
qs('#retry-issue-load').hidden = true;
|
||||
qs('#open-issue-gitea').href = item.url || '#';
|
||||
qs('#send-issue-comment').disabled = false;
|
||||
|
|
@ -1458,8 +1474,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||||
).join(' ');
|
||||
loadIssueLabelEditor(item, detail.labels || []);
|
||||
loadIssueMilestoneEditor(item, detail.milestone);
|
||||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||||
renderIssueConversation(issueConversation.snapshot());
|
||||
|
|
@ -1473,6 +1487,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#clear-issue-due-date').disabled = !detail.due_date;
|
||||
qs('#issue-due-status').textContent = detail.due_date ?
|
||||
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
|
||||
if (qs('#issue-planning').open) loadIssuePlanning();
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||||
|
|
@ -2346,6 +2361,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#retry-issue-load').addEventListener('click', () => {
|
||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||||
});
|
||||
qs('#issue-planning').addEventListener('toggle', event => {
|
||||
if (event.currentTarget.open) loadIssuePlanning();
|
||||
});
|
||||
qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning);
|
||||
qs('#edit-issue-content').addEventListener('click', () => {
|
||||
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
||||
const draft = issueController.loadEditDraft(selectedIssue) || {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,23 @@
|
|||
function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||
const requests = new Map();
|
||||
const keyFor = item => item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
const key = keyFor(item);
|
||||
if (requests.has(key)) return requests.get(key);
|
||||
const request = Promise.all([loadLabels(item), loadMilestones(item)])
|
||||
.then(([labels, milestones]) => ({ labels, milestones }))
|
||||
.catch(error => {
|
||||
if (requests.get(key) === request) requests.delete(key);
|
||||
throw error;
|
||||
});
|
||||
requests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
|
|
@ -213,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
|||
};
|
||||
}
|
||||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
|
|
|
|||
|
|
@ -535,6 +535,71 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
|
|||
assert 'buildMyWork.replaceIssueMilestone(' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
|
||||
html = await dashboard()
|
||||
|
||||
body = html.index('id="issue-sheet-body"')
|
||||
conversation = html.index('<h2>Full conversation</h2>', body)
|
||||
planning = html.index('id="issue-planning"', conversation)
|
||||
labels = html.index('id="issue-label-editor"', planning)
|
||||
milestone = html.index('id="issue-milestone"', planning)
|
||||
|
||||
assert '<summary>Plan & edit</summary>' in html[planning:labels]
|
||||
assert '<details class="issue-planning" id="issue-planning">' in html
|
||||
assert body < conversation < planning < labels < milestone
|
||||
|
||||
|
||||
def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const calls = [];
|
||||
let fail = true;
|
||||
const planning = createIssueSheet.createPlanningLoader({{
|
||||
loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
|
||||
loadMilestones: async item => {{
|
||||
calls.push('milestones:' + item.number);
|
||||
if (fail) throw new Error('offline');
|
||||
return [{{id:9,title:'RC'}}];
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
(async () => {{
|
||||
const before = calls.slice();
|
||||
let failed = false;
|
||||
try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
|
||||
fail = false;
|
||||
const first = await planning.open(item);
|
||||
const second = await planning.open(item);
|
||||
process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": [],
|
||||
"failed": True,
|
||||
"calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
|
||||
"same": True,
|
||||
"first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
|
||||
html = await dashboard()
|
||||
open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]
|
||||
|
||||
assert 'loadIssueLabelEditor(' not in open_handler
|
||||
assert 'loadIssueMilestoneEditor(' not in open_handler
|
||||
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
|
||||
assert "qs('#issue-planning').addEventListener('toggle'" in html
|
||||
assert 'planningLoader.open(selectedIssue)' in html
|
||||
assert 'id="retry-issue-planning"' in html
|
||||
|
||||
|
||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||
items = [
|
||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user