525 lines
14 KiB
JavaScript
525 lines
14 KiB
JavaScript
async function apiGet(path, params = {}) {
|
|
const url = new URL(path, window.location.origin);
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
const response = await fetch(url.toString(), {
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Cache-Control': 'no-cache'
|
|
}
|
|
});
|
|
return response.json();
|
|
}
|
|
|
|
async function apiPost(path, params = {}, body = {}) {
|
|
const url = new URL(path, window.location.origin);
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-cache'
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
console.error('API did not return JSON:', text);
|
|
|
|
return {
|
|
success: false,
|
|
error: 'API did not return JSON. Check console for PHP error.'
|
|
};
|
|
}
|
|
}
|
|
|
|
async function apiPostForm(path, params = {}, formData) {
|
|
const url = new URL(path, window.location.origin);
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Cache-Control': 'no-cache'
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
console.error('API did not return JSON:', text);
|
|
|
|
return {
|
|
success: false,
|
|
error: 'API did not return JSON. Check console for PHP error.'
|
|
};
|
|
}
|
|
}
|
|
|
|
let currentTask = null;
|
|
let currentVersion = null;
|
|
let currentProject = null;
|
|
let currentProfileUser = null;
|
|
let currentVersionTasks = [];
|
|
let currentProjectTasks = [];
|
|
let currentProjectTaskProject = null;
|
|
let currentTaskComments = [];
|
|
let workflowData = null;
|
|
let workflowGraph = null;
|
|
let selectedWorkflowTaskType = null;
|
|
let adminOptionsData = null;
|
|
let selectedAdminTaskType = null;
|
|
let selectedAdminUser = null;
|
|
let projectTreePromise = null;
|
|
|
|
const versionTaskSort = {
|
|
field: 'id',
|
|
direction: 'asc'
|
|
};
|
|
|
|
const projectTaskSort = {
|
|
field: 'id',
|
|
direction: 'asc'
|
|
};
|
|
|
|
const taskTablePageSize = 100;
|
|
let versionTaskPagination = null;
|
|
let projectTaskPagination = null;
|
|
|
|
const themeStyles = {
|
|
white: 'app/css/white_mode.css',
|
|
dark: 'app/css/dark_mode.css',
|
|
purple: 'app/css/purple_mode.css',
|
|
green: 'app/css/green_mode.css',
|
|
beige: 'app/css/beige_mode.css'
|
|
};
|
|
|
|
const customFieldTypes = [
|
|
{ id: 'string', name: 'String' },
|
|
{ id: 'int', name: 'Int' },
|
|
{ id: 'float', name: 'Float' },
|
|
{ id: 'date', name: 'Date' },
|
|
{ id: 'boolean', name: 'Boolean' },
|
|
{ id: 'text', name: 'Text' },
|
|
{ id: 'json', name: 'JSON' }
|
|
];
|
|
|
|
const taskLookupCache = {
|
|
types: null,
|
|
priorities: null,
|
|
users: null,
|
|
versionsByProject: new Map(),
|
|
projectsById: new Map(),
|
|
projectBlocksById: new Map()
|
|
};
|
|
|
|
function appFlag(name) {
|
|
return document.querySelector('.kiln-app')?.dataset[name] === '1';
|
|
}
|
|
|
|
function canEditTasks() {
|
|
return appFlag('canEditTasks');
|
|
}
|
|
|
|
function canEditVersions() {
|
|
return appFlag('canEditVersions');
|
|
}
|
|
|
|
function canEditProjects() {
|
|
return appFlag('canEditProjects');
|
|
}
|
|
|
|
function applyPermissionVisibility() {
|
|
const visibility = [
|
|
['taskEditButton', canEditTasks()],
|
|
['versionEditButton', canEditVersions()],
|
|
['projectEditButton', canEditProjects()]
|
|
];
|
|
|
|
visibility.forEach(([id, allowed]) => {
|
|
const element = document.getElementById(id);
|
|
if (element) element.hidden = !allowed;
|
|
});
|
|
|
|
[
|
|
['.task-editable', canEditTasks()],
|
|
['.version-editable', canEditVersions()],
|
|
['.project-editable', canEditProjects()]
|
|
].forEach(([selector, allowed]) => {
|
|
document.querySelectorAll(selector).forEach((element) => {
|
|
element.classList.toggle('is-readonly', !allowed);
|
|
|
|
if (allowed) {
|
|
element.setAttribute('tabindex', '0');
|
|
element.removeAttribute('aria-disabled');
|
|
return;
|
|
}
|
|
|
|
element.removeAttribute('title');
|
|
element.removeAttribute('tabindex');
|
|
element.setAttribute('aria-disabled', 'true');
|
|
});
|
|
});
|
|
}
|
|
|
|
function showView(viewId) {
|
|
document.querySelectorAll('#viewer > section').forEach((section) => {
|
|
section.hidden = true;
|
|
});
|
|
|
|
const view = document.getElementById(viewId);
|
|
|
|
if (!view) {
|
|
console.error(`Missing view: ${viewId}`);
|
|
return null;
|
|
}
|
|
|
|
view.hidden = false;
|
|
return view;
|
|
}
|
|
|
|
function setText(id, value) {
|
|
const element = document.getElementById(id);
|
|
|
|
if (element) {
|
|
element.textContent = value ?? '';
|
|
}
|
|
}
|
|
|
|
function setEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
|
const element = document.getElementById(id);
|
|
|
|
if (!element) return;
|
|
|
|
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
|
element.textContent = hasValue ? displayValue : emptyText;
|
|
element.dataset.currentValue = rawValue ?? '';
|
|
element.classList.remove('is-saving');
|
|
element.classList.toggle('is-empty', !hasValue);
|
|
}
|
|
|
|
function setEditableHtml(id, displayHtml, rawValue, emptyText = '-') {
|
|
const element = document.getElementById(id);
|
|
|
|
if (!element) return;
|
|
|
|
const hasValue = displayHtml !== null && displayHtml !== undefined && displayHtml !== '-';
|
|
element.innerHTML = hasValue ? displayHtml : emptyText;
|
|
element.dataset.currentValue = rawValue ?? '';
|
|
element.classList.remove('is-saving');
|
|
element.classList.toggle('is-empty', !hasValue || rawValue === '' || rawValue === null);
|
|
}
|
|
|
|
function setVersionEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
|
const element = document.getElementById(id);
|
|
|
|
if (!element) return;
|
|
|
|
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
|
element.textContent = hasValue ? displayValue : emptyText;
|
|
element.dataset.currentValue = rawValue ?? '';
|
|
element.classList.remove('is-saving');
|
|
element.classList.toggle('is-empty', !hasValue);
|
|
}
|
|
|
|
function renderMetaBadge(item) {
|
|
if (!item) {
|
|
return '-';
|
|
}
|
|
|
|
const icon = item.icon
|
|
? `<img src="${item.icon}" alt="" class="meta-icon">`
|
|
: '';
|
|
|
|
return `
|
|
<span class="meta-badge">
|
|
${icon}
|
|
<span>${escapeHtml(item.name)}</span>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
async function getTaskTypes() {
|
|
if (taskLookupCache.types) {
|
|
return taskLookupCache.types;
|
|
}
|
|
|
|
const result = await apiGet('/api/task.php', {
|
|
api: 'ListTypes'
|
|
});
|
|
|
|
taskLookupCache.types = result.success ? result.types : [];
|
|
return taskLookupCache.types;
|
|
}
|
|
|
|
async function getTaskPriorities() {
|
|
if (taskLookupCache.priorities) {
|
|
return taskLookupCache.priorities;
|
|
}
|
|
|
|
const result = await apiGet('/api/task.php', {
|
|
api: 'ListPriorities'
|
|
});
|
|
|
|
taskLookupCache.priorities = result.success ? result.priorities : [];
|
|
return taskLookupCache.priorities;
|
|
}
|
|
|
|
async function getUsers() {
|
|
if (taskLookupCache.users) {
|
|
return taskLookupCache.users;
|
|
}
|
|
|
|
const result = await apiGet('/api/user.php', {
|
|
api: 'ListUsers'
|
|
});
|
|
|
|
taskLookupCache.users = result.success ? result.users : [];
|
|
return taskLookupCache.users;
|
|
}
|
|
|
|
async function getProjectVersions(projectId) {
|
|
if (taskLookupCache.versionsByProject.has(projectId)) {
|
|
return taskLookupCache.versionsByProject.get(projectId);
|
|
}
|
|
|
|
const result = await apiGet('/api/version.php', {
|
|
api: 'ListVersions',
|
|
project_id: projectId
|
|
});
|
|
|
|
if (!result.success) {
|
|
taskLookupCache.versionsByProject.set(projectId, []);
|
|
return [];
|
|
}
|
|
|
|
const versions = [];
|
|
|
|
for (const versionId of result.versions) {
|
|
const versionInfo = await apiGet('/api/version.php', {
|
|
api: 'VersionInfo',
|
|
version_id: versionId
|
|
});
|
|
|
|
if (versionInfo.success && versionInfo.version) {
|
|
versions.push(versionInfo.version);
|
|
}
|
|
}
|
|
|
|
taskLookupCache.versionsByProject.set(projectId, versions);
|
|
return versions;
|
|
}
|
|
|
|
async function populateTaskOptionSelects() {
|
|
const [types, priorities] = await Promise.all([
|
|
getTaskTypes(),
|
|
getTaskPriorities()
|
|
]);
|
|
|
|
populateSelect(document.getElementById('createTaskType'), types, {
|
|
placeholder: 'Select type'
|
|
});
|
|
|
|
populateSelect(document.getElementById('createTaskPriority'), priorities, {
|
|
placeholder: 'Select priority'
|
|
});
|
|
}
|
|
|
|
async function populateTaskProjectSelect(selectedProjectId = '') {
|
|
const result = await apiGet('/api/project.php', {
|
|
api: 'ListProjects'
|
|
});
|
|
|
|
const projects = [];
|
|
|
|
if (result.success) {
|
|
for (const projectId of result.projects) {
|
|
const project = await getProjectInfo(projectId);
|
|
|
|
projects.push({
|
|
id: projectId,
|
|
name: project?.name ?? projectId
|
|
});
|
|
}
|
|
}
|
|
|
|
populateSelect(document.getElementById('taskFormProjectId'), projects, {
|
|
placeholder: 'Select project',
|
|
selectedValue: selectedProjectId
|
|
});
|
|
}
|
|
|
|
async function populateTaskRelationSelects(projectId = '', selected = {}) {
|
|
const users = await getUsers();
|
|
|
|
populateSelect(document.getElementById('taskFormAssignee'), users, {
|
|
placeholder: 'Unassigned',
|
|
selectedValue: selected.assignee ?? ''
|
|
});
|
|
|
|
const versions = projectId ? await getProjectVersions(projectId.toUpperCase()) : [];
|
|
|
|
populateSelect(document.getElementById('taskFormFixVersion'), versions, {
|
|
placeholder: 'No fix version',
|
|
selectedValue: selected.fix_version ?? ''
|
|
});
|
|
}
|
|
|
|
function resetTaskPopup() {
|
|
const form = document.getElementById('createTaskForm');
|
|
|
|
if (!form) return;
|
|
|
|
form.reset();
|
|
form.dataset.mode = 'create';
|
|
document.getElementById('taskPopupTitle').textContent = 'Create Task';
|
|
document.getElementById('taskPopupSubmit').textContent = 'Create Task';
|
|
document.getElementById('taskFormTaskId').value = '';
|
|
document.getElementById('taskFormProjectId').disabled = false;
|
|
}
|
|
|
|
function resetVersionPopup() {
|
|
const form = document.getElementById('createVersionForm');
|
|
|
|
if (!form) return;
|
|
|
|
form.reset();
|
|
form.dataset.mode = 'create';
|
|
document.getElementById('versionPopupTitle').textContent = 'Create Version';
|
|
document.getElementById('versionPopupSubmit').textContent = 'Create Version';
|
|
document.getElementById('versionFormVersionId').value = '';
|
|
document.getElementById('versionFormProjectId').value = '';
|
|
}
|
|
|
|
function populateSelect(select, options, settings = {}) {
|
|
if (!select) return;
|
|
|
|
const placeholder = settings.placeholder ?? 'Select value';
|
|
const includeEmpty = settings.includeEmpty ?? true;
|
|
const selectedValue = settings.selectedValue ?? '';
|
|
|
|
select.innerHTML = '';
|
|
|
|
if (includeEmpty) {
|
|
const option = document.createElement('option');
|
|
option.value = '';
|
|
option.textContent = placeholder;
|
|
select.appendChild(option);
|
|
}
|
|
|
|
options.forEach((item) => {
|
|
const option = document.createElement('option');
|
|
option.value = item.id;
|
|
option.textContent = item.name ?? item.email ?? item.id;
|
|
option.selected = String(option.value) === String(selectedValue);
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
async function getUserInfo(userId) {
|
|
if (!userId || userId === '-') {
|
|
return null;
|
|
}
|
|
|
|
const result = await apiGet('/api/user.php', {
|
|
api: 'UserInfo',
|
|
user_id: userId
|
|
});
|
|
|
|
return result.success ? result.user : null;
|
|
}
|
|
|
|
function renderUser(user) {
|
|
if (!user) {
|
|
return '-';
|
|
}
|
|
|
|
const avatar = user.picture
|
|
? `<img src="${user.picture}" alt="" class="user-avatar">`
|
|
: `
|
|
<span class="user-avatar user-avatar-fallback">
|
|
<i class="fa-solid fa-user"></i>
|
|
</span>
|
|
`;
|
|
|
|
return `
|
|
<span class="meta-user">
|
|
${avatar}
|
|
<span>${escapeHtml(user.name)}</span>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
function getCurrentUserId() {
|
|
return document.querySelector('.kiln-app')?.dataset.currentUserId ?? '';
|
|
}
|
|
|
|
function updateAccountProfile(user) {
|
|
setText('accountName', user.name ?? '');
|
|
setText('accountEmail', user.email ?? '');
|
|
|
|
const accountButton = document.querySelector('.kiln-account-btn');
|
|
const accountName = document.getElementById('accountName');
|
|
|
|
if (!accountButton || !accountName) return;
|
|
|
|
document.getElementById('accountAvatarImage')?.remove();
|
|
document.getElementById('accountAvatarFallback')?.remove();
|
|
|
|
const avatar = document.createElement(user.picture ? 'img' : 'span');
|
|
avatar.id = user.picture ? 'accountAvatarImage' : 'accountAvatarFallback';
|
|
avatar.className = user.picture ? 'user-avatar' : 'user-avatar user-avatar-fallback';
|
|
|
|
if (user.picture) {
|
|
avatar.src = user.picture;
|
|
avatar.alt = '';
|
|
} else {
|
|
avatar.innerHTML = '<i class="fa-solid fa-user"></i>';
|
|
}
|
|
|
|
accountButton.insertBefore(avatar, accountName);
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
const stylesheet = document.getElementById('themeStylesheet');
|
|
|
|
if (!stylesheet || !themeStyles[theme]) return;
|
|
|
|
stylesheet.href = themeStyles[theme];
|
|
stylesheet.dataset.theme = theme;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|