From 904607a04561d50b94272606a361cf3446e49986ac14b62d76f1295469cee71e Mon Sep 17 00:00:00 2001 From: Mexpert_RPO Date: Sun, 14 Jun 2026 10:47:39 +0200 Subject: [PATCH] made installer and seperated stuff into diferent files --- .../app/css/{viewer.css => viewer/admin.css} | 404 -- ProjectKiln/app/css/viewer/dashboard.css | 13 + ProjectKiln/app/css/viewer/profile.css | 81 + ProjectKiln/app/css/viewer/project.css | 3 + ProjectKiln/app/css/viewer/shared.css | 150 + ProjectKiln/app/css/viewer/task_list.css | 168 + ProjectKiln/app/css/viewer/version.css | 7 + ProjectKiln/app/home.php | 19 +- ProjectKiln/app/js/home.js | 3532 ----------------- ProjectKiln/app/js/home/admin.js | 745 ++++ ProjectKiln/app/js/home/core.js | 524 +++ ProjectKiln/app/js/home/dashboard.js | 15 + ProjectKiln/app/js/home/events.js | 623 +++ ProjectKiln/app/js/home/profile.js | 63 + ProjectKiln/app/js/home/project.js | 397 ++ ProjectKiln/app/js/home/router.js | 42 + ProjectKiln/app/js/home/task.js | 685 ++++ ProjectKiln/app/js/home/task_list.js | 181 + ProjectKiln/app/js/home/version.js | 258 ++ ProjectKiln/index.php | 62 +- ProjectKiln/install/install.php | 251 ++ ProjectKiln/install/install_db.php | 495 +++ 22 files changed, 4766 insertions(+), 3952 deletions(-) rename ProjectKiln/app/css/{viewer.css => viewer/admin.css} (52%) create mode 100644 ProjectKiln/app/css/viewer/dashboard.css create mode 100644 ProjectKiln/app/css/viewer/profile.css create mode 100644 ProjectKiln/app/css/viewer/project.css create mode 100644 ProjectKiln/app/css/viewer/shared.css create mode 100644 ProjectKiln/app/css/viewer/task_list.css create mode 100644 ProjectKiln/app/css/viewer/version.css delete mode 100644 ProjectKiln/app/js/home.js create mode 100644 ProjectKiln/app/js/home/admin.js create mode 100644 ProjectKiln/app/js/home/core.js create mode 100644 ProjectKiln/app/js/home/dashboard.js create mode 100644 ProjectKiln/app/js/home/events.js create mode 100644 ProjectKiln/app/js/home/profile.js create mode 100644 ProjectKiln/app/js/home/project.js create mode 100644 ProjectKiln/app/js/home/router.js create mode 100644 ProjectKiln/app/js/home/task.js create mode 100644 ProjectKiln/app/js/home/task_list.js create mode 100644 ProjectKiln/app/js/home/version.js create mode 100644 ProjectKiln/install/install.php create mode 100644 ProjectKiln/install/install_db.php diff --git a/ProjectKiln/app/css/viewer.css b/ProjectKiln/app/css/viewer/admin.css similarity index 52% rename from ProjectKiln/app/css/viewer.css rename to ProjectKiln/app/css/viewer/admin.css index 86597f9..806a7fe 100644 --- a/ProjectKiln/app/css/viewer.css +++ b/ProjectKiln/app/css/viewer/admin.css @@ -1,380 +1,3 @@ -.viewer-placeholder { - min-height: 100%; - display: grid; - place-items: center; - border: 1px dashed var(--bs-border-color); - border-radius: 8px; - color: var(--bs-secondary-color); - text-align: center; -} - -.viewer-placeholder h1 { - color: var(--bs-body-color); -} - -.task-list-view, -.project-view, -.version-view { - max-width: 1120px; -} - -.task-list-header, -.version-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 18px; -} - -.task-list-header h1 { - margin: 0; - color: var(--bs-body-color); - font-size: 28px; - font-weight: 800; -} - -.version-title { - display: block; - width: 100%; - margin: -4px -6px 0; - padding: 4px 6px; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - color: var(--bs-body-color); - font-size: 28px; - font-weight: 800; -} - -.viewer-key { - margin-bottom: 4px; - color: var(--bs-secondary-color); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.section-title { - margin: 0 0 14px; - color: var(--bs-body-color); - font-size: 16px; - font-weight: 800; -} - -.viewer-description { - min-height: 72px; - color: var(--bs-body-color); - line-height: 1.6; - white-space: pre-wrap; -} - -.version-detail-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) 340px; - gap: 32px; - align-items: start; -} - -.version-panel { - margin-bottom: 28px; -} - -.version-task-section { - max-width: 1080px; -} - -.version-task-table { - overflow: hidden; - border: 1px solid var(--bs-border-color); - border-radius: 8px; -} - -.version-task-row { - display: grid; - grid-template-columns: - minmax(92px, 0.7fr) - minmax(180px, 1.5fr) - minmax(120px, 0.9fr) - minmax(130px, 0.9fr) - minmax(150px, 1.1fr) - minmax(120px, 0.85fr); - gap: 14px; - align-items: center; - width: 100%; -} - -.version-task-head { - border-bottom: 1px solid var(--bs-border-color); - background: var(--bs-secondary-bg); -} - -.version-task-head button { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 7px; - min-height: 42px; - padding: 0 14px; - border: 0; - background: transparent; - color: var(--bs-secondary-color); - text-align: left; - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.version-task-head button:hover, -.version-task-head button:focus, -.version-task-head button.is-active { - color: var(--bs-body-color); - outline: 0; -} - -.version-task-head button i { - width: 12px; - color: inherit; - font-size: 11px; -} - -.version-task-item { - min-height: 50px; - padding: 8px 14px; - border: 0; - border-bottom: 1px solid var(--bs-border-color); - background: var(--bs-body-bg); - color: var(--bs-body-color); - text-align: left; -} - -.version-task-item:last-child { - border-bottom: 0; -} - -.version-task-item:hover, -.version-task-item:focus { - background: var(--bs-secondary-bg); - outline: 0; -} - -.version-task-id { - color: var(--bs-secondary-color); - font-size: 13px; - font-weight: 800; -} - -.version-task-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 700; -} - -.version-task-loading { - padding: 14px; - color: var(--bs-secondary-color); - font-weight: 700; -} - -.version-task-status { - display: inline-flex; - align-items: center; - width: fit-content; - max-width: 100%; - min-height: 26px; - padding: 3px 8px; - border: 1px solid color-mix(in srgb, var(--task-status-color, var(--color-accent)) 55%, var(--bs-border-color)); - border-radius: 999px; - background: color-mix(in srgb, var(--task-status-color, var(--color-accent)) 16%, transparent); - color: var(--bs-body-color); - font-size: 12px; - font-weight: 800; -} - -.version-task-pagination { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - min-height: 36px; - margin-top: 10px; - color: var(--bs-secondary-color); - font-size: 13px; - font-weight: 800; -} - -.version-task-page-actions { - display: flex; - gap: 6px; -} - -.version-task-page-actions .btn { - display: inline-grid; - width: 32px; - height: 30px; - place-items: center; - padding: 0; -} - -.version-inline-field { - min-height: 34px; - padding: 5px 7px; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; -} - -.version-title:hover, -.version-title:focus, -.version-inline-field:hover, -.version-inline-field:focus { - border-color: var(--bs-border-color); - background: var(--bs-secondary-bg); - outline: 0; -} - -.version-title.is-readonly:hover, -.version-title.is-readonly:focus, -.version-inline-field.is-readonly:hover, -.version-inline-field.is-readonly:focus { - border-color: transparent; - background: transparent; -} - -.version-inline-field.is-empty { - color: var(--bs-secondary-color); - font-style: italic; -} - -.version-inline-field.is-saving { - opacity: 0.65; - pointer-events: none; -} - -.version-inline-form { - display: grid; - gap: 8px; -} - -.version-inline-actions { - display: flex; - gap: 8px; -} - -.version-inline-actions .btn { - display: inline-grid; - width: 36px; - height: 32px; - place-items: center; - padding: 0; -} - -.version-inline-input { - width: 100%; -} - -.meta-list { - display: grid; - gap: 12px; -} - -.meta-row { - display: grid; - grid-template-columns: 120px minmax(0, 1fr); - gap: 12px; - align-items: center; - font-size: 14px; -} - -.meta-label { - color: var(--bs-secondary-color); - font-weight: 700; -} - -.meta-value { - min-width: 0; - color: var(--bs-body-color); - font-weight: 700; -} - -.profile-view { - max-width: 860px; -} - -.profile-header { - margin-bottom: 22px; -} - -.profile-header h1 { - margin: 0; - color: var(--bs-body-color); - font-size: 28px; - font-weight: 800; -} - -.profile-layout { - display: grid; - grid-template-columns: 240px minmax(0, 1fr); - gap: 32px; - align-items: start; -} - -.profile-avatar-panel, -.profile-form-panel { - min-width: 0; -} - -.profile-avatar-preview { - display: grid; - width: 132px; - height: 132px; - margin-bottom: 14px; - place-items: center; - overflow: hidden; - border: 1px solid var(--bs-border-color); - border-radius: 999px; - background: var(--bs-secondary-bg); -} - -.profile-avatar-preview img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.profile-avatar-fallback { - color: var(--bs-secondary-color); - font-size: 42px; -} - -.profile-picture-button { - margin-bottom: 10px; -} - -.profile-remove-picture { - display: flex; - align-items: center; - gap: 8px; - color: var(--bs-secondary-color); - font-size: 14px; - font-weight: 700; -} - -.profile-actions { - display: flex; - align-items: center; - gap: 12px; - margin-top: 18px; -} - -.profile-status { - color: var(--bs-secondary-color); - font-size: 14px; - font-weight: 700; -} - .admin-view { max-width: 1120px; } @@ -812,24 +435,6 @@ } @media (max-width: 700px) { - .task-list-header, - .version-header { - align-items: flex-start; - flex-direction: column; - } - - .meta-row { - grid-template-columns: 1fr; - gap: 3px; - } - - .version-detail-grid { - grid-template-columns: 1fr; - } - - .profile-layout { - grid-template-columns: 1fr; - } .admin-layout { grid-template-columns: 1fr; @@ -854,13 +459,4 @@ .admin-custom-field-item { grid-template-columns: 1fr; } - - .version-task-table { - overflow-x: auto; - } - - .version-task-row { - grid-template-columns: 100px 190px 130px 140px 170px 130px; - min-width: 870px; - } } diff --git a/ProjectKiln/app/css/viewer/dashboard.css b/ProjectKiln/app/css/viewer/dashboard.css new file mode 100644 index 0000000..58f06f7 --- /dev/null +++ b/ProjectKiln/app/css/viewer/dashboard.css @@ -0,0 +1,13 @@ +.viewer-placeholder { + min-height: 100%; + display: grid; + place-items: center; + border: 1px dashed var(--bs-border-color); + border-radius: 8px; + color: var(--bs-secondary-color); + text-align: center; +} + +.viewer-placeholder h1 { + color: var(--bs-body-color); +} diff --git a/ProjectKiln/app/css/viewer/profile.css b/ProjectKiln/app/css/viewer/profile.css new file mode 100644 index 0000000..b931705 --- /dev/null +++ b/ProjectKiln/app/css/viewer/profile.css @@ -0,0 +1,81 @@ +.profile-view { + max-width: 860px; +} + +.profile-header { + margin-bottom: 22px; +} + +.profile-header h1 { + margin: 0; + color: var(--bs-body-color); + font-size: 28px; + font-weight: 800; +} + +.profile-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 32px; + align-items: start; +} + +.profile-avatar-panel, +.profile-form-panel { + min-width: 0; +} + +.profile-avatar-preview { + display: grid; + width: 132px; + height: 132px; + margin-bottom: 14px; + place-items: center; + overflow: hidden; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + background: var(--bs-secondary-bg); +} + +.profile-avatar-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-avatar-fallback { + color: var(--bs-secondary-color); + font-size: 42px; +} + +.profile-picture-button { + margin-bottom: 10px; +} + +.profile-remove-picture { + display: flex; + align-items: center; + gap: 8px; + color: var(--bs-secondary-color); + font-size: 14px; + font-weight: 700; +} + +.profile-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 18px; +} + +.profile-status { + color: var(--bs-secondary-color); + font-size: 14px; + font-weight: 700; +} + +@media (max-width: 700px) { + .profile-layout { + grid-template-columns: 1fr; + } +} diff --git a/ProjectKiln/app/css/viewer/project.css b/ProjectKiln/app/css/viewer/project.css new file mode 100644 index 0000000..b9c6323 --- /dev/null +++ b/ProjectKiln/app/css/viewer/project.css @@ -0,0 +1,3 @@ +.project-view { + max-width: 1120px; +} diff --git a/ProjectKiln/app/css/viewer/shared.css b/ProjectKiln/app/css/viewer/shared.css new file mode 100644 index 0000000..fcd646f --- /dev/null +++ b/ProjectKiln/app/css/viewer/shared.css @@ -0,0 +1,150 @@ +.viewer-key { + margin-bottom: 4px; + color: var(--bs-secondary-color); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.section-title { + margin: 0 0 14px; + color: var(--bs-body-color); + font-size: 16px; + font-weight: 800; +} + +.viewer-description { + min-height: 72px; + color: var(--bs-body-color); + line-height: 1.6; + white-space: pre-wrap; +} + +.version-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.version-title { + display: block; + width: 100%; + margin: -4px -6px 0; + padding: 4px 6px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--bs-body-color); + font-size: 28px; + font-weight: 800; +} + +.version-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 32px; + align-items: start; +} + +.version-panel { + margin-bottom: 28px; +} + +.version-inline-field { + min-height: 34px; + padding: 5px 7px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; +} + +.version-title:hover, +.version-title:focus, +.version-inline-field:hover, +.version-inline-field:focus { + border-color: var(--bs-border-color); + background: var(--bs-secondary-bg); + outline: 0; +} + +.version-title.is-readonly:hover, +.version-title.is-readonly:focus, +.version-inline-field.is-readonly:hover, +.version-inline-field.is-readonly:focus { + border-color: transparent; + background: transparent; +} + +.version-inline-field.is-empty { + color: var(--bs-secondary-color); + font-style: italic; +} + +.version-inline-field.is-saving { + opacity: 0.65; + pointer-events: none; +} + +.version-inline-form { + display: grid; + gap: 8px; +} + +.version-inline-actions { + display: flex; + gap: 8px; +} + +.version-inline-actions .btn { + display: inline-grid; + width: 36px; + height: 32px; + place-items: center; + padding: 0; +} + +.version-inline-input { + width: 100%; +} + +.meta-list { + display: grid; + gap: 12px; +} + +.meta-row { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 12px; + align-items: center; + font-size: 14px; +} + +.meta-label { + color: var(--bs-secondary-color); + font-weight: 700; +} + +.meta-value { + min-width: 0; + color: var(--bs-body-color); + font-weight: 700; +} + +@media (max-width: 700px) { + .version-header { + align-items: flex-start; + flex-direction: column; + } + + .version-detail-grid { + grid-template-columns: 1fr; + } + + .meta-row { + grid-template-columns: 1fr; + gap: 3px; + } +} diff --git a/ProjectKiln/app/css/viewer/task_list.css b/ProjectKiln/app/css/viewer/task_list.css new file mode 100644 index 0000000..37a914e --- /dev/null +++ b/ProjectKiln/app/css/viewer/task_list.css @@ -0,0 +1,168 @@ +.task-list-view { + max-width: 1120px; +} + +.task-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.task-list-header h1 { + margin: 0; + color: var(--bs-body-color); + font-size: 28px; + font-weight: 800; +} + +.version-task-table { + overflow: hidden; + border: 1px solid var(--bs-border-color); + border-radius: 8px; +} + +.version-task-row { + display: grid; + grid-template-columns: + minmax(92px, 0.7fr) + minmax(180px, 1.5fr) + minmax(120px, 0.9fr) + minmax(130px, 0.9fr) + minmax(150px, 1.1fr) + minmax(120px, 0.85fr); + gap: 14px; + align-items: center; + width: 100%; +} + +.version-task-head { + border-bottom: 1px solid var(--bs-border-color); + background: var(--bs-secondary-bg); +} + +.version-task-head button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 7px; + min-height: 42px; + padding: 0 14px; + border: 0; + background: transparent; + color: var(--bs-secondary-color); + text-align: left; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.version-task-head button:hover, +.version-task-head button:focus, +.version-task-head button.is-active { + color: var(--bs-body-color); + outline: 0; +} + +.version-task-head button i { + width: 12px; + color: inherit; + font-size: 11px; +} + +.version-task-item { + min-height: 50px; + padding: 8px 14px; + border: 0; + border-bottom: 1px solid var(--bs-border-color); + background: var(--bs-body-bg); + color: var(--bs-body-color); + text-align: left; +} + +.version-task-item:last-child { + border-bottom: 0; +} + +.version-task-item:hover, +.version-task-item:focus { + background: var(--bs-secondary-bg); + outline: 0; +} + +.version-task-id { + color: var(--bs-secondary-color); + font-size: 13px; + font-weight: 800; +} + +.version-task-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; +} + +.version-task-loading { + padding: 14px; + color: var(--bs-secondary-color); + font-weight: 700; +} + +.version-task-status { + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; + min-height: 26px; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--task-status-color, var(--color-accent)) 55%, var(--bs-border-color)); + border-radius: 999px; + background: color-mix(in srgb, var(--task-status-color, var(--color-accent)) 16%, transparent); + color: var(--bs-body-color); + font-size: 12px; + font-weight: 800; +} + +.version-task-pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-height: 36px; + margin-top: 10px; + color: var(--bs-secondary-color); + font-size: 13px; + font-weight: 800; +} + +.version-task-page-actions { + display: flex; + gap: 6px; +} + +.version-task-page-actions .btn { + display: inline-grid; + width: 32px; + height: 30px; + place-items: center; + padding: 0; +} + +@media (max-width: 700px) { + .task-list-header { + align-items: flex-start; + flex-direction: column; + } + + .version-task-table { + overflow-x: auto; + } + + .version-task-row { + grid-template-columns: 100px 190px 130px 140px 170px 130px; + min-width: 870px; + } +} diff --git a/ProjectKiln/app/css/viewer/version.css b/ProjectKiln/app/css/viewer/version.css new file mode 100644 index 0000000..3a6d60a --- /dev/null +++ b/ProjectKiln/app/css/viewer/version.css @@ -0,0 +1,7 @@ +.version-view { + max-width: 1120px; +} + +.version-task-section { + max-width: 1080px; +} diff --git a/ProjectKiln/app/home.php b/ProjectKiln/app/home.php index d101afb..7f5b8fd 100644 --- a/ProjectKiln/app/home.php +++ b/ProjectKiln/app/home.php @@ -1,12 +1,27 @@ { - 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; - } - }); -}); diff --git a/ProjectKiln/app/js/home/admin.js b/ProjectKiln/app/js/home/admin.js new file mode 100644 index 0000000..a92d1a8 --- /dev/null +++ b/ProjectKiln/app/js/home/admin.js @@ -0,0 +1,745 @@ +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 loadAdmin(section = 'workflows', pushHistory = true) { + if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') { + loadDashboard(false); + 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(); + } +} diff --git a/ProjectKiln/app/js/home/core.js b/ProjectKiln/app/js/home/core.js new file mode 100644 index 0000000..f104bb2 --- /dev/null +++ b/ProjectKiln/app/js/home/core.js @@ -0,0 +1,524 @@ +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("'", '''); +} diff --git a/ProjectKiln/app/js/home/dashboard.js b/ProjectKiln/app/js/home/dashboard.js new file mode 100644 index 0000000..827c4c8 --- /dev/null +++ b/ProjectKiln/app/js/home/dashboard.js @@ -0,0 +1,15 @@ +function loadDashboard(pushHistory = true) { + showView('dashboardView'); + + if (!pushHistory) return; + + 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); +} diff --git a/ProjectKiln/app/js/home/events.js b/ProjectKiln/app/js/home/events.js new file mode 100644 index 0000000..709fa38 --- /dev/null +++ b/ProjectKiln/app/js/home/events.js @@ -0,0 +1,623 @@ +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', () => { + loadDashboard(); + }); + }); + + 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; + } + }); +}); diff --git a/ProjectKiln/app/js/home/profile.js b/ProjectKiln/app/js/home/profile.js new file mode 100644 index 0000000..409b2b0 --- /dev/null +++ b/ProjectKiln/app/js/home/profile.js @@ -0,0 +1,63 @@ +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 = ` + + + + `; +} + diff --git a/ProjectKiln/app/js/home/project.js b/ProjectKiln/app/js/home/project.js new file mode 100644 index 0000000..e9df2ee --- /dev/null +++ b/ProjectKiln/app/js/home/project.js @@ -0,0 +1,397 @@ +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(); + } + }); +} + diff --git a/ProjectKiln/app/js/home/router.js b/ProjectKiln/app/js/home/router.js new file mode 100644 index 0000000..ed8e11f --- /dev/null +++ b/ProjectKiln/app/js/home/router.js @@ -0,0 +1,42 @@ +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; + } + + loadDashboard(false); +} diff --git a/ProjectKiln/app/js/home/task.js b/ProjectKiln/app/js/home/task.js new file mode 100644 index 0000000..5da73ac --- /dev/null +++ b/ProjectKiln/app/js/home/task.js @@ -0,0 +1,685 @@ +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); +} + +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 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); +} + diff --git a/ProjectKiln/app/js/home/task_list.js b/ProjectKiln/app/js/home/task_list.js new file mode 100644 index 0000000..8f9e93f --- /dev/null +++ b/ProjectKiln/app/js/home/task_list.js @@ -0,0 +1,181 @@ +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 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'; + }); +} diff --git a/ProjectKiln/app/js/home/version.js b/ProjectKiln/app/js/home/version.js new file mode 100644 index 0000000..4701501 --- /dev/null +++ b/ProjectKiln/app/js/home/version.js @@ -0,0 +1,258 @@ +function openVersionCreatePopup(projectId) { + resetVersionPopup(); + document.getElementById('versionFormProjectId').value = projectId; + openPopup('createVersion'); +} + +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'); +} + +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 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'); +} + +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); + } + }); + }); +} + +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; +} + diff --git a/ProjectKiln/index.php b/ProjectKiln/index.php index d861f36..e13dae8 100644 --- a/ProjectKiln/index.php +++ b/ProjectKiln/index.php @@ -16,6 +16,30 @@ const THEME_STYLES = [ 'beige' => 'app/css/beige_mode.css', ]; +function projectKilnTableExists(string $table): bool +{ + $stmt = db()->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?' + ); + $stmt->execute([$table]); + + return (int)$stmt->fetchColumn() > 0; +} + +function projectKilnIsInstalled(): bool +{ + if (!projectKilnTableExists('settings')) { + return false; + } + + $stmt = db()->prepare( + "SELECT COUNT(*) FROM settings WHERE setting_name = 'installed' AND LOWER(setting_value) = 'true'" + ); + $stmt->execute(); + + return (int)$stmt->fetchColumn() > 0; +} + function currentTheme(): string { $theme = 'dark'; @@ -43,24 +67,34 @@ function currentTheme(): string return array_key_exists($storedTheme, THEME_STYLES) ? $storedTheme : $theme; } -$page = $_GET['page'] ?? 'home'; -$theme = currentTheme(); +$isInstalled = projectKilnIsInstalled(); +$theme = $isInstalled ? currentTheme() : 'dark'; -$routes = [ - 'home' => 'home.php', - 'login' => 'login.php', - 'logout' => 'logout.php', -]; +if (!$isInstalled) { + define('PROJECTKILN_INSTALL_EMBEDDED', true); -if (!isset($routes[$page])) { - $pageFile = __DIR__ . '/app/404.php'; + ob_start(); + require __DIR__ . '/install/install.php'; + $pageContent = ob_get_clean(); } else { - $pageFile = __DIR__ . '/app/' . $routes[$page]; -} + $page = $_GET['page'] ?? 'home'; -ob_start(); -require $pageFile; -$pageContent = ob_get_clean(); + $routes = [ + 'home' => 'home.php', + 'login' => 'login.php', + 'logout' => 'logout.php', + ]; + + if (!isset($routes[$page])) { + $pageFile = __DIR__ . '/app/404.php'; + } else { + $pageFile = __DIR__ . '/app/' . $routes[$page]; + } + + ob_start(); + require $pageFile; + $pageContent = ob_get_clean(); +} ?> diff --git a/ProjectKiln/install/install.php b/ProjectKiln/install/install.php new file mode 100644 index 0000000..359c3d8 --- /dev/null +++ b/ProjectKiln/install/install.php @@ -0,0 +1,251 @@ +prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?' + ); + $stmt->execute([$table]); + + return (int)$stmt->fetchColumn() > 0; +} + +function installerIsInstalled(PDO $pdo): bool { + if (!installerTableExists($pdo, 'settings')) { + return false; + } + + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM settings WHERE setting_name = 'installed' AND LOWER(setting_value) = 'true'" + ); + $stmt->execute(); + + return (int)$stmt->fetchColumn() > 0; +} + +function redirectHome(): never { + header('Location: ' . (installerEmbedded() ? '?page=home' : '../?page=home')); + exit; +} + +if (installerEmbedded()) { + $pageStyles[] = 'app/css/login.css'; +} + +$pdo = db(); + +if (installerIsInstalled($pdo)) { + redirectHome(); +} + +$installError = null; +$formError = null; + +try { + require __DIR__ . '/install_db.php'; + require_once __DIR__ . '/../auth.php'; +} catch (Throwable $exception) { + $installError = $exception->getMessage(); +} + +if ($installError === null && $_SERVER['REQUEST_METHOD'] === 'POST') { + $username = trim((string)($_POST['username'] ?? '')); + $email = strtolower(trim((string)($_POST['email'] ?? ''))); + $password = (string)($_POST['password'] ?? ''); + $passwordConfirm = (string)($_POST['password_confirm'] ?? ''); + + if ($username === '' || $email === '' || $password === '') { + $formError = 'Please fill in all required fields.'; + } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $formError = 'Please enter a valid email address.'; + } elseif (strlen($password) < 8) { + $formError = 'Password must be at least 8 characters long.'; + } elseif ($password !== $passwordConfirm) { + $formError = 'Passwords do not match.'; + } else { + $userId = register($username, $email, $password); + + if ($userId === null) { + $formError = 'Admin user could not be created. Check if the email is already used.'; + } else { + $adminRightStmt = $pdo->prepare("SELECT id FROM rights WHERE name = 'Admin' LIMIT 1"); + $adminRightStmt->execute(); + $adminRightId = (int)$adminRightStmt->fetchColumn(); + + if ($adminRightId <= 0) { + $formError = 'Admin right is missing after database setup.'; + } else { + $existingRight = $pdo->prepare( + 'SELECT COUNT(*) FROM user_rights WHERE user_id = ? AND right_id = ?' + ); + $existingRight->execute([$userId, $adminRightId]); + + if ((int)$existingRight->fetchColumn() === 0) { + $assignAdmin = $pdo->prepare( + 'INSERT INTO user_rights (user_id, right_id) VALUES (?, ?)' + ); + $assignAdmin->execute([$userId, $adminRightId]); + } + + $markInstalled = $pdo->prepare( + "INSERT INTO settings (id, setting_name, setting_value) + VALUES (2, 'installed', 'true') + ON DUPLICATE KEY UPDATE setting_name = VALUES(setting_name), setting_value = VALUES(setting_value)" + ); + $markInstalled->execute(); + + login($email, $password, false); + redirectHome(); + } + } + } +} + +function oldInput(string $name): string { + return htmlspecialchars((string)($_POST[$name] ?? ''), ENT_QUOTES, 'UTF-8'); +} +?> + + + + + + + ProjectKiln Install + + + + + + + + +
+
+
+
+ + +

