feat: manage issue blockers in app (Closes #531)
This commit is contained in:
1 parent
29ceedfc01
commit
2c8a1225e0
8 files changed
+484
-1
No files matched your search
@@ -278,6 +278,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
|
||||
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
|
||||
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
|
||||
.issue-blocker-row .issue-blocker { min-width:0; }
|
||||
.issue-blocker-remove { min-height:44px; white-space:normal; }
|
||||
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
|
||||
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
|
||||
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
|
||||
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
|
||||
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
@@ -468,6 +476,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
|
||||
+124
-1
@@ -123,6 +123,8 @@
|
||||
let selectedIssue = null;
|
||||
let selectedIssueOffline = false;
|
||||
let selectedIssueDetail = null;
|
||||
let issueBlockerCandidates = [];
|
||||
let issueBlockerSearchTimer = null;
|
||||
let issueConversation = null;
|
||||
let issueTrigger = null;
|
||||
let selectedPull = null;
|
||||
@@ -1332,13 +1334,42 @@
|
||||
|
||||
function renderPlanIssueDependencies(detail, loading = false) {
|
||||
const preview = planTodayPreview.snapshot();
|
||||
if (!preview.open || preview.item?.kind !== 'issue') return;
|
||||
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
|
||||
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
|
||||
const panel = qs('#issue-blockers');
|
||||
const list = qs('#issue-blocker-list');
|
||||
const status = qs('#issue-blocker-status');
|
||||
const addButton = qs('#add-plan-preview');
|
||||
panel.hidden = false;
|
||||
list.innerHTML = '';
|
||||
qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline;
|
||||
qs('#start-unblocked-issue').hidden = true;
|
||||
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
|
||||
if (assignedIssue) {
|
||||
const available = detail?.dependencies_available === true;
|
||||
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
|
||||
list.innerHTML = dependencies.map((blocker, index) =>
|
||||
'<div class="issue-blocker-row"><a class="issue-blocker" href="' + escAttr(blocker.url || '#') +
|
||||
'" target="_blank" rel="noopener noreferrer"><strong>' + escapeHtml(blocker.repository + '#' + blocker.number) +
|
||||
' · ' + escapeHtml(blocker.title || 'Untitled blocker') + '</strong><span class="small">State: ' +
|
||||
escapeHtml(blocker.state || 'open') + '</span></a><button class="issue-blocker-remove" type="button" data-remove-blocker-index="' +
|
||||
index + '">Remove blocker</button></div>'
|
||||
).join('');
|
||||
list.querySelectorAll('[data-remove-blocker-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const blocker = dependencies[Number(button.dataset.removeBlockerIndex)];
|
||||
if (blocker && window.confirm('Remove ' + blocker.repository + '#' + blocker.number + ' as a blocker?')) {
|
||||
mutateIssueBlocker(blocker, true, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
status.textContent = available ? (dependencies.length ?
|
||||
dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.') :
|
||||
'No unresolved blockers. Add one without leaving this issue.') :
|
||||
'Blocker status unavailable. Reload before changing relationships.';
|
||||
qs('#manage-issue-blockers').disabled = !available;
|
||||
return;
|
||||
}
|
||||
addButton.disabled = loading;
|
||||
addButton.dataset.planOverride = '';
|
||||
if (loading) {
|
||||
@@ -1371,6 +1402,98 @@
|
||||
addButton.textContent = 'Add to Today & back';
|
||||
}
|
||||
|
||||
async function mutateIssueBlocker(blocker, remove, button) {
|
||||
if (!selectedIssue || selectedIssueOffline) return;
|
||||
const item = selectedIssue;
|
||||
button.disabled = true;
|
||||
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
|
||||
try {
|
||||
const result = remove ?
|
||||
await issueController.updateBlocker(selectedIssue, blocker, true) :
|
||||
await issueController.updateBlocker(selectedIssue, blocker, false);
|
||||
if (selectedIssue !== item) return;
|
||||
selectedIssueDetail = { ...selectedIssueDetail, ...result };
|
||||
renderPlanIssueDependencies(selectedIssueDetail);
|
||||
if (remove && result.dependencies.length === 0 && workSession.checkpointed(item)) {
|
||||
qs('#issue-blocker-status').textContent = 'All blockers cleared. This Today item is ready.';
|
||||
qs('#start-unblocked-issue').hidden = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-blocker-status').textContent = error.message + ' The previous blocker list is unchanged; reload before retrying.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderIssueBlockerCandidates(items) {
|
||||
issueBlockerCandidates = items;
|
||||
const results = qs('#issue-blocker-results');
|
||||
results.innerHTML = items.map((result, index) =>
|
||||
'<button type="button" role="option" data-blocker-result="' + index + '"><strong>' +
|
||||
escapeHtml(result.repository + '#' + result.number) + '</strong><span class="small">' +
|
||||
escapeHtml(result.title || 'Untitled issue') + '</span></button>'
|
||||
).join('');
|
||||
results.querySelectorAll('[data-blocker-result]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
|
||||
if (blocker) mutateIssueBlocker(blocker, false, button).then(() => {
|
||||
if (selectedIssueDetail?.dependencies?.some(candidate =>
|
||||
candidate.repository === blocker.repository && candidate.number === blocker.number
|
||||
)) {
|
||||
qs('#issue-blocker-manager').hidden = true;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
qs('#manage-issue-blockers').addEventListener('click', () => {
|
||||
qs('#issue-blocker-manager').hidden = false;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'true');
|
||||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||||
qs('#issue-blocker-search').focus();
|
||||
});
|
||||
qs('#cancel-issue-blocker').addEventListener('click', () => {
|
||||
qs('#issue-blocker-manager').hidden = true;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||||
qs('#manage-issue-blockers').focus();
|
||||
});
|
||||
qs('#issue-blocker-search').addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') qs('#cancel-issue-blocker').click();
|
||||
});
|
||||
qs('#issue-blocker-search').addEventListener('input', event => {
|
||||
clearTimeout(issueBlockerSearchTimer);
|
||||
const query = event.target.value.trim();
|
||||
if (query.length < 2) {
|
||||
renderIssueBlockerCandidates([]);
|
||||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||||
return;
|
||||
}
|
||||
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
|
||||
issueBlockerSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const payload = await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10');
|
||||
const existing = selectedIssueDetail?.dependencies || [];
|
||||
const items = (payload.items || []).filter(result =>
|
||||
result.kind === 'issue' && result.state === 'open' &&
|
||||
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
|
||||
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
|
||||
);
|
||||
renderIssueBlockerCandidates(items);
|
||||
qs('#issue-blocker-search-status').textContent = items.length ?
|
||||
items.length + (items.length === 1 ? ' open issue found.' : ' open issues found.') : 'No eligible open issues found.';
|
||||
} catch (error) {
|
||||
renderIssueBlockerCandidates([]);
|
||||
qs('#issue-blocker-search-status').textContent = error.message + ' Retry your search.';
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
qs('#start-unblocked-issue').addEventListener('click', () => {
|
||||
if (selectedIssue) todayReadiness.run('start', workSession.items(), selectedIssue);
|
||||
});
|
||||
|
||||
let addPlanPreviewOnReturn = false;
|
||||
let addPlanPreviewOverride = false;
|
||||
|
||||
|
||||
@@ -307,6 +307,16 @@
|
||||
<h2 id="issue-blockers-title">Blocked by</h2>
|
||||
<div class="issue-blocker-list" id="issue-blocker-list"></div>
|
||||
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
|
||||
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
|
||||
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
|
||||
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
|
||||
<label for="issue-blocker-search">Search open issues</label>
|
||||
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
|
||||
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
|
||||
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
|
||||
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
|
||||
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
|
||||
@@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
let blockerRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
@@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
updateBlocker(item, blocker, remove = false) {
|
||||
if (blockerRequest) return blockerRequest;
|
||||
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
|
||||
method: remove ? 'DELETE' : 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
|
||||
}).then(result => {
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate =>
|
||||
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
|
||||
);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { blockerRequest = null; });
|
||||
return blockerRequest;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
|
||||
Reference in new issue
Block a user