/** * Toggle visibility groups field based on visibility selection */ function toggleVisibilityGroupsEdit() { const visibility = document.getElementById('visibilitySelect')?.value; const groupsField = document.getElementById('visibilityGroupsField'); if (groupsField) { groupsField.classList.toggle('is-hidden', visibility !== 'internal'); } } /** * Get selected visibility groups */ function getSelectedVisibilityGroups() { const checkboxes = document.querySelectorAll('.visibility-group-checkbox:checked'); return Array.from(checkboxes).map(cb => cb.value); } function saveTicket() { const editables = document.querySelectorAll('.editable'); const data = {}; const ticketId = getTicketIdFromUrl(); if (!ticketId) { return; } editables.forEach(field => { if (field.dataset.field) { // For contenteditable divs, use textContent/innerText; for inputs/textareas, use value if (field.hasAttribute('contenteditable')) { data[field.dataset.field] = field.textContent.trim(); } else { data[field.dataset.field] = field.value; } } }); // Get visibility settings const visibilitySelect = document.getElementById('visibilitySelect'); if (visibilitySelect) { data.visibility = visibilitySelect.value; if (data.visibility === 'internal') { data.visibility_groups = getSelectedVisibilityGroups(); } } // Include optimistic lock timestamp so the server can detect concurrent edits if (window.ticketData && window.ticketData.updated_at) { data.expected_updated_at = window.ticketData.updated_at; } // Use the correct API path lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data }) .then(resp => { if (resp.success) { const statusDisplay = document.getElementById('statusDisplay'); if (statusDisplay) { statusDisplay.className = `status-${resp.status}`; statusDisplay.textContent = resp.status; } // Keep local updated_at in sync so the next save uses the right lock key if (resp.updated_at && window.ticketData) { window.ticketData.updated_at = resp.updated_at; } lt.toast.success('Ticket updated successfully'); } else if (resp.conflict) { lt.toast.error('This ticket was modified by someone else while you were editing. Reload to see the latest version.', 8000); } else { lt.toast.error('Error saving ticket: ' + (resp.error || 'Unknown error')); } }) .catch(error => { lt.toast.error('Error saving ticket: ' + error.message); }); } // ── Description read/edit helpers ──────────────────────────────────────────── // Read mode: styled lt-markdown div (full contrast, even on OLED). // Edit mode: raw textarea (enabled for editing). function renderDescriptionView() { var viewDiv = document.getElementById('ticketDescriptionView'); var textarea = document.querySelector('textarea[data-field="description"]'); if (!viewDiv || !textarea) return; var raw = textarea.value || ''; if (!raw.trim()) { viewDiv.innerHTML = '
No description provided.
'; } else { // Ticket descriptions are plain text. CSS white-space:pre-wrap handles // line breaks and multiple spaces (ASCII art) — no${lt.escHtml(message)}
`; } if (dependentsList) { dependentsList.innerHTML = `${lt.escHtml(message)}
`; } } function _depStatusBadge(status) { const slug = (status || '').toLowerCase().replace(/ /g, '-'); const cls = status === 'Closed' ? 'lt-badge-closed' : status === 'Open' ? 'lt-badge-open' : 'lt-badge-sm'; return `${lt.escHtml(status)}`; } function renderDependencies(dependencies) { const container = document.getElementById('dependenciesList'); if (!container) return; const typeLabels = { 'blocks': 'Blocks', 'blocked_by': 'Blocked By', 'relates_to': 'Relates To', 'duplicates': 'Duplicates' }; // Check for open "blocked_by" dependencies — show alert const blockers = (dependencies['blocked_by'] || []).filter(d => d.status !== 'Closed'); const blockerAlert = document.getElementById('blockerAlert'); if (blockers.length > 0) { const alertHtml = `No dependencies configured.
'; } function renderDependents(dependents) { const container = document.getElementById('dependentsList'); if (!container) return; if (!dependents.length) { container.innerHTML = 'No tickets depend on this one.
'; return; } const relLabels = { 'blocks':'blocks', 'blocked_by':'blocked by', 'relates_to':'relates to', 'duplicates':'duplicates' }; let html = ''; dependents.forEach(dep => { const relLabel = relLabels[dep.dependency_type] || dep.dependency_type; html += `Error loading attachments.
'; } }) .catch(error => { container.innerHTML = 'Error loading attachments.
'; }); } function renderAttachments(attachments) { const container = document.getElementById('attachmentsList'); if (!container) return; if (attachments.length === 0) { container.innerHTML = 'No files attached to this ticket.
'; return; } let html = ''; container.innerHTML = html; // Initialize lightbox on image thumbnails if (window.lt && lt.lightbox) { lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true }); } } function deleteAttachment(attachmentId) { showConfirmModal( 'Delete Attachment', 'Are you sure you want to delete this attachment?', 'warning', function() { lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId }) .then(data => { if (data.success) { lt.toast.success('Attachment deleted', 3000); loadAttachments(); } else { lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000); } }) .catch(error => { lt.toast.error('Error deleting attachment', 4000); }); } ); } // ======================================== // @Mention Autocomplete Functions // ======================================== let mentionAutocomplete = null; let mentionUsers = []; let mentionStartPos = -1; let selectedMentionIndex = 0; /** * Initialize mention autocomplete for a textarea */ function initMentionAutocomplete() { const textarea = document.getElementById('newComment'); if (!textarea) return; // Create autocomplete dropdown mentionAutocomplete = document.createElement('div'); mentionAutocomplete.className = 'mention-autocomplete'; mentionAutocomplete.id = 'mentionAutocomplete'; mentionAutocomplete.setAttribute('role', 'listbox'); mentionAutocomplete.setAttribute('aria-label', 'User suggestions'); textarea.setAttribute('aria-autocomplete', 'list'); textarea.setAttribute('aria-controls', 'mentionAutocomplete'); textarea.setAttribute('aria-expanded', 'false'); textarea.parentElement.classList.add('has-overlay'); textarea.parentElement.appendChild(mentionAutocomplete); // Fetch users list fetchMentionUsers(); // Input event to detect @ symbol textarea.addEventListener('input', handleMentionInput); textarea.addEventListener('keydown', handleMentionKeydown); textarea.addEventListener('blur', () => { // Delay hiding to allow click on option setTimeout(hideMentionAutocomplete, 200); }); } /** * Fetch available users for mentions */ function fetchMentionUsers() { lt.api.get('/api/get_users.php') .then(data => { if (data.success && data.users) { mentionUsers = data.users; } }) .catch(() => { /* silently ignore mention user fetch failures */ }); } /** * Handle input events to detect @ mentions */ function handleMentionInput(e) { const textarea = e.target; const text = textarea.value; const cursorPos = textarea.selectionStart; // Find @ symbol before cursor. Only trigger when the @ is at a word boundary // (start of input or preceded by whitespace) so it does not fire inside email // addresses like foo@bar. let atPos = -1; for (let i = cursorPos - 1; i >= 0; i--) { const char = text[i]; if (char === '@') { const prev = i > 0 ? text[i - 1] : ''; if (i === 0 || /\s/.test(prev)) { atPos = i; } break; } if (char === ' ' || char === '\n') { break; } } if (atPos >= 0) { const query = text.substring(atPos + 1, cursorPos).toLowerCase(); mentionStartPos = atPos; showMentionSuggestions(query, textarea); } else { hideMentionAutocomplete(); } } /** * Handle keyboard navigation in autocomplete */ function handleMentionKeydown(e) { if (!mentionAutocomplete || !mentionAutocomplete.classList.contains('active')) { return; } const options = mentionAutocomplete.querySelectorAll('.mention-option'); switch (e.key) { case 'ArrowDown': e.preventDefault(); selectedMentionIndex = Math.min(selectedMentionIndex + 1, options.length - 1); updateMentionSelection(options); break; case 'ArrowUp': e.preventDefault(); selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0); updateMentionSelection(options); break; case 'Enter': case 'Tab': e.preventDefault(); if (options[selectedMentionIndex]) { selectMention(options[selectedMentionIndex].dataset.username); } break; case 'Escape': hideMentionAutocomplete(); break; } } /** * Update visual selection in autocomplete */ function updateMentionSelection(options) { options.forEach((opt, i) => { const isSelected = i === selectedMentionIndex; opt.classList.toggle('selected', isSelected); opt.setAttribute('aria-selected', isSelected ? 'true' : 'false'); }); } /** * Show mention suggestions */ function showMentionSuggestions(query, textarea) { const filtered = mentionUsers.filter(user => { const username = (user.username || '').toLowerCase(); const displayName = (user.display_name || '').toLowerCase(); return username.includes(query) || displayName.includes(query); }).slice(0, 5); if (filtered.length === 0) { hideMentionAutocomplete(); return; } let html = ''; filtered.forEach((user, index) => { const isSelected = index === 0 ? 'selected' : ''; const ariaSelected = index === 0 ? 'true' : 'false'; html += `