Source

components/shared/DataTable/DataTable.vue

<template>
  <div
    :id="id"
    ref="root"
    class="datatable"
    style="position: relative"
    :class="{
      selectable: !!select,
      'loader-overlay': isDatatableLoading && isDatatableLoading.value,
    }"
    :data-name="name"
  >
    <div
      v-if="isDatatableLoading && isDatatableLoading.value"
      class="loader-wrapper"
    >
      <b-spinner
        class="loader"
        variant="info"
        style="width: 3rem; height: 3rem; margin: 0 auto; display: block"
      ></b-spinner>
    </div>
    <div v-if="ssrPaging" class="datatable_toolbar">
      <div>
        <label>Afficher</label>
        <select v-model="pageLength" class="dataTable_cmp__rows_select">
          <option>10</option>
          <option>20</option>
          <option>50</option>
          <option>100</option>
        </select>
        <label>éléments</label>
      </div>

      <div v-show="ssrPaging && !ssrShowMultiColumnFilter" />
      <div
        v-show="ssrPaging && ssrShowMultiColumnFilter"
        class="datatable_filter"
      >
        <label class="datatable_filters__input__label"> Filtrer:&nbsp; </label>
        <!--- TODO: Built-in filter input -->
        <input class="datatable_filters__input" />
      </div>
    </div>

    <table ref="table" class="dataTable__table display compact stripe" />
    <DataTablePaginator
      v-if="ssrPaging === true"
      ref="paginator"
      :page-length="pageLen"
      :name="name"
    />
  </div>
</template>
<script>
import Vue from 'vue'
import $ from 'jquery'
import fr from '@/i18n/i18n.fr'
const defaultLanguage = fr.datatable
const R = require('ramda')
import DataTablePaginator from './DataTablePaginator'
require('datatables.net')
require('datatables.net-select-dt')
import { mapGetters } from 'vuex'

require('@/libs/datetime-moment')

const initDelay = 1000

var dynamicComponents = Vue.__DataTableDynamicComponents || {}
Vue.__DataTableDynamicComponents = dynamicComponents

var dataWatchFlags = {}
export function toggleDataWatch(name, shouldWatch = true) {
  dataWatchFlags[name] = shouldWatch
  Vue.$log.debug('toggleDataWatch', name, shouldWatch, {
    dataWatchFlags,
  })
}

/**
 * Allow you to dynamically render a Vue component inside a Datatable cell
 *
 * Available properties:
 *  row Object
 *  datatable Function
 *  vueDatatable Object
 *
 * Usage:
 *  columnDefs: [
 *       {
 *         targets: 0,
 *         orderable: false,
 *         render: createComponentRender({
 *           name: "MagnifyingGlass",
 *           template: `
 *             <StatusIcon :row="row" ></StatusIcon>
 *           `,
 *         }),
 *       },
 * @namespace components
 * @category components
 * @subcategory shared/Datatable
 * @module DataTable
 */
export function createComponentRender(componentDefinition = {}) {
  componentDefinition.props = {
    row: Object,
    datatable: Function,
    vueDatatable: Object,
  }
  dynamicComponents[componentDefinition.name] = componentDefinition

  return (data = '', type = '', row = {}) => {
    let dataRow = ''
    try {
      dataRow = btoa(encodeURI(JSON.stringify(row)))
    } catch (err) {
      Vue.$log.debug('datatable:fail_to_decode_row', {
        row,
      })
      throw err
    }
    return `<div data-needs-vue-rendering=1 data-component="${componentDefinition.name}" data-value="${data}"
            data-type="${type}"
            data-row="${dataRow}"
            ></div>`
  }
}

export function registerDatatableComponent(name, def = {}) {
  if (typeof def === 'function') {
    let dynamic = def
    def = {
      components: {
        [name + 'Inner']: dynamic,
      },
      template: `
      <${name + 'Inner'}/>
    `,
    }
  }
  def.name = name
  def.props = {
    row: Object,
    datatable: Function,
    vueDatatable: Object,
  }

  dynamicComponents[name] = def
}

