3533 lines
114 KiB
JavaScript
3533 lines
114 KiB
JavaScript
async function apiGet(path, params = {}) {
|
|
const url = new URL(path, window.location.origin);
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
const response = await fetch(url.toString(), {
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Cache-Control': 'no-cache'
|
|
}
|
|
});
|
|
return response.json();
|
|
}
|
|
|
|
async function apiPost(path, params = {}, body = {}) {
|
|
const url = new URL(path, window.location.origin);
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-cache'
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
console.error('API did not return JSON:', text);
|
|
|
|
return {
|
|
success: false,
|
|
error: 'API did not return JSON. Check console for PHP error.'
|
|
};
|
|
}
|
|
}
|
|
|
|
async function apiPostForm(path, params = {}, formData) {
|
|
const url = new URL(path, window.location.origin);
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Cache-Control': 'no-cache'
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
console.error('API did not return JSON:', text);
|
|
|
|
return {
|
|
success: false,
|
|
error: 'API did not return JSON. Check console for PHP error.'
|
|
};
|
|
}
|
|
}
|
|
|
|
let currentTask = null;
|
|
let currentVersion = null;
|
|
let currentProject = null;
|
|
let currentProfileUser = null;
|
|
let currentVersionTasks = [];
|
|
let currentProjectTasks = [];
|
|
let currentProjectTaskProject = null;
|
|
let currentTaskComments = [];
|
|
let workflowData = null;
|
|
let workflowGraph = null;
|
|
let selectedWorkflowTaskType = null;
|
|
let adminOptionsData = null;
|
|
let selectedAdminTaskType = null;
|
|
let selectedAdminUser = null;
|
|
let projectTreePromise = null;
|
|
|
|
const versionTaskSort = {
|
|
field: 'id',
|
|
direction: 'asc'
|
|
};
|
|
|
|
const projectTaskSort = {
|
|
field: 'id',
|
|
direction: 'asc'
|
|
};
|
|
|
|
const taskTablePageSize = 100;
|
|
let versionTaskPagination = null;
|
|
let projectTaskPagination = null;
|
|
|
|
const themeStyles = {
|
|
white: 'app/css/white_mode.css',
|
|
dark: 'app/css/dark_mode.css',
|
|
purple: 'app/css/purple_mode.css',
|
|
green: 'app/css/green_mode.css',
|
|
beige: 'app/css/beige_mode.css'
|
|
};
|
|
|
|
const customFieldTypes = [
|
|
{ id: 'string', name: 'String' },
|
|
{ id: 'int', name: 'Int' },
|
|
{ id: 'float', name: 'Float' },
|
|
{ id: 'date', name: 'Date' },
|
|
{ id: 'boolean', name: 'Boolean' },
|
|
{ id: 'text', name: 'Text' },
|
|
{ id: 'json', name: 'JSON' }
|
|
];
|
|
|
|
const taskLookupCache = {
|
|
types: null,
|
|
priorities: null,
|
|
users: null,
|
|
versionsByProject: new Map(),
|
|
projectsById: new Map(),
|
|
projectBlocksById: new Map()
|
|
};
|
|
|
|
function appFlag(name) {
|
|
return document.querySelector('.kiln-app')?.dataset[name] === '1';
|
|
}
|
|
|
|
function canEditTasks() {
|
|
return appFlag('canEditTasks');
|
|
}
|
|
|
|
function canEditVersions() {
|
|
return appFlag('canEditVersions');
|
|
}
|
|
|
|
function canEditProjects() {
|
|
return appFlag('canEditProjects');
|
|
}
|
|
|
|
function applyPermissionVisibility() {
|
|
const visibility = [
|
|
['taskEditButton', canEditTasks()],
|
|
['versionEditButton', canEditVersions()],
|
|
['projectEditButton', canEditProjects()]
|
|
];
|
|
|
|
visibility.forEach(([id, allowed]) => {
|
|
const element = document.getElementById(id);
|
|
if (element) element.hidden = !allowed;
|
|
});
|
|
|
|
[
|
|
['.task-editable', canEditTasks()],
|
|
['.version-editable', canEditVersions()],
|
|
['.project-editable', canEditProjects()]
|
|
].forEach(([selector, allowed]) => {
|
|
document.querySelectorAll(selector).forEach((element) => {
|
|
element.classList.toggle('is-readonly', !allowed);
|
|
|
|
if (allowed) {
|
|
element.setAttribute('tabindex', '0');
|
|
element.removeAttribute('aria-disabled');
|
|
return;
|
|
}
|
|
|
|
element.removeAttribute('title');
|
|
element.removeAttribute('tabindex');
|
|
element.setAttribute('aria-disabled', 'true');
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadProjectTree() {
|
|
const tree = document.getElementById('projectTree');
|
|
|
|
if (!tree) return;
|
|
|
|
tree.innerHTML = '<div class="kiln-tree-muted">Loading projects...</div>';
|
|
|
|
const result = await apiGet('/api/project.php', {
|
|
api: 'ListProjects'
|
|
});
|
|
|
|
if (!result.success) {
|
|
tree.innerHTML = '<div class="text-danger">Could not load projects.</div>';
|
|
return;
|
|
}
|
|
|
|
if (!result.projects.length) {
|
|
tree.innerHTML = '<div class="kiln-tree-muted">No projects yet.</div>';
|
|
return;
|
|
}
|
|
|
|
tree.innerHTML = '';
|
|
taskLookupCache.projectBlocksById.clear();
|
|
|
|
for (const projectId of result.projects) {
|
|
const project = await getProjectInfo(projectId);
|
|
const projectBlock = document.createElement('div');
|
|
projectBlock.dataset.projectId = projectId;
|
|
|
|
const projectButton = document.createElement('button');
|
|
projectButton.className = 'kiln-tree-project';
|
|
projectButton.innerHTML = `
|
|
<i class="fa-solid fa-caret-right me-1" data-project-caret></i>
|
|
<span>${escapeHtml(project?.name ?? projectId)}</span>
|
|
`;
|
|
projectButton.addEventListener('click', (event) => {
|
|
if (event.target.closest('[data-project-caret]')) {
|
|
toggleProject(projectId, projectBlock);
|
|
return;
|
|
}
|
|
|
|
loadProject(projectId);
|
|
|
|
if (!projectBlock.querySelector('.kiln-tree-group')) {
|
|
toggleProject(projectId, projectBlock, true);
|
|
}
|
|
});
|
|
|
|
projectBlock.appendChild(projectButton);
|
|
tree.appendChild(projectBlock);
|
|
taskLookupCache.projectBlocksById.set(projectId, projectBlock);
|
|
}
|
|
|
|
if (currentTask?.project) {
|
|
const projectBlock = taskLookupCache.projectBlocksById.get(currentTask.project);
|
|
|
|
if (projectBlock && !projectBlock.querySelector('.kiln-tree-group')) {
|
|
await toggleProject(currentTask.project, projectBlock, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getProjectInfo(projectId) {
|
|
if (taskLookupCache.projectsById.has(projectId)) {
|
|
return taskLookupCache.projectsById.get(projectId);
|
|
}
|
|
|
|
const result = await apiGet('/api/project.php', {
|
|
api: 'ProjectInfo',
|
|
project_id: projectId
|
|
});
|
|
|
|
const project = result.success ? result.project : null;
|
|
taskLookupCache.projectsById.set(projectId, project);
|
|
return project;
|
|
}
|
|
|
|
async function ensureProjectOpen(projectId) {
|
|
await projectTreePromise;
|
|
|
|
const projectBlock = taskLookupCache.projectBlocksById.get(projectId);
|
|
|
|
if (!projectBlock || projectBlock.querySelector('.kiln-tree-group')) {
|
|
return;
|
|
}
|
|
|
|
await toggleProject(projectId, projectBlock, true);
|
|
}
|
|
|
|
async function toggleProject(projectId, projectBlock, forceOpen = false) {
|
|
let group = projectBlock.querySelector('.kiln-tree-group');
|
|
|
|
if (group) {
|
|
if (forceOpen) return;
|
|
|
|
group.remove();
|
|
setProjectCaret(projectBlock, false);
|
|
return;
|
|
}
|
|
|
|
setProjectCaret(projectBlock, true);
|
|
|
|
group = document.createElement('div');
|
|
group.className = 'kiln-tree-group';
|
|
group.innerHTML = '<div class="kiln-tree-muted">Loading...</div>';
|
|
projectBlock.appendChild(group);
|
|
|
|
const versions = await apiGet('/api/version.php', {
|
|
api: 'ListVersions',
|
|
project_id: projectId
|
|
});
|
|
|
|
group.innerHTML = '';
|
|
|
|
const versionGroup = document.createElement('div');
|
|
versionGroup.className = 'kiln-tree-version-group';
|
|
group.appendChild(versionGroup);
|
|
|
|
const versionLabel = document.createElement('div');
|
|
versionLabel.className = 'kiln-tree-label';
|
|
versionLabel.textContent = 'Versions';
|
|
versionGroup.appendChild(versionLabel);
|
|
|
|
if (document.querySelector('.kiln-app')?.dataset.canCreateVersions === '1') {
|
|
const createVersion = document.createElement('button');
|
|
createVersion.className = 'kiln-tree-version';
|
|
createVersion.innerHTML = '<i class="fa-solid fa-plus me-1"></i>Create new version';
|
|
createVersion.addEventListener('click', () => openVersionCreatePopup(projectId));
|
|
versionGroup.appendChild(createVersion);
|
|
}
|
|
|
|
if (versions.success && versions.versions.length) {
|
|
for (const versionId of versions.versions) {
|
|
const versionInfo = await apiGet('/api/version.php', {
|
|
api: 'VersionInfo',
|
|
version_id: versionId
|
|
});
|
|
|
|
const versionButton = document.createElement('button');
|
|
versionButton.className = 'kiln-tree-version';
|
|
|
|
if (versionInfo.success && versionInfo.version) {
|
|
versionButton.textContent = versionInfo.version.name;
|
|
} else {
|
|
versionButton.textContent = `Version ${versionId}`;
|
|
}
|
|
|
|
versionButton.addEventListener('click', () => loadVersion(versionId));
|
|
versionGroup.appendChild(versionButton);
|
|
}
|
|
}
|
|
|
|
const showTasksButton = document.createElement('button');
|
|
showTasksButton.className = 'kiln-tree-version';
|
|
showTasksButton.innerHTML = '<i class="fa-solid fa-list-check me-1"></i>Show all tasks';
|
|
showTasksButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
loadProjectTasks(projectId);
|
|
});
|
|
|
|
group.appendChild(showTasksButton);
|
|
}
|
|
|
|
function setProjectCaret(projectBlock, isOpen) {
|
|
const caret = projectBlock.querySelector('[data-project-caret]');
|
|
|
|
if (!caret) return;
|
|
|
|
caret.classList.toggle('fa-caret-right', !isOpen);
|
|
caret.classList.toggle('fa-caret-down', isOpen);
|
|
}
|
|
|
|
async function loadProject(projectId, pushHistory = true) {
|
|
const view = showView('projectView');
|
|
|
|
if (!view) return;
|
|
|
|
if (pushHistory) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('page', 'home');
|
|
url.searchParams.set('project', projectId);
|
|
url.searchParams.delete('tasks');
|
|
url.searchParams.delete('task');
|
|
url.searchParams.delete('version');
|
|
url.searchParams.delete('profile');
|
|
url.searchParams.delete('admin');
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
|
|
const result = await apiGet('/api/project.php', {
|
|
api: 'ProjectInfo',
|
|
project_id: projectId,
|
|
_: Date.now()
|
|
});
|
|
|
|
if (!result.success) {
|
|
view.innerHTML = '<div class="alert alert-danger">Project not found.</div>';
|
|
return;
|
|
}
|
|
|
|
await renderProject(result.project);
|
|
await ensureProjectOpen(result.project.id);
|
|
}
|
|
|
|
async function renderProject(project) {
|
|
currentProject = project;
|
|
taskLookupCache.projectsById.set(project.id, project);
|
|
|
|
const owner = project.owner ? await getUserInfo(project.owner) : null;
|
|
|
|
setVersionEditableText('projectKey', project.id);
|
|
setVersionEditableText('projectName', project.name, project.name);
|
|
setVersionEditableText('projectInlineName', project.name, project.name);
|
|
setText('projectId', project.id);
|
|
setEditableHtml('projectOwner', renderUser(owner), project.owner ?? '', 'No owner');
|
|
setText('projectCreated', project.created_date || '-');
|
|
}
|
|
|
|
async function loadProjectTasks(projectId, page = 1, pushHistory = true) {
|
|
const view = showView('taskListView');
|
|
|
|
if (!view) return;
|
|
|
|
if (pushHistory) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('page', 'home');
|
|
url.searchParams.set('project', projectId);
|
|
url.searchParams.set('tasks', '1');
|
|
url.searchParams.delete('task');
|
|
url.searchParams.delete('version');
|
|
url.searchParams.delete('profile');
|
|
url.searchParams.delete('admin');
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
|
|
currentProjectTaskProject = projectId;
|
|
setText('taskListProjectKey', projectId);
|
|
setText('taskListTitle', `${projectId} Tasks`);
|
|
|
|
const container = document.getElementById('taskListContainer');
|
|
const empty = document.getElementById('taskListEmpty');
|
|
|
|
container.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
|
|
empty.hidden = true;
|
|
currentProjectTasks = [];
|
|
projectTaskPagination = null;
|
|
|
|
const result = await apiGet('/api/task.php', {
|
|
api: 'ListTasksByProject',
|
|
project_id: projectId,
|
|
page,
|
|
per_page: taskTablePageSize,
|
|
sort: projectTaskSort.field,
|
|
direction: projectTaskSort.direction
|
|
});
|
|
|
|
if (!result.success) {
|
|
container.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
|
|
return;
|
|
}
|
|
|
|
if (!result.tasks.length) {
|
|
container.innerHTML = '';
|
|
empty.hidden = false;
|
|
renderTaskTablePagination('project');
|
|
return;
|
|
}
|
|
|
|
currentProjectTasks = await normalizeTaskTableRows(result.tasks);
|
|
projectTaskPagination = result.pagination ?? null;
|
|
renderTaskTable('project');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function showView(viewId) {
|
|
document.querySelectorAll('#viewer > section').forEach((section) => {
|
|
section.hidden = true;
|
|
});
|
|
|
|
const view = document.getElementById(viewId);
|
|
|
|
if (!view) {
|
|
console.error(`Missing view: ${viewId}`);
|
|
return null;
|
|
}
|
|
|
|
view.hidden = false;
|
|
return view;
|
|
}
|
|
|
|
function setText(id, value) {
|
|
const element = document.getElementById(id);
|
|
|
|
if (element) {
|
|
element.textContent = value ?? '';
|
|
}
|
|
}
|
|
|
|
function setEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
|
const element = document.getElementById(id);
|
|
|
|
if (!element) return;
|
|
|
|
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
|
element.textContent = hasValue ? displayValue : emptyText;
|
|
element.dataset.currentValue = rawValue ?? '';
|
|
element.classList.remove('is-saving');
|
|
element.classList.toggle('is-empty', !hasValue);
|
|
}
|
|
|
|
function setEditableHtml(id, displayHtml, rawValue, emptyText = '-') {
|
|
const element = document.getElementById(id);
|
|
|
|
if (!element) return;
|
|
|
|
const hasValue = displayHtml !== null && displayHtml !== undefined && displayHtml !== '-';
|
|
element.innerHTML = hasValue ? displayHtml : emptyText;
|
|
element.dataset.currentValue = rawValue ?? '';
|
|
element.classList.remove('is-saving');
|
|
element.classList.toggle('is-empty', !hasValue || rawValue === '' || rawValue === null);
|
|
}
|
|
|
|
function setVersionEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
|
const element = document.getElementById(id);
|
|
|
|
if (!element) return;
|
|
|
|
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
|
element.textContent = hasValue ? displayValue : emptyText;
|
|
element.dataset.currentValue = rawValue ?? '';
|
|
element.classList.remove('is-saving');
|
|
element.classList.toggle('is-empty', !hasValue);
|
|
}
|
|
|
|
function renderMetaBadge(item) {
|
|
if (!item) {
|
|
return '-';
|
|
}
|
|
|
|
const icon = item.icon
|
|
? `<img src="${item.icon}" alt="" class="meta-icon">`
|
|
: '';
|
|
|
|
return `
|
|
<span class="meta-badge">
|
|
${icon}
|
|
<span>${escapeHtml(item.name)}</span>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
async function getTaskTypes() {
|
|
if (taskLookupCache.types) {
|
|
return taskLookupCache.types;
|
|
}
|
|
|
|
const result = await apiGet('/api/task.php', {
|
|
api: 'ListTypes'
|
|
});
|
|
|
|
taskLookupCache.types = result.success ? result.types : [];
|
|
return taskLookupCache.types;
|
|
}
|
|
|
|
async function getTaskPriorities() {
|
|
if (taskLookupCache.priorities) {
|
|
return taskLookupCache.priorities;
|
|
}
|
|
|
|
const result = await apiGet('/api/task.php', {
|
|
api: 'ListPriorities'
|
|
});
|
|
|
|
taskLookupCache.priorities = result.success ? result.priorities : [];
|
|
return taskLookupCache.priorities;
|
|
}
|
|
|
|
async function getUsers() {
|
|
if (taskLookupCache.users) {
|
|
return taskLookupCache.users;
|
|
}
|
|
|
|
const result = await apiGet('/api/user.php', {
|
|
api: 'ListUsers'
|
|
});
|
|
|
|
taskLookupCache.users = result.success ? result.users : [];
|
|
return taskLookupCache.users;
|
|
}
|
|
|
|
async function getProjectVersions(projectId) {
|
|
if (taskLookupCache.versionsByProject.has(projectId)) {
|
|
return taskLookupCache.versionsByProject.get(projectId);
|
|
}
|
|
|
|
const result = await apiGet('/api/version.php', {
|
|
api: 'ListVersions',
|
|
project_id: projectId
|
|
});
|
|
|
|
if (!result.success) {
|
|
taskLookupCache.versionsByProject.set(projectId, []);
|
|
return [];
|
|
}
|
|
|
|
const versions = [];
|
|
|
|
for (const versionId of result.versions) {
|
|
const versionInfo = await apiGet('/api/version.php', {
|
|
api: 'VersionInfo',
|
|
version_id: versionId
|
|
});
|
|
|
|
if (versionInfo.success && versionInfo.version) {
|
|
versions.push(versionInfo.version);
|
|
}
|
|
}
|
|
|
|
taskLookupCache.versionsByProject.set(projectId, versions);
|
|
return versions;
|
|
}
|
|
|
|
async function populateTaskOptionSelects() {
|
|
const [types, priorities] = await Promise.all([
|
|
getTaskTypes(),
|
|
getTaskPriorities()
|
|
]);
|
|
|
|
populateSelect(document.getElementById('createTaskType'), types, {
|
|
placeholder: 'Select type'
|
|
});
|
|
|
|
populateSelect(document.getElementById('createTaskPriority'), priorities, {
|
|
placeholder: 'Select priority'
|
|
});
|
|
}
|
|
|
|
async function populateTaskProjectSelect(selectedProjectId = '') {
|
|
const result = await apiGet('/api/project.php', {
|
|
api: 'ListProjects'
|
|
});
|
|
|
|
const projects = [];
|
|
|
|
if (result.success) {
|
|
for (const projectId of result.projects) {
|
|
const project = await getProjectInfo(projectId);
|
|
|
|
projects.push({
|
|
id: projectId,
|
|
name: project?.name ?? projectId
|
|
});
|
|
}
|
|
}
|
|
|
|
populateSelect(document.getElementById('taskFormProjectId'), projects, {
|
|
placeholder: 'Select project',
|
|
selectedValue: selectedProjectId
|
|
});
|
|
}
|
|
|
|
async function populateTaskRelationSelects(projectId = '', selected = {}) {
|
|
const users = await getUsers();
|
|
|
|
populateSelect(document.getElementById('taskFormAssignee'), users, {
|
|
placeholder: 'Unassigned',
|
|
selectedValue: selected.assignee ?? ''
|
|
});
|
|
|
|
const versions = projectId ? await getProjectVersions(projectId.toUpperCase()) : [];
|
|
|
|
populateSelect(document.getElementById('taskFormFixVersion'), versions, {
|
|
placeholder: 'No fix version',
|
|
selectedValue: selected.fix_version ?? ''
|
|
});
|
|
}
|
|
|
|
function resetTaskPopup() {
|
|
const form = document.getElementById('createTaskForm');
|
|
|
|
if (!form) return;
|
|
|
|
form.reset();
|
|
form.dataset.mode = 'create';
|
|
document.getElementById('taskPopupTitle').textContent = 'Create Task';
|
|
document.getElementById('taskPopupSubmit').textContent = 'Create Task';
|
|
document.getElementById('taskFormTaskId').value = '';
|
|
document.getElementById('taskFormProjectId').disabled = false;
|
|
}
|
|
|
|
function resetVersionPopup() {
|
|
const form = document.getElementById('createVersionForm');
|
|
|
|
if (!form) return;
|
|
|
|
form.reset();
|
|
form.dataset.mode = 'create';
|
|
document.getElementById('versionPopupTitle').textContent = 'Create Version';
|
|
document.getElementById('versionPopupSubmit').textContent = 'Create Version';
|
|
document.getElementById('versionFormVersionId').value = '';
|
|
document.getElementById('versionFormProjectId').value = '';
|
|
}
|
|
|
|
function populateSelect(select, options, settings = {}) {
|
|
if (!select) return;
|
|
|
|
const placeholder = settings.placeholder ?? 'Select value';
|
|
const includeEmpty = settings.includeEmpty ?? true;
|
|
const selectedValue = settings.selectedValue ?? '';
|
|
|
|
select.innerHTML = '';
|
|
|
|
if (includeEmpty) {
|
|
const option = document.createElement('option');
|
|
option.value = '';
|
|
option.textContent = placeholder;
|
|
select.appendChild(option);
|
|
}
|
|
|
|
options.forEach((item) => {
|
|
const option = document.createElement('option');
|
|
option.value = item.id;
|
|
option.textContent = item.name ?? item.email ?? item.id;
|
|
option.selected = String(option.value) === String(selectedValue);
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
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 openVersionCreatePopup(projectId) {
|
|
resetVersionPopup();
|
|
document.getElementById('versionFormProjectId').value = projectId;
|
|
openPopup('createVersion');
|
|
}
|
|
|
|
async function populateProjectOwnerSelect(selectedOwner = '') {
|
|
populateSelect(document.getElementById('projectFormOwner'), await getUsers(), {
|
|
includeEmpty: false,
|
|
selectedValue: selectedOwner
|
|
});
|
|
}
|
|
|
|
function resetProjectPopup() {
|
|
const form = document.getElementById('createProjectForm');
|
|
|
|
if (!form) return;
|
|
|
|
form.reset();
|
|
form.dataset.mode = 'create';
|
|
document.getElementById('projectPopupTitle').textContent = 'Create Project';
|
|
document.getElementById('projectPopupSubmit').textContent = 'Create Project';
|
|
document.getElementById('projectFormId').disabled = false;
|
|
document.getElementById('projectFormId').value = '';
|
|
document.getElementById('projectFormName').value = '';
|
|
}
|
|
|
|
async function openProjectCreatePopup() {
|
|
resetProjectPopup();
|
|
await populateProjectOwnerSelect(getCurrentUserId());
|
|
openPopup('createProject');
|
|
}
|
|
|
|
async function openProjectEditPopup() {
|
|
if (!currentProject) return;
|
|
|
|
resetProjectPopup();
|
|
|
|
const form = document.getElementById('createProjectForm');
|
|
|
|
if (!form) return;
|
|
|
|
form.dataset.mode = 'edit';
|
|
document.getElementById('projectPopupTitle').textContent = `Edit ${currentProject.id}`;
|
|
document.getElementById('projectPopupSubmit').textContent = 'Update Project';
|
|
document.getElementById('projectFormId').value = currentProject.id;
|
|
document.getElementById('projectFormId').disabled = true;
|
|
document.getElementById('projectFormName').value = currentProject.name ?? '';
|
|
await populateProjectOwnerSelect(currentProject.owner ?? '');
|
|
|
|
openPopup('createProject');
|
|
}
|
|
|
|
function openVersionEditPopup() {
|
|
if (!currentVersion) return;
|
|
|
|
resetVersionPopup();
|
|
|
|
const form = document.getElementById('createVersionForm');
|
|
|
|
if (!form) return;
|
|
|
|
form.dataset.mode = 'edit';
|
|
document.getElementById('versionPopupTitle').textContent = `Edit ${currentVersion.name}`;
|
|
document.getElementById('versionPopupSubmit').textContent = 'Update Version';
|
|
document.getElementById('versionFormVersionId').value = currentVersion.id;
|
|
document.getElementById('versionFormProjectId').value = currentVersion.project;
|
|
document.getElementById('versionFormName').value = currentVersion.name ?? '';
|
|
document.getElementById('versionFormDescription').value = currentVersion.description ?? '';
|
|
document.getElementById('versionFormDueDate').value = currentVersion.due_date ?? '';
|
|
document.getElementById('versionFormReleasedDate').value = currentVersion.released_date ?? '';
|
|
|
|
openPopup('createVersion');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function loadVersion(versionId, pushHistory = true) {
|
|
const view = showView('versionView');
|
|
|
|
if (!view) return;
|
|
|
|
if (pushHistory) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('page', 'home');
|
|
url.searchParams.set('version', versionId);
|
|
url.searchParams.delete('task');
|
|
url.searchParams.delete('project');
|
|
url.searchParams.delete('tasks');
|
|
url.searchParams.delete('profile');
|
|
url.searchParams.delete('admin');
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
|
|
const result = await apiGet('/api/version.php', {
|
|
api: 'VersionInfo',
|
|
version_id: versionId
|
|
});
|
|
|
|
if (!result.success) {
|
|
setText('versionName', 'Version not found');
|
|
setText('versionDescription', '');
|
|
return;
|
|
}
|
|
|
|
await renderVersion(result.version);
|
|
await loadVersionTasks(versionId);
|
|
await ensureProjectOpen(result.version.project);
|
|
}
|
|
|
|
async function loadProfile(pushHistory = true) {
|
|
const view = showView('profileView');
|
|
|
|
if (!view) return;
|
|
|
|
if (pushHistory) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('page', 'home');
|
|
url.searchParams.set('profile', '1');
|
|
url.searchParams.delete('task');
|
|
url.searchParams.delete('project');
|
|
url.searchParams.delete('tasks');
|
|
url.searchParams.delete('version');
|
|
url.searchParams.delete('admin');
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
|
|
const userId = getCurrentUserId();
|
|
const result = await apiGet('/api/user.php', {
|
|
api: 'UserInfo',
|
|
user_id: userId,
|
|
_: Date.now()
|
|
});
|
|
|
|
if (!result.success) {
|
|
view.innerHTML = '<div class="alert alert-danger">Could not load profile.</div>';
|
|
return;
|
|
}
|
|
|
|
renderProfile(result.user);
|
|
}
|
|
|
|
function renderProfile(user) {
|
|
currentProfileUser = user;
|
|
|
|
setText('profileStatus', '');
|
|
document.getElementById('profileName').value = user.name ?? '';
|
|
document.getElementById('profileEmail').value = user.email ?? '';
|
|
document.getElementById('profilePassword').value = '';
|
|
document.getElementById('profilePasswordConfirm').value = '';
|
|
document.getElementById('profileTheme').value = user.settings?.theme ?? 'dark';
|
|
document.getElementById('profileRemovePicture').checked = false;
|
|
document.getElementById('profilePictureInput').value = '';
|
|
renderProfileAvatar(user.picture);
|
|
}
|
|
|
|
function renderProfileAvatar(picture) {
|
|
const preview = document.getElementById('profileAvatarPreview');
|
|
|
|
if (!preview) return;
|
|
|
|
if (picture) {
|
|
preview.innerHTML = `<img src="${picture}" alt="">`;
|
|
return;
|
|
}
|
|
|
|
preview.innerHTML = `
|
|
<span class="profile-avatar-fallback">
|
|
<i class="fa-solid fa-user"></i>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
async function renderVersion(version) {
|
|
currentVersion = version;
|
|
|
|
setVersionEditableText('versionKey', `Version ${version.id}`);
|
|
setVersionEditableText('versionName', version.name, version.name);
|
|
setVersionEditableText('versionDescription', version.description, version.description, 'No description provided.');
|
|
setText('versionProject', version.project);
|
|
setText('versionCreated', version.created_date || '-');
|
|
setVersionEditableText('versionDueDate', version.due_date, version.due_date, 'No due date');
|
|
setVersionEditableText('versionReleasedDate', version.released_date, version.released_date, 'Not released');
|
|
}
|
|
|
|
async function loadVersionTasks(versionId, page = 1) {
|
|
const list = document.getElementById('versionTaskList');
|
|
const empty = document.getElementById('versionTaskEmpty');
|
|
|
|
if (!list || !empty) return;
|
|
|
|
list.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
|
|
empty.hidden = true;
|
|
currentVersionTasks = [];
|
|
versionTaskPagination = null;
|
|
|
|
const result = await apiGet('/api/task.php', {
|
|
api: 'ListTasksByVersion',
|
|
version_id: versionId,
|
|
page,
|
|
per_page: taskTablePageSize,
|
|
sort: versionTaskSort.field,
|
|
direction: versionTaskSort.direction
|
|
});
|
|
|
|
if (!result.success) {
|
|
list.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
|
|
return;
|
|
}
|
|
|
|
if (!result.tasks.length) {
|
|
list.innerHTML = '';
|
|
empty.hidden = false;
|
|
renderTaskTablePagination('version');
|
|
return;
|
|
}
|
|
|
|
currentVersionTasks = await normalizeTaskTableRows(result.tasks);
|
|
versionTaskPagination = result.pagination ?? null;
|
|
renderTaskTable('version');
|
|
}
|
|
|
|
async function normalizeTaskTableRows(tasks) {
|
|
const [types, priorities] = await Promise.all([
|
|
getTaskTypes(),
|
|
getTaskPriorities()
|
|
]);
|
|
|
|
return tasks.map((task) => {
|
|
const normalizedTask = typeof task === 'string'
|
|
? { id: task, title: '', type: null, priority: null }
|
|
: task;
|
|
|
|
const type = types.find((item) => String(item.id) === String(normalizedTask.type)) ?? null;
|
|
const priority = priorities.find((item) => String(item.id) === String(normalizedTask.priority)) ?? null;
|
|
|
|
return {
|
|
...normalizedTask,
|
|
typeOption: type,
|
|
priorityOption: priority,
|
|
typeName: type?.name ?? '',
|
|
priorityName: priority?.name ?? '',
|
|
statusName: normalizedTask.status_state?.name ?? '',
|
|
statusColor: normalizedTask.status_state?.color ?? '',
|
|
assigneeUser: normalizedTask.assignee
|
|
? {
|
|
id: normalizedTask.assignee,
|
|
name: normalizedTask.assignee_name ?? `User ${normalizedTask.assignee}`,
|
|
picture: normalizedTask.assignee_picture ?? null
|
|
}
|
|
: null,
|
|
assigneeName: normalizedTask.assignee_name ?? ''
|
|
};
|
|
});
|
|
}
|
|
|
|
function renderTaskTable(kind) {
|
|
const isVersion = kind === 'version';
|
|
const list = document.getElementById(isVersion ? 'versionTaskList' : 'taskListContainer');
|
|
const empty = document.getElementById(isVersion ? 'versionTaskEmpty' : 'taskListEmpty');
|
|
const tasks = isVersion ? currentVersionTasks : currentProjectTasks;
|
|
|
|
if (!list || !empty) return;
|
|
|
|
updateTaskTableSortButtons(kind);
|
|
|
|
list.innerHTML = '';
|
|
empty.hidden = tasks.length > 0;
|
|
|
|
tasks.forEach((task) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'version-task-row version-task-item';
|
|
button.innerHTML = `
|
|
<span class="version-task-id">${escapeHtml(task.id)}</span>
|
|
<span class="version-task-title">${escapeHtml(task.title || '-')}</span>
|
|
<span>${renderMetaBadge(task.typeOption)}</span>
|
|
<span>${renderMetaBadge(task.priorityOption)}</span>
|
|
<span>${renderUser(task.assigneeUser)}</span>
|
|
<span>${renderTaskTableStatus(task)}</span>
|
|
`;
|
|
button.addEventListener('click', () => loadTask(task.id));
|
|
|
|
list.appendChild(button);
|
|
});
|
|
|
|
renderTaskTablePagination(kind);
|
|
}
|
|
|
|
function renderTaskTableStatus(task) {
|
|
if (!task.statusName) return '-';
|
|
|
|
const color = task.statusColor || '#6c757d';
|
|
|
|
return `
|
|
<span class="version-task-status" style="--task-status-color: ${escapeHtml(color)}">
|
|
${escapeHtml(task.statusName)}
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
function renderTaskTablePagination(kind) {
|
|
const isVersion = kind === 'version';
|
|
const pagination = isVersion ? versionTaskPagination : projectTaskPagination;
|
|
const container = document.getElementById(isVersion ? 'versionTaskPagination' : 'projectTaskPagination');
|
|
|
|
if (!container) return;
|
|
|
|
if (!pagination || pagination.total <= pagination.per_page) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const start = ((pagination.page - 1) * pagination.per_page) + 1;
|
|
const end = Math.min(pagination.total, pagination.page * pagination.per_page);
|
|
|
|
container.innerHTML = `
|
|
<span>${escapeHtml(start)}-${escapeHtml(end)} of ${escapeHtml(pagination.total)}</span>
|
|
<div class="version-task-page-actions">
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page - 1)}" ${pagination.page <= 1 ? 'disabled' : ''}>
|
|
<i class="fa-solid fa-chevron-left"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page + 1)}" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>
|
|
<i class="fa-solid fa-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateTaskTableSortButtons(kind) {
|
|
const isVersion = kind === 'version';
|
|
const selector = isVersion ? '[data-version-task-sort]' : '[data-project-task-sort]';
|
|
const state = isVersion ? versionTaskSort : projectTaskSort;
|
|
|
|
document.querySelectorAll(selector).forEach((button) => {
|
|
const field = isVersion ? button.dataset.versionTaskSort : button.dataset.projectTaskSort;
|
|
const isActive = field === state.field;
|
|
const icon = button.querySelector('i');
|
|
|
|
button.classList.toggle('is-active', isActive);
|
|
|
|
if (!icon) return;
|
|
|
|
icon.className = isActive
|
|
? `fa-solid ${state.direction === 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`
|
|
: 'fa-solid fa-sort';
|
|
});
|
|
}
|
|
|
|
function initVersionInlineEditing() {
|
|
if (!canEditVersions()) return;
|
|
|
|
document.querySelectorAll('.version-editable').forEach((element) => {
|
|
if (element.dataset.inlineReady === 'true') return;
|
|
|
|
element.dataset.inlineReady = 'true';
|
|
element.title = 'Click to edit';
|
|
|
|
element.addEventListener('click', () => openVersionInlineEditor(element));
|
|
element.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
openVersionInlineEditor(element);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function initProjectInlineEditing() {
|
|
if (!canEditProjects()) return;
|
|
|
|
document.querySelectorAll('.project-editable').forEach((element) => {
|
|
if (element.dataset.inlineReady === 'true') return;
|
|
|
|
element.dataset.inlineReady = 'true';
|
|
element.title = 'Click to edit';
|
|
|
|
element.addEventListener('click', () => openProjectInlineEditor(element));
|
|
element.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
openProjectInlineEditor(element);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function openProjectInlineEditor(element) {
|
|
if (!canEditProjects() || !currentProject || element.querySelector('.version-inline-form')) return;
|
|
|
|
const field = element.dataset.projectField;
|
|
const currentValue = element.dataset.currentValue ?? '';
|
|
const originalHtml = element.innerHTML;
|
|
const originalClasses = Array.from(element.classList);
|
|
|
|
if (!['name', 'owner'].includes(field)) return;
|
|
|
|
element.classList.remove('is-empty');
|
|
element.innerHTML = '';
|
|
|
|
const form = document.createElement('form');
|
|
form.className = 'version-inline-form';
|
|
|
|
let input;
|
|
|
|
if (field === 'owner') {
|
|
input = document.createElement('select');
|
|
input.className = 'form-select version-inline-input';
|
|
populateSelect(input, await getUsers(), {
|
|
includeEmpty: false,
|
|
selectedValue: currentValue
|
|
});
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.className = 'form-control version-inline-input';
|
|
input.type = 'text';
|
|
input.required = true;
|
|
input.maxLength = 128;
|
|
input.value = currentValue;
|
|
}
|
|
|
|
form.appendChild(input);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'version-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-project-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 = field === 'name' ? input.value.trim() : input.value;
|
|
|
|
if (nextValue === currentValue) {
|
|
restore();
|
|
return;
|
|
}
|
|
|
|
const result = await apiPost('/api/project.php', {
|
|
api: 'Edit',
|
|
project_id: currentProject.id
|
|
}, {
|
|
[field]: nextValue
|
|
});
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not update project.');
|
|
restore();
|
|
return;
|
|
}
|
|
|
|
taskLookupCache.projectsById.delete(currentProject.id);
|
|
closed = true;
|
|
await loadProject(currentProject.id, false);
|
|
projectTreePromise = loadProjectTree();
|
|
};
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
await save();
|
|
});
|
|
|
|
if (input.tagName === 'SELECT') {
|
|
input.addEventListener('change', save);
|
|
}
|
|
|
|
form.querySelector('[data-project-inline-cancel]')?.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
restore();
|
|
});
|
|
|
|
input.addEventListener('keydown', async (event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
restore();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function openVersionInlineEditor(element) {
|
|
if (!canEditVersions() || !currentVersion || element.querySelector('.version-inline-form')) return;
|
|
|
|
const field = element.dataset.versionField;
|
|
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 = 'version-inline-form';
|
|
|
|
const input = createVersionInlineInput(field, currentValue);
|
|
form.appendChild(input);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'version-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-version-inline-cancel>
|
|
<i class="fa-solid fa-xmark"></i>
|
|
</button>
|
|
`;
|
|
form.appendChild(actions);
|
|
|
|
element.appendChild(form);
|
|
input.focus();
|
|
|
|
if (input.select && input.type !== 'date') {
|
|
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/version.php', {
|
|
api: 'Edit',
|
|
version_id: currentVersion.id
|
|
}, {
|
|
[field]: nextValue
|
|
});
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not update version.');
|
|
saving = false;
|
|
restore();
|
|
return;
|
|
}
|
|
|
|
editorClosed = true;
|
|
element.classList.remove('is-saving');
|
|
currentVersion = {
|
|
...currentVersion,
|
|
[field]: nextValue === '' ? null : nextValue
|
|
};
|
|
|
|
taskLookupCache.versionsByProject.delete(currentVersion.project);
|
|
await renderVersion(currentVersion);
|
|
projectTreePromise = loadProjectTree();
|
|
await projectTreePromise;
|
|
};
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
await save();
|
|
});
|
|
|
|
form.querySelector('[data-version-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();
|
|
}
|
|
});
|
|
}
|
|
|
|
function createVersionInlineInput(field, currentValue) {
|
|
if (field === 'description') {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.className = 'form-control version-inline-input';
|
|
textarea.rows = 5;
|
|
textarea.value = currentValue;
|
|
return textarea;
|
|
}
|
|
|
|
const input = document.createElement('input');
|
|
input.className = 'form-control version-inline-input';
|
|
input.type = field === 'due_date' || field === 'released_date' ? 'date' : 'text';
|
|
input.value = currentValue;
|
|
input.required = field === 'name';
|
|
return input;
|
|
}
|
|
|
|
async function getUserInfo(userId) {
|
|
if (!userId || userId === '-') {
|
|
return null;
|
|
}
|
|
|
|
const result = await apiGet('/api/user.php', {
|
|
api: 'UserInfo',
|
|
user_id: userId
|
|
});
|
|
|
|
return result.success ? result.user : null;
|
|
}
|
|
|
|
function renderUser(user) {
|
|
if (!user) {
|
|
return '-';
|
|
}
|
|
|
|
const avatar = user.picture
|
|
? `<img src="${user.picture}" alt="" class="user-avatar">`
|
|
: `
|
|
<span class="user-avatar user-avatar-fallback">
|
|
<i class="fa-solid fa-user"></i>
|
|
</span>
|
|
`;
|
|
|
|
return `
|
|
<span class="meta-user">
|
|
${avatar}
|
|
<span>${escapeHtml(user.name)}</span>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
function getCurrentUserId() {
|
|
return document.querySelector('.kiln-app')?.dataset.currentUserId ?? '';
|
|
}
|
|
|
|
function updateAccountProfile(user) {
|
|
setText('accountName', user.name ?? '');
|
|
setText('accountEmail', user.email ?? '');
|
|
|
|
const accountButton = document.querySelector('.kiln-account-btn');
|
|
const accountName = document.getElementById('accountName');
|
|
|
|
if (!accountButton || !accountName) return;
|
|
|
|
document.getElementById('accountAvatarImage')?.remove();
|
|
document.getElementById('accountAvatarFallback')?.remove();
|
|
|
|
const avatar = document.createElement(user.picture ? 'img' : 'span');
|
|
avatar.id = user.picture ? 'accountAvatarImage' : 'accountAvatarFallback';
|
|
avatar.className = user.picture ? 'user-avatar' : 'user-avatar user-avatar-fallback';
|
|
|
|
if (user.picture) {
|
|
avatar.src = user.picture;
|
|
avatar.alt = '';
|
|
} else {
|
|
avatar.innerHTML = '<i class="fa-solid fa-user"></i>';
|
|
}
|
|
|
|
accountButton.insertBefore(avatar, accountName);
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
const stylesheet = document.getElementById('themeStylesheet');
|
|
|
|
if (!stylesheet || !themeStyles[theme]) return;
|
|
|
|
stylesheet.href = themeStyles[theme];
|
|
stylesheet.dataset.theme = theme;
|
|
}
|
|
|
|
async function loadWorkflowEditor() {
|
|
const result = await apiGet('/api/workflow.php', {
|
|
api: 'WorkflowData',
|
|
_: Date.now()
|
|
});
|
|
|
|
if (!result.success) {
|
|
document.getElementById('workflowGraph').innerHTML = '<div class="alert alert-danger m-3">Could not load workflow data.</div>';
|
|
return;
|
|
}
|
|
|
|
workflowData = result.workflow;
|
|
if (!selectedWorkflowTaskType && workflowData.task_types.length) {
|
|
selectedWorkflowTaskType = String(workflowData.task_types[0].id);
|
|
}
|
|
|
|
if (selectedWorkflowTaskType && !workflowData.task_types.some((type) => String(type.id) === String(selectedWorkflowTaskType))) {
|
|
selectedWorkflowTaskType = workflowData.task_types.length ? String(workflowData.task_types[0].id) : null;
|
|
}
|
|
|
|
renderWorkflowEditor();
|
|
}
|
|
|
|
function renderWorkflowEditor() {
|
|
if (!workflowData) return;
|
|
|
|
populateWorkflowSelects();
|
|
renderWorkflowLists();
|
|
renderWorkflowGraph();
|
|
}
|
|
|
|
function populateWorkflowSelects() {
|
|
const stateOptions = workflowData.states.map((state) => ({
|
|
id: state.id,
|
|
name: state.name
|
|
}));
|
|
|
|
populateSelect(document.getElementById('workflowTransitionFrom'), stateOptions, {
|
|
placeholder: 'From state'
|
|
});
|
|
populateSelect(document.getElementById('workflowTransitionTo'), stateOptions, {
|
|
placeholder: 'To state'
|
|
});
|
|
populateSelect(document.getElementById('workflowAssignmentState'), stateOptions, {
|
|
placeholder: 'State'
|
|
});
|
|
populateSelect(document.getElementById('workflowTaskTypeSelect'), workflowData.task_types, {
|
|
placeholder: 'Task type',
|
|
selectedValue: selectedWorkflowTaskType ?? ''
|
|
});
|
|
|
|
const assignedStates = getSelectedWorkflowAssignments()
|
|
.map((assignment) => workflowData.states.find((state) => Number(state.id) === Number(assignment.state)))
|
|
.filter(Boolean);
|
|
const selectedType = getSelectedWorkflowTaskType();
|
|
|
|
populateSelect(document.getElementById('workflowDefaultStateSelect'), assignedStates, {
|
|
placeholder: 'No default state',
|
|
selectedValue: selectedType?.default_state ?? ''
|
|
});
|
|
}
|
|
|
|
function renderWorkflowLists() {
|
|
const statesById = new Map(workflowData.states.map((state) => [Number(state.id), state]));
|
|
const selectedAssignments = getSelectedWorkflowAssignments();
|
|
|
|
const stateList = document.getElementById('workflowStateList');
|
|
stateList.innerHTML = workflowData.states.map((state) => `
|
|
<div class="workflow-list-item">
|
|
<div class="workflow-list-main">
|
|
<span class="workflow-color-dot" style="background: ${escapeHtml(state.color)}"></span>
|
|
<span>${escapeHtml(state.name)}</span>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-state="${escapeHtml(state.id)}">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`).join('') || '<div class="text-secondary">No states yet.</div>';
|
|
|
|
const transitionList = document.getElementById('workflowTransitionList');
|
|
transitionList.innerHTML = workflowData.transitions.map((transition) => `
|
|
<div class="workflow-list-item">
|
|
<div class="workflow-list-main">
|
|
<span>${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)}</span>
|
|
<i class="fa-solid fa-arrow-right"></i>
|
|
<span>${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)}</span>
|
|
<span class="text-secondary">${escapeHtml(transition.action_name)}</span>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-transition="${escapeHtml(transition.id)}">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`).join('') || '<div class="text-secondary">No transitions yet.</div>';
|
|
|
|
const assignmentList = document.getElementById('workflowAssignmentList');
|
|
assignmentList.innerHTML = selectedAssignments.map((assignment) => `
|
|
<div class="workflow-list-item">
|
|
<div class="workflow-list-main">
|
|
<span>${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}</span>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-remove-assignment="${escapeHtml(assignment.id)}">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`).join('') || '<div class="text-secondary">No assigned states yet.</div>';
|
|
}
|
|
|
|
function getSelectedWorkflowTaskType() {
|
|
return workflowData?.task_types.find((type) => String(type.id) === String(selectedWorkflowTaskType)) ?? null;
|
|
}
|
|
|
|
function getSelectedWorkflowAssignments() {
|
|
if (!selectedWorkflowTaskType) return [];
|
|
|
|
return workflowData.assignments.filter((assignment) => String(assignment.task_type) === String(selectedWorkflowTaskType));
|
|
}
|
|
|
|
function renderWorkflowGraph() {
|
|
const graph = document.getElementById('workflowGraph');
|
|
const empty = document.getElementById('workflowGraphEmpty');
|
|
|
|
if (!graph || !empty) return;
|
|
|
|
const assignedStateIds = new Set(getSelectedWorkflowAssignments().map((assignment) => Number(assignment.state)));
|
|
const visibleStates = workflowData.states.filter((state) => assignedStateIds.has(Number(state.id)));
|
|
const visibleTransitions = workflowData.transitions.filter((transition) => (
|
|
assignedStateIds.has(Number(transition.from_id))
|
|
&& assignedStateIds.has(Number(transition.to_id))
|
|
));
|
|
|
|
if (!visibleStates.length) {
|
|
if (workflowGraph) {
|
|
workflowGraph.destroy();
|
|
workflowGraph = null;
|
|
}
|
|
|
|
graph.innerHTML = '';
|
|
empty.hidden = false;
|
|
empty.textContent = selectedWorkflowTaskType
|
|
? 'Assign states to this task type to render its workflow graph.'
|
|
: 'Select a task type to render its workflow graph.';
|
|
return;
|
|
}
|
|
|
|
empty.hidden = true;
|
|
|
|
if (typeof cytoscape !== 'function') {
|
|
graph.innerHTML = '<div class="alert alert-warning m-3">Cytoscape.js could not be loaded.</div>';
|
|
return;
|
|
}
|
|
|
|
const elements = [
|
|
...visibleStates.map((state) => ({
|
|
data: {
|
|
id: `state-${state.id}`,
|
|
label: state.name,
|
|
color: state.color
|
|
}
|
|
})),
|
|
...visibleTransitions.map((transition) => ({
|
|
data: {
|
|
id: `transition-${transition.id}`,
|
|
source: `state-${transition.from_id}`,
|
|
target: `state-${transition.to_id}`,
|
|
label: transition.action_name
|
|
}
|
|
}))
|
|
];
|
|
const styles = getComputedStyle(document.documentElement);
|
|
const borderColor = styles.getPropertyValue('--bs-border-color').trim() || '#3a3f46';
|
|
const bodyColor = styles.getPropertyValue('--bs-body-color').trim() || '#f3f4f6';
|
|
const secondaryColor = styles.getPropertyValue('--bs-secondary-color').trim() || '#aeb7c4';
|
|
|
|
if (workflowGraph) {
|
|
workflowGraph.destroy();
|
|
}
|
|
|
|
workflowGraph = cytoscape({
|
|
container: graph,
|
|
elements,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'background-color': 'data(color)',
|
|
'border-color': borderColor,
|
|
'border-width': 1,
|
|
'color': bodyColor,
|
|
'font-size': 13,
|
|
'font-weight': 700,
|
|
'label': 'data(label)',
|
|
'text-valign': 'center',
|
|
'text-halign': 'center',
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': 90,
|
|
'width': 86,
|
|
'height': 42,
|
|
'shape': 'round-rectangle'
|
|
}
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'curve-style': 'bezier',
|
|
'target-arrow-shape': 'triangle',
|
|
'line-color': secondaryColor,
|
|
'target-arrow-color': secondaryColor,
|
|
'color': secondaryColor,
|
|
'font-size': 11,
|
|
'label': 'data(label)',
|
|
'text-rotation': 'autorotate',
|
|
'text-margin-y': -8,
|
|
'width': 2
|
|
}
|
|
}
|
|
],
|
|
layout: {
|
|
name: 'breadthfirst',
|
|
directed: true,
|
|
padding: 36,
|
|
spacingFactor: 1.25
|
|
}
|
|
});
|
|
}
|
|
|
|
async function saveWorkflowAction(api, body = {}, params = {}) {
|
|
const result = await apiPost('/api/workflow.php', {
|
|
api,
|
|
...params
|
|
}, body);
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not save workflow change.');
|
|
return false;
|
|
}
|
|
|
|
await loadWorkflowEditor();
|
|
return true;
|
|
}
|
|
|
|
async function loadAdminOptions() {
|
|
let result = null;
|
|
|
|
try {
|
|
result = await apiGet('/api/admin_options.php', {
|
|
api: 'OptionsData',
|
|
_: Date.now()
|
|
});
|
|
} catch (error) {
|
|
result = {
|
|
success: false,
|
|
error: 'Could not load admin options.'
|
|
};
|
|
}
|
|
|
|
if (!result.success) {
|
|
const typeEditor = document.getElementById('adminSelectedTypeEditor');
|
|
const priorityList = document.getElementById('adminPriorityList');
|
|
const userRightsList = document.getElementById('adminUserRightsList');
|
|
|
|
if (typeEditor) {
|
|
typeEditor.innerHTML = '<div class="text-danger">Could not load task types.</div>';
|
|
}
|
|
|
|
if (priorityList) {
|
|
priorityList.innerHTML = '<div class="text-danger">Could not load priorities.</div>';
|
|
}
|
|
|
|
if (userRightsList) {
|
|
userRightsList.innerHTML = '<div class="text-danger">Could not load users.</div>';
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
adminOptionsData = {
|
|
types: result.options?.types ?? [],
|
|
priorities: result.options?.priorities ?? [],
|
|
custom_fields: result.options?.custom_fields ?? [],
|
|
users: result.options?.users ?? [],
|
|
rights: result.options?.rights ?? [],
|
|
user_rights: result.options?.user_rights ?? [],
|
|
projects: result.options?.projects ?? [],
|
|
user_access: result.options?.user_access ?? []
|
|
};
|
|
if (!selectedAdminTaskType && adminOptionsData.types.length) {
|
|
selectedAdminTaskType = String(adminOptionsData.types[0].id);
|
|
}
|
|
|
|
if (selectedAdminTaskType && !adminOptionsData.types.some((type) => String(type.id) === String(selectedAdminTaskType))) {
|
|
selectedAdminTaskType = adminOptionsData.types.length ? String(adminOptionsData.types[0].id) : null;
|
|
}
|
|
|
|
if (!selectedAdminUser && adminOptionsData.users.length) {
|
|
selectedAdminUser = String(adminOptionsData.users[0].id);
|
|
}
|
|
|
|
if (selectedAdminUser && !adminOptionsData.users.some((user) => String(user.id) === String(selectedAdminUser))) {
|
|
selectedAdminUser = adminOptionsData.users.length ? String(adminOptionsData.users[0].id) : null;
|
|
}
|
|
|
|
renderAdminOptions();
|
|
}
|
|
|
|
function renderAdminOptions() {
|
|
if (!adminOptionsData) return;
|
|
|
|
const types = adminOptionsData.types ?? [];
|
|
const priorities = adminOptionsData.priorities ?? [];
|
|
|
|
populateSelect(document.getElementById('adminTaskTypeSelect'), types, {
|
|
placeholder: 'Select task type',
|
|
selectedValue: selectedAdminTaskType ?? ''
|
|
});
|
|
renderSelectedAdminTaskType();
|
|
renderAdminCustomFields();
|
|
renderAdminOptionList('priority', priorities, document.getElementById('adminPriorityList'));
|
|
renderAdminUsers();
|
|
}
|
|
|
|
function getSelectedAdminTaskType() {
|
|
return adminOptionsData?.types.find((type) => String(type.id) === String(selectedAdminTaskType)) ?? null;
|
|
}
|
|
|
|
function renderSelectedAdminTaskType() {
|
|
const container = document.getElementById('adminSelectedTypeEditor');
|
|
|
|
if (!container) return;
|
|
|
|
const type = getSelectedAdminTaskType();
|
|
|
|
if (!type) {
|
|
container.innerHTML = '<div class="text-secondary">Create or select a task type first.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<form class="admin-option-item admin-selected-option" data-admin-option-edit="type" data-option-id="${escapeHtml(type.id)}">
|
|
<div class="admin-option-icon">
|
|
${type.icon
|
|
? `<img src="${escapeHtml(type.icon)}" alt="">`
|
|
: '<i class="fa-solid fa-tag"></i>'}
|
|
</div>
|
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(type.name)}" required maxlength="20">
|
|
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
|
<label class="admin-option-remove-logo">
|
|
<input type="checkbox" name="remove_logo" value="1">
|
|
<span>Remove logo</span>
|
|
</label>
|
|
<div class="admin-option-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="type" data-option-id="${escapeHtml(type.id)}" title="Delete">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
function renderAdminCustomFields() {
|
|
const container = document.getElementById('adminCustomFieldList');
|
|
|
|
if (!container) return;
|
|
|
|
if (!selectedAdminTaskType) {
|
|
container.innerHTML = '<div class="text-secondary">Select a task type first.</div>';
|
|
return;
|
|
}
|
|
|
|
const fields = (adminOptionsData.custom_fields ?? [])
|
|
.filter((field) => String(field.task_type) === String(selectedAdminTaskType));
|
|
|
|
container.innerHTML = fields.map((field) => `
|
|
<form class="admin-custom-field-item" data-admin-custom-field-edit="${escapeHtml(field.id)}">
|
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(field.name)}" required maxlength="128">
|
|
<select class="form-select form-select-sm" name="type" required>
|
|
${customFieldTypes.map((type) => `
|
|
<option value="${escapeHtml(type.id)}" ${String(type.id) === String(field.type) ? 'selected' : ''}>${escapeHtml(type.name)}</option>
|
|
`).join('')}
|
|
</select>
|
|
<input class="form-control form-control-sm" name="value" value="${escapeHtml(field.raw_value ?? '')}">
|
|
<div class="admin-option-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-custom-field-delete="${escapeHtml(field.id)}" title="Delete">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`).join('') || '<div class="text-secondary">No custom fields yet.</div>';
|
|
}
|
|
|
|
function renderAdminOptionList(kind, options, container) {
|
|
if (!container) return;
|
|
|
|
container.innerHTML = options.map((option) => `
|
|
<form class="admin-option-item" data-admin-option-edit="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}">
|
|
<div class="admin-option-icon">
|
|
${option.icon
|
|
? `<img src="${escapeHtml(option.icon)}" alt="">`
|
|
: '<i class="fa-solid fa-tag"></i>'}
|
|
</div>
|
|
<input class="form-control form-control-sm" name="name" value="${escapeHtml(option.name)}" required maxlength="128">
|
|
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
|
<label class="admin-option-remove-logo">
|
|
<input type="checkbox" name="remove_logo" value="1">
|
|
<span>Remove logo</span>
|
|
</label>
|
|
<div class="admin-option-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
|
<i class="fa-solid fa-check"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}" title="Delete">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`).join('') || '<div class="text-secondary">No entries yet.</div>';
|
|
}
|
|
|
|
function getSelectedAdminUser() {
|
|
return adminOptionsData?.users.find((user) => String(user.id) === String(selectedAdminUser)) ?? null;
|
|
}
|
|
|
|
function userHasAdminRight(userId, rightId) {
|
|
return (adminOptionsData?.user_rights ?? []).some((entry) => (
|
|
String(entry.user_id) === String(userId)
|
|
&& String(entry.right_id) === String(rightId)
|
|
));
|
|
}
|
|
|
|
function getAdminUserAccess(userId) {
|
|
return (adminOptionsData?.user_access ?? []).filter((entry) => (
|
|
String(entry.user_id) === String(userId)
|
|
));
|
|
}
|
|
|
|
function getAdminProject(projectId) {
|
|
return (adminOptionsData?.projects ?? []).find((project) => (
|
|
String(project.id) === String(projectId)
|
|
)) ?? null;
|
|
}
|
|
|
|
function renderAdminUsers() {
|
|
const select = document.getElementById('adminUserSelect');
|
|
const container = document.getElementById('adminUserRightsList');
|
|
|
|
if (!select || !container) return;
|
|
|
|
const users = adminOptionsData.users ?? [];
|
|
const rights = adminOptionsData.rights ?? [];
|
|
const projects = adminOptionsData.projects ?? [];
|
|
const selectedUser = getSelectedAdminUser();
|
|
|
|
populateSelect(select, users, {
|
|
placeholder: 'Select user',
|
|
selectedValue: selectedAdminUser ?? ''
|
|
});
|
|
|
|
if (!selectedUser) {
|
|
container.innerHTML = '<div class="text-secondary">Select a user first.</div>';
|
|
return;
|
|
}
|
|
|
|
if (!rights.length) {
|
|
container.innerHTML = '<div class="text-secondary">No rights configured yet.</div>';
|
|
return;
|
|
}
|
|
|
|
const explicitAccess = getAdminUserAccess(selectedUser.id);
|
|
const explicitProjectIds = new Set(explicitAccess.map((entry) => String(entry.project_id)));
|
|
const ownerProjects = projects.filter((project) => String(project.owner) === String(selectedUser.id));
|
|
const ownerProjectIds = new Set(ownerProjects.map((project) => String(project.id)));
|
|
const addableProjects = projects.filter((project) => (
|
|
!explicitProjectIds.has(String(project.id))
|
|
&& !ownerProjectIds.has(String(project.id))
|
|
));
|
|
|
|
container.innerHTML = `
|
|
<div class="admin-user-summary">
|
|
${renderUser(selectedUser)}
|
|
</div>
|
|
|
|
<div class="admin-user-section">
|
|
<h4>Rights</h4>
|
|
<div class="admin-right-list">
|
|
${rights.map((right) => {
|
|
const checked = userHasAdminRight(selectedUser.id, right.id);
|
|
const isSelfAdminRight = appFlag('isAdmin')
|
|
&& String(selectedUser.id) === String(getCurrentUserId())
|
|
&& String(right.name) === 'Admin';
|
|
|
|
return `
|
|
<label class="admin-right-item">
|
|
<input
|
|
type="checkbox"
|
|
data-admin-user-right
|
|
data-user-id="${escapeHtml(selectedUser.id)}"
|
|
data-right-id="${escapeHtml(right.id)}"
|
|
${checked ? 'checked' : ''}
|
|
${isSelfAdminRight ? 'disabled' : ''}
|
|
>
|
|
<span>${escapeHtml(right.name)}</span>
|
|
</label>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-user-section">
|
|
<h4>Project Access</h4>
|
|
<form class="admin-project-access-form" id="adminProjectAccessForm">
|
|
<select class="form-select form-select-sm" name="project_id" ${addableProjects.length ? '' : 'disabled'}>
|
|
${addableProjects.map((project) => `
|
|
<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)} (${escapeHtml(project.id)})</option>
|
|
`).join('')}
|
|
</select>
|
|
<button class="btn btn-sm btn-primary" type="submit" ${addableProjects.length ? '' : 'disabled'}>
|
|
<i class="fa-solid fa-plus me-1"></i>
|
|
Add Access
|
|
</button>
|
|
</form>
|
|
|
|
<div class="admin-project-access-list">
|
|
${explicitAccess.map((access) => {
|
|
const project = getAdminProject(access.project_id);
|
|
|
|
return `
|
|
<div class="admin-project-access-item">
|
|
<span>${escapeHtml(project?.name ?? access.project_id)} <small>${escapeHtml(access.project_id)}</small></span>
|
|
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-project-access-delete="${escapeHtml(access.id)}" title="Remove access">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}).join('') || '<div class="text-secondary">No explicit project access yet.</div>'}
|
|
</div>
|
|
|
|
${ownerProjects.length ? `
|
|
<div class="admin-project-owner-list">
|
|
<strong>Owner access</strong>
|
|
${ownerProjects.map((project) => `
|
|
<span>${escapeHtml(project.name)} <small>${escapeHtml(project.id)}</small></span>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function setAdminUserRight(userId, rightId, enabled) {
|
|
const result = await apiPost('/api/admin_options.php', {
|
|
api: 'SetUserRight'
|
|
}, {
|
|
user_id: userId,
|
|
right_id: rightId,
|
|
enabled: enabled ? 1 : 0
|
|
});
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not update user right.');
|
|
return false;
|
|
}
|
|
|
|
await loadAdminOptions();
|
|
return true;
|
|
}
|
|
|
|
async function grantAdminProjectAccess(userId, projectId) {
|
|
const result = await apiPost('/api/admin_options.php', {
|
|
api: 'GrantProjectAccess'
|
|
}, {
|
|
user_id: userId,
|
|
project_id: projectId
|
|
});
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not add project access.');
|
|
return false;
|
|
}
|
|
|
|
await loadAdminOptions();
|
|
return true;
|
|
}
|
|
|
|
async function revokeAdminProjectAccess(accessId) {
|
|
const result = await apiPost('/api/admin_options.php', {
|
|
api: 'RevokeProjectAccess',
|
|
access_id: accessId
|
|
});
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not remove project access.');
|
|
return false;
|
|
}
|
|
|
|
await loadAdminOptions();
|
|
return true;
|
|
}
|
|
|
|
async function saveAdminOptionForm(kind, form, optionId = null) {
|
|
const formData = new FormData(form);
|
|
formData.set('kind', kind);
|
|
|
|
if (optionId) {
|
|
formData.set('id', optionId);
|
|
}
|
|
|
|
const result = await apiPostForm('/api/admin_options.php', {
|
|
api: optionId ? 'UpdateOption' : 'CreateOption'
|
|
}, formData);
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not save option.');
|
|
return false;
|
|
}
|
|
|
|
if (kind === 'type' && !optionId && result.option_id) {
|
|
selectedAdminTaskType = String(result.option_id);
|
|
}
|
|
|
|
taskLookupCache.types = null;
|
|
taskLookupCache.priorities = null;
|
|
await populateTaskOptionSelects();
|
|
await loadAdminOptions();
|
|
|
|
return true;
|
|
}
|
|
|
|
async function deleteAdminOption(kind, optionId) {
|
|
const result = await apiPost('/api/admin_options.php', {
|
|
api: 'DeleteOption',
|
|
kind,
|
|
id: optionId
|
|
});
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not delete option.');
|
|
return false;
|
|
}
|
|
|
|
if (kind === 'type' && String(selectedAdminTaskType) === String(optionId)) {
|
|
selectedAdminTaskType = null;
|
|
}
|
|
|
|
taskLookupCache.types = null;
|
|
taskLookupCache.priorities = null;
|
|
await populateTaskOptionSelects();
|
|
await loadAdminOptions();
|
|
|
|
return true;
|
|
}
|
|
|
|
async function saveAdminCustomFieldForm(form, fieldId = null) {
|
|
if (!selectedAdminTaskType) {
|
|
alert('Select a task type first.');
|
|
return false;
|
|
}
|
|
|
|
const data = Object.fromEntries(new FormData(form));
|
|
data.task_type = selectedAdminTaskType;
|
|
|
|
if (fieldId) {
|
|
data.id = fieldId;
|
|
}
|
|
|
|
const result = await apiPost('/api/admin_options.php', {
|
|
api: fieldId ? 'UpdateCustomField' : 'CreateCustomField'
|
|
}, data);
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not save custom field.');
|
|
return false;
|
|
}
|
|
|
|
await loadAdminOptions();
|
|
return true;
|
|
}
|
|
|
|
async function deleteAdminCustomField(fieldId) {
|
|
const result = await apiPost('/api/admin_options.php', {
|
|
api: 'DeleteCustomField',
|
|
field_id: fieldId
|
|
});
|
|
|
|
if (!result.success) {
|
|
alert(result.error || 'Could not delete custom field.');
|
|
return false;
|
|
}
|
|
|
|
await loadAdminOptions();
|
|
return true;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function loadRouteFromUrl() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const taskId = params.get('task');
|
|
const versionId = params.get('version');
|
|
const projectId = params.get('project');
|
|
const projectTasks = params.get('tasks');
|
|
const profile = params.get('profile');
|
|
const adminSection = params.get('admin');
|
|
|
|
closePopups();
|
|
|
|
if (adminSection) {
|
|
loadAdmin(adminSection, false);
|
|
return;
|
|
}
|
|
|
|
if (profile) {
|
|
loadProfile(false);
|
|
return;
|
|
}
|
|
|
|
if (taskId) {
|
|
loadTask(taskId, false);
|
|
return;
|
|
}
|
|
|
|
if (versionId) {
|
|
loadVersion(versionId, false);
|
|
return;
|
|
}
|
|
|
|
if (projectId) {
|
|
if (projectTasks) {
|
|
loadProjectTasks(projectId, 1, false);
|
|
} else {
|
|
loadProject(projectId, false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
showView('dashboardView');
|
|
}
|
|
|
|
function loadAdmin(section = 'workflows', pushHistory = true) {
|
|
if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') {
|
|
showView('dashboardView');
|
|
return;
|
|
}
|
|
|
|
const view = showView('adminView');
|
|
|
|
if (!view) return;
|
|
|
|
if (!['workflows', 'options', 'users'].includes(section)) {
|
|
section = 'workflows';
|
|
}
|
|
|
|
document.querySelectorAll('[data-admin-section]').forEach((button) => {
|
|
button.classList.toggle('is-active', button.dataset.adminSection === section);
|
|
});
|
|
|
|
document.querySelectorAll('.admin-panel').forEach((panel) => {
|
|
panel.hidden = true;
|
|
});
|
|
|
|
const panelBySection = {
|
|
workflows: 'adminWorkflowsPanel',
|
|
options: 'adminOptionsPanel',
|
|
users: 'adminUsersPanel'
|
|
};
|
|
const panel = document.getElementById(panelBySection[section]);
|
|
if (panel) panel.hidden = false;
|
|
|
|
if (pushHistory) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('page', 'home');
|
|
url.searchParams.set('admin', section);
|
|
url.searchParams.delete('task');
|
|
url.searchParams.delete('project');
|
|
url.searchParams.delete('tasks');
|
|
url.searchParams.delete('version');
|
|
url.searchParams.delete('profile');
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
|
|
if (section === 'workflows') {
|
|
loadWorkflowEditor();
|
|
} else if (section === 'options' || section === 'users') {
|
|
loadAdminOptions();
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
projectTreePromise = loadProjectTree();
|
|
populateTaskOptionSelects();
|
|
applyPermissionVisibility();
|
|
initTaskInlineEditing();
|
|
initVersionInlineEditing();
|
|
initProjectInlineEditing();
|
|
|
|
loadRouteFromUrl();
|
|
|
|
window.addEventListener('popstate', () => {
|
|
loadRouteFromUrl();
|
|
});
|
|
|
|
document.querySelectorAll('[data-view="dashboard"]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
showView('dashboardView');
|
|
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('page', 'home');
|
|
url.searchParams.delete('task');
|
|
url.searchParams.delete('project');
|
|
url.searchParams.delete('tasks');
|
|
url.searchParams.delete('version');
|
|
url.searchParams.delete('profile');
|
|
url.searchParams.delete('admin');
|
|
window.history.pushState({}, '', url);
|
|
});
|
|
});
|
|
|
|
document.getElementById('adminMenuButton')?.addEventListener('click', () => {
|
|
loadAdmin('workflows');
|
|
});
|
|
|
|
document.querySelectorAll('[data-admin-section]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
loadAdmin(button.dataset.adminSection);
|
|
});
|
|
});
|
|
|
|
document.getElementById('workflowRefreshButton')?.addEventListener('click', () => {
|
|
loadWorkflowEditor();
|
|
});
|
|
|
|
document.getElementById('workflowTaskTypeSelect')?.addEventListener('change', (event) => {
|
|
selectedWorkflowTaskType = event.target.value || null;
|
|
renderWorkflowEditor();
|
|
});
|
|
|
|
document.getElementById('workflowDefaultStateSelect')?.addEventListener('change', (event) => {
|
|
if (!selectedWorkflowTaskType) return;
|
|
|
|
saveWorkflowAction('SetDefaultState', {
|
|
task_type: selectedWorkflowTaskType,
|
|
state: event.target.value
|
|
});
|
|
});
|
|
|
|
document.getElementById('workflowStateForm')?.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const form = event.currentTarget;
|
|
const data = Object.fromEntries(new FormData(form));
|
|
const saved = await saveWorkflowAction('CreateState', data);
|
|
|
|
if (saved) form.reset();
|
|
});
|
|
|
|
document.getElementById('workflowTransitionForm')?.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const form = event.currentTarget;
|
|
const data = Object.fromEntries(new FormData(form));
|
|
const saved = await saveWorkflowAction('CreateTransition', data);
|
|
|
|
if (saved) form.reset();
|
|
});
|
|
|
|
document.getElementById('workflowAssignmentForm')?.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const form = event.currentTarget;
|
|
const data = Object.fromEntries(new FormData(form));
|
|
data.task_type = selectedWorkflowTaskType;
|
|
|
|
if (!data.task_type) {
|
|
alert('Select a task type first.');
|
|
return;
|
|
}
|
|
|
|
const saved = await saveWorkflowAction('AssignState', data);
|
|
|
|
if (saved) form.reset();
|
|
});
|
|
|
|
document.getElementById('adminWorkflowsPanel')?.addEventListener('click', (event) => {
|
|
const stateButton = event.target.closest('[data-workflow-delete-state]');
|
|
const transitionButton = event.target.closest('[data-workflow-delete-transition]');
|
|
const assignmentButton = event.target.closest('[data-workflow-remove-assignment]');
|
|
|
|
if (stateButton) {
|
|
if (!window.confirm('Delete this state and related transitions/assignments?')) return;
|
|
|
|
saveWorkflowAction('DeleteState', {}, {
|
|
state_id: stateButton.dataset.workflowDeleteState
|
|
});
|
|
}
|
|
|
|
if (transitionButton) {
|
|
saveWorkflowAction('DeleteTransition', {}, {
|
|
transition_id: transitionButton.dataset.workflowDeleteTransition
|
|
});
|
|
}
|
|
|
|
if (assignmentButton) {
|
|
saveWorkflowAction('RemoveAssignedState', {}, {
|
|
assignment_id: assignmentButton.dataset.workflowRemoveAssignment
|
|
});
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('[data-admin-option-form]').forEach((form) => {
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const saved = await saveAdminOptionForm(form.dataset.adminOptionForm, form);
|
|
|
|
if (saved) form.reset();
|
|
});
|
|
});
|
|
|
|
document.getElementById('adminTaskTypeSelect')?.addEventListener('change', (event) => {
|
|
selectedAdminTaskType = event.target.value || null;
|
|
renderAdminOptions();
|
|
});
|
|
|
|
document.getElementById('adminUserSelect')?.addEventListener('change', (event) => {
|
|
selectedAdminUser = event.target.value || null;
|
|
renderAdminOptions();
|
|
});
|
|
|
|
document.getElementById('adminCustomFieldForm')?.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const saved = await saveAdminCustomFieldForm(event.currentTarget);
|
|
|
|
if (saved) event.currentTarget.reset();
|
|
});
|
|
|
|
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
|
|
const form = event.target.closest('[data-admin-option-edit]');
|
|
|
|
if (!form) return;
|
|
|
|
event.preventDefault();
|
|
await saveAdminOptionForm(form.dataset.adminOptionEdit, form, form.dataset.optionId);
|
|
});
|
|
|
|
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
|
|
const form = event.target.closest('[data-admin-custom-field-edit]');
|
|
|
|
if (!form) return;
|
|
|
|
event.preventDefault();
|
|
await saveAdminCustomFieldForm(form, form.dataset.adminCustomFieldEdit);
|
|
});
|
|
|
|
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
|
|
const deleteButton = event.target.closest('[data-admin-option-delete]');
|
|
|
|
if (!deleteButton) return;
|
|
|
|
if (!window.confirm('Delete this option?')) return;
|
|
|
|
deleteAdminOption(deleteButton.dataset.adminOptionDelete, deleteButton.dataset.optionId);
|
|
});
|
|
|
|
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
|
|
const deleteButton = event.target.closest('[data-admin-custom-field-delete]');
|
|
|
|
if (!deleteButton) return;
|
|
|
|
if (!window.confirm('Delete this custom field?')) return;
|
|
|
|
deleteAdminCustomField(deleteButton.dataset.adminCustomFieldDelete);
|
|
});
|
|
|
|
document.getElementById('adminUsersPanel')?.addEventListener('change', async (event) => {
|
|
const checkbox = event.target.closest('[data-admin-user-right]');
|
|
|
|
if (!checkbox) return;
|
|
|
|
checkbox.disabled = true;
|
|
const saved = await setAdminUserRight(
|
|
checkbox.dataset.userId,
|
|
checkbox.dataset.rightId,
|
|
checkbox.checked
|
|
);
|
|
|
|
if (!saved) {
|
|
checkbox.checked = !checkbox.checked;
|
|
checkbox.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.getElementById('adminUsersPanel')?.addEventListener('submit', async (event) => {
|
|
const form = event.target.closest('#adminProjectAccessForm');
|
|
|
|
if (!form) return;
|
|
|
|
event.preventDefault();
|
|
|
|
if (!selectedAdminUser) return;
|
|
|
|
const data = Object.fromEntries(new FormData(form));
|
|
if (!data.project_id) return;
|
|
|
|
await grantAdminProjectAccess(selectedAdminUser, data.project_id);
|
|
});
|
|
|
|
document.getElementById('adminUsersPanel')?.addEventListener('click', async (event) => {
|
|
const deleteButton = event.target.closest('[data-admin-project-access-delete]');
|
|
|
|
if (!deleteButton) return;
|
|
|
|
await revokeAdminProjectAccess(deleteButton.dataset.adminProjectAccessDelete);
|
|
});
|
|
|
|
document.getElementById('profileMenuButton')?.addEventListener('click', () => {
|
|
loadProfile();
|
|
});
|
|
|
|
document.getElementById('taskEditButton')?.addEventListener('click', () => {
|
|
if (!canEditTasks()) return;
|
|
openTaskEditPopup();
|
|
});
|
|
|
|
document.getElementById('taskView')?.addEventListener('click', (event) => {
|
|
const customField = event.target.closest('[data-custom-field-id]');
|
|
|
|
if (customField) {
|
|
if (!canEditTasks()) return;
|
|
openCustomFieldInlineEditor(customField);
|
|
return;
|
|
}
|
|
|
|
const statusButton = event.target.closest('#taskStatusButton');
|
|
|
|
if (statusButton) {
|
|
const menu = document.getElementById('taskStatusMenu');
|
|
if (menu) menu.hidden = !menu.hidden;
|
|
return;
|
|
}
|
|
|
|
const transitionButton = event.target.closest('[data-task-transition]');
|
|
|
|
if (transitionButton) {
|
|
transitionCurrentTask(transitionButton.dataset.taskTransition);
|
|
}
|
|
});
|
|
|
|
document.getElementById('taskView')?.addEventListener('keydown', (event) => {
|
|
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(event.target.tagName)) return;
|
|
|
|
const customField = event.target.closest('[data-custom-field-id]');
|
|
|
|
if (!customField || !['Enter', ' '].includes(event.key)) return;
|
|
|
|
event.preventDefault();
|
|
if (!canEditTasks()) return;
|
|
openCustomFieldInlineEditor(customField);
|
|
});
|
|
|
|
document.getElementById('taskCommentButton')?.addEventListener('click', () => {
|
|
document.getElementById('taskCommentInput')?.focus();
|
|
});
|
|
|
|
document.getElementById('versionEditButton')?.addEventListener('click', () => {
|
|
if (!canEditVersions()) return;
|
|
openVersionEditPopup();
|
|
});
|
|
|
|
document.getElementById('projectEditButton')?.addEventListener('click', () => {
|
|
if (!canEditProjects()) return;
|
|
openProjectEditPopup();
|
|
});
|
|
|
|
document.querySelectorAll('[data-version-task-sort]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const field = button.dataset.versionTaskSort;
|
|
|
|
if (versionTaskSort.field === field) {
|
|
versionTaskSort.direction = versionTaskSort.direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
versionTaskSort.field = field;
|
|
versionTaskSort.direction = 'asc';
|
|
}
|
|
|
|
if (currentVersion) {
|
|
loadVersionTasks(currentVersion.id, 1);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[data-project-task-sort]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
const field = button.dataset.projectTaskSort;
|
|
|
|
if (projectTaskSort.field === field) {
|
|
projectTaskSort.direction = projectTaskSort.direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
projectTaskSort.field = field;
|
|
projectTaskSort.direction = 'asc';
|
|
}
|
|
|
|
if (currentProjectTaskProject) {
|
|
loadProjectTasks(currentProjectTaskProject, 1);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const pageButton = event.target.closest('[data-task-page]');
|
|
|
|
if (!pageButton || pageButton.disabled) return;
|
|
|
|
const page = Number(pageButton.dataset.page);
|
|
|
|
if (!Number.isFinite(page) || page < 1) return;
|
|
|
|
if (pageButton.dataset.taskPage === 'version' && currentVersion) {
|
|
loadVersionTasks(currentVersion.id, page);
|
|
}
|
|
|
|
if (pageButton.dataset.taskPage === 'project' && currentProjectTaskProject) {
|
|
loadProjectTasks(currentProjectTaskProject, page);
|
|
}
|
|
});
|
|
|
|
const createProjectForm = document.getElementById('createProjectForm');
|
|
|
|
if (createProjectForm) {
|
|
createProjectForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const data = Object.fromEntries(new FormData(createProjectForm));
|
|
const mode = createProjectForm.dataset.mode ?? 'create';
|
|
const projectId = document.getElementById('projectFormId').value;
|
|
|
|
const result = await apiPost('/api/project.php', {
|
|
api: mode === 'edit' ? 'Edit' : 'Create',
|
|
project_id: mode === 'edit' ? projectId : undefined
|
|
}, data);
|
|
|
|
if (result.success) {
|
|
taskLookupCache.projectsById.delete(result.project_id);
|
|
closePopups();
|
|
createProjectForm.reset();
|
|
projectTreePromise = loadProjectTree();
|
|
|
|
if (mode === 'edit') {
|
|
await loadProject(projectId, false);
|
|
}
|
|
} else {
|
|
alert(result.error || 'Could not create project.');
|
|
}
|
|
});
|
|
}
|
|
|
|
const createVersionForm = document.getElementById('createVersionForm');
|
|
|
|
if (createVersionForm) {
|
|
createVersionForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const data = Object.fromEntries(new FormData(createVersionForm));
|
|
const mode = createVersionForm.dataset.mode ?? 'create';
|
|
const versionId = data.version_id;
|
|
const projectId = data.project_id;
|
|
delete data.version_id;
|
|
|
|
if (mode === 'edit') {
|
|
delete data.project_id;
|
|
}
|
|
|
|
const result = await apiPost('/api/version.php', {
|
|
api: mode === 'edit' ? 'Edit' : 'Create',
|
|
version_id: mode === 'edit' ? versionId : undefined
|
|
}, data);
|
|
|
|
if (result.success) {
|
|
taskLookupCache.versionsByProject.delete(projectId);
|
|
closePopups();
|
|
projectTreePromise = loadProjectTree();
|
|
|
|
if (mode === 'edit') {
|
|
await loadVersion(versionId, false);
|
|
}
|
|
} else {
|
|
alert(result.error || 'Could not save version.');
|
|
}
|
|
});
|
|
}
|
|
|
|
const profileForm = document.getElementById('profileForm');
|
|
|
|
if (profileForm) {
|
|
document.getElementById('profilePictureInput')?.addEventListener('change', (event) => {
|
|
const file = event.target.files?.[0];
|
|
|
|
if (!file) {
|
|
renderProfileAvatar(currentProfileUser?.picture ?? null);
|
|
return;
|
|
}
|
|
|
|
document.getElementById('profileRemovePicture').checked = false;
|
|
|
|
const reader = new FileReader();
|
|
reader.addEventListener('load', () => renderProfileAvatar(reader.result));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
document.getElementById('profileRemovePicture')?.addEventListener('change', (event) => {
|
|
if (event.target.checked) {
|
|
document.getElementById('profilePictureInput').value = '';
|
|
renderProfileAvatar(null);
|
|
} else {
|
|
renderProfileAvatar(currentProfileUser?.picture ?? null);
|
|
}
|
|
});
|
|
|
|
profileForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const password = document.getElementById('profilePassword').value;
|
|
const passwordConfirm = document.getElementById('profilePasswordConfirm').value;
|
|
const status = document.getElementById('profileStatus');
|
|
const submitButton = document.getElementById('profileSubmitButton');
|
|
|
|
if (password !== passwordConfirm) {
|
|
status.textContent = 'Passwords do not match.';
|
|
return;
|
|
}
|
|
|
|
const data = new FormData(profileForm);
|
|
|
|
if (!password) {
|
|
data.delete('password');
|
|
}
|
|
|
|
submitButton.disabled = true;
|
|
status.textContent = 'Saving...';
|
|
|
|
const result = await apiPostForm('/api/user.php', {
|
|
api: 'Edit',
|
|
user_id: getCurrentUserId()
|
|
}, data);
|
|
|
|
submitButton.disabled = false;
|
|
|
|
if (!result.success) {
|
|
status.textContent = result.error || 'Could not save profile.';
|
|
return;
|
|
}
|
|
|
|
const userResult = await apiGet('/api/user.php', {
|
|
api: 'UserInfo',
|
|
user_id: getCurrentUserId(),
|
|
_: Date.now()
|
|
});
|
|
|
|
if (userResult.success) {
|
|
taskLookupCache.users = null;
|
|
updateAccountProfile(userResult.user);
|
|
applyTheme(userResult.user.settings?.theme ?? 'dark');
|
|
renderProfile(userResult.user);
|
|
status.textContent = 'Saved.';
|
|
} else {
|
|
status.textContent = 'Saved. Refresh to see all changes.';
|
|
}
|
|
});
|
|
}
|
|
|
|
const createTaskForm = document.getElementById('createTaskForm');
|
|
|
|
if (createTaskForm) {
|
|
document.getElementById('taskFormProjectId')?.addEventListener('change', (event) => {
|
|
populateTaskRelationSelects(event.target.value.trim());
|
|
});
|
|
|
|
createTaskForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const data = Object.fromEntries(new FormData(createTaskForm));
|
|
const mode = createTaskForm.dataset.mode ?? 'create';
|
|
const taskId = data.task_id;
|
|
delete data.task_id;
|
|
|
|
if (mode === 'edit') {
|
|
delete data.project_id;
|
|
}
|
|
|
|
const result = await apiPost('/api/task.php', {
|
|
api: mode === 'edit' ? 'Edit' : 'Create',
|
|
task_id: mode === 'edit' ? taskId : undefined
|
|
}, data);
|
|
|
|
if (result.success) {
|
|
closePopups();
|
|
projectTreePromise = loadProjectTree();
|
|
loadTask(mode === 'edit' ? taskId : result.task_id, mode !== 'edit');
|
|
} else {
|
|
alert(result.error || 'Could not save task.');
|
|
}
|
|
});
|
|
}
|
|
|
|
const taskCommentForm = document.getElementById('taskCommentForm');
|
|
|
|
if (taskCommentForm) {
|
|
taskCommentForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
const input = document.getElementById('taskCommentInput');
|
|
const status = document.getElementById('taskCommentStatus');
|
|
const comment = input.value.trim();
|
|
|
|
if (!comment) {
|
|
status.textContent = 'Comment cannot be empty.';
|
|
return;
|
|
}
|
|
|
|
status.textContent = 'Saving...';
|
|
|
|
try {
|
|
await submitTaskComment(comment);
|
|
input.value = '';
|
|
status.textContent = 'Saved.';
|
|
} catch (error) {
|
|
status.textContent = error.message;
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById('taskCommentList')?.addEventListener('click', (event) => {
|
|
const replyButton = event.target.closest('[data-comment-reply]');
|
|
const editButton = event.target.closest('[data-comment-edit]');
|
|
const deleteButton = event.target.closest('[data-comment-delete]');
|
|
const cancelButton = event.target.closest('[data-comment-reply-cancel]');
|
|
const editCancelButton = event.target.closest('[data-comment-edit-cancel]');
|
|
|
|
if (replyButton) {
|
|
const commentCard = replyButton.closest('.task-comment');
|
|
const slot = commentCard?.querySelector('.task-comment-reply-slot');
|
|
|
|
if (!slot || slot.querySelector('form')) return;
|
|
|
|
slot.innerHTML = `
|
|
<form class="task-comment-reply-form" data-comment-reply-form="${replyButton.dataset.commentReply}">
|
|
<textarea class="form-control" name="comment" rows="2" placeholder="Write a reply..."></textarea>
|
|
<div class="task-comment-reply-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit">Reply</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-reply-cancel>Cancel</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
slot.querySelector('textarea')?.focus();
|
|
}
|
|
|
|
if (editButton) {
|
|
const commentCard = editButton.closest('.task-comment');
|
|
const slot = commentCard?.querySelector('.task-comment-edit-slot');
|
|
|
|
if (!slot || slot.querySelector('form')) return;
|
|
|
|
slot.innerHTML = `
|
|
<form class="task-comment-edit-form" data-comment-edit-form="${editButton.dataset.commentEdit}">
|
|
<textarea class="form-control" name="comment" rows="3">${escapeHtml(commentCard.dataset.commentText ?? '')}</textarea>
|
|
<div class="task-comment-reply-actions">
|
|
<button class="btn btn-sm btn-primary" type="submit">Save</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-edit-cancel>Cancel</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
slot.querySelector('textarea')?.focus();
|
|
}
|
|
|
|
if (deleteButton) {
|
|
if (!window.confirm('Delete this comment?')) return;
|
|
|
|
deleteTaskComment(deleteButton.dataset.commentDelete).catch((error) => {
|
|
alert(error.message);
|
|
});
|
|
}
|
|
|
|
if (cancelButton) {
|
|
cancelButton.closest('.task-comment-reply-slot').innerHTML = '';
|
|
}
|
|
|
|
if (editCancelButton) {
|
|
editCancelButton.closest('.task-comment-edit-slot').innerHTML = '';
|
|
}
|
|
});
|
|
|
|
document.getElementById('taskCommentList')?.addEventListener('submit', async (event) => {
|
|
const form = event.target.closest('[data-comment-reply-form], [data-comment-edit-form]');
|
|
|
|
if (!form) return;
|
|
|
|
event.preventDefault();
|
|
|
|
const textarea = form.querySelector('textarea');
|
|
const button = form.querySelector('button[type="submit"]');
|
|
const comment = textarea.value.trim();
|
|
const responseTo = form.dataset.commentReplyForm;
|
|
const editCommentId = form.dataset.commentEditForm;
|
|
|
|
if (!comment) return;
|
|
|
|
button.disabled = true;
|
|
|
|
try {
|
|
if (editCommentId) {
|
|
await updateTaskComment(editCommentId, comment);
|
|
} else {
|
|
await submitTaskComment(comment, responseTo);
|
|
}
|
|
} catch (error) {
|
|
alert(error.message);
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
});
|