/** * 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 = `