540 lines
19 KiB
JavaScript
540 lines
19 KiB
JavaScript
/**
|
||
* DataTable - Универсальный модуль для интерактивных таблиц
|
||
*
|
||
* Функциональность:
|
||
* - AJAX-загрузка данных
|
||
* - Сортировка по столбцам
|
||
* - Поиск по столбцам (click-to-search)
|
||
* - Пагинация
|
||
* - Состояние URL (back/forward навигация)
|
||
* - Сохранение состояния поиска между запросами
|
||
*/
|
||
class DataTable {
|
||
/**
|
||
* @param {string} containerId - ID контейнера таблицы
|
||
* @param {Object} options - Конфигурация таблицы
|
||
*/
|
||
constructor(containerId, options = {}) {
|
||
this.containerId = containerId;
|
||
this.options = {
|
||
url: options.url || '/api/table',
|
||
perPage: options.perPage || 10,
|
||
debounceTime: options.debounceTime || 300,
|
||
preserveSearchOnSort: options.preserveSearchOnSort !== false,
|
||
...options
|
||
};
|
||
|
||
// Состояние таблицы
|
||
this.state = {
|
||
page: 1,
|
||
perPage: this.options.perPage,
|
||
sort: '',
|
||
order: 'asc',
|
||
filters: {}
|
||
};
|
||
|
||
this.searchTimeout = null;
|
||
|
||
// Инициализация с задержкой для уверенности что DOM готов
|
||
this.initWithDOM();
|
||
}
|
||
|
||
/**
|
||
* Инициализация с проверкой доступности DOM
|
||
*/
|
||
initWithDOM() {
|
||
// Пробуем найти контейнер
|
||
this.container = document.getElementById(this.containerId);
|
||
|
||
if (!this.container) {
|
||
// Контейнер не найден - пробуем через setTimeout
|
||
setTimeout(() => {
|
||
this.container = document.getElementById(this.containerId);
|
||
if (this.container) {
|
||
this.init();
|
||
} else {
|
||
// Контейнер всё ещё не найден, это нормально для AJAX-обновлений
|
||
// Не показываем ошибку в консоли
|
||
}
|
||
}, 0);
|
||
return;
|
||
}
|
||
|
||
this.init();
|
||
}
|
||
|
||
/**
|
||
* Инициализация таблицы
|
||
*/
|
||
init() {
|
||
// Проверяем, не инициализирован ли уже этот экземпляр
|
||
if (this.container.dataset.datatableInitialized) {
|
||
return;
|
||
}
|
||
this.container.dataset.datatableInitialized = 'true';
|
||
|
||
// Проверяем, есть ли уже данные в таблице (рендер на сервере)
|
||
const tbody = this.container.querySelector('tbody');
|
||
const existingRows = tbody ? tbody.querySelectorAll('tr') : [];
|
||
|
||
// Если есть строки в tbody и это не строка загрузки - данные уже есть
|
||
if (existingRows.length > 0) {
|
||
const hasDataRow = Array.from(existingRows).some(tr => !tr.classList.contains('loading'));
|
||
if (hasDataRow) {
|
||
// Данные уже есть на странице - не делаем AJAX запрос
|
||
this.bindEvents();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Если tbody пустой или содержит только строку загрузки - делаем AJAX запрос
|
||
this.bindEvents();
|
||
this.loadData();
|
||
}
|
||
|
||
/**
|
||
* Привязка событий
|
||
*/
|
||
bindEvents() {
|
||
// Делегирование кликов на уровне контейнера
|
||
this.container.addEventListener('click', (e) => this.handleClick(e));
|
||
|
||
// Делегирование ввода в поля поиска
|
||
this.container.addEventListener('input', (e) => this.handleInput(e));
|
||
|
||
// Обработка Enter в полях поиска
|
||
this.container.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && e.target.matches('[data-search-input]')) {
|
||
e.preventDefault();
|
||
e.target.blur();
|
||
}
|
||
});
|
||
|
||
// Обработка изменения количества записей на странице
|
||
this.container.addEventListener('change', (e) => {
|
||
if (e.target.matches('[id^="per-page-select-"]')) {
|
||
const value = e.target.value;
|
||
this.setPerPage(value);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Обработка кликов
|
||
*/
|
||
handleClick(e) {
|
||
// Клик по иконке сортировки
|
||
const sortIcon = e.target.closest('[data-sort]');
|
||
if (sortIcon) {
|
||
e.stopPropagation();
|
||
const column = sortIcon.dataset.sort;
|
||
this.toggleSort(column);
|
||
return;
|
||
}
|
||
|
||
// Клик по иконке поиска
|
||
const searchTrigger = e.target.closest('[data-search-trigger]');
|
||
if (searchTrigger) {
|
||
e.stopPropagation();
|
||
const th = searchTrigger.closest('th');
|
||
this.openSearch(th);
|
||
return;
|
||
}
|
||
|
||
// Клик по тексту заголовка
|
||
const headerText = e.target.closest('[data-header-text]');
|
||
if (headerText) {
|
||
e.stopPropagation();
|
||
const th = headerText.closest('th');
|
||
this.openSearch(th);
|
||
return;
|
||
}
|
||
|
||
// Клик по ссылке пагинации
|
||
const pageLink = e.target.closest('[data-page]');
|
||
if (pageLink && !e.target.closest('.disabled') && !e.target.closest('.active')) {
|
||
e.preventDefault();
|
||
const page = parseInt(pageLink.dataset.page);
|
||
if (page > 0) {
|
||
this.state.page = page;
|
||
this.loadData();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Клик по кнопке "предыдущая/следующая" страница
|
||
const navLink = e.target.closest('[data-nav-page]');
|
||
if (navLink && !e.target.closest('.disabled')) {
|
||
e.preventDefault();
|
||
const direction = navLink.dataset.navPage;
|
||
this.state.page = direction === 'prev'
|
||
? Math.max(1, this.state.page - 1)
|
||
: this.state.page + 1;
|
||
this.loadData();
|
||
return;
|
||
}
|
||
|
||
// Клик вне заголовков таблицы - закрываем все поисковые поля
|
||
if (!e.target.closest('thead')) {
|
||
this.closeAllSearches();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обработка ввода в поля поиска
|
||
*/
|
||
handleInput(e) {
|
||
if (e.target.matches('[data-search-input]')) {
|
||
const column = e.target.dataset.searchInput;
|
||
this.debouncedSearch(column, e.target.value);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Debounced поиск
|
||
*/
|
||
debouncedSearch(column, value) {
|
||
clearTimeout(this.searchTimeout);
|
||
this.searchTimeout = setTimeout(() => {
|
||
this.state.filters[column] = value;
|
||
this.state.page = 1;
|
||
this.loadData();
|
||
}, this.options.debounceTime);
|
||
}
|
||
|
||
/**
|
||
* Переключение сортировки
|
||
*/
|
||
toggleSort(column) {
|
||
if (this.state.sort === column) {
|
||
this.state.order = this.state.order === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
this.state.sort = column;
|
||
this.state.order = 'desc';
|
||
}
|
||
this.loadData();
|
||
}
|
||
|
||
/**
|
||
* Открытие поля поиска
|
||
*/
|
||
openSearch(th) {
|
||
const headerText = th.querySelector('[data-header-text]');
|
||
const searchInput = th.querySelector('[data-search-input]');
|
||
|
||
if (headerText && searchInput) {
|
||
headerText.style.display = 'none';
|
||
searchInput.style.display = 'inline';
|
||
searchInput.focus();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Закрытие всех полей поиска
|
||
*/
|
||
closeAllSearches() {
|
||
const inputs = this.container.querySelectorAll('[data-search-input]');
|
||
inputs.forEach(input => {
|
||
const th = input.closest('th');
|
||
const headerText = th.querySelector('[data-header-text]');
|
||
|
||
if (headerText) {
|
||
if (input.value.trim()) {
|
||
// Если есть значение - показываем заголовок, скрываем input
|
||
input.style.display = 'none';
|
||
headerText.style.display = 'inline';
|
||
} else {
|
||
// Если пусто - также скрываем input и показываем заголовок
|
||
input.style.display = 'none';
|
||
headerText.style.display = 'inline';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Получение CSRF токена из cookie CodeIgniter 4
|
||
*/
|
||
getCsrfToken() {
|
||
// Сначала пробуем из data-атрибутов контейнера (если заданы)
|
||
if (this.container.dataset.csrfToken) {
|
||
return this.container.dataset.csrfToken;
|
||
}
|
||
|
||
// Пробуем из скрытого input внутри контейнера
|
||
const csrfInput = this.container.querySelector('input[name*="csrf"]');
|
||
if (csrfInput) {
|
||
return csrfInput.value;
|
||
}
|
||
|
||
// Пробуем из скрытого input на всей странице
|
||
const globalCsrfInput = document.querySelector('input[name*="csrf"]');
|
||
if (globalCsrfInput) {
|
||
return globalCsrfInput.value;
|
||
}
|
||
|
||
// Пробуем из cookie - ищем по паттерну
|
||
const cookies = document.cookie.split(';');
|
||
for (let cookie of cookies) {
|
||
const [name, value] = cookie.trim().split('=');
|
||
// Ищем cookie, содержащий 'csrf' в имени
|
||
if (name.toLowerCase().includes('csrf')) {
|
||
return decodeURIComponent(value);
|
||
}
|
||
}
|
||
|
||
console.warn('CSRF token not found');
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Получение имени CSRF параметра
|
||
*/
|
||
getCsrfTokenName() {
|
||
// Из data-атрибута
|
||
if (this.container.dataset.csrfTokenName) {
|
||
return this.container.dataset.csrfTokenName;
|
||
}
|
||
|
||
// Из скрытого input внутри контейнера
|
||
const csrfInput = this.container.querySelector('input[name*="csrf"]');
|
||
if (csrfInput) {
|
||
return csrfInput.name;
|
||
}
|
||
|
||
// Из скрытого input на странице
|
||
const globalCsrfInput = document.querySelector('input[name*="csrf"]');
|
||
if (globalCsrfInput) {
|
||
return globalCsrfInput.name;
|
||
}
|
||
|
||
// Значения по умолчанию
|
||
return 'csrf_test_name';
|
||
}
|
||
|
||
/**
|
||
* Обновление CSRF токена из ответа сервера
|
||
*/
|
||
updateCsrfToken(response) {
|
||
// CodeIgniter 4 может возвращать новый токен в заголовке
|
||
const csrfHeader = response.headers.get('X-CSRF-TOKEN');
|
||
if (csrfHeader) {
|
||
// Обновляем все найденные CSRF inputs
|
||
document.querySelectorAll('input[name*="csrf"]').forEach(input => {
|
||
input.value = csrfHeader;
|
||
});
|
||
// Также обновляем data-атрибут контейнера
|
||
this.container.dataset.csrfToken = csrfHeader;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Загрузка данных таблицы
|
||
*/
|
||
async loadData() {
|
||
const params = this.buildParams();
|
||
const csrfToken = this.getCsrfToken();
|
||
const csrfTokenName = this.getCsrfTokenName();
|
||
|
||
// Добавляем CSRF токен в параметры запроса
|
||
const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}`;
|
||
|
||
// Показываем лоадер в tbody
|
||
const tableBody = this.container.querySelector('tbody');
|
||
if (tableBody) {
|
||
tableBody.innerHTML = `
|
||
<tr>
|
||
<td colspan="100" class="text-center py-5">
|
||
<div class="spinner-border text-primary" role="status">
|
||
<span class="visually-hidden">Загрузка...</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': csrfToken
|
||
}
|
||
});
|
||
|
||
// Обновляем CSRF токен из ответа
|
||
this.updateCsrfToken(response);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const html = await response.text();
|
||
|
||
this.updateTable(html);
|
||
|
||
} catch (error) {
|
||
console.error('DataTable: Ошибка загрузки данных:', error);
|
||
this.showError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обновление таблицы
|
||
* Парсит HTML и заменяет только tbody/tfoot, сохраняя thead
|
||
*/
|
||
updateTable(html) {
|
||
// Сохраняем состояние полей поиска
|
||
const searchInputs = this.container.querySelectorAll('[data-search-input]');
|
||
const searchStates = {};
|
||
searchInputs.forEach(input => {
|
||
searchStates[input.dataset.searchInput] = input.value;
|
||
});
|
||
|
||
// Ищем tbody и tfoot в ответе
|
||
const tbodyMatch = html.match(/<tbody[^>]*>[\s\S]*?<\/tbody>/i);
|
||
const tfootMatch = html.match(/<tfoot[^>]*>[\s\S]*?<\/tfoot>/i);
|
||
|
||
const oldTableBody = this.container.querySelector('tbody');
|
||
const oldTableFooter = this.container.querySelector('tfoot');
|
||
const table = this.container.querySelector('table');
|
||
|
||
if (tbodyMatch && oldTableBody && table) {
|
||
// Извлекаем содержимое tbody (без тегов tbody)
|
||
const tbodyContent = tbodyMatch[0].replace(/<\/?tbody[^>]*>/gi, '');
|
||
|
||
// Очищаем старый tbody и заполняем новым содержимым
|
||
oldTableBody.innerHTML = tbodyContent;
|
||
|
||
// Обновляем tfoot
|
||
if (tfootMatch) {
|
||
const tfootContent = tfootMatch[0].replace(/<\/?tfoot[^>]*>/gi, '');
|
||
if (oldTableFooter) {
|
||
oldTableFooter.innerHTML = tfootContent;
|
||
} else {
|
||
const newTfoot = document.createElement('tfoot');
|
||
newTfoot.innerHTML = tfootContent;
|
||
table.appendChild(newTfoot);
|
||
}
|
||
} else if (oldTableFooter) {
|
||
oldTableFooter.remove();
|
||
}
|
||
} else {
|
||
// Нет tbody или нет table - полная замена контейнера
|
||
this.container.innerHTML = html;
|
||
}
|
||
|
||
// Восстанавливаем состояние полей поиска
|
||
this.restoreSearchStates(searchStates);
|
||
|
||
// Обновляем URL
|
||
this.updateURL();
|
||
}
|
||
|
||
/**
|
||
* Восстановление состояния полей поиска
|
||
*/
|
||
restoreSearchStates(savedStates = null) {
|
||
const inputs = this.container.querySelectorAll('[data-search-input]');
|
||
|
||
inputs.forEach(input => {
|
||
const column = input.dataset.searchInput;
|
||
const value = savedStates && savedStates[column] !== undefined
|
||
? savedStates[column]
|
||
: input.value.trim();
|
||
|
||
const th = input.closest('th');
|
||
const headerText = th.querySelector('[data-header-text]');
|
||
|
||
if (headerText) {
|
||
if (value) {
|
||
headerText.style.display = 'none';
|
||
input.style.display = 'inline';
|
||
input.value = value;
|
||
} else {
|
||
input.style.display = 'none';
|
||
headerText.style.display = 'inline';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Обновление URL
|
||
*/
|
||
updateURL() {
|
||
const params = this.buildParams();
|
||
// Убираем существующие параметры из URL и добавляем новые
|
||
const baseUrl = this.options.url.split('?')[0];
|
||
const url = `${baseUrl}?${params}`;
|
||
|
||
window.history.pushState(this.state, '', url);
|
||
}
|
||
|
||
/**
|
||
* Формирование параметров запроса
|
||
*/
|
||
buildParams() {
|
||
const params = new URLSearchParams();
|
||
params.set('page', this.state.page);
|
||
params.set('perPage', this.state.perPage);
|
||
|
||
if (this.state.sort) {
|
||
params.set('sort', this.state.sort);
|
||
params.set('order', this.state.order);
|
||
}
|
||
|
||
Object.entries(this.state.filters).forEach(([key, value]) => {
|
||
if (value && value.trim()) {
|
||
params.set(`filters[${key}]`, value);
|
||
}
|
||
});
|
||
|
||
return params.toString();
|
||
}
|
||
|
||
/**
|
||
* Показать ошибку загрузки
|
||
*/
|
||
showError() {
|
||
const tableBody = this.container.querySelector('tbody');
|
||
if (tableBody) {
|
||
tableBody.innerHTML = `
|
||
<tr>
|
||
<td colspan="100" class="alert alert-danger m-3">
|
||
Ошибка загрузки данных. Пожалуйста, обновите страницу.
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Установка параметров фильтрации извне
|
||
*/
|
||
setFilter(column, value) {
|
||
this.state.filters[column] = value;
|
||
this.state.page = 1;
|
||
this.loadData();
|
||
}
|
||
|
||
/**
|
||
* Установка количества записей на странице
|
||
*/
|
||
setPerPage(value) {
|
||
this.state.perPage = parseInt(value);
|
||
this.state.page = 1;
|
||
this.loadData();
|
||
}
|
||
|
||
/**
|
||
* Переход на страницу
|
||
*/
|
||
goToPage(page) {
|
||
this.state.page = Math.max(1, parseInt(page));
|
||
this.loadData();
|
||
}
|
||
}
|
||
|
||
// Экспорт для глобального использования
|
||
window.DataTable = DataTable;
|