Merge pull request 'Review and reorder mobile evidence before filing' (#826) from timmy/825-review-reorder-mobile-evidence into main
This commit is contained in:
commit
807364c63b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -762,7 +762,12 @@
|
|||
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
|
||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="create-issue-attachment-meta"></span>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove latest</button>
|
||||
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
|
||||
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
|
||||
<button id="move-create-issue-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
|
@ -1167,6 +1172,7 @@
|
|||
<script src="static/search-batch-plan.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-evidence-review.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
153
frontend/issue-evidence-review.js
Normal file
153
frontend/issue-evidence-review.js
Normal file
|
|
@ -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 };
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:''}};}},
|
||||
}});
|
||||
['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(
|
||||
"\n\n\n\n"
|
||||
)
|
||||
|
||||
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user