summaryrefslogblamecommitdiffstats
path: root/webapp/src/components/DataTable.vue
blob: 4643d54ad5735ec7f2b76fa1bfc273761126ab6b (plain) (tree)
1
2
3
4
5
6
7
8
9
10


         
                 


                              


                                                





                                       

         
                  


                                   
                     
                                                       
                                                    





                                                    





          


















                                                                                                           

                                                                                




























                                                                                                                                                 
 



                                                                                                                                     
 

                                                                             
              















                                                                     
 

                           
                    
                             
                    
                                     
                            
                           
                     
                                        
                                                   
     
                        
                                                                                   
                                  
                                                                          
                                                                    




                                                                                                                                 
                                              
                       

                                                                                             
                                                                                                                 







                                                                                                                     
                                                       

                                                                                                                    
                


                                                                               
              
                 

                                           
                                                                                                   






                                                                                                                                                  
                                                                                                                     
                                                                              
                
              



                                                                                           
                      
             
                       


                          


                                          
                                                               




















                                                                                  
                                                                                 



                                                                                        



           
                                                          
                                                


                    
               

                    
    







                    



                    




                       
                  
      
           


                    

                  



                    







                    
      
                 

                    



            
                   
                           
                     
                          

                           

                              
                      


                        


             


                                             




                                                                                
                      
                                                                  


                                                                                                                

                  
             
                                                                                                  
      


                                             
                                                                                        







                                                                                                        
       
                                



                                                                                                         


                                                           


          
             
                               

                                        



                          
                                 
                                      

       
                     
                                                    
                                 
                            


            
                  
                                                          




                                                                    





                                                                    
                             
                               




                                       
                                    


                                                   

                                                                                                        
              
                                                                

                                                                                                          
       

                                                
                            
                                   


                                        
                                                
                                  
                                                        
              
                                                          
       
                                                


                                        
                             
              

                                                                  
                                                                          
                         






                                      
              

                                                                  

                                                                          
                         










                                                                
                        
                                             








                                                                                   
                               


                                                          
                             



                                                                        
       
                                         


                                    
      











                                                 
                                                                                    
                                                   


                        
                                                                                                 





                                          

                                                                                                                                   
                                                                                             


























                                                                                         



















                                                  






                                                                   


                      
 


















                                    








                       




                      
                           





                           
                      



                         










                      





                      


                        











                                        



                   
                  
                             





                                                   
                                           

                   
             


             
                     



















                                                  




                                                                                     


                            
                                                          


                          
                                                                  


                        
                                                                                                              


                          
                                                                                                                



























                                         



                      

                                                       

 
                                

              

 
                                     
                 
                      
                 

 




                







                                               









                                                 
       

                                                  
               
                   

 
                                                         


                    
<i18n>
{
  "en": {
    "all": "All",
    "entries": "Entries",
    "noResult": "No entries.",
    "height": "Height",
    "regex": "Regex",
    "caseSensitive": "Case sensitive",
    "onlyShowSelected": "Selected entries only",
    "selected": "selected",
    "copyDone": "Copied to Clipboard",
    "nothingToCopy": "Nothing to Copy",
    "copyHeading": "Copy to Clipboard",
    "copy": "Copy",
    "close": "Close"
  },
  "de": {
    "all": "Alle",
    "entries": "Einträge",
    "noResult": "Keine Einträge.",
    "height": "Höhe",
    "regex": "Regex",
    "caseSensitive": "Groß-/Kleinschreibung beachten",
    "onlyShowSelected": "Nur ausgewählte Einträg",
    "selected": "ausgewählt",
    "copyDone": "In die Zwischenablage kopiert",
    "nothingToCopy": "Nichts zu kopieren",
    "copyHeading": "In die Zwischenablage kopieren",
    "copy": "Kopieren",
    "close": "Schließen"
  }
}
</i18n>

