/** * 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 = `
Загрузка...
`; } 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(/]*>[\s\S]*?<\/tbody>/i); const tfootMatch = html.match(/]*>[\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 = ` Ошибка загрузки данных. Пожалуйста, обновите страницу. `; } } /** * Установка параметров фильтрации извне */ 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;