feat: apply repository templates during mobile issue filing
Closes #845
This commit is contained in:
1 parent
2ac6b1f56b
commit
31f95589cb
10 files changed
+344
-8
No files matched your search
@@ -108,6 +108,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let issueTemplates = [];
|
||||
let activeTemplate = null;
|
||||
let templateRequest = 0;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let blockerSearchRequest = 0;
|
||||
@@ -148,6 +151,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
|
||||
draft.templateName = parsed.templateName.trim().slice(0, 80);
|
||||
draft.templateId = String(parsed.templateId || '').slice(0, 80);
|
||||
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safeAssignee(parsed.assignee);
|
||||
if (assignee) {
|
||||
draft.assignee = assignee;
|
||||
@@ -178,6 +186,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
|
||||
safe.templateName = draft.templateName.trim().slice(0, 80);
|
||||
safe.templateId = String(draft.templateId || '').slice(0, 80);
|
||||
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safeAssignee(draft?.assignee);
|
||||
if (assignee) {
|
||||
safe.assignee = assignee;
|
||||
@@ -189,7 +202,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const blockers = safeBlockers(draft?.blockers);
|
||||
if (blockers.length) safe.blockers = blockers;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName']
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName',
|
||||
'templateName', 'templateId', 'capturedBody']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
||||
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
||||
@@ -305,6 +319,120 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadTemplates(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
|
||||
Array.isArray(templates) ? templates.slice(0, 20) : []
|
||||
);
|
||||
}
|
||||
|
||||
function applyTemplate(draft, template, availableLabels = []) {
|
||||
const source = {...(draft || {})};
|
||||
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
|
||||
if (!template) {
|
||||
delete source.templateName;
|
||||
delete source.templateId;
|
||||
delete source.capturedBody;
|
||||
return {...source, body: capturedBody};
|
||||
}
|
||||
const scaffold = String(template.body || '').trim().slice(0, 9000);
|
||||
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
|
||||
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
|
||||
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
|
||||
.map(label => [label.name.toLowerCase(), label.id]));
|
||||
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
|
||||
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
|
||||
return {
|
||||
...source,
|
||||
title: source.title || String(template.title || '').trim().slice(0, 255),
|
||||
body,
|
||||
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
|
||||
templateId: String(template.id || '').slice(0, 80),
|
||||
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
|
||||
capturedBody,
|
||||
};
|
||||
}
|
||||
|
||||
function setTemplateState(draft) {
|
||||
activeTemplate = draft?.templateName ? {
|
||||
templateName:draft.templateName, templateId:draft.templateId,
|
||||
capturedBody:draft.capturedBody || '',
|
||||
} : null;
|
||||
}
|
||||
|
||||
function templateFields(draft) {
|
||||
return activeTemplate ? {...draft, ...activeTemplate} : draft;
|
||||
}
|
||||
|
||||
function restoreTemplate(draft, availableLabels) {
|
||||
const restored = applyTemplate(templateFields(draft), null, availableLabels);
|
||||
activeTemplate = null;
|
||||
return restored;
|
||||
}
|
||||
|
||||
async function loadTemplateOptions(repository, elements, selectedId = '') {
|
||||
const request = ++templateRequest;
|
||||
issueTemplates = [];
|
||||
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||
elements.field.hidden = true;
|
||||
if (!repository) return;
|
||||
elements.status.textContent = 'Loading issue types…';
|
||||
try {
|
||||
const templates = await loadTemplates(repository);
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
issueTemplates = templates;
|
||||
templates.forEach(template => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
elements.select.appendChild(option);
|
||||
});
|
||||
elements.field.hidden = templates.length === 0;
|
||||
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
|
||||
elements.status.textContent = templates.length ?
|
||||
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||
} catch (_error) {
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||
}
|
||||
}
|
||||
|
||||
function selectTemplate(identifier, draft, availableLabels) {
|
||||
const template = issueTemplates.find(item => item.id === identifier) || null;
|
||||
const applied = applyTemplate(templateFields(draft), template, availableLabels);
|
||||
activeTemplate = template ? {
|
||||
templateId:applied.templateId, templateName:applied.templateName,
|
||||
capturedBody:applied.capturedBody,
|
||||
} : null;
|
||||
return {draft: applied, template};
|
||||
}
|
||||
|
||||
function bindTemplatePicker(elements, callbacks) {
|
||||
let labels = [];
|
||||
elements.select.addEventListener('change', event => {
|
||||
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
|
||||
callbacks.setDraft(draft);
|
||||
const selected = new Set(draft.labelIds || []);
|
||||
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
|
||||
elements.status.textContent = template ?
|
||||
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
|
||||
callbacks.changed();
|
||||
});
|
||||
return {
|
||||
fields:templateFields,
|
||||
reset:setTemplateState,
|
||||
setLabels(value) { labels = Array.isArray(value) ? value : []; },
|
||||
load(repository, selectedId) {
|
||||
return loadTemplateOptions(repository, elements, selectedId);
|
||||
},
|
||||
changeRepository(draft) {
|
||||
const restored = restoreTemplate(draft, labels);
|
||||
callbacks.setDraft(restored);
|
||||
return restored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
@@ -426,7 +554,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadRepositoryPage,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
|
||||
applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
|
||||
selectTemplate, bindTemplatePicker, loadRepositoryPage,
|
||||
searchRepositories, searchBlockers, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
|
||||
+28
-5
@@ -520,6 +520,7 @@
|
||||
});
|
||||
let issueCapture = null;
|
||||
let issueOwnerPicker = null;
|
||||
let issueTemplatePicker = null;
|
||||
let updateFollowUp = null;
|
||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
@@ -566,6 +567,7 @@
|
||||
evidenceNote: qs('#issue-filing-review-evidence-note'),
|
||||
repository: qs('#issue-filing-review-repository'),
|
||||
intent: qs('#issue-filing-review-intent'),
|
||||
issueType: qs('#issue-filing-review-template'),
|
||||
title: qs('#issue-filing-review-title'),
|
||||
body: qs('#issue-filing-review-body'),
|
||||
metadata: qs('#issue-filing-review-metadata'),
|
||||
@@ -645,6 +647,16 @@
|
||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||||
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||||
issueTemplatePicker = issueCapture.bindTemplatePicker({
|
||||
field:qs('#create-issue-template-field'), select:qs('#create-issue-template'),
|
||||
status:qs('#create-issue-template-status'), document,
|
||||
getRepository:()=>qs('#create-issue-repository').value,
|
||||
labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'),
|
||||
}, {
|
||||
getDraft:currentIssueCaptureDraft,
|
||||
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
|
||||
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
|
||||
});
|
||||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||||
}
|
||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||
@@ -3678,13 +3690,15 @@
|
||||
let issueCaptureBlockers = [];
|
||||
|
||||
function saveIssueCaptureDraft() {
|
||||
if (issueCapture) issueCapture.saveDraft(
|
||||
if (issueCapture) issueCapture.saveDraft(issueTemplatePicker.fields(
|
||||
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers)
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
function currentIssueCaptureDraft() {
|
||||
return issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true);
|
||||
return issueTemplatePicker.fields(
|
||||
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true)
|
||||
);
|
||||
}
|
||||
|
||||
let issueCaptureRepositories = [];
|
||||
@@ -3856,6 +3870,8 @@
|
||||
status.textContent = 'Loading labels…';
|
||||
try {
|
||||
const labels = await issueCapture.loadLabels(repository);
|
||||
if (qs('#create-issue-repository').value !== repository) return;
|
||||
issueTemplatePicker.setLabels(labels);
|
||||
const selected = new Set(selectedIds.map(Number));
|
||||
list.innerHTML = labels.map(label =>
|
||||
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
||||
@@ -3868,6 +3884,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadIssueTemplates(repository, selectedId = '') {
|
||||
return issueTemplatePicker.load(repository, selectedId);
|
||||
}
|
||||
|
||||
async function loadIssueMilestones(repository, selectedId = null) {
|
||||
const select = qs('#create-issue-milestone');
|
||||
const status = qs('#create-issue-milestone-status');
|
||||
@@ -3918,6 +3938,7 @@
|
||||
return;
|
||||
}
|
||||
const captureDraft = issueCapture.loadDraft();
|
||||
issueTemplatePicker.reset(captureDraft);
|
||||
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
|
||||
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
||||
@@ -3943,7 +3964,8 @@
|
||||
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
||||
qs('#create-issue-blocker-search').value = '';
|
||||
qs('#create-issue-blocker-results').hidden = true;
|
||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds).then(() =>
|
||||
loadIssueTemplates(qs('#create-issue-repository').value, captureDraft.templateId));
|
||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
scheduleIssueDuplicateCheck();
|
||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||
@@ -5131,8 +5153,9 @@
|
||||
})
|
||||
);
|
||||
qs('#create-issue-repository').addEventListener('change', event => {
|
||||
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
|
||||
issueOwnerPicker.reset(event.target.value);
|
||||
loadIssueLabels(event.target.value);
|
||||
loadIssueLabels(event.target.value).then(() => loadIssueTemplates(event.target.value));
|
||||
loadIssueMilestones(event.target.value);
|
||||
qs('#create-issue-repository-search').value = event.target.value;
|
||||
saveIssueCaptureDraft();
|
||||
|
||||
@@ -797,6 +797,12 @@
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label id="create-issue-template-field" for="create-issue-template" hidden>Issue type
|
||||
<select id="create-issue-template" aria-describedby="create-issue-template-status">
|
||||
<option value="">Blank issue</option>
|
||||
</select>
|
||||
<span id="create-issue-template-status" class="small" aria-live="polite">Blank issue selected.</span>
|
||||
</label>
|
||||
<label class="create-issue-owner" for="create-issue-assignee">Owner
|
||||
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
|
||||
<option value="">Me</option>
|
||||
@@ -876,6 +882,7 @@
|
||||
<dl class="issue-filing-review-summary">
|
||||
<div><dt>Repository</dt><dd id="issue-filing-review-repository"></dd></div>
|
||||
<div><dt>Action</dt><dd id="issue-filing-review-intent"></dd></div>
|
||||
<div><dt>Issue type</dt><dd id="issue-filing-review-template"></dd></div>
|
||||
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
|
||||
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
|
||||
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
const draft = payload.draft;
|
||||
options.repository.textContent = draft.repository || 'No repository';
|
||||
options.intent.textContent = INTENT_LABELS[payload.intent] || payload.intent;
|
||||
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
|
||||
options.title.textContent = draft.title;
|
||||
options.body.textContent = draft.body || 'No note provided.';
|
||||
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
|
||||
|
||||
Reference in new issue
Block a user