<template>
  <div>

    <v-row no-gutters>
      <v-col cols="12" :md="slim ? 12 : 6" class="search-wrapper" :class="{ 'slim-search-wrapper': slim }">
        <div class="d-flex" style="flex: 1">
          <data-table-search
            ref="search"
            @filter="filteredRows = $event"
            :selected="value"
            :items="sortedRows"
            :data-keys="headersWithText"
            nested-data
            :regex="regex"
            :case-sensitive="caseSensitive"
            :only-show-selected="onlyShowSelected"
            :slim="slim"
            :dispatch-scroll="computedRowCount <= 0"
          ></data-table-search>
          <slot name="after-search"></slot>
        </div>
        <v-divider :class="{ 'hidden-md-and-up': !slim }"></v-divider>
        <v-divider v-if="!slim" vertical class="hidden-sm-and-down"></v-divider>
      </v-col>

      <v-col cols="12" :md="slim ? 12 : 6" class="px-4 table-controls non-selectable">
        <div class="nowrap">
          <v-tooltip top open-delay="800">
            <template #activator="{ on }">
              <v-btn v-on="on" icon class="toggle-button" @click="regex = !regex">
                <span :class="regex ? 'primary--text' : ''">.*?</span>
              </v-btn>
            </template>
            <span>{{ $t('regex') }}</span>
          </v-tooltip>
          <v-tooltip top open-delay="800">
            <template #activator="{ on }">
              <v-btn v-on="on" icon class="toggle-button" @click="caseSensitive = !caseSensitive">
                <span :class="caseSensitive ? 'primary--text' : ''">Aa</span>
              </v-btn>
            </template>
            <span>{{ $t('caseSensitive') }}</span>
          </v-tooltip>
          <v-tooltip v-if="!noSelect" top open-delay="800">
            <template #activator="{ on }">
              <v-btn v-on="on" icon class="toggle-button" @click="onlyShowSelected = !onlyShowSelected">
                <v-icon small :class="onlyShowSelected ? 'primary--text' : ''">{{ singleSelect ? 'radio_button_checked' : 'check_box' }}</v-icon>
              </v-btn>
            </template>
            <span>{{ $t('onlyShowSelected') }}</span>
          </v-tooltip>
        </div>

        <div class="entry-count" :style=" { 'justify-content': rowCount === undefined ? 'center' : 'flex-end' }">
          <span class="nowrap" :style="{ 'margin': noSelect ? '0' : '0 4px' }">{{ filteredRows.length + ' ' + $t('entries') }}</span>
          <span v-if="!noSelect" class="nowrap">{{ '(' + selected.length + ' ' + $t('selected') + ')' }}</span>
        </div>

        <div v-if="rowCount === undefined" class="text-right hidden-xs-only">
          {{ $t('height') }}:
        </div>
        <v-select
          v-if="rowCount === undefined"
          solo flat
          class="rowcount-select"
          v-model="internalRowCount"
          :items="[ 5, 10, 20, 50, { text: $t('all'), value: '-1' }]"
          color="primary"
          hide-details
          :menu-props="{
            offsetY: true,
            left: true,
            contentClass: 'data-table-rowcount-select-content'
          }"
        ></v-select>
      </v-col>
    </v-row>

    <v-divider></v-divider>

    <RecycleScroller
      :key="computedRowCount"
      ref="scroller"
      class="scroller non-selectable"
      :style="scrollerStyle"
      :items="filteredRows"
      :item-size="48"
      :page-mode="computedRowCount <= 0"
      @click.native.capture.passive="setShiftState"
    >
      <template #before>
        <div class="table-head-wrapper" :style="{ 'min-width': computedMinWidth }">
          <div class="table-head">
            <div v-if="!noSelect" class="header-cell" style="width: 24px">
              <v-icon v-if="!singleSelect" @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 }"
              :class="{
                'sortable-header': !noSort && header.text !== undefined,
                'header-sorted': headerSortState[header.sortKey || header.key] !== undefined,
                'header-sorted-desc': (headerSortState[header.sortKey || header.key] || {}).direction === '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-if="copyButton" class="copy-button">
              <v-btn icon @click="copyDialog = !copyDialog"><v-icon style="opacity: 0.3;">file_copy</v-icon></v-btn>
            </div>
          </div>
          <div class="header-separator" style="height: 2px">
            <loading-bar :loading="loading" :transparent="false"></loading-bar>
          </div>
        </div>
      </template>
      <template #default="{ item, index }">
        <div class="table-row"
          :style="item.selected && { backgroundColor: $vuetify.theme.currentTheme.primary + '11' }"
          @click="selectItem(item, index)"
          @dblclick="$emit('dblclick', item.data)"
        >
          <div v-if="!noSelect" class="non-selectable">
            <v-icon style="cursor: pointer" :color="item.selected ? 'primary' : ''">{{ selectedIconMap[item.selected] }}</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" class="selectable">{{ item.data[header.key] }}</span>
            <slot v-else :name="header.key" :item="item.data" :index="index"/>
          </div>
        </div>
      </template>
      <template #after>
        <div v-if="filteredRows.length === 0"  class="no-result">{{ $t('noResult') }}</div>
      </template>
    </RecycleScroller>
    <v-dialog
      v-if="copyButton"
      v-model="copyDialog"
      scrollable
      :max-width="300"
    >
      <v-card class="non-selectable">
        <v-card-title class="elevation-3">
          <div class="subtitle-1">{{ $t('copyHeading') }}</div>
          <v-spacer></v-spacer>
          <v-btn-toggle v-model="copyFormat" mandatory class="copy-format-toggle">
            <v-btn small :color="copyFormat === 0 ? 'primary' : ''">CSV</v-btn>
            <v-btn small :color="copyFormat === 1 ? 'primary' : ''">JSON</v-btn>
          </v-btn-toggle>
        </v-card-title>
        <v-card-text style="height: 100%">
          <textarea ref="copyHelper" style="display: none"></textarea>
          <v-checkbox
            v-for="header in headersWithText"
            v-model="header.includeInCopy"
            :key="header.key"
            color="primary"
            :label="header.text"
            hide-details
            class="copy-header-checkbox"
          ></v-checkbox>
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn small text @click="copyDialog = false">{{ $t('close') }}</v-btn>
          <v-btn small color="primary" @click="copyToClipboard">{{ $t('copy') }}</v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<script>
