feat: surface photo-only reply drafts in My Work (Closes #1396)
This commit is contained in:
parent
306d6e3056
commit
fa876e1e38
|
|
@ -5,7 +5,7 @@
|
|||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createConversationPhotoDrafts({ store, lanes }) {
|
||||
return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) {
|
||||
const states = {};
|
||||
Object.entries(lanes || {}).forEach(([kind, lane]) => {
|
||||
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
|
||||
|
|
@ -24,7 +24,11 @@
|
|||
const attachments = await current.controller.serialize();
|
||||
const save = current.pending.then(() => store.save(target, attachments));
|
||||
current.pending = save.catch(() => null);
|
||||
try { return await save; }
|
||||
try {
|
||||
const saved = await save;
|
||||
onChange();
|
||||
return saved;
|
||||
}
|
||||
catch (error) { current.onError?.(error); throw error; }
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +71,7 @@
|
|||
const target = { ...current.target };
|
||||
await current.pending;
|
||||
await store.remove(target);
|
||||
onChange();
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
|
|
|
|||
|
|
@ -33,10 +33,11 @@
|
|||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
|
@ -97,7 +98,10 @@
|
|||
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) { await transact('delete', id); return null; }
|
||||
await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, scope:recordScope, ...normalized,
|
||||
updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +126,38 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
async function list() {
|
||||
if (!transact || draftScope !== 'conversation') return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
(record.scope && record.scope !== 'conversation') ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
const updatedAt = Number(record.updatedAt || 0);
|
||||
if (record.kind === 'update') {
|
||||
const notificationId = Number(record.notificationId || 0);
|
||||
if (!Number.isInteger(notificationId) || notificationId < 1) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
|
||||
photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
|
||||
}];
|
||||
}
|
||||
const repository = String(record.repository || '');
|
||||
const number = Number(record.number || 0);
|
||||
if (!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1) return [];
|
||||
const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label, repository, number,
|
||||
title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:record.kind, repository, number }, photo_store:'conversation',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -777,6 +777,7 @@
|
|||
try {
|
||||
const attachments = await searchReplyAttachmentController.serialize();
|
||||
await searchReplyDraftStore.save(target, attachments);
|
||||
void refreshPhotoDraftInbox();
|
||||
} catch (error) {
|
||||
qs('#search-preview-reply-status').textContent = error?.message ||
|
||||
'Photos could not be saved. They remain in this preview; retry before leaving.';
|
||||
|
|
@ -1002,6 +1003,7 @@
|
|||
} });
|
||||
const conversationPhotoDrafts = createConversationPhotoDrafts({
|
||||
store:conversationPhotoDraftStore,
|
||||
onChange:() => { void refreshPhotoDraftInbox(); },
|
||||
lanes:{
|
||||
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
|
||||
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
|
||||
|
|
@ -1310,6 +1312,10 @@
|
|||
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
|
||||
}
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
const photoDraftInbox = createPhotoDraftInbox({
|
||||
conversation:conversationPhotoDraftStore,
|
||||
search:searchReplyDraftStore,
|
||||
});
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||
let offlineStorageReady = await offlineWorkStore.ready();
|
||||
|
|
@ -3401,11 +3407,16 @@
|
|||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||
}));
|
||||
return draftInbox.list().concat(unfiled).sort((left, right) =>
|
||||
return draftInbox.list().concat(photoDraftInbox.list(), unfiled).sort((left, right) =>
|
||||
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshPhotoDraftInbox() {
|
||||
try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false }); }
|
||||
catch (_error) { /* Local photo inventory must not break My Work. */ }
|
||||
}
|
||||
|
||||
function refreshMyWorkView({ reconcileSession = true } = {}) {
|
||||
lastDrafts = listDrafts();
|
||||
const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
|
||||
|
|
@ -3579,7 +3590,7 @@
|
|||
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
|
||||
'<span class="small">' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||||
'<span class="draft-preview">' + escapeHtml(photoDraftInbox.description(item)) + '</span>' + state + attempt +
|
||||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||
};
|
||||
|
|
@ -3618,6 +3629,9 @@
|
|||
await dFS.open(item.capture_id, item.ready ? button : null);
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.kind === 'photo-reply' && item.route?.kind === 'search') {
|
||||
taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) });
|
||||
}
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
};
|
||||
});
|
||||
|
|
@ -3736,6 +3750,7 @@
|
|||
}
|
||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'photo-reply') await photoDraftInbox.discard(item);
|
||||
else if (item) draftInbox.discard(item.id);
|
||||
lastDrafts = listDrafts();
|
||||
const count = qs('[data-work-count="draft"]');
|
||||
|
|
@ -5493,6 +5508,7 @@
|
|||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
void refreshPhotoDraftInbox();
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
|
|
|
|||
|
|
@ -2264,6 +2264,7 @@
|
|||
<script src="static/search-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-photo-drafts.js"></script>
|
||||
<script src="static/photo-draft-inbox.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
|
|
|
|||
80
frontend/photo-draft-inbox.js
Normal file
80
frontend/photo-draft-inbox.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
(function (root, factory) {
|
||||
const createPhotoDraftInbox = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
|
||||
else root.createPhotoDraftInbox = createPhotoDraftInbox;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function targetKey(route) {
|
||||
if (route?.kind === 'update') {
|
||||
const notificationId = Number(route.notification_id || 0);
|
||||
return notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
|
||||
const repository = String(route?.repository || '');
|
||||
const number = Number(route?.number || 0);
|
||||
return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
|
||||
kind + ':' + repository.toLowerCase() + '#' + number : '';
|
||||
}
|
||||
|
||||
function removeTarget(item) {
|
||||
const route = item?.route || {};
|
||||
if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
|
||||
return {
|
||||
kind:route.kind === 'search' ? route.target_kind : route.kind,
|
||||
repository:route.repository,
|
||||
number:Number(route.number),
|
||||
};
|
||||
}
|
||||
|
||||
return function createPhotoDraftInbox({ conversation, search }) {
|
||||
let items = [];
|
||||
|
||||
async function refresh(existing = []) {
|
||||
const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
|
||||
const results = await Promise.allSettled([
|
||||
conversation?.list?.() || [],
|
||||
search?.list?.() || [],
|
||||
]);
|
||||
items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
|
||||
.filter(item => {
|
||||
const key = targetKey(item?.route);
|
||||
if (!key || occupied.has(key)) return false;
|
||||
occupied.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
|
||||
String(left.id || '').localeCompare(String(right.id || '')));
|
||||
return items.slice();
|
||||
}
|
||||
|
||||
function list() { return items.slice(); }
|
||||
|
||||
async function discard(item) {
|
||||
if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
|
||||
return false;
|
||||
}
|
||||
const store = item.photo_store === 'search' ? search : conversation;
|
||||
if (!store?.remove) return false;
|
||||
await store.remove(removeTarget(item));
|
||||
items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
|
||||
return true;
|
||||
}
|
||||
|
||||
function description(item) {
|
||||
return item?.kind === 'photo-reply' ?
|
||||
Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
|
||||
(item?.preview || 'Unfinished draft');
|
||||
}
|
||||
|
||||
function searchTarget(item) {
|
||||
const route = item?.route || {};
|
||||
return {
|
||||
kind:route.target_kind, repository:route.repository,
|
||||
number:route.number, title:item?.title,
|
||||
};
|
||||
}
|
||||
|
||||
return { refresh, list, discard, description, searchTarget };
|
||||
};
|
||||
});
|
||||
|
|
@ -33,10 +33,11 @@
|
|||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
|
@ -95,7 +96,9 @@
|
|||
await transact('delete', id);
|
||||
return null;
|
||||
}
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, ...normalized, updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +119,28 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
async function list() {
|
||||
if (!transact) return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
const repository = String(record?.repository || '');
|
||||
const number = Number(record?.number || 0);
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1 ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply',
|
||||
label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos',
|
||||
repository, number, title:repository + '#' + number,
|
||||
photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0),
|
||||
route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ const SHELL = [
|
|||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
BASE + 'static/photo-draft-inbox.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
|
|
|
|||
|
|
@ -41,12 +41,12 @@ FEATURE_SOURCES = {
|
|||
"today-timer": (
|
||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
|
|
|||
99
tests/e2e/test_mobile_photo_draft_inbox_release.py
Normal file
99
tests/e2e/test_mobile_photo_draft_inbox_release.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile photo-draft journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must provide exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Photo draft release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""() => new Promise((resolve, reject) => {
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onupgradeneeded=()=>{
|
||||
if (!request.result.objectStoreNames.contains('drafts')) {
|
||||
request.result.createObjectStore('drafts',{keyPath:'id'});
|
||||
}
|
||||
};
|
||||
request.onsuccess=()=>{
|
||||
const transaction=request.result.transaction('drafts','readwrite');
|
||||
transaction.objectStore('drafts').put({
|
||||
id:'timmy:issue:acme%2Fmobile%3A41', version:1, ownerLogin:'timmy',
|
||||
scope:'conversation', kind:'issue', repository:'acme/mobile', number:41,
|
||||
updatedAt:Date.now(), attachments:[{
|
||||
filename:'rack-label.jpg', contentType:'image/jpeg',
|
||||
blob:new Blob(['field evidence'],{type:'image/jpeg'}), note:'Rack label',
|
||||
}],
|
||||
});
|
||||
transaction.oncomplete=resolve;
|
||||
transaction.onerror=()=>reject(transaction.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
page.reload(wait_until="networkidle")
|
||||
stored = page.evaluate(
|
||||
"""() => new Promise((resolve,reject)=>{
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onsuccess=()=>{
|
||||
const values=request.result.transaction('drafts','readonly').objectStore('drafts').getAll();
|
||||
values.onsuccess=()=>resolve(values.result.map(item=>({ownerLogin:item.ownerLogin,kind:item.kind,number:item.number})));
|
||||
values.onerror=()=>reject(values.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
assert stored == [{"ownerLogin": "timmy", "kind": "issue", "number": 41}]
|
||||
inventory = page.evaluate(
|
||||
"""() => createConversationReplyDraftStore({indexedDB,getOwnerLogin:()=> 'timmy'}).list()"""
|
||||
)
|
||||
assert len(inventory) == 1
|
||||
expect(page.locator('[data-work-count="draft"]')).to_have_text("1", timeout=10_000)
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="draft"]').click()
|
||||
|
||||
card = page.locator(".draft-card", has_text="acme/mobile#41")
|
||||
expect(card).to_contain_text("1 saved photo")
|
||||
resume = card.get_by_role("button", name="Resume draft")
|
||||
bounds = resume.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
resume.dispatch_event("click")
|
||||
expect(page.locator("#issue-sheet")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-preview")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-meta")).to_contain_text("rack-label.jpg")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
|
@ -79,6 +79,50 @@ const photos = [
|
|||
}
|
||||
|
||||
|
||||
def test_store_lists_only_confirmed_accounts_conversation_photo_drafts_without_blobs():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
const records = new Map([
|
||||
['timmy:issue:stackchain%2Fdashboard%3A7', {{id:'a',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'stackchain/dashboard',number:7,updatedAt:100,attachments:[{{filename:'rack.jpg',blob:new Blob(['secret'])}},{{filename:'label.jpg',blob:new Blob(['secret'])}}]}},],
|
||||
['timmy:update:8', {{id:'b',version:1,ownerLogin:'timmy',scope:'conversation',kind:'update',notificationId:8,updatedAt:200,attachments:[{{filename:'alert.jpg',blob:new Blob(['secret'])}}]}},],
|
||||
['alexander:pull:o%2Fr%3A9', {{id:'c',version:1,ownerLogin:'alexander',scope:'conversation',kind:'pull',repository:'o/r',number:9,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},],
|
||||
['broken', {{id:'broken',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'',number:0,updatedAt:400,attachments:[{{filename:'bad.jpg',blob:new Blob(['bad'])}}]}},],
|
||||
]);
|
||||
const transaction = async (operation, key, value) => {{
|
||||
if (operation === 'list') return Array.from(records.values()).map(value => structuredClone(value));
|
||||
}};
|
||||
const store = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
|
||||
(async()=>{{
|
||||
const drafts=await store.list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == [
|
||||
{
|
||||
"id": "b",
|
||||
"kind": "photo-reply",
|
||||
"label": "Update photos",
|
||||
"title": "Update #8",
|
||||
"photo_count": 1,
|
||||
"updated_at": 200,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
"photo_store": "conversation",
|
||||
},
|
||||
{
|
||||
"id": "a",
|
||||
"kind": "photo-reply",
|
||||
"label": "Issue photos",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 7,
|
||||
"title": "stackchain/dashboard#7",
|
||||
"photo_count": 2,
|
||||
"updated_at": 100,
|
||||
"route": {"kind": "issue", "repository": "stackchain/dashboard", "number": 7},
|
||||
"photo_store": "conversation",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_store_isolates_today_progress_photos_from_conversation_photos():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
|
|
@ -109,6 +153,24 @@ const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
|
|||
}
|
||||
|
||||
|
||||
def test_coordinator_notifies_inventory_after_checkpoint_and_completion():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const changes=[];
|
||||
let current=[{{filename:'proof.jpg',blob:new Blob(['proof'])}}];
|
||||
const store={{save:async()=>null,load:async()=>null,remove:async()=>null}};
|
||||
const controller={{serialize:async()=>current,restore:()=>{{}},clear:()=>{{current=[];}}}};
|
||||
const drafts=createCoordinator({{store,lanes:{{issue:{{controller}}}},onChange:()=>changes.push('changed')}});
|
||||
(async()=>{{
|
||||
await drafts.open('issue',{{kind:'issue',repository:'o/r',number:7}});
|
||||
await drafts.checkpoint('issue');
|
||||
await drafts.complete('issue');
|
||||
process.stdout.write(JSON.stringify(changes));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == ["changed", "changed"]
|
||||
|
||||
|
||||
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
|
|
|
|||
110
tests/test_photo_draft_inbox.py
Normal file
110
tests/test_photo_draft_inbox.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "photo-draft-inbox.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_photo_draft_inbox_combines_stores_without_duplicate_target_cards_and_discards_exact_bundle():
|
||||
script = f"""
|
||||
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
|
||||
const removed=[];
|
||||
const conversation={{
|
||||
list:async()=>[
|
||||
{{id:'conversation-issue',kind:'photo-reply',photo_store:'conversation',title:'o/r#7',updated_at:30,route:{{kind:'issue',repository:'o/r',number:7}}}},
|
||||
{{id:'conversation-update',kind:'photo-reply',photo_store:'conversation',title:'Update #8',updated_at:20,route:{{kind:'update',notification_id:8}}}},
|
||||
],
|
||||
remove:async target=>removed.push(['conversation',target]),
|
||||
}};
|
||||
const search={{
|
||||
list:async()=>[
|
||||
{{id:'search-pull',kind:'photo-reply',photo_store:'search',title:'o/r#9',updated_at:40,route:{{kind:'search',target_kind:'pull',repository:'o/r',number:9}}}},
|
||||
],
|
||||
remove:async target=>removed.push(['search',target]),
|
||||
}};
|
||||
const inbox=createPhotoDraftInbox({{conversation,search}});
|
||||
(async()=>{{
|
||||
const drafts=await inbox.refresh([{{kind:'issue-comment',route:{{kind:'issue',repository:'o/r',number:7}}}}]);
|
||||
const description=inbox.description({{kind:'photo-reply',photo_count:2}});
|
||||
const searchTarget=inbox.searchTarget(drafts[0]);
|
||||
await inbox.discard(drafts[0]);
|
||||
process.stdout.write(JSON.stringify({{drafts,description,searchTarget,remaining:inbox.list(),removed}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"drafts": [
|
||||
{
|
||||
"id": "search-pull",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "search",
|
||||
"title": "o/r#9",
|
||||
"updated_at": 40,
|
||||
"route": {"kind": "search", "target_kind": "pull", "repository": "o/r", "number": 9},
|
||||
},
|
||||
{
|
||||
"id": "conversation-update",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "conversation",
|
||||
"title": "Update #8",
|
||||
"updated_at": 20,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
},
|
||||
],
|
||||
"description": "2 saved photos",
|
||||
"searchTarget": {"kind": "pull", "repository": "o/r", "number": 9, "title": "o/r#9"},
|
||||
"remaining": [
|
||||
{
|
||||
"id": "conversation-update",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "conversation",
|
||||
"title": "Update #8",
|
||||
"updated_at": 20,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
}
|
||||
],
|
||||
"removed": [["search", {"kind": "pull", "repository": "o/r", "number": 9}]],
|
||||
}
|
||||
|
||||
|
||||
def test_photo_draft_inbox_keeps_available_store_when_other_inventory_fails():
|
||||
script = f"""
|
||||
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
|
||||
const inbox=createPhotoDraftInbox({{
|
||||
conversation:{{list:async()=>{{throw new Error('IndexedDB unavailable')}}}},
|
||||
search:{{list:async()=>[{{id:'safe',route:{{kind:'search',target_kind:'issue',repository:'o/r',number:2}},updated_at:1}}]}},
|
||||
}});
|
||||
(async()=>process.stdout.write(JSON.stringify(await inbox.refresh([]))))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == [
|
||||
{"id": "safe", "route": {"kind": "search", "target_kind": "issue", "repository": "o/r", "number": 2}, "updated_at": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_my_work_discovers_reopens_and_discards_photo_only_reply_drafts():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
worker = WORKER.read_text()
|
||||
bundle = BUNDLE.read_text()
|
||||
|
||||
assert '<script src="static/photo-draft-inbox.js"></script>' in index
|
||||
assert "BASE + 'static/photo-draft-inbox.js'" in worker
|
||||
assert '"static/photo-draft-inbox.js"' in bundle
|
||||
assert "const photoDraftInbox = createPhotoDraftInbox({" in dashboard
|
||||
assert "draftInbox.list().concat(photoDraftInbox.list(), unfiled)" in dashboard
|
||||
assert "await photoDraftInbox.refresh(draftInbox.list())" in dashboard
|
||||
assert "void refreshPhotoDraftInbox();" in dashboard
|
||||
assert "onChange:() => { void refreshPhotoDraftInbox(); }" in dashboard
|
||||
assert "await searchReplyDraftStore.save(target, attachments);\n void refreshPhotoDraftInbox();" in dashboard
|
||||
assert "photoDraftInbox.description(item)" in dashboard
|
||||
assert "photoDraftInbox.searchTarget(item)" in dashboard
|
||||
assert "await photoDraftInbox.discard(item)" in dashboard
|
||||
|
|
@ -65,6 +65,32 @@ const photo = new Blob(['field-evidence'], {{type:'image/webp'}});
|
|||
}
|
||||
|
||||
|
||||
def test_search_store_lists_only_confirmed_accounts_photo_drafts_as_metadata():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
const records = [
|
||||
{{id:'mine',version:1,ownerLogin:'timmy',kind:'pull',repository:'stackchain/dashboard',number:19,updatedAt:220,attachments:[{{filename:'proof.jpg',blob:new Blob(['secret'])}}]}},
|
||||
{{id:'other',version:1,ownerLogin:'alexander',kind:'issue',repository:'stackchain/private',number:3,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},
|
||||
{{id:'empty',version:1,ownerLogin:'timmy',kind:'issue',repository:'stackchain/dashboard',number:20,updatedAt:400,attachments:[]}},
|
||||
];
|
||||
const transaction=async operation=>operation === 'list' ? records.map(value=>structuredClone(value)) : null;
|
||||
const store=createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
|
||||
(async()=>process.stdout.write(JSON.stringify(await store.list())))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == [{
|
||||
"id": "mine",
|
||||
"kind": "photo-reply",
|
||||
"label": "Search PR photos",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 19,
|
||||
"title": "stackchain/dashboard#19",
|
||||
"photo_count": 1,
|
||||
"updated_at": 220,
|
||||
"route": {"kind": "search", "target_kind": "pull", "repository": "stackchain/dashboard", "number": 19},
|
||||
"photo_store": "search",
|
||||
}]
|
||||
|
||||
|
||||
def test_photo_bundle_checkpoint_restores_stable_uploads_without_repeating_them():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
|
|
|
|||
|
|
@ -1389,6 +1389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/search-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-photo-drafts.js",
|
||||
"/dashboard/static/photo-draft-inbox.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user