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 = '
Loading projects...
'; const result = await apiGet('/api/project.php', { api: 'ListProjects' }); if (!result.success) { tree.innerHTML = '
Could not load projects.
'; return; } if (!result.projects.length) { tree.innerHTML = '
No projects yet.
'; 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 = ` ${escapeHtml(project?.name ?? projectId)} `; 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 = '
Loading...
'; 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 = '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 = '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 = '
Project not found.
'; 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 = '
Loading tasks...
'; 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 = '
Could not load tasks.
'; 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 = '
Task not found.
'; return; } await renderTask(result.task); await loadTaskComments(result.task.id); await ensureProjectOpen(result.task.project); } async function renderTask(task) { currentTask = task; const [types, priorities, versions, reporter, assignee] = await Promise.all([ getTaskTypes(), getTaskPriorities(), getProjectVersions(task.project), getUserInfo(task.reporter), task.assignee ? getUserInfo(task.assignee) : null ]); const type = types.find((item) => String(item.id) === String(task.type)) ?? null; const priority = priorities.find((item) => String(item.id) === String(task.priority)) ?? null; const fixVersion = versions.find((item) => String(item.id) === String(task.fix_version)) ?? null; setEditableText('taskKey', task.id); setEditableText('taskTitle', task.title, task.title); setEditableText('taskDescription', task.description, task.description, 'No description provided.'); setText('taskProject', task.project); document.getElementById('taskReporter').innerHTML = renderUser(reporter); setEditableHtml('taskAssignee', renderUser(assignee), task.assignee ?? '', 'Unassigned'); setEditableText('taskFixVersion', fixVersion ? fixVersion.name : null, task.fix_version ?? '', 'No fix version'); setText('taskCreated', task.created_date); setText('taskUpdated', task.last_changed); setEditableHtml('taskType', renderMetaBadge(type), task.type); setEditableHtml('taskPriority', renderMetaBadge(priority), task.priority); renderTaskStatus(task); renderTaskCustomFields(task.custom_fields ?? []); } function renderTaskStatus(task) { const slot = document.getElementById('taskStatusSlot'); if (!slot) return; slot.innerHTML = ''; if (!task.status_state) return; const isCurrentAssignee = String(task.assignee ?? '') === String(getCurrentUserId()); const canTransition = (canEditTasks() || appFlag('isAdmin') || isCurrentAssignee) && (task.status_transitions ?? []).length > 0; const statusStyle = `--task-status-color: ${escapeHtml(task.status_state.color ?? '#6c757d')}`; if (!canTransition) { slot.innerHTML = ` ${escapeHtml(task.status_state.name)} `; return; } slot.innerHTML = `
`; } function renderTaskCustomFields(fields) { const panel = document.getElementById('taskCustomFieldsPanel'); const list = document.getElementById('taskCustomFieldList'); if (!panel || !list) return; const canEdit = canEditTasks(); panel.hidden = fields.length === 0; list.innerHTML = fields.map((field) => `
${escapeHtml(field.name)} ${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}
`).join(''); } async function loadTaskComments(taskId) { const list = document.getElementById('taskCommentList'); const empty = document.getElementById('taskCommentEmpty'); if (!list || !empty) return; list.innerHTML = ''; empty.hidden = true; currentTaskComments = []; setText('taskCommentStatus', ''); const result = await apiGet('/api/task.php', { api: 'ListComments', task_id: taskId, _: Date.now() }); if (!result.success) { list.innerHTML = '
Could not load comments.
'; return; } currentTaskComments = result.comments ?? []; renderTaskComments(); } function renderTaskComments() { const list = document.getElementById('taskCommentList'); const empty = document.getElementById('taskCommentEmpty'); if (!list || !empty) return; list.innerHTML = ''; empty.hidden = currentTaskComments.length > 0; const commentsByParent = groupCommentsByParent(currentTaskComments); const renderComment = (comment, depth = 0) => { const user = { id: comment.commenter, name: comment.commenter_name, email: comment.commenter_email, picture: comment.commenter_picture }; const article = document.createElement('article'); const canManageComment = String(comment.commenter) === String(getCurrentUserId()); const manageActions = canManageComment ? ` ` : ''; article.className = `task-comment${comment.response_to ? ' is-reply' : ''}`; article.style.setProperty('--comment-depth', String(Math.min(depth, 8))); article.dataset.commentId = comment.id; article.dataset.commentText = comment.comment; article.innerHTML = `
${renderUser(user)} #${escapeHtml(comment.id)}
${escapeHtml(comment.comment)}
${manageActions}
`; list.appendChild(article); const children = commentsByParent.get(Number(comment.id)) ?? []; children.forEach((child) => renderComment(child, depth + 1)); }; const rootComments = commentsByParent.get(null) ?? []; rootComments.forEach((comment) => renderComment(comment)); } function groupCommentsByParent(comments) { const knownIds = new Set(comments.map((comment) => Number(comment.id))); const groups = new Map([[null, []]]); [...comments] .sort((first, second) => Number(first.id) - Number(second.id)) .forEach((comment) => { const parentId = comment.response_to && knownIds.has(Number(comment.response_to)) ? Number(comment.response_to) : null; if (!groups.has(parentId)) { groups.set(parentId, []); } groups.get(parentId).push(comment); }); return groups; } async function submitTaskComment(comment, responseTo = null) { if (!currentTask) return; const result = await apiPost('/api/task.php', { api: 'CreateComment', task_id: currentTask.id }, { comment, response_to: responseTo }); if (!result.success) { throw new Error(result.error || 'Could not save comment.'); } await loadTaskComments(currentTask.id); } async function updateTaskComment(commentId, comment) { if (!currentTask) return; const result = await apiPost('/api/task.php', { api: 'EditComment', task_id: currentTask.id, comment_id: commentId }, { comment }); if (!result.success) { throw new Error(result.error || 'Could not update comment.'); } await loadTaskComments(currentTask.id); } async function deleteTaskComment(commentId) { if (!currentTask) return; const result = await apiPost('/api/task.php', { api: 'DeleteComment', task_id: currentTask.id, comment_id: commentId }); if (!result.success) { throw new Error(result.error || 'Could not delete comment.'); } await loadTaskComments(currentTask.id); } 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 ? `` : ''; return ` ${icon} ${escapeHtml(item.name)} `; } 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 = ` `; if (input.tagName !== 'SELECT') { form.appendChild(actions); } element.appendChild(form); input.focus(); if (input.select && input.tagName !== 'SELECT') { input.select(); } let editorClosed = false; const restore = () => { if (editorClosed) return; editorClosed = true; element.innerHTML = originalHtml; element.className = originalClasses.join(' '); }; let saving = false; const save = async () => { if (saving) return; const nextValue = input.value; if (String(nextValue) === String(currentValue)) { restore(); return; } saving = true; element.classList.add('is-saving'); const result = await apiPost('/api/task.php', { api: 'Edit', task_id: currentTask.id }, { [field]: nextValue }); if (!result.success) { alert(result.error || 'Could not update task.'); saving = false; restore(); return; } editorClosed = true; element.classList.remove('is-saving'); currentTask = { ...currentTask, [field]: nextValue === '' ? null : nextValue }; if (field === 'type') { await loadTask(currentTask.id, false); return; } await renderTask(currentTask); }; form.addEventListener('submit', async (event) => { event.preventDefault(); await save(); }); form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); restore(); }); input.addEventListener('keydown', async (event) => { if (event.key === 'Escape') { event.preventDefault(); restore(); } if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); await save(); } }); if (input.tagName === 'SELECT') { input.addEventListener('change', save); input.addEventListener('blur', () => { window.setTimeout(() => { if (editorClosed) return; if (element.classList.contains('is-saving')) return; if (element.contains(document.activeElement)) return; restore(); }, 120); }); } } async function createTaskInlineInput(field, currentValue) { if (field === 'description') { const textarea = document.createElement('textarea'); textarea.className = 'form-control task-inline-input'; textarea.rows = 5; textarea.value = currentValue; return textarea; } if (field === 'type') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getTaskTypes(), { includeEmpty: false, selectedValue: currentValue }); return select; } if (field === 'priority') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getTaskPriorities(), { includeEmpty: false, selectedValue: currentValue }); return select; } if (field === 'fix_version') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getProjectVersions(currentTask.project), { placeholder: 'No fix version', selectedValue: currentValue }); return select; } if (field === 'assignee') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; populateSelect(select, await getUsers(), { placeholder: 'Unassigned', selectedValue: currentValue }); return select; } const input = document.createElement('input'); input.className = 'form-control task-inline-input'; input.type = 'text'; input.value = currentValue; input.required = field === 'title'; return input; } function createCustomFieldInput(type, currentValue) { if (type === 'boolean') { const select = document.createElement('select'); select.className = 'form-select task-inline-select'; [ { id: '', name: 'No value' }, { id: 'true', name: 'True' }, { id: 'false', name: 'False' } ].forEach((item) => { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name; option.selected = String(item.id) === String(currentValue); select.appendChild(option); }); return select; } if (type === 'text' || type === 'json') { const textarea = document.createElement('textarea'); textarea.className = 'form-control task-inline-input'; textarea.rows = type === 'json' ? 4 : 3; textarea.value = currentValue; return textarea; } const input = document.createElement('input'); input.className = 'form-control task-inline-input'; input.type = type === 'date' ? 'date' : 'text'; input.inputMode = ['int', 'float'].includes(type) ? 'decimal' : ''; input.value = currentValue; return input; } async function openCustomFieldInlineEditor(element) { if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return; const fieldId = element.dataset.customFieldId; const fieldType = element.dataset.customFieldType ?? 'string'; const currentValue = element.dataset.currentValue ?? ''; const originalHtml = element.innerHTML; const originalClasses = Array.from(element.classList); element.classList.remove('is-empty'); element.innerHTML = ''; const form = document.createElement('form'); form.className = 'task-inline-form'; const input = createCustomFieldInput(fieldType, currentValue); form.appendChild(input); const actions = document.createElement('div'); actions.className = 'task-inline-actions'; actions.innerHTML = ` `; form.appendChild(actions); element.appendChild(form); input.focus(); if (input.select && input.tagName !== 'SELECT') { input.select(); } let closed = false; const restore = () => { if (closed) return; closed = true; element.innerHTML = originalHtml; element.className = originalClasses.join(' '); }; const save = async () => { const nextValue = input.value; if (String(nextValue) === String(currentValue)) { restore(); return; } element.classList.add('is-saving'); const result = await apiPost('/api/task.php', { api: 'SetCustomFieldValue', task_id: currentTask.id }, { field_id: fieldId, value: nextValue }); if (!result.success) { alert(result.error || 'Could not update custom field.'); restore(); return; } closed = true; await loadTask(currentTask.id, false); }; form.addEventListener('submit', async (event) => { event.preventDefault(); await save(); }); form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); restore(); }); input.addEventListener('keydown', async (event) => { if (event.key === 'Escape') { event.preventDefault(); restore(); } if (event.key === 'Enter' && input.tagName !== 'TEXTAREA') { event.preventDefault(); await save(); } if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); await save(); } }); } async function transitionCurrentTask(transitionId) { if (!currentTask || !transitionId) return; const result = await apiPost('/api/task.php', { api: 'TransitionStatus', task_id: currentTask.id }, { transition_id: transitionId }); if (!result.success) { alert(result.error || 'Could not transition task.'); return; } await loadTask(currentTask.id, false); } 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 = '
Could not load profile.
'; 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 = ``; return; } preview.innerHTML = ` `; } 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 = '
Loading tasks...
'; 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 = '
Could not load tasks.
'; 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 = ` ${escapeHtml(task.id)} ${escapeHtml(task.title || '-')} ${renderMetaBadge(task.typeOption)} ${renderMetaBadge(task.priorityOption)} ${renderUser(task.assigneeUser)} ${renderTaskTableStatus(task)} `; button.addEventListener('click', () => loadTask(task.id)); list.appendChild(button); }); renderTaskTablePagination(kind); } function renderTaskTableStatus(task) { if (!task.statusName) return '-'; const color = task.statusColor || '#6c757d'; return ` ${escapeHtml(task.statusName)} `; } 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 = ` ${escapeHtml(start)}-${escapeHtml(end)} of ${escapeHtml(pagination.total)}
`; } 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 = ` `; 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 = ` `; 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 ? `` : ` `; return ` ${avatar} ${escapeHtml(user.name)} `; } 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 = ''; } 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 = '
Could not load workflow data.
'; 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) => `
${escapeHtml(state.name)}
`).join('') || '
No states yet.
'; const transitionList = document.getElementById('workflowTransitionList'); transitionList.innerHTML = workflowData.transitions.map((transition) => `
${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)} ${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)} ${escapeHtml(transition.action_name)}
`).join('') || '
No transitions yet.
'; const assignmentList = document.getElementById('workflowAssignmentList'); assignmentList.innerHTML = selectedAssignments.map((assignment) => `
${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}
`).join('') || '
No assigned states yet.
'; } 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 = '
Cytoscape.js could not be loaded.
'; 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 = '
Could not load task types.
'; } if (priorityList) { priorityList.innerHTML = '
Could not load priorities.
'; } if (userRightsList) { userRightsList.innerHTML = '
Could not load users.
'; } 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 = '
Create or select a task type first.
'; return; } container.innerHTML = `
${type.icon ? `` : ''}
`; } function renderAdminCustomFields() { const container = document.getElementById('adminCustomFieldList'); if (!container) return; if (!selectedAdminTaskType) { container.innerHTML = '
Select a task type first.
'; return; } const fields = (adminOptionsData.custom_fields ?? []) .filter((field) => String(field.task_type) === String(selectedAdminTaskType)); container.innerHTML = fields.map((field) => `
`).join('') || '
No custom fields yet.
'; } function renderAdminOptionList(kind, options, container) { if (!container) return; container.innerHTML = options.map((option) => `
${option.icon ? `` : ''}
`).join('') || '
No entries yet.
'; } 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 = '
Select a user first.
'; return; } if (!rights.length) { container.innerHTML = '
No rights configured yet.
'; 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 = `
${renderUser(selectedUser)}

Rights

${rights.map((right) => { const checked = userHasAdminRight(selectedUser.id, right.id); const isSelfAdminRight = appFlag('isAdmin') && String(selectedUser.id) === String(getCurrentUserId()) && String(right.name) === 'Admin'; return ` `; }).join('')}

Project Access

${explicitAccess.map((access) => { const project = getAdminProject(access.project_id); return `
${escapeHtml(project?.name ?? access.project_id)} ${escapeHtml(access.project_id)}
`; }).join('') || '
No explicit project access yet.
'}
${ownerProjects.length ? `
Owner access ${ownerProjects.map((project) => ` ${escapeHtml(project.name)} ${escapeHtml(project.id)} `).join('')}
` : ''}
`; } 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 = `
`; 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 = `
`; 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; } }); });