feat: manage mobile checklist steps inline (Closes #919)
This commit is contained in:
1 parent
6b924a4757
commit
4589c46f16
11 files changed
+454
-4
No files matched your search
@@ -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) "
|
||||
|
||||
+86
-1
@@ -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))});
|
||||
|
||||
Reference in new issue
Block a user