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




                       




                                     



                      




                                                      





          


































                                                                                                           
               



































                                                                                                         

               
 

                           




























                                                                                                                                 
                

                                                                                                                    
              















                                                                                                                                                



           


                    







                    



                    




                       

                      



            






                                                 


             


                                                           
                      

                                                                                                                            

                  



                                                                                   














                                                                                                                    
                    












                                                                           


          
              
                          
                             




















                                                     





                                    



                                                   
              




































































                                                                                   
       
                                         







                                                                   


















































                                        



                   
                  





                                                   







































































                                                                                                        



                      

                                                       

 

                                     




                          




                







                                               









                                                 








                                                          
<i18n>
{
  "en": {
    "search": "Search",
    "all": "All",
    "entries": "Entries",
    "noResult": "No entries.",
    "height": "Height",
    "regex": "Regular Expressions",
    "caseSensitive": "Case Sensitive"
  },
  "de": {
    "search": "Suche",
    "all": "Alle",
    "entries": "Einträge",
    "noResult": "Keine Einträge.",
    "height": "Höhe",
    "regex": "Regulärer Ausdruck",
    "caseSensitive": "Groß-/Kleinschreibung beachten"
  }
}
</i18n>

<template>
  <div>
    <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 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"
    >
      <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>
      </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>

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,
      default: '600px'
    }
  },
  data () {
    return {
      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 }
      if (this.rowCount > 0) style.height = (Math.max(Math.min(this.rowCount, this.filterdRows.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
    },
    filterdRows () {
      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: {
    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 (row) {
      const selected = row.selected = !row.selected
      if (selected) {
        this.selected.push(row.data)
      } else {
        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.headerSortState = newSortState
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>

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

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