commit
e7e8c24d05
|
|
@ -6430,6 +6430,7 @@
|
||||||
if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) {
|
if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) {
|
||||||
issueCapture.saveDraft({repository:'', labelIds:[],
|
issueCapture.saveDraft({repository:'', labelIds:[],
|
||||||
title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body});
|
title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body});
|
||||||
|
progressiveCaptureHandoff.complete?.();
|
||||||
await openCreateIssueSheet(false);
|
await openCreateIssueSheet(false);
|
||||||
}
|
}
|
||||||
qs('#create-issue-repository').addEventListener('change', event => {
|
qs('#create-issue-repository').addEventListener('change', event => {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ function createProgressiveCapture({
|
||||||
let saving = null;
|
let saving = null;
|
||||||
let saved = false;
|
let saved = false;
|
||||||
let handedOff = false;
|
let handedOff = false;
|
||||||
|
const checkpointKey = 'stackchain.progressive-capture.v1';
|
||||||
|
|
||||||
function listen(element, name, callback) {
|
function listen(element, name, callback) {
|
||||||
element?.addEventListener?.(name, callback);
|
element?.addEventListener?.(name, callback);
|
||||||
|
|
@ -41,8 +42,58 @@ function createProgressiveCapture({
|
||||||
root?.classList.remove('open');
|
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) {
|
function open(event) {
|
||||||
saved = false;
|
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';
|
if (heading) heading.textContent = 'Capture work';
|
||||||
root?.classList.add('open');
|
root?.classList.add('open');
|
||||||
title?.focus?.();
|
title?.focus?.();
|
||||||
|
|
@ -51,10 +102,10 @@ function createProgressiveCapture({
|
||||||
async function saveDraft() {
|
async function saveDraft() {
|
||||||
if (saved || saving) return saving;
|
if (saved || saving) return saving;
|
||||||
save.disabled = true;
|
save.disabled = true;
|
||||||
saving = Promise.resolve().then(() => captures.save({
|
const draft = {title:title?.value || '', body:body?.value || ''};
|
||||||
title:title?.value || '', body:body?.value || '',
|
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
|
||||||
})).then(() => {
|
|
||||||
saved = true;
|
saved = true;
|
||||||
|
clearCheckpoint(draft);
|
||||||
if (title) title.value = '';
|
if (title) title.value = '';
|
||||||
if (body) body.value = '';
|
if (body) body.value = '';
|
||||||
close();
|
close();
|
||||||
|
|
@ -94,6 +145,8 @@ function createProgressiveCapture({
|
||||||
listen(save, 'click', saveDraft);
|
listen(save, 'click', saveDraft);
|
||||||
listen(file, 'click', fileNow);
|
listen(file, 'click', fileNow);
|
||||||
listen(cancel, 'click', close);
|
listen(cancel, 'click', close);
|
||||||
|
listen(title, 'input', checkpoint);
|
||||||
|
listen(body, 'input', checkpoint);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +157,7 @@ function createProgressiveCapture({
|
||||||
open:Boolean(root?.classList.contains('open')),
|
open:Boolean(root?.classList.contains('open')),
|
||||||
title:title?.value || '', body:body?.value || '',
|
title:title?.value || '', body:body?.value || '',
|
||||||
};
|
};
|
||||||
|
state.complete = () => clearCheckpoint(state);
|
||||||
root?.classList.remove('progressive-capture');
|
root?.classList.remove('progressive-capture');
|
||||||
file.disabled = false;
|
file.disabled = false;
|
||||||
listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback));
|
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 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 file = {disabled:false, addEventListener(){}, removeEventListener(){}};
|
||||||
const cancel = {addEventListener(){}, removeEventListener(){}};
|
const cancel = {addEventListener(){}, removeEventListener(){}};
|
||||||
const title = {value:'', focused:0, focus(){this.focused++;}};
|
const field = name => ({value:'', focused:0, focus(){this.focused++;},
|
||||||
const body = {value:''};
|
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 heading = {textContent:''};
|
||||||
const status = {textContent:''};
|
const status = {textContent:''};
|
||||||
const classes = new Set();
|
const classes = new Set();
|
||||||
|
|
@ -43,15 +47,16 @@ const document = {
|
||||||
querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);},
|
querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);},
|
||||||
};
|
};
|
||||||
const values = new Map();
|
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});
|
const flow = createProgressiveCapture({document, storage, getLogin:()=> 'timmy', createCaptures:createUnfiledCaptures, createId:()=> 'draft-1', now:()=> 42});
|
||||||
flow.start();
|
flow.start();
|
||||||
listeners.get('new:click')({currentTarget:button});
|
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')();
|
||||||
await listeners.get('save:click')();
|
await listeners.get('save:click')();
|
||||||
const stored=JSON.parse(values.get('stackchain.unfiled-issues.v1'));
|
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 == {
|
assert result == {
|
||||||
|
|
@ -66,7 +71,136 @@ console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,headi
|
||||||
"body": "Investigate mobile reports",
|
"body": "Investigate mobile reports",
|
||||||
"savedAt": 42,
|
"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,
|
"focused": 2,
|
||||||
"title": "Keep this",
|
"title": "Keep this",
|
||||||
"body": "Do not lose this note",
|
"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": [],
|
"after": [],
|
||||||
"handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"},
|
"handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"},
|
||||||
"second": None,
|
"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 "const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();" in source
|
||||||
assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source
|
assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source
|
||||||
assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source
|
assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source
|
||||||
|
assert "progressiveCaptureHandoff.complete?.();" in source
|
||||||
assert "await openCreateIssueSheet(false);" in source
|
assert "await openCreateIssueSheet(false);" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user