+ ProjectKiln Setup +

+ +

+ Create the first administrator account to finish the installation. +

+
+ +
+ +
+ + Database setup failed: +
+ + + +
+ + +
+ + +
+
+ +
+ + + + + > +
+
+ +
+ +
+ + + + + > +
+
+ +
+ +
+ + + + + > +
+
+ +
+ +
+ + + + + > +
+
+ + +
+
+
+
+
+ + + + diff --git a/ProjectKiln/install/install_db.php b/ProjectKiln/install/install_db.php new file mode 100644 index 0000000..b3f645e --- /dev/null +++ b/ProjectKiln/install/install_db.php @@ -0,0 +1,495 @@ +prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?' + ); + $stmt->execute([$table]); + + return (int) $stmt->fetchColumn() > 0; +} + +function indexExists(PDO $pdo, string $table, string $index): bool { + $stmt = $pdo->prepare( + 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?' + ); + $stmt->execute([$table, $index]); + + return (int) $stmt->fetchColumn() > 0; +} + +function foreignKeyExists(PDO $pdo, string $constraint): bool { + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = 'FOREIGN KEY'" + ); + $stmt->execute([$constraint]); + + return (int) $stmt->fetchColumn() > 0; +} + +function addIndexIfMissing(PDO $pdo, string $table, string $index, string $definition): void { + if (indexExists($pdo, $table, $index)) { + return; + } + + $pdo->exec('ALTER TABLE ' . quoteIdentifier($table) . ' ADD ' . $definition); +} + +function addForeignKeyIfMissing(PDO $pdo, string $constraint, string $definition): void { + if (foreignKeyExists($pdo, $constraint)) { + return; + } + + $pdo->exec($definition); +} + +function assetBlob(string $relativePath): ?string { + $path = __DIR__ . '/../../raw-resources/' . ltrim($relativePath, '/'); + + if (!is_file($path)) { + return null; + } + + return file_get_contents($path) ?: null; +} + +function upsert(PDO $pdo, string $sql, array $params): void { + $stmt = $pdo->prepare($sql); + $stmt->execute($params); +} + +try { + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `task_states` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `color` varchar(7) COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `task_priorities` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(20) COLLATE utf8mb4_0900_bin NOT NULL, + `logo` blob, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `task_types` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + `logo` blob, + `default_state` int DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `users` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `email` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `passwd` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL, + `picture` mediumblob, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `projects` ( + `id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `owner` int NOT NULL, + `created_date` date NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `versions` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin, + `created_date` date NOT NULL, + `due_date` date DEFAULT NULL, + `released_date` date DEFAULT NULL, + `project` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `tasks` ( + `id` varchar(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + `title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin, + `project` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + `created_date` date NOT NULL, + `last_changed` date NOT NULL, + `reporter` int NOT NULL, + `assignee` int DEFAULT NULL, + `fix_version` int DEFAULT NULL, + `type` int NOT NULL, + `priority` int NOT NULL, + `status` int DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `assigned_task_states` ( + `id` int NOT NULL AUTO_INCREMENT, + `state` int NOT NULL, + `task_type` int NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `auth_tokens` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `token_hash` char(64) COLLATE utf8mb4_0900_bin NOT NULL, + `expires_at` datetime NOT NULL, + `revoked_at` datetime DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `comments` ( + `id` int NOT NULL AUTO_INCREMENT, + `response_to` int DEFAULT NULL, + `task_id` varchar(26) COLLATE utf8mb4_0900_bin NOT NULL, + `commenter` int NOT NULL, + `comment` text COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `custom_task_fields` ( + `id` int NOT NULL AUTO_INCREMENT, + `task_type` int NOT NULL, + `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `type` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `default_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `custom_field_values` ( + `id` int NOT NULL AUTO_INCREMENT, + `field_id` int NOT NULL, + `task_id` varchar(26) COLLATE utf8mb4_0900_bin NOT NULL, + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `remember_tokens` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `selector` char(32) COLLATE utf8mb4_0900_bin NOT NULL, + `token_hash` char(64) COLLATE utf8mb4_0900_bin NOT NULL, + `expires_at` datetime NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `rights` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `settings` ( + `id` int NOT NULL AUTO_INCREMENT, + `setting_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `setting_value` varchar(256) COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `task_state_transitions` ( + `id` int NOT NULL AUTO_INCREMENT, + `from_id` int NOT NULL, + `to_id` int NOT NULL, + `action_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `user_access` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `project_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `user_rights` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `right_id` int NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS `user_settings` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `setting_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL, + `setting_value` varchar(256) COLLATE utf8mb4_0900_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin +SQL); + + $indexes = [ + ['assigned_task_states', 'state', 'KEY `state` (`state`)'], + ['assigned_task_states', 'task_type', 'KEY `task_type` (`task_type`)'], + ['auth_tokens', 'unique_token_hash', 'UNIQUE KEY `unique_token_hash` (`token_hash`)'], + ['auth_tokens', 'user_id', 'KEY `user_id` (`user_id`)'], + ['comments', 'response_to', 'KEY `response_to` (`response_to`)'], + ['comments', 'task_id', 'KEY `task_id` (`task_id`)'], + ['comments', 'commenter', 'KEY `commenter` (`commenter`)'], + ['custom_field_values', 'field_id', 'KEY `field_id` (`field_id`)'], + ['custom_field_values', 'task_id', 'KEY `task_id` (`task_id`)'], + ['custom_task_fields', 'task_type', 'KEY `task_type` (`task_type`)'], + ['projects', 'owner', 'KEY `owner` (`owner`)'], + ['remember_tokens', 'unique_selector', 'UNIQUE KEY `unique_selector` (`selector`)'], + ['remember_tokens', 'user_id', 'KEY `user_id` (`user_id`)'], + ['tasks', 'project', 'KEY `project` (`project`)'], + ['tasks', 'reporter', 'KEY `reporter` (`reporter`,`assignee`,`fix_version`,`type`,`priority`)'], + ['tasks', 'type', 'KEY `type` (`type`)'], + ['tasks', 'priority', 'KEY `priority` (`priority`)'], + ['tasks', 'assignee', 'KEY `assignee` (`assignee`)'], + ['tasks', 'fix_version', 'KEY `fix_version` (`fix_version`)'], + ['tasks', 'status', 'KEY `status` (`status`)'], + ['task_state_transitions', 'from_id', 'KEY `from_id` (`from_id`)'], + ['task_state_transitions', 'to_id', 'KEY `to_id` (`to_id`)'], + ['task_types', 'default_state', 'KEY `default_state` (`default_state`)'], + ['users', 'unique_email', 'UNIQUE KEY `unique_email` (`email`)'], + ['user_access', 'user_id', 'KEY `user_id` (`user_id`)'], + ['user_access', 'project_id', 'KEY `project_id` (`project_id`)'], + ['user_rights', 'user_id', 'KEY `user_id` (`user_id`)'], + ['user_rights', 'right_id', 'KEY `right_id` (`right_id`)'], + ['user_settings', 'user_id', 'KEY `user_id` (`user_id`)'], + ['versions', 'project', 'KEY `project` (`project`)'], + ]; + + foreach ($indexes as [$table, $index, $definition]) { + addIndexIfMissing($pdo, $table, $index, $definition); + } + + upsert( + $pdo, + "INSERT INTO `settings` (`id`, `setting_name`, `setting_value`) + VALUES (1, 'version', '0.0.1') + ON DUPLICATE KEY UPDATE `setting_name` = VALUES(`setting_name`), `setting_value` = VALUES(`setting_value`)", + [] + ); + + $states = [ + [1, 'Open', '#858e99'], + [2, 'In Progress', '#3384e1'], + [3, 'Resolved', '#1a7f21'], + [4, 'Reopened', '#8693a2'], + [5, 'Closed', '#3c810e'], + ]; + + foreach ($states as $state) { + upsert( + $pdo, + "INSERT INTO `task_states` (`id`, `name`, `color`) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `color` = VALUES(`color`)", + $state + ); + } + + $priorities = [ + [1, 'Critical', 'images_task_priority/1_critical.svg'], + [2, 'Heigh', 'images_task_priority/2_heigh.svg'], + [3, 'Medium', 'images_task_priority/3_medium.svg'], + [4, 'Low', 'images_task_priority/4_low.svg'], + [5, 'Trivial', 'images_task_priority/5_trivial.svg'], + ]; + + foreach ($priorities as [$id, $name, $asset]) { + upsert( + $pdo, + "INSERT INTO `task_priorities` (`id`, `name`, `logo`) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `logo` = VALUES(`logo`)", + [$id, $name, assetBlob($asset)] + ); + } + + $types = [ + [1, 'Unknown', 'images_task_types/unknown.svg', 1], + [2, 'Bug', 'images_task_types/bug.svg', 1], + [3, 'New Feature', 'images_task_types/new_feature.svg', 1], + [4, 'Improvement', 'images_task_types/improvement.svg', 1], + [5, 'Task', 'images_task_types/task.svg', 1], + ]; + + foreach ($types as [$id, $name, $asset, $defaultState]) { + upsert( + $pdo, + "INSERT INTO `task_types` (`id`, `name`, `logo`, `default_state`) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `logo` = VALUES(`logo`), `default_state` = VALUES(`default_state`)", + [$id, $name, assetBlob($asset), $defaultState] + ); + } + + $transitions = [ + [1, 1, 2, 'Start Work'], + [2, 2, 3, 'Resolve Issue'], + [3, 3, 4, 'Reopen Issue'], + [4, 4, 2, 'Start Work'], + [5, 1, 5, 'Close'], + [6, 2, 5, 'Close'], + [7, 2, 1, 'Stop Work'], + [8, 5, 4, 'Reopen Issue'], + ]; + + foreach ($transitions as $transition) { + upsert( + $pdo, + "INSERT INTO `task_state_transitions` (`id`, `from_id`, `to_id`, `action_name`) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `from_id` = VALUES(`from_id`), `to_id` = VALUES(`to_id`), `action_name` = VALUES(`action_name`)", + $transition + ); + } + + $assignedStates = [ + [1, 1, 5], [2, 2, 5], [3, 3, 5], [4, 5, 5], [5, 4, 5], + [6, 1, 1], + [7, 1, 2], [8, 2, 2], [9, 3, 2], [10, 4, 2], [11, 5, 2], + [12, 1, 3], [13, 2, 3], [14, 3, 3], [15, 4, 3], [16, 5, 3], + [17, 1, 4], [18, 2, 4], [19, 3, 4], [20, 4, 4], [21, 5, 4], + ]; + + foreach ($assignedStates as $assignedState) { + upsert( + $pdo, + "INSERT INTO `assigned_task_states` (`id`, `state`, `task_type`) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE `state` = VALUES(`state`), `task_type` = VALUES(`task_type`)", + $assignedState + ); + } + + $rights = [ + [1, 'Admin'], + [2, 'Create Tasks'], + [3, 'Edit Tasks'], + [4, 'Create Versions'], + [5, 'Edit Versions'], + [6, 'Create Projects'], + [7, 'Edit Projects'], + ]; + + foreach ($rights as $right) { + upsert( + $pdo, + "INSERT INTO `rights` (`id`, `name`) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)", + $right + ); + } + + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + + $foreignKeys = [ + ['assigned_task_states_ibfk_1', 'ALTER TABLE `assigned_task_states` ADD CONSTRAINT `assigned_task_states_ibfk_1` FOREIGN KEY (`task_type`) REFERENCES `task_types` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['assigned_task_states_ibfk_2', 'ALTER TABLE `assigned_task_states` ADD CONSTRAINT `assigned_task_states_ibfk_2` FOREIGN KEY (`state`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['auth_tokens_ibfk_1', 'ALTER TABLE `auth_tokens` ADD CONSTRAINT `auth_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE'], + ['comments_ibfk_1', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`commenter`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['comments_ibfk_2', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_2` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['comments_ibfk_3', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_3` FOREIGN KEY (`response_to`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['custom_field_values_ibfk_1', 'ALTER TABLE `custom_field_values` ADD CONSTRAINT `custom_field_values_ibfk_1` FOREIGN KEY (`field_id`) REFERENCES `custom_task_fields` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['custom_field_values_ibfk_2', 'ALTER TABLE `custom_field_values` ADD CONSTRAINT `custom_field_values_ibfk_2` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['custom_task_fields_ibfk_1', 'ALTER TABLE `custom_task_fields` ADD CONSTRAINT `custom_task_fields_ibfk_1` FOREIGN KEY (`task_type`) REFERENCES `task_types` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['projects_ibfk_1', 'ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`owner`) REFERENCES `users` (`id`) ON UPDATE CASCADE'], + ['remember_tokens_ibfk_1', 'ALTER TABLE `remember_tokens` ADD CONSTRAINT `remember_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE'], + ['tasks_ibfk_1', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`type`) REFERENCES `task_types` (`id`) ON UPDATE CASCADE'], + ['tasks_ibfk_2', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`priority`) REFERENCES `task_priorities` (`id`) ON UPDATE CASCADE'], + ['tasks_ibfk_3', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`reporter`) REFERENCES `users` (`id`) ON UPDATE CASCADE'], + ['tasks_ibfk_4', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_4` FOREIGN KEY (`assignee`) REFERENCES `users` (`id`) ON UPDATE CASCADE'], + ['tasks_ibfk_5', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_5` FOREIGN KEY (`project`) REFERENCES `projects` (`id`) ON UPDATE CASCADE'], + ['tasks_ibfk_6', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_6` FOREIGN KEY (`fix_version`) REFERENCES `versions` (`id`) ON UPDATE CASCADE'], + ['tasks_ibfk_7', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_7` FOREIGN KEY (`status`) REFERENCES `task_states` (`id`) ON DELETE SET NULL ON UPDATE CASCADE'], + ['task_state_transitions_ibfk_1', 'ALTER TABLE `task_state_transitions` ADD CONSTRAINT `task_state_transitions_ibfk_1` FOREIGN KEY (`to_id`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['task_state_transitions_ibfk_2', 'ALTER TABLE `task_state_transitions` ADD CONSTRAINT `task_state_transitions_ibfk_2` FOREIGN KEY (`from_id`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['task_types_ibfk_1', 'ALTER TABLE `task_types` ADD CONSTRAINT `task_types_ibfk_1` FOREIGN KEY (`default_state`) REFERENCES `task_states` (`id`) ON DELETE SET NULL ON UPDATE CASCADE'], + ['user_access_ibfk_1', 'ALTER TABLE `user_access` ADD CONSTRAINT `user_access_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['user_access_ibfk_2', 'ALTER TABLE `user_access` ADD CONSTRAINT `user_access_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['user_rights_ibfk_1', 'ALTER TABLE `user_rights` ADD CONSTRAINT `user_rights_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['user_rights_ibfk_2', 'ALTER TABLE `user_rights` ADD CONSTRAINT `user_rights_ibfk_2` FOREIGN KEY (`right_id`) REFERENCES `rights` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['user_settings_ibfk_1', 'ALTER TABLE `user_settings` ADD CONSTRAINT `user_settings_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'], + ['versions_ibfk_1', 'ALTER TABLE `versions` ADD CONSTRAINT `versions_ibfk_1` FOREIGN KEY (`project`) REFERENCES `projects` (`id`) ON UPDATE CASCADE'], + ]; + + foreach ($foreignKeys as [$constraint, $definition]) { + addForeignKeyIfMissing($pdo, $constraint, $definition); + } + + if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { + header('Location: install.php'); + exit; + } +} catch (Throwable $exception) { + try { + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } catch (Throwable) { + } + + if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !== __FILE__) { + throw $exception; + } + + http_response_code(500); + echo 'ProjectKiln install failed'; + echo '

Database install failed

'; + echo '
' . htmlspecialchars($exception->getMessage(), ENT_QUOTES, 'UTF-8') . '
'; +}