Visually verify mobile evidence in final filing review #836
|
|
@ -654,8 +654,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-filing-review-summary > div { display:grid; gap:4px; padding:10px; border:1px solid #2a496e; border-radius:10px; min-width:0; }
|
||||
.issue-filing-review-summary dt { color:#94a3b8; font-size:12px; font-weight:700; text-transform:uppercase; }
|
||||
.issue-filing-review-summary dd { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.issue-filing-review ol { display:grid; gap:8px; margin:0; padding-left:24px; overflow-wrap:anywhere; }
|
||||
.issue-filing-review ol:empty::after { content:'No screenshots attached.'; display:list-item; color:#94a3b8; }
|
||||
.issue-filing-review ol { display:grid; grid-template-columns:repeat(auto-fit,minmax(72px,1fr)); gap:8px; margin:0; padding:0; list-style:none; overflow-wrap:anywhere; }
|
||||
.issue-filing-evidence-selector { min-height:44px; width:100%; padding:4px; border:2px solid #31577f; border-radius:10px; background:#08111f; }
|
||||
.issue-filing-evidence-selector[aria-pressed="true"] { border-color:#93c5fd; box-shadow:0 0 0 2px rgba(96,165,250,.35); }
|
||||
.issue-filing-evidence-selector img { display:block; width:100%; height:64px; object-fit:cover; border-radius:6px; }
|
||||
.issue-filing-evidence-preview { display:grid; gap:8px; margin:10px 0 0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#020617; }
|
||||
.issue-filing-evidence-preview[hidden] { display:none; }
|
||||
.issue-filing-evidence-preview > img { display:block; width:100%; max-height:42dvh; object-fit:contain; border-radius:8px; background:#000; }
|
||||
.issue-filing-evidence-preview figcaption { display:grid; gap:3px; min-width:0; overflow-wrap:anywhere; }
|
||||
.issue-filing-review-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.issue-filing-review-actions button { min-height:44px; width:100%; }
|
||||
@media(max-width:320px) { .issue-filing-review-panel { padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); } }
|
||||
|
|
|
|||
|
|
@ -557,6 +557,12 @@
|
|||
confirmButton: qs('#confirm-issue-filing'),
|
||||
backButton: qs('#back-to-issue-edit'),
|
||||
evidenceList: qs('#issue-filing-review-evidence'),
|
||||
evidenceEmpty: qs('#issue-filing-review-evidence-empty'),
|
||||
evidencePreview: qs('#issue-filing-review-evidence-preview'),
|
||||
evidenceImage: qs('#issue-filing-review-evidence-image'),
|
||||
evidencePosition: qs('#issue-filing-review-evidence-position'),
|
||||
evidenceFilename: qs('#issue-filing-review-evidence-filename'),
|
||||
evidenceNote: qs('#issue-filing-review-evidence-note'),
|
||||
repository: qs('#issue-filing-review-repository'),
|
||||
intent: qs('#issue-filing-review-intent'),
|
||||
title: qs('#issue-filing-review-title'),
|
||||
|
|
@ -564,6 +570,8 @@
|
|||
metadata: qs('#issue-filing-review-metadata'),
|
||||
status: qs('#issue-filing-review-status'),
|
||||
document,
|
||||
createObjectURL: blob => URL.createObjectURL(blob),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
onConfirm: admitReviewedIssue,
|
||||
});
|
||||
const authoredOutbox = createAuthoredOutbox({
|
||||
|
|
|
|||
|
|
@ -866,7 +866,16 @@
|
|||
</dl>
|
||||
<section aria-labelledby="issue-filing-review-evidence-heading">
|
||||
<h3 id="issue-filing-review-evidence-heading">Evidence in filing order</h3>
|
||||
<p class="small muted" id="issue-filing-review-evidence-empty" hidden>No screenshots will be filed.</p>
|
||||
<ol id="issue-filing-review-evidence"></ol>
|
||||
<figure class="issue-filing-evidence-preview" id="issue-filing-review-evidence-preview" hidden>
|
||||
<img id="issue-filing-review-evidence-image" alt="" />
|
||||
<figcaption>
|
||||
<span class="small" id="issue-filing-review-evidence-position"></span>
|
||||
<strong id="issue-filing-review-evidence-filename"></strong>
|
||||
<span class="small" id="issue-filing-review-evidence-note"></span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
<div class="issue-filing-review-actions">
|
||||
<button id="back-to-issue-edit" type="button">Back to edit</button>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,68 @@
|
|||
let reviewed = null;
|
||||
let trigger = null;
|
||||
let busy = false;
|
||||
let evidenceItems = [];
|
||||
const generatedUrls = new Set();
|
||||
|
||||
function releaseEvidenceUrls() {
|
||||
generatedUrls.forEach(url => options.revokeObjectURL?.(url));
|
||||
generatedUrls.clear();
|
||||
evidenceItems = [];
|
||||
}
|
||||
|
||||
function evidenceUrl(attachment) {
|
||||
if (attachment?.blob) {
|
||||
const url = options.createObjectURL(attachment.blob);
|
||||
generatedUrls.add(url);
|
||||
return url;
|
||||
}
|
||||
if (attachment?.data) {
|
||||
return 'data:' + String(attachment.contentType || 'application/octet-stream') +
|
||||
';base64,' + String(attachment.data);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function selectEvidence(index) {
|
||||
const selected = evidenceItems[index];
|
||||
if (!selected) return;
|
||||
evidenceItems.forEach((item, position) =>
|
||||
item.button.setAttribute('aria-pressed', position === index ? 'true' : 'false'));
|
||||
const filename = selected.attachment.filename || 'Screenshot';
|
||||
options.evidencePreview.hidden = false;
|
||||
options.evidenceImage.src = selected.url;
|
||||
options.evidenceImage.alt = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length + ': ' + filename;
|
||||
options.evidencePosition.textContent = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length;
|
||||
options.evidenceFilename.textContent = filename;
|
||||
options.evidenceNote.textContent = selected.attachment.note || 'No evidence note.';
|
||||
}
|
||||
|
||||
function renderVisualEvidence(attachments) {
|
||||
releaseEvidenceUrls();
|
||||
options.evidenceEmpty.hidden = attachments.length > 0;
|
||||
options.evidencePreview.hidden = true;
|
||||
options.evidenceImage.src = '';
|
||||
evidenceItems = attachments.map((attachment, index) => {
|
||||
const url = evidenceUrl(attachment);
|
||||
const item = options.document.createElement('li');
|
||||
const button = options.document.createElement('button');
|
||||
const image = options.document.createElement('img');
|
||||
const filename = attachment.filename || 'Screenshot';
|
||||
button.setAttribute('type', 'button');
|
||||
button.className = 'issue-filing-evidence-selector';
|
||||
button.setAttribute('aria-label', 'Preview evidence ' + (index + 1) + ' of ' + attachments.length + ': ' + filename);
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
image.src = url;
|
||||
image.alt = '';
|
||||
button.appendChild(image);
|
||||
item.appendChild(button);
|
||||
return { attachment, button, item, url };
|
||||
});
|
||||
evidenceItems.forEach((entry, index) =>
|
||||
entry.button.addEventListener('click', () => selectEvidence(index)));
|
||||
options.evidenceList.replaceChildren(...evidenceItems.map(entry => entry.item));
|
||||
if (evidenceItems.length) selectEvidence(0);
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
|
|
@ -27,6 +89,7 @@
|
|||
trigger = null;
|
||||
reviewed = null;
|
||||
busy = false;
|
||||
releaseEvidenceUrls();
|
||||
options.confirmButton.disabled = false;
|
||||
previousTrigger?.focus();
|
||||
}
|
||||
|
|
@ -47,6 +110,10 @@
|
|||
'Assigned to you',
|
||||
].join(' · ');
|
||||
const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean);
|
||||
if (options.evidencePreview) {
|
||||
renderVisualEvidence(attachments);
|
||||
return;
|
||||
}
|
||||
options.evidenceList.replaceChildren(...attachments.map((attachment, index) => {
|
||||
const item = options.document.createElement('li');
|
||||
item.textContent = (index + 1) + '. ' + (attachment.filename || 'Screenshot') +
|
||||
|
|
|
|||
|
|
@ -99,6 +99,122 @@ process.stdout.write(JSON.stringify({{hidden:sheet.hidden,focused:trigger.focuse
|
|||
assert run_node(script) == {"hidden": True, "focused": True, "title": "Original"}
|
||||
|
||||
|
||||
def test_review_renders_exact_evidence_bytes_and_selects_the_preview():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
function target(tag='div') {{
|
||||
const listeners={{}}, attributes={{}};
|
||||
return {{tag,hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'',
|
||||
addEventListener:(name,fn)=>listeners[name]=fn,
|
||||
dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},currentTarget:this,...event}}),
|
||||
replaceChildren(...items){{this.children=items;}}, appendChild(item){{this.children.push(item);}},
|
||||
setAttribute(name,value){{attributes[name]=String(value);}}, getAttribute:name=>attributes[name],
|
||||
focus(){{this.focused=true;}}}};
|
||||
}}
|
||||
const documentRef={{createElement:tag=>target(tag),addEventListener:()=>{{}}}};
|
||||
const evidence=target(), preview=target(), image=target('img');
|
||||
const position=target(), filename=target(), note=target(), empty=target();
|
||||
const created=[];
|
||||
const review=createReview({{
|
||||
sheet:target(),confirmButton:target(),backButton:target(),evidenceList:evidence,
|
||||
evidencePreview:preview,evidenceImage:image,evidencePosition:position,
|
||||
evidenceFilename:filename,evidenceNote:note,evidenceEmpty:empty,
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
|
||||
document:documentRef,createObjectURL:blob=>{{const url='blob:review-'+blob.size;created.push(url);return url;}},
|
||||
revokeObjectURL:()=>{{}},onConfirm:async()=>{{}},
|
||||
}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Privacy check',attachments:[
|
||||
{{filename:'redacted.png',contentType:'image/png',blob:new Blob(['flattened-redaction']),note:'Secrets hidden'}},
|
||||
{{filename:'legacy.webp',contentType:'image/webp',data:'bGVnYWN5',note:'Expected result'}}
|
||||
]}},intent:'create-and-assign'}},target());
|
||||
const firstButton=evidence.children[0].children[0];
|
||||
const secondButton=evidence.children[1].children[0];
|
||||
secondButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
created,thumbnailSources:[firstButton.children[0].src,secondButton.children[0].src],
|
||||
labels:[firstButton.getAttribute('aria-label'),secondButton.getAttribute('aria-label')],
|
||||
selected:[firstButton.getAttribute('aria-pressed'),secondButton.getAttribute('aria-pressed')],
|
||||
preview:{{hidden:preview.hidden,src:image.src,alt:image.alt,position:position.textContent,
|
||||
filename:filename.textContent,note:note.textContent}},emptyHidden:empty.hidden,
|
||||
}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result == {
|
||||
"created": ["blob:review-19"],
|
||||
"thumbnailSources": [
|
||||
"blob:review-19",
|
||||
"data:image/webp;base64,bGVnYWN5",
|
||||
],
|
||||
"labels": [
|
||||
"Preview evidence 1 of 2: redacted.png",
|
||||
"Preview evidence 2 of 2: legacy.webp",
|
||||
],
|
||||
"selected": ["false", "true"],
|
||||
"preview": {
|
||||
"hidden": False,
|
||||
"src": "data:image/webp;base64,bGVnYWN5",
|
||||
"alt": "Evidence 2 of 2: legacy.webp",
|
||||
"position": "Evidence 2 of 2",
|
||||
"filename": "legacy.webp",
|
||||
"note": "Expected result",
|
||||
},
|
||||
"emptyHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_failed_filing_keeps_preview_and_success_releases_blob_url_once():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
function target() {{
|
||||
const listeners={{}};
|
||||
return {{hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'',
|
||||
addEventListener:(name,fn)=>listeners[name]=fn,
|
||||
dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},...event}}),
|
||||
replaceChildren(...items){{this.children=items;}},appendChild(item){{this.children.push(item);}},
|
||||
setAttribute(){{}},focus(){{this.focused=true;}}}};
|
||||
}}
|
||||
const documentRef={{createElement:()=>target(),addEventListener:()=>{{}}}};
|
||||
const sheet=target(), confirm=target(), trigger=target(), image=target(), preview=target();
|
||||
const revoked=[];
|
||||
let attempts=0;
|
||||
const review=createReview({{
|
||||
sheet,confirmButton:confirm,backButton:target(),evidenceList:target(),evidencePreview:preview,
|
||||
evidenceImage:image,evidencePosition:target(),evidenceFilename:target(),evidenceNote:target(),evidenceEmpty:target(),
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),status:target(),
|
||||
document:documentRef,createObjectURL:blob=>'blob:attempt-'+blob.size,
|
||||
revokeObjectURL:url=>revoked.push(url),
|
||||
onConfirm:async()=>{{attempts+=1;if(attempts===1)throw new Error('Network unavailable');}},
|
||||
}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Verify',attachment:{{filename:'one.png',blob:new Blob(['one'])}}}},intent:'create-and-assign'}},trigger);
|
||||
confirm.dispatch('click');
|
||||
setImmediate(()=>{{
|
||||
const afterFailure={{hidden:sheet.hidden,previewHidden:preview.hidden,src:image.src,disabled:confirm.disabled,revoked:[...revoked]}};
|
||||
confirm.dispatch('click');
|
||||
setImmediate(()=>process.stdout.write(JSON.stringify({{
|
||||
attempts,afterFailure,afterSuccess:{{hidden:sheet.hidden,triggerFocused:trigger.focused||false,revoked}}
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"attempts": 2,
|
||||
"afterFailure": {
|
||||
"hidden": False,
|
||||
"previewHidden": False,
|
||||
"src": "blob:attempt-3",
|
||||
"disabled": False,
|
||||
"revoked": [],
|
||||
},
|
||||
"afterSuccess": {
|
||||
"hidden": True,
|
||||
"triggerFocused": True,
|
||||
"revoked": ["blob:attempt-3"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_create_actions_review_the_complete_payload_before_durable_admission():
|
||||
root = Path(__file__).parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
|
|
@ -112,6 +228,12 @@ def test_create_actions_review_the_complete_payload_before_durable_admission():
|
|||
assert 'id="issue-filing-review-title"' in html
|
||||
assert 'id="issue-filing-review-body"' in html
|
||||
assert 'id="issue-filing-review-evidence"' in html
|
||||
assert 'id="issue-filing-review-evidence-empty"' in html
|
||||
assert 'id="issue-filing-review-evidence-preview"' in html
|
||||
assert 'id="issue-filing-review-evidence-image"' in html
|
||||
assert 'id="issue-filing-review-evidence-position"' in html
|
||||
assert 'id="issue-filing-review-evidence-filename"' in html
|
||||
assert 'id="issue-filing-review-evidence-note"' in html
|
||||
assert 'id="back-to-issue-edit"' in html
|
||||
assert 'id="confirm-issue-filing"' in html
|
||||
assert '<script src="static/issue-filing-review.js"></script>' in html
|
||||
|
|
@ -122,6 +244,10 @@ def test_create_actions_review_the_complete_payload_before_durable_admission():
|
|||
assert "filingReview.open" in submit_handler
|
||||
assert "issueOutbox.enqueueDurably" not in submit_handler
|
||||
assert "async function admitReviewedIssue" in dashboard
|
||||
assert "evidencePreview: qs('#issue-filing-review-evidence-preview')" in dashboard
|
||||
assert "evidenceImage: qs('#issue-filing-review-evidence-image')" in dashboard
|
||||
assert "createObjectURL: blob => URL.createObjectURL(blob)" in dashboard
|
||||
assert "revokeObjectURL: url => URL.revokeObjectURL(url)" in dashboard
|
||||
assert "issueOutbox.enqueueDurably" in dashboard.split(
|
||||
"async function admitReviewedIssue", 1
|
||||
)[1].split("qs('#create-issue-form').addEventListener", 1)[0]
|
||||
|
|
@ -130,6 +256,9 @@ def test_create_actions_review_the_complete_payload_before_durable_admission():
|
|||
assert "max-height:100dvh" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".issue-filing-evidence-preview" in css
|
||||
assert ".issue-filing-evidence-selector" in css
|
||||
assert "min-height:44px" in css.split(".issue-filing-evidence-selector", 1)[1]
|
||||
assert '"static/issue-filing-review.js"' in bundle
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user