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