feat: recover progressive mobile captures (Closes #1409)
This commit is contained in:
parent
dcad1da1da
commit
8b56618e62
|
|
@ -6430,6 +6430,7 @@
|
|||
if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) {
|
||||
issueCapture.saveDraft({repository:'', labelIds:[],
|
||||
title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body});
|
||||
progressiveCaptureHandoff.complete?.();
|
||||
await openCreateIssueSheet(false);
|
||||
}
|
||||
qs('#create-issue-repository').addEventListener('change', event => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ function createProgressiveCapture({
|
|||
let saving = null;
|
||||
let saved = false;
|
||||
let handedOff = false;
|
||||
const checkpointKey = 'stackchain.progressive-capture.v1';
|
||||
|
||||
function listen(element, name, callback) {
|
||||
element?.addEventListener?.(name, callback);
|
||||
|
|
@ -41,8 +42,58 @@ function createProgressiveCapture({
|
|||
root?.classList.remove('open');
|
||||
}
|
||||
|
||||
function readCheckpoints() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(checkpointKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && typeof item.body === 'string');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function readCheckpoint() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
return readCheckpoints().find(item => item.ownerLogin === login) || null;
|
||||
}
|
||||
|
||||
function checkpoint() {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
if (!ownerLogin) return false;
|
||||
try {
|
||||
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
|
||||
items.push({
|
||||
ownerLogin,
|
||||
title:String(title?.value || '').slice(0, 255),
|
||||
body:String(body?.value || '').slice(0, 10000),
|
||||
});
|
||||
storage?.setItem(checkpointKey, JSON.stringify({
|
||||
version:1, items,
|
||||
}));
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function clearCheckpoint(expected) {
|
||||
const current = readCheckpoint();
|
||||
if (!current || current.title !== expected.title || current.body !== expected.body) return false;
|
||||
try {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
|
||||
if (items.length) storage?.setItem(checkpointKey, JSON.stringify({version:1, items}));
|
||||
else storage?.removeItem(checkpointKey);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function open(event) {
|
||||
saved = false;
|
||||
const recovered = readCheckpoint();
|
||||
if (recovered) {
|
||||
if (title) title.value = recovered.title;
|
||||
if (body) body.value = recovered.body;
|
||||
if (status) status.textContent = 'Unfinished capture restored from this phone.';
|
||||
}
|
||||
if (heading) heading.textContent = 'Capture work';
|
||||
root?.classList.add('open');
|
||||
title?.focus?.();
|
||||
|
|
@ -51,10 +102,10 @@ function createProgressiveCapture({
|
|||
async function saveDraft() {
|
||||
if (saved || saving) return saving;
|
||||
save.disabled = true;
|
||||
saving = Promise.resolve().then(() => captures.save({
|
||||
title:title?.value || '', body:body?.value || '',
|
||||
})).then(() => {
|
||||
const draft = {title:title?.value || '', body:body?.value || ''};
|
||||
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
|
||||
saved = true;
|
||||
clearCheckpoint(draft);
|
||||
if (title) title.value = '';
|
||||
if (body) body.value = '';
|
||||
close();
|
||||
|
|
@ -94,6 +145,8 @@ function createProgressiveCapture({
|
|||
listen(save, 'click', saveDraft);
|
||||
listen(file, 'click', fileNow);
|
||||
listen(cancel, 'click', close);
|
||||
listen(title, 'input', checkpoint);
|
||||
listen(body, 'input', checkpoint);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +157,7 @@ function createProgressiveCapture({
|
|||
open:Boolean(root?.classList.contains('open')),
|
||||
title:title?.value || '', body:body?.value || '',
|
||||
};
|
||||
state.complete = () => clearCheckpoint(state);
|
||||
root?.classList.remove('progressive-capture');
|
||||
file.disabled = false;
|
||||
listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback));
|
||||
|
|
|
|||
|
|
@ -28,8 +28,12 @@ const button = {dataset:{mobileTask:'new'}, addEventListener(name, callback){lis
|
|||
const save = {disabled:false, addEventListener(name, callback){listeners.set('save:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('save:'+name)===callback) listeners.delete('save:'+name);}};
|
||||
const file = {disabled:false, addEventListener(){}, removeEventListener(){}};
|
||||
const cancel = {addEventListener(){}, removeEventListener(){}};
|
||||
const title = {value:'', focused:0, focus(){this.focused++;}};
|
||||
const body = {value:''};
|
||||
const field = name => ({value:'', focused:0, focus(){this.focused++;},
|
||||
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
|
||||
removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);},
|
||||
});
|
||||
const title = field('title');
|
||||
const body = field('body');
|
||||
const heading = {textContent:''};
|
||||
const status = {textContent:''};
|
||||
const classes = new Set();
|
||||
|
|
@ -43,15 +47,16 @@ const document = {
|
|||
querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);},
|
||||
};
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const flow = createProgressiveCapture({document, storage, getLogin:()=> 'timmy', createCaptures:createUnfiledCaptures, createId:()=> 'draft-1', now:()=> 42});
|
||||
flow.start();
|
||||
listeners.get('new:click')({currentTarget:button});
|
||||
title.value='Production outage'; body.value='Investigate mobile reports';
|
||||
title.value='Production outage'; listeners.get('title:input')();
|
||||
body.value='Investigate mobile reports'; listeners.get('body:input')();
|
||||
await listeners.get('save:click')();
|
||||
await listeners.get('save:click')();
|
||||
const stored=JSON.parse(values.get('stackchain.unfiled-issues.v1'));
|
||||
console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()]}));
|
||||
console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()],checkpoint:values.get('stackchain.progressive-capture.v1')||null}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
|
|
@ -66,7 +71,136 @@ console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,headi
|
|||
"body": "Investigate mobile reports",
|
||||
"savedAt": 42,
|
||||
}],
|
||||
"listeners": ["new:click", "save:click"],
|
||||
"listeners": ["new:click", "save:click", "title:input", "body:input"],
|
||||
"checkpoint": None,
|
||||
}
|
||||
|
||||
|
||||
def test_failed_progressive_save_keeps_rendered_fields_and_checkpoint_for_retry():
|
||||
result = run_capture(r"""
|
||||
const listeners={};
|
||||
const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){this.focused=true;}});
|
||||
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
|
||||
const title=control('title'); const body=control('body'); const status={textContent:''}; const classes=new Set();
|
||||
const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
|
||||
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
|
||||
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
|
||||
'#cancel-new-issue':cancel,'#my-work-action-status':status};
|
||||
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
|
||||
const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:()=>({save(){throw new Error('Phone storage is full.');}})});
|
||||
flow.start(); listeners['new:click']();
|
||||
title.value='Keep title'; listeners['title:input'](); body.value='Keep note'; listeners['body:input']();
|
||||
const saved=await listeners['save:click']();
|
||||
console.log(JSON.stringify({saved,open:classes.has('open'),title:title.value,body:body.value,status:status.textContent,
|
||||
checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1'))}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"saved": False,
|
||||
"open": True,
|
||||
"title": "Keep title",
|
||||
"body": "Keep note",
|
||||
"status": "Phone storage is full.",
|
||||
"checkpoint": {
|
||||
"version": 1,
|
||||
"items": [{
|
||||
"ownerLogin": "timmy",
|
||||
"title": "Keep title",
|
||||
"body": "Keep note",
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_progressive_capture_restores_latest_checkpoint_only_for_its_account():
|
||||
result = run_capture(r"""
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
function mount(login) {
|
||||
const listeners = new Map();
|
||||
const control = name => ({disabled:false,value:'',
|
||||
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
|
||||
removeEventListener(){},focus(){},
|
||||
});
|
||||
const button=control('new'); const save=control('save'); const file=control('file');
|
||||
const cancel=control('cancel'); const title=control('title'); const body=control('body');
|
||||
const status={textContent:''}; const classes=new Set();
|
||||
const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
|
||||
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
|
||||
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
|
||||
'#cancel-new-issue':cancel,'#my-work-action-status':status};
|
||||
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
|
||||
const flow=createProgressiveCapture({document,storage,getLogin:()=>login,createCaptures:createUnfiledCaptures});
|
||||
flow.start();
|
||||
return {flow,listeners,title,body,status,button};
|
||||
}
|
||||
const first=mount('timmy'); first.listeners.get('new:click')();
|
||||
first.title.value='Production outage'; first.listeners.get('title:input')?.();
|
||||
first.body.value='Latest phone note'; first.listeners.get('body:input')?.();
|
||||
const restored=mount('timmy'); restored.listeners.get('new:click')();
|
||||
const other=mount('alexander'); other.listeners.get('new:click')();
|
||||
other.title.value='Alexander note'; other.listeners.get('title:input')?.();
|
||||
const timmyAgain=mount('timmy'); timmyAgain.listeners.get('new:click')();
|
||||
console.log(JSON.stringify({
|
||||
restored:{title:restored.title.value,body:restored.body.value,status:restored.status.textContent},
|
||||
other:{title:other.title.value,body:other.body.value,status:other.status.textContent},
|
||||
timmyAgain:{title:timmyAgain.title.value,body:timmyAgain.body.value},
|
||||
checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1') || 'null'),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"restored": {
|
||||
"title": "Production outage",
|
||||
"body": "Latest phone note",
|
||||
"status": "Unfinished capture restored from this phone.",
|
||||
},
|
||||
"other": {"title": "Alexander note", "body": "", "status": ""},
|
||||
"timmyAgain": {"title": "Production outage", "body": "Latest phone note"},
|
||||
"checkpoint": {
|
||||
"version": 1,
|
||||
"items": [
|
||||
{"ownerLogin": "timmy", "title": "Production outage", "body": "Latest phone note"},
|
||||
{"ownerLogin": "alexander", "title": "Alexander note", "body": ""},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_hydration_handoff_clears_checkpoint_only_after_explicit_completion():
|
||||
result = run_capture(r"""
|
||||
const listeners={};
|
||||
const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){}});
|
||||
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
|
||||
const title=control('title'); const body=control('body'); const root={classList:{add(){},remove(){},contains:()=>true}};
|
||||
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
|
||||
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
|
||||
'#cancel-new-issue':cancel,'#my-work-action-status':{textContent:''}};
|
||||
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
|
||||
const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:createUnfiledCaptures});
|
||||
flow.start(); title.value='Hand off'; listeners['title:input'](); body.value='Preserve me'; listeners['body:input']();
|
||||
const handoff=flow.handoff();
|
||||
const before=JSON.parse(values.get('stackchain.progressive-capture.v1'));
|
||||
if (typeof handoff.complete === 'function') handoff.complete();
|
||||
console.log(JSON.stringify({before,after:values.get('stackchain.progressive-capture.v1')||null}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"before": {
|
||||
"version": 1,
|
||||
"items": [{
|
||||
"ownerLogin": "timmy",
|
||||
"title": "Hand off",
|
||||
"body": "Preserve me",
|
||||
}],
|
||||
},
|
||||
"after": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -109,7 +243,7 @@ console.log(JSON.stringify({filed,requests,open:classes.has('open'),status:statu
|
|||
"focused": 2,
|
||||
"title": "Keep this",
|
||||
"body": "Do not lose this note",
|
||||
"before": ["cancel:click", "file:click", "new:click", "save:click"],
|
||||
"before": ["cancel:click", "file:click", "new:click", "save:click", "title:input"],
|
||||
"after": [],
|
||||
"handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"},
|
||||
"second": None,
|
||||
|
|
@ -122,6 +256,7 @@ def test_hydrated_dashboard_adopts_open_progressive_capture_without_losing_field
|
|||
assert "const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();" in source
|
||||
assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source
|
||||
assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source
|
||||
assert "progressiveCaptureHandoff.complete?.();" in source
|
||||
assert "await openCreateIssueSheet(false);" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user