From a2501e47c30903ef189434f275eb739c565bea8a Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 23 Aug 2026 18:37:57 +0000 Subject: [PATCH] feat: open Following alerts in changed review (Closes #1317) --- frontend/dashboard.js | 1 + frontend/following.js | 12 +++++---- frontend/work-route.js | 2 +- tests/e2e/test_mobile_following_release.py | 31 ++++++++++++++++++++++ tests/test_following_frontend.py | 20 ++++++++++++++ tests/test_my_work.py | 28 +++++++++++++++++++ 6 files changed, 88 insertions(+), 6 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 875a91d..94ae1ed 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8233,6 +8233,7 @@ return true; } async function openWorkQueueRoute(filter, action = null) { + if (filter === 'following') return followingQueue.route(); if (!selectWorkQueue(filter, { preserveRoute:true })) return; qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); diff --git a/frontend/following.js b/frontend/following.js index 71c38c9..0610398 100644 --- a/frontend/following.js +++ b/frontend/following.js @@ -137,6 +137,7 @@ return payload; }; let feature; + const show = () => query('#following-sheet').open || query('#following-sheet').showModal(); function render(state) { hooks.onStatus?.(state.status); const list = query('#following-list'); @@ -191,16 +192,18 @@ query('#review-following').addEventListener('click', () => { query('#following-sheet').close(); feature.startReview().catch(() => { - if (!query('#following-sheet').open) query('#following-sheet').showModal(); + show(); }); }); query('#retry-following').addEventListener('click', () => feature.load().catch(() => {})); return { load:feature.load, review:feature.startReview, + async route() { + await feature.load().then(feature.startReview).catch(() => false) || show(); + }, open() { - const sheet = query('#following-sheet'); - if (!sheet.open) sheet.showModal(); + show(); feature.load().catch(() => {}); return 'opened-following'; }, @@ -210,8 +213,7 @@ returnToFollowing() { const completed = feature.finishReview(); if (completed) return 'completed-following'; - const sheet = query('#following-sheet'); - if (!sheet.open) sheet.showModal(); + show(); globalThis.requestAnimationFrame?.(() => { const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following'); target?.focus(); diff --git a/frontend/work-route.js b/frontend/work-route.js index 957cd5a..b6873c8 100644 --- a/frontend/work-route.js +++ b/frontend/work-route.js @@ -6,7 +6,7 @@ 'use strict'; const repositoryPart = /^[A-Za-z0-9_.-]+$/; - const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft']; + const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft', 'following']; const sections = { issue: ['overview', 'conversation', 'reply', 'actions'], filed: ['overview', 'conversation', 'reply', 'actions'], diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py index 8ae7a3f..624f9a6 100644 --- a/tests/e2e/test_mobile_following_release.py +++ b/tests/e2e/test_mobile_following_release.py @@ -80,3 +80,34 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport): overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth") assert overflow is False browser.close() + + +def test_following_alert_route_opens_truthful_empty_state_with_one_refresh(): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 390, "height": 844}) + page.set_content((FRONTEND / "index.html").read_text()) + page.add_style_tag(path=FRONTEND / "dashboard.css") + page.add_script_tag(path=FRONTEND / "following.js") + + result = page.evaluate("""async () => { + let requests=0; + globalThis.fetch=async () => { + requests += 1; + return {ok:true,json:async () => ({revision:2,items:[]})}; + }; + const queue=attachFollowing(() => {}); + await queue.route(); + return {requests,open:document.querySelector('#following-sheet').open, + status:document.querySelector('#following-status').textContent}; + }""") + + assert result == { + "requests": 1, + + "open": True, + "status": "No watched items yet. Watch an issue or pull request from Search to keep it here.", + } + expect(page.locator("#following-sheet")).to_be_visible() + assert page.locator("#close-following").bounding_box()["height"] >= 44 + browser.close() diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 091a676..4aea579 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -76,6 +76,26 @@ const feature=createFollowing({{ assert result["session"]["items"][0]["kind"] == "pull" +def test_following_route_loads_authoritative_state_before_starting_review(): + following = MODULE.read_text() + start = following.index("async route()") + route = following[start:following.index("open()", start)] + + assert "await feature.load().then(feature.startReview)" in route + assert ".catch(() => false)" in route + assert "show();" in route + + +def test_dashboard_dispatches_following_route_to_changed_first_review(): + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + + start = dashboard.index("async function openWorkQueueRoute") + route_handler = dashboard[start:dashboard.index("qs('#work-milestone-filter')", start)] + assert "if (filter === 'following')" in route_handler + assert "return followingQueue.route();" in route_handler + assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue") + + def test_following_typed_identity_prevents_issue_pull_collisions(): script = f""" const createFollowing = require({json.dumps(str(MODULE))}); diff --git a/tests/test_my_work.py b/tests/test_my_work.py index e0c5718..fd80520 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -676,6 +676,34 @@ process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today assert json.loads(result.stdout) == { "kind": "queue", "filter": "agenda", "action": "protect-today" } + + +def test_following_route_waits_for_hydration_then_opens_once(): + script = f""" +const routes = require({json.dumps(str(WORK_ROUTE))}); +const listeners = {{}}; +const location = {{hash:'#/my-work/following'}}; +const calls = []; +const controller = routes.createController({{ + location, + history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}}, + eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}}, + onQueue: (queue, action) => calls.push([queue, action]), + onInvalid: () => calls.push(['invalid']), +}}); +controller.start(); +controller.setItems([]); +controller.sync(); +process.stdout.write(JSON.stringify({{route:routes.parse(location.hash), calls}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "route": {"kind": "queue", "filter": "following"}, + "calls": [["following", None]], + } CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js" TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"