async function loadTask(taskId, pushHistory = true) { const view = showView('taskView'); if (!view) return; if (pushHistory) { const url = new URL(window.location.href); url.searchParams.set('page', 'home'); url.searchParams.set('task', taskId); url.searchParams.delete('project'); url.searchParams.delete('tasks'); url.searchParams.delete('version'); url.searchParams.delete('profile'); url.searchParams.delete('admin'); window.history.pushState({}, '', url); } const result = await apiGet('/api/task.php', { api: 'TaskInfo', task_id: taskId, _: Date.now() }); if (!result.success) { view.innerHTML = '
Task not found.
'; return; } await renderTask(result.task); await loadTaskComments(result.task.id); await ensureProjectOpen(result.task.project); } async function renderTask(task) { currentTask = task; const [types, priorities, versions, reporter, assignee] = await Promise.all([ getTaskTypes(), getTaskPriorities(), getProjectVersions(task.project), getUserInfo(task.reporter), task.assignee ? getUserInfo(task.assignee) : null ]); const type = types.find((item) => String(item.id) === String(task.type)) ?? null; const priority = priorities.find((item) => String(item.id) === String(task.priority)) ?? null; const fixVersion = versions.find((item) => String(item.id) === String(task.fix_version)) ?? null; setEditableText('taskKey', task.id); setEditableText('taskTitle', task.title, task.title); setEditableText('taskDescription', task.description, task.description, 'No description provided.'); setText('taskProject', task.project); document.getElementById('taskReporter').innerHTML = renderUser(reporter); setEditableHtml('taskAssignee', renderUser(assignee), task.assignee ?? '', 'Unassigned'); setEditableText('taskFixVersion', fixVersion ? fixVersion.name : null, task.fix_version ?? '', 'No fix version'); setText('taskCreated', task.created_date); setText('taskUpdated', task.last_changed); setEditableHtml('taskType', renderMetaBadge(type), task.type); setEditableHtml('taskPriority', renderMetaBadge(priority), task.priority); renderTaskStatus(task); renderTaskCustomFields(task.custom_fields ?? []); } function renderTaskStatus(task) { const slot = document.getElementById('taskStatusSlot'); if (!slot) return; slot.innerHTML = ''; if (!task.status_state) return; const isCurrentAssignee = String(task.assignee ?? '') === String(getCurrentUserId()); const canTransition = (canEditTasks() || appFlag('isAdmin') || isCurrentAssignee) && (task.status_transitions ?? []).length > 0; const statusStyle = `--task-status-color: ${escapeHtml(task.status_state.color ?? '#6c757d')}`; if (!canTransition) { slot.innerHTML = ` ${escapeHtml(task.status_state.name)} `; return; } slot.innerHTML = `
`; } function renderTaskCustomFields(fields) { const panel = document.getElementById('taskCustomFieldsPanel'); const list = document.getElementById('taskCustomFieldList'); if (!panel || !list) return; const canEdit = canEditTasks(); panel.hidden = fields.length === 0; list.innerHTML = fields.map((field) => `
${escapeHtml(field.name)} ${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}
`).join(''); } async function loadTaskComments(taskId) { const list = document.getElementById('taskCommentList'); const empty = document.getElementById('taskCommentEmpty'); if (!list || !empty) return; list.innerHTML = ''; empty.hidden = true; currentTaskComments = []; setText('taskCommentStatus', ''); const result = await apiGet('/api/task.php', { api: 'ListComments', task_id: taskId, _: Date.now() }); if (!result.success) { list.innerHTML = '
Could not load comments.
'; return; } currentTaskComments = result.comments ?? []; renderTaskComments(); } function renderTaskComments() { const list = document.getElementById('taskCommentList'); const empty = document.getElementById('taskCommentEmpty'); if (!list || !empty) return; list.innerHTML = ''; empty.hidden = currentTaskComments.length > 0; const commentsByParent = groupCommentsByParent(currentTaskComments); const renderComment = (comment, depth = 0) => { const user = { id: comment.commenter, name: comment.commenter_name, email: comment.commenter_email, picture: comment.commenter_picture }; const article = document.createElement('article'); const canManageComment = String(comment.commenter) === String(getCurrentUserId()); const manageActions = canManageComment ? ` ` : ''; article.className = `task-comment${comment.response_to ? ' is-reply' : ''}`; article.style.setProperty('--comment-depth', String(Math.min(depth, 8))); article.dataset.commentId = comment.id; article.dataset.commentText = comment.comment; article.innerHTML = `
${renderUser(user)} #${escapeHtml(comment.id)}
${escapeHtml(comment.comment)}
${manageActions}
`; list.appendChild(article); const children = commentsByParent.get(Number(comment.id)) ?? []; children.forEach((child) => renderComment(child, depth + 1)); }; const rootComments = commentsByParent.get(null) ?? []; rootComments.forEach((comment) => renderComment(comment)); } function groupCommentsByParent(comments) { const knownIds = new Set(comments.map((comment) => Number(comment.id))); const groups = new Map([[null, []]]); [...comments] .sort((first, second) => Number(first.id) - Number(second.id)) .forEach((comment) => { const parentId = comment.response_to && knownIds.has(Number(comment.response_to)) ? Number(comment.response_to) : null; if (!groups.has(parentId)) { groups.set(parentId, []); } groups.get(parentId).push(comment); }); return groups; } async function submitTaskComment(comment, responseTo = null) { if (!currentTask) return; const result = await apiPost('/api/task.php', { api: 'CreateComment', task_id: currentTask.id }, { comment, response_to: responseTo }); if (!result.success) { throw new Error(result.error || 'Could not save comment.'); } await loadTaskComments(currentTask.id); } async function updateTaskComment(commentId, comment) { if (!currentTask) return; const result = await apiPost('/api/task.php', { api: 'EditComment', task_id: currentTask.id, comment_id: commentId }, { comment }); if (!result.success) { throw new Error(result.error || 'Could not update comment.'); } await loadTaskComments(currentTask.id); } async function deleteTaskComment(commentId) { if (!currentTask) return; const result = await apiPost('/api/task.php', { api: 'DeleteComment', task_id: currentTask.id, comment_id: commentId }); if (!result.success) { throw new Error(result.error || 'Could not delete comment.'); } await loadTaskComments(currentTask.id); } async function openTaskCreatePopup() { const form = document.getElementById('createTaskForm'); if (!form) return; await populateTaskOptionSelects(); await populateTaskProjectSelect(); await populateTaskRelationSelects(); resetTaskPopup(); openPopup('createTask'); } async function openTaskEditPopup() { if (!currentTask) return; const form = document.getElementById('createTaskForm'); if (!form) return; await populateTaskOptionSelects(); await populateTaskProjectSelect(currentTask.project); await populateTaskRelationSelects(currentTask.project, { assignee: currentTask.assignee ?? '', fix_version: currentTask.fix_version ?? '' }); form.reset(); form.dataset.mode = 'edit'; document.getElementById('taskPopupTitle').textContent = `Edit ${currentTask.id}`; document.getElementById('taskPopupSubmit').textContent = 'Update Task'; document.getElementById('taskFormTaskId').value = currentTask.id; document.getElementById('taskFormProjectId').value = currentTask.project; document.getElementById('taskFormProjectId').disabled = true; document.getElementById('taskFormTitle').value = currentTask.title ?? ''; document.getElementById('taskFormDescription').value = currentTask.description ?? ''; document.getElementById('createTaskType').value = currentTask.type ?? ''; document.getElementById('createTaskPriority').value = currentTask.priority ?? ''; document.getElementById('taskFormFixVersion').value = currentTask.fix_version ?? ''; document.getElementById('taskFormAssignee').value = currentTask.assignee ?? ''; openPopup('createTask'); } function initTaskInlineEditing() { if (!canEditTasks()) return; document.querySelectorAll('.task-editable').forEach((element) => { if (element.dataset.inlineReady === 'true') return; element.dataset.inlineReady = 'true'; element.title = 'Click to edit'; element.addEventListener('click', () => openTaskInlineEditor(element)); element.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskInlineEditor(element); } }); }); } async function openTaskInlineEditor(element) { if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return; const field = element.dataset.taskField; const currentValue = element.dataset.currentValue ?? ''; const originalHtml = element.innerHTML; const originalClasses = Array.from(element.classList); element.classList.remove('is-empty'); element.innerHTML = ''; const form = document.createElement('form'); form.className = 'task-inline-form'; const input = await createTaskInlineInput(field, currentValue); form.appendChild(input); const actions = document.createElement('div'); actions.className = 'task-inline-actions'; actions.innerHTML = ` `; if (input.tagName !== 'SELECT') { form.appendChild(actions); } element.appendChild(form); input.focus(); if (input.select && input.tagName !== 'SELECT') { input.select(); } let editorClosed = false; const restore = () => { if (editorClosed) return; editorClosed = true; element.innerHTML = originalHtml; element.className = originalClasses.join(' '); }; let saving = false; const save = async () => { if (saving) return; const nextValue = input.value; if (String(nextValue) === String(currentValue)) { restore(); return; } saving = true; element.classList.add('is-saving'); const result = await apiPost('/api/task.php', { api: 'Edit', task_id: currentTask.id }, { [field]: nextValue }); if (!result.success) { alert(result.error || 'Could not update task.'); saving = false; restore(); return; } editorClosed = true; element.classList.remove('is-saving'); currentTask = { ...currentTask, [field]: nextValue === '' ? null : nextValue }; if (field === 'type') { await loadTask(currentTask.id, false); return; } await renderTask(currentTask); }; form.addEventListener('submit', async (event) => { event.preventDefault(); await save(); }); form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); restore(); }); input.addEventListener('keydown', async (event) => { if (event.key === 'Escape') { event.preventDefault(); restore(); } if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); await save(); } }); if (input.tagName === 'SELECT') { input.addEventListener('change', save); input.addEventListener('blur', () => { window.setTimeout(() => { if (editorClosed) return; if (element.classList.contains('is-saving')) return; if (element.contains(document.activeElement)) return; restore(); }, 120); }); } } async function createTaskInlineInput(field, currentValue) { if (field === 'description') { const textarea = document.createElement('textarea'); textarea.className = 'form-control task-inline-input'; textarea.rows = 5; textarea.value = currentValue; return textarea; } if (field === 'type') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getTaskTypes(), { includeEmpty: false, selectedValue: currentValue }); return select; } if (field === 'priority') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getTaskPriorities(), { includeEmpty: false, selectedValue: currentValue }); return select; } if (field === 'fix_version') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getProjectVersions(currentTask.project), { placeholder: 'No fix version', selectedValue: currentValue }); return select; } if (field === 'assignee') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getUsers(), { placeholder: 'Unassigned', selectedValue: currentValue }); return select; } const input = document.createElement('input'); input.className = 'form-control task-inline-input'; input.type = 'text'; input.value = currentValue; input.required = field === 'title'; return input; } function createCustomFieldInput(type, currentValue) { if (type === 'boolean') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; [ { id: '', name: 'No value' }, { id: 'true', name: 'True' }, { id: 'false', name: 'False' } ].forEach((item) => { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; option.selected = String(item.id) === String(currentValue); select.appendChild(option); }); return select; } if (type === 'text' || type === 'json') { const textarea = document.createElement('textarea'); textarea.className = 'form-control task-inline-input'; textarea.rows = type === 'json' ? 4 : 3; textarea.value = currentValue; return textarea; } const input = document.createElement('input'); input.className = 'form-control task-inline-input'; input.type = type === 'date' ? 'date' : 'text'; input.inputMode = ['int', 'float'].includes(type) ? 'decimal' : ''; input.value = currentValue; return input; } async function openCustomFieldInlineEditor(element) { if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return; const fieldId = element.dataset.customFieldId; const fieldType = element.dataset.customFieldType ?? 'string'; const currentValue = element.dataset.currentValue ?? ''; const originalHtml = element.innerHTML; const originalClasses = Array.from(element.classList); element.classList.remove('is-empty'); element.innerHTML = ''; const form = document.createElement('form'); form.className = 'task-inline-form'; const input = createCustomFieldInput(fieldType, currentValue); form.appendChild(input); const actions = document.createElement('div'); actions.className = 'task-inline-actions'; actions.innerHTML = ` `; form.appendChild(actions); element.appendChild(form); input.focus(); if (input.select && input.tagName !== 'SELECT') { input.select(); } let closed = false; const restore = () => { if (closed) return; closed = true; element.innerHTML = originalHtml; element.className = originalClasses.join(' '); }; const save = async () => { const nextValue = input.value; if (String(nextValue) === String(currentValue)) { restore(); return; } element.classList.add('is-saving'); const result = await apiPost('/api/task.php', { api: 'SetCustomFieldValue', task_id: currentTask.id }, { field_id: fieldId, value: nextValue }); if (!result.success) { alert(result.error || 'Could not update custom field.'); restore(); return; } closed = true; await loadTask(currentTask.id, false); }; form.addEventListener('submit', async (event) => { event.preventDefault(); await save(); }); form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); restore(); }); input.addEventListener('keydown', async (event) => { if (event.key === 'Escape') { event.preventDefault(); restore(); } if (event.key === 'Enter' && input.tagName !== 'TEXTAREA') { event.preventDefault(); await save(); } if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); await save(); } }); } async function transitionCurrentTask(transitionId) { if (!currentTask || !transitionId) return; const result = await apiPost('/api/task.php', { api: 'TransitionStatus', task_id: currentTask.id }, { transition_id: transitionId }); if (!result.success) { alert(result.error || 'Could not transition task.'); return; } await loadTask(currentTask.id, false); }