diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 18008b1..74b0fcc 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -446,6 +446,7 @@ function createBackgroundIssueSync({ title: item.title, body: item.body, label_ids: item.labelIds, + ...(item.assignee ? { assignee: item.assignee } : {}), ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), }), diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index ee63d05..19c7d19 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -19,6 +19,90 @@ function normalizeSharedContent(value = {}) { return { title, body }; } +function createIssueOwnerPicker(issueCapture, documentRef, onChange) { + const select = documentRef.querySelector('#create-issue-assignee'); + const status = documentRef.querySelector('#create-issue-assignee-status'); + const getRepository = () => documentRef.querySelector('#create-issue-repository').value; + const loadOwners = repository => issueCapture.loadOwners(repository); + let ownerRequest = 0; + function reset(repository, selected = {}) { + select.replaceChildren(); + const me = documentRef.createElement('option'); + me.value = ''; + me.textContent = 'Me'; + select.appendChild(me); + if (selected.assignee) { + const option = documentRef.createElement('option'); + option.value = selected.assignee; + option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')'; + option.dataset.name = selected.assigneeName || selected.assignee; + select.appendChild(option); + select.value = selected.assignee; + } + select.dataset.repository = ''; + status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.'; + } + + async function load(repository) { + if (!repository || select.dataset.repository === repository) return; + const request = ++ownerRequest; + const selected = select.value; + const selectedName = select.selectedOptions?.[0]?.dataset.name || ''; + status.textContent = 'Loading eligible teammates…'; + try { + const owners = await loadOwners(repository); + if (request !== ownerRequest || getRepository() !== repository) return; + const selectedStillEligible = owners.some(owner => owner?.login === selected); + reset(repository, selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}); + owners.forEach(owner => { + if (!owner?.login || owner.login === selected) return; + const option = documentRef.createElement('option'); + option.value = owner.login; + option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')'; + option.dataset.name = owner.name || owner.login; + select.appendChild(option); + }); + select.dataset.repository = repository; + status.textContent = owners.length ? 'Choose yourself or an eligible teammate.' : 'No eligible teammates are available.'; + } catch (_error) { + if (request !== ownerRequest || getRepository() !== repository) return; + status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.'; + } + } + function updateActions(hasRepository, hasBlockers, canStart) { + const submit = documentRef.querySelector('#submit-new-issue'); + const start = documentRef.querySelector('#create-and-start-issue'); + const hasTeammateOwner = Boolean(select.value); + submit.disabled = !hasRepository; + submit.textContent = hasTeammateOwner ? 'Create & assign' : 'Create & assign to me'; + start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || !canStart; + start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' : + (hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''); + } + function fields() { + return { + assignee: select.value, + assigneeName: select.selectedOptions?.[0]?.dataset.name || '', + }; + } + function draft(labelIds, blockers, trim = false) { + const value = id => documentRef.querySelector(id).value; + const clean = input => trim ? input.trim() : input; + return { + repository:value('#create-issue-repository'), + title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')), + labelIds, milestoneId:Number(value('#create-issue-milestone')) || null, + dueDate:value('#create-issue-due-date'), ...fields(), blockers, + }; + } + select.addEventListener('focus', () => { + const repository = getRepository(); + if (repository) load(repository); + }); + select.addEventListener('change', onChange); + return { reset, load, updateActions, fields, draft }; +} + function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) { const storageKey = 'stackchain.issue-capture.v1'; const sharedStorageKey = 'stackchain.issue-share.v1'; @@ -51,6 +135,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] }); const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null; const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : ''; + const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : ''; function loadStored() { try { @@ -63,6 +148,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp labelIds: safeLabelIds(parsed.labelIds), operationId: String(parsed.operationId || '').slice(0, 128), }; + const assignee = safeAssignee(parsed.assignee); + if (assignee) { + draft.assignee = assignee; + draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255); + } const milestoneId = safeMilestoneId(parsed.milestoneId); const dueDate = safeDueDate(parsed.dueDate); if (milestoneId !== null) draft.milestoneId = milestoneId; @@ -88,13 +178,18 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp body: String(draft?.body || ''), labelIds: safeLabelIds(draft?.labelIds), }; + const assignee = safeAssignee(draft?.assignee); + if (assignee) { + safe.assignee = assignee; + safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255); + } const milestoneId = safeMilestoneId(draft?.milestoneId); const dueDate = safeDueDate(draft?.dueDate); if (milestoneId !== null) safe.milestoneId = milestoneId; if (dueDate) safe.dueDate = dueDate; const blockers = safeBlockers(draft?.blockers); if (blockers.length) safe.blockers = blockers; - const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate'] + const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName'] .every(key => (previous[key] || '') === (safe[key] || '')) && JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) && JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []); @@ -203,6 +298,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp ); } + function loadOwners(repository) { + const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/'); + return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners => + Array.isArray(owners) ? owners : [] + ); + } + function loadRepositoryPage(page) { const safePage = Math.max(1, Math.floor(Number(page) || 1)); if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage); @@ -312,6 +414,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp }, body: JSON.stringify({ title: saved.title, body: saved.body, label_ids: saved.labelIds, + ...(saved.assignee ? {assignee: saved.assignee} : {}), ...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}), ...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}), }), @@ -323,7 +426,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } return { - saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, + saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadRepositoryPage, searchRepositories, searchBlockers, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, @@ -332,5 +435,6 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } createIssueCapture.normalizeSharedContent = normalizeSharedContent; +createIssueCapture.createOwnerPicker = createIssueOwnerPicker; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 820188c..c68c382 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -682,6 +682,8 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-blocker-selected button { min-height:44px; } .create-issue-form label { display:grid; gap:6px; } .create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } +.create-issue-owner { min-width:0; } +.create-issue-owner select { min-height:44px; width:100%; } .create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; } .create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; } .create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ea84ae6..bf190f2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -519,6 +519,7 @@ loadMilestones: item => issueController.loadMilestones(item), }); let issueCapture = null; + let issueOwnerPicker = null; let updateFollowUp = null; const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null; const unfiledCaptures = createUnfiledCaptures({ @@ -642,6 +643,8 @@ }, () => { if (!issueCapture) { issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document, + () => { saveIssueCaptureDraft(); updateIssueCreateActions(); }); updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin }); } if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) { @@ -3675,28 +3678,13 @@ let issueCaptureBlockers = []; function saveIssueCaptureDraft() { - if (!issueCapture) return; - issueCapture.saveDraft({ - repository: qs('#create-issue-repository').value, - title: qs('#create-issue-title').value, - body: qs('#create-issue-body').value, - labelIds: selectedIssueLabelIds(), - milestoneId: Number(qs('#create-issue-milestone').value) || null, - dueDate: qs('#create-issue-due-date').value, - blockers: issueCaptureBlockers, - }); + if (issueCapture) issueCapture.saveDraft( + issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers) + ); } function currentIssueCaptureDraft() { - return { - repository: qs('#create-issue-repository').value, - title: qs('#create-issue-title').value.trim(), - body: qs('#create-issue-body').value.trim(), - labelIds: selectedIssueLabelIds(), - milestoneId: Number(qs('#create-issue-milestone').value) || null, - dueDate: qs('#create-issue-due-date').value, - blockers: issueCaptureBlockers, - }; + return issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true); } let issueCaptureRepositories = []; @@ -3723,11 +3711,8 @@ } function updateIssueCreateActions() { - const hasRepository = Boolean(qs('#create-issue-repository').value); - const hasBlockers = issueCaptureBlockers.length > 0; - qs('#submit-new-issue').disabled = !hasRepository; - qs('#create-and-start-issue').disabled = !hasRepository || hasBlockers || !createAndStart.available(); - qs('#create-and-start-issue').title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' : ''; + issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value), + issueCaptureBlockers.length > 0, createAndStart.available()); } function renderIssueCaptureBlockers(blockers) { @@ -3801,6 +3786,7 @@ qs('#create-issue-repository-search').value = repository; qs('#create-issue-repository-results').hidden = true; qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.'; + issueOwnerPicker.reset(repository); loadIssueLabels(repository); loadIssueMilestones(repository); saveIssueCaptureDraft(); @@ -3953,6 +3939,7 @@ setIssueFilingMode(Boolean(captureDraft.repository)); qs('#create-issue-capture-status').textContent = ''; qs('#create-issue-due-date').value = captureDraft.dueDate || ''; + issueOwnerPicker.reset(captureDraft.repository, captureDraft); renderIssueCaptureBlockers(captureDraft.blockers || []); qs('#create-issue-blocker-search').value = ''; qs('#create-issue-blocker-results').hidden = true; @@ -5144,6 +5131,7 @@ }) ); qs('#create-issue-repository').addEventListener('change', event => { + issueOwnerPicker.reset(event.target.value); loadIssueLabels(event.target.value); loadIssueMilestones(event.target.value); qs('#create-issue-repository-search').value = event.target.value; diff --git a/frontend/index.html b/frontend/index.html index 41e9eee..018c2c3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -797,6 +797,12 @@
+