feat: toggle mobile issue checklists (Closes #905)
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-15 16:50:48 +00:00
1 parent d8f2b803c7
commit 72dcdc6c52
16 files changed
+261 -55

No files matched your search

+2
View File
@@ -639,6 +639,8 @@ textarea { resize: vertical; min-height: 120px; }
.markdown-content .task-list { padding-left:0; list-style:none; }
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
.markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; }
.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; }
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
@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); }
+26 -24
View File
@@ -3373,20 +3373,6 @@
.map(input => Number(input.value)).filter(Number.isInteger);
}
function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) {
const select = qs('#issue-milestone');
const status = qs('#issue-milestone-status');
select.innerHTML = '<option value="">No milestone</option>';
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;
@@ -3399,7 +3385,7 @@
const planning = await planningLoader.open(selectedIssue);
if (selectedIssue !== item) return;
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
issueController.renderMilestoneEditor(item, detail.milestone, planning.milestones);
} catch (_error) {
if (selectedIssue !== item) return;
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
@@ -3408,6 +3394,23 @@
}
}
function renderIssueBody(detail) {
issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline &&
!issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at);
}
function applyIssueContent(editing, detail, confirmed) {
const merged = issueController.mergeContent(
lastContextSnapshot, editing, detail, confirmed, buildMyWork.replaceIssueContent
);
lastContextSnapshot = merged.snapshot;
selectedIssue = merged.item;
selectedIssueDetail = merged.detail;
qs('#issue-sheet-title').textContent = confirmed.title;
renderIssueBody(selectedIssueDetail);
if (lastContextSnapshot) paintMyWork(lastContextSnapshot);
}
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
const readOnly = issueController.readOnly(item);
@@ -3493,7 +3496,7 @@
renderPlanIssueDependencies(detail);
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderIssueBody(detail);
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
@@ -5593,6 +5596,12 @@
qs('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
});
issueController.bindTaskToggles({
container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
current:()=>({item:selectedIssue,detail:selectedIssueDetail}),
confirmed:applyIssueContent,
restore:renderIssueBody,
});
qs('#issue-planning').addEventListener('toggle', event => {
if (event.currentTarget.open) loadIssuePlanning();
});
@@ -5653,14 +5662,7 @@
qs('#issue-edit-status').textContent = 'Saving issue…';
try {
const confirmed = await issueController.updateContent(editing, draft);
lastContextSnapshot = buildMyWork.replaceIssueContent(
lastContextSnapshot, editing.repository, editing.number, confirmed
);
selectedIssue = { ...editing, ...confirmed, key: editing.key };
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
qs('#issue-sheet-title').textContent = confirmed.title;
qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.');
paintMyWork(lastContextSnapshot);
applyIssueContent(editing, selectedIssueDetail, confirmed);
if (issueEditHistoryActive) history.back();
else qs('#issue-edit-form').hidden = true;
qs('#issue-sheet-status').textContent = 'Issue saved.';
+67 -1
View File
@@ -18,7 +18,13 @@ function createPlanningLoader({ loadLabels, loadMilestones }) {
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function escapeOptionHtml(value) {
return String(value || '').replace(/[&<>"']/g, character => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
})[character]);
}
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
@@ -138,6 +144,66 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { editRequest = null; });
return editRequest;
},
toggleTask(item, detail, taskIndex, checked) {
if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.'));
return this.updateContent(item, {
title: detail.title,
body: toggleTask(detail.body, taskIndex, checked),
expectedUpdatedAt: detail.updated_at,
});
},
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
container.addEventListener('change', async event => {
const control = event.target.closest('input.task-list-toggle');
const state = current();
if (!control || !state?.item || !state?.detail?.updated_at) return;
container.classList.add('checklist-pending');
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
status.textContent = 'Updating checklist…';
try {
const result = await this.toggleTask(
state.item, state.detail, Number(control.dataset.taskIndex), control.checked
);
const latest = current();
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
confirmed(state.item, state.detail, result);
status.textContent = 'Checklist updated.';
}
} catch (error) {
const latest = current();
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
restore(state.detail);
status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.';
retry.hidden = false;
}
}
});
},
renderTasks(container, detail, interactive) {
container.classList.remove('checklist-pending');
container.innerHTML = renderMarkdown(
detail.body || 'No description provided.', { interactiveTasks: Boolean(interactive) }
);
},
mergeContent(snapshot, item, detail, confirmed, replace) {
return {
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
item: { ...item, ...confirmed, key:item.key },
detail: { ...detail, ...confirmed },
};
},
renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) {
const select = document.querySelector('#issue-milestone');
const status = document.querySelector('#issue-milestone-status');
select.innerHTML = '<option value="">No milestone</option>' + milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeOptionHtml(milestone.title) + '</option>'
).join('');
select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? '');
select.disabled = false;
document.querySelector('#save-issue-milestone').disabled = false;
status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.';
},
loadDueDateDraft(item) {
try {
const raw = storage?.getItem(dueDateDraftKey(item));
+37 -5
View File
@@ -32,23 +32,28 @@
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
}
function renderList(lines) {
function renderList(lines, options, firstTaskIndex) {
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
const items = lines.map(line => {
const items = lines.map((line, offset) => {
let body = line.replace(/^[-*+]\s+/, '');
if (!taskList) return '<li>' + renderInline(body) + '</li>';
const checked = /^\[[xX]\]\s+/.test(body);
body = body.replace(/^\[[ xX]\]\s+/, '');
return '<li class="task-list-item"><input type="checkbox" disabled' +
const control = options.interactiveTasks ?
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
' disabled';
return '<li class="task-list-item"><input type="checkbox"' + control +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
}).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
}
return function renderMarkdown(raw) {
function renderMarkdown(raw, options = {}) {
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
const output = [];
let index = 0;
let taskIndex = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
@@ -90,7 +95,10 @@
list.push(lines[index]);
index += 1;
}
output.push(renderList(list));
output.push(renderList(list, options, taskIndex));
if (list.every(candidate => /^[-*+]\s+\[[ xX]\]\s+/.test(candidate))) {
taskIndex += list.length;
}
continue;
}
const paragraph = [];
@@ -103,5 +111,29 @@
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
}
return output.join('');
}
renderMarkdown.toggleTask = function toggleTask(raw, targetIndex, checked) {
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) {
fenced = !fenced;
continue;
}
if (fenced) continue;
const task = line.match(/^([-*+]\s+\[)([ xX])(\])/);
if (!task) continue;
if (taskIndex === Number(targetIndex)) {
parts[index] = task[1] + (checked ? 'x' : ' ') + task[3] + line.slice(task[0].length);
return parts.join('');
}
taskIndex += 1;
}
return parts.join('');
};
return renderMarkdown;
});
+1 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v106';
const CACHE = 'stackchain-dashboard-shell-v107';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;