first commit
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user