summaryrefslogtreecommitdiffstats
path: root/webapp/src/components/DataTable.vue
diff options
context:
space:
mode:
authorUdo Walter2019-01-15 03:19:50 +0100
committerUdo Walter2019-01-15 03:19:50 +0100
commitf27f78f79a8729c5e57f1fcfb4424c6abb6bbd5b (patch)
tree02f334355dbecfde5195aa881240ba6ae777c22d /webapp/src/components/DataTable.vue
parent[server] eslint fixes (diff)
downloadbas-f27f78f79a8729c5e57f1fcfb4424c6abb6bbd5b.tar.gz
bas-f27f78f79a8729c5e57f1fcfb4424c6abb6bbd5b.tar.xz
bas-f27f78f79a8729c5e57f1fcfb4424c6abb6bbd5b.zip
[webapp] add second iteration of the virtual table with more features
search (normal, regex, case sensitive, per column, multiple search filter) sorting + ui polishing
Diffstat (limited to 'webapp/src/components/DataTable.vue')
-rw-r--r--webapp/src/components/DataTable.vue490
1 files changed, 413 insertions, 77 deletions
diff --git a/webapp/src/components/DataTable.vue b/webapp/src/components/DataTable.vue
index 721f137..74b114a 100644
--- a/webapp/src/components/DataTable.vue
+++ b/webapp/src/components/DataTable.vue
@@ -3,77 +3,159 @@
"en": {
"search": "Search",
"all": "All",
- "pageText": "{0}-{1} of {2}",
- "pageTextZero": "0 of 0",
- "rowsPerPageText": "Rows per page:"
+ "entries": "Entries",
+ "noResult": "No entries.",
+ "height": "Height",
+ "regex": "Regular Expressions",
+ "caseSensitive": "Case Sensitive"
},
"de": {
"search": "Suche",
"all": "Alle",
- "pageText": "{0}-{1} von {2}",
- "pageTextZero": "0 von 0",
- "rowsPerPageText": "Reihen pro page:"
+ "entries": "Einträge",
+ "noResult": "Keine Einträge.",
+ "height": "Höhe",
+ "regex": "Regulärer Ausdruck",
+ "caseSensitive": "Groß-/Kleinschreibung beachten"
}
}
</i18n>
<template>
<div>
- <v-layout wrap align-center class="actions-container">
- <v-flex md3 sm5 xs12 order-md1 order-sm1 order-xs1 class="text-xs-left">
- <v-text-field
- class="search-field"
- :placeholder="$t('search')"
- v-model="search"
- hide-details
- prepend-inner-icon="search"
- ></v-text-field>
+ <v-layout wrap>
+ <v-flex xs12 sm6 class="d-flex align-center search-wrapper">
+ <div class="search-field-wrapper">
+ <div v-for="(s, index) in search" :key="index">
+ <v-text-field
+ color="primary"
+ solo flat
+ style="padding: 0: margin: 0;"
+ class="search-field"
+ :placeholder="$t('search')"
+ :value="s.text.raw"
+ @input="prerocessSearch(s, $event)"
+ hide-details
+ prepend-inner-icon="search"
+ ></v-text-field>
+ <v-select
+ solo flat
+ class="column-select"
+ v-model="s.key"
+ :items="[ { text: $t('all'), key: null }, ...searchableHeaders ]"
+ item-text="text"
+ item-value="key"
+ color="primary"
+ hide-details
+ :menu-props="{
+ offsetY: true,
+ left: true
+ }"
+ ></v-select>
+ <v-btn v-if="index === 0" icon @click="newSearchField"><v-icon>add</v-icon></v-btn>
+ <v-btn v-else icon @click="search.splice(search.indexOf(s), 1)"><v-icon>remove</v-icon></v-btn>
+ </div>
+ </div>
+ <v-divider class="hidden-sm-and-up"></v-divider>
+ <v-divider vertical class="hidden-xs-only"></v-divider>
</v-flex>
- <v-flex md4 sm12 xs12 offset-md1 offset-sm0 offset-xs0 order-md2 order-sm3 order-xs3
- class="text-md-center text-xs-right caption font-weight-thin">
- {{ $t('rowsPerPageText') }}
- <v-select
- v-model="rowCount"
- :items="[ 5, 10, 20, 50, 100, { text: $t('all'), value: '-1' }]"
- color="primary"
- hide-details
- :menu-props="{
- offsetY: ''
- }"
- ></v-select>
+ <v-flex style="padding: 6px 16px" class="caption font-weight-thin d-flex align-start" xs12 sm6>
+ <div class="d-flex align-center">
+ <div>
+ <v-tooltip top open-delay="800">
+ <v-btn icon class="toggle-button" @click="regex = !regex" slot="activator">
+ <span :class="regex ? 'primary--text' : ''">.*?</span>
+ </v-btn>
+ <span>{{ $t('regex') }}</span>
+ </v-tooltip>
+ <v-tooltip top open-delay="800">
+ <v-btn icon class="toggle-button" @click="caseSensitive = !caseSensitive" slot="activator">
+ <span :class="caseSensitive ? 'primary--text' : ''">Aa</span>
+ </v-btn>
+ <span>{{ $t('caseSensitive') }}</span>
+ </v-tooltip>
+ </div>
+ <div class="text-xs-center">
+ {{ filterdRows.length + ' ' + $t('entries') }}
+ </div>
+ <div class="text-xs-right">
+ {{ $t('height') }}:
+ </div>
+ <v-select
+ solo flat
+ class="rowcount-select"
+ v-model="rowCount"
+ :items="[ 5, 10, 20, 50, 100, { text: $t('all'), value: '-1' }]"
+ color="primary"
+ hide-details
+ :menu-props="{
+ offsetY: true,
+ left: true,
+ contentClass: 'data-table-rowcount-select-content'
+ }"
+ ></v-select>
+ </div>
</v-flex>
</v-layout>
+
<v-divider></v-divider>
- <RecycleScroller
- class="scroller"
- :style="scrollerStyle"
- :items="filterdRows"
- :item-height="48"
- :page-mode="rowCount <= 0"
- >
- <div slot-scope="{ item }" class="table-row"
- :style="item.selected && { backgroundColor: $vuetify.theme.primary + '11' }"
- @click="selectItem(item)"
- >
- <div>
- <v-icon style="cursor: pointer">{{ item.selected ? 'check_box' : 'check_box_outline_blank' }}</v-icon>
- </div>
- <div v-for="header in headers" :key="header.key" :style="{ width: header.width || headerWidthPercent }">
- {{ item.data[header.key] }}
+ <RecycleScroller
+ class="scroller"
+ :style="scrollerStyle"
+ :items="filterdRows"
+ :item-height="48"
+ :page-mode="rowCount <= 0"
+ >
+ <template slot="before-container">
+ <div class="table-head-wrapper non-selectable" :style="{ 'min-width': minWidth }">
+ <div class="table-head">
+ <div class="non-selectable header-cell">
+ <v-icon @click="toggleSelectAll">
+ {{ selectState === 0 ? 'check_box_outline_blank' : selectState === 1 ? 'indeterminate_check_box' : 'check_box' }}
+ </v-icon>
+ </div>
+ <div v-for="header in headers" :key="header.key" class="header-cell"
+ @click="toggleHeaderSort(header)"
+ :style="{ width: header.width, cursor: header.text !== undefined ? 'pointer' : '' }"
+ :class="{
+ 'header-sorted': headerSortState[header.key] !== undefined,
+ 'header-sorted-desc': headerSortState[header.key] === 'desc',
+ 'auto-width': header.width === undefined
+ }"
+ >
+ {{ header.text }}
+ <v-icon v-if="header.text !== undefined" class="header-sort-icon" style="font-size: 16px; opacity: 0;">
+ arrow_upward
+ </v-icon>
+ </div>
</div>
+ <v-progress-linear v-if="loading" :indeterminate="true" :height="2" style="margin: 0"></v-progress-linear>
+ <div v-else class="header-separator"></div>
</div>
- </RecycleScroller>
-
+ </template>
+ <div slot-scope="{ item }" class="table-row"
+ :style="item.selected && { backgroundColor: $vuetify.theme.primary + '11' }"
+ @click="selectItem(item)"
+ @dblclick="$emit('dblclick', item.data)"
+ >
+ <div class="non-selectable">
+ <v-icon style="cursor: pointer">{{ item.selected ? 'check_box' : 'check_box_outline_blank' }}</v-icon>
+ </div>
+ <div v-for="header in headers" :key="header.key" :style="{ width: header.width }" :class="{ 'auto-width': header.width === undefined }">
+ <span v-if="$scopedSlots[header.key] === undefined" @dblclick.stop>{{ item.data[header.key] }}</span>
+ <slot v-else :name="header.key" :item="item.data" />
+ </div>
+ </div>
+ <div v-if="filterdRows.length === 0" slot="after-container" class="no-result">{{ $t('noResult') }}</div>
+ </RecycleScroller>
</div>
</template>
<script>
-import DataTableItem from '@/components/DataTableItem'
export default {
name: 'DataTable',
- components: { DataTableItem },
props: {
headers: {
type: Array
@@ -82,64 +164,181 @@ export default {
type: Array,
required: true
},
+ loading: {
+ type: Boolean,
+ default: false
+ },
value: {
type: Array,
default: () => []
},
minWidth: {
- type: Number,
- default: 600
+ type: String,
+ default: '600px'
}
},
data () {
return {
- search: '',
- lastSelectId: null,
- shiftKey: false,
- rowCount: 10
+ selected: [],
+ search: [{ text: { raw: '' }, key: null }],
+ rowCount: 10,
+ selectState: 0,
+ regex: false,
+ caseSensitive: false,
+ headerSortState: {}
}
},
computed: {
+ searchableHeaders () {
+ return this.headers.filter(h => h.text !== undefined)
+ },
scrollerStyle () {
- const style = { '--min-table-width': this.minWidth + 'px' }
- if (this.rowCount > 0) style.height = Math.min(this.rowCount, this.filterdRows.length) * 48 + 'px'
+ const style = { '--min-table-width': this.minWidth }
+ if (this.rowCount > 0) style.height = (Math.max(Math.min(this.rowCount, this.filterdRows.length), 1) * 48 + 58) + 'px'
return style
},
- headerWidthPercent () { return 100/this.headers.length + '%' },
dataKeys () { return this.headers.map(x => x.key) },
rows () {
return this.items.map(item => ({ data: item, selected: false, id: item.id }))
},
+ sortedRows () {
+ const rows = this.rows.slice(0)
+ for (let key in this.headerSortState) {
+ const direction = this.headerSortState[key]
+ if (direction === 'asc') rows.sort((a, b) => String(a.data[key]).localeCompare(String(b.data[key])))
+ if (direction === 'desc') rows.sort((b, a) => String(a.data[key]).localeCompare(String(b.data[key])))
+ }
+ return rows
+ },
+ filterFunction () {
+ if (this.regex && this.caseSensitive) return (s, str) => s.text.regex.test(str)
+ else if (this.regex && !this.caseSensitive) return (s, str) => s.text.regexCI.test(str)
+ else if (!this.regex && this.caseSensitive) return (s, str) => str.indexOf(s.text.raw) !== -1
+ else if (!this.regex && !this.caseSensitive) return (s, str) => str.toUpperCase().indexOf(s.text.upper) !== -1
+ },
filterdRows () {
- const search = String(this.search).toLowerCase().trim()
- return search === '' ? this.rows : this.rows.filter(row => {
- return Object.keys(row.data).some(key => {
- return this.dataKeys.includes(key) && String(row.data[key]).toLowerCase().includes(search)
- })
- })
+ if (this.search.every(s => s.text.raw === '')) return this.sortedRows
+ const search = this.search
+ const dataKeys = this.dataKeys
+ const test = this.filterFunction
+ return this.sortedRows.filter(row => search.every(s => {
+ if (s.key === null) {
+ return dataKeys.some(key => {
+ return test(s, String(row.data[key]))
+ })
+ } else {
+ return test(s, String(row.data[s.key]))
+ }
+ }))
}
},
watch: {
- value (v) {
-
- },
items () {
+ this.selectState = 0
this.$emit('input', [])
+ },
+ value: {
+ immediate: true,
+ handler (newValue) {
+ if (this.selected !== newValue) {
+ const tmp = {}
+ newValue.forEach(x => { tmp[x.id] = true })
+ this.selected = []
+ this.rows.forEach(row => {
+ if (tmp[row.data.id] === true) {
+ row.selected = true
+ this.selected.push(row.data)
+ }
+ })
+ this.calcSelectState()
+ this.$emit('input', this.selected)
+ }
+ }
+ },
+ filterdRows () {
+ this.calcSelectState()
}
},
methods: {
setShiftState (event) {
this.shiftKey = event.shiftKey
},
- selectItem (item) {
- item.selected = !item.selected
- var tmp = this.value
- if (item.selected) {
- tmp.push(item.data)
+ selectItem (row) {
+ const selected = row.selected = !row.selected
+ if (selected) {
+ this.selected.push(row.data)
} else {
- tmp.splice(tmp.indexOf(item.data), 1)
+ this.selected.splice(this.selected.indexOf(row.data), 1)
+ }
+ this.calcSelectState()
+ this.$emit('input', this.selected)
+ },
+ toggleSelectAll () {
+ if (this.selectState <= 1) {
+ this.filterdRows.forEach(row => {
+ if (row.selected !== true) {
+ row.selected = true
+ this.selected.push(row.data)
+ }
+ })
+ } else {
+ const tmpMap = {}
+ this.filterdRows.forEach(row => {
+ if (row.selected === true) {
+ row.selected = false
+ tmpMap[row.data.id] = true
+ }
+ })
+ var newValue = []
+ this.selected.forEach(item => {
+ if (tmpMap[item.id] !== true) newValue.push(item)
+ })
+ this.selected = newValue
+ }
+ this.calcSelectState()
+ this.$emit('input', this.selected)
+ },
+ calcSelectState () {
+ const displayedRows = this.filterdRows
+ var seenTrue = false
+ var seenFalse = false
+ for (let i = 0; i < displayedRows.length; i++) {
+ if (displayedRows[i].selected) seenTrue = true
+ else seenFalse = true
+ if (seenTrue && seenFalse) break
+ }
+ this.selectState = seenTrue && seenFalse ? 1 : seenTrue && !seenFalse ? 2 : 0
+ },
+ prerocessSearch (s, raw) {
+ const tmp = { raw, upper: raw.toUpperCase() }
+ try {
+ tmp.regex = new RegExp(raw)
+ tmp.regexCI = new RegExp(raw, 'i')
+ } catch (e) {
+ tmp.regex = { test: () => false }
+ tmp.regexCI = { test: () => false }
+ }
+ s.text = tmp
+ },
+ newSearchField () {
+ this.search.push({ text: {
+ raw: '',
+ upper: '',
+ regex: new RegExp(),
+ regexCI: new RegExp()
+ },
+ key: null })
+ },
+ toggleHeaderSort (header) {
+ if (header.text === undefined) return
+ const state = this.headerSortState[header.key]
+ const newSortState = {}
+ if (state === undefined || state === 'desc') {
+ newSortState[header.key] = 'asc'
+ } else if (state === 'asc') {
+ newSortState[header.key] = 'desc'
}
- this.$emit('input', tmp)
+ this.headerSortState = newSortState
}
}
}
@@ -148,31 +347,160 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
-.scroller-wrapper {
- overflow-x: auto;
+.toggle-button {
+ margin: 0;
+ padding: 0;
+ min-width: 36px;
+ text-transform: none;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+@media (max-width: 599px) {
+ .search-wrapper {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+.search-field-wrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.search-field-wrapper > div {
+ display: flex;
+ align-items: center;
+}
+
+.search-field {
+ margin: 0;
+ padding: 0;
+}
+
+.column-select {
+ flex: 0 1 0
+}
+
+.rowcount-select {
+ padding: 0;
+ display: inline-block;
+ margin: 0 0 0 10px;
+ max-width: 80px;
+}
+
+.rowcount-select >>> .v-input__control {
+ min-height: auto;
+}
+
+.no-result {
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
.scroller {
overflow-x: auto;
+ font-size: 13px;
}
.scroller >>> .vue-recycle-scroller__item-wrapper {
min-width: var(--min-table-width);
}
+.table-head-wrapper {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.table-head {
+ height: 56px;
+ display: flex;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.theme--dark .table-head {
+ color: rgba(255,255,255,.7);
+}
+
+.theme--light .table-head {
+ color: rgba(0,0,0,.54);
+}
+
+.table-head > .header-cell {
+ display: flex;
+ align-items: center;
+ transition: color .3s cubic-bezier(.25,.8,.5,1);
+}
+
+.table-head > .header-cell.header-sorted.header-sorted-desc > .header-sort-icon {
+ transform: rotate(180deg);
+}
+
+.table-head > .header-cell:hover > .header-sort-icon {
+ opacity: 0.6 !important;
+}
+
+.table-head > .header-cell.header-sorted > .header-sort-icon {
+ opacity: 1 !important;
+}
+
+.theme--dark .table-head > .header-cell:hover, .theme--dark .table-head > .header-cell.header-sorted {
+ color: rgb(255,255,255);
+}
+
+.theme--light .table-head > .header-cell:hover, .theme--light .table-head > .header-cell.header-sorted {
+ color: rgba(0,0,0,.87);
+}
+
+.header-separator {
+ height: 2px;
+}
+
+.theme--dark .header-separator {
+ background-color: rgba(255,255,255,.5);
+}
+
+.theme--light .header-separator {
+ background-color: rgba(0,0,0,.5);
+}
+
+.theme--dark .table-head-wrapper {
+ background-color: #424242;
+}
+
+.theme--light .table-head-wrapper {
+ background-color: #fff;
+}
+
+.table-row .table-actions {
+ padding: 0;
+ text-align: right;
+}
+
.table-row {
height: 48px;
display: flex;
align-items: center;
+ transition: background .3s cubic-bezier(.25,.8,.5,1);
+ will-change: background;
}
-.table-row > div {
- padding: 0 24px;
+.table-row > div, .table-head > div {
+ margin: 0 24px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+.auto-width {
+ flex-grow: 1;
+ flex-basis: 0;
+}
+
.theme--dark .table-row {
border-bottom: 1px solid hsla(0,0%,100%,.12);
}
@@ -181,7 +509,6 @@ export default {
border-bottom: 1px solid rgba(0,0,0,.12);
}
-
.theme--dark .scroller >>> .hover > .table-row {
background-color: #616161;
}
@@ -192,3 +519,12 @@ export default {
</style>
+<style>
+.data-table-rowcount-select-content .v-list__tile {
+ height: 36px;
+}
+
+.data-table-rowcount-select-content .v-list__tile__title {
+ text-align: right;
+}
+</style>