stackchain-dashboard/frontend/issue-sheet.js
timmy d09ceb8650
All checks were successful
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
feat: make mobile issue sheets read-first (#215)
2026-08-07 17:38:01 +00:00

239 lines
10 KiB
JavaScript

function createPlanningLoader({ loadLabels, loadMilestones }) {
const requests = new Map();
const keyFor = item => item.repository + '#' + item.number;
return {
open(item) {
const key = keyFor(item);
if (requests.has(key)) return requests.get(key);
const request = Promise.all([loadLabels(item), loadMilestones(item)])
.then(([labels, milestones]) => ({ labels, milestones }))
.catch(error => {
if (requests.get(key) === request) requests.delete(key);
throw error;
});
requests.set(key, request);
return request;
},
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
let milestoneRequest = 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;
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
load(item) {
return fetchJson(issuePath(item) + '/detail', {
headers: { Accept: 'application/json' },
});
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
loadLabels(item) {
return fetchJson(issuePath(item) + '/labels', {
headers: { Accept: 'application/json' },
});
},
loadMilestones(item) {
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + repository + '/milestones', {
headers: { Accept: 'application/json' },
});
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
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;
},
loadDueDateDraft(item) {
try {
const raw = storage?.getItem(dueDateDraftKey(item));
if (raw === null || raw === undefined) return null;
const value = JSON.parse(raw);
return typeof value === 'string' ? value : null;
} catch (_error) { return null; }
},
updateDueDate(item, dueDate) {
if (dueDateRequest) return dueDateRequest;
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
catch (_error) { /* The date input remains the fallback. */ }
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ due_date: dueDate }),
}).then(result => {
if (result?.number !== item.number || result?.due_date !== dueDate) {
throw new Error('Issue due date was not confirmed.');
}
try { storage?.removeItem(dueDateDraftKey(item)); }
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
return result;
}).finally(() => { dueDateRequest = null; });
return dueDateRequest;
},
loadMilestoneDraft(item) {
try {
const raw = storage?.getItem(milestoneDraftKey(item));
if (raw === null || raw === undefined) return null;
const value = JSON.parse(raw);
return value === null || Number.isInteger(value) ? value : null;
} catch (_error) { return null; }
},
updateMilestone(item, milestoneId) {
if (milestoneRequest) return milestoneRequest;
try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); }
catch (_error) { /* The select remains the fallback. */ }
milestoneRequest = fetchJson(issuePath(item) + '/milestone', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ milestone_id: milestoneId }),
}).then(result => {
if (result?.number !== item.number ||
(milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) {
throw new Error('Issue milestone was not confirmed.');
}
try { storage?.removeItem(milestoneDraftKey(item)); }
catch (_error) { /* Confirmed upstream milestone is authoritative. */ }
return result;
}).finally(() => { milestoneRequest = null; });
return milestoneRequest;
},
loadLabelDraft(item) {
try {
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
return Array.isArray(value) ? value.filter(Number.isInteger) : [];
} catch (_error) { return []; }
},
updateLabels(item, labelIds) {
if (labelRequest) return labelRequest;
try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); }
catch (_error) { /* The checked controls remain the fallback. */ }
labelRequest = fetchJson(issuePath(item) + '/labels', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ label_ids: labelIds }),
}).then(result => {
if (result?.number !== item.number || !Array.isArray(result.labels)) {
throw new Error('Issue labels were not confirmed.');
}
try { storage?.removeItem(labelDraftKey(item)); }
catch (_error) { /* The upstream label set is authoritative. */ }
return result;
}).finally(() => { labelRequest = null; });
return labelRequest;
},
close(item) {
if (closeRequest) return closeRequest;
closeRequest = fetchJson(issuePath(item) + '/close', {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
return result;
}).finally(() => { closeRequest = null; });
return closeRequest;
},
release(item, currentLogin) {
if (releaseRequest) return releaseRequest;
releaseRequest = fetchJson(issuePath(item) + '/release', {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
if (
result?.number !== item.number ||
!Array.isArray(result.assignees) ||
result.assignees.includes(currentLogin)
) {
throw new Error('Issue release was not confirmed.');
}
return result;
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(issuePath(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
},
};
}
createIssueSheet.createPlanningLoader = createPlanningLoader;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;