diff --git a/README.md b/README.md index 80bcf36..6adf2bb 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,10 @@ either stage. The mobile **New issue** capture-first stage accepts an ordered ev five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered -bundle when the operator later chooses a repository. The source Draft remains available until its -evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob. +bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator +review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected** +change the durable evidence order without changing the issue title or note. The source Draft remains +available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob. Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a0e48e5..16cb1a5 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -622,6 +622,16 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-filing { display:grid; gap:12px; min-width:0; } .create-issue-filing[hidden] { display:none; } .create-issue-attachment { display:grid; gap:8px; min-width:0; } +#create-issue-attachment-preview { grid-template-columns:1fr; } +#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; } +.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; } +.issue-evidence-tray[hidden] { display:none; } +.issue-evidence-thumbnail { position:relative; flex:0 0 64px; min-width:44px; min-height:64px; padding:3px; border:2px solid transparent; border-radius:10px; background:#10213a; } +.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); } +.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; } +.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; } +.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; } +.issue-evidence-review-actions button { min-width:44px; min-height:44px; } .create-issue-repository-more { min-height:44px; width:100%; } .create-issue-repository-picker { min-width:0; display:grid; gap:8px; } .create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3dcd1af..936b18e 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -479,6 +479,9 @@ image: qs('#create-issue-attachment-image'), meta: qs('#create-issue-attachment-meta'), remove: qs('#remove-create-issue-attachment'), + tray: qs('#create-issue-evidence-tray'), + earlier: qs('#move-create-issue-attachment-earlier'), + later: qs('#move-create-issue-attachment-later'), status: qs('#create-issue-attachment-status'), readyMessage: 'Screenshot ready to file with this issue.', removedMessage: 'Screenshot removed. Your issue draft is unchanged.', diff --git a/frontend/index.html b/frontend/index.html index a38dbf4..7f24c75 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -762,7 +762,12 @@
@@ -1167,6 +1172,7 @@ + diff --git a/frontend/issue-attachment.js b/frontend/issue-attachment.js index bb161fa..35a68df 100644 --- a/frontend/issue-attachment.js +++ b/frontend/issue-attachment.js @@ -1,8 +1,9 @@ (function(root, factory) { - const api = factory(); + const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview; + const api = factory(review); if (typeof module === 'object' && module.exports) module.exports = api; else root.issueAttachment = api; -})(typeof self !== 'undefined' ? self : this, function() { +})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview) { 'use strict'; const MAX_BYTES = 2 * 1024 * 1024; @@ -10,47 +11,7 @@ const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.'; const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); - function namedBlob(blob, name) { - if (typeof File === 'function') { - return new File([blob], name, { type: blob.type }); - } - Object.defineProperty(blob, 'name', { value: name, configurable: true }); - return blob; - } - - async function optimizeImage(file, environment = {}) { - if (file.size <= MAX_BYTES) return file; - const decode = environment.createImageBitmap || globalThis.createImageBitmap; - const makeCanvas = environment.createCanvas || (() => document.createElement('canvas')); - if (typeof decode !== 'function') { - throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.'); - } - - let bitmap; - try { - bitmap = await decode(file); - const canvas = makeCanvas(); - const context = canvas && canvas.getContext && canvas.getContext('2d'); - if (!context || !bitmap.width || !bitmap.height) throw new Error('decode'); - let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92); - for (let attempt = 0; attempt < 10; attempt += 1) { - canvas.width = Math.max(1, Math.round(bitmap.width * scale)); - canvas.height = Math.max(1, Math.round(bitmap.height * scale)); - context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); - const blob = await new Promise(resolve => { - canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86); - }); - if (!blob) throw new Error('encode'); - if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name); - scale *= 0.8; - } - } catch (_error) { - throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.'); - } finally { - if (bitmap && typeof bitmap.close === 'function') bitmap.close(); - } - throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.'); - } + const optimizeImage = issueEvidenceReview.optimizeImage; function multipart(attachment) { let blob = attachment?.blob; @@ -124,6 +85,16 @@ return state(); } + function move(fromIndex, toIndex) { + const from = Number(fromIndex); + const to = Number(toIndex); + if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || + from >= selected.length || to < 0 || to >= selected.length || from === to) return state(); + const [item] = selected.splice(from, 1); + selected.splice(to, 0, item); + return state(); + } + function restore(value) { if (Array.isArray(value)) { clear(); @@ -198,7 +169,7 @@ return text ? text + '\n\n' + evidence : evidence; } - return { select, restore, remove, clear, state, serialize, prepareComment }; + return { select, restore, remove, move, clear, state, serialize, prepareComment }; } function mount(options) { @@ -207,18 +178,22 @@ }); const clearSelection = controller.clear; const restoreSelection = controller.restore; + const reviewEnabled = Boolean(options.tray && options.earlier && options.later); let previewUrl = ''; let selectionSequence = 0; + const review = reviewEnabled ? issueEvidenceReview.create({ ...options, controller }) : null; - function setBusy(busy) { - options.input.disabled = Boolean(busy); - options.remove.disabled = Boolean(busy); + function setBusy(value) { + options.input.disabled = Boolean(value); + options.remove.disabled = Boolean(value); + review?.setBusy(value); } function clearPreview() { selectionSequence += 1; if (previewUrl) options.revokeObjectURL(previewUrl); previewUrl = ''; + review?.clear(); options.image.src = ''; options.preview.hidden = true; options.input.value = ''; @@ -247,7 +222,7 @@ options.input.value = ''; return; } - if (files.length === 1 && (!first || typeof first.then !== 'function')) { + if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) { showPreview(files[0], false); return; } @@ -269,10 +244,14 @@ if (sequence !== selectionSequence) return; const values = Array.isArray(value) ? value : [value]; const latest = values[values.length - 1]; - showPreview(latest.blob, optimized); - if (values.length > 1) { - options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename; - options.status.textContent = values.length + ' screenshots ready to file in this order.'; + if (reviewEnabled) { + review.render(values, values.length - 1, optimized); + } else { + showPreview(latest.blob, optimized); + if (values.length > 1) { + options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename; + options.status.textContent = values.length + ' screenshots ready to file in this order.'; + } } }).catch(error => { if (sequence === selectionSequence) { @@ -288,25 +267,34 @@ const count = Array.isArray(current) ? current.length : (current ? 1 : 0); if (count <= 1) { clearPreview(); - } else { - controller.remove(count - 1); - selectionSequence += 1; - controller.serialize().then(value => { - const values = Array.isArray(value) ? value : [value]; + options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.'; + return Promise.resolve(); + } + const removedIndex = reviewEnabled ? review.activeIndex() : count - 1; + controller.remove(removedIndex); + selectionSequence += 1; + review?.afterRemoval(removedIndex, count); + return controller.serialize().then(value => { + const values = Array.isArray(value) ? value : [value]; + if (reviewEnabled) { + review.render(values, review.activeIndex()); + } else { const latest = values[values.length - 1]; showPreview(latest.blob, false); options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename; - }); - } - options.status.textContent = options.removedMessage || (count > 1 ? - 'Latest screenshot removed. Your text is unchanged.' : - 'Screenshot removed. Your comment is unchanged.'); + } + options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.'; + }); }); function restorePreview(value) { clearPreview(); const restored = restoreSelection(value); const values = Array.isArray(value) ? value : [value]; + if (reviewEnabled) { + review.render(values, values.length - 1); + return restored; + } const latest = values[values.length - 1]; previewUrl = latest.blob ? options.createObjectURL(latest.blob) : 'data:' + latest.contentType + ';base64,' + latest.data; diff --git a/frontend/issue-evidence-review.js b/frontend/issue-evidence-review.js new file mode 100644 index 0000000..c59367e --- /dev/null +++ b/frontend/issue-evidence-review.js @@ -0,0 +1,153 @@ +(function(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.issueEvidenceReview = api; +})(typeof self !== 'undefined' ? self : this, function() { + 'use strict'; + + const MAX_BYTES = 2 * 1024 * 1024; + + function namedBlob(blob, name) { + if (typeof File === 'function') return new File([blob], name, { type: blob.type }); + Object.defineProperty(blob, 'name', { value: name, configurable: true }); + return blob; + } + + async function optimizeImage(file, environment = {}) { + if (file.size <= MAX_BYTES) return file; + const decode = environment.createImageBitmap || globalThis.createImageBitmap; + const makeCanvas = environment.createCanvas || (() => document.createElement('canvas')); + if (typeof decode !== 'function') throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.'); + let bitmap; + try { + bitmap = await decode(file); + const canvas = makeCanvas(); + const context = canvas && canvas.getContext && canvas.getContext('2d'); + if (!context || !bitmap.width || !bitmap.height) throw new Error('decode'); + let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92); + for (let attempt = 0; attempt < 10; attempt += 1) { + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86)); + if (!blob) throw new Error('encode'); + if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name); + scale *= 0.8; + } + } catch (_error) { + throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.'); + } finally { + if (bitmap && typeof bitmap.close === 'function') bitmap.close(); + } + throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.'); + } + + function create(options) { + const controller = options.controller; + const documentRef = options.document || document; + let activeIndex = 0; + let busy = false; + let previewUrl = ''; + let thumbnailUrls = []; + + function revoke(url) { + if (url && !url.startsWith('data:')) options.revokeObjectURL(url); + } + + function attachmentUrl(attachment) { + return attachment?.blob ? options.createObjectURL(attachment.blob) : + 'data:' + attachment.contentType + ';base64,' + attachment.data; + } + + function count() { + const value = controller.state(); + return Array.isArray(value) ? value.length : (value ? 1 : 0); + } + + function setBusy(value) { + busy = Boolean(value); + options.earlier.disabled = busy || activeIndex <= 0; + options.later.disabled = busy || activeIndex >= count() - 1; + Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; }); + } + + function update(values, optimized = false) { + if (!values.length) return; + activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1)); + revoke(previewUrl); + previewUrl = attachmentUrl(values[activeIndex]); + options.image.src = previewUrl; + const current = values[activeIndex]; + const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4)); + options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' + + current.filename + ' · ' + Math.ceil(size / 1024) + ' KB'; + options.preview.hidden = false; + Array.from(options.tray.children || []).forEach((button, index) => + button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false')); + options.earlier.disabled = busy || activeIndex === 0; + options.later.disabled = busy || activeIndex === values.length - 1; + options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' : + values.length + ' screenshots ready to file in this order.'; + } + + function render(value, index = activeIndex, optimized = false) { + const values = (Array.isArray(value) ? value : [value]).filter(Boolean); + activeIndex = Math.max(0, Math.min(index, values.length - 1)); + thumbnailUrls.forEach(revoke); + thumbnailUrls = []; + options.tray.replaceChildren(...values.map((item, itemIndex) => { + const button = documentRef.createElement('button'); + button.type = 'button'; + button.className = 'issue-evidence-thumbnail'; + button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename); + const thumbnail = documentRef.createElement('img'); + thumbnail.alt = ''; + thumbnail.src = attachmentUrl(item); + thumbnailUrls.push(thumbnail.src); + button.appendChild(thumbnail); + const position = documentRef.createElement('span'); + position.textContent = String(itemIndex + 1); + button.appendChild(position); + button.addEventListener('click', () => { activeIndex = itemIndex; update(values); }); + return button; + })); + options.tray.hidden = !values.length; + if (values.length) update(values, optimized); + } + + function clear() { + revoke(previewUrl); + thumbnailUrls.forEach(revoke); + previewUrl = ''; + thumbnailUrls = []; + activeIndex = 0; + options.tray.replaceChildren(); + options.tray.hidden = true; + options.earlier.disabled = true; + options.later.disabled = true; + } + + function afterRemoval(removedIndex, previousCount) { + activeIndex = Math.min(removedIndex, previousCount - 2); + } + + function move(offset) { + const total = count(); + const destination = activeIndex + offset; + if (destination < 0 || destination >= total) return Promise.resolve(); + controller.move(activeIndex, destination); + activeIndex = destination; + return controller.serialize().then(value => { + render(value, activeIndex); + options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.'; + }); + } + + options.earlier.addEventListener('click', () => move(-1)); + options.later.addEventListener('click', () => move(1)); + clear(); + return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy }; + } + + return { create, optimizeImage }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 3287d8a..7c8a74c 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -64,6 +64,7 @@ const SHELL = [ BASE + 'static/search-batch-plan.js', BASE + 'static/conversation.js', BASE + 'static/comment-actions.js', + BASE + 'static/issue-evidence-review.js', BASE + 'static/issue-attachment.js', BASE + 'static/issue-sheet.js', BASE + 'static/create-issue-sheet.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index f6a6771..549564e 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -35,7 +35,7 @@ FEATURE_SOURCES = { "static/assign-and-start.js", "static/queue-today.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", - "static/search-batch-plan.js", + "static/search-batch-plan.js", "static/issue-evidence-review.js", ), } CACHE_DECLARATION = re.compile( diff --git a/tests/test_issue_attachment_ui.py b/tests/test_issue_attachment_ui.py index 5fe8b32..172456a 100644 --- a/tests/test_issue_attachment_ui.py +++ b/tests/test_issue_attachment_ui.py @@ -89,6 +89,33 @@ controller.select(image('replacement.png')); assert len({key for _, key in output["calls"]}) == 5 +def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload(): + script = f""" +const attachment = require({json.dumps(str(ATTACHMENT))}); +const uploaded=[]; +const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}}; +const controller=attachment.create({{ + maxFiles:5, + upload:async payload=>{{uploaded.push(payload.filename);return {{markdown:'!['+payload.filename+'](url)'}};}}, +}}); +['one.png','two.png','three.png'].forEach(name=>controller.select(image(name))); +controller.move(2, 0); +controller.move(0, 99); +controller.move(0, 1); +(async()=>{{ + const serialized=await controller.serialize(); + const comment=await controller.prepareComment({{repository:'o/r',number:825}},'Reviewed'); + process.stdout.write(JSON.stringify({{names:serialized.map(x=>x.filename),uploaded,comment}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = json.loads(run_node(script)) + assert output["names"] == ["one.png", "three.png", "two.png"] + assert output["uploaded"] == output["names"] + assert output["comment"].endswith( + "![one.png](url)\n\n![three.png](url)\n\n![two.png](url)" + ) + + def test_mobile_attachment_retry_reuses_operation_key_until_file_changes(): script = f""" const attachment = require({json.dumps(str(ATTACHMENT))}); @@ -273,6 +300,53 @@ process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textConte +def test_evidence_review_tray_selects_reorders_and_removes_any_screenshot(): + script = f""" +const attachment=require({json.dumps(str(ATTACHMENT))}); +class Element{{ + constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};this.dataset={{}};}} + addEventListener(type,fn){{this.listeners[type]=fn;}} + dispatch(type){{return this.listeners[type]({{target:this}});}} + appendChild(child){{this.children.push(child);return child;}} + replaceChildren(...children){{this.children=children;}} + setAttribute(name,value){{this.attributes[name]=String(value);}} +}} +const document={{createElement:tag=>new Element(tag)}}; +const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button'); +input.multiple=true; +const urls=[]; +const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document, + removedMessage:'Screenshot removed. Your issue draft is unchanged.', + createObjectURL:blob=>{{const url='blob:'+blob.name;urls.push(url);return url;}},revokeObjectURL:()=>{{}},upload:async()=>{{}}}}); +const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}}; +input.files=['one.png','two.png','three.png'].map(file); +(async()=>{{ + await input.dispatch('change'); + await tray.children[1].dispatch('click'); + await earlier.dispatch('click'); + const reordered=(await controller.serialize()).map(item=>item.filename); + await remove.dispatch('click'); + const remaining=(await controller.serialize()).map(item=>item.filename); + process.stdout.write(JSON.stringify({{reordered,remaining,preview:image.src,meta:meta.textContent, + selected:tray.children.map(button=>button.attributes['aria-pressed']), + labels:tray.children.map(button=>button.attributes['aria-label']), + earlierDisabled:earlier.disabled,laterDisabled:later.disabled,status:status.textContent}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = json.loads(run_node(script)) + assert output["reordered"] == ["two.png", "one.png", "three.png"] + assert output["remaining"] == ["one.png", "three.png"] + assert output["preview"] == "blob:one.png" + assert output["meta"] == "Screenshot 1 of 2 · one.png · 1 KB" + assert output["selected"] == ["true", "false"] + assert output["labels"] == [ + "Review screenshot 1 of 2: one.png", "Review screenshot 2 of 2: three.png" + ] + assert output["earlierDisabled"] is True + assert output["laterDisabled"] is False + assert output["status"] == "Screenshot removed. Your issue draft is unchanged." + + def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot(): script = f""" const attachment=require({json.dumps(str(ATTACHMENT))}); @@ -317,11 +391,25 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox(): assert 'Up to 5' in html assert 'id="create-issue-attachment-preview"' in html assert 'id="remove-create-issue-attachment"' in html + assert 'id="create-issue-evidence-tray"' in html + assert 'role="toolbar"' in html + assert 'aria-label="Evidence screenshots"' in html + assert 'id="move-create-issue-attachment-earlier"' in html + assert 'id="move-create-issue-attachment-later"' in html + assert "tray: qs('#create-issue-evidence-tray')" in source + assert "earlier: qs('#move-create-issue-attachment-earlier')" in source + assert "later: qs('#move-create-issue-attachment-later')" in source assert "const createIssueAttachmentController = issueAttachment.mount({" in source assert "attachments:evidence" in source assert "hydrated.attachments || hydrated.attachment" in source assert "createIssueAttachmentController.clear();" in source assert ".create-issue-attachment" in css + assert ".issue-evidence-tray" in css + assert ".issue-evidence-review-actions" in css + assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css + assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css + assert "overflow-x:auto" in css + assert "min-width:44px" in css assert "overflow-x:hidden" in css @@ -461,21 +549,38 @@ def test_pending_comment_admission_locks_attachment_replacement_and_remove_contr script = f""" const attachment=require({json.dumps(str(ATTACHMENT))}); class Element {{ - constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }} + constructor() {{ this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}}; }} addEventListener(type,fn) {{ this.listeners[type]=fn; }} + appendChild(child) {{ this.children.push(child); }} + replaceChildren(...children) {{ this.children=children; }} + setAttribute(name,value) {{ this.attributes[name]=String(value); }} }} -const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(); -const controller=attachment.mount({{input,preview,image,meta,remove,status, +const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(),tray=new Element(),earlier=new Element(),later=new Element(); +input.multiple=true; +const document={{createElement:()=>new Element()}}; +const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document, createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}}); +controller.restore([ + {{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}}, + {{filename:'two.png',contentType:'image/png',blob:new Blob(['two'],{{type:'image/png'}})}}, +]); controller.setBusy(true); -const pending={{input:input.disabled,remove:remove.disabled}}; +const pending={{input:input.disabled,remove:remove.disabled,earlier:earlier.disabled,later:later.disabled, + thumbnails:tray.children.map(button=>button.disabled)}}; controller.setBusy(false); -process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}})); +process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled, + earlier:earlier.disabled,later:later.disabled,thumbnails:tray.children.map(button=>button.disabled)}}}})); """ assert json.loads(run_node(script)) == { - "pending": {"input": True, "remove": True}, - "released": {"input": False, "remove": False}, + "pending": { + "input": True, "remove": True, "earlier": True, "later": True, + "thumbnails": [True, True], + }, + "released": { + "input": False, "remove": False, "earlier": False, "later": True, + "thumbnails": [False, False], + }, } @@ -513,6 +618,9 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order(): assert "uploads before the comment is posted" in readme assert "New issue" in readme assert "retry resumes with the confirmed issue" in readme + assert "thumbnail tray" in readme + assert "Move earlier" in readme + assert "Remove selected" in readme def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing(): diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 73beeba..2dd53db 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -837,6 +837,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/search-batch-plan.js", "/dashboard/static/conversation.js", "/dashboard/static/comment-actions.js", + "/dashboard/static/issue-evidence-review.js", "/dashboard/static/issue-attachment.js", "/dashboard/static/issue-sheet.js", "/dashboard/static/create-issue-sheet.js",