bp/public/assets/js/modules/DataTable.js

540 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 токен и format=partial в параметры запроса
const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}&format=partial`;
// Показываем лоадер в 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;