686 lines
22 KiB
JavaScript
686 lines
22 KiB
JavaScript
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 = '<div class="alert alert-danger">Task not found.</div>';
|
|
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 = `
|
|
<span class="task-status-badge" style="${statusStyle}">
|
|
${escapeHtml(task.status_state.name)}
|
|
</span>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
slot.innerHTML = `
|
|
<div class="task-status-control">
|
|
<button class="task-status-badge task-status-button" type="button" style="${statusStyle}" id="taskStatusButton">
|
|
<span>${escapeHtml(task.status_state.name)}</span>
|
|
<i class="fa-solid fa-chevron-down"></i>
|
|
</button>
|
|
<div class="task-status-menu" id="taskStatusMenu" hidden>
|
|
${(task.status_transitions ?? []).map((transition) => `
|
|
<button type="button" data-task-transition="${escapeHtml(transition.id)}">
|
|
<span>${escapeHtml(transition.action_name)}</span>
|
|
<small>${escapeHtml(transition.to_state?.name ?? '')}</small>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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) => `
|
|
<div class="task-custom-field-row">
|
|
<span>${escapeHtml(field.name)}</span>
|
|
<strong
|
|
class="task-custom-field-value ${field.raw_value ? '' : 'is-empty'} ${canEdit ? '' : 'is-readonly'}"
|
|
${canEdit ? 'tabindex="0"' : 'aria-disabled="true"'}
|
|
data-custom-field-id="${escapeHtml(field.id)}"
|
|
data-custom-field-type="${escapeHtml(field.type)}"
|
|
data-current-value="${escapeHtml(field.raw_value ?? '')}"
|
|
>${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}</strong>
|
|
</div>
|
|
`).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 = '<div class="alert alert-danger mb-0">Could not load comments.</div>';
|
|
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
|
|
? `
|
|
<button class="task-comment-action" type="button" data-comment-edit="${escapeHtml(comment.id)}">Edit</button>
|
|
<button class="task-comment-action text-danger" type="button" data-comment-delete="${escapeHtml(comment.id)}">Delete</button>
|
|
`
|
|
: '';
|
|
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 = `
|
|
<div class="task-comment-header">
|
|
${renderUser(user)}
|
|
<span class="task-comment-id">#${escapeHtml(comment.id)}</span>
|
|
</div>
|
|
<div class="task-comment-body">${escapeHtml(comment.comment)}</div>
|
|
<div class="task-comment-action-row">
|
|
<button class="task-comment-action" type="button" data-comment-reply="${escapeHtml(comment.id)}">Reply</button>
|
|
${manageActions}
|
|
</div>
|
|
<div class="task-comment-reply-slot"></div>
|
|
<div class="task-comment-edit-slot"></div>
|
|
`;
|
|
|
|
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 = `
|
|
<button class="btn btn-sm btn-primary" type="submit">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
|
|
<i class="fa-solid fa-xmark"></i>
|
|
</button>
|
|
`;
|
|
|
|
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 = `
|
|
<button class="btn btn-sm btn-primary" type="submit">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
|
|
<i class="fa-solid fa-xmark"></i>
|
|
</button>
|
|
`;
|
|
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);
|
|
}
|
|
|