Compare commits
No commits in common. "e7e8c24d058e10f88113e50e28852c4a0c913e04" and "dcad1da1da854f70d2893b6117a3219bf8d5e0d9" have entirely different histories.
e7e8c24d05
...
dcad1da1da
|
|
@ -6430,7 +6430,6 @@
|
||||||
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,7 +31,6 @@ 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);
|
||||||
|
|
@ -42,58 +41,8 @@ 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?.();
|
||||||
|
|
@ -102,10 +51,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;
|
||||||
const draft = {title:title?.value || '', body:body?.value || ''};
|
saving = Promise.resolve().then(() => captures.save({
|
||||||
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
|
title:title?.value || '', body:body?.value || '',
|
||||||
|
})).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();
|
||||||
|
|
@ -145,8 +94,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,7 +104,6 @@ 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,12 +28,8 @@ 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 field = name => ({value:'', focused:0, focus(){this.focused++;},
|
const title = {value:'', focused:0, focus(){this.focused++;}};
|
||||||
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
|
const body = {value:''};
|
||||||
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();
|
||||||
|
|
@ -47,16 +43,15 @@ 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),removeItem:key=>values.delete(key)};
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||||
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'; listeners.get('title:input')();
|
title.value='Production outage'; body.value='Investigate mobile reports';
|
||||||
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()],checkpoint:values.get('stackchain.progressive-capture.v1')||null}));
|
console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()]}));
|
||||||
""")
|
""")
|
||||||
|
|
||||||
assert result == {
|
assert result == {
|
||||||
|
|
@ -71,136 +66,7 @@ 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", "title:input", "body:input"],
|
"listeners": ["new:click", "save:click"],
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -243,7 +109,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", "title:input"],
|
"before": ["cancel:click", "file:click", "new:click", "save:click"],
|
||||||
"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,
|
||||||
|
|
@ -256,7 +122,6 @@ 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