Recover unfinished mobile drafts from My Work #212
144
frontend/drafts.js
Normal file
144
frontend/drafts.js
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||||
|
const indexKey = 'stackchain.draft-index.v1';
|
||||||
|
|
||||||
|
function readIndex() {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||||
|
} catch (_error) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIndex(index) {
|
||||||
|
try { storage?.setItem(indexKey, JSON.stringify(index)); }
|
||||||
|
catch (_error) { /* Draft discovery remains available without metadata. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function keys() {
|
||||||
|
try {
|
||||||
|
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch (_error) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPreview(value) {
|
||||||
|
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTarget(value) {
|
||||||
|
const match = String(value || '').match(/^(.+)#(\d+)$/);
|
||||||
|
return match ? { repository: match[1], number: Number(match[2]) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(key, raw) {
|
||||||
|
let match;
|
||||||
|
if (key === 'stackchain.issue-capture.v1') {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||||
|
if (!preview && !value?.repository) return null;
|
||||||
|
return {
|
||||||
|
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
|
||||||
|
title: textPreview(value?.title) || 'Untitled new issue', preview,
|
||||||
|
};
|
||||||
|
} catch (_error) { return null; }
|
||||||
|
}
|
||||||
|
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const target = parseTarget(match[2]);
|
||||||
|
const preview = textPreview(raw);
|
||||||
|
if (!target || !preview) return null;
|
||||||
|
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
|
||||||
|
return {
|
||||||
|
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
|
||||||
|
...target, title: target.repository + '#' + target.number, preview,
|
||||||
|
route: { kind: routeKind, ...target },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const target = parseTarget(match[1]);
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||||
|
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
|
||||||
|
return {
|
||||||
|
kind: 'issue-edit', label: 'Issue edit', ...target,
|
||||||
|
title: target.repository + '#' + target.number, preview,
|
||||||
|
route: { kind: 'issue', ...target },
|
||||||
|
};
|
||||||
|
} catch (_error) { return null; }
|
||||||
|
}
|
||||||
|
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const preview = textPreview(raw);
|
||||||
|
if (!preview) return null;
|
||||||
|
const notificationId = Number(match[1]);
|
||||||
|
return {
|
||||||
|
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
|
||||||
|
title: 'Update #' + notificationId, preview,
|
||||||
|
route: { kind: 'update', notification_id: notificationId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
|
||||||
|
if (match) {
|
||||||
|
const target = parseTarget(match[1]);
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
const notes = Object.values(value?.notes || {});
|
||||||
|
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
|
||||||
|
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
|
||||||
|
if (!target || !preview) return null;
|
||||||
|
return {
|
||||||
|
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
|
||||||
|
title: target.repository + '#' + target.number, preview,
|
||||||
|
route: { kind: 'review', ...target },
|
||||||
|
};
|
||||||
|
} catch (_error) { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
const index = readIndex();
|
||||||
|
const seen = new Set();
|
||||||
|
const drafts = [];
|
||||||
|
keys().forEach(key => {
|
||||||
|
if (key === indexKey || key.endsWith(':operation')) return;
|
||||||
|
let raw;
|
||||||
|
try { raw = storage.getItem(key); }
|
||||||
|
catch (_error) { return; }
|
||||||
|
const parsed = parse(key, raw);
|
||||||
|
if (!parsed) return;
|
||||||
|
seen.add(key);
|
||||||
|
const fingerprint = String(raw);
|
||||||
|
const previous = index[key];
|
||||||
|
if (!previous || previous.fingerprint !== fingerprint) {
|
||||||
|
index[key] = { fingerprint, updated_at: Number(now()) };
|
||||||
|
}
|
||||||
|
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
|
||||||
|
});
|
||||||
|
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
|
||||||
|
writeIndex(index);
|
||||||
|
return drafts.sort((left, right) =>
|
||||||
|
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard(id) {
|
||||||
|
if (typeof id !== 'string' || !parse(id, (() => {
|
||||||
|
try { return storage?.getItem(id); } catch (_error) { return null; }
|
||||||
|
})())) return false;
|
||||||
|
try {
|
||||||
|
storage?.removeItem(id);
|
||||||
|
storage?.removeItem(id + ':operation');
|
||||||
|
const index = readIndex();
|
||||||
|
delete index[id];
|
||||||
|
writeIndex(index);
|
||||||
|
return true;
|
||||||
|
} catch (_error) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { list, discard };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||||
|
|
@ -65,6 +65,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||||
|
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||||
|
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||||
|
.draft-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.draft-actions button { min-height:44px; width:100%; }
|
||||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||||
|
|
@ -276,6 +280,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||||||
|
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
|
||||||
</div>
|
</div>
|
||||||
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||||
<select class="work-milestone-filter" id="work-milestone-filter">
|
<select class="work-milestone-filter" id="work-milestone-filter">
|
||||||
|
|
@ -660,6 +665,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
|
<script src="static/drafts.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
<script src="static/pick-work.js"></script>
|
<script src="static/pick-work.js"></script>
|
||||||
<script src="static/conversation.js"></script>
|
<script src="static/conversation.js"></script>
|
||||||
|
|
@ -701,13 +707,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let selectedWorkMilestone = 'all';
|
let selectedWorkMilestone = 'all';
|
||||||
try {
|
try {
|
||||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||||
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
if (['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not restore My Work filter', e);
|
console.warn('Could not restore My Work filter', e);
|
||||||
}
|
}
|
||||||
let lastMyWork = [];
|
let lastMyWork = [];
|
||||||
|
let lastDrafts = [];
|
||||||
let lastNotifications = [];
|
let lastNotifications = [];
|
||||||
let lastContextSnapshot = null;
|
let lastContextSnapshot = null;
|
||||||
let notificationPagination = { page: 1, total: 0, has_more: false };
|
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||||||
|
|
@ -759,6 +766,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
|
const draftInbox = createDraftInbox({ storage: localStorage });
|
||||||
const findWorkController = createFindWork({
|
const findWorkController = createFindWork({
|
||||||
fetchJson: fetchReviewJson,
|
fetchJson: fetchReviewJson,
|
||||||
onItems: renderAvailableIssues,
|
onItems: renderAvailableIssues,
|
||||||
|
|
@ -1115,7 +1123,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshMyWorkView() {
|
function refreshMyWorkView() {
|
||||||
|
lastDrafts = draftInbox.list();
|
||||||
const counts = countMyWork(lastMyWork);
|
const counts = countMyWork(lastMyWork);
|
||||||
|
counts.draft = lastDrafts.length;
|
||||||
Object.entries(counts).forEach(([filter, count]) => {
|
Object.entries(counts).forEach(([filter, count]) => {
|
||||||
const element = qs('[data-work-count="' + filter + '"]');
|
const element = qs('[data-work-count="' + filter + '"]');
|
||||||
if (element) element.textContent = count;
|
if (element) element.textContent = count;
|
||||||
|
|
@ -1150,6 +1160,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDrafts() {
|
||||||
|
const list = qs('#my-work-list');
|
||||||
|
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) =>
|
||||||
|
'<article class="my-work-card draft-card">' +
|
||||||
|
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||||
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||||
|
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' +
|
||||||
|
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||||
|
'<div class="draft-actions"><button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
||||||
|
'<button class="draft-discard" data-draft-id="' + escAttr(item.id) + '" type="button">Discard draft</button></div></article>'
|
||||||
|
).join('') : '<div class="muted">No unfinished drafts.</div>';
|
||||||
|
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
|
if (!item) return;
|
||||||
|
if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||||
|
else if (item.route) workRoute.open(item.route);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||||
|
draftInbox.discard(button.dataset.draftId);
|
||||||
|
lastDrafts = draftInbox.list();
|
||||||
|
const count = qs('[data-work-count="draft"]');
|
||||||
|
if (count) count.textContent = lastDrafts.length;
|
||||||
|
renderDrafts();
|
||||||
|
qs('#my-work-action-status').textContent = 'Draft discarded.';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateWorkPaginationControls() {
|
function updateWorkPaginationControls() {
|
||||||
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
|
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
|
||||||
const streams = activeWorkStreams();
|
const streams = activeWorkStreams();
|
||||||
|
|
@ -1166,6 +1208,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMyWork() {
|
function renderMyWork() {
|
||||||
|
lastDrafts = draftInbox.list();
|
||||||
|
const draftCount = qs('[data-work-count="draft"]');
|
||||||
|
if (draftCount) draftCount.textContent = lastDrafts.length;
|
||||||
|
if (selectedWorkFilter === 'draft') {
|
||||||
|
renderDrafts();
|
||||||
|
qs('#bulk-mark-read-bar').hidden = true;
|
||||||
|
qs('#load-more-notifications').hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
|
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||||
|
|
@ -1663,13 +1714,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
|
|
||||||
function openCreateIssueSheet() {
|
function openCreateIssueSheet() {
|
||||||
const captureDraft = issueCapture.loadDraft();
|
const captureDraft = issueCapture.loadDraft();
|
||||||
const repositories = lastContextSnapshot?.repos || [];
|
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||||
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
|
if (captureDraft.repository && !repositories.includes(captureDraft.repository)) {
|
||||||
'<option value="' + escAttr(repository.full_name) + '">' + escapeHtml(repository.full_name) + '</option>'
|
repositories.unshift(captureDraft.repository);
|
||||||
).join('');
|
|
||||||
if (repositories.some(repository => repository.full_name === captureDraft.repository)) {
|
|
||||||
qs('#create-issue-repository').value = captureDraft.repository;
|
|
||||||
}
|
}
|
||||||
|
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
|
||||||
|
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
|
||||||
|
).join('');
|
||||||
|
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
||||||
qs('#create-issue-title').value = captureDraft.title;
|
qs('#create-issue-title').value = captureDraft.title;
|
||||||
qs('#create-issue-body').value = captureDraft.body;
|
qs('#create-issue-body').value = captureDraft.body;
|
||||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||||
|
|
|
||||||
117
tests/test_drafts.py
Normal file
117
tests/test_drafts.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.views import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str):
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
|
||||||
|
script = f"""
|
||||||
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
const values = new Map([
|
||||||
|
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
|
||||||
|
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
|
||||||
|
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
|
||||||
|
['stackchain.update-reply.v1.44', 'I can take this'],
|
||||||
|
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
|
||||||
|
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
|
||||||
|
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
|
||||||
|
]);
|
||||||
|
const storage = {{
|
||||||
|
get length() {{ return values.size; }},
|
||||||
|
key: index => Array.from(values.keys())[index] || null,
|
||||||
|
getItem: key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem: (key, value) => values.set(key, value),
|
||||||
|
removeItem: key => values.delete(key),
|
||||||
|
}};
|
||||||
|
let clock = 1000;
|
||||||
|
const inbox = createDraftInbox({{storage, now:() => ++clock}});
|
||||||
|
const first = inbox.list();
|
||||||
|
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
|
||||||
|
const second = inbox.list();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
kinds:first.map(item => item.kind).sort(),
|
||||||
|
newest:second[0],
|
||||||
|
count:second.length,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
|
||||||
|
assert output["count"] == 5
|
||||||
|
assert output["newest"]["kind"] == "issue-comment"
|
||||||
|
assert output["newest"]["repository"] == "stackchain/api"
|
||||||
|
assert output["newest"]["number"] == 17
|
||||||
|
assert output["newest"]["preview"] == "Newest comment text"
|
||||||
|
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_discard_removes_only_selected_content_and_operation_identity():
|
||||||
|
script = f"""
|
||||||
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
const values = new Map([
|
||||||
|
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
|
||||||
|
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
|
||||||
|
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
|
||||||
|
]);
|
||||||
|
const storage = {{
|
||||||
|
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
|
||||||
|
getItem:key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
|
||||||
|
}};
|
||||||
|
const inbox = createDraftInbox({{storage, now:() => 1000}});
|
||||||
|
const selected = inbox.list().find(item => item.kind === 'issue-comment');
|
||||||
|
inbox.discard(selected.id);
|
||||||
|
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
|
||||||
|
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
|
||||||
|
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
|
||||||
|
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
|
||||||
|
script = f"""
|
||||||
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
const values = new Map([
|
||||||
|
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
|
||||||
|
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
|
||||||
|
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
|
||||||
|
]);
|
||||||
|
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const drafts = createDraftInbox({{storage,now:()=>42}}).list();
|
||||||
|
process.stdout.write(JSON.stringify(drafts));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert len(output) == 1
|
||||||
|
assert output[0]["kind"] == "issue-edit"
|
||||||
|
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
|
||||||
|
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/drafts.js"></script>' in html
|
||||||
|
assert 'data-work-filter="draft"' in html
|
||||||
|
assert 'data-work-count="draft"' in html
|
||||||
|
assert 'class="draft-resume"' in html
|
||||||
|
assert 'class="draft-discard"' in html
|
||||||
|
assert 'Discard this unfinished draft?' in html
|
||||||
|
assert '.draft-actions button { min-height:44px;' in html
|
||||||
|
assert 'createDraftInbox({ storage: localStorage' in html
|
||||||
|
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||||
|
|
@ -2250,7 +2250,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||||
assert 'data-work-count="pull"' in html
|
assert 'data-work-count="pull"' in html
|
||||||
assert 'data-work-count="review"' in html
|
assert 'data-work-count="review"' in html
|
||||||
assert 'data-work-count="update"' in html
|
assert 'data-work-count="update"' in html
|
||||||
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html
|
assert "['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
|
||||||
|
assert 'data-work-count="draft"' in html
|
||||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user