Merge pull request #924 from timmy/923-complete-delegated-parent-checklist
Complete delegated parent task from mobile Filed review
This commit is contained in:
commit
40b124bc2c
|
|
@ -469,10 +469,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.completed-filed-actions[hidden] { display:none; }
|
||||
.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)); }
|
||||
.completed-filed-buttons { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.completed-filed-buttons button { flex:1 1 180px; }
|
||||
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(170px + 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; }
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@
|
|||
let selectedIssue = null;
|
||||
let selectedIssueOffline = false;
|
||||
let selectedIssueDetail = null;
|
||||
let delegatedParentReview = null;
|
||||
let dismissedChecklistBody = null;
|
||||
|
||||
let issueBlockerCandidates = [];
|
||||
|
|
@ -3502,6 +3503,7 @@
|
|||
issueMentions.dismiss();
|
||||
selectedIssueOffline = Boolean(offlineDetail);
|
||||
selectedIssueDetail = null;
|
||||
delegatedParentReview = null;
|
||||
issueConversation = null;
|
||||
issueTrigger = trigger;
|
||||
qs('#issue-sheet').classList.add('open');
|
||||
|
|
@ -3513,6 +3515,8 @@
|
|||
candidate.repository === item.repository && candidate.number === item.number
|
||||
);
|
||||
qs('#completed-filed-actions').hidden = !item.is_completed;
|
||||
qs('#complete-parent-and-acknowledge').hidden = true;
|
||||
qs('#complete-parent-and-acknowledge').disabled = true;
|
||||
qs('#filed-claim-actions').hidden = true;
|
||||
qs('#queue-filed-issue').disabled = true;
|
||||
qs('#start-filed-issue').disabled = true;
|
||||
|
|
@ -3598,6 +3602,30 @@
|
|||
qs('#issue-sheet-status').textContent = item.is_completed ?
|
||||
'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' :
|
||||
'Issue ready · ' + (detail.state || 'open');
|
||||
const parentReference = item.is_completed && !offlineDetail ?
|
||||
issueController.delegatedParentReference(detail) : null;
|
||||
if (parentReference) {
|
||||
try {
|
||||
const parentItem = { ...parentReference, key:parentReference.repository + '#' + parentReference.number };
|
||||
const parentDetail = await issueController.load(parentItem);
|
||||
if (selectedIssue !== item) return;
|
||||
const relationship = issueController.resolveDelegatedParent(item, detail, parentDetail);
|
||||
if (relationship && parentDetail.state === 'open' && parentDetail.updated_at) {
|
||||
delegatedParentReview = { parentItem, parentDetail, relationship };
|
||||
const button = qs('#complete-parent-and-acknowledge');
|
||||
button.hidden = false;
|
||||
button.disabled = false;
|
||||
button.textContent = relationship.alreadyCompleted ?
|
||||
'Parent complete · acknowledge' : 'Complete parent step & acknowledge';
|
||||
qs('#completed-filed-progress').textContent = relationship.alreadyCompleted ?
|
||||
'Parent checklist already reflects this outcome.' :
|
||||
'Delegated outcome ready · complete its parent checklist step.';
|
||||
}
|
||||
} catch (_error) {
|
||||
if (selectedIssue !== item) return;
|
||||
delegatedParentReview = null;
|
||||
}
|
||||
}
|
||||
const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open';
|
||||
qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue';
|
||||
qs('#edit-issue-content').disabled = readOnly && !revisableFiling;
|
||||
|
|
@ -3651,7 +3679,7 @@
|
|||
}
|
||||
window.addEventListener('popstate', closeIssueEditorFromHistory);
|
||||
|
||||
qs('#acknowledge-completed-filed').addEventListener('click', () => {
|
||||
function acknowledgeCompletedFiled() {
|
||||
if (!selectedIssue?.is_completed) return;
|
||||
if (!completedFiledReview.acknowledge(selectedIssue)) {
|
||||
qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.';
|
||||
|
|
@ -3674,6 +3702,30 @@
|
|||
window.location.hash = '#/my-work/filed';
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
}
|
||||
|
||||
qs('#acknowledge-completed-filed').addEventListener('click', acknowledgeCompletedFiled);
|
||||
|
||||
qs('#complete-parent-and-acknowledge').addEventListener('click', async () => {
|
||||
if (!selectedIssue?.is_completed || !delegatedParentReview) return;
|
||||
const button = qs('#complete-parent-and-acknowledge');
|
||||
button.disabled = true;
|
||||
const { parentItem, parentDetail, relationship } = delegatedParentReview;
|
||||
try {
|
||||
if (!relationship.alreadyCompleted) {
|
||||
const completion = issueController.completeDelegatedParentTask(parentDetail.body, selectedIssue.url);
|
||||
await issueController.updateContent(parentItem, {
|
||||
title:parentDetail.title, body:completion.body, expectedUpdatedAt:parentDetail.updated_at,
|
||||
});
|
||||
}
|
||||
acknowledgeCompletedFiled();
|
||||
} catch (error) {
|
||||
if (!selectedIssue) return;
|
||||
button.disabled = false;
|
||||
qs('#issue-sheet-status').textContent =
|
||||
'Parent step was not changed. Reload the parent relationship and retry. ' + error.message;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function renderCheckSection(prefix, detail, offline = false) {
|
||||
|
|
|
|||
|
|
@ -600,7 +600,10 @@
|
|||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
|
||||
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
|
||||
<button id="acknowledge-completed-filed" type="button">Acknowledge & next</button>
|
||||
<div class="completed-filed-buttons">
|
||||
<button id="complete-parent-and-acknowledge" type="button" hidden>Complete parent step & acknowledge</button>
|
||||
<button id="acknowledge-completed-filed" type="button">Acknowledge & next</button>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,47 @@ function manageChecklistTask(raw, targetIndex, operation = {}) {
|
|||
return parts.join('');
|
||||
}
|
||||
|
||||
function completeDelegatedParentTask(raw, childUrl) {
|
||||
const url = String(childUrl || '');
|
||||
if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
|
||||
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*[-*+]\s+\[)([ xX])(\]\s+.*)$/);
|
||||
if (!task) continue;
|
||||
const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/);
|
||||
if (linkedTask?.[1] === url) {
|
||||
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
|
||||
if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
|
||||
return { body:parts.join(''), taskIndex, alreadyCompleted };
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
throw new Error('The parent checklist no longer links to this delegated issue.');
|
||||
}
|
||||
|
||||
function delegatedParentReference(childDetail) {
|
||||
const body = String(childDetail?.body || '');
|
||||
const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m);
|
||||
if (!match) return null;
|
||||
return { repository:match[1], number:Number(match[2]), url:match[3] };
|
||||
}
|
||||
|
||||
function resolveDelegatedParent(child, childDetail, parentDetail) {
|
||||
const parent = delegatedParentReference(childDetail);
|
||||
if (!parent) return null;
|
||||
try {
|
||||
const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
|
||||
return { parent, taskIndex:completion.taskIndex, alreadyCompleted:completion.alreadyCompleted };
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
|
|
@ -98,6 +139,9 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
|||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
delegatedParentReference,
|
||||
resolveDelegatedParent,
|
||||
completeDelegatedParentTask,
|
||||
readOnly(item) {
|
||||
return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned));
|
||||
},
|
||||
|
|
@ -585,4 +629,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
|||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
createIssueSheet.manageChecklistTask = manageChecklistTask;
|
||||
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
|
||||
createIssueSheet.delegatedParentReference = delegatedParentReference;
|
||||
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
|
|
|
|||
|
|
@ -100,6 +100,84 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
|||
assert result[1]["reason"] == "Filed by you"
|
||||
|
||||
|
||||
def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
|
||||
script = f"""
|
||||
const sheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const child = {{repository:'stackchain/dashboard', number:44,
|
||||
url:'https://forge.example/git/stackchain/dashboard/issues/44'}};
|
||||
const childDetail = {{body:'Delivered.\\n\\nRelated to [stackchain/dashboard#12](https://forge.example/git/stackchain/dashboard/issues/12).'}};
|
||||
const parentDetail = {{title:'Launch', updated_at:'2026-08-16T10:00:00Z',
|
||||
body:'Plan\\r\\n - [ ] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\\r\\n- [ ] Other'}};
|
||||
const relationship = sheet.resolveDelegatedParent(child, childDetail, parentDetail);
|
||||
const unrelated = sheet.resolveDelegatedParent(child,
|
||||
{{body:'Related to stackchain/dashboard#12 without a canonical link.'}}, parentDetail);
|
||||
const mismatch = sheet.resolveDelegatedParent(child, childDetail,
|
||||
{{...parentDetail, body:'- [ ] [Different](https://forge.example/git/stackchain/dashboard/issues/45)'}});
|
||||
const incidental = sheet.resolveDelegatedParent(child, childDetail,
|
||||
{{...parentDetail, body:'- [ ] Notes [child](https://forge.example/git/stackchain/dashboard/issues/44)'}});
|
||||
process.stdout.write(JSON.stringify({{relationship, unrelated, mismatch, incidental}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"relationship": {
|
||||
"parent": {
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"url": "https://forge.example/git/stackchain/dashboard/issues/12",
|
||||
},
|
||||
"taskIndex": 0,
|
||||
"alreadyCompleted": False,
|
||||
},
|
||||
"unrelated": None,
|
||||
"mismatch": None,
|
||||
"incidental": None,
|
||||
}
|
||||
|
||||
|
||||
def test_delegated_parent_completion_changes_only_the_matching_task_marker():
|
||||
script = f"""
|
||||
const complete = require({json.dumps(str(ISSUE_SHEET))}).completeDelegatedParentTask;
|
||||
const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44';
|
||||
const body = 'Plan\\r\\n - [ ] [Ship mobile flow](' + childUrl + ')\\r\\n' +
|
||||
'- [ ] Duplicate words\\r\\n- [x] [Already](https://forge.example/issues/9)';
|
||||
const changed = complete(body, childUrl);
|
||||
const repeated = complete(changed.body, childUrl);
|
||||
process.stdout.write(JSON.stringify({{changed, repeated}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"changed": {
|
||||
"body": "Plan\r\n - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)",
|
||||
"taskIndex": 0,
|
||||
"alreadyCompleted": False,
|
||||
},
|
||||
"repeated": {
|
||||
"body": "Plan\r\n - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)",
|
||||
"taskIndex": 0,
|
||||
"alreadyCompleted": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_filed_review_offers_parent_completion_before_acknowledgement():
|
||||
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
||||
source = await dashboard()
|
||||
|
||||
assert 'id="complete-parent-and-acknowledge"' in markup
|
||||
assert 'Complete parent step & acknowledge' in markup
|
||||
assert "resolveDelegatedParent(item, detail, parentDetail)" in source
|
||||
handler = source.split("qs('#complete-parent-and-acknowledge').addEventListener('click'", 1)[1].split(
|
||||
"function renderCheckSection", 1
|
||||
)[0]
|
||||
assert "await issueController.updateContent" in handler
|
||||
assert handler.index("await issueController.updateContent") < handler.index("acknowledgeCompletedFiled")
|
||||
|
||||
|
||||
def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
|
|
@ -249,7 +327,9 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
|
|||
assert "createCompletedFiledReview({" in source
|
||||
assert "lastMyWork = buildMyWork(data)" in source
|
||||
assert "filed: 'filed issues'" in source
|
||||
handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1]
|
||||
handler = source.split("function acknowledgeCompletedFiled()", 1)[1].split(
|
||||
"qs('#acknowledge-completed-filed').addEventListener", 1
|
||||
)[0]
|
||||
assert "completedFiledReview.acknowledge(selectedIssue)" in handler
|
||||
assert "syncCompletedFiledReviews()" in source
|
||||
assert "api/v1/completed-filed-reviews" in source
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user