UNPKG

avalynx-datatable

Version:

AvalynxDataTable is a simple, lightweight, and customizable datatable for the web. Based on Bootstrap >=5.3 without any framework dependencies.

447 lines (409 loc) 19.8 kB
/** * AvalynxDataTable * * A simple, lightweight, and customizable data table for the web. Based on Bootstrap >=5.3 without any framework dependencies. * * @version 0.0.4 * @license MIT * @author https://github.com/avalynx/avalynx-datatable/graphs/contributors * @website https://github.com/avalynx/ * @repository https://github.com/avalynx/avalynx-datatable.git * @bugs https://github.com/avalynx/avalynx-datatable/issues * * @param {string} id - The ID of the element to attach the table to. * @param {object} options - An object containing the following keys: * @param {string} options.apiUrl - The URL to fetch the data from (default: null). * @param {string} options.apiMethod - The HTTP method to use when fetching data from the API (default: 'POST'). * @param {object} options.apiParams - Additional parameters to send with the API request (default: {}). * @param {object} options.sorting - The initial sorting configuration for the table. Format is an array of objects specifying column and direction, e.g., [{"column": "name", "dir": "asc"}] (default: []). * @param {number} options.currentPage - The initial page number to display (default: 1). * @param {string} options.search - The initial search string to filter the table data (default: ''). * @param {number} options.searchWait - The debounce time in milliseconds for search input to wait after the last keystroke before performing the search (default: 800). * @param {array} options.listPerPage - The list of options for the per-page dropdown (default: [10, 25, 50, 100]). * @param {number} options.perPage - The initial number of items per page (default: 10). * @param {string} options.className - The CSS classes to apply to the table (default: 'table table-striped table-bordered table-responsive'). * @param {boolean} options.paginationPrevNext - Whether to show the previous and next buttons in the pagination (default: true). * @param {number} options.paginationRange - The number of pages to show on either side of the current page in the pagination (default: 2). * @param {object} options.loader - An instance of AvalynxLoader to use as the loader for the table (default: null). * @param {object} language - An object containing the following keys: * @param {string} language.showLabel - The label for the per-page select (default: 'Show'). * @param {string} language.entriesLabel - The label next to the per-page select indicating what the numbers represent (default: 'entries'). * @param {string} language.searchLabel - The label for the search input (default: 'Search'). * @param {string} language.previousLabel - The label for the pagination's previous button (default: 'Previous'). * @param {string} language.nextLabel - The label for the pagination's next button (default: 'Next'). * @param {function} language.showingEntries - A function to format the text showing the range of visible entries out of the total (default: (start, end, total) => 'Showing ${start} to ${end} of ${total} entries'). * @param {function} language.showingFilteredEntries - A function to format the text showing the range of visible entries out of the total when filtered (default: (start, end, filtered, total) => 'Showing ${start} to ${end} of ${filtered} entries (filtered from ${total} entries)'). * */ class AvalynxDataTable { constructor(id, options = {}, language = {}) { this.dt = document.getElementById(id); if (this.dt === null) { console.error(`AvalynxDataTable: Element with id '${id}' not found`); return; } this.id = id; this.options = { apiUrl: '', apiMethod: 'POST', apiParams: {}, sorting: [], currentPage: 1, search: '', searchWait: 800, listPerPage: [10, 25, 50, 100], perPage: 10, className: 'table table-striped table-bordered table-responsive align-middle', paginationPrevNext: true, paginationRange: 2, loader: null, ...options }; this.language = { showLabel: "Show", entriesLabel: "entries", searchLabel: "Search", previousLabel: "Previous", nextLabel: "Next", showingEntries: (start, end, total) => `Showing ${start} to ${end} of ${total} entries`, showingFilteredEntries: (start, end, filtered, total) => `Showing ${start} to ${end} of ${filtered} entries (filtered from ${total} total entries)`, ...language }; if (!this.options.listPerPage.includes(this.options.perPage)) { this.options.perPage = 10; } this.options.searchIsNew = false; this.result = null; this.totalPages = 0; this.init(); this.fetchData(); } init() { this.ensureTemplatesExist(); const template_avalynx_datatable_top = document.getElementById("avalynx-datatable-top").content.cloneNode(true); const template_avalynx_datatable_table = document.getElementById("avalynx-datatable-table").content.cloneNode(true); const template_avalynx_datatable_bottom = document.getElementById("avalynx-datatable-bottom").content.cloneNode(true); template_avalynx_datatable_table.querySelector("table").className = this.options.className + ' avalynx-datatable-table'; template_avalynx_datatable_top.querySelector(".avalynx-datatable-top-entries label:first-child").textContent = this.language.showLabel; template_avalynx_datatable_top.querySelector(".avalynx-datatable-top-entries label:last-child").textContent = this.language.entriesLabel; template_avalynx_datatable_top.querySelector(".avalynx-datatable-top-search label").textContent = this.language.searchLabel; this.dt.append(template_avalynx_datatable_top, template_avalynx_datatable_table, template_avalynx_datatable_bottom); this.setupOverlayAndLoader(); this.setupPerPageChangeEvent(); this.setupSearchInputChangeEvent(); this.populatePerPageOptions(); this.populateSearchInput(); } async fetchData() { if (this.options.loader === null) { const overlay = document.getElementById(`${this.id}-overlay`); overlay.style.display = 'flex'; } else { this.options.loader.load=true; } try { const postData = { "search": this.options.search, "sorting": this.options.sorting, "page": this.options.currentPage, "perpage": this.options.perPage, "searchisnew": (this.options.searchIsNew === true) ? 1 : 0, ...this.options.apiParams }; postData.sorting = JSON.stringify(postData.sorting); let url = this.options.apiUrl; let fetchOptions = { method: this.options.apiMethod, headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }; if (this.options.apiMethod === 'GET') { const queryParams = new URLSearchParams(postData).toString(); url += '?' + queryParams; } else { const formBody = Object.keys(postData).map(key => { return encodeURIComponent(key) + '=' + encodeURIComponent(postData[key]); }).join('&'); fetchOptions.body = formBody; } const response = await fetch(url, fetchOptions); const data = await response.json(); if (data.error) { alert(data.error); console.error('Error:', data.error); return; } this.result = data; this.options.searchIsNew = false; this.options.currentPage = this.result.count.page; if (this.options.perPage !== this.result.count.perpage) { this.options.perPage = this.result.count.perpage; this.populatePerPageOptions(); } this.totalPages = Math.ceil(this.result.count.filtered / this.result.count.perpage); this.populateTable(); if (this.options.sorting !== this.result.sorting) { this.options.sorting = this.result.sorting; this.updateSortingIcons(); } } catch (error) { alert(error); console.error('Error:', error); } finally { if (this.options.loader === null) { const overlay = document.getElementById(`${this.id}-overlay`); if (overlay) { overlay.style.display = 'none'; } } else { this.options.loader.load = false; } } } ensureTemplatesExist() { this.addTemplateIfMissing("avalynx-datatable-top", ` <div class="d-flex flex-column flex-md-row avalynx-datatable-top"> <div class="d-flex align-self-center pb-2 avalynx-datatable-top-entries"> <label class="align-self-center">Show</label> <div class="align-self-center px-2"> <select class="form-select"> </select> </div> <label class="align-self-center">entries</label> </div> <div class="flex-grow-1"></div> <div class="d-flex align-self-center pb-2 avalynx-datatable-top-search"> <label class="align-self-center">Search</label> <div class="align-self-center ps-2"><input type="text" class="form-control"></div> </div> </div> `); this.addTemplateIfMissing("avalynx-datatable-table", ` <table> <thead></thead> <tbody></tbody> </table> `); this.addTemplateIfMissing("avalynx-datatable-bottom", ` <div class="d-flex flex-column flex-md-row avalynx-datatable-bottom"> <div class="d-flex avalynx-datatable-bottom-entries pb-2"></div> <div class="flex-grow-1"></div> <nav class="align-self-center avalynx-datatable-bottom-pagination"> <ul class="pagination"></ul> </nav> </div> `); } addTemplateIfMissing(id, content) { if (!document.getElementById(id)) { const template = document.createElement('template'); template.id = id; template.innerHTML = content; document.body.appendChild(template); } } setupPerPageChangeEvent() { const select = this.dt.querySelector(".avalynx-datatable-top .avalynx-datatable-top-entries .form-select"); select.addEventListener('change', (event) => { this.options.perPage = parseInt(event.target.value); this.fetchData(this.options.currentPage); }); } populatePerPageOptions() { const select = this.dt.querySelector(".avalynx-datatable-top .avalynx-datatable-top-entries .form-select"); select.innerHTML = ''; this.options.listPerPage.forEach((num) => { const option = document.createElement("option"); option.value = num; option.textContent = num; if (num === this.options.perPage) { option.selected = true; } select.appendChild(option); }); } setupSearchInputChangeEvent() { const searchInput = this.dt.querySelector(".avalynx-datatable-top .avalynx-datatable-top-search .form-control"); let debounceTimeout; searchInput.addEventListener('input', (event) => { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { if (event.target.value !== this.options.search) { this.options.searchIsNew = true; } this.options.search = event.target.value; this.fetchData(this.options.currentPage); }, this.options.searchWait); }); } populateSearchInput() { const searchInput = this.dt.querySelector(".avalynx-datatable-top .avalynx-datatable-top-search .form-control"); searchInput.value = this.options.search; } populateTable() { const thead = this.dt.querySelector(".avalynx-datatable-table thead"); thead.innerHTML = ''; const headerRow = document.createElement("tr"); this.result.head.columns.forEach((column) => { const th = document.createElement("th"); if (column.hidden) { th.classList.add("d-none"); } if (column.class) { th.classList.add(column.class); } th.textContent = column.name; th.setAttribute("data-avalynx-datatable-column-id", column.id); if (column.sortable) { th.classList.add("avalynx-datatable-sorting"); th.setAttribute("data-avalynx-datatable-sortable", "true"); } headerRow.appendChild(th); }); thead.appendChild(headerRow); const tbody = this.dt.querySelector(".avalynx-datatable-table tbody"); tbody.innerHTML = ''; this.result.data.forEach((rowData) => { const tr = document.createElement("tr"); if (rowData.class) { tr.classList.add(rowData.class); } this.result.head.columns.forEach((column) => { const td = document.createElement("td"); if (column.hidden) { td.classList.add("d-none"); } if (column.class) { td.classList.add(column.class); } if (rowData.data_class && rowData.data_class[column.id]) { td.classList.add(rowData.data_class[column.id]); } if (column.raw) { tr.appendChild(td); td.innerHTML = rowData.data[column.id]; } else { td.textContent = rowData.data[column.id]; tr.appendChild(td); } }); tbody.appendChild(tr); }); this.setupSortingEvent(); this.populateShowEntries(); this.populatePagination(); } setupSortingEvent() { const sortableHeaders = this.dt.querySelectorAll(".avalynx-datatable-table thead th[data-avalynx-datatable-sortable]"); sortableHeaders.forEach(header => { header.addEventListener('click', (event) => { const columnId = header.getAttribute('data-avalynx-datatable-column-id'); if (!columnId) return; const isCtrlPressed = event.ctrlKey; const isShiftPressed = event.shiftKey; if (this.options.sorting[columnId]) { let sort = this.options.sorting[columnId] === 'asc' ? 'desc' : 'asc'; if (!isCtrlPressed && !isShiftPressed) { this.options.sorting = {}; } else { delete this.options.sorting[columnId]; } this.options.sorting[columnId] = sort; } else { if (!isCtrlPressed && !isShiftPressed) { this.options.sorting = {}; } this.options.sorting[columnId] = 'asc'; } this.fetchData(); }); }); } updateSortingIcons() { const sortableHeaders = this.dt.querySelectorAll(".avalynx-datatable-table thead th[data-avalynx-datatable-sortable]"); sortableHeaders.forEach(header => { const columnId = header.getAttribute('data-avalynx-datatable-column-id'); if (!columnId) return; if (this.options.sorting[columnId]) { if (this.options.sorting[columnId] === 'asc') { header.classList.add('avalynx-datatable-sorting-asc'); header.classList.remove('avalynx-datatable-sorting-desc'); } else { header.classList.add('avalynx-datatable-sorting-desc'); header.classList.remove('avalynx-datatable-sorting-asc'); } } else { header.classList.remove('avalynx-datatable-sorting-asc', 'avalynx-datatable-sorting-desc'); } }); } populateShowEntries() { const entries = this.dt.querySelector(".avalynx-datatable-bottom .avalynx-datatable-bottom-entries"); const start = this.result.count.start; const end = this.result.count.end; const total = this.result.count.total; const filtered = this.result.count.filtered; if (this.result.count.filtered === this.result.count.all) { entries.textContent = this.language.showingEntries(start, end, total); } else { entries.textContent = this.language.showingFilteredEntries(start, end, filtered, total); } } populatePagination() { const paginationUl = this.dt.querySelector(".avalynx-datatable-bottom-pagination ul"); paginationUl.innerHTML = ''; const prevDisabled = this.options.currentPage === 1; if (this.options.paginationPrevNext) { this.addPaginationItem(paginationUl, this.options.currentPage - 1, this.language.previousLabel, prevDisabled); } let startPage = Math.max(1, this.options.currentPage - this.options.paginationRange); let endPage = Math.min(this.options.currentPage + this.options.paginationRange, this.totalPages); for (let i = startPage; i <= endPage; i++) { this.addPaginationItem(paginationUl, i, i, false); } const nextDisabled = this.options.currentPage === this.totalPages; if (this.options.paginationPrevNext) { this.addPaginationItem(paginationUl, this.options.currentPage + 1, this.language.nextLabel, nextDisabled); } } addPaginationItem(paginationUl, pageNumber, text = pageNumber, disabled = false) { const li = document.createElement("li"); li.className = "page-item" + (pageNumber === this.options.currentPage ? " active" : "") + (disabled ? " disabled" : ""); const a = document.createElement("a"); a.className = "page-link"; a.href = "#"; a.textContent = text; if (!disabled) { a.addEventListener('click', (e) => { e.preventDefault(); this.options.currentPage = pageNumber; this.fetchData(); }); } li.appendChild(a); paginationUl.appendChild(li); } setupOverlayAndLoader() { if (this.options.loader === null) { const overlay = document.createElement('div'); overlay.id = `${this.id}-overlay`; overlay.style.position = 'absolute'; overlay.style.top = 0; overlay.style.left = 0; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.display = 'none'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; overlay.style.backgroundColor = 'rgba(var(--bs-body-bg-rgb, 0, 0, 0), 0.7)'; overlay.style.zIndex = '1000'; const spinner = document.createElement('div'); spinner.className = 'spinner-border text-primary'; spinner.role = 'status'; spinner.innerHTML = '<span class="visually-hidden">Loading...</span>'; overlay.appendChild(spinner); this.dt.style.position = 'relative'; this.dt.appendChild(overlay); } } }