feat: manage mobile checklist steps inline (Closes #919)
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-15 22:38:52 +00:00
1 parent 6b924a4757
commit 4589c46f16
11 files changed
+454 -4

No files matched your search

+29
View File
@@ -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))});
+26
View File
@@ -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": [],
}
+74
View File
@@ -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) "
+86 -1
View File
@@ -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))});