feat: add mobile checklist steps inline (Closes #915)
This commit is contained in:
1 parent
e7a4eb3331
commit
3f559a50ea
19 files changed
+272
-21
No files matched your search
@@ -42,6 +42,22 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
|
||||
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
|
||||
}
|
||||
|
||||
for (const [key, localMatches] of local) {
|
||||
if (base.has(key)) continue;
|
||||
if (localMatches.length !== 1) {
|
||||
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length > 1) {
|
||||
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
if (remoteMatches.length === 0) {
|
||||
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length) return { body: null, changes, conflicts };
|
||||
const desired = new Map(changes.map(change => [
|
||||
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
|
||||
@@ -52,6 +68,9 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
|
||||
const marker = desired.get(entry.key) ? 'x' : ' ';
|
||||
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
|
||||
});
|
||||
changes.filter(change => change.added).forEach(change => {
|
||||
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
|
||||
});
|
||||
return { body: lines.join('\n'), changes, conflicts: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -474,6 +474,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.checklist-add { display:grid; gap:8px; margin:10px 0 16px; }
|
||||
.checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; }
|
||||
.checklist-add form[hidden] { display:none; }
|
||||
.checklist-add input { min-width:0; width:100%; box-sizing:border-box; }
|
||||
.checklist-add button, .checklist-add input { min-height:44px; }
|
||||
#issue-sheet.read-only .checklist-add { display:none; }
|
||||
@media(max-width:340px) { .checklist-add form { grid-template-columns:1fr 1fr; } .checklist-add input { grid-column:1 / -1; } }
|
||||
.checklist-completion { position:fixed; right:0; bottom:0; z-index:58; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) repeat(2,minmax(0,auto)); align-items:center; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid #4ade80; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.checklist-completion[hidden] { display:none; }
|
||||
.checklist-completion button { min-height:44px; min-width:0; }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
entries: [
|
||||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
|
||||
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.checklist-add'), composer:qs('#add-checklist-step'), submit:qs('#save-checklist-step'), status:qs('#add-checklist-step-status') },
|
||||
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
|
||||
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
|
||||
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
|
||||
@@ -3455,6 +3456,7 @@
|
||||
function renderIssueBody(detail) {
|
||||
const interactive = !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at;
|
||||
issueController.renderTasks(qs('#issue-sheet-body'), detail, interactive);
|
||||
qs('#open-add-checklist-step').disabled = !interactive;
|
||||
renderChecklistCompletion(detail);
|
||||
}
|
||||
|
||||
@@ -3503,6 +3505,10 @@
|
||||
qs('#completed-filed-progress').textContent = item.is_completed ?
|
||||
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
|
||||
qs('#issue-sheet-body').textContent = '';
|
||||
qs('#open-add-checklist-step').disabled = true;
|
||||
qs('#add-checklist-step-form').hidden = true;
|
||||
qs('#add-checklist-step').value = '';
|
||||
qs('#add-checklist-step-status').textContent = '';
|
||||
qs('#checklist-completion').hidden = true;
|
||||
qs('#issue-labels').textContent = '';
|
||||
qs('#issue-assignees').textContent = '';
|
||||
@@ -5666,6 +5672,42 @@
|
||||
confirmed:applyIssueContent,
|
||||
restore:renderIssueBody,
|
||||
});
|
||||
function closeAddChecklistStep() {
|
||||
qs('#add-checklist-step-form').hidden = true;
|
||||
qs('#add-checklist-step').value = '';
|
||||
qs('#open-add-checklist-step').focus();
|
||||
}
|
||||
qs('#open-add-checklist-step').addEventListener('click', () => {
|
||||
qs('#add-checklist-step-form').hidden = false;
|
||||
qs('#add-checklist-step-status').textContent = 'Add one required step.';
|
||||
qs('#add-checklist-step').focus();
|
||||
});
|
||||
qs('#cancel-checklist-step').addEventListener('click', closeAddChecklistStep);
|
||||
qs('#add-checklist-step-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const state = {item:selectedIssue, detail:selectedIssueDetail, offline:selectedIssueOffline};
|
||||
if (!state.item || !state.detail?.updated_at) return;
|
||||
const submit = qs('#save-checklist-step');
|
||||
submit.disabled = true;
|
||||
qs('#add-checklist-step-status').textContent = state.offline ? 'Queueing checklist step…' : 'Adding checklist step…';
|
||||
try {
|
||||
const result = await (selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(
|
||||
issueController, state.item, state.detail, qs('#add-checklist-step').value
|
||||
);
|
||||
if (selectedIssue !== state.item) return;
|
||||
applyIssueContent(state.item, state.detail, state.offline ? result.detail : result);
|
||||
closeAddChecklistStep();
|
||||
qs('#add-checklist-step-status').textContent = state.offline ?
|
||||
'Checklist step queued. Pending sync.' : 'Checklist step added.';
|
||||
} catch (error) {
|
||||
if (selectedIssue === state.item) {
|
||||
qs('#add-checklist-step-status').textContent = error.message;
|
||||
qs('#add-checklist-step').focus();
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#complete-checklist-issue').addEventListener('click', () => {
|
||||
qs('#close-issue').click();
|
||||
});
|
||||
|
||||
@@ -605,6 +605,16 @@
|
||||
<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>
|
||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
||||
<section class="checklist-add" aria-label="Add checklist step">
|
||||
<button id="open-add-checklist-step" type="button" disabled>Add step</button>
|
||||
<form id="add-checklist-step-form" hidden>
|
||||
<label class="visually-hidden" for="add-checklist-step">Checklist step</label>
|
||||
<input id="add-checklist-step" type="text" maxlength="240" autocomplete="off" placeholder="New checklist step" />
|
||||
<button id="save-checklist-step" type="submit">Add</button>
|
||||
<button id="cancel-checklist-step" type="button">Cancel</button>
|
||||
</form>
|
||||
<div id="add-checklist-step-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="checklist-completion" id="checklist-completion" aria-label="Completed checklist" hidden>
|
||||
<span id="checklist-completion-status" role="status" aria-live="polite">Checklist complete</span>
|
||||
<button id="complete-checklist-issue" type="button">Close issue</button>
|
||||
|
||||
@@ -24,6 +24,19 @@ function escapeOptionHtml(value) {
|
||||
})[character]);
|
||||
}
|
||||
|
||||
function appendChecklistTask(body, label) {
|
||||
const normalized = String(label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) throw new Error('Enter a checklist step.');
|
||||
const key = normalized.toLocaleLowerCase();
|
||||
const duplicate = String(body || '').split('\n').some(line => {
|
||||
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
|
||||
return prefix + '- [ ] ' + normalized;
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
@@ -152,6 +165,23 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async addTask(item, detail, label) {
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body,
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async queueAddedTask(item, detail, label) {
|
||||
if (typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
|
||||
@@ -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-v107';
|
||||
const CACHE = 'stackchain-dashboard-shell-v108';
|
||||
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;
|
||||
@@ -73,6 +73,7 @@ const SHELL = [
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
|
||||
Reference in new issue
Block a user