feat: preserve mobile work section history (Closes #1006)
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 06:59:20 +00:00
1 parent 484dcc6b9c
commit 09dbdb9961
9 files changed
+282 -24

No files matched your search

@@ -116,6 +116,45 @@ process.stdout.write(JSON.stringify({{current,disconnected}}));
}
def test_mobile_issue_navigation_distinguishes_taps_scrolls_and_keyboard_safe_restore():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener() {{}}
click() {{ this.listeners.click({{preventDefault() {{}}}}); }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
scrollIntoView() {{}}
focus() {{ this.focuses += 1; }}
}}
const names=['overview','conversation','reply','actions'];
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const changes=[];
let observer;
const navigation=createNavigation({{
buttons, targets,
onSectionChange:(name, options) => changes.push([name, options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.reply.click();
observer([{{target:targets.actions,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('reply', {{focus:false}});
process.stdout.write(JSON.stringify({{changes,replyFocuses:targets.reply.focuses,current:buttons.reply.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["reply", False], ["actions", True]],
"replyFocuses": 1,
"current": "location",
}
def test_issue_sheet_ships_a_mobile_only_safe_area_navigation_rail():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
@@ -116,6 +116,38 @@ process.stdout.write(JSON.stringify({{current,rootMatches:observedRoot === panel
}
def test_review_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
script = f"""
const createNavigation=require({json.dumps(str(CONTROLLER))});
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
}});
const names=['overview','files','feedback','history'];
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
const targets=Object.fromEntries(names.map(name => [name,element(name)]));
const composer=element('composer'); const changes=[]; let observer;
const navigation=createNavigation({{buttons,targets,summaryComposer:composer,
onSectionChange:(name,options)=>changes.push([name,options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.feedback.listeners.click({{preventDefault() {{}}}});
observer([{{target:targets.history,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('feedback',{{focus:false}});
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.feedback.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["feedback", False], ["history", True]],
"focuses": 1,
"current": "location",
}
def test_review_sheet_wires_a_mobile_safe_area_navigation_rail_and_offline_asset():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
@@ -123,6 +123,38 @@ process.stdout.write(JSON.stringify({{current,disconnected}}));
}
def test_update_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
script = f"""
const createNavigation=require({json.dumps(str(CONTROLLER))});
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,open:false,
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
}});
const names=['activity','conversation','context','reply'];
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,element(name)]));
const composer=element('composer'); const changes=[]; let observer;
const navigation=createNavigation({{buttons,targets,replyComposer:composer,jumpToNewActivity() {{}},
onSectionChange:(name,options)=>changes.push([name,options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.reply.listeners.click({{preventDefault() {{}}}});
observer([{{target:targets.context,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('reply',{{focus:false}});
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.reply.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["reply", False], ["context", True]],
"focuses": 1,
"current": "location",
}
def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
+81 -1
View File
@@ -1477,6 +1477,68 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_work_route_sections_are_validated_back_safe_and_do_not_reopen_the_item():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17'}};
const calls = [];
const stack = [location.hash];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; calls.push(['push', hash]); }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; calls.push(['replace', hash]); }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onOpen: item => calls.push(['open', item.number, item.section || null]),
onSection: (section, options) => calls.push(['section', section, options.restore]),
onClose() {{}}, onInvalid() {{}},
}});
controller.start();
controller.setItems([{{kind:'issue', repository:'stackchain/api', number:17}}]);
controller.section('reply');
controller.section('actions', {{replace:true}});
history.back();
process.stdout.write(JSON.stringify({{
calls,
hash:location.hash,
valid:[
routes.parse('#/my-work/issue/stackchain/api/17/reply'),
routes.parse('#/my-work/update/42/context'),
],
invalid:[
routes.parse('#/my-work/issue/stackchain/api/17/files'),
routes.parse('#/my-work/update/42/overview'),
routes.parse('#/my-work/review/stackchain/api/17/feedback/extra'),
],
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [
["open", 17, None],
["push", "#/my-work/issue/stackchain/api/17/reply"],
["section", "reply", False],
["replace", "#/my-work/issue/stackchain/api/17/actions"],
["section", "actions", False],
["section", "overview", True],
],
"hash": "#/my-work/issue/stackchain/api/17",
"valid": [
{"kind": "issue", "repository": "stackchain/api", "number": 17, "section": "reply"},
{"kind": "update", "notification_id": 42, "section": "context"},
],
"invalid": [None, None, None],
}
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
@@ -1690,7 +1752,7 @@ const calls = [];
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
html = await dashboard()
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
assert "else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);" in html
assert "const readOnly = issueController.readOnly(item);" in html
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
assert "#issue-sheet.read-only .issue-comment-composer" not in html
@@ -1730,6 +1792,24 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
assert "openDeliveryReceiptRoute" not in html
@pytest.mark.anyio
async def test_dashboard_wires_mobile_section_routes_to_each_detail_navigation():
html = await dashboard()
assert html.count(
"onSectionChange:(section, options) => workRoute.section(section, options)"
) == 4
assert "function navigateWorkSection(kind, section)" in html
assert "mobileIssueDetailNavigation.navigate(section, { focus:false })" in html
assert "mobilePullDetailNavigation.navigate(section, { focus:false })" in html
assert "mobileUpdateDetailNavigation.navigate(section, { focus:false })" in html
assert "mobileReviewDetailNavigation.navigate(section, { focus:false })" in html
assert "onSection: (section, options) => {" in html
assert "if (options.restore) navigateWorkSection(route?.kind, section);" in html
assert "await openRoutedWorkSection(item);" in html
assert "navigateWorkSection(item.kind, item.section);" in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
payload = {
"user": {"login": "timmy"},