export default {
  components: {
    DataTablePaginator,
  },
  inject: {
    disableDatatableSetHeightFromParent: {
      default: null,
    },
    isDatatableLoading: {
      default: null, //{value: true/false}
    },
  },
  props: {
    columns: {
      type: Array,
      default: () => [],
    },
    rowId: {
      type: String,
      default: '',
    },
    columnDefs: {
      type: Array,
      default: () => [],
    },
    name: {
      type: String,
      required: true,
    },
    paging: Boolean,
    ssrPaging: Boolean,
    searching: Boolean,
    ssrColumnOrderingMiddleware: {
      type: Function,
      default: () => null,
    },
    language: {
      type: Object,
      default: () => ({}),
    },
    ssrSortingCooldownDuration: {
      type: Number,
      default: 0,
    },
    defaultSortingColumn: {
      type: Number,
      default: null,
    },
    defaultSortingColumnDirection: {
      type: String,
      default: 'asc',
    },
    scrollY: {
      type: [String, Boolean],
      default: false,
    },
    scroller: Boolean,
    ssrShowMultiColumnFilter: Boolean,
    select: {
      type: [Object, Boolean],
      default: false,
    },
    extraOptions: {
      type: Object,
      default: () => ({}),
    },
    autoHeight: {
      type: Boolean,
      default: false,
    },
    autoHeightOffset: {
      type: Number,
      default: 0,
    },
  },
  data() {
    return {
      initialized: false,
      id: `datatable_${btoa(Math.random()).substring(0, 12)}`,
      pageLength: this.extraOptions.pageLength || 10,
      datatable: null,
      ssrSortingCooldown: null,
    }
  },

  computed: {
    ...mapGetters({
      getTableInfos: 'datatable/getTableInfos',
      getTableItems: 'datatable/getTableItems',
      appDynamicLayout: 'app/layout',
    }),
    currentPage() {
      return this.getTableInfos(this.name).currentPage || 1
    },
    pageLen() {
      return this.datatable && this.datatable.page.len()
    },
    data() {
      return this.getTableItems(this.name)
    },
  },
  watch: {
    /**
     * @todo: Ideally, this wrapper shouldn't know about the app layout. But app layout changes requires datatable to redraw-itself (column offset issue)
     */
    appDynamicLayout: {
      handler() {
        setTimeout(() => {
          this.$nextTick(() => {
            this.draw()
          })
        }, 600)
      },
      deep: true,
    },
    data: {
      handler(data) {
        if (dataWatchFlags[this.name] === false) {
          return
        }
        /*this.$log.debug(
          "datatable.watch.data",
          this.name,
          "dataWatchFlag",
          dataWatchFlags[this.name]
        );*/
        this.onDataChange(data)
      },
      deep: true,
    },
    pageLength() {
      this.datatable && this.datatable.page.len(this.pageLength)
      this.$refs.paginator &&
        this.$refs.paginator.$emit('paginateToPage', {
          page: 1,
          itemsPerPage: this.pageLength,
        })
    },
  },
  mounted() {
    //this.$log.debug("datatable.mounted", this.name);
    this.cooldownSsrSorting()
    this.redrawOnWindowResizeBind()
    this.$nextTick(() => {
      this.onDataChange(this.data)
    })
    if (this.autoHeight) {
      $(window).resize(this.setHeightFromParent)
    }

    let self = this
    function waitForInitialized(cb) {
      if (self.initialized) {
        cb()
      } else {
        self.waitForInitializedTimeout = setTimeout(
          () => waitForInitialized(cb),
          200
        )
      }
    }

    //Issue: Initialization might occurs before completely mounting the component (datatable rendering issue: horizontal scrollbar, columns, offets, etc)
    //Solution:Wait for initialized flag, then re-draw after one second
    waitForInitialized(() => {
      this.waitForInitializedTimeout = setTimeout(() => {
        this.datatable.draw()
      }, 1000)
    })
  },
  destroyed() {
    $(window).off('resize', this.redraw)
    if (this.autoHeight) {
      $(window).off('resize', this.setHeightFromParent)
    }
    if (this.waitForInitializedTimeout) {
      clearTimeout(this.waitForInitializedTimeout)
    }
  },

  methods: {
    setHeightFromParent() {
      if (this.disableDatatableSetHeightFromParent === true) {
        return
      }

      if (
        this._isDestroyed ||
        !this.$refs.root ||
        !this.$refs.root.parentNode
      ) {
        return //Skip if destroyed
      }

      if (
        !!this.$refs.root &&
        $(this.$refs.root).find('.dataTables_scrollHead').length === 0
      ) {
        this.$log.debug('setHeightFromParent::skip')
        return
      }
      const self = this

      let otherParentChildsHeight = $(this.$refs.root.parentNode)
        .children()
        .toArray()
        .filter(function (item) {
          return (
            $(item).attr('id') !== self.id && item.dataset.autoheight !== '0'
          )
        })
        .map((item) => {
          return $(item).height()
        })
        .reduce((a, v) => a + v, 0)

      $(this.$refs.root).find('.dataTables_scrollBody').css('height', `${0}px`)

      let tableBaseHeight = $(this.$refs.root)
        .find('.dataTables_wrapper')
        .height()

      let maxHeight = $(this.$refs.root.parentNode).height()
      if ($(this.$refs.root.parentNode.parentNode).height() > maxHeight) {
        maxHeight = $(this.$refs.root.parentNode.parentNode).height()
      }

      let height =
        maxHeight -
        (otherParentChildsHeight +
          (this.autoHeightOffset || 0) +
          tableBaseHeight)
      $(this.$refs.root)
        .find('.dataTables_scrollBody')
        .css({
          height: `${height}px`,
          'max-height': `${height}px`,
        })
      /*this.$log.debug("setHeightFromParent::compute", {
        maxHeight,
        otherParentChildsHeight,
        autoHeightOffset: this.autoHeightOffset,
        tableBaseHeight,
        height,
      });*/
    },
    setOptions(options = {}) {
      $(`#${this.id} table`).DataTable({
        ...this.getOptions(),
        ...options,
        destroy: true,
      })
    },
    redrawOnWindowResizeBind() {
      this.redraw = () => {
        this.$nextTick(() => {
          this.draw()
        })
      }
      $(window).on('resize', this.redraw)
    },
    draw() {
      //this.$log.debug("datatable.draw", this.name);
      this.cooldownSsrSorting()
      this.$emit('draw', {
        name: this.name,
      })
      this.datatable.draw()
    },
    cooldownSsrSorting() {
      if (this.ssrPaging) {
        this.ssrSortingCooldown = Date.now()
      }
    },
    getDataTable() {
      return this.datatable
    },
    onDataChange() {
      this.$emit('change', this.name)

      if (this.datatable === null) {
        this.initializeDataTable()
      }

      this.cooldownSsrSorting()
      this.datatable.clear().rows.add(this.data || [])
      this.draw()
    },
    getColumns() {
      if (this.columns) {
        return this.columns
      }
      if (this.labels) {
        return Object.keys(this.labels)
      }
    },
    getOptions() {
      let record = this.data[0]
      let columns = this.getColumns() || Object.keys(record)

      //Warning: defaultLanguage is undefined (new i18n object is a plain object instead of a nested object). See i18n/index.js
      let language = {
        ...defaultLanguage,
        ...(this.language || {}),
      }
      delete language.emptyTable

      return {
        initComplete: () => {
          this.onDraw()
        },
        dom: 'lftipr',
        ordering: true,
        autoWidth: true,
        language,
        scrollX: true,
        searching: this.searching === true && !this.ssrPaging,
        scrollY: this.scrollY !== undefined ? this.scrollY : false,
        scroller: this.scroller !== undefined ? this.scroller : false,
        deferRender: window.innerHeight > 900 ? true : false,
        paging: this.paging === true && !this.ssrPaging,
        lengthChange: true,
        info: false,
        ...((this.select && { select: this.select }) || {}),
        rowId: this.rowId || 'id',
        columnDefs: (this.columnDefs && R.clone(this.columnDefs)) || undefined,
        data: this.data,
        columns: columns.map((c) => {
          const capitalize = (text, defaultValue = '') =>
            text
              ? text.charAt(0).toUpperCase() + text.substring(1)
              : defaultValue
          const getValue = (obj, key, defaultValue) =>
            obj[key] !== undefined ? obj[key] : defaultValue
          const isObj = () => typeof c === 'object'

          if (!isObj() && c.indexOf('::') !== -1) {
            c = {
              data: c.split('::')[0],
              title: this.$t(c.split('::')[1]),
            }
          }

          c = {
            data: typeof c === 'string' ? c : undefined,
            ...(isObj() ? c : {}),
            title:
              this.labels && this.labels[c] !== undefined
                ? this.labels[c]
                : (!isObj() && capitalize(c)) ||
                  (isObj() && getValue(c, 'title', getValue(c, 'label'))),
          }
          return c
        }),
        ...(this.extraOptions || {}),
      }
    },
    /**
     * @warn This doesn't work well with column filters
     */
    toggleColumnVisible(index, value) {
      if (this.datatable) {
        this.datatable.column(index).visible(value)
        this.datatable.columns.adjust().draw(false)
      }
    },
    initializeDataTable() {
      /*this.$log.debug(
        "initializeDataTable",
        this.name,
        $(`#${this.id} table`).length
      );*/
      let self = this

      $.fn.dataTable && $.fn.dataTable.moment(this.$date.getDatePattern())

      if (!$.fn.dataTable) {
        this.$log.warn('Datatable::moment plugin not available')
      }

      this.datatable = $(`#${this.id} table`).DataTable(this.getOptions())

      this.datatable.on('draw.dt', (e) => {
        e.stopPropagation()
        //let wrapperId = $(e.target).closest(`[data-name="${this.name}"]`).get(0).id;
        //this.$log.debug("datatable.on.draw", this.name);

        this.onDraw()
      })

      this.datatable.on('order.dt', async (e) => {
        e.stopPropagation()
        //let wrapperId = $(e.target).closest(`[data-name="${this.name}"]`).get(0).id;

        //this.$log.debug("datatable.on.order", this.name);

        if (this.ssrPaging) {
          if (
            this.ssrSortingCooldown != null &&
            Date.now() - this.ssrSortingCooldown <
              (this.ssrSortingCooldownDuration || 1000)
          ) {
            this.$log.debug('sorting update skip (ssr)')
            return
          } else {
            this.ssrSortingCooldown = Date.now()
          }

          let fetchOptions = {
            name: this.name,
            sort: this.datatable.order()[0],
          }
          if (
            this.ssrColumnOrderingMiddleware &&
            !this.ssrColumnOrderingMiddleware(fetchOptions)
          ) {
            this.$log.debug('Skip column ordering (SSR)')
            return
          } else {
            this.$log.debug('Sorting...')
          }

          this.$log.debug('DataTable.$loader.show', this.name)
          this.$loader && this.$loader.show()
          await this.$store.dispatch('datatable/fetchTableItems', fetchOptions)
          this.$log.debug('DataTable.$loader.hide', this.name)
          this.$loader && this.$loader.hide()
        }

        this.$emit('order', {
          name: this.name,
        })
      })

      this.datatable.on('select.dt', function (e, dt, type, indexes) {
        let payload = {
          e,
          dt,
          type,
          indexes,
          ...(indexes.length >= 1 ? { item: dt.row(indexes[0]).data() } : {}),
        }
        self.$emit('select', payload)
        self.lastSelectedItem = payload.item || null
      })
      this.datatable.on('deselect.dt', function (e, dt, type, indexes) {
        self.$emit('deselect', {
          e,
          dt,
          type,
          indexes,
          item: self.lastSelectedItem || null,
        })
      })

      if (this.defaultSortingColumn) {
        this.datatable
          .column(this.defaultSortingColumn)
          .order(this.defaultSortingColumnDirection || 'asc')
        this.draw()
      }

      setTimeout(() => {
        self.$emit('init', {
          name: this.name,
        })

        if (this.autoHeight) {
          this.setHeightFromParent()
        }
      }, initDelay)

      this.initialized = true
    },
    onDraw() {
      let rendeables =
        (!!this.$refs.root &&
          Array.prototype.slice.call(
            this.$refs.root.querySelectorAll('[data-needs-vue-rendering]'),
            0
          )) ||
        []
      rendeables.forEach((el) => {
        let ComponentClass = Vue.extend(dynamicComponents[el.dataset.component])
        let row = JSON.parse(decodeURI(atob(el.dataset.row || btoa('{}'))))
        let instance = new ComponentClass({
          parent: this,
          propsData: {
            row,
            datatable: () => this.datatable,
            vueDatatable: this,
          },
        })
        instance.$mount()
        let parentNode = el.parentNode
        let index = Array.from(parentNode.children).indexOf(el)
        parentNode.removeChild(el)
        if (index == Array.from(parentNode.children).length - 1) {
          parentNode.appendChild(instance.$el)
        } else {
          parentNode.insertBefore(
            instance.$el,
            Array.from(parentNode.children)[index + 1]
          )
        }
      })
      if (this.autoHeight) {
        this.setHeightFromParent()
      }
    },
    getInnerHTMLFromVueRenderable: function (htmlRenderable) {
      let el = $(htmlRenderable).get(0)
      let ComponentClass = Vue.extend(dynamicComponents[el.dataset.component])
      let row = JSON.parse(decodeURI(atob(el.dataset.row || btoa('{}'))))
      let instance = new ComponentClass({
        parent: this,
        propsData: {
          row,
          datatable: () => this.datatable,
          vueDatatable: this,
        },
      })
      instance.$mount()
      return instance.$el.outerHTML
    },
  },
}
</script>
<style lang="scss">
.datatable th {
  color: #014470 !important;
  font-size: 12px;
}
.datatable td {
  color: #524d4d;
  font-size: 12px;
}
.dataTable__table {
  width: 100% !important;
}
.dataTable_cmp__rows_select {
  margin: 0px 5px;
}
.datatable_toolbar {
  display: grid;
  grid-template-columns: 50% 50%;
}
.datatable_filter {
  justify-self: end;
}
.datatable.selectable tr:hover {
  cursor: pointer !important;
}
.loader-wrapper {
  position: absolute;
  width: 100%;
  top: 45%;
  z-index: 10;
}

.loader-overlay > div:not(.loader-wrapper) {
  opacity: 0.1;
}
</style>