421 lines
16 KiB
JavaScript
421 lines
16 KiB
JavaScript
leantime.kanbanController = (function () {
|
|
|
|
/**
|
|
* Toggle quick-add form visibility
|
|
* @param {HTMLElement} triggerElement - The link element that triggered the toggle
|
|
*/
|
|
var toggleQuickAdd = function(triggerElement) {
|
|
var container = triggerElement.closest('.quickaddContainer');
|
|
var form = container.querySelector('[data-quickadd-form]');
|
|
var input = form.querySelector('[data-quickadd-input]');
|
|
var link = container.querySelector('.quickAddLink');
|
|
|
|
var isVisible = form.classList.contains('active');
|
|
|
|
if (isVisible) {
|
|
// Hide form, show link
|
|
form.classList.remove('active');
|
|
form.style.display = 'none';
|
|
form.dataset.submitting = 'false';
|
|
link.style.display = '';
|
|
link.setAttribute('aria-expanded', 'false');
|
|
} else {
|
|
// Show form, hide link
|
|
form.classList.add('active');
|
|
form.style.display = 'block';
|
|
form.dataset.submitting = 'false';
|
|
link.style.display = 'none';
|
|
link.setAttribute('aria-expanded', 'true');
|
|
input.focus();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Initialize keyboard interactions for quick-add forms
|
|
* - Enter: Save and stay open
|
|
* - Shift+Enter: Save and close
|
|
* - Escape: Cancel
|
|
*/
|
|
var initQuickAddKeyboard = function() {
|
|
document.addEventListener('keydown', function(e) {
|
|
var input = e.target;
|
|
if (!input.matches('[data-quickadd-input]')) return;
|
|
|
|
var form = input.closest('[data-quickadd-form]');
|
|
|
|
// Prevent multiple submissions
|
|
if (form.dataset.submitting === 'true') {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
var stayOpenInput = form.querySelector('[data-stay-open-input]');
|
|
|
|
// Enter: Save and close
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
form.dataset.submitting = 'true';
|
|
stayOpenInput.value = '0';
|
|
form.submit();
|
|
}
|
|
|
|
// Shift+Enter: Save and stay open
|
|
if (e.key === 'Enter' && e.shiftKey) {
|
|
e.preventDefault();
|
|
form.dataset.submitting = 'true';
|
|
stayOpenInput.value = '1';
|
|
form.submit();
|
|
}
|
|
|
|
// Escape: Cancel
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
var container = form.closest('.quickaddContainer');
|
|
var link = container.querySelector('.quickAddLink');
|
|
toggleQuickAdd(link);
|
|
input.value = '';
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize click outside behavior
|
|
* - If form has text: save and close
|
|
* - If form is empty: just close
|
|
*/
|
|
var initClickOutsideSave = function() {
|
|
document.addEventListener('click', function(e) {
|
|
var forms = document.querySelectorAll('[data-quickadd-form].active');
|
|
|
|
forms.forEach(function(form) {
|
|
var container = form.closest('.quickaddContainer');
|
|
var link = container.querySelector('.quickAddLink');
|
|
var input = form.querySelector('[data-quickadd-input]');
|
|
var isClickInside = form.contains(e.target) || link.contains(e.target);
|
|
|
|
// Prevent action if already submitting
|
|
if (form.dataset.submitting === 'true') {
|
|
return;
|
|
}
|
|
|
|
if (!isClickInside && input.value.trim() !== '') {
|
|
// Save if there's text
|
|
var stayOpenInput = form.querySelector('[data-stay-open-input]');
|
|
stayOpenInput.value = '0';
|
|
form.dataset.submitting = 'true';
|
|
form.submit();
|
|
} else if (!isClickInside) {
|
|
// Just close if empty
|
|
toggleQuickAdd(link);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Equalize column heights within a swimlane content area
|
|
* Sets all columns to the max height for consistent appearance
|
|
* @param {HTMLElement} contentElement - The swimlane content container
|
|
*/
|
|
var equalizeColumnHeights = function(contentElement) {
|
|
var columns = contentElement.querySelectorAll('.column .contentInner');
|
|
if (columns.length === 0) return;
|
|
|
|
// Reset ALL height styles first to get natural heights
|
|
// This includes height set by setUpKanbanColumns() in ticketsController
|
|
columns.forEach(function(col) {
|
|
col.style.minHeight = '';
|
|
col.style.height = '';
|
|
});
|
|
|
|
// Find max height from natural content
|
|
var maxHeight = 0;
|
|
columns.forEach(function(col) {
|
|
var height = col.offsetHeight;
|
|
if (height > maxHeight) {
|
|
maxHeight = height;
|
|
}
|
|
});
|
|
|
|
// Set all columns to max height using minHeight (allows growth if needed)
|
|
if (maxHeight > 0) {
|
|
columns.forEach(function(col) {
|
|
col.style.minHeight = maxHeight + 'px';
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reset column heights to natural height
|
|
* @param {HTMLElement} contentElement - The swimlane content container
|
|
*/
|
|
var resetColumnHeights = function(contentElement) {
|
|
var columns = contentElement.querySelectorAll('.column .contentInner');
|
|
columns.forEach(function(col) {
|
|
col.style.minHeight = '';
|
|
col.style.height = ''; // Clear height set by setUpKanbanColumns()
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize column heights for all expanded swimlanes on page load
|
|
* Uses requestAnimationFrame to ensure DOM is fully rendered before measuring
|
|
*/
|
|
var initExpandedColumnHeights = function() {
|
|
// Wait for next frame to ensure DOM is painted
|
|
requestAnimationFrame(function() {
|
|
// Double RAF for extra safety (after layout + paint)
|
|
requestAnimationFrame(function() {
|
|
var expandedContents = document.querySelectorAll('.kanban-swimlane-content:not(.collapsed)');
|
|
expandedContents.forEach(function(content) {
|
|
equalizeColumnHeights(content);
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Toggle swimlane collapse/expand
|
|
* Two states only:
|
|
* - Expanded: full ticket cards
|
|
* - Collapsed: compact ticket cards
|
|
* @param {string} swimlaneId - Swimlane identifier
|
|
*/
|
|
var toggleSwimlane = function(swimlaneId) {
|
|
var row = document.getElementById('swimlane-row-' + swimlaneId);
|
|
var sidebar = document.querySelector('.kanban-swimlane-sidebar[data-swimlane-id="' + swimlaneId + '"]');
|
|
var content = document.getElementById('swimlane-content-' + swimlaneId);
|
|
|
|
if (!row || !sidebar || !content) {
|
|
console.error('Swimlane elements not found for ID:', swimlaneId);
|
|
return;
|
|
}
|
|
|
|
var isExpanded = sidebar.getAttribute('aria-expanded') === 'true';
|
|
var newExpanded = !isExpanded;
|
|
|
|
// Update aria-expanded on sidebar
|
|
sidebar.setAttribute('aria-expanded', newExpanded.toString());
|
|
|
|
// Update chevron icon
|
|
var chevronIcon = sidebar.querySelector('.kanban-lane-chevron i');
|
|
if (chevronIcon) {
|
|
chevronIcon.className = newExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right';
|
|
}
|
|
|
|
// Update data-expanded attribute on row (CSS uses this for styling)
|
|
row.setAttribute('data-expanded', newExpanded.toString());
|
|
|
|
// Toggle state on content area
|
|
if (newExpanded) {
|
|
content.classList.remove('collapsed');
|
|
// Equalize column heights so empty columns match tallest
|
|
equalizeColumnHeights(content);
|
|
} else {
|
|
content.classList.add('collapsed');
|
|
// Equalize column heights when collapsed - maintain visual alignment
|
|
equalizeColumnHeights(content);
|
|
}
|
|
|
|
// Persist state to session via JSON-RPC
|
|
leantime.rpc('Api.Api.setSubmenuState', {
|
|
submenu: 'swimlane_' + swimlaneId,
|
|
state: newExpanded ? 'open' : 'closed'
|
|
}).catch(function (error) {
|
|
console.error('Could not persist swimlane state', error);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize keyboard support for swimlane headers
|
|
* - Enter/Space: Toggle swimlane
|
|
* - Arrow keys: Navigate between swimlanes
|
|
*/
|
|
var initSwimlaneKeyboard = function() {
|
|
document.addEventListener('keydown', function(e) {
|
|
var header = e.target.closest('[data-swimlane-id]');
|
|
if (!header) return;
|
|
|
|
var swimlaneId = header.getAttribute('data-swimlane-id');
|
|
|
|
// Enter or Space to toggle
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
toggleSwimlane(swimlaneId);
|
|
}
|
|
|
|
// Arrow Up/Down to navigate between swimlanes
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
var allHeaders = Array.from(document.querySelectorAll('[data-swimlane-id]'));
|
|
var currentIndex = allHeaders.indexOf(header);
|
|
|
|
if (e.key === 'ArrowDown' && currentIndex < allHeaders.length - 1) {
|
|
allHeaders[currentIndex + 1].focus();
|
|
} else if (e.key === 'ArrowUp' && currentIndex > 0) {
|
|
allHeaders[currentIndex - 1].focus();
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize Tippy.js tooltips for swimlane header elements
|
|
*/
|
|
var initProgressBarTooltips = function() {
|
|
if (typeof tippy === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
var tippyConfig = {
|
|
allowHTML: true,
|
|
placement: 'top',
|
|
arrow: true,
|
|
theme: 'light-border',
|
|
trigger: 'mouseenter focus',
|
|
delay: [500, 0] // 500ms show delay, 0ms hide delay
|
|
};
|
|
|
|
// All swimlane header elements with tooltips
|
|
var tooltipSelectors = [
|
|
'.status-segment[data-tippy-content]', // Progress bar segments
|
|
'.time-indicator[data-tippy-content]', // Time indicators (overdue, due soon, stale)
|
|
'.kanban-lane-count[data-tippy-content]', // Count badges
|
|
'.tshirt-icon[data-tippy-content]', // Effort icons
|
|
'.thermometer-icon[data-tippy-content]', // Priority icons
|
|
'.user-avatar[data-tippy-content]', // User avatars
|
|
'.type-icon[data-tippy-content]', // Type icons
|
|
'.sprint-icon[data-tippy-content]', // Sprint icons
|
|
'.milestone-icon[data-tippy-content]', // Milestone icons
|
|
'.swimlane-header-label[data-tippy-content]' // Swimlane labels
|
|
];
|
|
|
|
tooltipSelectors.forEach(function(selector) {
|
|
var elements = document.querySelectorAll(selector);
|
|
if (elements.length > 0) {
|
|
tippy(elements, tippyConfig);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize tap-to-reveal column counts on mobile/touch devices
|
|
* Toggles .count-visible class on .widgettitle elements
|
|
*/
|
|
var initMobileColumnCountToggle = function() {
|
|
// Only on touch devices
|
|
if (!('ontouchstart' in window)) return;
|
|
|
|
document.querySelectorAll('.widgettitle').forEach(function(header) {
|
|
header.addEventListener('click', function(e) {
|
|
// Don't interfere with dropdown clicks
|
|
if (e.target.closest('.dropdown-toggle, .dropdown-menu')) return;
|
|
|
|
// Toggle visibility
|
|
this.classList.toggle('count-visible');
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize column heights for all collapsed swimlanes on page load
|
|
* Equalize heights so columns visually align even when collapsed
|
|
*/
|
|
var initCollapsedColumnHeights = function() {
|
|
var collapsedContents = document.querySelectorAll('.kanban-swimlane-content.collapsed');
|
|
collapsedContents.forEach(function(content) {
|
|
equalizeColumnHeights(content);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initialize sticky swimlane sidebars using scroll listener and transform
|
|
* Uses transform instead of position:fixed to avoid layout shifts
|
|
*/
|
|
var initStickySwimlaneSidebars = function() {
|
|
var rows = document.querySelectorAll('.kanban-swimlane-row');
|
|
if (rows.length === 0) return;
|
|
|
|
// Skip on mobile (vertical layout doesn't need sticky)
|
|
if (window.innerWidth <= 768) return;
|
|
|
|
var STICKY_TOP = 120; // Distance from viewport top when sticky
|
|
|
|
var updateStickyPositions = function() {
|
|
rows.forEach(function(row) {
|
|
var sidebar = row.querySelector('.kanban-swimlane-sidebar');
|
|
var sidebarInner = row.querySelector('.kanban-swimlane-sidebar-inner');
|
|
var sentinel = row.querySelector('.kanban-swimlane-sentinel');
|
|
if (!sidebar || !sidebarInner || !sentinel) return;
|
|
|
|
var sentinelRect = sentinel.getBoundingClientRect();
|
|
var sidebarRect = sidebar.getBoundingClientRect();
|
|
|
|
// Calculate if sidebar content should be sticky
|
|
var shouldBeSticky = sentinelRect.top < STICKY_TOP && sidebarRect.bottom > (STICKY_TOP + 100);
|
|
|
|
if (shouldBeSticky) {
|
|
// Calculate how much to translate the inner content
|
|
var translateY = STICKY_TOP - sidebarRect.top;
|
|
|
|
// Don't translate beyond the sidebar bottom
|
|
var maxTranslate = sidebarRect.height - sidebarInner.offsetHeight - 12;
|
|
translateY = Math.min(translateY, Math.max(0, maxTranslate));
|
|
|
|
sidebar.classList.add('is-sticky');
|
|
sidebarInner.style.transform = 'translateY(' + translateY + 'px)';
|
|
} else {
|
|
sidebar.classList.remove('is-sticky');
|
|
sidebarInner.style.transform = '';
|
|
}
|
|
});
|
|
};
|
|
|
|
// Update on scroll
|
|
window.addEventListener('scroll', updateStickyPositions, { passive: true });
|
|
|
|
// Initial check
|
|
updateStickyPositions();
|
|
};
|
|
|
|
// Initialize on page load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initQuickAddKeyboard();
|
|
initClickOutsideSave();
|
|
initSwimlaneKeyboard();
|
|
initProgressBarTooltips();
|
|
initStickySwimlaneSidebars();
|
|
initMobileColumnCountToggle();
|
|
initCollapsedColumnHeights();
|
|
initExpandedColumnHeights();
|
|
});
|
|
} else {
|
|
initQuickAddKeyboard();
|
|
initClickOutsideSave();
|
|
initSwimlaneKeyboard();
|
|
initProgressBarTooltips();
|
|
initStickySwimlaneSidebars();
|
|
initMobileColumnCountToggle();
|
|
initCollapsedColumnHeights();
|
|
initExpandedColumnHeights();
|
|
}
|
|
|
|
// Make public what you want to have public, everything else is private
|
|
return {
|
|
toggleQuickAdd: toggleQuickAdd,
|
|
initQuickAddKeyboard: initQuickAddKeyboard,
|
|
initClickOutsideSave: initClickOutsideSave,
|
|
toggleSwimlane: toggleSwimlane,
|
|
initSwimlaneKeyboard: initSwimlaneKeyboard,
|
|
initProgressBarTooltips: initProgressBarTooltips,
|
|
equalizeColumnHeights: equalizeColumnHeights,
|
|
resetColumnHeights: resetColumnHeights,
|
|
initCollapsedColumnHeights: initCollapsedColumnHeights,
|
|
initExpandedColumnHeights: initExpandedColumnHeights,
|
|
initStickySwimlaneSidebars: initStickySwimlaneSidebars,
|
|
initMobileColumnCountToggle: initMobileColumnCountToggle
|
|
};
|
|
|
|
})();
|