/** * @typedef {Object} ttTableColumnConfig * @property {string} text - The display text of the column. * @property {string} key - The unique key of the column. * @property {string} filter - Indicates if filtering is enabled for the column. * @property {boolean} sortable - Indicates if sorting is enabled for the column. * @property {string} class - The CSS class(es) applied to the column. */ Vue.component('tt-table-pagination', { props: { pagination: { type: Object, required: true, default: () => ({page: 1, per_page: 10, total_rows: 0, filtered_available: 0, total_pages: 1}) }, reverse: {type: Boolean, default: false} }, computed: { pagesToDisplay() { const range = 2; const start = Math.max(this.pagination.page - range, 1); const end = Math.min(this.pagination.page + range, this.pagination.total_pages); let pages = []; for (let i = start; i <= end; i++) { pages.push(i); } return pages.length === 0 ? [1] : pages; }, pageInfoText() { const start = Math.min(this.pagination.page * this.pagination.per_page - this.pagination.per_page + 1, this.pagination.total_rows); const end = Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total_rows); const total = this.pagination.total_rows === this.pagination.filtered_available ? this.pagination.total_rows : `${this.pagination.filtered_available} (${this.pagination.total_rows})`; return `${start} bis ${end} von ${total}`; } }, methods: { fetchRows(page) { this.$emit('fetch-rows', page); } }, template: `
Einträge pro Seite
` }) Vue.component('tt-table', { template: `
{{ column.text }}
Keine Ergebnisse!
Laden...
`, props: { fetchUrl: String, striped: {type: Boolean, default: true}, bordered: {type: Boolean, default: true}, hover: {type: Boolean, default: true}, sticky: {type: Boolean, default: true}, small: {type: Boolean, default: true}, excelExport: {type: Boolean, default: false}, config: {type: Object, default: () => ({}), required: true}, ssr: {type: Boolean, default: false}, disableInitialFetch: {type: Boolean, default: false} }, data() { return { window: window, moment: window.moment, XLSX: window.XLSX, loading: false, rows: null, rawRows: null, pagination: {}, filters: {}, debounceTimeout: null, disableDebounce: false, latestFetchTimestamp: null, order: { key: null, order: 'asc' // default sort order }, expandedRows: {}, isInitialised: false }; }, methods: { /** * Creates a debounced function that delays invoking `fn` until after `wait` milliseconds * have elapsed since the last time the debounced function was invoked. * * @param {Function} fn The function to debounce. * @param {number} wait The number of milliseconds to delay. * @return {Function} The debounced function. */ debounce(fn, wait) { return function (...args) { const context = this; if (this.debounceTimeout) clearTimeout(this.debounceTimeout); this.debounceTimeout = setTimeout(() => fn.apply(context, args), wait); } }, /** * Fetches and updates data for a specified page. * * @param {number} page The page number to fetch data for. * @async */ async fetchData(page = 0) { try { if (this.ssr === false) { const response = await axios.get(this.fetchUrl); this.rawRows = response.data this.pagination = { page: page++, per_page: this.pagination?.per_page ? parseInt(this.pagination.per_page) : 10, total_rows: this.rawRows.length || 0, total_pages: this.rawRows.length / this.pagination?.per_page, filtered_available: this.rawRows.length }; this.loading = false; return } const fetchTimestamp = Date.now(); this.latestFetchTimestamp = fetchTimestamp; const response = await axios.post(this.fetchUrl, { pagination: { page: Math.max(page, 1), per_page: this.pagination?.per_page ? this.pagination.per_page : 10, }, filters: this.filters, order: this.order }); if (fetchTimestamp !== this.latestFetchTimestamp) return; if (typeof response.data !== 'object') { // if the response is not an object this.rows = []; this.pagination = {page: 1, per_page: 10, total_rows: 0, total_pages: 1}; } else { this.rows = response.data.rows; this.pagination = response.data.pagination; } this.loading = false; this.isInitialised = true; } catch (error) { console.error('Error fetching data:', error); } }, /** * Fetches rows for a given page, with an option to debounce the fetch operation. * * @param {number} page The page number to fetch. Defaults to 1. * @param {boolean} debounce Whether to debounce the fetch operation. Defaults to false. */ async fetchRows(page = 1, debounce = false) { if (this.disableDebounce === true) { debounce = false; this.disableDebounce = false; } this.loading = true if (debounce) { this.debounce(this.fetchData.bind(this), 300)(page); } else { await this.fetchData(page); // Directly call fetchData without debounce } }, saveSettingsToLocalStorage() { if (this.isInitialised === false) return; const filters = Object.entries(this.filters).reduce((acc, [key, value]) => { if (!value) { return acc; // Skip empty strings } value = JSON.parse(JSON.stringify(value)); // Deep copy to avoid Vue reactivity if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) { return acc; // Skip empty objects } acc[key] = value; // Add non-empty properties to accumulator return acc; }, {}); localStorage.setItem(`tt-table-${this.config.key}`, JSON.stringify({ // filter filters with empty values or empty objects filters, paginationPerPage: this.pagination.per_page, order: this.order.key ? this.order : undefined, expandedRows: this.expandedRows })); }, parseSettingsFromLocalStorage() { const settings = JSON.parse(localStorage.getItem(`tt-table-${this.config.key}`) || '{}'); if (settings) { this.disableDebounce = true; this.filters = settings.filters || {}; this.pagination.per_page = parseInt(settings.paginationPerPage) || this.config.defaultPageSize || 10; this.order = settings.order || {key: null, order: 'asc'}; this.expandedRows = settings.expandedRows || {}; } return !!settings; }, setOrder(key) { if (this.order.key === key) { // if current order is desc then set key to null if (this.order.order === 'desc') { this.order.key = null; this.order.order = 'asc'; } else { // if current order is asc then set order to desc this.order.order = 'desc'; } } else { // Set new key and default to ascending this.order.key = key; this.order.order = 'asc'; } }, getSortIconClass(key) { if (this.order.key === key) { return this.order.order === 'asc' ? 'fa fa-sort-asc' : 'fa fa-sort-desc'; } return 'fa fa-sort'; // default icon when not sorted }, async exportToExcel() { // create script and await downloading: /plugins/xlsx/xlsx.min.js await new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = '/plugins/xlsx/xlsx.min.js'; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }) const wb = this.XLSX.utils.book_new(); let data = typeof this.config.customExcelProcessor === 'function' ? this.config.customExcelProcessor(this.rawRows) : JSON.parse(JSON.stringify(this.rawRows)); // convert all columns with date with momentjs data = data.map(row => { for (const key in row) { if (this.columns[key].filter === 'date') { row[key] = this.moment(row[key]).format('DD.MM.YYYY HH:mm'); } } return row; }); const ws = this.XLSX.utils.json_to_sheet(data); for (const cell in ws) { if (cell.startsWith('!')) continue; // Skip non-cell properties like '!ref' const cellValue = ws[cell].v; if (cellValue.toString().includes('\n')) { console.log('Found newline in cell:', cell, cellValue); if (!ws[cell].s) ws[cell].s = {}; ws[cell].s.alignment = {wrapText: true, vertical: 'center'}; } else { ws[cell].s = {alignment: {vertical: 'center'}}; } } this.XLSX.utils.book_append_sheet(wb, ws, "Sheet1"); this.XLSX.writeFile(wb, 'export.xlsx'); }, resetTable() { this.$emit('reset-table'); this.filters = {}; this.order = {key: null, order: 'asc'}; this.expandedRows = {}; this.disableDebounce = true; window.notify('success', 'Filter zurückgesetzt'); }, toggleExpand(index) { this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows, index, true); }, isExpanded(index) { return !!this.expandedRows[index]; } }, watch: { filters: { handler: function (newVal, oldVal) { if (!this.isInitialised) return; if (this.ssr) { this.fetchRows(this.pagination?.page || 1, true).then(); } this.saveSettingsToLocalStorage(); }, deep: true }, 'pagination.per_page': { handler: function (newVal, oldVal) { if (!this.isInitialised) return; if (newVal === oldVal) return this.saveSettingsToLocalStorage(); }, deep: true }, order: { handler: function (newVal, oldVal) { if (!this.isInitialised) return; if (this.ssr) { this.fetchRows(this.pagination?.page || 1, true).then(); } this.saveSettingsToLocalStorage(); }, deep: true }, expandedRows: { handler: function (newVal, oldVal) { if (!this.isInitialised) return; this.saveSettingsToLocalStorage(); }, deep: true } }, computed: { /** * Returns an object containing the columns' configuration. * @return {ttTableColumnConfig} The columns configuration. */ columns() { return this.config.headers.reduce((columns, column) => { if (!column.key) { console.warn('WARN: tt-table: Column text or key is not defined:', column); return columns; // Continue to the next iteration without modifying the accumulator } columns[column.key] = { text: column.text, key: column.key, filter: column.filter !== undefined ? column.filter : 'search', filterOptions: column.filterOptions || undefined, sortable: column.sortable !== undefined ? column.sortable : true, class: column.class !== undefined ? column.class : '' }; return columns; }, {}); }, pagesToDisplay() { let range = 2; // Number of pages before and after the current page let start = (this.pagination.page < 4 ? 1 : this.pagination.page - range); let end = (this.pagination.page + range > this.pagination.total_pages ? this.pagination.total_pages : this.pagination.page + range); if (end < 5) end = 5; // Adjust start and end if they are out of bounds end = end > this.pagination.total_pages ? this.pagination.total_pages : end; // Adjust the start and end if we are at the end of the page range if (this.pagination.page > this.pagination.total_pages - 2) { start = this.pagination.total_pages - 4 < 1 ? 1 : this.pagination.total_pages - 4; } // Create an array of page numbers to display let pagesArray = []; for (let i = start; i <= end; i++) { pagesArray.push(i); } return pagesArray.length === 0 ? [1] : pagesArray; }, computedRows() { if (!this.rawRows || this.ssr === true) return null; console.time('Filtering and pagination'); function handleRangeFilter(filter, value) { if (filter[0] === '<') { const bound = parseFloat(filter.slice(1)); return value <= bound; } else if (filter[0] === '>') { const bound = parseFloat(filter.slice(1)); return value >= bound; } else if (filter.includes('-')) { const bounds = filter.split('-').map(Number); return value >= bounds[0] && value <= bounds[1]; } return false; // Fallback for any non-matching cases } const data = this.rawRows; const output = []; const filters = this.filters; const filtersLength = Object.keys(filters).length; const headers = this.columns; const dataLength = data.length; for (var i = 0; i < dataLength; ++i) { const row = data[i]; if (Object.keys(filters).length === 0) { output.push(row); continue; } let match = true; for (var j = 0; j < filtersLength; ++j) { // find header by filter key const filter = Object.keys(filters)[j]; const header = headers[filter]; const filterValue = filters[filter]; if (filterValue === '') continue; if (filterValue === "!") continue; if (header.filter === 'search') { const isNegated = filterValue.startsWith('!'); const targetValue = (row[header.key] ? row[header.key].toString().toLowerCase() : ''); const substrings = (isNegated ? filterValue.slice(1) : filterValue).split(' ').map(s => s.toLowerCase()); let substringMatch = true; for (var k = 0, klen = substrings.length; k < klen; ++k) { if (!targetValue.includes(substrings[k])) { substringMatch = false; break; } } if ((isNegated && substringMatch) || (!isNegated && !substringMatch)) { match = false; break; } } else if (header.filter === 'numberRange') { const rangeMatch = handleRangeFilter(filterValue, parseFloat(row[header.key])); if (!rangeMatch) { match = false; break; } } else if (header.filter === 'select' || header.filter === 'iconSelect') { if (filterValue === '') continue; if (filterValue !== row[header.key].toString()) { match = false; break; } } else if (header.filter === 'date') { if (!filterValue.from || !filterValue.to) continue; let rowDate = new Date(row[header.key]).getTime() / 1000; if (rowDate < filterValue.from || rowDate > filterValue.to) { match = false; break; } } } if (match) { output.push(row); } } // order the output using this.order in most performant way if (this.order.key) { function naturalSort(a, b) { return a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'}); } const isDateColumn = this.columns[this.order.key].filter === 'date'; output.sort((a, b) => { let valueA = isDateColumn ? new Date(a[this.order.key]).getTime() : a[this.order.key]; let valueB = isDateColumn ? new Date(b[this.order.key]).getTime() : b[this.order.key]; if (valueA === valueB) return 0; if (this.order.order === 'asc') { return typeof valueA === 'string' ? naturalSort(valueA, valueB) : (valueA < valueB ? -1 : 1); } else { return typeof valueA === 'string' ? naturalSort(valueB, valueA) : (valueA > valueB ? -1 : 1); } }); } console.timeEnd('Filtering and pagination'); // Pagination and slice logic this.pagination.total_pages = Math.ceil(output.length / this.pagination.per_page); this.pagination.filtered_available = output.length; const perPage = this.pagination.per_page; const page = this.pagination.page; const startIndex = (page - 1) * perPage; const endIndex = startIndex + perPage; this.rows = output.slice(startIndex, Math.min(endIndex, output.length)); return this.rows; } }, async created() { if (this.config.hasOwnProperty('defaultPageSize') && this.config.defaultPageSize) { this.pagination = {page: 1, per_page: this.config.defaultPageSize, total_rows: null, total_pages: 1}; } this.parseSettingsFromLocalStorage() if (!this.disableInitialFetch) { this.disableDebounce = true; await this.fetchRows() this.isInitialised = true; } // if sticky is true then add style element to style thead sticky if (this.sticky) { const style = document.createElement('style'); style.innerHTML = `table thead th { position: sticky; top: 0; z-index: 1; background-color: white; }`; document.head.appendChild(style); } }, })