Files

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);
}