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'); }); }); } 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 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; } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); }