vuetify
Version:
Vue Material Component Framework
603 lines (544 loc) • 20.7 kB
text/typescript
import './VDataTable.sass'
// Types
import { VNode, VNodeChildrenArrayContents, VNodeChildren } from 'vue'
import { PropValidator } from 'vue/types/options'
import {
DataTableHeader,
DataTableFilterFunction,
DataScopeProps,
DataOptions,
DataPagination,
DataTableCompareFunction,
DataItemsPerPageOption,
ItemGroup,
RowClassFunction,
DataTableItemProps,
} from 'vuetify/types'
// Components
import { VData } from '../VData'
import { VDataFooter, VDataIterator } from '../VDataIterator'
import VBtn from '../VBtn'
import VDataTableHeader from './VDataTableHeader'
// import VVirtualTable from './VVirtualTable'
import VIcon from '../VIcon'
import Row from './Row'
import RowGroup from './RowGroup'
import VSimpleCheckbox from '../VCheckbox/VSimpleCheckbox'
import VSimpleTable from './VSimpleTable'
import MobileRow from './MobileRow'
// Mixins
import Loadable from '../../mixins/loadable'
// Directives
import ripple from '../../directives/ripple'
// Helpers
import mixins from '../../util/mixins'
import { deepEqual, getObjectValueByPath, getPrefixedScopedSlots, getSlot, defaultFilter, camelizeObjectKeys, getPropertyFromItem } from '../../util/helpers'
import { breaking } from '../../util/console'
import { mergeClasses } from '../../util/mergeData'
function filterFn (item: any, search: string | null, filter: DataTableFilterFunction) {
return (header: DataTableHeader) => {
const value = getObjectValueByPath(item, header.value)
return header.filter ? header.filter(value, search, item) : filter(value, search, item)
}
}
function searchTableItems (
items: any[],
search: string | null,
headersWithCustomFilters: DataTableHeader[],
headersWithoutCustomFilters: DataTableHeader[],
customFilter: DataTableFilterFunction
) {
search = typeof search === 'string' ? search.trim() : null
return items.filter(item => {
// Headers with custom filters are evaluated whether or not a search term has been provided.
// We need to match every filter to be included in the results.
const matchesColumnFilters = headersWithCustomFilters.every(filterFn(item, search, defaultFilter))
// Headers without custom filters are only filtered by the `search` property if it is defined.
// We only need a single column to match the search term to be included in the results.
const matchesSearchTerm = !search || headersWithoutCustomFilters.some(filterFn(item, search, customFilter))
return matchesColumnFilters && matchesSearchTerm
})
}
/* @vue/component */
export default mixins(
VDataIterator,
Loadable,
).extend({
name: 'v-data-table',
// https://github.com/vuejs/vue/issues/6872
directives: {
ripple,
},
props: {
headers: {
type: Array,
default: () => [],
} as PropValidator<DataTableHeader[]>,
showSelect: Boolean,
showExpand: Boolean,
showGroupBy: Boolean,
// TODO: Fix
// virtualRows: Boolean,
height: [Number, String],
hideDefaultHeader: Boolean,
caption: String,
dense: Boolean,
headerProps: Object,
calculateWidths: Boolean,
fixedHeader: Boolean,
headersLength: Number,
expandIcon: {
type: String,
default: '$expand',
},
customFilter: {
type: Function,
default: defaultFilter,
} as PropValidator<typeof defaultFilter>,
itemClass: {
type: [String, Function],
default: () => '',
} as PropValidator<RowClassFunction | string>,
loaderHeight: {
type: [Number, String],
default: 4,
},
},
data () {
return {
internalGroupBy: [] as string[],
openCache: {} as { [key: string]: boolean },
widths: [] as number[],
}
},
computed: {
computedHeaders (): DataTableHeader[] {
if (!this.headers) return []
const headers = this.headers.filter(h => h.value === undefined || !this.internalGroupBy.find(v => v === h.value))
const defaultHeader = { text: '', sortable: false, width: '1px' }
if (this.showSelect) {
const index = headers.findIndex(h => h.value === 'data-table-select')
if (index < 0) headers.unshift({ ...defaultHeader, value: 'data-table-select' })
else headers.splice(index, 1, { ...defaultHeader, ...headers[index] })
}
if (this.showExpand) {
const index = headers.findIndex(h => h.value === 'data-table-expand')
if (index < 0) headers.unshift({ ...defaultHeader, value: 'data-table-expand' })
else headers.splice(index, 1, { ...defaultHeader, ...headers[index] })
}
return headers
},
colspanAttrs (): object | undefined {
return this.isMobile ? undefined : {
colspan: this.headersLength || this.computedHeaders.length,
}
},
columnSorters (): Record<string, DataTableCompareFunction> {
return this.computedHeaders.reduce<Record<string, DataTableCompareFunction>>((acc, header) => {
if (header.sort) acc[header.value] = header.sort
return acc
}, {})
},
headersWithCustomFilters (): DataTableHeader[] {
return this.headers.filter(header => header.filter && (!header.hasOwnProperty('filterable') || header.filterable === true))
},
headersWithoutCustomFilters (): DataTableHeader[] {
return this.headers.filter(header => !header.filter && (!header.hasOwnProperty('filterable') || header.filterable === true))
},
sanitizedHeaderProps (): Record<string, any> {
return camelizeObjectKeys(this.headerProps)
},
computedItemsPerPage (): number {
const itemsPerPage = this.options && this.options.itemsPerPage ? this.options.itemsPerPage : this.itemsPerPage
const itemsPerPageOptions: DataItemsPerPageOption[] | undefined = this.sanitizedFooterProps.itemsPerPageOptions
if (
itemsPerPageOptions &&
!itemsPerPageOptions.find(item => typeof item === 'number' ? item === itemsPerPage : item.value === itemsPerPage)
) {
const firstOption = itemsPerPageOptions[0]
return typeof firstOption === 'object' ? firstOption.value : firstOption
}
return itemsPerPage
},
},
created () {
const breakingProps = [
['sort-icon', 'header-props.sort-icon'],
['hide-headers', 'hide-default-header'],
['select-all', 'show-select'],
]
/* istanbul ignore next */
breakingProps.forEach(([original, replacement]) => {
if (this.$attrs.hasOwnProperty(original)) breaking(original, replacement, this)
})
},
mounted () {
// if ((!this.sortBy || !this.sortBy.length) && (!this.options.sortBy || !this.options.sortBy.length)) {
// const firstSortable = this.headers.find(h => !('sortable' in h) || !!h.sortable)
// if (firstSortable) this.updateOptions({ sortBy: [firstSortable.value], sortDesc: [false] })
// }
if (this.calculateWidths) {
window.addEventListener('resize', this.calcWidths)
this.calcWidths()
}
},
beforeDestroy () {
if (this.calculateWidths) {
window.removeEventListener('resize', this.calcWidths)
}
},
methods: {
calcWidths () {
this.widths = Array.from(this.$el.querySelectorAll('th')).map(e => e.clientWidth)
},
customFilterWithColumns (items: any[], search: string) {
return searchTableItems(items, search, this.headersWithCustomFilters, this.headersWithoutCustomFilters, this.customFilter)
},
customSortWithHeaders (items: any[], sortBy: string[], sortDesc: boolean[], locale: string) {
return this.customSort(items, sortBy, sortDesc, locale, this.columnSorters)
},
createItemProps (item: any): DataTableItemProps {
const props = VDataIterator.options.methods.createItemProps.call(this, item)
return Object.assign(props, { headers: this.computedHeaders })
},
genCaption (props: DataScopeProps) {
if (this.caption) return [this.$createElement('caption', [this.caption])]
return getSlot(this, 'caption', props, true)
},
genColgroup (props: DataScopeProps) {
return this.$createElement('colgroup', this.computedHeaders.map(header => {
return this.$createElement('col', {
class: {
divider: header.divider,
},
})
}))
},
genLoading () {
const th = this.$createElement('th', {
staticClass: 'column',
attrs: this.colspanAttrs,
}, [this.genProgress()])
const tr = this.$createElement('tr', {
staticClass: 'v-data-table__progress',
}, [th])
return this.$createElement('thead', [tr])
},
genHeaders (props: DataScopeProps) {
const data = {
props: {
...this.sanitizedHeaderProps,
headers: this.computedHeaders,
options: props.options,
mobile: this.isMobile,
showGroupBy: this.showGroupBy,
someItems: this.someItems,
everyItem: this.everyItem,
singleSelect: this.singleSelect,
disableSort: this.disableSort,
},
on: {
sort: props.sort,
group: props.group,
'toggle-select-all': this.toggleSelectAll,
},
}
const children: VNodeChildrenArrayContents = [getSlot(this, 'header', data)]
if (!this.hideDefaultHeader) {
const scopedSlots = getPrefixedScopedSlots('header.', this.$scopedSlots)
children.push(this.$createElement(VDataTableHeader, {
...data,
scopedSlots,
}))
}
if (this.loading) children.push(this.genLoading())
return children
},
genEmptyWrapper (content: VNodeChildrenArrayContents) {
return this.$createElement('tr', {
staticClass: 'v-data-table__empty-wrapper',
}, [
this.$createElement('td', {
attrs: this.colspanAttrs,
}, content),
])
},
genItems (items: any[], props: DataScopeProps) {
const empty = this.genEmpty(props.originalItemsLength, props.pagination.itemsLength)
if (empty) return [empty]
return props.groupedItems
? this.genGroupedRows(props.groupedItems, props)
: this.genRows(items, props)
},
genGroupedRows (groupedItems: ItemGroup<any>[], props: DataScopeProps) {
return groupedItems.map(group => {
if (!this.openCache.hasOwnProperty(group.name)) this.$set(this.openCache, group.name, true)
if (this.$scopedSlots.group) {
return this.$scopedSlots.group({
group: group.name,
options: props.options,
items: group.items,
headers: this.computedHeaders,
})
} else {
return this.genDefaultGroupedRow(group.name, group.items, props)
}
})
},
genDefaultGroupedRow (group: string, items: any[], props: DataScopeProps) {
const isOpen = !!this.openCache[group]
const children: VNodeChildren = [
this.$createElement('template', { slot: 'row.content' }, this.genRows(items, props)),
]
const toggleFn = () => this.$set(this.openCache, group, !this.openCache[group])
const removeFn = () => props.updateOptions({ groupBy: [], groupDesc: [] })
if (this.$scopedSlots['group.header']) {
children.unshift(this.$createElement('template', { slot: 'column.header' }, [
this.$scopedSlots['group.header']!({ group, groupBy: props.options.groupBy, items, headers: this.computedHeaders, isOpen, toggle: toggleFn, remove: removeFn }),
]))
} else {
const toggle = this.$createElement(VBtn, {
staticClass: 'ma-0',
props: {
icon: true,
small: true,
},
on: {
click: toggleFn,
},
}, [this.$createElement(VIcon, [isOpen ? '$minus' : '$plus'])])
const remove = this.$createElement(VBtn, {
staticClass: 'ma-0',
props: {
icon: true,
small: true,
},
on: {
click: removeFn,
},
}, [this.$createElement(VIcon, ['$close'])])
const column = this.$createElement('td', {
staticClass: 'text-start',
attrs: this.colspanAttrs,
}, [toggle, `${props.options.groupBy[0]}: ${group}`, remove])
children.unshift(this.$createElement('template', { slot: 'column.header' }, [column]))
}
if (this.$scopedSlots['group.summary']) {
children.push(this.$createElement('template', { slot: 'column.summary' }, [
this.$scopedSlots['group.summary']!({ group, groupBy: props.options.groupBy, items, headers: this.computedHeaders, isOpen, toggle: toggleFn }),
]))
}
return this.$createElement(RowGroup, {
key: group,
props: {
value: isOpen,
},
}, children)
},
genRows (items: any[], props: DataScopeProps) {
return this.$scopedSlots.item ? this.genScopedRows(items, props) : this.genDefaultRows(items, props)
},
genScopedRows (items: any[], props: DataScopeProps) {
const rows = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
rows.push(this.$scopedSlots.item!({
...this.createItemProps(item),
index: i,
}))
if (this.isExpanded(item)) {
rows.push(this.$scopedSlots['expanded-item']!({ item, headers: this.computedHeaders }))
}
}
return rows
},
genDefaultRows (items: any[], props: DataScopeProps) {
return this.$scopedSlots['expanded-item']
? items.map(item => this.genDefaultExpandedRow(item))
: items.map(item => this.genDefaultSimpleRow(item))
},
genDefaultExpandedRow (item: any): VNode {
const isExpanded = this.isExpanded(item)
const classes = {
'v-data-table__expanded v-data-table__expanded__row': isExpanded,
}
const headerRow = this.genDefaultSimpleRow(item, classes)
const expandedRow = this.$createElement('tr', {
staticClass: 'v-data-table__expanded v-data-table__expanded__content',
}, [this.$scopedSlots['expanded-item']!({ item, headers: this.computedHeaders })])
return this.$createElement(RowGroup, {
props: {
value: isExpanded,
},
}, [
this.$createElement('template', { slot: 'row.header' }, [headerRow]),
this.$createElement('template', { slot: 'row.content' }, [expandedRow]),
])
},
genDefaultSimpleRow (item: any, classes: Record<string, boolean> = {}): VNode {
const scopedSlots = getPrefixedScopedSlots('item.', this.$scopedSlots)
const data = this.createItemProps(item)
if (this.showSelect) {
const slot = scopedSlots['data-table-select']
scopedSlots['data-table-select'] = slot ? () => slot(data) : () => this.$createElement(VSimpleCheckbox, {
staticClass: 'v-data-table__checkbox',
props: {
value: data.isSelected,
disabled: !this.isSelectable(item),
},
on: {
input: (val: boolean) => data.select(val),
},
})
}
if (this.showExpand) {
const slot = scopedSlots['data-table-expand']
scopedSlots['data-table-expand'] = slot ? () => slot(data) : () => this.$createElement(VIcon, {
staticClass: 'v-data-table__expand-icon',
class: {
'v-data-table__expand-icon--active': data.isExpanded,
},
on: {
click: (e: MouseEvent) => {
e.stopPropagation()
data.expand(!data.isExpanded)
},
},
}, [this.expandIcon])
}
return this.$createElement(this.isMobile ? MobileRow : Row, {
key: getObjectValueByPath(item, this.itemKey),
class: mergeClasses(
{ ...classes, 'v-data-table__selected': data.isSelected },
getPropertyFromItem(item, this.itemClass)
),
props: {
headers: this.computedHeaders,
hideDefaultHeader: this.hideDefaultHeader,
item,
rtl: this.$vuetify.rtl,
},
scopedSlots,
on: {
// TODO: for click, the first argument should be the event, and the second argument should be data,
// but this is a breaking change so it's for v3
click: () => this.$emit('click:row', item, data),
contextmenu: (event: MouseEvent) => this.$emit('contextmenu:row', event, data),
dblclick: (event: MouseEvent) => this.$emit('dblclick:row', event, data),
},
})
},
genBody (props: DataScopeProps): VNode | string | VNodeChildren {
const data = {
...props,
expand: this.expand,
headers: this.computedHeaders,
isExpanded: this.isExpanded,
isMobile: this.isMobile,
isSelected: this.isSelected,
select: this.select,
}
if (this.$scopedSlots.body) {
return this.$scopedSlots.body!(data)
}
return this.$createElement('tbody', [
getSlot(this, 'body.prepend', data, true),
this.genItems(props.items, props),
getSlot(this, 'body.append', data, true),
])
},
genFooters (props: DataScopeProps) {
const data = {
props: {
options: props.options,
pagination: props.pagination,
itemsPerPageText: '$vuetify.dataTable.itemsPerPageText',
...this.sanitizedFooterProps,
},
on: {
'update:options': (value: any) => props.updateOptions(value),
},
widths: this.widths,
headers: this.computedHeaders,
}
const children: VNodeChildren = [
getSlot(this, 'footer', data, true),
]
if (!this.hideDefaultFooter) {
children.push(this.$createElement(VDataFooter, {
...data,
scopedSlots: getPrefixedScopedSlots('footer.', this.$scopedSlots),
}))
}
return children
},
genDefaultScopedSlot (props: DataScopeProps): VNode {
const simpleProps = {
height: this.height,
fixedHeader: this.fixedHeader,
dense: this.dense,
}
// if (this.virtualRows) {
// return this.$createElement(VVirtualTable, {
// props: Object.assign(simpleProps, {
// items: props.items,
// height: this.height,
// rowHeight: this.dense ? 24 : 48,
// headerHeight: this.dense ? 32 : 48,
// // TODO: expose rest of props from virtual table?
// }),
// scopedSlots: {
// items: ({ items }) => this.genItems(items, props) as any,
// },
// }, [
// this.proxySlot('body.before', [this.genCaption(props), this.genHeaders(props)]),
// this.proxySlot('bottom', this.genFooters(props)),
// ])
// }
return this.$createElement(VSimpleTable, {
props: simpleProps,
}, [
this.proxySlot('top', getSlot(this, 'top', props, true)),
this.genCaption(props),
this.genColgroup(props),
this.genHeaders(props),
this.genBody(props),
this.proxySlot('bottom', this.genFooters(props)),
])
},
proxySlot (slot: string, content: VNodeChildren) {
return this.$createElement('template', { slot }, content)
},
},
render (): VNode {
return this.$createElement(VData, {
props: {
...this.$props,
customFilter: this.customFilterWithColumns,
customSort: this.customSortWithHeaders,
itemsPerPage: this.computedItemsPerPage,
},
on: {
'update:options': (v: DataOptions, old: DataOptions) => {
this.internalGroupBy = v.groupBy || []
!deepEqual(v, old) && this.$emit('update:options', v)
},
'update:page': (v: number) => this.$emit('update:page', v),
'update:items-per-page': (v: number) => this.$emit('update:items-per-page', v),
'update:sort-by': (v: string | string[]) => this.$emit('update:sort-by', v),
'update:sort-desc': (v: boolean | boolean[]) => this.$emit('update:sort-desc', v),
'update:group-by': (v: string | string[]) => this.$emit('update:group-by', v),
'update:group-desc': (v: boolean | boolean[]) => this.$emit('update:group-desc', v),
pagination: (v: DataPagination, old: DataPagination) => !deepEqual(v, old) && this.$emit('pagination', v),
'current-items': (v: any[]) => {
this.internalCurrentItems = v
this.$emit('current-items', v)
},
'page-count': (v: number) => this.$emit('page-count', v),
},
scopedSlots: {
default: this.genDefaultScopedSlot as any,
},
})
},
})