/**
* 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 replacement needed.
viewDiv.innerHTML = lt.escHtml(raw);
}
}
function showDescriptionView() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = '';
if (t) t.style.display = 'none';
}
function showDescriptionEdit() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = 'none';
if (t) t.style.display = '';
}
function toggleEditMode() {
const editButton = document.getElementById('editButton');
const titleField = document.querySelector('.title-input');
const descriptionField = document.querySelector('textarea[data-field="description"]');
const metadataFields = document.querySelectorAll('.editable-metadata');
const isEditing = editButton.classList.contains('active');
if (!isEditing) {
editButton.textContent = 'Save Changes';
editButton.classList.add('active');
// Enable title (contenteditable div)
if (titleField) {
titleField.setAttribute('contenteditable', 'true');
titleField.focus();
}
// Enable description (swap to textarea)
if (descriptionField) {
showDescriptionEdit();
descriptionField.disabled = false;
descriptionField.style.height = 'auto';
descriptionField.style.height = descriptionField.scrollHeight + 'px';
}
// Enable metadata fields (priority, category, type) — remove display-only class
metadataFields.forEach(field => {
field.classList.remove('lt-display-field');
});
// Show edit-mode selects for category/type, hide their read-mode tags
document.querySelectorAll('.read-mode-tag').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = ''; });
} else {
saveTicket();
editButton.textContent = 'Edit Ticket';
editButton.classList.remove('active');
// Disable title
if (titleField) {
titleField.setAttribute('contenteditable', 'false');
}
// Re-render description view div with latest content
if (descriptionField) {
descriptionField.disabled = true;
renderDescriptionView();
showDescriptionView();
}
// Return metadata fields to display-only using .lt-display-field (not disabled)
metadataFields.forEach(field => {
field.classList.add('lt-display-field');
});
// Hide edit-mode selects, show and update read-mode tags
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = 'none'; });
var catSel = document.getElementById('categorySelect');
var typSel = document.getElementById('typeSelect');
var catTag = document.getElementById('categoryTag');
var typTag = document.getElementById('typeTag');
if (catTag) {
if (catSel) catTag.textContent = catSel.options[catSel.selectedIndex].text;
catTag.style.display = '';
}
if (typTag) {
if (typSel) typTag.textContent = typSel.options[typSel.selectedIndex].text;
typTag.style.display = '';
}
document.querySelectorAll('.read-mode-tag:not(#categoryTag):not(#typeTag)').forEach(el => { el.style.display = ''; });
}
}
function addComment() {
const newComment = document.getElementById('newComment');
if (!newComment) return;
const commentText = newComment.value;
if (!commentText.trim()) {
return;
}
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
return;
}
const markdownMaster = document.getElementById('markdownMaster');
const isMarkdownEnabled = markdownMaster ? markdownMaster.checked : false;
lt.api.post('/api/add_comment.php', {
ticket_id: ticketId,
comment_text: commentText,
markdown_enabled: isMarkdownEnabled
})
.then(data => {
if(data.success) {
// Clear the comment box
const nc = document.getElementById('newComment');
if (nc) nc.value = '';
// Format the comment text for display
let displayText;
if (isMarkdownEnabled) {
// For markdown, use parseMarkdown (sanitizes HTML)
displayText = parseMarkdown(commentText);
} else {
// For non-markdown, convert line breaks to and escape HTML
displayText = commentText
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\n/g, ' ');
}
// Add new comment to the list (using safe DOM API to prevent XSS)
const commentsList = document.querySelector('.comments-list');
const commentDiv = document.createElement('div');
commentDiv.className = 'comment';
const headerDiv = document.createElement('div');
headerDiv.className = 'comment-header';
const userSpan = document.createElement('span');
userSpan.className = 'comment-user';
userSpan.textContent = data.user_name; // Safe - auto-escapes
const dateSpan = document.createElement('span');
dateSpan.className = 'comment-date';
dateSpan.textContent = data.created_at; // Safe - auto-escapes
const textDiv = document.createElement('div');
textDiv.className = 'comment-text';
textDiv.innerHTML = displayText; // displayText already sanitized above
headerDiv.appendChild(userSpan);
headerDiv.appendChild(dateSpan);
commentDiv.appendChild(headerDiv);
commentDiv.appendChild(textDiv);
commentsList.insertBefore(commentDiv, commentsList.firstChild);
} else {
lt.toast.error(data.error || 'Failed to add comment');
}
})
.catch(error => {
lt.toast.error('Error adding comment: ' + error.message);
});
}
function togglePreview() {
const preview = document.getElementById('markdownPreview');
const textarea = document.getElementById('newComment');
const toggleEl = document.getElementById('markdownToggle');
if (!preview || !textarea || !toggleEl) return;
const isPreviewEnabled = toggleEl.checked;
preview.classList.toggle('is-hidden', !isPreviewEnabled);
if (isPreviewEnabled) {
preview.innerHTML = parseMarkdown(textarea.value);
textarea.addEventListener('input', updatePreview);
} else {
textarea.removeEventListener('input', updatePreview);
}
}
function updatePreview() {
const textarea = document.getElementById('newComment');
const previewDiv = document.getElementById('markdownPreview');
const masterEl = document.getElementById('markdownMaster');
if (!textarea || !previewDiv || !masterEl) return;
const commentText = textarea.value;
const isMarkdownEnabled = masterEl.checked;
if (isMarkdownEnabled && commentText.trim()) {
previewDiv.innerHTML = parseMarkdown(commentText);
previewDiv.classList.remove('is-hidden');
} else {
previewDiv.classList.add('is-hidden');
}
}
function toggleMarkdownMode() {
const previewToggle = document.getElementById('markdownToggle');
const masterEl = document.getElementById('markdownMaster');
if (!previewToggle || !masterEl) return;
const isMasterEnabled = masterEl.checked;
previewToggle.disabled = !isMasterEnabled;
if (!isMasterEnabled) {
previewToggle.checked = false;
const preview = document.getElementById('markdownPreview');
if (preview) preview.classList.add('is-hidden');
}
}
document.addEventListener('DOMContentLoaded', function() {
// Show description tab by default
showTab('description');
// Populate and show description view div on page load
renderDescriptionView();
showDescriptionView();
// Auto-resize function for textareas
function autoResizeTextarea(textarea) {
// Reset height to auto to get the correct scrollHeight
textarea.style.height = 'auto';
// Set the height to match the scrollHeight
textarea.style.height = textarea.scrollHeight + 'px';
}
// Auto-resize the description textarea to fit content
const descriptionTextarea = document.querySelector('textarea[data-field="description"]');
if (descriptionTextarea) {
// Initial resize
autoResizeTextarea(descriptionTextarea);
// Resize on input when in edit mode
descriptionTextarea.addEventListener('input', function() {
autoResizeTextarea(this);
});
}
// Initialize assignment handling
handleAssignmentChange();
// Initialize metadata field handlers (priority, category, type)
handleMetadataChanges();
});
/**
* Handle ticket assignment dropdown changes
*/
function handleAssignmentChange() {
const assignedToSelect = document.getElementById('assignedToSelect');
if (!assignedToSelect) return;
assignedToSelect.addEventListener('change', function() {
const ticketId = window.ticketData.id;
const assignedTo = this.value || null;
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
.then(data => {
if (!data.success) {
lt.toast.error('Error updating assignment');
}
})
.catch(error => {
lt.toast.error('Error updating assignment: ' + error.message);
});
});
}
/**
* Handle metadata field changes (priority, category, type)
*/
function handleMetadataChanges() {
const prioritySelect = document.getElementById('prioritySelect');
const categorySelect = document.getElementById('categorySelect');
const typeSelect = document.getElementById('typeSelect');
// Helper function to update ticket field
function updateTicketField(fieldName, newValue) {
const ticketId = window.ticketData.id;
lt.api.post('/api/update_ticket.php', {
ticket_id: ticketId,
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
})
.then(data => {
if (!data.success) {
lt.toast.error(`Error updating ${fieldName}`);
} else {
// Update window.ticketData
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
// For priority, update the TDS frame border accent
if (fieldName === 'priority') {
const ticketFrame = document.querySelector('.lt-frame-ticket');
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
}
}
})
.catch(error => {
lt.toast.error(`Error updating ${fieldName}: ` + error.message);
});
}
// Priority change handler
if (prioritySelect) {
prioritySelect.addEventListener('change', function() {
updateTicketField('priority', this.value);
});
}
// Category change handler
if (categorySelect) {
categorySelect.addEventListener('change', function() {
updateTicketField('category', this.value);
});
}
// Type change handler
if (typeSelect) {
typeSelect.addEventListener('change', function() {
updateTicketField('type', this.value);
});
}
}
function updateTicketStatus() {
const statusSelect = document.getElementById('statusSelect');
const selectedOption = statusSelect.options[statusSelect.selectedIndex];
const newStatus = selectedOption.value;
const requiresComment = selectedOption.dataset.requiresComment === '1';
const requiresAdmin = selectedOption.dataset.requiresAdmin === '1';
// Check if transitioning to the same status (current)
if (selectedOption.text.includes('(current)')) {
return; // No change needed
}
// Warn if comment is required
if (requiresComment) {
showConfirmModal(
'Status Change Requires Comment',
`This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`,
'warning',
() => {
// User confirmed, proceed with status change
performStatusChange(statusSelect, selectedOption, newStatus);
},
() => {
// User cancelled, reset to current status
statusSelect.selectedIndex = 0;
}
);
return;
}
performStatusChange(statusSelect, selectedOption, newStatus);
}
// Extract status change logic into reusable function
function performStatusChange(statusSelect, selectedOption, newStatus) {
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
statusSelect.selectedIndex = 0;
return;
}
// Update status via API
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => {
if (data.success) {
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
// Update the selected option text to show as current
selectedOption.text = newStatus + ' (current)';
// Move the selected option to the top
statusSelect.remove(statusSelect.selectedIndex);
statusSelect.insertBefore(selectedOption, statusSelect.firstChild);
statusSelect.selectedIndex = 0;
// Reload page to refresh activity timeline
setTimeout(() => {
window.location.reload();
}, 500);
} else {
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error'));
// Reset to current status
statusSelect.selectedIndex = 0;
}
})
.catch(error => {
lt.toast.error('Error updating status: ' + error.message);
// Reset to current status
statusSelect.selectedIndex = 0;
});
}
function showTab(tabName) {
// Load content for tabs that require it (TDS v1.2 handles the actual show/hide via lt.tabs)
if (tabName === 'attachments') {
loadAttachments();
initializeUploadZone();
} else if (tabName === 'dependencies') {
loadDependencies();
loadPotentialDuplicates();
}
}
// ========================================
// Dependency Management Functions
// ========================================
function loadDependencies() {
const ticketId = window.ticketData.id;
lt.api.get(`/api/ticket_dependencies.php?ticket_id=${ticketId}`)
.then(data => {
if (data.success) {
renderDependencies(data.dependencies);
renderDependents(data.dependents);
} else {
showDependencyError(data.error || 'Failed to load dependencies');
}
})
.catch(error => {
showDependencyError('Failed to load dependencies. The feature may not be available.');
});
}
// Load potential duplicates from check_duplicates API and show "Mark as duplicate" buttons
let _dupsLoaded = false;
function loadPotentialDuplicates() {
if (_dupsLoaded) return;
_dupsLoaded = true;
const frame = document.getElementById('potentialDupsFrame');
const list = document.getElementById('potentialDupsList');
if (!frame || !list) return;
const title = window.ticketData?.title || document.querySelector('.title-input')?.textContent?.trim() || '';
if (!title) return;
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(data => {
if (!data.success || !data.duplicates || !data.duplicates.length) return;
// Filter out this ticket itself
const thisId = String(window.ticketData.id);
const dupes = data.duplicates.filter(d => String(d.ticket_id) !== thisId);
if (!dupes.length) return;
let html = '
`;
}
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 = `
[!]
Blocked
This ticket is blocked by ${blockers.length} open ticket${blockers.length > 1 ? 's' : ''}:
${blockers.map(b => `#${lt.escHtml(b.depends_on_id)}`).join(', ')}
`;
// Insert blocker alert above the frame if not already there
const panel = document.getElementById('dependencies-panel');
if (panel && !panel.querySelector('#blockerAlert')) {
panel.insertAdjacentHTML('afterbegin', alertHtml);
}
}
let html = '';
let hasAny = false;
for (const [type, items] of Object.entries(dependencies)) {
if (!items.length) continue;
hasAny = true;
const label = typeLabels[type] || type;
html += `
${lt.escHtml(label)}`;
items.forEach(dep => {
html += `