import DataTableSearch from '@/components/DataTableSearch'
import LoadingBar from '@/components/LoadingBar'

export default {
  name: 'DataTable',
  components: {
    DataTableSearch,
    LoadingBar
  },
  props: {
    headers: {
      type: Array
    },
    items: {
      type: Array,
      required: true
    },
    loading: {
      type: Boolean,
      default: false
    },
    value: {
      type: Array,
      default: () => []
    },
    minWidth: {
      type: String
    },
    slim: {
      type: Boolean,
      default: false
    },
    rowCount: {
      type: Number
    },
    singleSelect: {
      type: Boolean,
      default: false
    },
    noSelect: {
      type: Boolean,
      default: false
    },
    noSort: {
      type: Boolean,
      default: false
    },
    copyButton: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      selected: [],
      internalRowCount: 10,
      selectState: 0,
      headerSortState: {},
      regex: false,
      caseSensitive: false,
      onlyShowSelected: false,
      lastSelectIndex: null,
      shiftKey: false,
      filteredRows: [],
      copyDialog: false,
      copyFormat: 0
    }
  },
  computed: {
    computedMinWidth () {
      if (this.minWidth) return this.minWidth
      if (this.slim) return '200px'
      return '600px'
    },
    computedRowCount () {
      return this.rowCount === undefined ? this.internalRowCount : this.rowCount
    },
    scrollerStyle () {
      const style = { '--min-table-width': this.computedMinWidth }
      if (this.computedRowCount > 0 && this.computedRowCount < this.filteredRows.length) {
        style.height = (Math.max(Math.min(this.computedRowCount, this.filteredRows.length), 1) * 48 + 58) + 'px'
      }
      return style
    },
    rows () {
      return Object.freeze(this.items.map(item => ({ data: item, selected: false, id: item.id })))
    },
    sortedRows () {
      const rows = this.rows.slice(0)
      for (let key in this.headerSortState) {
        if (!this.headers.some(header => header.sortKey || header.key === key)) continue
        const direction = this.headerSortState[key].direction
        const type = this.headerSortState[key].type
        let compareFunction
        if (type === 'number') compareFunction = (a, b) => (Number(a.data[key]) - Number(b.data[key]))
        else if (type === 'ipv4') compareFunction = (a, b) => this.ipv4Compare(a.data[key], b.data[key])
        else compareFunction = (a, b) => String(a.data[key]).localeCompare(String(b.data[key]))
        rows.sort(compareFunction)
        if (direction === 'desc') rows.reverse()
      }
      return Object.freeze(rows)
    },
    selectedIconMap () {
      if (this.singleSelect) return { 'true': 'radio_button_checked', 'false': 'radio_button_unchecked' }
      return { 'true': 'check_box', 'false': 'check_box_outline_blank' }
    },
    headersWithText () {
      return this.headers.filter(h => h.text !== undefined)
    }
  },
  watch: {
    rows () {
      if (this.noSelect) return
      this.processSelected(this.value)
      this.$emit('input', this.selected)
    },
    value: {
      immediate: true,
      handler (newValue) {
        if (this.noSelect) return
        this.processSelected(newValue)
      }
    },
    filteredRows () {
      if (this.noSelect || this.singleSelect) return
      this.lastSelectIndex = null
      this.calcSelectState()
    }
  },
  methods: {
    resetData () {
      if (this.$refs.search) this.$refs.search.resetData()
      Object.assign(this.$data, this.$options.data.call(this))
      this.processSelected(this.value)
      this.$emit('input', this.selected)
      if (this.$refs.scroller) this.$refs.scroller.$el.scrollTop = 0
    },
    resetSearch () {
      if (this.$refs.search) this.$refs.search.resetData()
      this.processSelected(this.value)
      this.$emit('input', this.selected)
      if (this.$refs.scroller) this.$refs.scroller.$el.scrollTop = 0
    },
    selectItem (row, index) {
      if (this.noSelect) return
      if (this.singleSelect) {
        this.$emit('input', [row.data])
        return
      }

      // Select or deselect this row
      const selected = row.selected = !row.selected
      if (selected) {
        this.selected.push(row.data)
        // Shift click: Select all rows between this row and the last selected row
        if (this.shiftKey && this.lastSelectIndex !== null) this.selectRows(index, this.lastSelectIndex)
      } else {
        this.selected.splice(this.selected.indexOf(row.data), 1)
        // Shift click: Deselect all rows between this row and the last selected row
        if (this.shiftKey && this.lastSelectIndex !== null) this.deselectRows(index, this.lastSelectIndex)
      }
      // Update variables and emit the new value
      this.lastSelectIndex = index
      this.calcSelectState()
      this.$emit('click', row.data)
      this.$emit('input', this.selected)
    },
    toggleSelectAll () {
      if (this.filteredRows.length === 0) return
      if (this.selectState <= 1) {
        this.selectRows(0, this.filteredRows.length - 1)
      } else {
        this.deselectRows(0, this.filteredRows.length - 1)
      }
      // Update variables and emit the new value
      this.calcSelectState()
      this.$emit('input', this.selected)
    },
    selectRows (start, end) {
      var rows
      if (this.onlyShowSelected) rows = this.filteredRows.slice(0)
      else rows = this.filteredRows
      for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
        let row = rows[i]
        if (row.selected !== true) {
          row.selected = true
          this.selected.push(row.data)
        }
      }
    },
    deselectRows (start, end) {
      var rows
      if (this.onlyShowSelected) rows = this.filteredRows.slice(0)
      else rows = this.filteredRows
      const deselected = {}
      for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
        let row = rows[i]
        if (row.selected === true) {
          row.selected = false
          deselected[row.data.id] = true
        }
      }
      var newSelected = []
      this.selected.forEach(item => {
        if (deselected[item.id] !== true) newSelected.push(item)
      })
      this.selected = newSelected
    },
    calcSelectState () {
      const displayedRows = this.filteredRows
      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
    },
    toggleHeaderSort (header) {
      if (header.text === undefined || this.noSort) return
      const key = header.sortKey || header.key
      const state = this.headerSortState[key]
      const newSortState = {}
      if (state === undefined || state.direction === 'desc') {
        newSortState[key] = { direction: 'asc', type: header.sortType }
      } else {
        newSortState[key] = { direction: 'desc', type: header.sortType }
      }
      this.headerSortState = newSortState
    },
    setShiftState (event) {
      this.shiftKey = event.shiftKey
    },
    processSelected (selected) {
      const tmp = {}
      selected.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)
        } else {
          row.selected = false
        }
      })
      if (this.onlyShowSelected && this.$refs.search) this.$refs.search.filterRows()
      if (this.filteredRows) this.calcSelectState()
    },
    copyToClipboard () {
      let result = ''
      const keys = this.headersWithText.filter(h => h.includeInCopy).map(x => x.copyKey || x.key)
      if (this.copyFormat === 0) {
        const topIndex = keys.length - 1
        this.filteredRows.forEach(row => {
          let rowString = ''
          for (let i in keys) {
            let value = row.data[keys[i]]
            if (typeof value === 'string' &&
              (value.indexOf(',') !== -1 || value.indexOf('"') !== -1 || value.indexOf('\n') !== -1)) value = JSON.stringify(value)
            else if (typeof value === 'object') value = JSON.stringify(JSON.stringify(value))
            if (value) rowString += value
            if (i < topIndex) rowString += ','
          }
          if (rowString) result += rowString + '\n'
        })
      } else if (this.copyFormat === 1) {
        const rows = this.filteredRows.map(row => {
          let filterdRow = {}
          for (let i in keys) {
            let k = keys[i]
            filterdRow[k] = row.data[k]
          }
          return filterdRow
        })
        result = JSON.stringify(rows, null, 4)
      }
      if (!result) {
        this.$snackbar({ text: this.$t('nothingToCopy'), color: 'error', timeout: 1200 })
        return
      }
      const copyHelper = this.$refs.copyHelper
      copyHelper.style.display = 'block'
      copyHelper.value = result
      copyHelper.select()
      document.execCommand('copy')
      copyHelper.style.display = 'none'
      this.$snackbar({ text: this.$t('copyDone'), color: 'primary', timeout: 1200 })
    },
    ipv4Compare (a, b) {
      if (!this.isValidIpv4(a)) return 1
      if (!this.isValidIpv4(b)) return -1
      a = a.split('.').map(x => parseInt(x))
      b = b.split('.').map(x => parseInt(x))
      for (let i in a) {
        if (a[i] < b[i]) return -1
        else if (a[i] > b[i]) return 1
      }
      return 0
    },
    isValidIpv4 (ip) {
      if (!ip) return false
      ip = ip.split('.')
      if (ip.length !== 4) return false
      for (let i in ip) {
        if (ip[i] < 0 || ip[i] > 255) return false
      }
      return true
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.nowrap {
  white-space: nowrap;
}

.copy-button {
  position: absolute;
  top: 0;
  bottom: 0;
  right: 8px;
  margin: 0 !important;
  display: flex;
  align-items: center;
}

.copy-header-checkbox {
  margin: 0;
  padding: 0;
}

.copy-format-toggle .v-btn--active {
  color: white;
}

.toggle-button {
  margin: 0;
  padding: 0;
  min-width: 36px;
  text-transform: none;
  font-size: 14px;
  font-weight: 600;
}

.search-wrapper {
  display: flex;
  align-items: center;
}

@media (max-width: 959px) {
  .search-wrapper {
    flex-direction: column;
    align-items: stretch;
  }
}

.slim-search-wrapper {
  flex-direction: column;
  align-items: stretch;
}

.table-controls {
  display: flex;
  align-items: center;
  font-size: 12px;
  min-height: 48px;
}

.table-controls > * {
  flex: 1 1 auto;
}

.entry-count {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}

.rowcount-select {
  padding: 0;
  display: inline-block;
  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;
  font-family: 'Roboto Mono';
}

.scroller >>> .vue-recycle-scroller__item-wrapper {
  min-width: var(--min-table-width);
}

.scroller >>> .vue-recycle-scroller__slot {
  position: sticky;
  top: 0;
  z-index: 2;
}

.table-head {
  position: relative;
  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 > .sortable-header {
  cursor: pointer;
}

.table-head > .sortable-header.header-sorted.header-sorted-desc > .header-sort-icon {
  transform: rotate(180deg);
}

.table-head > .sortable-header:hover > .header-sort-icon {
  opacity: 0.6 !important;
}

.table-head > .sortable-header.header-sorted > .header-sort-icon {
  opacity: 1 !important;
}

.theme--dark .table-head > .sortable-header:hover, .theme--dark .table-head > .sortable-header.header-sorted {
  color: rgb(255,255,255);
}

.theme--light .table-head > .sortable-header:hover, .theme--light .table-head > .sortable-header.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 ::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

.table-row > div, .table-head > div {
  margin: 0 16px;
  white-space: nowrap;
  overflow: auto;
}

.auto-width {
  flex-grow: 1;
  flex-basis: 0;
}

.theme--dark .table-row {
  border-bottom: 1px solid hsla(0,0%,100%,.12);
}

.theme--light .table-row {
  border-bottom: 1px solid rgba(0,0,0,.12);
}

.theme--dark .scroller >>> .hover > .table-row {
  background-color: #616161;
}

.theme--light .scroller >>> .hover > .table-row {
  background-color: #eee;
}

</style>

<style>
.data-table-search-select-content .v-list-item,
.data-table-rowcount-select-content .v-list-item {
  height: 36px;
  min-height: 36px;
}

.data-table-rowcount-select-content .v-list-item__title {
  text-align: right;
}
</style>