Files
thetool/public/plugins/vue/tt-components/tt-table.js
2024-05-10 21:03:01 +00:00

682 lines
31 KiB
JavaScript

/**
* @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: `
<div class="tt-table-pagination-container">
<div v-if="pagination && typeof pagination.total_rows === 'number'" class="tt-table-pagination-wrapper">
<span class="tt-table-text-center" v-text="pageInfoText"
:style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 1 }"></span>
<ul class="pagination tt-table-pagination" :style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 1 }">
<li class="page-item tt-table-page-item" v-bind:class="{ disabled: pagination.page === 1 }">
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">First</span>
</a>
</li>
<li class="page-item tt-table-page-item" v-for="pageNumber in pagesToDisplay"
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }">
<a class="page-link"
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }"
href="#"
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
</li>
<li class="page-item tt-table-page-item"
v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
aria-label="Last">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Last</span>
</a>
</li>
</ul>
<span class="tt-table-text-center" :style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 2 }">Einträge pro Seite</span>
<select v-model="pagination.per_page" v-on:change="fetchRows(1)"
class="form-control form-control-sm tt-table-select"
:style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 2 }">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
</div>
`
})
Vue.component('tt-table', {
template: `
<div class="card tt-table-card" v-if="columns && pagination">
<div class="card-body">
<!-- Top Buttons -->
<div
style="display:grid; grid-template-columns: auto auto auto auto auto; grid-gap: 8px; padding-bottom: 8px">
<slot name="top-buttons"></slot>
</div>
<!-- Pagination Controls -->
<nav aria-label="Page navigation">
<div
style="display:grid; grid-template-columns: 1fr 1fr;padding-bottom: 8px;align-items:center; justify-content: space-between">
<!-- if excelExport is true, show the export button fontawesome icon excel -->
<div style="display:flex;align-items: center;">
<i v-if="!Object.values(columns).every(column => column.filter === false)" title="Filter zurücksetzen"
@click="resetTable" class="fa-solid fa-trash-undo"
style="font-size: 24px;margin-right: 8px;cursor: pointer; color: var(--orange)"></i>
<h4 style="margin: 0">{{ config.tableHeader }}</h4>
<i v-if="excelExport" title="EXCEL Export" @click="exportToExcel" class="fa fa-file-excel"
style="font-size: 24px;margin-left: 8px;cursor: pointer; color: var(--success)"></i>
</div>
<div v-if="pagination && typeof pagination.total_rows === 'number'"
style="display:grid; grid-template-rows: auto auto; grid-template-columns: auto auto; grid-auto-flow: column; grid-gap: 4px; justify-content: end">
<ul class="pagination" style="margin: 0">
<li class="page-item" v-bind:class="{ disabled: pagination.page === 1 }">
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">First</span>
</a>
</li>
<li class="page-item" v-for="pageNumber in pagesToDisplay"
v-bind:class="{ 'active disabled': pageNumber === pagination.page }">
<a class="page-link" v-bind:class="{ 'active disabled': pageNumber === pagination.page }" href="#"
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
</li>
<li class="page-item" v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
aria-label="Last">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Last</span>
</a>
</li>
</ul>
<span class="text-center"
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.filtered_available)
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.filtered_available) + ' von ' + (pagination.total_rows === pagination.filtered_available ? pagination.total_rows : pagination.filtered_available + ' ('+pagination.total_rows+')')"></span>
<select v-model="pagination.per_page" v-on:change="fetchRows(1)" class="form-control form-control-sm">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span class="text-center">Einträge pro Seite</span>
</div>
</div>
</nav>
<!-- Table -->
<table
:class="['table','tt-table','table-condensed',{ 'loading': loading },{ 'table-striped': striped },{ 'table-bordered': bordered },{ 'table-hover': hover },{ 'table-sm': small }]">
<thead style="border-width: 2px">
<tr>
<th scope="col" v-for="column in columns"
:style="'vertical-align: top; text-align: center;' + (column.filter === 'dateRange' ? 'min-width: 260px;' : '')">
<div style="text-align:center; white-space: nowrap;word-break: keep-all;"
:style="{ 'cursor': column.sortable ? 'pointer' : 'default' }"
@click="column.sortable ? setOrder(column.key) : undefined">
{{ column.text }}
<i
v-if="column.sortable"
:class="getSortIconClass(column.key)"></i>
</div>
<tt-input v-if="column.filter === 'search'" sm v-model="filters[column.key]"></tt-input>
<tt-icon-select v-else-if="column.filter === 'iconSelect'" :options="column.filterOptions"
v-model="filters[column.key]"></tt-icon-select>
<tt-number-range v-else-if="column.filter === 'numberRange'" :returnText="!ssr"
v-model="filters[column.key]"></tt-number-range>
<tt-select v-else-if="column.filter === 'select'"
:options="[{text: 'Alle', value: undefined}, ...column.filterOptions]"
v-model="filters[column.key]"></tt-select>
<tt-date-picker v-else-if="column.filter === 'date'" v-model="filters[column.key]"></tt-date-picker>
</th>
</tr>
</thead>
<tbody>
<tr v-if="pagination?.filtered_available === 0" style="height: 150px">
<td :colspan="Object.keys(columns).length" :rowspan="5" class="text-center">Keine Ergebnisse!</td>
</tr>
<tr v-else-if="(pagination === null && ssr === true) || rows === null"
style="height: 150px">
<td :colspan="Object.keys(columns).length" class="text-center">Laden...</td>
</tr>
<template v-for="(row) in (ssr === false ? computedRows : rows)">
<tr :class="typeof config.customRowClass === 'function' ? config.customRowClass(row) : ''"
@click="$emit('row-click', row)"
>
<template v-for="(column, key) in columns">
<td :class="{ 'text-center': column.filter === 'iconSelect', [columns[key].class]: true }">
<!-- If td is first of row then check isExpanded and display fas.fa-chevron-right or fas.fa-chevron-down with cursor pointer -->
<i v-if="key === Object.keys(columns)[0] && $scopedSlots.expandedRow && (typeof config.expandCondition !== 'function' || config.expandCondition(row))"
@click.stop="toggleExpand(row.id)"
:class="isExpanded(row.id) ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"
style="cursor: pointer;font-size: 14px;padding-right: 8px;user-select: none"></i>
<slot :name="key.toLowerCase()" :value="row[key]" :row="row">
<span
v-if="column.filter === 'date'">{{ row[key] ? (moment.unix(row[key]).isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key]).format('DD.MM.YYYY HH:mm')) : '' }}</span>
<i v-else-if="column.filter === 'iconSelect'"
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
<span v-else
v-html="row[key] === null || typeof row[key] === 'undefined' ? null : row[key]?.toString()?.replace('\\n', '<br>')"></span>
</slot>
</td>
</template>
</tr>
<tr v-if="isExpanded(row.id) && $scopedSlots.expandedRow">
<td :colspan="Object.keys(columns).length">
<slot name="expandedRow" :row="row"></slot>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Pagination Controls -->
<nav aria-label="Page navigation">
<tt-table-pagination :pagination="pagination" @fetch-rows="fetchRows"
v-if="pagination"></tt-table-pagination>
</nav>
</div>
</div>
`, 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);
}
},
})