feat: keep progressive My Work live (Closes #1411)
This commit is contained in:
parent
e7e8c24d05
commit
f9b29d8d14
|
|
@ -1,8 +1,11 @@
|
||||||
function createProgressiveMyWork({ document, fetchSnapshot }) {
|
function createProgressiveMyWork({
|
||||||
|
document, fetchSnapshot, pollerOptions = {}, lifecycleTarget = globalThis,
|
||||||
|
}) {
|
||||||
const list = document.querySelector('#my-work-list');
|
const list = document.querySelector('#my-work-list');
|
||||||
const status = document.querySelector('#my-work-status');
|
const status = document.querySelector('#my-work-status');
|
||||||
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
|
const lifecycleListeners = [];
|
||||||
let items = [];
|
let items = [];
|
||||||
let active = 'all';
|
let active = 'all';
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
@ -10,6 +13,7 @@ function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||||
let liveSnapshot = null;
|
let liveSnapshot = null;
|
||||||
let liveSnapshotPromise = null;
|
let liveSnapshotPromise = null;
|
||||||
let confirmedLogin = '';
|
let confirmedLogin = '';
|
||||||
|
let poller = null;
|
||||||
const deferredQueues = {
|
const deferredQueues = {
|
||||||
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
|
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||||
};
|
};
|
||||||
|
|
@ -60,6 +64,40 @@ function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||||
listeners.push([button, listener]);
|
listeners.push([button, listener]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applySnapshot = snapshot => {
|
||||||
|
if (stopped) return false;
|
||||||
|
const transferable = snapshot && typeof snapshot === 'object' &&
|
||||||
|
Object.prototype.hasOwnProperty.call(snapshot, 'context');
|
||||||
|
if (transferable) liveSnapshot = snapshot;
|
||||||
|
const context = snapshot?.context || snapshot || {};
|
||||||
|
confirmedLogin = String(context.user?.login || '').trim();
|
||||||
|
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
||||||
|
updateCounts();
|
||||||
|
render();
|
||||||
|
const assigned = items.filter(item => item.is_assigned).length;
|
||||||
|
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof createContextPoller === 'function') {
|
||||||
|
poller = createContextPoller({
|
||||||
|
...pollerOptions,
|
||||||
|
fetchContext: fetchSnapshot,
|
||||||
|
onSnapshot: applySnapshot,
|
||||||
|
onError: () => {
|
||||||
|
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
||||||
|
},
|
||||||
|
isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)),
|
||||||
|
});
|
||||||
|
const recover = () => {
|
||||||
|
if (!document.hidden) void poller.refresh();
|
||||||
|
};
|
||||||
|
['online', 'visibilitychange'].forEach(eventName => {
|
||||||
|
lifecycleTarget.addEventListener?.(eventName, recover);
|
||||||
|
lifecycleListeners.push([eventName, recover]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login() { return confirmedLogin; },
|
login() { return confirmedLogin; },
|
||||||
handoff() {
|
handoff() {
|
||||||
|
|
@ -76,6 +114,14 @@ function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||||
},
|
},
|
||||||
async start() {
|
async start() {
|
||||||
if (status) status.textContent = 'Loading assigned work…';
|
if (status) status.textContent = 'Loading assigned work…';
|
||||||
|
if (poller) {
|
||||||
|
const request = poller.start();
|
||||||
|
liveSnapshotPromise = request.then(snapshot => (
|
||||||
|
snapshot && typeof snapshot === 'object' &&
|
||||||
|
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
|
||||||
|
));
|
||||||
|
return Boolean(await request);
|
||||||
|
}
|
||||||
const request = Promise.resolve().then(() => fetchSnapshot());
|
const request = Promise.resolve().then(() => fetchSnapshot());
|
||||||
liveSnapshotPromise = request.then(snapshot => (
|
liveSnapshotPromise = request.then(snapshot => (
|
||||||
snapshot && typeof snapshot === 'object' &&
|
snapshot && typeof snapshot === 'object' &&
|
||||||
|
|
@ -84,18 +130,9 @@ function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||||
try {
|
try {
|
||||||
const snapshot = await request;
|
const snapshot = await request;
|
||||||
if (stopped) return false;
|
if (stopped) return false;
|
||||||
const transferable = snapshot && typeof snapshot === 'object' &&
|
const applied = applySnapshot(snapshot);
|
||||||
Object.prototype.hasOwnProperty.call(snapshot, 'context');
|
if (!liveSnapshot) liveSnapshotPromise = null;
|
||||||
if (transferable) liveSnapshot = snapshot;
|
return applied;
|
||||||
else liveSnapshotPromise = null;
|
|
||||||
const context = snapshot?.context || snapshot || {};
|
|
||||||
confirmedLogin = String(context.user?.login || '').trim();
|
|
||||||
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
|
||||||
updateCounts();
|
|
||||||
render();
|
|
||||||
const assigned = items.filter(item => item.is_assigned).length;
|
|
||||||
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
|
||||||
return true;
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -103,7 +140,10 @@ function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
poller?.stop();
|
||||||
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
|
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
|
||||||
|
lifecycleListeners.forEach(([eventName, listener]) =>
|
||||||
|
lifecycleTarget.removeEventListener?.(eventName, listener));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -111,9 +151,17 @@ function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||||
window.stackchainProgressiveMyWork = createProgressiveMyWork({
|
window.stackchainProgressiveMyWork = createProgressiveMyWork({
|
||||||
document,
|
document,
|
||||||
fetchSnapshot: async () => {
|
lifecycleTarget: window,
|
||||||
const response = await fetch('api/v1/live', { headers:{Accept:'application/json'} });
|
fetchSnapshot: async (revisions = {}, { signal } = {}) => {
|
||||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
const query = createContextPoller.buildRevisionQuery(revisions);
|
||||||
|
const response = await fetch('api/v1/live' + (query ? '?' + query : ''), {
|
||||||
|
headers:{Accept:'application/json'}, signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error('HTTP ' + response.status);
|
||||||
|
error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -243,4 +243,125 @@ const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
assert json.loads(result.stdout) == {"before": "", "after": "timmy", "stopped": "timmy"}
|
assert json.loads(result.stdout) == {"before": "", "after": "timmy", "stopped": "timmy"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progressive_my_work_retries_a_failed_snapshot_without_reloading():
|
||||||
|
harness = f"""
|
||||||
|
const fs=require('fs'); const vm=require('vm');
|
||||||
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
||||||
|
const document={{
|
||||||
|
querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null,
|
||||||
|
querySelectorAll:()=>[],
|
||||||
|
}};
|
||||||
|
let timer=null; let attempts=0;
|
||||||
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||||
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context);
|
||||||
|
context.createContextPoller=context.module.exports; context.module={{exports:{{}}}};
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
||||||
|
const flow=context.module.exports({{
|
||||||
|
document,
|
||||||
|
fetchSnapshot:async()=>{{
|
||||||
|
attempts += 1;
|
||||||
|
if(attempts===1) throw new Error('temporary outage');
|
||||||
|
return {{context:{{user:{{login:'timmy'}},issues:[{{
|
||||||
|
number:9,title:'Recovered assignment',repository:'stackchain/dashboard',
|
||||||
|
assignees:['timmy'],url:'https://forge.example/issues/9',
|
||||||
|
}}],pull_requests:[]}},events:[],notifications:[]}};
|
||||||
|
}},
|
||||||
|
pollerOptions:{{
|
||||||
|
setTimer:callback=>{{timer=callback; return 1;}}, clearTimer:()=>{{timer=null;}},
|
||||||
|
setDeadlineTimer:()=>2, clearDeadlineTimer:()=>{{}}, intervalMs:5,
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
const first=await flow.start();
|
||||||
|
const reconnecting=status.textContent;
|
||||||
|
if(!timer) throw new Error('retry was not scheduled');
|
||||||
|
timer(); await Promise.resolve(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve));
|
||||||
|
console.log(JSON.stringify({{first,reconnecting,attempts,status:status.textContent,html:list.innerHTML}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"first": False,
|
||||||
|
"reconnecting": "Assigned work is reconnecting…",
|
||||||
|
"attempts": 2,
|
||||||
|
"status": "1 assigned work item ready.",
|
||||||
|
"html": '<article class="my-work-card progressive-my-work-card"><a class="my-work-card-main" href="https://forge.example/issues/9"><strong>Recovered assignment</strong><span class="small">stackchain/dashboard#9 · Assigned to you</span></a></article>',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progressive_my_work_refreshes_with_revisions_and_coalesces_lifecycle_recovery():
|
||||||
|
harness = f"""
|
||||||
|
const fs=require('fs'); const vm=require('vm');
|
||||||
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
||||||
|
const document={{
|
||||||
|
hidden:false,
|
||||||
|
querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null,
|
||||||
|
querySelectorAll:()=>[],
|
||||||
|
}};
|
||||||
|
const handlers={{}}; const removed=[];
|
||||||
|
const lifecycleTarget={{
|
||||||
|
addEventListener:(name,callback)=>{{handlers[name]=callback;}},
|
||||||
|
removeEventListener:(name,callback)=>{{if(handlers[name]===callback){{removed.push(name);delete handlers[name];}}}},
|
||||||
|
}};
|
||||||
|
let timer=null; let calls=[]; let resolveRefresh;
|
||||||
|
const initial={{
|
||||||
|
context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[],
|
||||||
|
revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2',notifications:'0011223344556677.3'}},
|
||||||
|
freshness:{{fresh_for_seconds:8,sections:{{context:{{degraded:false,age_seconds:2}},events:{{degraded:false,age_seconds:2}},notifications:{{degraded:false,age_seconds:2}}}}}},
|
||||||
|
}};
|
||||||
|
const updated={{...initial,context:{{user:{{login:'timmy'}},issues:[{{number:10,title:'New assignment',repository:'stackchain/dashboard',assignees:['timmy']}}],pull_requests:[]}}}};
|
||||||
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||||
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context);
|
||||||
|
context.createContextPoller=context.module.exports; context.module={{exports:{{}}}};
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
||||||
|
const flow=context.module.exports({{
|
||||||
|
document,lifecycleTarget,
|
||||||
|
fetchSnapshot:(revisions,options)=>{{
|
||||||
|
calls.push({{revisions,hasSignal:Boolean(options.signal)}});
|
||||||
|
if(calls.length===1)return Promise.resolve(initial);
|
||||||
|
return new Promise(resolve=>{{resolveRefresh=()=>resolve(updated);}});
|
||||||
|
}},
|
||||||
|
pollerOptions:{{
|
||||||
|
setTimer:callback=>{{timer=callback;return 1;}},clearTimer:()=>{{timer=null;}},
|
||||||
|
setDeadlineTimer:()=>2,clearDeadlineTimer:()=>{{}},intervalMs:5,
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await flow.start(); const freshnessTimer=timer; freshnessTimer();
|
||||||
|
handlers.online(); handlers.visibilitychange();
|
||||||
|
await Promise.resolve();
|
||||||
|
const callsWhilePending=calls.length;
|
||||||
|
resolveRefresh(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve));
|
||||||
|
const handoff=flow.handoff(); flow.stop();
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
callsWhilePending,calls,latestTitle:handoff.liveSnapshot.context.issues[0].title,
|
||||||
|
removed:removed.sort(),status:status.textContent,
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||||
|
state = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert state["callsWhilePending"] == 2
|
||||||
|
assert state["calls"][0] == {"revisions": {}, "hasSignal": True}
|
||||||
|
assert state["calls"][1] == {
|
||||||
|
"revisions": {
|
||||||
|
"context": "0123456789abcdef.1",
|
||||||
|
"events": "fedcba9876543210.2",
|
||||||
|
"notifications": "0011223344556677.3",
|
||||||
|
},
|
||||||
|
"hasSignal": True,
|
||||||
|
}
|
||||||
|
assert state["latestTitle"] == "New assignment"
|
||||||
|
assert state["removed"] == ["online", "visibilitychange"]
|
||||||
|
assert state["status"] == "1 assigned work item ready."
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user