Files
michaelschiemer/resources/js/modules/admin/data-table.js
2025-11-24 21:28:25 +01:00

375 lines
12 KiB
JavaScript

/**
* Admin Data Table Module
*
* Provides interactive functionality for admin data tables:
* - AJAX loading and pagination
* - Sorting by columns
* - Search/filtering
* - Auto-refresh
*/
export class AdminDataTable {
constructor(element) {
this.element = element;
this.resource = element.dataset.resource;
this.apiEndpoint = element.dataset.apiEndpoint;
this.sortable = element.dataset.sortable === 'true';
this.searchable = element.dataset.searchable === 'true';
this.paginated = element.dataset.paginated === 'true';
this.perPage = parseInt(element.dataset.perPage) || 25;
this.currentPage = 1;
this.currentSort = null;
this.currentSearch = '';
}
async init() {
this.tbody = this.element.querySelector('tbody');
this.thead = this.element.querySelector('thead');
if (!this.tbody) {
console.warn('AdminDataTable: No tbody found');
return;
}
// Always setup event listeners for sorting, even if data is already loaded
this.setupEventListeners();
// Only load data via AJAX if API endpoint is configured and table is empty
if (this.apiEndpoint && this.tbody.children.length === 0) {
await this.loadData();
}
}
setupEventListeners() {
// Sortable columns - only for columns with data-column attribute
// Check both table-level sortable flag and individual column sortable attribute
if (this.thead) {
this.thead.querySelectorAll('th[data-column]').forEach((th) => {
const columnKey = th.dataset.column;
if (columnKey) {
// Only make sortable if table-level sortable is enabled OR column has data-column
if (this.sortable || th.hasAttribute('data-column')) {
th.style.cursor = 'pointer';
th.classList.add('sortable');
th.addEventListener('click', () => {
// If API endpoint is available, use AJAX sorting
if (this.apiEndpoint) {
this.sortByColumn(columnKey, th);
} else {
// Otherwise, do client-side sorting
this.sortByColumnClientSide(columnKey, th);
}
});
}
}
});
}
// Search input
if (this.searchable) {
const searchInput = this.getSearchInput();
if (searchInput) {
searchInput.addEventListener('input', this.debounce(() => {
this.currentSearch = searchInput.value;
this.currentPage = 1;
this.loadData();
}, 300));
}
}
// Pagination links (if exist)
this.setupPaginationListeners();
}
setupPaginationListeners() {
const paginationContainer = this.getPaginationContainer();
if (!paginationContainer) return;
paginationContainer.addEventListener('click', (e) => {
const pageLink = e.target.closest('[data-page]');
if (pageLink) {
e.preventDefault();
const page = parseInt(pageLink.dataset.page);
if (page && page !== this.currentPage) {
this.currentPage = page;
this.loadData();
}
}
});
}
async sortByColumn(columnKey, th) {
// Toggle sort direction
const currentDir = th.dataset.sortDir;
const newDir = currentDir === 'asc' ? 'desc' : 'asc';
// Update sort state
this.currentSort = { column: columnKey, direction: newDir };
// Update UI
this.thead.querySelectorAll('th').forEach(header => {
header.removeAttribute('data-sort-dir');
header.classList.remove('sort-asc', 'sort-desc');
});
th.dataset.sortDir = newDir;
th.classList.add(`sort-${newDir}`);
// Reload data if API endpoint is available
if (this.apiEndpoint) {
await this.loadData();
}
}
sortByColumnClientSide(columnKey, th) {
// Client-side sorting for tables without API endpoint
const currentDir = th.dataset.sortDir;
const newDir = currentDir === 'asc' ? 'desc' : 'asc';
// Update UI
this.thead.querySelectorAll('th').forEach(header => {
header.removeAttribute('data-sort-dir');
header.classList.remove('sort-asc', 'sort-desc');
});
th.dataset.sortDir = newDir;
th.classList.add(`sort-${newDir}`);
// Get all rows
const rows = Array.from(this.tbody.querySelectorAll('tr'));
const columnIndex = Array.from(this.thead.querySelectorAll('th')).indexOf(th);
// Sort rows
rows.sort((a, b) => {
const aValue = a.cells[columnIndex]?.textContent?.trim() || '';
const bValue = b.cells[columnIndex]?.textContent?.trim() || '';
// Try numeric comparison first
const aNum = parseFloat(aValue);
const bNum = parseFloat(bValue);
if (!isNaN(aNum) && !isNaN(bNum)) {
return newDir === 'asc' ? aNum - bNum : bNum - aNum;
}
// String comparison
const comparison = aValue.localeCompare(bValue);
return newDir === 'asc' ? comparison : -comparison;
});
// Re-append sorted rows
rows.forEach(row => this.tbody.appendChild(row));
}
async loadData() {
const params = new URLSearchParams({
page: this.currentPage.toString(),
per_page: this.perPage.toString(),
});
if (this.currentSearch) {
params.set('search', this.currentSearch);
}
if (this.currentSort) {
params.set('sort_by', this.currentSort.column);
params.set('sort_dir', this.currentSort.direction);
}
const url = `${this.apiEndpoint}?${params}`;
try {
this.setLoadingState(true);
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.renderRows(result.data);
if (this.paginated && result.pagination) {
this.renderPagination(result.pagination);
}
} else {
this.showError(result.error || 'Failed to load data');
}
} catch (error) {
console.error('AdminDataTable: Failed to load data', error);
console.error('AdminDataTable: URL was', url);
// Fallback to client-side sorting if API fails
if (this.currentSort && this.tbody.children.length > 0) {
console.warn('AdminDataTable: API failed, falling back to client-side sorting');
const th = this.thead.querySelector(`th[data-column="${this.currentSort.column}"]`);
if (th) {
this.sortByColumnClientSide(this.currentSort.column, th);
} else {
this.showError('Failed to load data. Please refresh the page.');
}
} else {
this.showError('Failed to load data');
}
} finally {
this.setLoadingState(false);
}
}
renderRows(data) {
if (!data || data.length === 0) {
this.tbody.innerHTML = this.getEmptyRow();
return;
}
// Get column keys from header
const columnKeys = this.getColumnKeys();
this.tbody.innerHTML = data.map(item => {
const cells = columnKeys.map(key => {
const value = item[key] !== undefined ? item[key] : '';
return `<td>${this.escapeHtml(value)}</td>`;
}).join('');
return `<tr data-id="${item.id || ''}">${cells}</tr>`;
}).join('');
}
renderPagination(pagination) {
const container = this.getPaginationContainer();
if (!container) return;
const { page, pages, total } = pagination;
if (pages <= 1) {
container.innerHTML = '';
return;
}
const links = [];
// Previous
if (page > 1) {
links.push(`<a href="#" data-page="${page - 1}" class="page-link">Previous</a>`);
}
// Page numbers
for (let i = 1; i <= pages; i++) {
if (i === page) {
links.push(`<span class="page-link active">${i}</span>`);
} else {
links.push(`<a href="#" data-page="${i}" class="page-link">${i}</a>`);
}
}
// Next
if (page < pages) {
links.push(`<a href="#" data-page="${page + 1}" class="page-link">Next</a>`);
}
container.innerHTML = `
<div class="pagination">
${links.join('')}
<span class="pagination-info">Total: ${total}</span>
</div>
`;
}
setLoadingState(loading) {
if (loading) {
this.element.classList.add('loading');
this.tbody.style.opacity = '0.5';
} else {
this.element.classList.remove('loading');
this.tbody.style.opacity = '1';
}
}
showError(message) {
const columnCount = this.getColumnKeys().length;
this.tbody.innerHTML = `
<tr>
<td colspan="${columnCount}" class="error-message">
${this.escapeHtml(message)}
</td>
</tr>
`;
}
getEmptyRow() {
const columnCount = this.getColumnKeys().length;
return `
<tr>
<td colspan="${columnCount}" class="empty-message">
No data available
</td>
</tr>
`;
}
getColumnKeys() {
if (!this.thead) return [];
const headers = this.thead.querySelectorAll('th');
return Array.from(headers).map((th, index) =>
th.dataset.column || this.getColumnKeyFromIndex(index)
).filter(Boolean);
}
getColumnKeyFromIndex(index) {
// Fallback: use column index as key
return `col_${index}`;
}
getSearchInput() {
return document.querySelector(`[data-table-search="${this.resource}"]`);
}
getPaginationContainer() {
return document.querySelector(`[data-table-pagination="${this.resource}"]`) ||
this.element.parentElement.querySelector('.pagination-container');
}
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
}
// Auto-initialize all admin data tables
// Initialize tables with API endpoints (AJAX loading)
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-resource][data-api-endpoint]').forEach(table => {
new AdminDataTable(table).init();
});
// Also initialize tables with sortable attribute but without API endpoint (client-side sorting)
document.querySelectorAll('table.admin-table[data-sortable="true"]').forEach(table => {
if (!table.dataset.apiEndpoint) {
// Create a minimal AdminDataTable instance for client-side sorting
const dataTable = new AdminDataTable(table);
dataTable.sortable = true;
dataTable.init();
}
});
});