Files
tinker_tickets/assets/js/advanced-search.js
Jared Vititoe a8738fdf57 Add comment threading and fix fetch authentication
- Add comment threading/reply functionality with nested display
  - Database migration for parent_comment_id and thread_depth columns
  - Recursive comment rendering with depth-based indentation
  - Reply form with inline UI and smooth animations
  - Thread collapse/expand capability
  - Max thread depth of 3 levels

- Fix 401 authentication errors on API calls
  - Add credentials: 'same-origin' to all fetch calls
  - Affects settings.js, ticket.js, dashboard.js, advanced-search.js
  - Ensures session cookies are sent with requests

- Enhanced comment styling
  - Thread connector lines for visual hierarchy
  - Reply button on comments (up to depth 3)
  - Quote block styling for replies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:43:36 -05:00

374 lines
13 KiB
JavaScript

/**
* Advanced Search Functionality
* Handles complex search queries with date ranges, user filters, and multiple criteria
*/
// Open advanced search modal
function openAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
loadUsersForSearch();
populateCurrentFilters();
loadSavedFilters();
}
}
// Close advanced search modal
function closeAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
}
// Load users for dropdown
async function loadUsersForSearch() {
try {
const response = await fetch('/api/get_users.php', {
credentials: 'same-origin'
});
const data = await response.json();
if (data.success && data.users) {
const createdBySelect = document.getElementById('adv-created-by');
const assignedToSelect = document.getElementById('adv-assigned-to');
// Clear existing options (except first default option)
while (createdBySelect.options.length > 1) {
createdBySelect.remove(1);
}
while (assignedToSelect.options.length > 2) { // Keep "Any" and "Unassigned"
assignedToSelect.remove(2);
}
// Add users to both dropdowns
data.users.forEach(user => {
const displayName = user.display_name || user.username;
const option1 = document.createElement('option');
option1.value = user.user_id;
option1.textContent = displayName;
createdBySelect.appendChild(option1);
const option2 = document.createElement('option');
option2.value = user.user_id;
option2.textContent = displayName;
assignedToSelect.appendChild(option2);
});
}
} catch (error) {
console.error('Error loading users:', error);
}
}
// Populate form with current URL parameters
function populateCurrentFilters() {
const urlParams = new URLSearchParams(window.location.search);
// Search text
if (urlParams.has('search')) {
document.getElementById('adv-search-text').value = urlParams.get('search');
}
// Status
if (urlParams.has('status')) {
const statuses = urlParams.get('status').split(',');
const statusSelect = document.getElementById('adv-status');
Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value);
});
}
}
// Perform advanced search
function performAdvancedSearch(event) {
event.preventDefault();
const params = new URLSearchParams();
// Search text
const searchText = document.getElementById('adv-search-text').value.trim();
if (searchText) {
params.set('search', searchText);
}
// Date ranges
const createdFrom = document.getElementById('adv-created-from').value;
const createdTo = document.getElementById('adv-created-to').value;
const updatedFrom = document.getElementById('adv-updated-from').value;
const updatedTo = document.getElementById('adv-updated-to').value;
if (createdFrom) params.set('created_from', createdFrom);
if (createdTo) params.set('created_to', createdTo);
if (updatedFrom) params.set('updated_from', updatedFrom);
if (updatedTo) params.set('updated_to', updatedTo);
// Status (multi-select)
const statusSelect = document.getElementById('adv-status');
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) {
params.set('status', selectedStatuses.join(','));
}
// Priority range
const priorityMin = document.getElementById('adv-priority-min').value;
const priorityMax = document.getElementById('adv-priority-max').value;
if (priorityMin) params.set('priority_min', priorityMin);
if (priorityMax) params.set('priority_max', priorityMax);
// Users
const createdBy = document.getElementById('adv-created-by').value;
const assignedTo = document.getElementById('adv-assigned-to').value;
if (createdBy) params.set('created_by', createdBy);
if (assignedTo) params.set('assigned_to', assignedTo);
// Redirect to dashboard with params
window.location.href = '/?' + params.toString();
}
// Reset advanced search form
function resetAdvancedSearch() {
document.getElementById('advancedSearchForm').reset();
// Unselect all multi-select options
const statusSelect = document.getElementById('adv-status');
Array.from(statusSelect.options).forEach(option => {
option.selected = false;
});
}
// Save current search as a filter
async function saveCurrentFilter() {
showInputModal(
'Save Search Filter',
'Enter a name for this filter:',
'My Filter',
async (filterName) => {
if (!filterName || filterName.trim() === '') {
toast.warning('Filter name cannot be empty', 2000);
return;
}
const filterCriteria = getCurrentFilterCriteria();
try {
const response = await fetch('/api/saved_filters.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
filter_name: filterName.trim(),
filter_criteria: filterCriteria
})
});
const result = await response.json();
if (result.success) {
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
loadSavedFilters();
} else {
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
}
} catch (error) {
console.error('Error saving filter:', error);
toast.error('Error saving filter', 4000);
}
}
);
}
// Get current filter criteria from form
function getCurrentFilterCriteria() {
const criteria = {};
const searchText = document.getElementById('adv-search-text').value.trim();
if (searchText) criteria.search = searchText;
const createdFrom = document.getElementById('adv-created-from').value;
if (createdFrom) criteria.created_from = createdFrom;
const createdTo = document.getElementById('adv-created-to').value;
if (createdTo) criteria.created_to = createdTo;
const updatedFrom = document.getElementById('adv-updated-from').value;
if (updatedFrom) criteria.updated_from = updatedFrom;
const updatedTo = document.getElementById('adv-updated-to').value;
if (updatedTo) criteria.updated_to = updatedTo;
const statusSelect = document.getElementById('adv-status');
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
const priorityMin = document.getElementById('adv-priority-min').value;
if (priorityMin) criteria.priority_min = priorityMin;
const priorityMax = document.getElementById('adv-priority-max').value;
if (priorityMax) criteria.priority_max = priorityMax;
const createdBy = document.getElementById('adv-created-by').value;
if (createdBy) criteria.created_by = createdBy;
const assignedTo = document.getElementById('adv-assigned-to').value;
if (assignedTo) criteria.assigned_to = assignedTo;
return criteria;
}
// Load saved filters
async function loadSavedFilters() {
try {
const response = await fetch('/api/saved_filters.php', {
credentials: 'same-origin'
});
const data = await response.json();
if (data.success && data.filters) {
populateSavedFiltersDropdown(data.filters);
}
} catch (error) {
console.error('Error loading saved filters:', error);
}
}
// Populate saved filters dropdown
function populateSavedFiltersDropdown(filters) {
const dropdown = document.getElementById('saved-filters-select');
if (!dropdown) return;
// Clear existing options except the first (placeholder)
while (dropdown.options.length > 1) {
dropdown.remove(1);
}
// Add saved filters
filters.forEach(filter => {
const option = document.createElement('option');
option.value = filter.filter_id;
option.textContent = filter.filter_name + (filter.is_default ? ' ⭐' : '');
option.dataset.criteria = JSON.stringify(filter.filter_criteria);
dropdown.appendChild(option);
});
}
// Load a saved filter
function loadSavedFilter() {
const dropdown = document.getElementById('saved-filters-select');
const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || !selectedOption.dataset.criteria) return;
try {
const criteria = JSON.parse(selectedOption.dataset.criteria);
applySavedFilterCriteria(criteria);
} catch (error) {
console.error('Error loading filter:', error);
}
}
// Apply saved filter criteria to form
function applySavedFilterCriteria(criteria) {
// Search text
document.getElementById('adv-search-text').value = criteria.search || '';
// Date ranges
document.getElementById('adv-created-from').value = criteria.created_from || '';
document.getElementById('adv-created-to').value = criteria.created_to || '';
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
// Status
const statusSelect = document.getElementById('adv-status');
const statuses = criteria.status ? criteria.status.split(',') : [];
Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value);
});
// Priority
document.getElementById('adv-priority-min').value = criteria.priority_min || '';
document.getElementById('adv-priority-max').value = criteria.priority_max || '';
// Users
document.getElementById('adv-created-by').value = criteria.created_by || '';
document.getElementById('adv-assigned-to').value = criteria.assigned_to || '';
}
// Delete saved filter
async function deleteSavedFilter() {
const dropdown = document.getElementById('saved-filters-select');
const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || selectedOption.value === '') {
if (typeof toast !== 'undefined') {
toast.error('Please select a filter to delete');
}
return;
}
const filterId = selectedOption.value;
const filterName = selectedOption.textContent;
showConfirmModal(
`Delete Filter "${filterName}"?`,
'This action cannot be undone.',
'error',
async () => {
try {
const response = await fetch('/api/saved_filters.php', {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ filter_id: filterId })
});
const result = await response.json();
if (result.success) {
toast.success('Filter deleted successfully', 3000);
loadSavedFilters();
resetAdvancedSearch();
} else {
toast.error('Failed to delete filter', 4000);
}
} catch (error) {
console.error('Error deleting filter:', error);
toast.error('Error deleting filter', 4000);
}
}
);
}
// Keyboard shortcut (Ctrl+Shift+F)
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
openAdvancedSearch();
}
// ESC to close
if (e.key === 'Escape') {
const modal = document.getElementById('advancedSearchModal');
if (modal && modal.style.display === 'flex') {
closeAdvancedSearch();
}
}
});