feat: edit assigned issue content from mobile (#189)
This commit is contained in:
1 parent
c0eabbf865
commit
937df5d20b
7 files changed
+479
-1
No files matched your search
+98
-1
@@ -131,6 +131,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-edit-form textarea { min-height:132px; resize:vertical; }
|
||||
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
@@ -346,7 +353,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Retry loading issue</button>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
@@ -355,6 +362,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||
</fieldset>
|
||||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="issue-edit-body">Description
|
||||
<textarea id="issue-edit-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-issue-content" type="submit">Save issue</button>
|
||||
<button id="cancel-issue-content" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<h2>Recent discussion</h2>
|
||||
<div id="issue-comments"></div>
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
@@ -589,6 +610,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let selectedUpdate = null;
|
||||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let selectedIssueDetail = null;
|
||||
let issueTrigger = null;
|
||||
let selectedPull = null;
|
||||
let pullTrigger = null;
|
||||
@@ -1051,6 +1073,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
async function openIssueSheet(item, trigger) {
|
||||
if (!item) return;
|
||||
selectedIssue = item;
|
||||
selectedIssueDetail = null;
|
||||
issueTrigger = trigger;
|
||||
qs('#issue-sheet').classList.add('open');
|
||||
qs('#issue-sheet-key').textContent = item.key || '';
|
||||
@@ -1068,11 +1091,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#retry-issue-load').hidden = true;
|
||||
qs('#open-issue-gitea').href = item.url || '#';
|
||||
qs('#send-issue-comment').disabled = false;
|
||||
qs('#edit-issue-content').disabled = true;
|
||||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#issue-edit-status').textContent = '';
|
||||
qs('#close-issue').disabled = false;
|
||||
qs('#close-issue-sheet').focus();
|
||||
try {
|
||||
const detail = await issueController.load(item);
|
||||
if (selectedIssue !== item) return;
|
||||
selectedIssueDetail = detail;
|
||||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||||
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
|
||||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||||
@@ -1085,6 +1112,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||||
qs('#edit-issue-content').disabled = false;
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||||
@@ -1096,6 +1124,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
function closeIssueSheet() {
|
||||
qs('#issue-sheet').classList.remove('open');
|
||||
selectedIssue = null;
|
||||
selectedIssueDetail = null;
|
||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||
}
|
||||
|
||||
@@ -1730,6 +1759,74 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#retry-issue-load').addEventListener('click', () => {
|
||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||||
});
|
||||
qs('#edit-issue-content').addEventListener('click', () => {
|
||||
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
||||
const draft = issueController.loadEditDraft(selectedIssue) || {
|
||||
title: selectedIssueDetail.title || '',
|
||||
body: selectedIssueDetail.body || '',
|
||||
expectedUpdatedAt: selectedIssueDetail.updated_at,
|
||||
};
|
||||
qs('#issue-edit-title').value = draft.title;
|
||||
qs('#issue-edit-body').value = draft.body;
|
||||
qs('#issue-edit-form').hidden = false;
|
||||
qs('#issue-edit-status').textContent = 'Edit the issue, then save.';
|
||||
qs('#issue-edit-title').focus();
|
||||
});
|
||||
['#issue-edit-title', '#issue-edit-body'].forEach(selector =>
|
||||
qs(selector).addEventListener('input', () => {
|
||||
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
||||
issueController.saveEditDraft(selectedIssue, {
|
||||
title: qs('#issue-edit-title').value,
|
||||
body: qs('#issue-edit-body').value,
|
||||
expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
||||
});
|
||||
})
|
||||
);
|
||||
qs('#cancel-issue-content').addEventListener('click', () => {
|
||||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#edit-issue-content').focus();
|
||||
});
|
||||
qs('#issue-edit-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return;
|
||||
const title = qs('#issue-edit-title').value.trim();
|
||||
const body = qs('#issue-edit-body').value.trim();
|
||||
if (!title) {
|
||||
qs('#issue-edit-status').textContent = 'Add a title before saving.';
|
||||
qs('#issue-edit-title').focus();
|
||||
return;
|
||||
}
|
||||
const editing = selectedIssue;
|
||||
const savedDraft = issueController.loadEditDraft(editing);
|
||||
const draft = {
|
||||
title,
|
||||
body,
|
||||
expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
||||
};
|
||||
const button = qs('#save-issue-content');
|
||||
button.disabled = true;
|
||||
qs('#issue-edit-status').textContent = 'Saving issue…';
|
||||
try {
|
||||
const confirmed = await issueController.updateContent(editing, draft);
|
||||
lastContextSnapshot = buildMyWork.replaceIssueContent(
|
||||
lastContextSnapshot, editing.repository, editing.number, confirmed
|
||||
);
|
||||
selectedIssue = { ...editing, ...confirmed, key: editing.key };
|
||||
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
|
||||
qs('#issue-sheet-title').textContent = confirmed.title;
|
||||
qs('#issue-sheet-body').textContent = confirmed.body || 'No description provided.';
|
||||
paintMyWork(lastContextSnapshot);
|
||||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#issue-sheet-status').textContent = 'Issue saved.';
|
||||
qs('#edit-issue-content').focus();
|
||||
} catch (error) {
|
||||
qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.';
|
||||
qs('#retry-issue-load').hidden = false;
|
||||
qs('#issue-edit-title').focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#issue-comment').addEventListener('input', event => {
|
||||
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
||||
});
|
||||
|
||||
@@ -3,11 +3,13 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = 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;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
@@ -31,6 +33,38 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
||||
typeof value.expectedUpdatedAt === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
saveEditDraft(item, draft) {
|
||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
||||
},
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
expected_updated_at: draft.expectedUpdatedAt,
|
||||
}),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) {
|
||||
throw new Error('Issue update was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(editDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
loadLabelDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
|
||||
@@ -349,6 +349,15 @@ function replaceIssueLabels(data, repository, number, labels) {
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueContent(data, repository, number, content) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, ...content } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function removeIssue(data, repository, number) {
|
||||
return {
|
||||
...data,
|
||||
@@ -381,6 +390,7 @@ function countMyWork(items) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
|
||||
Reference in new issue
Block a user