feat: capture issues from mobile My Work (#159)
This commit is contained in:
1 parent
8c889e3ffc
commit
bb394be8b2
7 files changed
+451
-4
No files matched your search
@@ -0,0 +1,53 @@
|
||||
function createIssueCapture({ fetchJson, storage }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
let pending = null;
|
||||
const emptyDraft = () => ({ repository: '', title: '', body: '' });
|
||||
|
||||
function saveDraft(draft) {
|
||||
const safe = {
|
||||
repository: String(draft?.repository || ''),
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
};
|
||||
try { storage.setItem(storageKey, JSON.stringify(safe)); }
|
||||
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
||||
return safe;
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||
return parsed && typeof parsed === 'object' ? {
|
||||
repository: String(parsed.repository || ''),
|
||||
title: String(parsed.title || ''),
|
||||
body: String(parsed.body || ''),
|
||||
} : emptyDraft();
|
||||
} catch (_error) {
|
||||
return emptyDraft();
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
try { storage.removeItem(storageKey); }
|
||||
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
|
||||
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: saved.title, body: saved.body }),
|
||||
}).then(issue => {
|
||||
clearDraft();
|
||||
return issue;
|
||||
}).finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return { saveDraft, loadDraft, submit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
@@ -129,6 +129,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.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; }
|
||||
@media (max-width: 600px) {
|
||||
header { align-items:flex-start; }
|
||||
.my-work { margin:0; }
|
||||
@@ -138,6 +148,7 @@ 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; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@@ -163,6 +174,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<h2>My Work</h2>
|
||||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||
</div>
|
||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
@@ -292,6 +304,31 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
||||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">New issue</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<label for="create-issue-title">Title
|
||||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
@@ -380,6 +417,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script>
|
||||
@@ -427,6 +465,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let issueTrigger = null;
|
||||
let creatingIssue = false;
|
||||
let progress = null;
|
||||
let draft = null;
|
||||
let reviewFiles = [];
|
||||
@@ -443,6 +482,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
|
||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
|
||||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||
@@ -806,6 +846,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||
}
|
||||
|
||||
function saveIssueCaptureDraft() {
|
||||
issueCapture.saveDraft({
|
||||
repository: qs('#create-issue-repository').value,
|
||||
title: qs('#create-issue-title').value,
|
||||
body: qs('#create-issue-body').value,
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateIssueSheet() {
|
||||
const captureDraft = issueCapture.loadDraft();
|
||||
const repositories = lastContextSnapshot?.repos || [];
|
||||
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
|
||||
'<option value="' + escAttr(repository.full_name) + '">' + escapeHtml(repository.full_name) + '</option>'
|
||||
).join('');
|
||||
if (repositories.some(repository => repository.full_name === captureDraft.repository)) {
|
||||
qs('#create-issue-repository').value = captureDraft.repository;
|
||||
}
|
||||
qs('#create-issue-title').value = captureDraft.title;
|
||||
qs('#create-issue-body').value = captureDraft.body;
|
||||
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');
|
||||
creatingIssue = true;
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
|
||||
function closeCreateIssueSheet() {
|
||||
qs('#create-issue-sheet').classList.remove('open');
|
||||
creatingIssue = false;
|
||||
qs('#new-issue').focus();
|
||||
}
|
||||
|
||||
async function openReviewSheet(item, trigger) {
|
||||
selectedReview = item;
|
||||
reviewTrigger = trigger;
|
||||
@@ -1051,6 +1123,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && creatingIssue) {
|
||||
e.preventDefault();
|
||||
saveIssueCaptureDraft();
|
||||
closeCreateIssueSheet();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && selectedIssue) {
|
||||
e.preventDefault();
|
||||
closeIssueSheet();
|
||||
@@ -1066,6 +1144,46 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||
qs('#cancel-new-issue').addEventListener('click', () => {
|
||||
saveIssueCaptureDraft();
|
||||
closeCreateIssueSheet();
|
||||
});
|
||||
['#create-issue-repository', '#create-issue-title', '#create-issue-body'].forEach(selector =>
|
||||
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
||||
);
|
||||
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(),
|
||||
};
|
||||
if (!captureDraft.repository || !captureDraft.title) {
|
||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||
qs('#create-issue-title').focus();
|
||||
return;
|
||||
}
|
||||
const button = qs('#submit-new-issue');
|
||||
button.disabled = true;
|
||||
qs('#create-issue-status').textContent = 'Creating issue…';
|
||||
try {
|
||||
const confirmed = await issueCapture.submit(captureDraft);
|
||||
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||
lastMyWork = buildMyWork(lastContextSnapshot);
|
||||
const created = lastMyWork.find(item =>
|
||||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||||
);
|
||||
closeCreateIssueSheet();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
|
||||
openIssueSheet(created, qs('#new-issue'));
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
button.disabled = false;
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
||||
qs('#retry-issue-load').addEventListener('click', () => {
|
||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||||
|
||||
Reference in new issue
Block a user