feat: add mobile pull-to-refresh for My Work (Closes #1002)
This commit is contained in:
1 parent
a2606f1dc2
commit
386acda1dc
9 files changed
+356
-1
No files matched your search
@@ -29,6 +29,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
live_requests: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
@@ -56,6 +57,12 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
if "feature-today-timer-" in request.url
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: live_requests.append(request.url)
|
||||
if "/api/v1/live" in request.url
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
||||
@@ -64,6 +71,29 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
initial_live_requests = len(live_requests)
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.scrollTo(0, 0);
|
||||
const surface = document.querySelector('#my-work');
|
||||
surface.setPointerCapture = () => {};
|
||||
surface.releasePointerCapture = () => {};
|
||||
const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y,
|
||||
}));
|
||||
pointer('pointerdown', 10);
|
||||
pointer('pointermove', 90);
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh")
|
||||
page.evaluate(
|
||||
"document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', "
|
||||
"{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))"
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date")
|
||||
assert len(live_requests) == initial_live_requests + 1
|
||||
dock = page.locator("#mobile-task-dock")
|
||||
expect(dock).to_be_visible()
|
||||
expect(dock.locator("button")).to_have_count(5)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-pull-refresh.js"
|
||||
|
||||
|
||||
def run_scenario(source: str) -> dict:
|
||||
script = f"""
|
||||
const createPullRefresh = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor() {{
|
||||
this.listeners = {{}};
|
||||
this.dataset = {{}};
|
||||
this.hidden = true;
|
||||
this.textContent = '';
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
setPointerCapture() {{}}
|
||||
releasePointerCapture() {{}}
|
||||
}}
|
||||
const surface = new FakeElement();
|
||||
const indicator = new FakeElement();
|
||||
const plain = {{closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100}};
|
||||
const event = (x, y, target=plain, id=1) => ({{
|
||||
pointerId:id, pointerType:'touch', clientX:x, clientY:y, target,
|
||||
preventDefault() {{ this.prevented = true; }},
|
||||
}});
|
||||
{source}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_runs_one_refresh_after_vertical_threshold_and_announces_success():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
surface.listeners.pointerdown(event(100, 10));
|
||||
surface.listeners.pointermove(event(102, 90));
|
||||
const ready = {hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent};
|
||||
const completion = surface.listeners.pointerup(event(102, 90));
|
||||
Promise.resolve(completion).then(() => process.stdout.write(JSON.stringify({
|
||||
calls, ready,
|
||||
final:{hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent},
|
||||
})));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"ready": {"hidden": False, "state": "ready", "text": "Release to refresh"},
|
||||
"final": {"hidden": False, "state": "success", "text": "My Work is up to date"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_ignores_ineligible_gestures_and_short_pulls():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
let mobile = true;
|
||||
let scrollY = 0;
|
||||
let blocked = false;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; },
|
||||
isMobile: () => mobile,
|
||||
getScrollY: () => scrollY,
|
||||
isBlocked: () => blocked,
|
||||
});
|
||||
controller.start();
|
||||
async function drag(fromX, fromY, toX, toY, target=plain) {
|
||||
surface.listeners.pointerdown(event(fromX, fromY, target));
|
||||
surface.listeners.pointermove(event(toX, toY, target));
|
||||
await surface.listeners.pointerup(event(toX, toY, target));
|
||||
}
|
||||
(async () => {
|
||||
await drag(100, 10, 102, 50);
|
||||
await drag(100, 10, 180, 30);
|
||||
mobile = false; await drag(100, 10, 100, 100);
|
||||
mobile = true; scrollY = 1; await drag(100, 10, 100, 100);
|
||||
scrollY = 0; blocked = true; await drag(100, 10, 100, 100);
|
||||
blocked = false;
|
||||
const interactive = {closest: selector => selector ? interactive : null, parentElement:null, scrollWidth:100, clientWidth:100};
|
||||
await drag(100, 10, 100, 100, interactive);
|
||||
const scroller = {closest: () => null, parentElement:null, scrollWidth:300, clientWidth:100};
|
||||
await drag(100, 10, 100, 100, scroller);
|
||||
process.stdout.write(JSON.stringify({calls}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert result == {"calls": 0}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_stays_single_flight_and_keeps_refreshing_status():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
let finish;
|
||||
const pending = new Promise(resolve => { finish = resolve; });
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: () => { calls += 1; return pending; },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
function pull(id) {
|
||||
surface.listeners.pointerdown(event(100, 10, plain, id));
|
||||
surface.listeners.pointermove(event(100, 90, plain, id));
|
||||
return surface.listeners.pointerup(event(100, 90, plain, id));
|
||||
}
|
||||
const first = pull(1);
|
||||
Promise.resolve().then(async () => {
|
||||
const second = pull(2);
|
||||
const during = {calls, state:indicator.dataset.state, text:indicator.textContent};
|
||||
finish();
|
||||
await first;
|
||||
await second;
|
||||
process.stdout.write(JSON.stringify({calls, during, final:indicator.dataset.state}));
|
||||
});
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"during": {"calls": 1, "state": "refreshing", "text": "Refreshing My Work…"},
|
||||
"final": "success",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_announces_failure_and_allows_a_retry():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; if (calls === 1) throw new Error('offline secret'); },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
async function pull(id) {
|
||||
surface.listeners.pointerdown(event(100, 10, plain, id));
|
||||
surface.listeners.pointermove(event(100, 90, plain, id));
|
||||
try { await surface.listeners.pointerup(event(100, 90, plain, id)); } catch (_) {}
|
||||
}
|
||||
(async () => {
|
||||
await pull(1);
|
||||
const failed = {state:indicator.dataset.state, text:indicator.textContent};
|
||||
await pull(2);
|
||||
process.stdout.write(JSON.stringify({calls, failed, final:indicator.dataset.state}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 2,
|
||||
"failed": {"state": "error", "text": "Could not refresh · pull to retry"},
|
||||
"final": "success",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_refresh_is_wired_to_live_refresh_and_packaged_offline():
|
||||
html = await dashboard()
|
||||
frontend = CONTROLLER.parent
|
||||
build = build_frontend(frontend)
|
||||
today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode()
|
||||
|
||||
assert 'id="mobile-pull-refresh"' in html
|
||||
assert 'role="status"' in html
|
||||
assert html.index('static/mobile-pull-refresh.js') < html.index('static/dashboard.js')
|
||||
assert "createMobilePullRefresh" in html
|
||||
assert "contextPoller.refresh({ force: true })" in html
|
||||
assert "createMobilePullRefresh" in today_bundle
|
||||
assert "liveDataStatus" in today_bundle
|
||||
assert build.feature_bundles["today-timer"].runtime_name in build.service_worker_source
|
||||
@@ -1023,6 +1023,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-search-preview-nav.js",
|
||||
"/dashboard/static/mobile-plan-today-nav.js",
|
||||
"/dashboard/static/mobile-find-work-nav.js",
|
||||
"/dashboard/static/mobile-pull-refresh.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/voice-transcript-store.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
|
||||
Reference in new issue
Block a user