feat: toggle mobile issue checklists (Closes #905)
This commit is contained in:
1 parent
d8f2b803c7
commit
72dcdc6c52
16 files changed
+261
-55
No files matched your search
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v106" in worker
|
||||
assert "stackchain-dashboard-shell-v107" in worker
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -10,10 +10,11 @@ FRONTEND = Path(__file__).parent.parent / "frontend"
|
||||
RENDERER = FRONTEND / "markdown.js"
|
||||
|
||||
|
||||
def render_markdown(payload):
|
||||
def render_markdown(payload, options=None):
|
||||
options = options or {}
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(render({json.dumps(payload)}));"
|
||||
f"process.stdout.write(render({json.dumps(payload)}, {json.dumps(options)}));"
|
||||
)
|
||||
return subprocess.run(
|
||||
["node", "-e", script],
|
||||
@@ -23,6 +24,22 @@ def render_markdown(payload):
|
||||
).stdout
|
||||
|
||||
|
||||
def toggle_task(payload, task_index, checked):
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(JSON.stringify(render.toggleTask({json.dumps(payload)}, "
|
||||
f"{task_index}, {json.dumps(checked)})));"
|
||||
)
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -81,6 +98,34 @@ def test_markdown_renderer_preserves_mobile_reading_structure():
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_markdown_tasks_expose_accessible_source_indices():
|
||||
rendered = render_markdown(
|
||||
"- [ ] Verify production\n- [x] Notify support",
|
||||
{"interactiveTasks": True},
|
||||
)
|
||||
|
||||
assert rendered == (
|
||||
'<ul class="task-list"><li class="task-list-item">'
|
||||
'<input type="checkbox" class="task-list-toggle" data-task-index="0" '
|
||||
'aria-label="Mark Verify production complete"> Verify production</li>'
|
||||
'<li class="task-list-item"><input type="checkbox" class="task-list-toggle" '
|
||||
'data-task-index="1" aria-label="Mark Notify support incomplete" checked> '
|
||||
'Notify support</li></ul>'
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_task_toggle_changes_exact_source_marker_only():
|
||||
body = (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [ ] Duplicate\r\n"
|
||||
)
|
||||
|
||||
assert toggle_task(body, 1, True) == (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [x] Duplicate\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
||||
rendered = render_markdown(
|
||||
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
||||
@@ -110,12 +155,12 @@ def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||
"renderMarkdown(detail.body || 'No description provided.')",
|
||||
"renderMarkdown(item.body || 'No description provided.')",
|
||||
"renderMarkdown(review.body)",
|
||||
"renderMarkdown(confirmed.body || 'No description provided.')",
|
||||
"issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline",
|
||||
)
|
||||
for path in expected_paths:
|
||||
assert path in dashboard
|
||||
|
||||
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4
|
||||
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 3
|
||||
|
||||
|
||||
def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
@@ -137,4 +182,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v106" in worker
|
||||
assert "stackchain-dashboard-shell-v107" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v106" in worker
|
||||
assert "stackchain-dashboard-shell-v107" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v106" in worker
|
||||
assert "stackchain-dashboard-shell-v107" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v106" in service_worker
|
||||
assert "stackchain-dashboard-shell-v107" in service_worker
|
||||
+60
-1
@@ -2201,6 +2201,65 @@ async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_co
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
|
||||
html = await dashboard()
|
||||
controller = ISSUE_SHEET.read_text()
|
||||
|
||||
assert "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline" in html
|
||||
assert "issueController.bindTaskToggles({" in html
|
||||
assert "container.addEventListener('change', async event =>" in controller
|
||||
assert "event.target.closest('input.task-list-toggle')" in controller
|
||||
assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller
|
||||
assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail})" in html
|
||||
assert "buildMyWork.replaceIssueContent" in html
|
||||
assert "status.textContent = 'Checklist updated.'" in controller
|
||||
assert "restore(state.detail)" in controller
|
||||
assert "error.message + ' Checklist was not changed; reload latest or use Edit issue.'" in controller
|
||||
assert '.task-list-toggle { min-width:44px; min-height:44px;' in html
|
||||
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
|
||||
|
||||
|
||||
def test_issue_checklist_toggle_submits_exact_revision_checked_body_once():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
toggleTask:(body, index, checked) => body.replace(index === 1 ? '[X]' : '[ ]', checked ? '[x]' : '[ ]'),
|
||||
fetchJson:(url, options) => {{
|
||||
calls.push({{url, options}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}};
|
||||
const first = controller.toggleTask(item, detail, 1, true);
|
||||
const duplicate = controller.toggleTask(item, detail, 1, true);
|
||||
finish({{number:17,title:'Release',body:'- [ ] Build\\n- [x] Ship',updated_at:'2026-08-15T10:01:00Z'}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls:calls.map(call => ({{url:call.url,body:JSON.parse(call.options.body)}})),
|
||||
same:first === duplicate,
|
||||
confirmed:results[0],
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/api/issues/17/content",
|
||||
"body": {
|
||||
"title": "Release",
|
||||
"body": "- [ ] Build\n- [x] Ship",
|
||||
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||
},
|
||||
}]
|
||||
assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z"
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -4863,7 +4922,7 @@ async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
|
||||
assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
|
||||
assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
|
||||
assert 'buildMyWork.replaceIssueContent(' in html
|
||||
assert 'buildMyWork.replaceIssueContent' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -155,7 +155,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -164,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -179,7 +179,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -187,14 +187,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -203,21 +203,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -881,7 +881,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v106';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v107';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v106" in source
|
||||
assert "stackchain-dashboard-shell-v107" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user