Feature/rework vue schema
This commit is contained in:
@@ -20,10 +20,56 @@
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
|
||||
.tt-table-card {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tt-table-card .page-link {
|
||||
padding: 5px .75rem !important;
|
||||
}
|
||||
|
||||
.tt-table {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tt-table-pagination-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
padding-bottom: 8px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
.tt-table-pagination-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-columns: auto auto;
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 4px;
|
||||
justify-content: end;
|
||||
}
|
||||
.tt-table-pagination {
|
||||
margin: 0;
|
||||
}
|
||||
.tt-table-page-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tt-table-page-item.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tt-table-page-item.active {
|
||||
font-weight: bold;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.tt-table-select {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
.tt-table-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.tt-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -66,6 +66,7 @@ Vue.component('tt-date-picker', {
|
||||
});
|
||||
|
||||
function checkIfAppliedElseClear() {
|
||||
if (this.value && this.value.from && this.value.to) return;
|
||||
$(_this.$refs.input).val('');
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ Vue.component('tt-date-picker', {
|
||||
}
|
||||
|
||||
$(this.$refs.input).on('cancel.daterangepicker', clearIfCancelled);
|
||||
$(this.$refs.input).on('hide.daterangepicker', checkIfAppliedElseClear);
|
||||
$(this.$refs.input).on('hide.daterangepicker', checkIfAppliedElseClear.bind(this));
|
||||
|
||||
// if value from or to is undefined then clear the input field
|
||||
if (!this.value || this.value.from === null || this.value.to === null) {
|
||||
@@ -84,11 +85,15 @@ Vue.component('tt-date-picker', {
|
||||
|
||||
},
|
||||
watch: {
|
||||
value: function (newVal, oldVal) {
|
||||
if (this.isInitialized) {
|
||||
if (!newVal || newVal.from === null || newVal.to === null) {
|
||||
$(this.$refs.input).val('');
|
||||
}
|
||||
value: function (newVal) {
|
||||
if (!this.isInitialized) return;
|
||||
|
||||
const datePicker = $(this.$refs.input).data('daterangepicker');
|
||||
if (!newVal || newVal.from === null || newVal.to === null) {
|
||||
$(this.$refs.input).val('');
|
||||
} else {
|
||||
datePicker.setStartDate(this.moment.unix(newVal.from));
|
||||
datePicker.setEndDate(this.moment.unix(newVal.to));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,11 @@ Vue.component('tt-icon-select', {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
this.observer.disconnect();
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.selectedOption = this.options.find(option => option.value.toString() === val) || null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectOption(option) {
|
||||
this.selectedOption = option;
|
||||
@@ -38,16 +43,16 @@ Vue.component('tt-icon-select', {
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="form-group tt-select" style="user-select: none;margin-bottom: 0">
|
||||
<div class="form-group tt-select" style="user-select: none;margin-bottom: 0; margin-top: 6px">
|
||||
<div class="dropdown" :class="{'show': isOpen}">
|
||||
<i v-if="selectedOption !== null" :class="selectedOption.icon" style="font-size: 24px; cursor: pointer" ref="selectedIcon"></i>
|
||||
<i v-if="selectedOption !== null" :class="selectedOption.icon" style="font-size: 18px; cursor: pointer" ref="selectedIcon"></i>
|
||||
<span v-else style="cursor: pointer" ref="selectedIcon">Alle<i class="fas fa-caret-down"></i></span>
|
||||
<div style="display: grid; justify-items: center;" ref="select">
|
||||
<div class="dropdown-menu" :class="{'show': isOpen}" style="min-width: unset !important;">
|
||||
<a class="dropdown-item text-center" href="#" @click.prevent="selectOption(null)">Alle</a>
|
||||
<a v-for="option in options" class="dropdown-item text-center" href="#"
|
||||
@click.prevent="selectOption(option)">
|
||||
<i :class="option.icon" style="font-size: 24px"></i>
|
||||
<i :class="option.icon" style="font-size: 18px" :title="option.text"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,11 @@ Vue.component('tt-input', {
|
||||
inputValue: this.value,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.inputValue = val;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="form-group">
|
||||
<slot name="prepend"></slot>
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
Vue.component('tt-number-range', {
|
||||
props: {
|
||||
valueFrom: {type: Number, default: 0},
|
||||
valueTo: {type: Number, default: 0},
|
||||
returnText: {type: Boolean, default: false}
|
||||
returnText: {type: Boolean, default: false},
|
||||
value: [String, Object],
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inputValueFrom: this.valueFrom || '', inputValueTo: this.valueTo || '',
|
||||
inputValueFrom: this.value?.from || '',
|
||||
inputValueTo: this.value?.to || '',
|
||||
};
|
||||
}, watch: {
|
||||
valueFrom(newValue) {
|
||||
this.inputValueFrom = newValue;
|
||||
}, valueTo(newValue) {
|
||||
this.inputValueTo = newValue;
|
||||
value(val) {
|
||||
if (this.returnText !== true) {
|
||||
this.inputValueFrom = val.from;
|
||||
this.inputValueTo = val.to;
|
||||
} else {
|
||||
if (val.includes('<')) {
|
||||
this.inputValueFrom = '';
|
||||
this.inputValueTo = val.replace('<', '');
|
||||
} else if (val.includes('>')) {
|
||||
this.inputValueFrom = val.replace('>', '');
|
||||
this.inputValueTo = '';
|
||||
} else {
|
||||
this.inputValueFrom = val.split('-')[0];
|
||||
this.inputValueTo = val.split('-')[1];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, methods: {
|
||||
updateValue() {
|
||||
if (this.returnText !== true) {
|
||||
this.$emit('input', {target: {value: {from: this.inputValueFrom, to: this.inputValueTo}}});
|
||||
this.$emit('input', {from: this.inputValueFrom || undefined, to: this.inputValueTo || undefined});
|
||||
} else if (this.returnText === true) {
|
||||
if (this.inputValueFrom === '' && this.inputValueTo === '') {
|
||||
if (!this.inputValueFrom && !this.inputValueTo) {
|
||||
this.$emit('input', '');
|
||||
} else if (this.inputValueFrom === '') {
|
||||
} else if (!this.inputValueFrom) {
|
||||
this.$emit('input', '<' + this.inputValueTo);
|
||||
} else if (this.inputValueTo === '') {
|
||||
} else if (!this.inputValueTo) {
|
||||
this.$emit('input', '>' + this.inputValueFrom);
|
||||
} else {
|
||||
this.$emit('input', this.inputValueFrom + '-' + this.inputValueTo);
|
||||
@@ -31,13 +44,14 @@ Vue.component('tt-number-range', {
|
||||
}
|
||||
}
|
||||
}, template: `
|
||||
<div style="display:grid;grid-template-columns: 1fr 1fr;grid-gap: 4px;">
|
||||
<div style="display:grid;grid-template-columns: 75px 25px 75px;grid-gap: 4px;justify-content: center">
|
||||
<slot name="prepend"></slot>
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
v-model.number="inputValueFrom"
|
||||
@input="updateValue"
|
||||
>
|
||||
<div style="align-self: center;padding: 0 8px"><i class="fa-solid fa-sort" style="transform: rotate(90deg);"></i></div>
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
v-model.number="inputValueTo"
|
||||
|
||||
@@ -2,7 +2,7 @@ Vue.component('tt-select', {
|
||||
props: ['options', 'label', 'required', 'value', 'suffix'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: '',
|
||||
selectedOption: undefined,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -17,7 +17,7 @@ Vue.component('tt-select', {
|
||||
<div class="form-group">
|
||||
<label v-if="label" :for="label">{{ label }}</label>
|
||||
<select class="form-control form-control-sm" :required="required" v-model="selectedOption"
|
||||
@change="$emit('input', $event.target.value)">
|
||||
@change="$emit('input', $event.target.value ? $event.target.value : undefined)">
|
||||
<template v-for="option of options">
|
||||
<option v-if="['string','number'].includes(typeof option)" :value="option">{{ option }}
|
||||
<template v-if="suffix"> {{ suffix }}</template>
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
//TODO: tt-autocomplete , tt-select aswell as tt-input should be used for filtering
|
||||
//TODO: Add sorting functionality
|
||||
//TODO: Add export to excel and pdf functionality
|
||||
//TODO: Add Date-Range filter
|
||||
//TODO: Add Exact Date filter
|
||||
//TODO: Add new prop serverSide to disable pagination and filtering on the client side
|
||||
//TODO: Add filtering function if serverSide is disabled
|
||||
//TODO: Add JSDoc for various functions and props
|
||||
//TODO: Fixed Table Header
|
||||
|
||||
/**
|
||||
* @typedef {Object} ttTableColumnConfig
|
||||
* @property {string} text - The display text of the column.
|
||||
@@ -17,30 +7,112 @@
|
||||
* @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">«</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">»</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">
|
||||
<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="filters = {}; window.notify('success','Filter zurückgesetzt')" class="fa-solid fa-trash-undo" style="font-size: 24px;margin-right: 8px;cursor: pointer; color: var(--orange)"></i>
|
||||
|
||||
<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>
|
||||
<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 && pagination.total_rows > 0"
|
||||
<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 }">
|
||||
@@ -63,8 +135,8 @@ Vue.component('tt-table', {
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-center"
|
||||
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.total_rows)
|
||||
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.total_rows) + ' von ' + (pagination.total_rows === pagination.filtered_available ? pagination.total_rows : pagination.filtered_available + ' ('+pagination.total_rows+')')"></span>
|
||||
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>
|
||||
@@ -74,7 +146,6 @@ Vue.component('tt-table', {
|
||||
</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 }]">
|
||||
@@ -82,96 +153,69 @@ Vue.component('tt-table', {
|
||||
<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' }"
|
||||
<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
|
||||
<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?.total_rows === 0" style="height: 150px">
|
||||
<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>
|
||||
|
||||
<tr v-for="row in (ssr === false ? computedRows : rows)"
|
||||
:class="typeof config.customRowClass === 'function' ? config.customRowClass(row) : ''">
|
||||
<template v-for="(column, key) in columns">
|
||||
<td :class="{ 'text-center': column.filter === 'iconSelect', [columns[key].class]: true }">
|
||||
<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'" :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>
|
||||
<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>
|
||||
</template>
|
||||
</tr>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Pagination Controls -->
|
||||
<!-- Pagination Controls -->
|
||||
<nav aria-label="Page navigation">
|
||||
<div style="display:grid; grid-template-columns: 1fr;padding-bottom: 8px;align-items:center; justify-content: end">
|
||||
|
||||
<div v-if="pagination && pagination.total_rows > 0"
|
||||
style="display:grid; grid-template-rows: auto auto; grid-template-columns: auto auto; grid-auto-flow: column; grid-gap: 4px; justify-content: end">
|
||||
|
||||
<span class="text-center"
|
||||
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.total_rows)
|
||||
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.total_rows) + ' von ' + (pagination.total_rows === pagination.filtered_available ? pagination.total_rows : pagination.filtered_available + ' ('+pagination.total_rows+')')"></span>
|
||||
|
||||
|
||||
<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">«</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">»</span>
|
||||
<span class="sr-only">Last</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-center">Einträge pro Seite</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>
|
||||
</div>
|
||||
</div>
|
||||
<tt-table-pagination :pagination="pagination" @fetch-rows="fetchRows"
|
||||
v-if="pagination"></tt-table-pagination>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +228,8 @@ Vue.component('tt-table', {
|
||||
small: {type: Boolean, default: true},
|
||||
excelExport: {type: Boolean, default: false},
|
||||
config: {type: Object, default: () => ({}), required: true},
|
||||
ssr: {type: Boolean, default: false}
|
||||
ssr: {type: Boolean, default: false},
|
||||
disableInitialFetch: {type: Boolean, default: false}
|
||||
}, data() {
|
||||
return {
|
||||
window: window,
|
||||
@@ -193,14 +238,17 @@ Vue.component('tt-table', {
|
||||
loading: false,
|
||||
rows: null,
|
||||
rawRows: null,
|
||||
pagination: null,
|
||||
pagination: {},
|
||||
filters: {},
|
||||
debounceTimeout: null,
|
||||
disableDebounce: false,
|
||||
latestFetchTimestamp: null,
|
||||
order: {
|
||||
key: null,
|
||||
order: 'asc' // default sort order
|
||||
}
|
||||
},
|
||||
expandedRows: {},
|
||||
isInitialised: false
|
||||
};
|
||||
},
|
||||
|
||||
@@ -235,7 +283,7 @@ Vue.component('tt-table', {
|
||||
this.pagination = {
|
||||
page: page++,
|
||||
per_page: this.pagination?.per_page ? parseInt(this.pagination.per_page) : 10,
|
||||
total_rows: this.rawRows.length,
|
||||
total_rows: this.rawRows.length || 0,
|
||||
total_pages: this.rawRows.length / this.pagination?.per_page,
|
||||
filtered_available: this.rawRows.length
|
||||
};
|
||||
@@ -261,6 +309,7 @@ Vue.component('tt-table', {
|
||||
this.pagination = response.data.pagination;
|
||||
}
|
||||
this.loading = false;
|
||||
this.isInitialised = true;
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
@@ -271,6 +320,12 @@ Vue.component('tt-table', {
|
||||
* @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);
|
||||
@@ -278,21 +333,39 @@ Vue.component('tt-table', {
|
||||
await this.fetchData(page); // Directly call fetchData without debounce
|
||||
}
|
||||
},
|
||||
applyFilter(event, key) {
|
||||
this.$set(this.filters, key, event.target.value); // Ensure reactivity
|
||||
},
|
||||
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({
|
||||
filters: this.filters,
|
||||
pagination: this.pagination,
|
||||
// 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}`));
|
||||
const settings = JSON.parse(localStorage.getItem(`tt-table-${this.config.key}`) || '{}');
|
||||
if (settings) {
|
||||
this.filters = settings.filters;
|
||||
this.pagination = settings.pagination;
|
||||
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) {
|
||||
@@ -316,7 +389,18 @@ Vue.component('tt-table', {
|
||||
}
|
||||
return 'fa fa-sort'; // default icon when not sorted
|
||||
},
|
||||
exportToExcel() {
|
||||
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));
|
||||
@@ -348,21 +432,56 @@ Vue.component('tt-table', {
|
||||
|
||||
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 () {
|
||||
handler: function (newVal, oldVal) {
|
||||
if (!this.isInitialised) return;
|
||||
|
||||
if (this.ssr) {
|
||||
this.fetchRows(this.pagination?.page || 1, true).then();
|
||||
}
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
pagination: {
|
||||
handler: function () {
|
||||
'pagination.per_page': {
|
||||
handler: function (newVal, oldVal) {
|
||||
if (!this.isInitialised) return;
|
||||
if (newVal === oldVal) return
|
||||
|
||||
this.saveSettingsToLocalStorage();
|
||||
},
|
||||
deep: true
|
||||
}, 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: {
|
||||
/**
|
||||
@@ -406,7 +525,7 @@ Vue.component('tt-table', {
|
||||
pagesArray.push(i);
|
||||
}
|
||||
|
||||
return pagesArray;
|
||||
return pagesArray.length === 0 ? [1] : pagesArray;
|
||||
},
|
||||
computedRows() {
|
||||
if (!this.rawRows || this.ssr === true) return null;
|
||||
@@ -430,7 +549,6 @@ Vue.component('tt-table', {
|
||||
const data = this.rawRows;
|
||||
const output = [];
|
||||
const filters = this.filters;
|
||||
console.log(filters)
|
||||
const filtersLength = Object.keys(filters).length;
|
||||
const headers = this.columns;
|
||||
const dataLength = data.length;
|
||||
@@ -539,19 +657,19 @@ Vue.component('tt-table', {
|
||||
}
|
||||
|
||||
},
|
||||
beforeMount() {
|
||||
this.parseSettingsFromLocalStorage();
|
||||
},
|
||||
mounted() {
|
||||
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};
|
||||
}
|
||||
|
||||
// if ssr is true then register watcher for order
|
||||
if (this.ssr) {
|
||||
this.$watch('order', () => {
|
||||
this.fetchRows(this.pagination.page, true).then();
|
||||
}, {deep: true});
|
||||
|
||||
|
||||
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
|
||||
@@ -560,7 +678,5 @@ Vue.component('tt-table', {
|
||||
style.innerHTML = `table thead th { position: sticky; top: 0; z-index: 1; background-color: white; }`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
this.fetchRows().then();
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user