import _ from 'lodash'; import { Vue, Prop, Watch } from 'vue-property-decorator'; import Component, { mixins } from 'vue-class-component'; import LangMixin from '../mixins/LangMixin'; import * as Utils from '../utils/utils'; import HeaderButtons from './HeaderButtons.vue'; import VuexHeader from './Header.vue'; import Paginator from './Paginator.vue'; /** * Main component of the table */ @Component({ name: 'vuex-table', components: { HeaderButtons, VuexHeader, Paginator, }, }) export default class Table extends mixins(LangMixin) { /** * Data */ @Prop({ type: Array, required: true }) readonly data!: any[]; /** * Headers of the table. * * This property defines the column name, the possibility to sort a column or * not, to hide a column or not, ... */ @Prop({ type: Array, required: true }) readonly headers!: any[]; /** * * Default sort direction on column (asc (for ascending) or desc (for descending)) */ @Prop({ type: String, default: 'asc' }) readonly defaultSortDirection!: string; /** * Set of indices of selected columns */ @Prop({ type: Array, required: false, default: () => [] }) readonly selectedColumns!: number[]; /** * Set of indices of selected rows */ @Prop({ type: Array, required: false, default: () => [] }) readonly selectedRows!: number[]; /** * Allow columns to be selectable (default: false) */ @Prop({ type: Boolean, default: false }) readonly selectableColumns!: boolean; /** * Allow rows to be selectable (default: false) */ @Prop({ type: Boolean, default: false }) readonly selectableRows!: boolean; /** * To enable or disable sorting function on column (default: true) */ @Prop({ type: Boolean, default: true }) readonly isSortable!: boolean; /** * To enable or disable details slot (default: false) */ @Prop({ type: Boolean, default: false }) readonly enableDetails!: boolean; /** * Array containing all opened slots */ @Prop({ type: Array, default: () => [] }) readonly details!: number[]; /** * Use backend to handle pagination */ @Prop({ type: Boolean, default: false }) readonly backendPagination!: boolean; /** * Backend sorting (it disables frontend sorting) */ @Prop({ type: Boolean, default: false }) readonly backendSorting!: boolean; /** * Enable or disable frontend pagination */ @Prop({ type: Boolean, default: true }) readonly paginated!: boolean; /** * Number of rows per Page */ @Prop({ type: Number, default: 10 }) readonly itemsPerPage!: number; /** * First page showed (otherwise page 1 is firstly showed by default) */ @Prop({ type: Number, default: 1 }) readonly currentPage!: number; /** * Total of pages. This prop is mandatory if backend pagination is implemented * */ @Prop({ type: [Number, String], default: 0 }) readonly total!: number; /** * Internal state of the components */ state: {[key: string]: any} = { data: _.range(0, this.data.length), currentSortDirection: this.defaultSortDirection, currentSortIndex: -1, openedRows: this.details, headersMap: {}, currentCard: 0, newCurrentPage: this.currentPage, backendData: this.backendPagination ? this.total : this.data.length, }; /** * Compute all indexes of hidden columns * @return Array of indexes for hidden columns */ get hiddenColumns() { return this.headers.reduce((arr, h, i) => { if (h.hideable && !h.visible) { arr.push(i); } return arr; }, []); } /** * Function to show column according to header's name * @returns {Function} */ get showColumn() { return (name: string) => { const header = this.headers[this.state.headersMap[name]]; if (!(header.hideable || false)) { return true; } return header.visible; }; } /** * Function to check if a row (or column) is selected * @return true if the column (or row) is selected (false otherwise) */ get isSelected() { return (idx: number, type: string) => { if (type === 'column') { return this.selectedColumns.find(r => r === idx) !== undefined; } return this.selectedRows.find(r => r === idx) !== undefined; }; } /** * Function to check if everything is selected * @return true if everything is selected */ get isAllSelected() { return this.selectedRows.length === this.data.length || this.selectedColumns.length === this.headers.length; } /** * Computed property to paginate rows according to itemsPerPage */ get paginateData() { if (!this.paginated) { return this.state.data; } if (!this.backendPagination) { const currentPage = this.state.newCurrentPage; const itemsPerPage = this.itemsPerPage; if (this.state.data.length <= itemsPerPage) { return this.state.data; } const start = (currentPage - 1) * itemsPerPage; const end = start + itemsPerPage; return this.state.data.slice(start, end); } } /** * Retrieve header based on name */ get header() { return (name: string) => { return this.headers[this.state.headersMap[name]] || {}; }; } /** * Hook called before the component is mounted; */ beforeMount() { this.constructHeadersMap(); } /** * Sorting function according to header and direction * This functions allows to sort a column in ascending * or descending order by clicking column's header * * @param idx Index of the column to be sorted * @param direction Direction of the sort * * Event sort Sort event with two parameters: * header (object) and currentSortDirection (string) */ sort(idx: number, direction?: string) { if (!this.isSortable) { return; } const header = this.headers.find((h, i) => i === idx); if (direction) { this.state.currentSortDirection = direction; } else { const { currentSortDirection, currentSortIndex } = this.state; if (currentSortIndex === idx) { this.state.currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; } } this.state.currentSortIndex = idx; this.state.currentSortHeader = header; this.$emit('sort', header, this.state.currentSortDirection); this.applySort(header.name, this.state.currentSortDirection); } /** * Sorting algorithm * * @param name * @param direction (asc or desc) */ applySort(name: string, direction: string) { if (this.backendSorting) { return; } this.state.data.sort((a: any, b: any) => { const firstProperty = this.data[a][name]; const secondProperty = this.data[b][name]; const firstPropertyType = typeof firstProperty; const secondPropertyType = typeof secondProperty; if (firstPropertyType !== secondPropertyType) { console.warn(`Different properties type for column ${name}`); return 0; } switch (firstPropertyType) { case 'string': return direction === 'desc' ? firstProperty.localeCompare(secondProperty) : secondProperty.localeCompare(firstProperty); default: return direction === 'desc' ? firstProperty - secondProperty : secondProperty - firstProperty; } }); } /** * Searching function * This function allows to filter data by search input * @param e (event) */ searchData(e: any) { this.state.search = e.target.value.trim(); if (this.state.search === '' && this.isSortable) { this.state.data = _.range(0, this.data.length); this.applySort(this.state.currentSortHeader, this.state.currentSortDirection); return; } const matcher = new RegExp(this.state.search, 'gmi'); const matchFunction = (value: any) => matcher.test(`${Utils.smartTrim(value)}`); const indexes = Utils .filterIndexes(this.data, row => _.map(row, matchFunction).some(val => val)); if (this.isSortable) { const indexesSet = new Set(indexes); this.state.data = this.state.data.filter((idx: number) => indexesSet.has(idx)); } else { this.state.data = indexes; } } /** * Mobile feature: function to next page * * Event next-card Event with two parameters: oldCard (number), newCard (number) */ nextCard() { const oldCard = this.state.currentCard; this.state.currentCard += 1; if (this.state.currentCard >= this.state.data.length) { this.state.currentCard = 0; } this.$emit('next-card', oldCard, this.state.currentCard); } /** * Mobile feature: function to previous page * * Event previous-card Event with two parameters: oldCard (number), newCard (number) */ previousCard() { const oldCard = this.state.currentCard; this.state.currentCard -= 1; if (this.state.currentCard < 0) { this.state.currentCard = this.state.data.length - 1; } this.$emit('previous-card', oldCard, this.state.currentCard); } /** * Function to show and hide details * @param obj * * Event details-open Triggered when a collapsible detail slot is opened. * One parameter: idx (number) * * Event details-close Triggered when a collapsible detail slots is closed. * One parameter: idx (number) * * Event update:details */ toggleDetails(idx: number) { if (!this.enableDetails) { return; } const foundItem = this.state.openedRows.find((i: number) => i === idx); if (foundItem !== undefined) { this.state.openedRows = this.state.openedRows.filter((i: number) => i !== idx); this.$emit('details-close', idx); } else { this.state.openedRows.push(idx); this.$emit('details-open', idx); } this.$emit('update:details', this.state.openedRows); } /** * Select row or column by ID * @param {number} idx: Index of the row * @param {Enum} type: `row` if we want to select a row / `column` otherwise. * * Event selected-row Triggered when a row is selected. 3 parameters: * 1. (Array) Selected rows * 2. (number) Index of newly selected row (-1 since all is selected at once) * 3. (boolean) If it was a deselection this is true, if it was a selection, this is false * * Event selected-column Triggered when a column is selected. Same parameters */ selectRowOrColumn(idx: number, type: string) { const selectable = type === 'column' ? this.selectableColumns : this.selectableRows; const info = type === 'column' ? this.selectedColumns : this.selectedRows; const name = type === 'column' ? 'columns' : 'rows'; const sndName = type === 'column' ? 'selectedColumns' : 'selectedRows'; if (!selectable) { return; } const remove = info.find(r => r === idx) !== undefined; let newItems = []; if (remove) { newItems = info.filter(r => r !== idx); } else { newItems = [...info, idx]; } this.$emit(`selected-${name}`, newItems, idx, remove); this.$emit(`update:${sndName}`, newItems); } /** * Select all rows and columns * Event selected-rows with 3 parameters: * 1. (Array) Selected rows * 2. (number) Index of newly selected row (-1 since all is selected at once) * 3. (boolean) If it was a deselection this is true, if it was a selection, this is false * Event selected-columns with same parameters */ selectAll() { if (!this.selectableRows && !this.selectableColumns) { return; } const remove = this.selectedRows.length === this.data.length || this.selectedColumns.length === this.headers.length; if (remove) { this.$emit('selected-rows', [], -1, remove); this.$emit('selected-columns', [], -1, remove); this.$emit('update:selectedRows', []); this.$emit('update:selectedColumns', []); } else { const rows = _.range(0, this.data.length); const columns = _.range(0, this.headers.length); this.$emit('selected-rows', rows, -1, remove); this.$emit('selected-columns', columns, -1, remove); this.$emit('update:selectedRows', rows); this.$emit('update:selectedColumns', columns); } } /** * Construct a mapping between headers' name and their index. * @param {Object} headers: Headers' objects. */ constructHeadersMap(headers?: any[]) { const myHeaders = headers || this.headers; this.state.headersMap = myHeaders.reduce((obj, header, idx) => { obj[header.name] = idx; return obj; }, {}); } /** * Function to change page * @param page * * Event `page-change`. Triggered when the page changed. * Two parameters: oldPage (number), newPage (number) * * Event update:currentPage */ changePage(page: number) { const oldPage = this.state.newCurrentPage; this.state.newCurrentPage = page > 0 ? page : 1; this.$emit('page-change', oldPage, this.state.newCurrentPage); this.$emit('update:currentPage', this.state.newCurrentPage); } /** * Returns true if the detailed component of the row * at index idx should be displayed * * @param idx The index of the row * @return true if the detailed component should be displayed (false otherwise) */ isVisibleDetailRow(idx: number) { const found = this.state.openedRows.find((i: number) => i === idx); return found !== undefined; } /** * Watcher for headers */ @Watch('headers') onHeadersChanged(newHeaders: any[]) { this.constructHeadersMap(newHeaders); } /** * Watcher on details toggle function * @param expandedRows * @see [[Table.toggleDetails]] */ @Watch('details') onDetailsChanged(expandedRows: number[]) { this.state.openedRows = expandedRows; } /** * Getting current page for @function changePage * @param value * @see [[Table.changePage]] */ @Watch('currentPage') onCurrentPageChanged(value: number) { this.state.newCurrentPage = value; } /** * Function for getting number of total pages from backend * @param newData * @see [[Table.backendPagination]] * */ @Watch('total') onTotalChanged(newData: number) { if (!this.backendPagination) return; this.state.backendData = newData; } }