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




                       



                                   
                                      
                                                      
                          



                      



                                   
                                                       
                                                               
                             





          
                                                
                                                                                                                    











                                                         
                       

                            
                          












                                                                               



                                                                                                 

                

                                                                                
               
                                                                                 
                                                                 
                              











                                                                                                         





                                                                                                               
                
 
                                                                                                              

                                                                                                
                
 
                                                                                  


                               
                                         

                                   
                                      









                                                                            

               
 

                           
                    
                    
                                     
                            
                           
                       
                                        
                                                   

                                        
                                                                                                  



















                                                                                                                                 
                
                                                                                                                    
                                                     
              
                 
                                                         
                                                                                    
                                        


                                                
                                                                                                                                                         

                                                                                                                                                
                                                                                                                                         


                                                              
                                                                                                               
                      



           


                    







                    



                    




                       
                  
      
           


                    

                  



            
                   
                                                                                                        
                           
                     
                          

                           

                              

                       
                                                           


             


                                                           


                                             




                                                                                
                      
                                                                  
                                                                                                                                             

                  



                                                                                   













                                                                                                                    


          
             

                                        



                          
                                      

       


                       








                         






                         
                                 
                            


            





                                                                    

                                    


                                                   

                                                                                                        
              
                                                                

                                                                                                          
       

                                                
                            
                                   


                                        
                                                
                                  
                                                        
              
                                                          
       
                                                


                                        
                             
              

                                                                  
                                                                          
                         






                                      
              

                                                                  

                                                                          
                         










                                                                
                        
                                             









                                                                                   
                                           


















                                                   



                                                   







                                                    
       
                                         


                                    
      














                                                   
                   
                                       
                                 
 




                                                                                
 
                                              
                       
                                                                   
              
                                        
                                                                                                             
                                 

                                                                    

                    
                                                                    


                              

                                            
     


                     





                                                                   


                      
 








                       
                           





                           
                      



                         












                             
                             








                        
 











                                        



                   
                  
                             





                                                   







































































                                                                                                        



                      

                                                       

 




                                
                                     
                 
                      
                 

 




                







                                               









                                                 








                                                          
<i18n>
{
  "en": {
    "search": "Search",
    "all": "All",
    "entries": "Entries",
    "noResult": "No entries.",
    "height": "Height",
    "regex": "Regular Expressions",
    "caseSensitive": "Case Sensitive",
    "onlyShowSelected": "Only show selected entries.",
    "selected": "selected"
  },
  "de": {
    "search": "Suche",
    "all": "Alle",
    "entries": "Einträge",
    "noResult": "Keine Einträge.",
    "height": "Höhe",
    "regex": "Regulärer Ausdruck",
    "caseSensitive": "Groß-/Kleinschreibung beachten",
    "onlyShowSelected": "Nur ausgewählte Einträge anzeigen.",
    "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 }">
        <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"
              clearable
            ></v-text-field>
            <v-select
              v-if="!slim"
              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>
            <template v-if="!slim">
              <v-btn v-if="index === 0" icon @click="newSearchField"><v-icon>add</v-icon></v-btn>
              <v-btn v-else icon @click="removeSearchField(s)"><v-icon>remove</v-icon></v-btn>
            </template>
          </div>
        </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-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">
              <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>
            <v-tooltip top open-delay="800">
              <v-btn icon class="toggle-button" @click="onlyShowSelected = !onlyShowSelected" slot="activator">
                <v-icon small :class="onlyShowSelected ? 'primary--text' : ''">check_box</v-icon>
              </v-btn>
              <span>{{ $t('onlyShowSelected') }}</span>
            </v-tooltip>
          </div>

          <div :class="{ 'text-xs-center': rowCount === undefined, 'text-xs-right': rowCount !== undefined }">
            <span class="nowrap">{{ filteredRows.length + ' ' + $t('entries') }}</span>
            <span 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-height="48"
      :page-mode="computedRowCount <= 0"
      @click.native.capture.passive="setShiftState"
    >
      <template slot="before-container">
        <div class="table-head-wrapper non-selectable" :style="{ 'min-width': computedMinWidth }">
          <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>
      </template>
      <div slot-scope="{ item, index }" class="table-row"
        :style="item.selected && { backgroundColor: $vuetify.theme.primary + '11' }"
        @click="selectItem(item, index)"
        @dblclick="$emit('dblclick', item.data)"
      >
        <div class="non-selectable">
          <v-icon style="cursor: pointer" :color="item.selected ? 'primary' : ''">{{ 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 style="user-select: text" 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="filteredRows.length === 0" slot="after-container" class="no-result">{{ $t('noResult') }}</div>
    </RecycleScroller>
  </div>
</template>

<script>

export default {
  name: 'DataTable',
  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
    }
  },
  data () {
    return {
      selected: [],
      search: [{ text: { raw: '', upper: '', regex: new RegExp(), regexCI: new RegExp() }, key: null }],
      internalRowCount: 10,
      selectState: 0,
      headerSortState: {},
      regex: false,
      caseSensitive: false,
      onlyShowSelected: false,
      lastSelectIndex: null,
      shiftKey: false,
      filteredRows: [],
      filteringLoop: { running: false, cancel: () => true }
    }
  },
  computed: {
    searchableHeaders () {
      return this.headers.filter(h => h.text !== undefined)
    },
    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
    },
    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
    }
  },
  watch: {
    rows () {
      this.processSelected(this.value)
      this.$emit('input', this.selected)
    },
    value: {
      immediate: true,
      handler (newValue) {
        this.processSelected(newValue)
      }
    },
    sortedRows () {
      this.filterRows()
    },
    regex () {
      this.filterRows()
    },
    caseSensitive () {
      this.filterRows()
    },
    onlyShowSelected () {
      this.filterRows()
    },
    search: {
      deep: true,
      handler () {
        this.filterRows()
      }
    },
    filteredRows () {
      this.lastSelectIndex = null
      this.calcSelectState()
    }
  },
  methods: {
    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
    },
    selectItem (row, index) {
      // 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
    },
    prerocessSearch (s, raw) {
      if (typeof raw !== 'string') 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 })
    },
    removeSearchField (s) {
      this.search.splice(this.search.indexOf(s), 1)
      window.dispatchEvent(new Event('scroll'))
    },
    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.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.filterRows()
      if (this.filteredRows) this.calcSelectState()
    },
    filterRows () {
      // Cancel the last filtering loop
      this.filteringLoop.cancel()

      // Skip filtering if all search strings are empty
      if (this.search.every(s => s.text.raw === '') && !this.onlyShowSelected) {
        this.filteredRows = this.sortedRows
        return
      }

      // Filter rows using a time slicing loop
      const result = []
      this.filteringLoop = this.$loop(this.sortedRows.length, 1000,
        i => {
          const row = this.sortedRows[i]
          if ((!this.onlyShowSelected || (this.onlyShowSelected && row.selected)) && this.search.every(s => {
            if (s.key === null) {
              return this.dataKeys.some(key => {
                return this.filterFunction(s, String(row.data[key]))
              })
            } else {
              return this.filterFunction(s, String(row.data[s.key]))
            }
          })) result.push(row)
        },
        () => { this.filteredRows = result }
      )
    }
  },
  created () {
    this.filterRows()
  }
}
</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;
}

.search-field-wrapper {
  display: flex;
  flex-direction: column;
}

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

.search-field {
  margin: 0;
  padding: 0;
  font-family: 'Roboto Mono';
}

.column-select {
  flex: 0 1 0
}

.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);
}

.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 ::-webkit-scrollbar {
  width: 3px;
  height: 3px;
}

.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>