diff --git a/.github/workflows/docker-staging-deployment.yml b/.github/workflows/docker-staging-deployment.yml index 932f6c4569..2dec4691fd 100644 --- a/.github/workflows/docker-staging-deployment.yml +++ b/.github/workflows/docker-staging-deployment.yml @@ -302,4 +302,4 @@ jobs: echo "Deployment to staging failed!" echo "Check logs for details." echo "If rollback occurred, the previous version should still be running." - echo "======================================" \ No newline at end of file + echo "======================================" diff --git a/app/Core/Middleware/StartSession.php b/app/Core/Middleware/StartSession.php index 3f9f029193..5f08766623 100644 --- a/app/Core/Middleware/StartSession.php +++ b/app/Core/Middleware/StartSession.php @@ -87,7 +87,7 @@ protected function handleRequestWhileBlocking(IncomingRequest $request, $session $holdLockFor = $this->calculateLockDuration($request); // Hold lock for x seconds after acquiring // Maximum time to wait for acquiring the lock if already held - $maxWaitForLock = 5; // Wait for up to y seconds to acquire the lock + $maxWaitForLock = 1; // Wait for up to y seconds to acquire the lock $lock = $this->cache($this->manager->blockDriver()) ->lock('session:'.$session->getId(), $holdLockFor) @@ -308,6 +308,9 @@ protected function saveSession(IncomingRequest $request) protected function shouldLockSession(IncomingRequest $request) { + if ($this->isKanbanMoveApiRequest($request)) { + return false; + } if ( $request->isApiOrCronRequest() === false && @@ -319,6 +322,25 @@ protected function shouldLockSession(IncomingRequest $request) return false; } + /** + * Kanban move requests can become high-frequency and should not wait on session lock contention. + */ + protected function isKanbanMoveApiRequest(IncomingRequest $request): bool + { + if (! $request->isApiOrCronRequest() || ! $request->isMethod('POST')) { + return false; + } + + $path = trim((string) $request->path(), '/'); + if ($path !== 'api/tickets') { + return false; + } + + $action = (string) ($request->input('action') ?? ''); + + return in_array($action, ['kanbanMoveDelta', 'kanbanSort'], true); + } + /** * Get the session lifetime in seconds. * diff --git a/app/Domain/Tickets/Controllers/KanbanPage.php b/app/Domain/Tickets/Controllers/KanbanPage.php new file mode 100644 index 0000000000..b3009fb156 --- /dev/null +++ b/app/Domain/Tickets/Controllers/KanbanPage.php @@ -0,0 +1,67 @@ +projectService = $projectService; + $this->ticketService = $ticketService; + $this->timesheetService = $timesheetService; + } + + public function load(array $params): Response + { + $status = (int) ($params['status'] ?? -999); + $limit = (int) ($params['limit'] ?? $this->ticketService->getKanbanPageSize()); + + if (! array_key_exists($status, $this->ticketService->getKanbanColumns())) { + return new Response('', 400); + } + + $limit = min(max(1, $limit), 100); + $tickets = $this->ticketService->getKanbanTicketsForStatus($params, $status, $limit); + + $this->tpl->assign('allKanbanColumns', $this->ticketService->getKanbanColumns()); + $this->tpl->assign('onTheClock', $this->timesheetService->isClocked(session('userdata.id'))); + $this->tpl->assign('todoTypeIcons', $this->ticketService->getTypeIcons()); + $this->tpl->assign('efforts', $this->ticketService->getEffortLabels()); + $this->tpl->assign('priorities', $this->ticketService->getPriorityLabels()); + $this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject'))); + $this->tpl->assign('milestones', $this->ticketService->getAllMilestones([ + 'sprint' => '', + 'type' => 'milestone', + 'currentProject' => session('currentProject'), + ])); + + $tpl = $this->tpl; + $key = $status; + $todoTypeIcons = $this->ticketService->getTypeIcons(); + $efforts = $this->ticketService->getEffortLabels(); + $priorities = $this->ticketService->getPriorityLabels(); + + ob_start(); + foreach ($tickets as $row) { + include __DIR__.'/../Templates/partials/kanbanTicketCard.tpl.php'; + } + $html = ob_get_clean(); + + return new Response($html, 200); + } +} diff --git a/app/Domain/Tickets/Js/ticketsController.js b/app/Domain/Tickets/Js/ticketsController.js index 7db142933c..73c53dc618 100644 --- a/app/Domain/Tickets/Js/ticketsController.js +++ b/app/Domain/Tickets/Js/ticketsController.js @@ -1,35 +1,67 @@ leantime.ticketsController = (function () { //Variables + var kanbanTicketStatusListCache = []; + var kanbanDndDebugEnabled = window.localStorage && window.localStorage.getItem('kanbanDebug') === '1'; + + function getKanbanStatusFromColumn($column) { + var classes = ($column.attr('class') || '').split(/\s+/); + for (var i = 0; i < classes.length; i++) { + if (classes[i].indexOf('status_') === 0) { + return classes[i].replace('status_', ''); + } + } + return ''; + } + function kanbanDebugLog(label, payload) { + if (!kanbanDndDebugEnabled) { + return; + } + console.log('[KanbanDnD][' + label + ']', payload); + } - //Functions - function countTickets() - { + function getKanbanCountElement(statusId) { + return jQuery(".widgettitle .count").filter(function () { + return String(jQuery(this).attr('data-status-id')) === String(statusId); + }); + } - let ticketCounts = []; - jQuery(".sortableTicketList").each(function (indexList) { - jQuery(this).find(".column").each(function (indexCol) { + function setKanbanColumnCount(statusId, value) { + var count = Math.max(0, parseInt(value, 10) || 0); + var $count = getKanbanCountElement(statusId); - if (ticketCounts[indexCol] === undefined) { - ticketCounts[indexCol] = 0; - } + $count.text(count); + $count.attr('data-total-count', count); + } - var counting = jQuery(this).find('.moveable:visible').length; - ticketCounts[indexCol] += counting; + function adjustKanbanColumnCount(statusId, delta) { + var $count = getKanbanCountElement(statusId); - }); + if (! $count.length) { + return; + } - }); + var current = parseInt($count.attr('data-total-count'), 10); + if (isNaN(current)) { + current = parseInt($count.text(), 10) || 0; + } - jQuery(".widgettitle .count").each(function (index) { - var value = ticketCounts[index]; - if (value === undefined) { - value = 0; - } - jQuery(this).text(value); + setKanbanColumnCount(statusId, current + delta); + } + + function restoreKanbanColumnCounts() { + jQuery(".widgettitle .count[data-total-count]").each(function () { + var total = parseInt(jQuery(this).attr('data-total-count'), 10) || 0; + jQuery(this).text(total); }); + } + + //Functions + function countTickets() + { + restoreKanbanColumnCounts(); } /* Legacy kanban search function - removed but kept as comment for reference @@ -622,7 +654,8 @@ leantime.ticketsController = (function () { jQuery('[data-toggle="tooltip"]').tooltip(); }; - var initEffortDropdown = function () { + var initEffortDropdown = function (scope) { + var $scope = scope ? jQuery(scope) : jQuery(document); var storyPointLabels = { '0.5': '< 2min', @@ -634,7 +667,7 @@ leantime.ticketsController = (function () { '13': "XXL" }; - jQuery(".effortDropdown .dropdown-menu a").unbind().on("click", function () { + $scope.find(".effortDropdown .dropdown-menu a").off("click.ticketEffort").on("click.ticketEffort", function () { var dataValue = jQuery(this).attr("data-value").split("_"); @@ -664,7 +697,8 @@ leantime.ticketsController = (function () { }; - var initPriorityDropdown = function () { + var initPriorityDropdown = function (scope) { + var $scope = scope ? jQuery(scope) : jQuery(document); // '1' => 'Critical', '2' => 'High', '3' => 'Medium', '4' => 'Low' var priorityLabels = { '1': 'Critical', @@ -674,7 +708,7 @@ leantime.ticketsController = (function () { '5': "Lowest" }; - jQuery(".priorityDropdown .dropdown-menu a").unbind().on("click", function () { + $scope.find(".priorityDropdown .dropdown-menu a").off("click.ticketPriority").on("click.ticketPriority", function () { var dataValue = jQuery(this).attr("data-value").split("_"); @@ -711,9 +745,10 @@ leantime.ticketsController = (function () { }; - var initMilestoneDropdown = function () { + var initMilestoneDropdown = function (scope) { + var $scope = scope ? jQuery(scope) : jQuery(document); - jQuery(".milestoneDropdown .dropdown-menu a").unbind().on("click", function () { + $scope.find(".milestoneDropdown .dropdown-menu a").off("click.ticketMilestone").on("click.ticketMilestone", function () { var dataValue = jQuery(this).attr("data-value").split("_"); var dataLabel = jQuery(this).attr('data-label'); @@ -783,9 +818,10 @@ leantime.ticketsController = (function () { }; - var initUserDropdown = function () { + var initUserDropdown = function (scope) { + var $scope = scope ? jQuery(scope) : jQuery(document); - jQuery(".userDropdown .dropdown-menu a").unbind().on("click", function () { + $scope.find(".userDropdown .dropdown-menu a").off("click.ticketUser").on("click.ticketUser", function () { var dataValue = jQuery(this).attr("data-value").split("_"); var dataLabel = jQuery(this).attr('data-label'); @@ -914,9 +950,10 @@ leantime.ticketsController = (function () { }; - var initDueDateTimePickers = function () { + var initDueDateTimePickers = function (scope) { + var $scope = scope ? jQuery(scope) : jQuery(document); // Reset due date by clicking a button on the task in the dashboard - jQuery(".date-picker-form-control .reset-button").on('click', function () { + $scope.find(".date-picker-form-control .reset-button").off('click.ticketDueReset').on('click.ticketDueReset', function () { // Ticket id for api patch call const id = jQuery(this).attr("data-id"); @@ -930,7 +967,12 @@ leantime.ticketsController = (function () { }); }); - leantime.dateController.initDatePicker(".quickDueDates, .duedates", function(date, instance) { + var $dateInputs = $scope.find(".quickDueDates, .duedates"); + if ($dateInputs.length === 0) { + return; + } + + leantime.dateController.initDatePicker($dateInputs, function(date, instance) { //TODO: Update to use htmx, this is awful var day = instance.currentDay; var month = instance.currentMonth; @@ -1176,14 +1218,19 @@ leantime.ticketsController = (function () { let height = 250; let kanbanLaneId = jQuery(this).attr("id"); + let $columns = jQuery("#"+kanbanLaneId+" .column .contentInner"); - jQuery(this).find(".column .contentInner").each(function () { - if (jQuery(this).height() > height) { - height = jQuery(this).height(); + // Reset height so measurement includes newly appended cards. + $columns.css("height", "auto"); + + $columns.each(function () { + let contentHeight = this.scrollHeight || jQuery(this).outerHeight(); + if (contentHeight > height) { + height = contentHeight; } }); - jQuery("#"+kanbanLaneId+" .column .contentInner").css("height", height); + $columns.css("height", height + "px"); }); @@ -1191,13 +1238,139 @@ leantime.ticketsController = (function () { } + var initKanbanProgressiveLoading = function () { + function updateButtonLabel($button) { + var visibleCount = parseInt($button.attr('data-visible-count'), 10) || 0; + var totalCount = parseInt($button.attr('data-total-count'), 10) || 0; + + $button.text('Load more (' + visibleCount + '/' + totalCount + ')'); + } + + function revealNextBatch($button) { + if ($button.data('isLoadingBatch')) { + return; + } + + var batchSize = parseInt($button.attr('data-batch-size'), 10) || 50; + var visibleCount = parseInt($button.attr('data-visible-count'), 10) || 0; + var totalCount = parseInt($button.attr('data-total-count'), 10) || 0; + var statusId = $button.attr('data-status-id'); + var $column = $button.closest('.contentInner'); + + if (! $column.length) { + return; + } + + var loadedCount = $column.children('.ticketBox.moveable').length; + if (loadedCount >= totalCount) { + $button.closest('.kanban-load-more-container').hide(); + return; + } + + $button.data('isLoadingBatch', true); + $button.prop('disabled', true).text('Loading...'); + + var requestData = {}; + if (window.location.search.length > 1) { + window.location.search.substring(1).split('&').forEach(function (param) { + var parts = param.split('='); + if (parts[0]) { + requestData[decodeURIComponent(parts[0])] = decodeURIComponent(parts.slice(1).join('=') || ''); + } + }); + } + + requestData.status = statusId; + requestData.limit = batchSize; + + var $lastCard = $column.children('.ticketBox.moveable').last(); + if ($lastCard.length) { + requestData.afterSortIndex = parseInt($lastCard.attr('data-kanban-sort-index'), 10) || 0; + requestData.afterTicketId = parseInt($lastCard.attr('data-ticket-id'), 10) || 0; + } + + jQuery.ajax({ + type: 'GET', + url: leantime.appUrl + '/tickets/kanbanPage/load', + data: requestData + }).done(function (html) { + var $cards = jQuery.trim(html) !== '' ? jQuery(html) : jQuery(); + if ($cards.length === 0) { + $button.closest('.kanban-load-more-container').hide(); + return; + } + + $button.closest('.kanban-load-more-container').before($cards); + var $board = $button.closest('.sortableTicketList'); + var boardSelector = '#' + $board.attr('id') + ' .contentInner'; + var $boardSortableColumns = $board.find('.contentInner.ui-sortable'); + if ($boardSortableColumns.length > 0) { + $boardSortableColumns.sortable('option', 'connectWith', boardSelector); + $boardSortableColumns.sortable('refresh'); + $boardSortableColumns.sortable('refreshPositions'); + } + // Recalculate equalized column heights so cross-column drop zones + // include newly loaded cards, not just the initial 50. + setUpKanbanColumns(); + visibleCount = Math.min(totalCount, loadedCount + $cards.filter('.ticketBox').length); + $button.attr('data-visible-count', visibleCount); + updateButtonLabel($button); + countTickets(); + + initUserDropdown($cards); + initMilestoneDropdown($cards); + initDueDateTimePickers($cards); + initEffortDropdown($cards); + initPriorityDropdown($cards); + + if (visibleCount >= totalCount) { + $button.closest('.kanban-load-more-container').hide(); + } + }).always(function () { + $button.data('isLoadingBatch', false); + $button.prop('disabled', false); + updateButtonLabel($button); + }); + } + + jQuery('.kanban-load-more').off('click.kanbanLoadMore').on('click.kanbanLoadMore', function () { + revealNextBatch(jQuery(this)); + }); + + if ('IntersectionObserver' in window) { + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) { + return; + } + + var $button = jQuery(entry.target); + if ($button.is(':visible')) { + revealNextBatch($button); + } + }); + }, { + root: null, + threshold: 0.1, + rootMargin: '0px 0px 120px 0px' + }); + + jQuery('.kanban-load-more').each(function () { + observer.observe(this); + }); + } + }; + var initTicketKanban = function (ticketStatusListParameter) { var ticketStatusList = ticketStatusListParameter; + kanbanTicketStatusListCache = Array.isArray(ticketStatusListParameter) ? ticketStatusListParameter : []; - jQuery(".sortableTicketList.kanbanBoard .ticketBox").hover(function () { + jQuery(".sortableTicketList.kanbanBoard .ticketBox") + .off("mouseenter.ticketHover mouseleave.ticketHover") + .on("mouseenter.ticketHover", function () { jQuery(this).css("background", "var(--kanban-card-hover)"); - },function () { + }).on("mouseleave.ticketHover", function () { jQuery(this).css("background", "var(--kanban-card-bg)"); }); @@ -1206,26 +1379,65 @@ leantime.ticketsController = (function () { jQuery(".sortableTicketList").each(function () { var currentElement = this; + var $sortableColumns = jQuery(currentElement).find(".contentInner"); + + $sortableColumns.each(function () { + if (jQuery(this).hasClass('ui-sortable')) { + jQuery(this).sortable('destroy'); + } + }); + + var boardSelector = '#' + jQuery(currentElement).attr('id') + ' .contentInner'; - jQuery(currentElement).find(".contentInner").sortable({ - connectWith: ".contentInner", + $sortableColumns.sortable({ + connectWith: boardSelector, items: "> .moveable", - tolerance: 'intersect', + tolerance: 'pointer', placeholder: "ui-state-highlight", forcePlaceholderSize: true, + dropOnEmpty: true, cancel: ".portlet-toggle,:input,a,input", distance: 10, start: function (event, ui) { + var $sourceColumn = ui.item.closest('.contentInner'); + ui.item.data('kanban-source-status', getKanbanStatusFromColumn($sourceColumn)); ui.item.addClass('tilt'); tilt_direction(ui.item); + kanbanDebugLog('start', { + ticketId: ui.item.attr('id'), + sourceStatus: ui.item.data('kanban-source-status') + }); + }, + receive: function (event, ui) { + var $targetColumn = jQuery(this); + kanbanDebugLog('receive', { + ticketId: ui.item.attr('id'), + sourceStatus: ui.item.data('kanban-source-status') || '', + targetStatus: getKanbanStatusFromColumn($targetColumn) + }); }, stop: function (event, ui) { ui.item.removeClass("tilt"); jQuery("html").unbind('mousemove', ui.item.data("move_handler")); ui.item.removeData("move_handler"); - countTickets(); + var $targetColumn = ui.item.closest('.contentInner'); + var sourceStatus = ui.item.data('kanban-source-status') || ''; + var targetStatus = getKanbanStatusFromColumn($targetColumn); + kanbanDebugLog('stop', { + ticketId: ui.item.attr('id'), + sourceStatus: sourceStatus, + targetStatus: targetStatus + }); + ui.item.removeData('kanban-source-status'); + + if (sourceStatus !== '' && targetStatus !== '' && sourceStatus !== targetStatus) { + adjustKanbanColumnCount(sourceStatus, -1); + adjustKanbanColumnCount(targetStatus, 1); + } else { + countTickets(); + } var statusPostData = { action: "kanbanSort", @@ -1755,6 +1967,7 @@ leantime.ticketsController = (function () { initTicketTabs:initTicketTabs, initTicketSearchSubmit:initTicketSearchSubmit, initTicketKanban:initTicketKanban, + initKanbanProgressiveLoading:initKanbanProgressiveLoading, initTicketsTable:initTicketsTable, initEffortDropdown:initEffortDropdown, initPriorityDropdown:initPriorityDropdown, diff --git a/app/Domain/Tickets/Repositories/Tickets.php b/app/Domain/Tickets/Repositories/Tickets.php index 115e21c27f..70e7dfb117 100644 --- a/app/Domain/Tickets/Repositories/Tickets.php +++ b/app/Domain/Tickets/Repositories/Tickets.php @@ -636,6 +636,370 @@ public function getAllBySearchCriteria(array $searchCriteria, string $sort = 'st return $values; } + public function getKanbanPageBySearchCriteria(array $searchCriteria, int $limit, int $offset = 0): array + { + $query = " + SELECT + zp_tickets.id, + zp_tickets.kanbanSortIndex + FROM + zp_tickets + LEFT JOIN zp_projects ON zp_tickets.projectId = zp_projects.id + LEFT JOIN zp_clients ON zp_projects.clientId = zp_clients.id + LEFT JOIN zp_user AS requestor ON requestor.id = :requestorId + LEFT JOIN zp_relationuserproject AS rup ON zp_tickets.projectId = rup.projectId AND rup.userId = :userId + WHERE ( + rup.projectId IS NOT NULL + OR zp_projects.psettings = 'all' + OR (zp_projects.psettings = 'clients' AND zp_projects.clientId = :clientId) + OR (requestor.role >= 40) + ) + "; + + if (isset($searchCriteria['dateFrom']) && $searchCriteria['dateFrom'] != '') { + $query .= ' AND zp_tickets.date > :dateFrom'; + } + + if (isset($searchCriteria['dateTo']) && $searchCriteria['dateTo'] != '') { + $query .= ' AND zp_tickets.date < :dateTo'; + } + + if (isset($searchCriteria['excludeType']) && $searchCriteria['excludeType'] != '') { + $query .= ' AND zp_tickets.type <> :excludeType'; + } + + if (isset($searchCriteria['currentProject']) && $searchCriteria['currentProject'] != '') { + $query .= ' AND zp_tickets.projectId = :projectId'; + } + + if (isset($searchCriteria['users']) && $searchCriteria['users'] != '') { + $editorIdIn = DbCore::arrayToPdoBindingString('users', count(explode(',', $searchCriteria['users']))); + $query .= ' AND zp_tickets.editorId IN('.$editorIdIn.')'; + } + + if (isset($searchCriteria['milestone']) && $searchCriteria['milestone'] != '') { + $milestoneIn = DbCore::arrayToPdoBindingString('milestone', count(explode(',', $searchCriteria['milestone']))); + $query .= ' AND zp_tickets.milestoneid IN('.$milestoneIn.')'; + } + + if (isset($searchCriteria['status']) && $searchCriteria['status'] != '') { + $query .= ' AND zp_tickets.status = :status0'; + } else { + $query .= ' AND zp_tickets.status <> -1'; + } + + if (isset($searchCriteria['type']) && $searchCriteria['type'] != '') { + $typeIn = DbCore::arrayToPdoBindingString('type', count(explode(',', strtolower($searchCriteria['type'])))); + $query .= ' AND LOWER(zp_tickets.type) IN('.$typeIn.')'; + } + + if (isset($searchCriteria['priority']) && $searchCriteria['priority'] != '') { + $priorityIn = DbCore::arrayToPdoBindingString('priority', count(explode(',', strtolower($searchCriteria['priority'])))); + $query .= ' AND LOWER(zp_tickets.priority) IN('.$priorityIn.')'; + } + + if (isset($searchCriteria['term']) && $searchCriteria['term'] != '') { + $query .= ' AND (FIND_IN_SET(:termStandard, zp_tickets.tags) OR zp_tickets.headline LIKE :termWild OR zp_tickets.description LIKE :termWild OR zp_tickets.id LIKE :termWild)'; + } + + if (isset($searchCriteria['sprint']) && $searchCriteria['sprint'] > 0 && $searchCriteria['sprint'] != 'all') { + $sprintIn = DbCore::arrayToPdoBindingString('sprint', count(explode(',', $searchCriteria['sprint']))); + $query .= ' AND zp_tickets.sprint IN('.$sprintIn.')'; + } + + if (isset($searchCriteria['sprint']) && $searchCriteria['sprint'] == 'backlog') { + $query .= " AND (zp_tickets.sprint IS NULL OR zp_tickets.sprint = '' OR zp_tickets.sprint = -1)"; + } + + if ( + isset($searchCriteria['afterSortIndex'], $searchCriteria['afterTicketId']) + && $searchCriteria['afterSortIndex'] !== null + && $searchCriteria['afterTicketId'] !== null + ) { + $query .= ' AND (zp_tickets.kanbanSortIndex > :afterSortIndex OR (zp_tickets.kanbanSortIndex = :afterSortIndex AND zp_tickets.id < :afterTicketId))'; + } + + $query .= ' + GROUP BY zp_tickets.id + ORDER BY zp_tickets.kanbanSortIndex ASC, zp_tickets.id DESC + LIMIT :limit + '; + + $query = " + SELECT + page.id, + page.kanbanSortIndex, + ticket.headline, + ticket.description, + ticket.date, + ticket.sprint, + '' AS sprintName, + ticket.storypoints, + ticket.sortindex, + ticket.dateToFinish, + ticket.projectId, + ticket.priority, + IF(ticket.type <> '', ticket.type, 'task') AS type, + ticket.status, + ticket.tags, + ticket.editorId, + ticket.dependingTicketId, + ticket.milestoneid, + ticket.planHours, + ticket.editFrom, + ticket.editTo, + ticket.hourRemaining, + 0 AS bookedHours, + zp_projects.name AS projectName, + zp_projects.projectKey AS projectKey, + COALESCE(zp_projects.incrementalTicketId, 1) AS incrementalTicketId, + ticket.id AS projectTicketNumber, + '' AS clientName, + 0 AS clientId, + 0 AS authorId, + '' AS authorLastname, + '' AS authorFirstname, + '' AS authorProfileId, + editor.firstname AS editorFirstname, + editor.lastname AS editorLastname, + editor.profileId AS editorProfileId, + milestone.headline AS milestoneHeadline, + IF((milestone.tags IS NULL OR milestone.tags = ''), 'var(--grey)', milestone.tags) AS milestoneColor, + 0 AS commentCount, + 0 AS fileCount, + 0 AS subtaskCount, + parent.headline AS parentHeadline + FROM ({$query}) AS page + INNER JOIN zp_tickets AS ticket ON ticket.id = page.id + LEFT JOIN zp_projects ON ticket.projectId = zp_projects.id + LEFT JOIN zp_user AS editor ON ticket.editorId = editor.id + LEFT JOIN zp_tickets AS milestone ON ticket.milestoneid = milestone.id AND ticket.milestoneid > 0 AND milestone.type = 'milestone' + LEFT JOIN zp_tickets AS parent ON ticket.dependingTicketId = parent.id + ORDER BY page.kanbanSortIndex ASC, ticket.id DESC + "; + + $stmn = $this->db->database->prepare($query); + + if (isset($searchCriteria['dateFrom']) && $searchCriteria['dateFrom'] != '') { + $stmn->bindValue(':dateFrom', $searchCriteria['dateFrom'], PDO::PARAM_STR); + } + + if (isset($searchCriteria['dateTo']) && $searchCriteria['dateTo'] != '') { + $stmn->bindValue(':dateTo', $searchCriteria['dateTo'], PDO::PARAM_STR); + } + + if (isset($searchCriteria['excludeType']) && $searchCriteria['excludeType'] != '') { + $stmn->bindValue(':excludeType', $searchCriteria['excludeType'], PDO::PARAM_STR); + } + + if (isset($searchCriteria['currentUser'])) { + $stmn->bindValue(':userId', $searchCriteria['currentUser'], PDO::PARAM_INT); + } else { + $stmn->bindValue(':userId', session('userdata.id') ?? '-1', PDO::PARAM_INT); + } + + if (isset($searchCriteria['currentClient'])) { + $stmn->bindValue(':clientId', $searchCriteria['currentClient'], PDO::PARAM_INT); + } else { + $stmn->bindValue(':clientId', session('userdata.clientId') ?? '-1', PDO::PARAM_INT); + } + + if (isset($searchCriteria['currentProject']) && $searchCriteria['currentProject'] != '') { + $stmn->bindValue(':projectId', $searchCriteria['currentProject'], PDO::PARAM_INT); + } + + if (isset($searchCriteria['users']) && $searchCriteria['users'] != '') { + foreach (explode(',', $searchCriteria['users']) as $key => $user) { + $stmn->bindValue(':users'.$key, $user, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['milestone']) && $searchCriteria['milestone'] != '') { + foreach (explode(',', $searchCriteria['milestone']) as $key => $milestone) { + $stmn->bindValue(':milestone'.$key, $milestone, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['status']) && $searchCriteria['status'] != '') { + $stmn->bindValue(':status0', $searchCriteria['status'], PDO::PARAM_STR); + } + + if (isset($searchCriteria['type']) && $searchCriteria['type'] != '') { + foreach (explode(',', $searchCriteria['type']) as $key => $type) { + $stmn->bindValue(':type'.$key, $type, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['priority']) && $searchCriteria['priority'] != '') { + foreach (explode(',', $searchCriteria['priority']) as $key => $priority) { + $stmn->bindValue(':priority'.$key, $priority, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['sprint']) && $searchCriteria['sprint'] > 0 && $searchCriteria['sprint'] != 'all') { + foreach (explode(',', $searchCriteria['sprint']) as $key => $sprint) { + $stmn->bindValue(':sprint'.$key, $sprint, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['term']) && $searchCriteria['term'] != '') { + $termWild = '%'.$searchCriteria['term'].'%'; + $stmn->bindValue(':termWild', $termWild, PDO::PARAM_STR); + $stmn->bindValue(':termStandard', $searchCriteria['term'], PDO::PARAM_STR); + } + + if ( + isset($searchCriteria['afterSortIndex'], $searchCriteria['afterTicketId']) + && $searchCriteria['afterSortIndex'] !== null + && $searchCriteria['afterTicketId'] !== null + ) { + $stmn->bindValue(':afterSortIndex', $searchCriteria['afterSortIndex'], PDO::PARAM_INT); + $stmn->bindValue(':afterTicketId', $searchCriteria['afterTicketId'], PDO::PARAM_INT); + } + + $stmn->bindValue(':requestorId', session('userdata.id') ?? '-1', PDO::PARAM_INT); + $stmn->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmn->execute(); + + $values = $stmn->fetchAll(PDO::FETCH_ASSOC); + $stmn->closeCursor(); + + return $values; + } + + public function getStatusCountsBySearchCriteria(array $searchCriteria): array + { + $query = " + SELECT + zp_tickets.status, + COUNT(DISTINCT zp_tickets.id) AS total + FROM + zp_tickets + LEFT JOIN zp_projects ON zp_tickets.projectId = zp_projects.id + LEFT JOIN zp_clients ON zp_projects.clientId = zp_clients.id + LEFT JOIN zp_user AS requestor ON requestor.id = :requestorId + LEFT JOIN zp_relationuserproject AS rup ON zp_tickets.projectId = rup.projectId AND rup.userId = :userId + WHERE ( + rup.projectId IS NOT NULL + OR zp_projects.psettings = 'all' + OR (zp_projects.psettings = 'clients' AND zp_projects.clientId = :clientId) + OR (requestor.role >= 40) + ) + "; + + if (isset($searchCriteria['excludeType']) && $searchCriteria['excludeType'] != '') { + $query .= ' AND zp_tickets.type <> :excludeType'; + } + + if (isset($searchCriteria['currentProject']) && $searchCriteria['currentProject'] != '') { + $query .= ' AND zp_tickets.projectId = :projectId'; + } + + if (isset($searchCriteria['users']) && $searchCriteria['users'] != '') { + $editorIdIn = DbCore::arrayToPdoBindingString('users', count(explode(',', $searchCriteria['users']))); + $query .= ' AND zp_tickets.editorId IN('.$editorIdIn.')'; + } + + if (isset($searchCriteria['milestone']) && $searchCriteria['milestone'] != '') { + $milestoneIn = DbCore::arrayToPdoBindingString('milestone', count(explode(',', $searchCriteria['milestone']))); + $query .= ' AND zp_tickets.milestoneid IN('.$milestoneIn.')'; + } + + if (isset($searchCriteria['type']) && $searchCriteria['type'] != '') { + $typeIn = DbCore::arrayToPdoBindingString('type', count(explode(',', strtolower($searchCriteria['type'])))); + $query .= ' AND LOWER(zp_tickets.type) IN('.$typeIn.')'; + } + + if (isset($searchCriteria['priority']) && $searchCriteria['priority'] != '') { + $priorityIn = DbCore::arrayToPdoBindingString('priority', count(explode(',', strtolower($searchCriteria['priority'])))); + $query .= ' AND LOWER(zp_tickets.priority) IN('.$priorityIn.')'; + } + + if (isset($searchCriteria['term']) && $searchCriteria['term'] != '') { + $query .= ' AND (FIND_IN_SET(:termStandard, zp_tickets.tags) OR zp_tickets.headline LIKE :termWild OR zp_tickets.description LIKE :termWild OR zp_tickets.id LIKE :termWild)'; + } + + if (isset($searchCriteria['sprint']) && $searchCriteria['sprint'] > 0 && $searchCriteria['sprint'] != 'all') { + $sprintIn = DbCore::arrayToPdoBindingString('sprint', count(explode(',', $searchCriteria['sprint']))); + $query .= ' AND zp_tickets.sprint IN('.$sprintIn.')'; + } + + if (isset($searchCriteria['sprint']) && $searchCriteria['sprint'] == 'backlog') { + $query .= " AND (zp_tickets.sprint IS NULL OR zp_tickets.sprint = '' OR zp_tickets.sprint = -1)"; + } + + $query .= ' AND zp_tickets.status <> -1 GROUP BY zp_tickets.status'; + + $stmn = $this->db->database->prepare($query); + + if (isset($searchCriteria['currentUser'])) { + $stmn->bindValue(':userId', $searchCriteria['currentUser'], PDO::PARAM_INT); + } else { + $stmn->bindValue(':userId', session('userdata.id') ?? '-1', PDO::PARAM_INT); + } + + if (isset($searchCriteria['currentClient'])) { + $stmn->bindValue(':clientId', $searchCriteria['currentClient'], PDO::PARAM_INT); + } else { + $stmn->bindValue(':clientId', session('userdata.clientId') ?? '-1', PDO::PARAM_INT); + } + + if (isset($searchCriteria['excludeType']) && $searchCriteria['excludeType'] != '') { + $stmn->bindValue(':excludeType', $searchCriteria['excludeType'], PDO::PARAM_STR); + } + + if (isset($searchCriteria['currentProject']) && $searchCriteria['currentProject'] != '') { + $stmn->bindValue(':projectId', $searchCriteria['currentProject'], PDO::PARAM_INT); + } + + if (isset($searchCriteria['users']) && $searchCriteria['users'] != '') { + foreach (explode(',', $searchCriteria['users']) as $key => $user) { + $stmn->bindValue(':users'.$key, $user, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['milestone']) && $searchCriteria['milestone'] != '') { + foreach (explode(',', $searchCriteria['milestone']) as $key => $milestone) { + $stmn->bindValue(':milestone'.$key, $milestone, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['type']) && $searchCriteria['type'] != '') { + foreach (explode(',', $searchCriteria['type']) as $key => $type) { + $stmn->bindValue(':type'.$key, $type, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['priority']) && $searchCriteria['priority'] != '') { + foreach (explode(',', $searchCriteria['priority']) as $key => $priority) { + $stmn->bindValue(':priority'.$key, $priority, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['sprint']) && $searchCriteria['sprint'] > 0 && $searchCriteria['sprint'] != 'all') { + foreach (explode(',', $searchCriteria['sprint']) as $key => $sprint) { + $stmn->bindValue(':sprint'.$key, $sprint, PDO::PARAM_STR); + } + } + + if (isset($searchCriteria['term']) && $searchCriteria['term'] != '') { + $termWild = '%'.$searchCriteria['term'].'%'; + $stmn->bindValue(':termWild', $termWild, PDO::PARAM_STR); + $stmn->bindValue(':termStandard', $searchCriteria['term'], PDO::PARAM_STR); + } + + $stmn->bindValue(':requestorId', session('userdata.id') ?? '-1', PDO::PARAM_INT); + $stmn->execute(); + + $counts = []; + foreach ($stmn->fetchAll(PDO::FETCH_ASSOC) as $row) { + $counts[(string) $row['status']] = (int) $row['total']; + } + $stmn->closeCursor(); + + return $counts; + } + public function simpleTicketQuery(?int $userId, ?int $projectId, array $types = []): array|false { diff --git a/app/Domain/Tickets/Services/Tickets.php b/app/Domain/Tickets/Services/Tickets.php index fad454ed01..85ea4f40bb 100644 --- a/app/Domain/Tickets/Services/Tickets.php +++ b/app/Domain/Tickets/Services/Tickets.php @@ -35,6 +35,8 @@ class Tickets { use DispatchesEvents; + private const KANBAN_PAGE_SIZE = 50; + /** * Constructor method for the class. * @@ -174,6 +176,11 @@ public function getKanbanColumns(): array return $visibleCols; } + public function getKanbanPageSize(): int + { + return self::KANBAN_PAGE_SIZE; + } + /** * @return array|string[] * @@ -751,6 +758,23 @@ public function getAllGrouped($searchCriteria): array return $ticketGroups; } + public function getKanbanTicketsForStatus(array $params, int $status, int $limit, int $offset = 0): array + { + $searchCriteria = $this->prepareTicketSearchArray($params); + $searchCriteria['orderBy'] = 'kanbansort'; + $searchCriteria['status'] = (string) $status; + $searchCriteria['afterSortIndex'] = isset($params['afterSortIndex']) ? (int) $params['afterSortIndex'] : null; + $searchCriteria['afterTicketId'] = isset($params['afterTicketId']) ? (int) $params['afterTicketId'] : null; + + $tickets = $this->ticketRepository->getKanbanPageBySearchCriteria( + $searchCriteria, + $limit, + $offset + ); + + return $tickets; + } + /** * @api */ @@ -2173,87 +2197,28 @@ public function updateTicketSorting($params) */ public function updateTicketStatusAndSorting($params, $handler = null): bool { - foreach ($params as $status => $ticketList) { - if (is_numeric($status) && ! empty($ticketList)) { - $tickets = explode('&', $ticketList); + foreach ($params as $status => $ticketList) { + if (is_numeric($status) && ! empty($ticketList)) { + $parsedTicketList = []; + parse_str((string) $ticketList, $parsedTicketList); + $tickets = $parsedTicketList['ticket'] ?? []; - if (is_array($tickets) === true) { - foreach ($tickets as $key => $ticketString) { - $id = substr($ticketString, 9); + if (! is_array($tickets)) { + return false; + } - try { - $oldTicket = $this->getTicket($id); - $oldStatus = $oldTicket ? $oldTicket->status : null; - } catch (\Exception $e) { - error_log('Error getting old ticket status: ' . $e->getMessage()); - $oldStatus = null; + foreach ($tickets as $key => $ticketId) { + $id = (int) $ticketId; + if ($id <= 0) { + continue; } - if ($this->ticketRepository->updateTicketStatus($id, $status, ($key * 100), $handler) === false) { - return false; - } - - if ($oldStatus !== null && $oldStatus != $status) { - try { - $statusLabels = $this->getStatusLabels(); - $currentUserName = session('userdata.name') ?? 'Unknown User'; - - $oldStatusText = $statusLabels[$oldStatus]['name'] ?? 'Unknown'; - $newStatusText = $statusLabels[$status]['name'] ?? 'Unknown'; - - $ticketHistoryModel = app()->make(\Leantime\Domain\Tickets\Models\TicketHistoryModel::class); - - $ticketHistoryModel->addStatusChange( - $id, - $oldStatus, - $status, - $oldStatusText, - $newStatusText, - $currentUserName, - 'kanban-board' - ); - } catch (\Exception $e) { - error_log('Failed to log kanban status change: ' . $e->getMessage()); - } - - // Auto-unpin if moved to different column - if (session()->exists('currentProject')) { - $projectId = (int) session('currentProject'); - if ($this->isTicketPinned($id, $projectId)) { - $this->unpinTicket($id, $projectId); - } - } + if ($this->ticketRepository->updateTicketStatus($id, $status, ($key * 100), $handler) === false) { + return false; } } } } - } - - if ($handler) { - $id = substr($handler, 7); - - $ticket = $this->getTicket($id); - - if ($ticket) { - $subject = sprintf($this->language->__('email_notifications.todo_update_subject'), $id, strip_tags($ticket->headline)); - $actual_link = BASE_URL.'/dashboard/home#/tickets/showTicket/'.$id; - $message = sprintf('%1$s has the ticket ready to test: \'%2$s\'', session('userdata.name'), strip_tags($ticket->headline)); - - $notification = app()->make(NotificationModel::class); - $notification->url = [ - 'url' => $actual_link, - 'text' => $this->language->__('email_notifications.todo_update_cta'), - ]; - $notification->entity = $ticket; - $notification->module = 'tickets'; - $notification->projectId = $ticket->projectId ?? session('currentProject') ?? -1; - $notification->subject = $subject; - $notification->authorId = session('userdata.id') ?? -1; - $notification->message = $message; - - $this->projectService->notifyProjectUsers($notification); - } - } self::dispatchEvent('ticket_updated'); @@ -2556,7 +2521,34 @@ public function getTicketTemplateAssignments($params): array $searchCriteria = $this->prepareTicketSearchArray($params); $searchCriteria['orderBy'] = 'kanbansort'; - $allTickets = $this->getAllGrouped($searchCriteria); + $kanbanPageSize = self::KANBAN_PAGE_SIZE; + $kanbanStatusCounts = []; + $usesDefaultKanbanGrouping = $searchCriteria['groupBy'] == null + || $searchCriteria['groupBy'] == '' + || $searchCriteria['groupBy'] == 'all'; + + if ($usesDefaultKanbanGrouping) { + $allTickets = []; + $kanbanStatusCounts = $this->ticketRepository->getStatusCountsBySearchCriteria($searchCriteria); + + foreach ($this->getKanbanColumns() as $status => $column) { + $allTickets = array_merge( + $allTickets, + $this->getKanbanTicketsForStatus($params, (int) $status, $kanbanPageSize) + ); + } + + $allTickets = [ + 'all' => [ + 'label' => 'all', + 'id' => 'all', + 'class' => '', + 'items' => $allTickets, + ], + ]; + } else { + $allTickets = $this->getAllGrouped($searchCriteria); + } $allTicketStates = $this->getStatusLabels(); $efforts = $this->getEffortLabels(); @@ -2598,6 +2590,8 @@ public function getTicketTemplateAssignments($params): array 'currentSprint' => session('currentSprint'), 'searchCriteria' => $searchCriteria, 'allTickets' => $allTickets, + 'kanbanPageSize' => $kanbanPageSize, + 'kanbanStatusCounts' => $kanbanStatusCounts, 'allTicketStates' => $allTicketStates, 'efforts' => $efforts, 'priorities' => $priorities, diff --git a/app/Domain/Tickets/Templates/partials/kanbanTicketCard.tpl.php b/app/Domain/Tickets/Templates/partials/kanbanTicketCard.tpl.php new file mode 100644 index 0000000000..f3d1b101ca --- /dev/null +++ b/app/Domain/Tickets/Templates/partials/kanbanTicketCard.tpl.php @@ -0,0 +1,203 @@ +get('allKanbanColumns'); +$columnKeys = array_keys($allColumns); +$isFirstColumn = $key === $columnKeys[0]; +$isLastColumn = $key === end($columnKeys); +?> +