summaryrefslogblamecommitdiffstats
path: root/webapp/src/components/DataTable.vue
blob: d51b73588c6ddf0aac0304848868f9a774512959 (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"
  },
  "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"
  }
}
</i18n>

<template>
  <div>
    <v-layout wrap :class="{ 'd-block': slim }">
      <v-flex xs12 :md6="!slim" class="d-flex align-center search-wrapper" :class="{ 'slim-search-wrapper': slim }">
        <data-table-search
          ref="search"
          @filter="filteredRows = $event"
          :selected="value"
          :items="sortedRows"
          :data-keys="headers.filter(h => h.text !== undefined)"
          nested-data
          :regex="regex"
          :case-sensitive="caseSensitive"
          :only-show-selected="onlyShowSelected"
          :slim="slim"
          :dispatch-scroll="computedRowCount <= 0"
        ></data-table-search>
        <v-divider :class="{ 'hidden-md-and-up': !slim }"></v-divider>
        <v-divider v-if="!slim" vertical class="hidden-sm-and-down"></v-divider>
      </v-flex>
      <v-flex style="padding: 6px 16px" class="non-selectable" xs12 :md6="!slim">
        <div class="d-flex align-center" style="font-size: 12px">
          <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-xs-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, 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
      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] === '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>
          <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.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 style="user-select: text" v-if="$scopedSlots[header.key] === undefined">{{ item.data[header.key] }}</span>
            <slot v-else :name="header.key" :item="item.data" />
          </div>
        </div>
      </template>
      <template #after>
        <div v-if="filteredRows.length === 0"  class="no-result">{{ $t('noResult') }}</div>
      </template>
    </RecycleScroller>
  </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
    }
  },
  data () {
    return {
      selected: [],
      internalRowCount: 10,
      selectState: 0,
      headerSortState: {},
      regex: false,
      caseSensitive: false,
      onlyShowSelected: false,
      lastSelectIndex: null,
      shiftKey: false,
      filteredRows: []
    }
  },
  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) 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]
        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 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' }
    }
  },
  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 === 'desc') {
        newSortState[key] = 'asc'
      } else if (state === 'asc') {
        newSortState[key] = 'desc'
      }
      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()
    }
  }
}
</script>

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

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

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

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

.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 {
  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-rowcount-select-content .v-list__tile {
  height: 36px;
}

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