Manage checklist steps inline from mobile issue detail #920
|
|
@ -6,6 +6,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
|
||||
function checklistOperation(value) {
|
||||
const action = String(value?.action || '');
|
||||
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
|
||||
return {
|
||||
action,
|
||||
index:Number(value.index),
|
||||
...(action === 'rename' ? { label:String(value.label || '') } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
body: String(message.body || ''),
|
||||
|
|
@ -66,11 +76,13 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
|
||||
item.number === Number(message.number || 0));
|
||||
if (queuedContent) {
|
||||
const operation = checklistOperation(message.checklistOperation);
|
||||
const replacement = {
|
||||
...queuedContent,
|
||||
operationId: String(requestedOperationId || makeId()).slice(0, 128),
|
||||
title: String(message.title || ''),
|
||||
body: String(message.body || ''),
|
||||
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
|
||||
};
|
||||
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
|
||||
return replacement;
|
||||
|
|
@ -115,6 +127,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
title: String(message.title || ''),
|
||||
baseBody: String(message.baseBody ?? message.body ?? ''),
|
||||
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
||||
...(checklistOperation(message.checklistOperation) ? {
|
||||
checklistOperations:[checklistOperation(message.checklistOperation)],
|
||||
} : {}),
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
|
||||
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
|
||||
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
|
||||
|
||||
function tasks(body) {
|
||||
|
|
@ -20,6 +20,70 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function replayOperations(body, requested, reportChanges) {
|
||||
let lines = String(body || '').split('\n');
|
||||
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
|
||||
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
|
||||
const replayed = [];
|
||||
for (const operation of requested) {
|
||||
const index = Number(operation?.index);
|
||||
const target = logical[index];
|
||||
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
|
||||
let entries = visibleTasks(lines.join('\n'));
|
||||
const matches = entries.filter(entry => entry.key === target.key);
|
||||
if (matches.length !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{
|
||||
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
|
||||
} };
|
||||
}
|
||||
const match = matches[0];
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
const key = label.toLocaleLowerCase();
|
||||
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
|
||||
}
|
||||
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
|
||||
if (reportChanges) replayed.push({ label:target.label, renamed:label });
|
||||
target.label = label;
|
||||
target.key = key;
|
||||
} else if (operation.action === 'remove') {
|
||||
lines.splice(match.lineIndex, 1);
|
||||
logical.splice(index, 1);
|
||||
if (reportChanges) replayed.push({ label:target.label, removed:true });
|
||||
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
|
||||
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
|
||||
const neighbor = logical[neighborIndex];
|
||||
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
|
||||
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
|
||||
}
|
||||
const neighborLine = neighborMatches[0].lineIndex;
|
||||
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
|
||||
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
|
||||
if (reportChanges) replayed.push({
|
||||
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { body:lines.join('\n'), changes:replayed, conflict:null };
|
||||
}
|
||||
|
||||
if (Array.isArray(operations) && operations.length) {
|
||||
const baseReplay = replayOperations(baseBody, operations, false);
|
||||
const remoteReplay = replayOperations(remoteBody, operations, true);
|
||||
const conflict = baseReplay.conflict || remoteReplay.conflict;
|
||||
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
|
||||
const residual = mergeChecklistConflict({
|
||||
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
|
||||
});
|
||||
return {
|
||||
body:residual.body,
|
||||
changes:[...remoteReplay.changes, ...residual.changes],
|
||||
conflicts:residual.conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
const base = grouped(tasks(baseBody));
|
||||
const local = grouped(tasks(localBody));
|
||||
const remoteEntries = tasks(remoteBody);
|
||||
|
|
|
|||
|
|
@ -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-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; }
|
||||
.checklist-step-editor[hidden] { display:none; }
|
||||
.checklist-step-editor input { width:100%; min-width:0; box-sizing:border-box; }
|
||||
.checklist-step-editor-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.checklist-step-editor button, .checklist-step-editor input { min-height:44px; }
|
||||
#remove-checklist-step { border-color:#b45309; }
|
||||
#issue-sheet.read-only .checklist-step-editor { display:none; }
|
||||
.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; }
|
||||
|
|
@ -653,7 +660,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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 .task-list-manage { min-width:44px; min-height:44px; margin:-9px -9px -9px auto; padding:6px 8px; flex:0 0 auto; }
|
||||
.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; }
|
||||
.markdown-content.checklist-pending .task-list-manage { 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); }
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@
|
|||
let selectedIssueOffline = false;
|
||||
let selectedIssueDetail = null;
|
||||
let dismissedChecklistBody = null;
|
||||
|
||||
let issueBlockerCandidates = [];
|
||||
let issueBlockerSearchTimer = null;
|
||||
let issueConversation = null;
|
||||
|
|
@ -3505,6 +3506,8 @@
|
|||
qs('#completed-filed-progress').textContent = item.is_completed ?
|
||||
'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : '';
|
||||
qs('#issue-sheet-body').textContent = '';
|
||||
checklistStepManagement.reset();
|
||||
qs('#checklist-step-label').value = '';
|
||||
qs('#open-add-checklist-step').disabled = true;
|
||||
qs('#add-checklist-step-form').hidden = true;
|
||||
qs('#add-checklist-step').value = '';
|
||||
|
|
@ -5672,6 +5675,14 @@
|
|||
confirmed:applyIssueContent,
|
||||
restore:renderIssueBody,
|
||||
});
|
||||
const checklistStepManagement = issueController.bindTaskManagement({
|
||||
container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'),
|
||||
earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'),
|
||||
remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'),
|
||||
status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
|
||||
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
|
||||
confirmed:applyIssueContent,
|
||||
});
|
||||
function closeAddChecklistStep() {
|
||||
qs('#add-checklist-step-form').hidden = true;
|
||||
qs('#add-checklist-step').value = '';
|
||||
|
|
|
|||
|
|
@ -605,6 +605,18 @@
|
|||
<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>
|
||||
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
|
||||
<label for="checklist-step-label">Checklist step</label>
|
||||
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
|
||||
<div class="checklist-step-editor-actions">
|
||||
<button id="save-checklist-step-edit" type="submit">Rename</button>
|
||||
<button id="move-checklist-step-earlier" type="button">Move earlier</button>
|
||||
<button id="move-checklist-step-later" type="button">Move later</button>
|
||||
<button id="remove-checklist-step" type="button">Remove</button>
|
||||
<button id="cancel-checklist-step-edit" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="checklist-step-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,47 @@ function appendChecklistTask(body, label) {
|
|||
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() }) {
|
||||
function manageChecklistTask(raw, targetIndex, operation = {}) {
|
||||
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]\]\s+)(.*)$/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!label) throw new Error('Enter a checklist step.');
|
||||
const key = label.toLocaleLowerCase();
|
||||
let inFence = false;
|
||||
const duplicate = parts.some((candidate, candidateIndex) => {
|
||||
if (candidateIndex % 2) return false;
|
||||
if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; }
|
||||
if (inFence || candidateIndex === index) return false;
|
||||
const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
parts[index] = task[1] + label;
|
||||
} else if (operation.action === 'remove') {
|
||||
if (index + 1 < parts.length) parts.splice(index, 2);
|
||||
else if (index > 0) parts.splice(index - 1, 2);
|
||||
} else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) {
|
||||
[parts[index - 2], parts[index]] = [parts[index], parts[index - 2]];
|
||||
} else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) {
|
||||
[parts[index], parts[index + 2]] = [parts[index + 2], parts[index]];
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
|
|
@ -165,6 +205,14 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
|||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
manageTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body: manageTaskTransform(detail.body, taskIndex, operation),
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async addTask(item, detail, label) {
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
return this.updateContent(item, {
|
||||
|
|
@ -182,6 +230,19 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
|||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueManagedTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist management is unavailable.');
|
||||
}
|
||||
const body = manageTaskTransform(detail.body, taskIndex, operation);
|
||||
const checklistOperation = {
|
||||
action:String(operation?.action || ''), index:Number(taskIndex),
|
||||
...(operation?.label ? { label:String(operation.label) } : {}),
|
||||
};
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation });
|
||||
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.');
|
||||
|
|
@ -226,11 +287,69 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
|||
}
|
||||
});
|
||||
},
|
||||
bindTaskManagement({ container, editor, label, earlier, later, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
|
||||
let taskIndex = null;
|
||||
let trigger = null;
|
||||
const reset = (returnFocus = false) => {
|
||||
editor.hidden = true;
|
||||
status.textContent = '';
|
||||
if (returnFocus && trigger?.isConnected) trigger.focus();
|
||||
taskIndex = null;
|
||||
trigger = null;
|
||||
};
|
||||
container.addEventListener('click', event => {
|
||||
const control = event.target.closest('button.task-list-manage');
|
||||
if (!control || !current()?.detail?.updated_at) return;
|
||||
taskIndex = Number(control.dataset.taskIndex);
|
||||
trigger = control;
|
||||
label.value = control.dataset.taskLabel || '';
|
||||
earlier.disabled = control.dataset.taskFirst === 'true';
|
||||
later.disabled = control.dataset.taskLast === 'true';
|
||||
editor.hidden = false;
|
||||
status.textContent = 'Rename, reorder, or remove this step.';
|
||||
label.focus();
|
||||
});
|
||||
const run = async operation => {
|
||||
const state = current();
|
||||
const selectedIndex = taskIndex;
|
||||
if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return;
|
||||
if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return;
|
||||
const controls = editor.querySelectorAll('button,input');
|
||||
controls.forEach(control => { control.disabled = true; });
|
||||
status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…';
|
||||
try {
|
||||
const result = await (state.offline ? this.queueManagedTask : this.manageTask).call(
|
||||
this, state.item, state.detail, selectedIndex, operation
|
||||
);
|
||||
if (current()?.item !== state.item) return;
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
reset();
|
||||
sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.';
|
||||
const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex;
|
||||
container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus();
|
||||
} catch (error) {
|
||||
if (current()?.item === state.item) {
|
||||
status.textContent = error.message + ' Your change is still here; retry.';
|
||||
retry.hidden = false;
|
||||
label.focus();
|
||||
}
|
||||
} finally {
|
||||
if (!editor.hidden) controls.forEach(control => { control.disabled = false; });
|
||||
}
|
||||
};
|
||||
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
|
||||
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
|
||||
later.addEventListener('click', () => run({ action:'move-later' }));
|
||||
remove.addEventListener('click', () => run({ action:'remove' }));
|
||||
cancel.addEventListener('click', () => reset(true));
|
||||
return { reset };
|
||||
},
|
||||
renderTasks(container, detail, interactive) {
|
||||
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
|
||||
container.innerHTML = renderMarkdown(
|
||||
detail.body || 'No description provided.', {
|
||||
interactiveTasks: Boolean(interactive),
|
||||
manageTasks: Boolean(interactive),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
|
@ -438,4 +557,5 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
|||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
createIssueSheet.manageChecklistTask = manageChecklistTask;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
|
|
|
|||
|
|
@ -43,8 +43,13 @@
|
|||
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
|
||||
' disabled';
|
||||
const manage = options.interactiveTasks && options.manageTasks ?
|
||||
'<button type="button" class="task-list-manage" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" data-task-label="' + escapeHtml(body) + '" aria-label="Manage step: ' + escapeHtml(body) + '"' +
|
||||
(offset === 0 ? ' data-task-first="true"' : '') +
|
||||
(offset === lines.length - 1 ? ' data-task-last="true"' : '') + '>Manage</button>' : '';
|
||||
return '<li class="task-list-item"><input type="checkbox"' + control +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
|
||||
}).join('');
|
||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,35 @@ const outbox = createAuthoredOutbox({{
|
|||
assert output["mirrors"][1][0]["operationId"] == "check-2"
|
||||
|
||||
|
||||
def test_durable_issue_content_coalesces_ordered_checklist_management_intent():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const ids = ['manage-1','manage-2'];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=>ids.shift(),
|
||||
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
|
||||
baseBody:'- [ ] Build\\n- [ ] Test',body:'- [ ] Compile\\n- [ ] Test',expectedUpdatedAt:'old',
|
||||
checklistOperation:{{action:'rename',index:0,label:'Compile'}}}});
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
|
||||
baseBody:'- [ ] Compile\\n- [ ] Test',body:'- [ ] Test\\n- [ ] Compile',expectedUpdatedAt:'old',
|
||||
checklistOperation:{{action:'move-later',index:0}}}});
|
||||
process.stdout.write(JSON.stringify(outbox.list()[0]));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["baseBody"] == "- [ ] Build\n- [ ] Test"
|
||||
assert output["body"] == "- [ ] Test\n- [ ] Compile"
|
||||
assert output["checklistOperations"] == [
|
||||
{"action": "rename", "index": 0, "label": "Compile"},
|
||||
{"action": "move-later", "index": 0},
|
||||
]
|
||||
|
||||
|
||||
def test_failed_durable_issue_content_replacement_restores_previous_intent():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
|
|||
|
|
@ -82,3 +82,29 @@ process.stdout.write(JSON.stringify(result));
|
|||
|
||||
assert output["body"] is None
|
||||
assert output["conflicts"] == [{"label": "Verify rollback", "reason": "ambiguous"}]
|
||||
|
||||
|
||||
def test_merge_replays_rename_and_reorder_intent_onto_unrelated_remote_edits():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||
localBody:'Intro\\n- [ ] Test\\n- [ ] Compile',
|
||||
remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Test\\nRemote note',
|
||||
operations:[
|
||||
{{action:'rename',index:0,label:'Compile'}},
|
||||
{{action:'move-later',index:0}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [ ] Test\n- [x] Compile\nRemote note",
|
||||
"changes": [
|
||||
{"label": "Build", "renamed": "Compile"},
|
||||
{"label": "Compile", "moved": "later"},
|
||||
],
|
||||
"conflicts": [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,22 @@ def toggle_task(payload, task_index, checked):
|
|||
)
|
||||
|
||||
|
||||
def manage_task(payload, task_index, operation):
|
||||
script = (
|
||||
f"const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;"
|
||||
f"process.stdout.write(JSON.stringify(manage({json.dumps(payload)}, "
|
||||
f"{task_index}, {json.dumps(operation)})));"
|
||||
)
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
|
@ -114,6 +130,19 @@ def test_interactive_markdown_tasks_expose_accessible_source_indices():
|
|||
)
|
||||
|
||||
|
||||
def test_manageable_markdown_tasks_expose_touch_action_with_position_boundaries():
|
||||
rendered = render_markdown(
|
||||
"- [ ] Verify production\n- [x] Notify support",
|
||||
{"interactiveTasks": True, "manageTasks": True},
|
||||
)
|
||||
|
||||
assert 'class="task-list-manage" data-task-index="0"' in rendered
|
||||
assert 'aria-label="Manage step: Verify production"' in rendered
|
||||
assert 'data-task-first="true"' in rendered
|
||||
assert 'class="task-list-manage" data-task-index="1"' in rendered
|
||||
assert 'data-task-last="true"' in rendered
|
||||
|
||||
|
||||
def test_interactive_task_toggle_changes_exact_source_marker_only():
|
||||
body = (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
|
|
@ -126,6 +155,51 @@ def test_interactive_task_toggle_changes_exact_source_marker_only():
|
|||
)
|
||||
|
||||
|
||||
def test_manage_task_renames_exact_visible_task_and_preserves_nested_step_line_endings_and_fences():
|
||||
body = (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Release\r\n"
|
||||
)
|
||||
|
||||
assert manage_task(body, 1, {"action": "rename", "label": "Ship"}) == (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Ship\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_manage_task_removes_only_the_selected_task_line():
|
||||
body = "Plan\n- [ ] Build\n- [x] Test\n\nNotes"
|
||||
|
||||
assert manage_task(body, 0, {"action": "remove"}) == "Plan\n- [x] Test\n\nNotes"
|
||||
|
||||
|
||||
def test_manage_task_moves_a_step_within_its_list_without_changing_step_content():
|
||||
body = "Plan\n- [ ] Build\n- [x] Test\n- [ ] Release\n\nNotes"
|
||||
|
||||
assert manage_task(body, 2, {"action": "move-earlier"}) == (
|
||||
"Plan\n- [ ] Build\n- [ ] Release\n- [x] Test\n\nNotes"
|
||||
)
|
||||
assert manage_task(body, 0, {"action": "move-later"}) == (
|
||||
"Plan\n- [x] Test\n- [ ] Build\n- [ ] Release\n\nNotes"
|
||||
)
|
||||
|
||||
|
||||
def test_manage_task_rejects_an_empty_or_duplicate_renamed_label():
|
||||
script = f"""
|
||||
const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;
|
||||
const body = '- [ ] Build\\n- [x] Test';
|
||||
const errors = ['', ' test '].map(label => {{
|
||||
try {{ manage(body, 0, {{action:'rename',label}}); return null; }}
|
||||
catch (error) {{ return error.message; }}
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(errors));
|
||||
"""
|
||||
|
||||
assert json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout) == ["Enter a checklist step.", "That checklist step already exists."]
|
||||
|
||||
|
||||
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
||||
rendered = render_markdown(
|
||||
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
||||
|
|
|
|||
|
|
@ -2245,6 +2245,27 @@ async def test_mobile_issue_detail_adds_a_checklist_step_inline_with_accessible_
|
|||
assert 'grid-template-columns:minmax(0,1fr) auto auto' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_detail_manages_checklist_steps_with_touch_safe_inline_editor():
|
||||
html = await dashboard()
|
||||
controller = ISSUE_SHEET.read_text()
|
||||
|
||||
assert 'id="checklist-step-editor"' in html
|
||||
assert 'id="checklist-step-label" type="text"' in html
|
||||
assert 'id="move-checklist-step-earlier"' in html
|
||||
assert 'id="move-checklist-step-later"' in html
|
||||
assert 'id="remove-checklist-step"' in html
|
||||
assert 'id="cancel-checklist-step-edit"' in html
|
||||
assert "manageTasks: Boolean(interactive)" in controller
|
||||
assert "issueController.bindTaskManagement({" in html
|
||||
assert "event.target.closest('button.task-list-manage')" in controller
|
||||
assert "this.queueManagedTask : this.manageTask" in controller
|
||||
assert "globalThis.confirm('Remove this checklist step?')" in controller
|
||||
assert ".task-list-manage { min-width:44px; min-height:44px;" in html
|
||||
assert ".checklist-step-editor button, .checklist-step-editor input { min-height:44px;" in html
|
||||
assert "label.focus()" in controller
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
|
||||
html = await dashboard()
|
||||
|
|
@ -2422,7 +2443,7 @@ process.stdout.write(JSON.stringify({{options,classes,html:container.innerHTML}}
|
|||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["options"] == {"interactiveTasks": True}
|
||||
assert output["options"] == {"interactiveTasks": True, "manageTasks": True}
|
||||
assert output["classes"] == [["checklist-pending", True]]
|
||||
assert "task-list-toggle" in output["html"]
|
||||
|
||||
|
|
@ -2467,6 +2488,70 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
|||
assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z"
|
||||
|
||||
|
||||
def test_issue_sheet_manages_a_step_through_one_revision_guarded_update():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const calls = [];
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
manageTask:(body,index,operation) => body + `|${{index}}:${{operation.action}}:${{operation.label || ''}}`,
|
||||
fetchJson:(url,options) => {{
|
||||
calls.push({{url,body:JSON.parse(options.body)}});
|
||||
return Promise.resolve({{number:17,title:'Release',body:'- [ ] Build|0:rename:Compile',updated_at:'new'}});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api',number:17}};
|
||||
const detail = {{title:'Release',body:'- [ ] Build',updated_at:'old'}};
|
||||
controller.manageTask(item,detail,0,{{action:'rename',label:'Compile'}})
|
||||
.then(result => process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/api/issues/17/content",
|
||||
"body": {
|
||||
"title": "Release", "body": "- [ ] Build|0:rename:Compile",
|
||||
"expected_updated_at": "old",
|
||||
},
|
||||
}]
|
||||
assert output["result"]["updated_at"] == "new"
|
||||
|
||||
|
||||
def test_offline_issue_sheet_manages_a_step_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let admit; const queued = [];
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
manageTask:(body,index,operation) => '- [ ] Compile\\n- [x] Test',
|
||||
enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit=resolve); }},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api',number:17}};
|
||||
const detail = {{title:'Release',body:'- [ ] Build\\n- [x] Test',updated_at:'old'}};
|
||||
let settled = false;
|
||||
const pending = controller.queueManagedTask(item,detail,0,{{action:'rename',label:'Compile'}})
|
||||
.then(result => {{settled=true;return result;}});
|
||||
const before = settled;
|
||||
admit({{item:{{id:'manage-step'}}}});
|
||||
pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["before"] is False
|
||||
assert output["queued"] == [{
|
||||
"kind": "issue-content", "repository": "stackchain/api", "number": 17,
|
||||
"title": "Release", "baseBody": "- [ ] Build\n- [x] Test",
|
||||
"body": "- [ ] Compile\n- [x] Test", "expectedUpdatedAt": "old",
|
||||
"checklistOperation": {"action": "rename", "index": 0, "label": "Compile"},
|
||||
}]
|
||||
assert output["result"]["detail"]["checklist_pending"] is True
|
||||
assert output["result"]["detail"]["body"] == "- [ ] Compile\n- [x] Test"
|
||||
|
||||
|
||||
def test_issue_sheet_adds_a_validated_unchecked_step_without_replacing_existing_content():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user