feat: add mobile pull-to-refresh for My Work (Closes #1002)
This commit is contained in:
parent
a2606f1dc2
commit
386acda1dc
|
|
@ -1061,3 +1061,47 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
/* One-handed live-data recovery on the mobile Home surface. */
|
||||||
|
.mobile-pull-refresh {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.mobile-pull-refresh {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(3.25rem + env(safe-area-inset-top));
|
||||||
|
z-index: 8;
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
margin: 0 auto -2rem;
|
||||||
|
padding: .45rem .8rem;
|
||||||
|
border: 1px solid rgba(125, 211, 252, .45);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(11, 18, 32, .96);
|
||||||
|
color: #dbeafe;
|
||||||
|
font-size: .78rem;
|
||||||
|
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .28);
|
||||||
|
transform: translateY(.5rem);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-pull-refresh[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-pull-refresh[data-state="ready"],
|
||||||
|
.mobile-pull-refresh[data-state="success"] {
|
||||||
|
border-color: rgba(52, 211, 153, .7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-pull-refresh[data-state="error"] {
|
||||||
|
border-color: rgba(251, 113, 133, .75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) and (prefers-reduced-motion: no-preference) {
|
||||||
|
.mobile-pull-refresh {
|
||||||
|
transition: transform 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7183,6 +7183,7 @@
|
||||||
intervalMs: 8000,
|
intervalMs: 8000,
|
||||||
});
|
});
|
||||||
function load() { return contextPoller.refresh({ force: true }); }
|
function load() { return contextPoller.refresh({ force: true }); }
|
||||||
|
createMobilePullRefresh({refresh:load}).start();
|
||||||
const liveDataStatusSheet = qs('#live-data-status-sheet');
|
const liveDataStatusSheet = qs('#live-data-status-sheet');
|
||||||
const liveDataStatusTrigger = qs('#open-live-data-status');
|
const liveDataStatusTrigger = qs('#open-live-data-status');
|
||||||
let latestLiveFreshness = {};
|
let latestLiveFreshness = {};
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div id="mobile-pull-refresh" class="mobile-pull-refresh" role="status" aria-live="polite" hidden></div>
|
||||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||||
<div class="my-work-header">
|
<div class="my-work-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1634,6 +1635,7 @@
|
||||||
<script src="static/context-poller.js"></script>
|
<script src="static/context-poller.js"></script>
|
||||||
<script src="static/live-data-status.js"></script>
|
<script src="static/live-data-status.js"></script>
|
||||||
<script src="static/mobile-task-dock.js"></script>
|
<script src="static/mobile-task-dock.js"></script>
|
||||||
|
<script src="static/mobile-pull-refresh.js"></script>
|
||||||
<script src="static/mobile-work-entry.js"></script>
|
<script src="static/mobile-work-entry.js"></script>
|
||||||
<script src="static/mobile-queue-launcher.js"></script>
|
<script src="static/mobile-queue-launcher.js"></script>
|
||||||
<script src="static/mobile-start-day.js"></script>
|
<script src="static/mobile-start-day.js"></script>
|
||||||
|
|
|
||||||
88
frontend/mobile-pull-refresh.js
Normal file
88
frontend/mobile-pull-refresh.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||||
|
else root.createMobilePullRefresh = factory;
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
|
||||||
|
const surface = options.surface || document.querySelector('#my-work');
|
||||||
|
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
|
||||||
|
const threshold = options.threshold || 64;
|
||||||
|
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
|
||||||
|
const getScrollY = options.getScrollY || (() => window.scrollY);
|
||||||
|
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
|
||||||
|
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
|
||||||
|
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
|
||||||
|
let gesture = null;
|
||||||
|
let inFlight = null;
|
||||||
|
|
||||||
|
function show(state, text) {
|
||||||
|
indicator.hidden = false;
|
||||||
|
indicator.dataset.state = state;
|
||||||
|
indicator.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGesture() {
|
||||||
|
gesture = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsOnExcludedTarget(target) {
|
||||||
|
if (!target) return false;
|
||||||
|
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
|
||||||
|
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||||
|
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event) {
|
||||||
|
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
|
||||||
|
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
|
||||||
|
surface.setPointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event) {
|
||||||
|
if (!gesture || gesture.id !== event.pointerId) return;
|
||||||
|
const dx = event.clientX - gesture.x;
|
||||||
|
const dy = event.clientY - gesture.y;
|
||||||
|
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
|
||||||
|
event.preventDefault();
|
||||||
|
gesture.ready = dy >= threshold;
|
||||||
|
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPointerUp(event) {
|
||||||
|
if (!gesture || gesture.id !== event.pointerId) return;
|
||||||
|
const shouldRefresh = gesture.ready;
|
||||||
|
resetGesture();
|
||||||
|
surface.releasePointerCapture?.(event.pointerId);
|
||||||
|
if (!shouldRefresh || inFlight) return;
|
||||||
|
show('refreshing', 'Refreshing My Work…');
|
||||||
|
inFlight = Promise.resolve().then(options.refresh);
|
||||||
|
try {
|
||||||
|
await inFlight;
|
||||||
|
show('success', 'My Work is up to date');
|
||||||
|
} catch (_) {
|
||||||
|
show('error', 'Could not refresh · pull to retry');
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel() {
|
||||||
|
resetGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
surface.addEventListener('pointerdown', onPointerDown);
|
||||||
|
surface.addEventListener('pointermove', onPointerMove);
|
||||||
|
surface.addEventListener('pointerup', onPointerUp);
|
||||||
|
surface.addEventListener('pointercancel', onPointerCancel);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
surface.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
surface.removeEventListener('pointermove', onPointerMove);
|
||||||
|
surface.removeEventListener('pointerup', onPointerUp);
|
||||||
|
surface.removeEventListener('pointercancel', onPointerCancel);
|
||||||
|
resetGesture();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -86,6 +86,7 @@ const SHELL = [
|
||||||
BASE + 'static/mobile-search-preview-nav.js',
|
BASE + 'static/mobile-search-preview-nav.js',
|
||||||
BASE + 'static/mobile-plan-today-nav.js',
|
BASE + 'static/mobile-plan-today-nav.js',
|
||||||
BASE + 'static/mobile-find-work-nav.js',
|
BASE + 'static/mobile-find-work-nav.js',
|
||||||
|
BASE + 'static/mobile-pull-refresh.js',
|
||||||
BASE + 'static/checklist-conflict.js',
|
BASE + 'static/checklist-conflict.js',
|
||||||
BASE + 'static/voice-transcript-store.js',
|
BASE + 'static/voice-transcript-store.js',
|
||||||
BASE + 'static/voice-issue-capture.js',
|
BASE + 'static/voice-issue-capture.js',
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ FEATURE_SOURCES = {
|
||||||
),
|
),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
|
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
browser_errors: list[str] = []
|
browser_errors: list[str] = []
|
||||||
failed_responses: list[str] = []
|
failed_responses: list[str] = []
|
||||||
workspace_requests: list[str] = []
|
workspace_requests: list[str] = []
|
||||||
|
live_requests: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
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
|
if "feature-today-timer-" in request.url
|
||||||
else None,
|
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.goto(origin + "/", wait_until="networkidle")
|
||||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
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")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
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")
|
dock = page.locator("#mobile-task-dock")
|
||||||
expect(dock).to_be_visible()
|
expect(dock).to_be_visible()
|
||||||
expect(dock.locator("button")).to_have_count(5)
|
expect(dock.locator("button")).to_have_count(5)
|
||||||
|
|
|
||||||
188
tests/test_mobile_pull_refresh.py
Normal file
188
tests/test_mobile_pull_refresh.py
Normal file
|
|
@ -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-search-preview-nav.js",
|
||||||
"/dashboard/static/mobile-plan-today-nav.js",
|
"/dashboard/static/mobile-plan-today-nav.js",
|
||||||
"/dashboard/static/mobile-find-work-nav.js",
|
"/dashboard/static/mobile-find-work-nav.js",
|
||||||
|
"/dashboard/static/mobile-pull-refresh.js",
|
||||||
"/dashboard/static/checklist-conflict.js",
|
"/dashboard/static/checklist-conflict.js",
|
||||||
"/dashboard/static/voice-transcript-store.js",
|
"/dashboard/static/voice-transcript-store.js",
|
||||||
"/dashboard/static/voice-issue-capture.js",
|
"/dashboard/static/voice-issue-capture.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user