feat: warn before duplicate mobile issue capture (#371)
This commit is contained in:
1 parent
1bf759dc76
commit
c0fc4eddd0
12 files changed
+293
-21
No files matched your search
@@ -23,6 +23,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
@@ -140,6 +143,51 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
return { repository, title, key: repository + '\n' + title.toLowerCase() };
|
||||
}
|
||||
|
||||
async function findDuplicates(draft) {
|
||||
const input = duplicateKey(draft);
|
||||
const request = ++duplicateRequest;
|
||||
if (!input.repository || input.title.length < 4) {
|
||||
duplicateState = {status: 'idle', key: input.key, candidates: []};
|
||||
return duplicateState;
|
||||
}
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {
|
||||
status: 'ready',
|
||||
key: input.key,
|
||||
partial: payload?.partial === true,
|
||||
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
|
||||
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
|
||||
).slice(0, 3),
|
||||
};
|
||||
return duplicateState;
|
||||
} catch (error) {
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
|
||||
return duplicateState;
|
||||
}
|
||||
}
|
||||
|
||||
function needsDuplicateAcknowledgement(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
|
||||
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
|
||||
}
|
||||
|
||||
function acknowledgeDuplicates(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
|
||||
acknowledgedDuplicateKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
@@ -166,7 +214,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,6 +274,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||||
.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
|
||||
.create-issue-duplicates[hidden] { display:none; }
|
||||
.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
|
||||
.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
|
||||
.create-issue-duplicate-card a { color:#93c5fd; }
|
||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
|
||||
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
|
||||
+82
-9
@@ -1717,6 +1717,67 @@
|
||||
});
|
||||
}
|
||||
|
||||
function currentIssueCaptureDraft() {
|
||||
return {
|
||||
repository: qs('#create-issue-repository').value,
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
labelIds: selectedIssueLabelIds(),
|
||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||
dueDate: qs('#create-issue-due-date').value,
|
||||
};
|
||||
}
|
||||
|
||||
let duplicateCheckTimer = null;
|
||||
function renderIssueDuplicates(state) {
|
||||
if (state.status === 'stale') return;
|
||||
const panel = qs('#create-issue-duplicates');
|
||||
const list = qs('#create-issue-duplicate-list');
|
||||
const status = qs('#create-issue-duplicate-status');
|
||||
const anyway = qs('#create-issue-anyway');
|
||||
list.replaceChildren();
|
||||
anyway.hidden = true;
|
||||
if (state.status === 'idle') {
|
||||
panel.hidden = true;
|
||||
return;
|
||||
}
|
||||
panel.hidden = false;
|
||||
if (state.status === 'failed') {
|
||||
status.textContent = 'Could not check for existing issues. You can still create this issue.';
|
||||
return;
|
||||
}
|
||||
if (!state.candidates.length) {
|
||||
status.textContent = 'No similar open issues found in this repository.';
|
||||
return;
|
||||
}
|
||||
status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length +
|
||||
' similar open issue' + (state.candidates.length === 1 ? '' : 's') +
|
||||
' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.');
|
||||
state.candidates.forEach(item => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'create-issue-duplicate-card';
|
||||
const key = document.createElement('span');
|
||||
key.className = 'small';
|
||||
key.textContent = item.repository + ' #' + item.number;
|
||||
const link = document.createElement('a');
|
||||
link.textContent = item.title || 'Review existing issue';
|
||||
link.href = safeSearchUrl(item.url) || '#';
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.addEventListener('click', saveIssueCaptureDraft);
|
||||
card.append(key, link);
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleIssueDuplicateCheck() {
|
||||
clearTimeout(duplicateCheckTimer);
|
||||
const draft = currentIssueCaptureDraft();
|
||||
duplicateCheckTimer = setTimeout(async () => {
|
||||
renderIssueDuplicates(await issueCapture.findDuplicates(draft));
|
||||
}, 350);
|
||||
}
|
||||
|
||||
async function loadIssueLabels(repository, selectedIds = []) {
|
||||
const list = qs('#create-issue-label-list');
|
||||
const status = qs('#create-issue-label-status');
|
||||
@@ -1802,6 +1863,7 @@
|
||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
scheduleIssueDuplicateCheck();
|
||||
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
|
||||
qs('#submit-new-issue').disabled = !repositories.length;
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
@@ -1817,6 +1879,8 @@
|
||||
return;
|
||||
}
|
||||
qs('#create-issue-sheet').classList.remove('open');
|
||||
clearTimeout(duplicateCheckTimer);
|
||||
qs('#create-issue-duplicates').hidden = true;
|
||||
creatingIssue = false;
|
||||
qs('#new-issue').focus();
|
||||
}
|
||||
@@ -2565,30 +2629,39 @@
|
||||
closeCreateIssueSheet(true, !discardEditedDraft);
|
||||
});
|
||||
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
|
||||
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
||||
qs(selector).addEventListener('input', () => {
|
||||
saveIssueCaptureDraft();
|
||||
if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
|
||||
})
|
||||
);
|
||||
qs('#create-issue-repository').addEventListener('change', event => {
|
||||
loadIssueLabels(event.target.value);
|
||||
loadIssueMilestones(event.target.value);
|
||||
saveIssueCaptureDraft();
|
||||
scheduleIssueDuplicateCheck();
|
||||
});
|
||||
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
|
||||
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
|
||||
qs('#create-issue-anyway').addEventListener('click', () => {
|
||||
const captureDraft = currentIssueCaptureDraft();
|
||||
issueCapture.acknowledgeDuplicates(captureDraft);
|
||||
qs('#create-issue-anyway').hidden = true;
|
||||
qs('#create-issue-form').requestSubmit();
|
||||
});
|
||||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const captureDraft = {
|
||||
repository: qs('#create-issue-repository').value,
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
labelIds: selectedIssueLabelIds(),
|
||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||
dueDate: qs('#create-issue-due-date').value,
|
||||
};
|
||||
const captureDraft = currentIssueCaptureDraft();
|
||||
if (!captureDraft.repository || !captureDraft.title) {
|
||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||
qs('#create-issue-title').focus();
|
||||
return;
|
||||
}
|
||||
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
|
||||
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
|
||||
qs('#create-issue-anyway').hidden = false;
|
||||
qs('#create-issue-anyway').focus();
|
||||
return;
|
||||
}
|
||||
const button = qs('#submit-new-issue');
|
||||
button.disabled = true;
|
||||
qs('#create-issue-status').textContent = 'Saving for background delivery…';
|
||||
|
||||
@@ -355,6 +355,12 @@
|
||||
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
||||
<div class="create-issue-label-list" id="create-issue-label-list"></div>
|
||||
</fieldset>
|
||||
<section class="create-issue-duplicates" id="create-issue-duplicates" aria-live="polite" hidden>
|
||||
<strong>Possible existing issues</strong>
|
||||
<div id="create-issue-duplicate-status" class="small"></div>
|
||||
<div id="create-issue-duplicate-list" class="create-issue-duplicate-list"></div>
|
||||
<button id="create-issue-anyway" type="button" hidden>Create anyway</button>
|
||||
</section>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v51';
|
||||
const CACHE = 'stackchain-dashboard-shell-v52';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
|
||||
Reference in new issue
Block a user