` element with class
-`b-table-busy-slot`, which has one single `` with a `colspan` set to the number of fields.
-
-**Example of `table-busy` slot usage:**
-
-```html
-
-
-
Toggle Busy State
-
-
-
-
- Loading...
-
-
-
-
-
-
-
-
-```
-
-Also see the [**Using Items Provider Functions**](#using-items-provider-functions) below for
-additional information on the `busy` state.
-
-**Note:** All click related and hover events, and sort-changed events will **not** be emitted when
-the table is in the `busy` state.
-
## Custom Data Rendering
Custom rendering for each data field in a row is possible using either
@@ -915,8 +915,14 @@ scoped field slot
```
-**Warning:** Be cautious of using this to display user supplied content, **as script tags could be
-injected into your page!**
+
+ Warning: Be cautious of using the v-html
method to display user
+ supplied content, as it may make your application vulnerable to
+
+ XSS attacks , if you do not first
+ sanitize the
+ user supplied string.
+
### Formatter callback
@@ -1309,10 +1315,20 @@ pre-specify the column to be sorted, set the `sort-by` prop to the field's key.
direction by setting `sort-desc` to either `true` (for descending) or `false` (for ascending, the
default).
+- **Ascending**: Items are sorted lowest to highest (i.e. `A` to `Z`) and will be displayed with the
+ lowest value in the first row with progressively higher values in the following rows. The header
+ indicator arrow will point in the direction of lowest to highest. (i.e. down for ascending).
+- **Descending**: Items are sorted highest to lowest (i.e. `Z` to `A`) and will be displayed with
+ the highest value in the first row with progressively lower values in the following rows. The
+ header indicator arrow will point in the direction of lowest to highest (i.e. up for descending).
+
The props `sort-by` and `sort-desc` can be turned into _two-way_ (syncable) props by adding the
`.sync` modifier. Your bound variables will then be updated accordingly based on the current sort
criteria. See the [Vue docs](http://vuejs.org/v2/guide/components.html#sync-Modifier) for details on
-the `.sync` prop modifier
+the `.sync` prop modifier.
+
+Setting `sort-by` to a column that is not defined in the fields as `sortable` will result in the
+table not being sorted.
When the prop `foot-clone` is set, the footer headings will also allow sorting by clicking, even if
you have custom formatted footer field headers. To disable the sort icons and sorting via heading
@@ -1427,16 +1443,19 @@ with a single argument containing the context object of ``. See the
[Detection of sorting change](#detection-of-sorting-change) section below for details about the
sort-changed event and the context object.
-### Change sort direction
+### Change initial sort direction
Control the order in which ascending and descending sorting is applied when a sortable column header
is clicked, by using the `sort-direction` prop. The default value `'asc'` applies ascending sort
-first. To reverse the behavior and sort in descending direction first, set it to `'desc'`.
+first (when a column is not currently sorted). To reverse the behavior and sort in descending
+direction first, set it to `'desc'`.
-If you don't want the sorting direction to change at all when clicking another sortable column
-header, set `sort-direction` to `'last'`.
+If you don't want the current sorting direction to change when clicking another sortable column
+header, set `sort-direction` to `'last'`. This will maintain the sorting direction of the previously
+sorted column.
-For individual column sort directions, specify the property `sortDirection` in `fields`. See the
+For individual column initial sort direction (which applies when the column transitions from unsorted
+to sorted), specify the property `sortDirection` in `fields`. See the
[Complete Example](#complete-example) below for an example of using this feature.
## Filtering
diff --git a/src/components/table/fixtures/table.html b/src/components/table/fixtures/table.html
deleted file mode 100644
index e81446a62eb..00000000000
--- a/src/components/table/fixtures/table.html
+++ /dev/null
@@ -1,148 +0,0 @@
-
-
-
Basic table
-
-
- {{ data.value.first }} {{ data.value.last }}
-
-
- {{ data.value ? 'Yes' : 'No' }}
-
-
- Details
-
-
-
-
-
- {{ data.value.first }} {{ data.value.last }}
-
-
-
-
-
-
-
-
-
-
- Table Caption
-
-
-
-
-
-
-
Paginated Table
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ data.value.first }} {{ data.value.last }}
-
-
- Showing {{ visibleRecords.length }} People
-
-
- {{ data.value ? 'Active' : 'Inactive' }}
-
-
- Select
-
-
-
-
-
- Selected: {{ selectedRecords.length }}
-
-
-
-
Dark Table
-
-
-
- {{ data.value.first }} {{ data.value.last }}
-
-
- {{ data.value ? 'Active' : 'Inactive' }}
-
-
- Details
-
-
-
-
Provider Test Table
-
-
-
-
-
-
-
- {{ data.value.first }} {{ data.value.last }}
-
-
- {{ data.value ? 'Active' : 'Inactive' }}
-
-
- Details
-
-
-
-
-
diff --git a/src/components/table/fixtures/table.js b/src/components/table/fixtures/table.js
deleted file mode 100644
index 15a3a3c3207..00000000000
--- a/src/components/table/fixtures/table.js
+++ /dev/null
@@ -1,175 +0,0 @@
-window.app = new Vue({
- el: '#app',
- data: {
- fields: {
- name: {
- label: 'Person Full name',
- sortable: true,
- tdClass: 'bg-primary',
- tdAttr: { title: 'Person Full name' }
- },
- age: {
- label: 'Person age',
- sortable: true,
- formatter: 'formatAge',
- tdClass: ['bg-primary', 'text-dark']
- },
- isActive: {
- label: 'is Active',
- tdClass: (value, key, item) => {
- return 'bg-danger'
- },
- tdAttr: (value, key, item) => {
- return { title: 'is Active' }
- }
- },
- actions: {
- label: 'Actions',
- tdClass: 'formatCell',
- tdAttr: 'formatCellAttrs'
- }
- },
- currentPage: 1,
- perPage: 5,
- filter: null,
- selectedRecords: [],
- visibleRecords: [],
- isBusy: false,
- providerType: 'array',
- secondaryItems: [
- {
- isActive: false,
- age: 26,
- _rowVariant: 'success',
- name: 'Mitzi'
- }
- ],
- items: [
- {
- isActive: true,
- age: 40,
- name: { first: 'Dickerson', last: 'Macdonald' }
- },
- {
- isActive: false,
- age: 21,
- name: { first: 'Larsen', last: 'Shaw' }
- },
- {
- isActive: false,
- age: 26,
- _rowVariant: 'success',
- name: { first: 'Mitzi', last: 'Navarro' }
- },
- {
- isActive: false,
- age: 22,
- name: { first: 'Geneva', last: 'Wilson' }
- },
- {
- isActive: true,
- age: 38,
- name: { first: 'Jami', last: 'Carney' }
- },
- {
- isActive: false,
- age: 27,
- name: { first: 'Essie', last: 'Dunlap' }
- },
- {
- isActive: true,
- age: 65,
- name: { first: 'Alfred', last: 'Macdonald' }
- },
- {
- isActive: false,
- age: 21,
- name: { first: 'Lauren', last: 'Shaw' }
- },
- {
- isActive: false,
- age: 29,
- name: { first: 'Mini', last: 'Navarro' }
- },
- {
- isActive: false,
- age: 22,
- name: { first: 'Frank', last: 'Wilson' }
- },
- {
- isActive: true,
- age: 38,
- name: { first: 'Jami-Lee', last: 'Curtis' }
- },
- {
- isActive: false,
- age: 72,
- name: { first: 'Elsie', last: 'Dunlap' }
- }
- ]
- },
- computed: {
- provider() {
- // we are using provider wrappers here to trigger a reload
- switch (this.providerType) {
- case 'promise':
- return this._promiseProvider
- case 'callback':
- return this._callbackProvider
- default:
- return this._arrayProvider
- }
- }
- },
- methods: {
- details(item) {
- /* eslint-disable no-alert */
- alert(JSON.stringify(item))
- },
- _arrayProvider(ctx) {
- return this._provider(ctx)
- },
- _callbackProvider(ctx, cb) {
- return this._provider(ctx, cb)
- },
- _promiseProvider(ctx) {
- return this._provider(ctx)
- },
- _provider(ctx, cb) {
- const items = this.items.slice()
-
- switch (this.providerType) {
- case 'callback':
- setTimeout(() => {
- cb(items)
- }, 1)
- return
- case 'promise':
- const p = new Promise(resolve => setTimeout(resolve, 1))
- return p.then(() => {
- return items
- })
- default:
- return items
- }
- },
- formatAge(value) {
- return `${value} years old`
- },
- formatCell(value, key, item) {
- return ['bg-primary', 'text-light']
- },
- formatCellAttrs(value, key, item) {
- return { title: 'Actions' }
- },
- styleRow(item) {
- if (!item) {
- return
- }
- return {
- 'tr-start-with-l': item.name.first.charAt(0) === 'L',
- 'tr-last-name-macdonald': item.name.last === 'Macdonald'
- }
- }
- }
-})
diff --git a/src/components/table/helpers/mixin-empty.js b/src/components/table/helpers/mixin-empty.js
index 10bcdacb076..dcb396ba006 100644
--- a/src/components/table/helpers/mixin-empty.js
+++ b/src/components/table/helpers/mixin-empty.js
@@ -54,7 +54,7 @@ export default {
{
attrs: {
colspan: String(this.computedFields.length),
- role: this.isStacked ? 'cell' : null
+ role: 'cell'
}
},
[h('div', { attrs: { role: 'alert', 'aria-live': 'polite' } }, [$empty])]
@@ -69,7 +69,7 @@ export default {
? this.tbodyTrClass(null, 'row-empty')
: this.tbodyTrClass
],
- attrs: this.isStacked ? { role: 'row' } : {}
+ attrs: { role: 'row' }
},
[$empty]
)
diff --git a/src/components/table/helpers/mixin-filtering.js b/src/components/table/helpers/mixin-filtering.js
new file mode 100644
index 00000000000..03c8b67a8ed
--- /dev/null
+++ b/src/components/table/helpers/mixin-filtering.js
@@ -0,0 +1,200 @@
+import stringifyRecordValues from './stringify-record-values'
+import looseEqual from '../../../utils/loose-equal'
+import warn from '../../../utils/warn'
+
+export default {
+ props: {
+ filter: {
+ // Pasing a function to filter is deprecated and should be avoided
+ type: [String, RegExp, Object, Array, Function],
+ default: null
+ },
+ filterFunction: {
+ type: Function,
+ default: null
+ }
+ },
+ data() {
+ return {
+ // Flag for displaying which empty slot to show, and for some event triggering.
+ isFiltered: false
+ }
+ },
+ computed: {
+ localFiltering() {
+ return this.hasProvider ? !!this.noProviderFiltering : true
+ },
+ filteredCheck() {
+ // For watching changes to filteredItems vs localItems
+ return {
+ filteredItems: this.filteredItems,
+ localItems: this.localItems,
+ localFilter: this.localFilter
+ }
+ },
+ localFilter() {
+ // Returns a sanitized/normalized version of filter prop
+ if (typeof this.filter === 'function') {
+ // this.localFilterFn will contain the correct function ref.
+ // Deprecate setting prop filter to a function
+ /* istanbul ignore next */
+ return ''
+ } else if (
+ typeof this.filterFunction !== 'function' &&
+ !(typeof this.filter === 'string' || this.filter instanceof RegExp)
+ ) {
+ // Using internal filter function, which only accepts string or regexp at the moment
+ return ''
+ } else {
+ // Could be a string, object or array, as needed by external filter function
+ return this.filter
+ }
+ },
+ localFilterFn() {
+ let filter = this.filter
+ let filterFn = this.filterFunction
+ // Sanitized/normalize filter-function prop
+ if (typeof filterFn === 'function') {
+ return filterFn
+ } else if (typeof filter === 'function') {
+ // Deprecate setting prop filter to a function
+ /* istanbul ignore next */
+ warn(
+ 'b-table: Supplying a function to prop "filter" is deprecated. Use "filterFn" instead.'
+ )
+ /* istanbul ignore next */
+ return filter
+ } else {
+ // no filterFunction, so signal to use internal filter function
+ return null
+ }
+ },
+ filteredItems() {
+ // Returns the records in localItems that match the filter criteria.
+ // Returns the original localItems array if not sorting
+ let items = this.localItems || []
+ const criteria = this.localFilter
+ const filterFn =
+ this.filterFnFactory(this.localFilterFn, criteria) || this.defaultFilterFnFactory(criteria)
+
+ // We only do local filtering if requested, and if the are records to filter and
+ // if a filter criteria was specified
+ if (this.localFiltering && filterFn && items.length > 0) {
+ items = items.filter(filterFn)
+ }
+ return items
+ }
+ },
+ watch: {
+ // Watch for changes to the filter criteria and filtered items vs localItems).
+ // And set visual state and emit events as required
+ filteredCheck({ filteredItems, localItems, localFilter }) {
+ // Determine if the dataset is filtered or not
+ let isFiltered
+ if (!localFilter) {
+ // If filter criteria is falsey
+ isFiltered = false
+ } else if (looseEqual(localFilter, []) || looseEqual(localFilter, {})) {
+ // If filter criteria is an empty array or object
+ isFiltered = false
+ } else if (localFilter) {
+ // if Filter criteria is truthy
+ isFiltered = true
+ } else {
+ /* istanbul ignore next: rare chance of reaching this else */
+ isFiltered = false
+ }
+ if (isFiltered) {
+ this.$emit('filtered', filteredItems, filteredItems.length)
+ }
+ this.isFiltered = isFiltered
+ },
+ isFiltered(newVal, oldVal) {
+ if (newVal === false && oldVal === true) {
+ // We need to emit a filtered event if isFiltered transitions from true to
+ // false so that users can update their pagination controls.
+ this.$emit('filtered', this.localItems, this.localItems.length)
+ }
+ }
+ },
+ created() {
+ // Set the initial filtered state.
+ // In a nextTick so that we trigger a filtered event if needed
+ this.$nextTick(() => {
+ this.isFiltered = Boolean(this.localFilter)
+ })
+ },
+ methods: {
+ // Filter Function factories
+ filterFnFactory(filterFn, criteria) {
+ // Wrapper factory for external filter functions.
+ // Wrap the provided filter-function and return a new function.
+ // Returns null if no filter-function defined or if criteria is falsey.
+ // Rather than directly grabbing this.computedLocalFilterFn or this.filterFunction
+ // we have it passed, so that the caller computed prop will be reactive to changes
+ // in the original filter-function (as this routine is a method)
+ if (
+ !filterFn ||
+ typeof filterFn !== 'function' ||
+ !criteria ||
+ looseEqual(criteria, []) ||
+ looseEqual(criteria, {})
+ ) {
+ return null
+ }
+
+ // Build the wrapped filter test function, passing the criteria to the provided function
+ const fn = item => {
+ // Generated function returns true if the criteria matches part
+ // of the serialized data, otherwise false
+ return filterFn(item, criteria)
+ }
+
+ // Return the wrapped function
+ return fn
+ },
+ defaultFilterFnFactory(criteria) {
+ // Generates the default filter function, using the given filter criteria
+ if (!criteria || !(typeof criteria === 'string' || criteria instanceof RegExp)) {
+ // Built in filter can only support strings or RegExp criteria (at the moment)
+ return null
+ }
+
+ // Build the regexp needed for filtering
+ let regexp = criteria
+ if (typeof regexp === 'string') {
+ // Escape special RegExp characters in the string and convert contiguous
+ // whitespace to \s+ matches
+ const pattern = criteria
+ .replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
+ .replace(/[\s\uFEFF\xA0]+/g, '\\s+')
+ // Build the RegExp (no need for global flag, as we only need
+ // to find the value once in the string)
+ regexp = new RegExp(`.*${pattern}.*`, 'i')
+ }
+
+ // Generate the wrapped filter test function to use
+ const fn = item => {
+ // This searches all row values (and sub property values) in the entire (excluding
+ // special _ prefixed keys), because we convert the record to a space-separated
+ // string containing all the value properties (recursively), even ones that are
+ // not visible (not specified in this.fields).
+ //
+ // TODO: Enable searching on formatted fields and scoped slots
+ // TODO: Should we filter only on visible fields (i.e. ones in this.fields) by default?
+ // TODO: Allow for searching on specific fields/key, this could be combined with the previous TODO
+ // TODO: Give stringifyRecordValues extra options for filtering (i.e. passing the
+ // fields definition and a reference to $scopedSlots)
+ //
+ // Generated function returns true if the criteria matches part of
+ // the serialized data, otherwise false
+ // We set lastIndex = 0 on regex in case someone uses the /g global flag
+ regexp.lastIndex = 0
+ return regexp.test(stringifyRecordValues(item))
+ }
+
+ // Return the generated function
+ return fn
+ }
+ }
+}
diff --git a/src/components/table/helpers/mixin-items.js b/src/components/table/helpers/mixin-items.js
new file mode 100644
index 00000000000..c919933c5da
--- /dev/null
+++ b/src/components/table/helpers/mixin-items.js
@@ -0,0 +1,57 @@
+import normalizeFields from './normalize-fields'
+import { isArray } from '../../../utils/array'
+
+export default {
+ props: {
+ items: {
+ type: [Array, Function],
+ default() /* istanbul ignore next */ {
+ return []
+ }
+ },
+ fields: {
+ // Object format is deprecated and should be avoided
+ type: [Array, Object],
+ default: null
+ },
+ primaryKey: {
+ // Primary key for record.
+ // If provided the value in each row must be unique!!!
+ type: String,
+ default: null
+ }
+ },
+ data() {
+ return {
+ // Our local copy of the items. Must be an array
+ localItems: isArray(this.items) ? this.items.slice() : []
+ }
+ },
+ computed: {
+ computedFields() {
+ // We normalize fields into an array of objects
+ // [ { key:..., label:..., ...}, {...}, ..., {..}]
+ return normalizeFields(this.fields, this.localItems)
+ },
+ computedFieldsObj() /* istanbul ignore next: not using at the moment */ {
+ // Fields as a simple lookup hash object
+ // Mainly for scopedSlots for convenience
+ return this.computedFields.reduce((f, obj) => {
+ obj[f.key] = f
+ return obj
+ }, {})
+ }
+ },
+ watch: {
+ items(newItems) {
+ /* istanbul ignore else */
+ if (isArray(newItems)) {
+ // Set localItems/filteredItems to a copy of the provided array
+ this.localItems = newItems.slice()
+ } else if (newItems === null || newItems === undefined) {
+ /* istanbul ignore next */
+ this.localItems = []
+ }
+ }
+ }
+}
diff --git a/src/components/table/helpers/mixin-pagination.js b/src/components/table/helpers/mixin-pagination.js
new file mode 100644
index 00000000000..cb668244848
--- /dev/null
+++ b/src/components/table/helpers/mixin-pagination.js
@@ -0,0 +1,29 @@
+export default {
+ props: {
+ perPage: {
+ type: [Number, String],
+ default: 0
+ },
+ currentPage: {
+ type: [Number, String],
+ default: 1
+ }
+ },
+ computed: {
+ localPaging() {
+ return this.hasProvider ? !!this.noProviderPaging : true
+ },
+ paginatedItems() {
+ let items = this.sortedItems || []
+ const currentPage = Math.max(parseInt(this.currentPage, 10) || 1, 1)
+ const perPage = Math.max(parseInt(this.perPage, 10) || 0, 0)
+ // Apply local pagination
+ if (this.localPaging && !!perPage) {
+ // Grab the current page of data (which may be past filtered items limit)
+ items = items.slice((currentPage - 1) * perPage, currentPage * perPage)
+ }
+ // Return the items to display in the table
+ return items
+ }
+ }
+}
diff --git a/src/components/table/helpers/mixin-provider.js b/src/components/table/helpers/mixin-provider.js
index ce564d10a9a..8df30811883 100644
--- a/src/components/table/helpers/mixin-provider.js
+++ b/src/components/table/helpers/mixin-provider.js
@@ -54,6 +54,12 @@ export default {
},
watch: {
// Provider update triggering
+ items(newVal, oldVal) {
+ // If a new provider has been specified, trigger an update
+ if (this.hasProvider || newVal instanceof Function) {
+ this.$nextTick(this._providerUpdate)
+ }
+ },
providerTriggerContext(newVal, oldVal) {
// Trigger the provider to update as the relevant context values have changed.
if (!looseEqual(newVal, oldVal)) {
diff --git a/src/components/table/helpers/mixin-sorting.js b/src/components/table/helpers/mixin-sorting.js
new file mode 100644
index 00000000000..06b617e9707
--- /dev/null
+++ b/src/components/table/helpers/mixin-sorting.js
@@ -0,0 +1,252 @@
+import stableSort from '../../../utils/stable-sort'
+import startCase from '../../../utils/startcase'
+import { arrayIncludes } from '../../../utils/array'
+import defaultSortCompare from './default-sort-compare'
+
+export default {
+ props: {
+ sortBy: {
+ type: String,
+ default: null
+ },
+ sortDesc: {
+ // To Do: Make this tri-state: true, false, null
+ type: Boolean,
+ default: false
+ },
+ sortDirection: {
+ // This prop is named incorrectly.
+ // It should be initialSortDirection
+ // As it is a bit misleading (not to mention screws up
+ // the Aria Label on the headers)
+ type: String,
+ default: 'asc',
+ validator: direction => arrayIncludes(['asc', 'desc', 'last'], direction)
+ },
+ sortCompare: {
+ type: Function,
+ default: null
+ },
+ noSortReset: {
+ // Another prop that should have had a better name.
+ // It should be noSortClear (on non-sortable headers).
+ // We will need to make sure the documentation is clear on what
+ // this prop does (as well as in the code for future reference)
+ type: Boolean,
+ default: false
+ },
+ labelSortAsc: {
+ type: String,
+ default: 'Click to sort Ascending'
+ },
+ labelSortDesc: {
+ type: String,
+ default: 'Click to sort Descending'
+ },
+ labelSortClear: {
+ type: String,
+ default: 'Click to clear sorting'
+ },
+ noLocalSorting: {
+ type: Boolean,
+ default: false
+ },
+ noFooterSorting: {
+ type: Boolean,
+ default: false
+ }
+ },
+ data() {
+ return {
+ localSortBy: this.sortBy || '',
+ localSortDesc: this.sortDesc || false
+ }
+ },
+ computed: {
+ localSorting() {
+ return this.hasProvider ? !!this.noProviderSorting : !this.noLocalSorting
+ },
+ isSortable() {
+ return this.computedFields.some(f => f.sortable)
+ },
+ sortedItems() {
+ // Sorts the filtered items and returns a new array of the sorted items
+ // or the original items array if not sorted.
+ let items = (this.filteredItems || []).slice()
+ const sortBy = this.localSortBy
+ const sortDesc = this.localSortDesc
+ const sortCompare = this.sortCompare
+ const localSorting = this.localSorting
+ if (sortBy && localSorting) {
+ // stableSort returns a new array, and leaves the original array intact
+ return stableSort(items, (a, b) => {
+ let result = null
+ if (typeof sortCompare === 'function') {
+ // Call user provided sortCompare routine
+ result = sortCompare(a, b, sortBy, sortDesc)
+ }
+ if (result === null || result === undefined || result === false) {
+ // Fallback to built-in defaultSortCompare if sortCompare
+ // is not defined or returns null/false
+ result = defaultSortCompare(a, b, sortBy)
+ }
+ // Negate result if sorting in descending order
+ return (result || 0) * (sortDesc ? -1 : 1)
+ })
+ }
+ return items
+ }
+ },
+ watch: {
+ isSortable(newVal, oldVal) /* istanbul ignore next: pain in the butt to test */ {
+ if (newVal) {
+ if (this.isSortable) {
+ this.$on('head-clicked', this.handleSort)
+ }
+ } else {
+ this.$off('head-clicked', this.handleSort)
+ }
+ },
+ sortDesc(newVal, oldVal) {
+ if (newVal === this.localSortDesc) {
+ /* istanbul ignore next */
+ return
+ }
+ this.localSortDesc = newVal || false
+ },
+ sortBy(newVal, oldVal) {
+ if (newVal === this.localSortBy) {
+ /* istanbul ignore next */
+ return
+ }
+ this.localSortBy = newVal || null
+ },
+ // Update .sync props
+ localSortDesc(newVal, oldVal) {
+ // Emit update to sort-desc.sync
+ if (newVal !== oldVal) {
+ this.$emit('update:sortDesc', newVal)
+ }
+ },
+ localSortBy(newVal, oldVal) {
+ if (newVal !== oldVal) {
+ this.$emit('update:sortBy', newVal)
+ }
+ }
+ },
+ created() {
+ if (this.isSortable) {
+ this.$on('head-clicked', this.handleSort)
+ }
+ },
+ methods: {
+ // Handlers
+ // Need to move from thead-mixin
+ handleSort(key, field, evt, isFoot) {
+ if (!this.isSortable) {
+ /* istanbul ignore next */
+ return
+ }
+ if (isFoot && this.noFooterSorting) {
+ return
+ }
+ // TODO: make this tri-state sorting
+ // cycle desc => asc => none => desc => ...
+ let sortChanged = false
+ const toggleLocalSortDesc = () => {
+ const sortDirection = field.sortDirection || this.sortDirection
+ if (sortDirection === 'asc') {
+ this.localSortDesc = false
+ } else if (sortDirection === 'desc') {
+ this.localSortDesc = true
+ } else {
+ // sortDirection === 'last'
+ // Leave at last sort direction from previous column
+ }
+ }
+ if (field.sortable) {
+ if (key === this.localSortBy) {
+ // Change sorting direction on current column
+ this.localSortDesc = !this.localSortDesc
+ } else {
+ // Start sorting this column ascending
+ this.localSortBy = key
+ // this.localSortDesc = false
+ toggleLocalSortDesc()
+ }
+ sortChanged = true
+ } else if (this.localSortBy && !this.noSortReset) {
+ this.localSortBy = null
+ toggleLocalSortDesc()
+ sortChanged = true
+ }
+ if (sortChanged) {
+ // Sorting parameters changed
+ this.$emit('sort-changed', this.context)
+ }
+ },
+ // methods to compute classes and attrs for thead>th cells
+ sortTheadThClasses(key, field, isFoot) {
+ return {
+ // No Classes for sorting currently...
+ // All styles targeted using aria-* attrs
+ }
+ },
+ sortTheadThAttrs(key, field, isFoot) {
+ if (!this.isSortable || (isFoot && this.noFooterSorting)) {
+ // No atributes if not a sortable table
+ return {}
+ }
+ const sortable = field.sortable
+ let ariaLabel = ''
+ if ((!field.label || !field.label.trim()) && !field.headerTitle) {
+ // In case field's label and title are empty/blank, we need to
+ // add a hint about what the column is about for non-sighted users.
+ // This is dulicated code from tbody-row mixin, but we need it
+ // here as well, since we overwrite the original aria-label.
+ /* istanbul ignore next */
+ ariaLabel = startCase(key)
+ }
+ // The correctness of these labels is very important for screen-reader users.
+ let ariaLabelSorting = ''
+ if (sortable) {
+ if (this.localSortBy === key) {
+ // currently sorted sortable column.
+ ariaLabelSorting = this.localSortDesc ? this.labelSortAsc : this.labelSortDesc
+ } else {
+ // Not currently sorted sortable column.
+ // Not using nested ternary's here for clarity/readability
+ // Default for ariaLabel
+ ariaLabelSorting = this.localSortDesc ? this.labelSortDesc : this.labelSortAsc
+ // Handle sortDirection setting
+ const sortDirection = this.sortDirection || field.sortDirection
+ if (sortDirection === 'asc') {
+ ariaLabelSorting = this.labelSortAsc
+ } else if (sortDirection === 'desc') {
+ ariaLabelSorting = this.labelSortDesc
+ }
+ }
+ } else if (!this.noSortReset) {
+ // Non sortable column
+ ariaLabelSorting = this.localSortBy ? this.labelSortClear : ''
+ }
+ // Assemble the aria-label attribute value
+ ariaLabel = [ariaLabel.trim(), ariaLabelSorting.trim()].filter(Boolean).join(': ')
+ // Assemble the aria-sort attribute value
+ const ariaSort =
+ sortable && this.localSortBy === key
+ ? this.localSortDesc
+ ? 'descending'
+ : 'ascending'
+ : sortable
+ ? 'none'
+ : null
+ // Return the attributes
+ // (All the above just to get these two values)
+ return {
+ 'aria-label': ariaLabel || null,
+ 'aria-sort': ariaSort
+ }
+ }
+ }
+}
diff --git a/src/components/table/helpers/mixin-tbody-row.js b/src/components/table/helpers/mixin-tbody-row.js
index 08db431058d..4be4107feb9 100644
--- a/src/components/table/helpers/mixin-tbody-row.js
+++ b/src/components/table/helpers/mixin-tbody-row.js
@@ -149,6 +149,7 @@ export default {
return
} else if (filterEvent(e)) {
// clicked on a non-disabled control so ignore
+ /* istanbul ignore next: event filtering already tested via click handler */
return
}
this.$emit('row-dblclicked', item, index, e)
@@ -194,39 +195,23 @@ export default {
this.$set(item, '_showDetails', !item._showDetails)
}
}
- let $childNodes
-
- if ($scoped[field.key]) {
- // Has scoped field slot
- $childNodes = [
- $scoped[field.key]({
- item: item,
- index: rowIndex,
- field: field,
- unformatted: get(item, field.key, ''),
- value: formatted,
- toggleDetails: toggleDetailsFn,
- detailsShowing: Boolean(item._showDetails),
- rowSelected: Boolean(rowSelected)
- })
- ]
- if (this.isStacked) {
- // We wrap in a DIV to ensure rendered as a single cell when visually stacked!
- $childNodes = [h('div', {}, [$childNodes])]
- }
- } else {
- // No scoped field slot
- if (this.isStacked) {
- // We wrap in a DIV to ensure rendered as a single cell when visually stacked!
- $childNodes = [h('div', toString(formatted))]
- } else {
- // Non stacked
- $childNodes = toString(formatted)
- }
+ const slotScope = {
+ item: item,
+ index: rowIndex,
+ field: field,
+ unformatted: get(item, field.key, ''),
+ value: formatted,
+ toggleDetails: toggleDetailsFn,
+ detailsShowing: Boolean(item._showDetails),
+ rowSelected: Boolean(rowSelected)
+ }
+ let $childNodes = $scoped[field.key] ? $scoped[field.key](slotScope) : toString(formatted)
+ if (this.isStacked) {
+ // We wrap in a DIV to ensure rendered as a single cell when visually stacked!
+ $childNodes = [h('div', {}, [$childNodes])]
}
-
// Render either a td or th cell
- return h(field.isRowHeader ? 'th' : 'td', data, $childNodes)
+ return h(field.isRowHeader ? 'th' : 'td', data, [$childNodes])
},
renderTbodyRow(item, rowIndex) {
// Renders an item's row (or rows if details supported)
diff --git a/src/components/table/helpers/mixin-thead.js b/src/components/table/helpers/mixin-thead.js
index c00ade5cb0e..b342252d77d 100644
--- a/src/components/table/helpers/mixin-thead.js
+++ b/src/components/table/helpers/mixin-thead.js
@@ -33,11 +33,11 @@ export default {
field.thClass ? field.thClass : ''
]
},
- headClicked(e, field, isFoot) {
- if (this.stopIfBusy(e)) {
+ headClicked(evt, field, isFoot) {
+ if (this.stopIfBusy(evt)) {
// If table is busy (via provider) then don't propagate
return
- } else if (filterEvent(e)) {
+ } else if (filterEvent(evt)) {
// clicked on a non-disabled control so ignore
return
} else if (textSelectionActive(this.$el)) {
@@ -45,39 +45,9 @@ export default {
/* istanbul ignore next: JSDOM doesn't support getSelection() */
return
}
- e.stopPropagation()
- e.preventDefault()
- let sortChanged = false
- const toggleLocalSortDesc = () => {
- const sortDirection = field.sortDirection || this.sortDirection
- if (sortDirection === 'asc') {
- this.localSortDesc = false
- } else if (sortDirection === 'desc') {
- this.localSortDesc = true
- }
- }
- if (!(isFoot && this.noFooterSorting)) {
- if (field.sortable) {
- if (field.key === this.localSortBy) {
- // Change sorting direction on current column
- this.localSortDesc = !this.localSortDesc
- } else {
- // Start sorting this column ascending
- this.localSortBy = field.key
- toggleLocalSortDesc()
- }
- sortChanged = true
- } else if (this.localSortBy && !this.noSortReset) {
- this.localSortBy = null
- toggleLocalSortDesc()
- sortChanged = true
- }
- }
- this.$emit('head-clicked', field.key, field, e, isFoot)
- if (sortChanged) {
- // Sorting parameters changed
- this.$emit('sort-changed', this.context)
- }
+ evt.stopPropagation()
+ evt.preventDefault()
+ this.$emit('head-clicked', field.key, field, evt, isFoot)
},
renderThead(isFoot = false) {
const h = this.$createElement
@@ -91,54 +61,42 @@ export default {
// Helper function to generate a field TH cell
const makeCell = (field, colIndex) => {
- let ariaLabel = ''
+ let ariaLabel = null
if (!field.label.trim() && !field.headerTitle) {
// In case field's label and title are empty/blank
// We need to add a hint about what the column is about for non-sighted users
/* istanbul ignore next */
ariaLabel = startCase(field.key)
}
- const sortable = field.sortable && !(isFoot && this.noFooterSorting)
- const ariaLabelSorting = sortable
- ? this.localSortDesc && this.localSortBy === field.key
- ? this.labelSortAsc
- : this.labelSortDesc
- : null
- // Assemble the aria-label
- ariaLabel = [ariaLabel, ariaLabelSorting].filter(a => a).join(': ') || null
- const ariaSort =
- sortable && this.localSortBy === field.key
- ? this.localSortDesc
- ? 'descending'
- : 'ascending'
- : sortable
- ? 'none'
- : null
+ const hasHeadClickListener = this.$listeners['head-clicked'] || this.isSortable
+ const handlers = {}
+ if (hasHeadClickListener) {
+ handlers.click = evt => {
+ this.headClicked(evt, field, isFoot)
+ }
+ handlers.keydown = evt => {
+ const keyCode = evt.keyCode
+ if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
+ this.headClicked(evt, field, isFoot)
+ }
+ }
+ }
const data = {
key: field.key,
- class: this.fieldClasses(field),
+ class: [this.fieldClasses(field), this.sortTheadThClasses(field.key, field, isFoot)],
style: field.thStyle || {},
attrs: {
- tabindex: sortable ? '0' : null,
+ // We only add a tabindex of 0 if there is a head-clicked listener
+ tabindex: hasHeadClickListener ? '0' : null,
abbr: field.headerAbbr || null,
title: field.headerTitle || null,
role: 'columnheader',
scope: 'col',
'aria-colindex': String(colIndex + 1),
'aria-label': ariaLabel,
- 'aria-sort': ariaSort
+ ...this.sortTheadThAttrs(field.key, field, isFoot)
},
- on: {
- click: evt => {
- this.headClicked(evt, field, isFoot)
- },
- keydown: evt => {
- const keyCode = evt.keyCode
- if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
- this.headClicked(evt, field, isFoot)
- }
- }
- }
+ on: handlers
}
let fieldScope = { label: field.label, column: field.key, field: field }
let slot =
@@ -159,7 +117,7 @@ export default {
// Genrate the row(s)
const $trs = []
if (isFoot) {
- $trs.push(h('tr', { class: this.tfootTrClass }, $cells))
+ $trs.push(h('tr', { class: this.tfootTrClass, attrs: { role: 'row' } }, $cells))
} else {
const scope = {
columns: fields.length,
diff --git a/src/components/table/table-busy.spec.js b/src/components/table/table-busy.spec.js
index 59c34ad48c3..a9d6607d27c 100644
--- a/src/components/table/table-busy.spec.js
+++ b/src/components/table/table-busy.spec.js
@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
-describe('b-table busy state', () => {
+describe('table > busy state', () => {
it('default should have attribute aria-busy=false', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-caption.spec.js b/src/components/table/table-caption.spec.js
index fa2f1ba9520..b4821129d07 100644
--- a/src/components/table/table-caption.spec.js
+++ b/src/components/table/table-caption.spec.js
@@ -4,7 +4,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
const testFields = ['a', 'b', 'c']
-describe('table caption', () => {
+describe('table > caption', () => {
it('should not have caption by default', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-colgroup.spec.js b/src/components/table/table-colgroup.spec.js
index 4ddcbfdba52..77bc43ff967 100644
--- a/src/components/table/table-colgroup.spec.js
+++ b/src/components/table/table-colgroup.spec.js
@@ -5,7 +5,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
const testFields = ['a', 'b', 'c']
-describe('table colgroup', () => {
+describe('table > colgroup', () => {
it('should not have colgroup by default', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-filtering.spec.js b/src/components/table/table-filtering.spec.js
new file mode 100644
index 00000000000..2f8b67562d8
--- /dev/null
+++ b/src/components/table/table-filtering.spec.js
@@ -0,0 +1,237 @@
+import Table from './table'
+import stringifyRecordValues from './helpers/stringify-record-values'
+import { mount } from '@vue/test-utils'
+
+const testItems = [{ a: 3, b: 'b', c: 'x' }, { a: 1, b: 'c', c: 'y' }, { a: 2, b: 'a', c: 'z' }]
+const testFields = ['a', 'b', 'c']
+
+describe('table > filtering', () => {
+ it('should not be filtered by default', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('input').length).toBe(1)
+ expect(wrapper.emitted('input')[0][0]).toEqual(testItems)
+ const $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ const columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+
+ wrapper.destroy()
+ })
+
+ it('should be filtered when filter is a string', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ filter: 'z'
+ }
+ })
+ expect(wrapper).toBeDefined()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+
+ let $rows = wrapper.findAll('tbody > tr')
+ expect($rows.length).toBe(1)
+
+ const $tds = $rows.at(0).findAll('td')
+
+ expect($tds.at(0).text()).toBe('2')
+ expect($tds.at(1).text()).toBe('a')
+ expect($tds.at(2).text()).toBe('z')
+
+ wrapper.destroy()
+ })
+
+ it('should emit filtered event when filter string is changed', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ filter: ''
+ }
+ })
+ expect(wrapper).toBeDefined()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ expect(wrapper.emitted('filtered')).not.toBeDefined()
+
+ wrapper.setProps({
+ filter: 'z'
+ })
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+
+ expect(wrapper.emitted('filtered')).toBeDefined()
+ expect(wrapper.emitted('filtered').length).toBe(1)
+ // Copy of items matching filter
+ expect(wrapper.emitted('filtered')[0][0]).toEqual([testItems[2]])
+ // Number of rows matching filter
+ expect(wrapper.emitted('filtered')[0][1]).toEqual(1)
+
+ wrapper.setProps({
+ filter: ''
+ })
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+
+ expect(wrapper.emitted('filtered').length).toBe(2)
+ // Copy of items matching filter
+ expect(wrapper.emitted('filtered')[1][0]).toEqual(testItems)
+ // Number of rows matching filter
+ expect(wrapper.emitted('filtered')[1][1]).toEqual(3)
+
+ wrapper.setProps({
+ filter: '3'
+ })
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+
+ expect(wrapper.emitted('filtered').length).toBe(3)
+ // Copy of items matching filter
+ expect(wrapper.emitted('filtered')[2][0]).toEqual([testItems[0]])
+ // Number of rows matching filter
+ expect(wrapper.emitted('filtered')[2][1]).toEqual(1)
+
+ wrapper.setProps({
+ // Setting to null will also clear the filter
+ filter: null
+ })
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+
+ expect(wrapper.emitted('filtered').length).toBe(4)
+ // Copy of items matching filter
+ expect(wrapper.emitted('filtered')[3][0]).toEqual(testItems)
+ // Number of rows matching filter
+ expect(wrapper.emitted('filtered')[3][1]).toEqual(3)
+
+ wrapper.destroy()
+ })
+
+ it('should work with filter function', async () => {
+ const filterFn = (item, regexp) => {
+ // We are passing a regexp for this test
+ return regexp.test(stringifyRecordValues(item))
+ }
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ filter: '',
+ filterFunction: filterFn
+ }
+ })
+ expect(wrapper).toBeDefined()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ expect(wrapper.emitted('filtered')).not.toBeDefined()
+
+ wrapper.setProps({
+ filter: /z/
+ })
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+
+ expect(wrapper.emitted('filtered')).toBeDefined()
+ expect(wrapper.emitted('filtered').length).toBe(1)
+ // Copy of items matching filter
+ expect(wrapper.emitted('filtered')[0][0]).toEqual([testItems[2]])
+ // Number of rows matching filter
+ expect(wrapper.emitted('filtered')[0][1]).toEqual(1)
+
+ wrapper.setProps({
+ filter: []
+ })
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+
+ expect(wrapper.emitted('filtered').length).toBe(2)
+ // Copy of items matching filter
+ expect(wrapper.emitted('filtered')[1][0]).toEqual(testItems)
+ // Number of rows matching filter
+ expect(wrapper.emitted('filtered')[1][1]).toEqual(3)
+
+ wrapper.destroy()
+ })
+
+ it('should be filtered with no rows when no matches', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ filter: 'ZZZZZZZZ'
+ }
+ })
+ expect(wrapper).toBeDefined()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(0)
+
+ wrapper.destroy()
+ })
+
+ it('should show empty filtered message when no matches and show-empty=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ filter: '',
+ showEmpty: true
+ }
+ })
+ expect(wrapper).toBeDefined()
+ await wrapper.vm.$nextTick()
+ expect(wrapper.findAll('tbody > tr').length).toBe(testItems.length)
+
+ wrapper.setProps({
+ filter: 'ZZZZZZ'
+ })
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.find('tbody > tr').text()).toBe(wrapper.vm.emptyFilteredText)
+ expect(wrapper.find('tbody > tr').classes()).toContain('b-table-empty-row')
+ expect(wrapper.find('tbody > tr').attributes('role')).toBe('row')
+ expect(wrapper.find('tbody > tr > td').attributes('role')).toBe('cell')
+ expect(wrapper.find('tbody > tr > td > div').attributes('role')).toBe('alert')
+ expect(wrapper.find('tbody > tr > td > div').attributes('aria-live')).toBe('polite')
+
+ wrapper.destroy()
+ })
+})
diff --git a/src/components/table/table-pagination.spec.js b/src/components/table/table-pagination.spec.js
new file mode 100644
index 00000000000..d42b264d023
--- /dev/null
+++ b/src/components/table/table-pagination.spec.js
@@ -0,0 +1,139 @@
+import Table from './table'
+import { mount } from '@vue/test-utils'
+
+const testItems = [
+ { a: 1, b: 2, c: 3 },
+ { a: 4, b: 5, c: 6 },
+ { a: 7, b: 8, c: 9 },
+ { a: 10, b: 11, c: 12 },
+ { a: 13, b: 14, c: 15 }
+]
+
+describe('table > pagination', () => {
+ it('default should not be paginated', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: testItems
+ }
+ })
+ expect(wrapper.findAll('tbody > tr').length).toBe(5)
+
+ wrapper.destroy()
+ })
+
+ it('should have 3 rows when per-page=3', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: testItems,
+ perPage: 3,
+ currentPage: 1
+ }
+ })
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ const $trs = wrapper.findAll('tbody > tr')
+ expect(
+ $trs
+ .at(0)
+ .find('td')
+ .text()
+ ).toBe('1')
+ expect(
+ $trs
+ .at(1)
+ .find('td')
+ .text()
+ ).toBe('4')
+ expect(
+ $trs
+ .at(2)
+ .find('td')
+ .text()
+ ).toBe('7')
+
+ wrapper.destroy()
+ })
+
+ it('changing pages should update rows', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: testItems,
+ perPage: 3,
+ currentPage: 1
+ }
+ })
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $trs = wrapper.findAll('tbody > tr')
+ expect(
+ $trs
+ .at(0)
+ .find('td')
+ .text()
+ ).toBe('1')
+ expect(
+ $trs
+ .at(1)
+ .find('td')
+ .text()
+ ).toBe('4')
+ expect(
+ $trs
+ .at(2)
+ .find('td')
+ .text()
+ ).toBe('7')
+
+ wrapper.setProps({
+ currentPage: 2
+ })
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(2)
+ $trs = wrapper.findAll('tbody > tr')
+ expect(
+ $trs
+ .at(0)
+ .find('td')
+ .text()
+ ).toBe('10')
+ expect(
+ $trs
+ .at(1)
+ .find('td')
+ .text()
+ ).toBe('13')
+
+ wrapper.setProps({
+ currentPage: 3
+ })
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(0)
+
+ wrapper.destroy()
+ })
+
+ it('setting current-page to more than pages shows empty row when show-empty=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: testItems,
+ perPage: 3,
+ currentPage: 1,
+ showEmpty: true
+ }
+ })
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+
+ wrapper.setProps({
+ currentPage: 10
+ })
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ const $tr = wrapper.find('tbody > tr')
+ expect($tr.text()).toBe(wrapper.vm.emptyText)
+ expect($tr.classes()).toContain('b-table-empty-row')
+ expect($tr.attributes('role')).toBe('row')
+ expect(wrapper.find('tbody > tr > td').attributes('role')).toBe('cell')
+ expect(wrapper.find('tbody > tr > td > div').attributes('role')).toBe('alert')
+ expect(wrapper.find('tbody > tr > td > div').attributes('aria-live')).toBe('polite')
+
+ wrapper.destroy()
+ })
+})
diff --git a/src/components/table/table-primarykey.spec.js b/src/components/table/table-primarykey.spec.js
index fafbf9e00b3..e02300d3731 100644
--- a/src/components/table/table-primarykey.spec.js
+++ b/src/components/table/table-primarykey.spec.js
@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
-describe('b-table primary key', () => {
+describe('table > primary key', () => {
it('default should not have ids on table rows', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-provider.spec.js b/src/components/table/table-provider.spec.js
index 22b89f5717b..82a7722d6b8 100644
--- a/src/components/table/table-provider.spec.js
+++ b/src/components/table/table-provider.spec.js
@@ -12,7 +12,7 @@ const testItems = [
const testFields = Object.keys(testItems[0]).sort()
-describe('b-table provider functions', () => {
+describe('table > provider functions', () => {
it('syncronous items provider works', async () => {
function provider(ctx) {
return testItems.slice()
@@ -263,4 +263,54 @@ describe('b-table provider functions', () => {
wrapper.destroy()
})
+
+ it('reacts to items provider function change', async () => {
+ function provider1(ctx) {
+ return testItems.slice()
+ }
+
+ function provider2(ctx) {
+ return testItems.slice(testItems.length - 1)
+ }
+
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: provider1
+ }
+ })
+ expect(wrapper).toBeDefined()
+
+ await Vue.nextTick()
+
+ expect(wrapper.emitted('update:busy')).toBeDefined()
+ expect(wrapper.emitted('input')).toBeDefined()
+
+ expect(wrapper.find('tbody').exists()).toBe(true)
+ expect(
+ wrapper
+ .find('tbody')
+ .findAll('tr')
+ .exists()
+ ).toBe(true)
+ expect(wrapper.find('tbody').findAll('tr').length).toBe(testItems.length)
+
+ wrapper.setProps({
+ items: provider2
+ })
+
+ await Vue.nextTick()
+
+ expect(wrapper.find('tbody').exists()).toBe(true)
+ expect(
+ wrapper
+ .find('tbody')
+ .findAll('tr')
+ .exists()
+ ).toBe(true)
+
+ expect(wrapper.find('tbody').findAll('tr').length).toBe(1)
+
+ wrapper.destroy()
+ })
})
diff --git a/src/components/table/table-row-details.spec.js b/src/components/table/table-row-details.spec.js
index 6c865418d0c..6d3b4a83618 100644
--- a/src/components/table/table-row-details.spec.js
+++ b/src/components/table/table-row-details.spec.js
@@ -1,7 +1,7 @@
import Table from './table'
import { mount } from '@vue/test-utils'
-describe('table row details', () => {
+describe('table > row details', () => {
it('does not show details if slot row-details not defined', async () => {
const testItems = [
{ a: 1, b: 2, c: 3, _showDetails: true },
@@ -170,4 +170,63 @@ describe('table row details', () => {
wrapper.destroy()
})
+
+ it('should show details slot when slot method toggleDetails() called', async () => {
+ const testItems = [{ a: 1, b: 2, c: 3, _showDetails: true }]
+ const testFields = ['a', 'b', 'c']
+ let scopeDetails = null
+ let scopeField = null
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ },
+ scopedSlots: {
+ 'row-details': function(scope) {
+ scopeDetails = scope
+ return 'foobar'
+ },
+ a: function(scope) {
+ scopeField = scope
+ return 'AAA'
+ }
+ }
+ })
+ let $trs
+ expect(wrapper).toBeDefined()
+ expect(wrapper.find('tbody').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(2)
+
+ $trs = wrapper.findAll('tbody > tr')
+ expect($trs.length).toBe(2)
+ expect($trs.at(0).is('tr.b-table-details')).toBe(false)
+ expect($trs.at(1).is('tr.b-table-details')).toBe(true)
+ expect($trs.at(1).text()).toBe('foobar')
+
+ // Toggle details via details slot
+ expect(scopeDetails).not.toBe(null)
+ scopeDetails.toggleDetails()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+
+ $trs = wrapper.findAll('tbody > tr')
+ expect($trs.length).toBe(1)
+ expect($trs.at(0).is('tr.b-table-details')).toBe(false)
+
+ // Toggle details via field slot
+ expect(scopeField).not.toBe(null)
+ scopeField.toggleDetails()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(2)
+
+ $trs = wrapper.findAll('tbody > tr')
+ expect($trs.length).toBe(2)
+ expect($trs.at(0).is('tr.b-table-details')).toBe(false)
+ expect($trs.at(1).is('tr.b-table-details')).toBe(true)
+ expect($trs.at(1).text()).toBe('foobar')
+
+ wrapper.destroy()
+ })
})
diff --git a/src/components/table/table-selectable.spec.js b/src/components/table/table-selectable.spec.js
index b495d528900..5601c128bb7 100644
--- a/src/components/table/table-selectable.spec.js
+++ b/src/components/table/table-selectable.spec.js
@@ -4,7 +4,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }]
const testFields = [{ key: 'a', sortable: true }]
-describe('table row select', () => {
+describe('table > row select', () => {
it('should not emit row-selected event default', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-sort.spec.js b/src/components/table/table-sort.spec.js
deleted file mode 100644
index 677a5e7dc97..00000000000
--- a/src/components/table/table-sort.spec.js
+++ /dev/null
@@ -1,268 +0,0 @@
-import Table from './table'
-import defaultSortCompare from './helpers/default-sort-compare'
-import { mount } from '@vue/test-utils'
-
-const testItems = [{ a: 3, b: 'b', c: 'x' }, { a: 1, b: 'c', c: 'y' }, { a: 2, b: 'a', c: 'z' }]
-const testFields = [
- { key: 'a', label: 'A', sortable: true },
- { key: 'b', label: 'B', sortable: true },
- { key: 'c', label: 'C', sortable: false }
-]
-
-describe('table sorting', () => {
- it('should not be sorted by default', async () => {
- const wrapper = mount(Table, {
- propsData: {
- fields: testFields,
- items: testItems
- }
- })
- expect(wrapper).toBeDefined()
- expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
- expect(wrapper.findAll('tbody > tr').length).toBe(3)
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('input')).toBeDefined()
- expect(wrapper.emitted('input').length).toBe(1)
- expect(wrapper.emitted('input')[0][0]).toEqual(testItems)
- const $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the first column text value
- const columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('3')
- expect(columnA[1]).toBe('1')
- expect(columnA[2]).toBe('2')
-
- wrapper.destroy()
- })
-
- it('should sort column descending when sortBy set and sortDesc changed', async () => {
- const wrapper = mount(Table, {
- propsData: {
- fields: testFields,
- items: testItems,
- sortBy: 'a',
- sortDesc: false
- }
- })
- expect(wrapper).toBeDefined()
- expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
- expect(wrapper.findAll('tbody > tr').length).toBe(3)
- let $rows
- let columnA
-
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('input')).toBeDefined()
- expect(wrapper.emitted('input').length).toBe(1)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the first column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('1')
- expect(columnA[1]).toBe('2')
- expect(columnA[2]).toBe('3')
-
- // Change sort direction
- wrapper.setProps({
- sortDesc: true
- })
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('input').length).toBe(2)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the first column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('3')
- expect(columnA[1]).toBe('2')
- expect(columnA[2]).toBe('1')
-
- // Clear sort
- wrapper.setProps({
- sortBy: null,
- sortDesc: false
- })
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('input').length).toBe(4)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the first column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('3')
- expect(columnA[1]).toBe('1')
- expect(columnA[2]).toBe('2')
-
- wrapper.destroy()
- })
-
- it('should accept custom sort compare', async () => {
- const wrapper = mount(Table, {
- propsData: {
- fields: testFields,
- items: testItems,
- sortBy: 'a',
- sortDesc: false,
- sortCompare: (a, b, sortBy) => {
- // We just use our default sort compare to test passing a function
- return defaultSortCompare(a, b, sortBy)
- }
- }
- })
- expect(wrapper).toBeDefined()
- expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
- expect(wrapper.findAll('tbody > tr').length).toBe(3)
- let $rows
- let columnA
-
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('input')).toBeDefined()
- expect(wrapper.emitted('input').length).toBe(1)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the first column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('1')
- expect(columnA[1]).toBe('2')
- expect(columnA[2]).toBe('3')
-
- wrapper.destroy()
- })
-
- it('should sort columns when clicking headers', async () => {
- const wrapper = mount(Table, {
- propsData: {
- fields: testFields,
- items: testItems
- }
- })
- expect(wrapper).toBeDefined()
- expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
- expect(wrapper.findAll('tbody > tr').length).toBe(3)
- let $rows
- let columnA
- let columnB
-
- // Should not be sorted
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('input')).toBeDefined()
- expect(wrapper.emitted('sort-changed')).not.toBeDefined()
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the first column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('3')
- expect(columnA[1]).toBe('1')
- expect(columnA[2]).toBe('2')
-
- // Sort by first column
- wrapper
- .findAll('thead > tr > th')
- .at(0)
- .trigger('click')
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('sort-changed')).toBeDefined()
- expect(wrapper.emitted('sort-changed').length).toBe(1)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('1')
- expect(columnA[1]).toBe('2')
- expect(columnA[2]).toBe('3')
-
- // Click first column header again to reverse sort
- wrapper
- .findAll('thead > tr > th')
- .at(0)
- .trigger('click')
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('sort-changed').length).toBe(2)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('3')
- expect(columnA[1]).toBe('2')
- expect(columnA[2]).toBe('1')
-
- // Click second column header to sort by it (by using keydown.enter)
- wrapper
- .findAll('thead > tr > th')
- .at(1)
- .trigger('keydown.enter')
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('sort-changed').length).toBe(3)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the column text value
- columnB = $rows.map(row => {
- return row
- .findAll('td')
- .at(1)
- .text()
- })
- expect(columnB[0]).toBe('a')
- expect(columnB[1]).toBe('b')
- expect(columnB[2]).toBe('c')
-
- // Click third column header to clear sort
- wrapper
- .findAll('thead > tr > th')
- .at(2)
- .trigger('click')
- await wrapper.vm.$nextTick()
- expect(wrapper.emitted('sort-changed').length).toBe(4)
- $rows = wrapper.findAll('tbody > tr').wrappers
- expect($rows.length).toBe(3)
- // Map the rows to the column text value
- columnA = $rows.map(row => {
- return row
- .findAll('td')
- .at(0)
- .text()
- })
- expect(columnA[0]).toBe('3')
- expect(columnA[1]).toBe('1')
- expect(columnA[2]).toBe('2')
-
- wrapper.destroy()
- })
-})
diff --git a/src/components/table/table-sorting.spec.js b/src/components/table/table-sorting.spec.js
new file mode 100644
index 00000000000..eeadee28d84
--- /dev/null
+++ b/src/components/table/table-sorting.spec.js
@@ -0,0 +1,700 @@
+import Table from './table'
+import defaultSortCompare from './helpers/default-sort-compare'
+import { mount } from '@vue/test-utils'
+
+const testItems = [{ a: 3, b: 'b', c: 'x' }, { a: 1, b: 'c', c: 'y' }, { a: 2, b: 'a', c: 'z' }]
+const testFields = [
+ { key: 'a', label: 'A', sortable: true },
+ { key: 'b', label: 'B', sortable: true },
+ { key: 'c', label: 'C', sortable: false }
+]
+
+describe('table > sorting', () => {
+ it('should not be sorted by default', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('input').length).toBe(1)
+ expect(wrapper.emitted('input')[0][0]).toEqual(testItems)
+ const $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ const columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+
+ wrapper.destroy()
+ })
+
+ it('should sort column descending when sortBy set and sortDesc changed, with proper attributes', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ sortBy: 'a',
+ sortDesc: false
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $rows
+ let columnA
+
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('input').length).toBe(1)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('1')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('3')
+
+ let $ths = wrapper.findAll('thead > tr > th')
+
+ // currently sorted as ascending
+ expect($ths.at(0).attributes('aria-sort')).toBe('ascending')
+ // for switching to descending
+ expect($ths.at(0).attributes('aria-label')).toBe(wrapper.vm.labelSortDesc)
+
+ // not sorted by this column
+ expect($ths.at(1).attributes('aria-sort')).toBe('none')
+ // for sorting by ascending
+ expect($ths.at(1).attributes('aria-label')).toBe(wrapper.vm.labelSortAsc)
+
+ // not a sortable column
+ expect($ths.at(2).attributes('aria-sort')).not.toBeDefined()
+ // for clearing sorting
+ expect($ths.at(2).attributes('aria-label')).toBe(wrapper.vm.labelSortClear)
+
+ // Change sort direction
+ wrapper.setProps({
+ sortDesc: true
+ })
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input').length).toBe(2)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('1')
+
+ $ths = wrapper.findAll('thead > tr > th')
+
+ // currently sorted as descending
+ expect($ths.at(0).attributes('aria-sort')).toBe('descending')
+ // for switching to ascending
+ expect($ths.at(0).attributes('aria-label')).toBe(wrapper.vm.labelSortAsc)
+
+ // not sorted by this column
+ expect($ths.at(1).attributes('aria-sort')).toBe('none')
+ // for sorting by ascending
+ expect($ths.at(1).attributes('aria-label')).toBe(wrapper.vm.labelSortAsc)
+
+ // not a sortable column
+ expect($ths.at(2).attributes('aria-sort')).not.toBeDefined()
+ // for clearing sorting
+ expect($ths.at(2).attributes('aria-label')).toBe(wrapper.vm.labelSortClear)
+
+ // Clear sort
+ wrapper.setProps({
+ sortBy: null,
+ sortDesc: false
+ })
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input').length).toBe(4)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+
+ $ths = wrapper.findAll('thead > tr > th')
+
+ // currently not sorted
+ expect($ths.at(0).attributes('aria-sort')).toBe('none')
+ // for sorting by ascending
+ expect($ths.at(0).attributes('aria-label')).toBe(wrapper.vm.labelSortAsc)
+
+ // not sorted by this column
+ expect($ths.at(1).attributes('aria-sort')).toBe('none')
+ // for sorting by ascending
+ expect($ths.at(1).attributes('aria-label')).toBe(wrapper.vm.labelSortAsc)
+
+ // not a sortable column
+ expect($ths.at(2).attributes('aria-sort')).not.toBeDefined()
+ // for clearing sorting
+ expect($ths.at(2).attributes('aria-label')).not.toBeDefined()
+
+ wrapper.destroy()
+ })
+
+ it('should accept custom sort compare', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ sortBy: 'a',
+ sortDesc: false,
+ sortCompare: (a, b, sortBy) => {
+ // We just use our default sort compare to test passing a function
+ return defaultSortCompare(a, b, sortBy)
+ }
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $rows
+ let columnA
+
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('input').length).toBe(1)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('1')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('3')
+
+ wrapper.destroy()
+ })
+
+ it('should sort columns when clicking headers', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $rows
+ let columnA
+ let columnB
+
+ // Should not be sorted
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('sort-changed')).not.toBeDefined()
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+
+ // Sort by first column
+ wrapper
+ .findAll('thead > tr > th')
+ .at(0)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed')).toBeDefined()
+ expect(wrapper.emitted('sort-changed').length).toBe(1)
+ expect(wrapper.emitted('sort-changed')[0][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('1')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('3')
+
+ // Click first column header again to reverse sort
+ wrapper
+ .findAll('thead > tr > th')
+ .at(0)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed').length).toBe(2)
+ expect(wrapper.emitted('sort-changed')[1][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('1')
+
+ // Click second column header to sort by it (by using keydown.enter)
+ wrapper
+ .findAll('thead > tr > th')
+ .at(1)
+ .trigger('keydown.enter')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed').length).toBe(3)
+ expect(wrapper.emitted('sort-changed')[2][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnB = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(1)
+ .text()
+ })
+ expect(columnB[0]).toBe('a')
+ expect(columnB[1]).toBe('b')
+ expect(columnB[2]).toBe('c')
+
+ // Click third column header to clear sort
+ wrapper
+ .findAll('thead > tr > th')
+ .at(2)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed').length).toBe(4)
+ expect(wrapper.emitted('sort-changed')[3][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+
+ wrapper.destroy()
+ })
+
+ it('should sort columns when clicking footers', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ footClone: true
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $rows
+ let columnA
+ let columnB
+
+ // Should not be sorted
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('sort-changed')).not.toBeDefined()
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+ // Should have aria-* labels
+ expect(wrapper.findAll('tfoot > tr > th[aria-sort]').length).toBe(2)
+ expect(wrapper.findAll('tfoot > tr > th[aria-label]').length).toBe(2)
+
+ // Sort by first column
+ wrapper
+ .findAll('tfoot > tr > th')
+ .at(0)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed')).toBeDefined()
+ expect(wrapper.emitted('sort-changed').length).toBe(1)
+ expect(wrapper.emitted('sort-changed')[0][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('1')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('3')
+ // Should have aria-* labels
+ expect(wrapper.findAll('tfoot > tr > th[aria-sort]').length).toBe(2)
+ expect(wrapper.findAll('tfoot > tr > th[aria-label]').length).toBe(3)
+
+ // Click first column header again to reverse sort
+ wrapper
+ .findAll('tfoot > tr > th')
+ .at(0)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed').length).toBe(2)
+ expect(wrapper.emitted('sort-changed')[1][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('1')
+ // Should have aria-* labels
+ expect(wrapper.findAll('tfoot > tr > th[aria-sort]').length).toBe(2)
+ expect(wrapper.findAll('tfoot > tr > th[aria-label]').length).toBe(3)
+
+ // Click second column header to sort by it (by using keydown.enter)
+ wrapper
+ .findAll('tfoot > tr > th')
+ .at(1)
+ .trigger('keydown.enter')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed').length).toBe(3)
+ expect(wrapper.emitted('sort-changed')[2][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnB = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(1)
+ .text()
+ })
+ expect(columnB[0]).toBe('a')
+ expect(columnB[1]).toBe('b')
+ expect(columnB[2]).toBe('c')
+
+ // Click third column header to clear sort
+ wrapper
+ .findAll('tfoot > tr > th')
+ .at(2)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed').length).toBe(4)
+ expect(wrapper.emitted('sort-changed')[3][0]).toEqual(wrapper.vm.context)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+ // Should have aria-* labels
+ expect(wrapper.findAll('tfoot > tr > th[aria-sort]').length).toBe(2)
+ expect(wrapper.findAll('tfoot > tr > th[aria-label]').length).toBe(2)
+
+ wrapper.destroy()
+ })
+
+ it('should not sort columns when clicking footers and no-footer-sorting set', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ footClone: true,
+ noFooterSorting: true
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $rows
+ let columnA
+
+ // Should not be sorted
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('sort-changed')).not.toBeDefined()
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+ // Shouldn't have aria-* labels
+ expect(wrapper.findAll('tfoot > tr > th[aria-sort]').length).toBe(0)
+ expect(wrapper.findAll('tfoot > tr > th[aria-label]').length).toBe(0)
+
+ // Click first column
+ wrapper
+ .findAll('tfoot > tr > th')
+ .at(0)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed')).not.toBeDefined()
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+ // Shouldn't have aria-* labels
+ expect(wrapper.findAll('tfoot > tr > th[aria-sort]').length).toBe(0)
+ expect(wrapper.findAll('tfoot > tr > th[aria-label]').length).toBe(0)
+
+ // Click third column header
+ wrapper
+ .findAll('tfoot > tr > th')
+ .at(2)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed')).not.toBeDefined()
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+ // Shouldn't have aria-* labels
+ expect(wrapper.findAll('tfoot > tr > th[aria-sort]').length).toBe(0)
+ expect(wrapper.findAll('tfoot > tr > th[aria-label]').length).toBe(0)
+
+ wrapper.destroy()
+ })
+
+ it('should sort column descending first, when sort-direction=desc', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ sortDesc: false,
+ sortDirection: 'desc'
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $rows
+ let columnA
+
+ await wrapper.vm.$nextTick()
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+
+ let $ths = wrapper.findAll('thead > tr > th')
+
+ // currently not sorted
+ expect($ths.at(0).attributes('aria-sort')).toBe('none')
+ // for switching to descending
+ expect($ths.at(0).attributes('aria-label')).toBe(wrapper.vm.labelSortDesc)
+
+ // not sorted by this column
+ expect($ths.at(1).attributes('aria-sort')).toBe('none')
+ // for sorting by ascending
+ expect($ths.at(1).attributes('aria-label')).toBe(wrapper.vm.labelSortDesc)
+
+ // not a sortable column
+ expect($ths.at(2).attributes('aria-sort')).not.toBeDefined()
+ // for clearing sorting
+ expect($ths.at(2).attributes('aria-label')).not.toBeDefined()
+
+ // Change sort direction (should be descending first)
+ wrapper
+ .findAll('thead > tr > th')
+ .at(0)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('1')
+
+ $ths = wrapper.findAll('thead > tr > th')
+
+ // currently sorted as descending
+ expect($ths.at(0).attributes('aria-sort')).toBe('descending')
+ // for switching to ascending
+ expect($ths.at(0).attributes('aria-label')).toBe(wrapper.vm.labelSortAsc)
+
+ // not sorted by this column
+ expect($ths.at(1).attributes('aria-sort')).toBe('none')
+ // for sorting by ascending
+ expect($ths.at(1).attributes('aria-label')).toBe(wrapper.vm.labelSortDesc)
+
+ // not a sortable column
+ expect($ths.at(2).attributes('aria-sort')).not.toBeDefined()
+ // for clearing sorting
+ expect($ths.at(2).attributes('aria-label')).toBe(wrapper.vm.labelSortClear)
+
+ wrapper.destroy()
+ })
+
+ it('non-sortable header th should not emit a sort-changed event when clicked and prop no-sort-reset is set', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ noSortReset: true
+ }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').exists()).toBe(true)
+ expect(wrapper.findAll('tbody > tr').length).toBe(3)
+ let $rows
+ let columnA
+
+ // Should not be sorted
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('input')).toBeDefined()
+ expect(wrapper.emitted('sort-changed')).not.toBeDefined()
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the first column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('3')
+ expect(columnA[1]).toBe('1')
+ expect(columnA[2]).toBe('2')
+
+ // Click first column to sort
+ wrapper
+ .findAll('thead > tr > th')
+ .at(0)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed')).toBeDefined()
+ expect(wrapper.emitted('sort-changed').length).toBe(1)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('1')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('3')
+
+ // Click third column header (should not clear sorting)
+ wrapper
+ .findAll('thead > tr > th')
+ .at(2)
+ .trigger('click')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('sort-changed').length).toBe(1)
+ $rows = wrapper.findAll('tbody > tr').wrappers
+ expect($rows.length).toBe(3)
+ // Map the rows to the column text value
+ columnA = $rows.map(row => {
+ return row
+ .findAll('td')
+ .at(0)
+ .text()
+ })
+ expect(columnA[0]).toBe('1')
+ expect(columnA[1]).toBe('2')
+ expect(columnA[2]).toBe('3')
+
+ wrapper.destroy()
+ })
+})
diff --git a/src/components/table/table-bottom-row.spec.js b/src/components/table/table-tbody-bottom-row.spec.js
similarity index 98%
rename from src/components/table/table-bottom-row.spec.js
rename to src/components/table/table-tbody-bottom-row.spec.js
index df91e2d256c..8f8bb731705 100644
--- a/src/components/table/table-bottom-row.spec.js
+++ b/src/components/table/table-tbody-bottom-row.spec.js
@@ -5,7 +5,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
const testFields = ['a', 'b', 'c']
-describe('table bottom-row slot', () => {
+describe('table > tbody bottom-row slot', () => {
it('should not have bottom row by default', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-tbody-row-events.spec.js b/src/components/table/table-tbody-row-events.spec.js
index 4ea42025199..9ea365c71f2 100644
--- a/src/components/table/table-tbody-row-events.spec.js
+++ b/src/components/table/table-tbody-row-events.spec.js
@@ -4,7 +4,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
const testFields = ['a', 'b', 'c']
-describe('table tbody row events', () => {
+describe('table > tbody row events', () => {
it('should emit row-clicked event when a row is clicked', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-top-row.spec.js b/src/components/table/table-tbody-top-row.spec.js
similarity index 98%
rename from src/components/table/table-top-row.spec.js
rename to src/components/table/table-tbody-top-row.spec.js
index aa56b8099be..73398c79933 100644
--- a/src/components/table/table-top-row.spec.js
+++ b/src/components/table/table-tbody-top-row.spec.js
@@ -5,7 +5,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
const testFields = ['a', 'b', 'c']
-describe('table top-row', () => {
+describe('table > tbody top-row slot', () => {
it('should not have top row by default', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table-tbody-transition.spec.js b/src/components/table/table-tbody-transition.spec.js
index f7f391b21d0..7075af0daea 100644
--- a/src/components/table/table-tbody-transition.spec.js
+++ b/src/components/table/table-tbody-transition.spec.js
@@ -4,7 +4,7 @@ import { mount, TransitionGroupStub } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
const testFields = ['a', 'b', 'c']
-describe('table body transition', () => {
+describe('table > tbody transition', () => {
it('tbody should not be a transition-group component by default', async () => {
const wrapper = mount(Table, {
attachToDocument: true,
diff --git a/src/components/table/table-tfoot-events.spec.js b/src/components/table/table-tfoot-events.spec.js
index afdec6a389a..c00112176d4 100644
--- a/src/components/table/table-tfoot-events.spec.js
+++ b/src/components/table/table-tfoot-events.spec.js
@@ -4,13 +4,17 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }]
const testFields = [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }, { key: 'c', label: 'C' }]
-describe('table tfoot events', () => {
+describe('table > tfoot events', () => {
it('should emit head-clicked event when a head cell is clicked', async () => {
const wrapper = mount(Table, {
propsData: {
fields: testFields,
items: testItems,
footClone: true
+ },
+ listeners: {
+ // head-clicked will not be emitted unless there is a registered head-clicked listener
+ 'head-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
@@ -44,6 +48,10 @@ describe('table tfoot events', () => {
items: testItems,
footClone: true,
busy: true
+ },
+ listeners: {
+ // head-clicked will not be emitted unless there is a registered head-clicked listener
+ 'head-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
@@ -62,6 +70,10 @@ describe('table tfoot events', () => {
fields: testFields,
items: testItems,
footClone: true
+ },
+ listeners: {
+ // head-clicked will not be emitted unless there is a registered head-clicked listener
+ 'head-clicked': () => {}
}
})
wrapper.setData({
@@ -84,6 +96,10 @@ describe('table tfoot events', () => {
items: testItems,
footClone: true
},
+ listeners: {
+ // head-clicked will not be emitted unless there is a registered head-clicked listener
+ 'head-clicked': () => {}
+ },
slots: {
// in Vue 2.6x, slots get translated into scopedSlots
FOOT_a: 'button ',
diff --git a/src/components/table/table-thead-events.spec.js b/src/components/table/table-thead-events.spec.js
index bd5c8de46e8..1ed2b0a2bbc 100644
--- a/src/components/table/table-thead-events.spec.js
+++ b/src/components/table/table-thead-events.spec.js
@@ -4,12 +4,38 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }]
const testFields = [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }, { key: 'c', label: 'C' }]
-describe('table thead events', () => {
+describe('table > thead events', () => {
+ it('should not emit head-clicked event when a head cell is clicked and no head-clicked listener', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ },
+ listeners: {}
+ })
+ expect(wrapper).toBeDefined()
+ const $rows = wrapper.findAll('thead > tr')
+ expect($rows.length).toBe(1)
+ const $ths = wrapper.findAll('thead > tr > th')
+ expect($ths.length).toBe(testFields.length)
+ expect(wrapper.emitted('head-clicked')).not.toBeDefined()
+ $ths.at(0).trigger('click')
+ expect(wrapper.emitted('head-clicked')).not.toBeDefined()
+ $ths.at(1).trigger('click')
+ expect(wrapper.emitted('head-clicked')).not.toBeDefined()
+ $ths.at(2).trigger('click')
+ expect(wrapper.emitted('head-clicked')).not.toBeDefined()
+ })
+
it('should emit head-clicked event when a head cell is clicked', async () => {
const wrapper = mount(Table, {
propsData: {
fields: testFields,
items: testItems
+ },
+ listeners: {
+ // head-clicked will only be emitted if there is a registered listener
+ 'head-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
@@ -42,6 +68,10 @@ describe('table thead events', () => {
fields: testFields,
items: testItems,
busy: true
+ },
+ listeners: {
+ // head-clicked will only be emitted if there is a registered listener
+ 'head-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
@@ -59,6 +89,10 @@ describe('table thead events', () => {
propsData: {
fields: testFields,
items: testItems
+ },
+ listeners: {
+ // head-clicked will only be emitted if there is a registered listener
+ 'head-clicked': () => {}
}
})
wrapper.setData({
@@ -80,6 +114,10 @@ describe('table thead events', () => {
fields: testFields,
items: testItems
},
+ listeners: {
+ // head-clicked will only be emitted if there is a registered listener
+ 'head-clicked': () => {}
+ },
slots: {
// in Vue 2.6x, slots get translated into scopedSlots
HEAD_a: 'button ',
diff --git a/src/components/table/table-thead-top.spec.js b/src/components/table/table-thead-top.spec.js
index e8d020c45ac..aca91ddefe4 100644
--- a/src/components/table/table-thead-top.spec.js
+++ b/src/components/table/table-thead-top.spec.js
@@ -5,7 +5,7 @@ import { mount } from '@vue/test-utils'
const testItems = [{ a: 1, b: 2, c: 3 }, { a: 5, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
const testFields = ['a', 'b', 'c']
-describe('table thead-top slot', () => {
+describe('table > thead thead-top slot', () => {
it('should not have thead-top row by default', async () => {
const wrapper = mount(Table, {
propsData: {
diff --git a/src/components/table/table.js b/src/components/table/table.js
index 1678a43955e..e4918054d48 100644
--- a/src/components/table/table.js
+++ b/src/components/table/table.js
@@ -1,18 +1,15 @@
// Utilities
import looseEqual from '../../utils/loose-equal'
-import stableSort from '../../utils/stable-sort'
-import { arrayIncludes, isArray } from '../../utils/array'
-
-// Table helper functions
-import normalizeFields from './helpers/normalize-fields'
-import stringifyRecordValues from './helpers/stringify-record-values'
-import defaultSortCompare from './helpers/default-sort-compare'
// Mixins
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
-// Table helper mixins
+// Table helper Mixins
+import itemsMixin from './helpers/mixin-items'
+import filteringMixin from './helpers/mixin-filtering'
+import sortingMixin from './helpers/mixin-sorting'
+import paginationMixin from './helpers/mixin-pagination'
import captionMixin from './helpers/mixin-caption'
import colgroupMixin from './helpers/mixin-colgroup'
import theadMixin from './helpers/mixin-thead'
@@ -26,9 +23,15 @@ import providerMixin from './helpers/mixin-provider'
// @vue/component
export default {
name: 'BTable',
+ // Order of mixins is important.
+ // They are merged from left to fight, followed by this component.
mixins: [
idMixin,
normalizeSlotMixin,
+ itemsMixin,
+ filteringMixin,
+ sortingMixin,
+ paginationMixin,
busyMixin,
captionMixin,
colgroupMixin,
@@ -41,22 +44,6 @@ export default {
// Don't place ATTRS on root element automatically, as table could be wrapped in responsive div
inheritAttrs: false,
props: {
- items: {
- type: [Array, Function],
- default() /* istanbul ignore next */ {
- return []
- }
- },
- fields: {
- type: [Object, Array],
- default: null
- },
- primaryKey: {
- // Primary key for record.
- // If provided the value in each row must be unique!!!
- type: String,
- default: null
- },
striped: {
type: Boolean,
default: false
@@ -97,59 +84,6 @@ export default {
type: [Boolean, String],
default: false
},
- sortBy: {
- type: String,
- default: null
- },
- sortDesc: {
- type: Boolean,
- default: false
- },
- sortDirection: {
- type: String,
- default: 'asc',
- validator: direction => arrayIncludes(['asc', 'desc', 'last'], direction)
- },
- sortCompare: {
- type: Function,
- default: null
- },
- noSortReset: {
- type: Boolean,
- default: false
- },
- labelSortAsc: {
- type: String,
- default: 'Click to sort Ascending'
- },
- labelSortDesc: {
- type: String,
- default: 'Click to sort Descending'
- },
- perPage: {
- type: [Number, String],
- default: 0
- },
- currentPage: {
- type: [Number, String],
- default: 1
- },
- filter: {
- type: [String, RegExp, Object, Array, Function],
- default: null
- },
- filterFunction: {
- type: Function,
- default: null
- },
- noLocalSorting: {
- type: Boolean,
- default: false
- },
- noFooterSorting: {
- type: Boolean,
- default: false
- },
value: {
// v-model for retrieving the current displayed rows
type: Array,
@@ -159,15 +93,8 @@ export default {
}
},
data() {
- return {
- // Mixins will also add to data
- localSortBy: this.sortBy || '',
- localSortDesc: this.sortDesc || false,
- // Our local copy of the items. Must be an array
- localItems: isArray(this.items) ? this.items.slice() : [],
- // Flag for displaying which empty slot to show, and for some event triggering.
- isFiltered: false
- }
+ // Mixins add to data
+ return {}
},
computed: {
// Layout related computed props
@@ -225,203 +152,26 @@ export default {
...this.selectableTableAttrs
}
},
- // Items related computed props
- localFiltering() {
- return this.hasProvider ? !!this.noProviderFiltering : true
- },
- localSorting() {
- return this.hasProvider ? !!this.noProviderSorting : !this.noLocalSorting
- },
- localPaging() {
- return this.hasProvider ? !!this.noProviderPaging : true
- },
context() {
// Current state of sorting, filtering and pagination props/values
return {
filter: this.localFilter,
sortBy: this.localSortBy,
sortDesc: this.localSortDesc,
- perPage: this.perPage,
- currentPage: this.currentPage,
+ perPage: parseInt(this.perPage, 10) || 0,
+ currentPage: parseInt(this.currentPage, 10) || 1,
apiUrl: this.apiUrl
}
},
- computedFields() {
- // We normalize fields into an array of objects
- // [ { key:..., label:..., ...}, {...}, ..., {..}]
- return normalizeFields(this.fields, this.localItems)
- },
- filteredCheck() {
- // For watching changes to filteredItems vs localItems
- return {
- filteredItems: this.filteredItems,
- localItems: this.localItems,
- localFilter: this.localFilter
- }
- },
- localFilter() {
- // Returns a sanitized/normalized version of filter prop
- if (typeof this.filter === 'function') {
- // this.localFilterFn will contain the correct function ref.
- // Deprecate setting prop filter to a function
- /* istanbul ignore next */
- return ''
- } else if (
- typeof this.filterFunction !== 'function' &&
- !(typeof this.filter === 'string' || this.filter instanceof RegExp)
- ) {
- // Using internal filter function, which only accepts string or regexp at the moment
- return ''
- } else {
- // Could be a string, object or array, as needed by external filter function
- return this.filter
- }
- },
- localFilterFn() {
- let filter = this.filter
- let filterFn = this.filterFunction
- // Sanitized/normalize filter-function prop
- if (typeof filterFn === 'function') {
- return filterFn
- } else if (typeof filter === 'function') {
- // Deprecate setting prop filter to a function
- /* istanbul ignore next */
- return filter
- } else {
- // no filterFunction, so signal to use internal filter function
- return null
- }
- },
- filteredItems() {
- // Returns the records in localItems that match the filter criteria.
- // Returns the original localItems array if not sorting
- let items = this.localItems || []
- const criteria = this.localFilter
- const filterFn =
- this.filterFnFactory(this.localFilterFn, criteria) || this.defaultFilterFnFactory(criteria)
-
- // We only do local filtering if requested, and if the are records to filter and
- // if a filter criteria was specified
- if (this.localFiltering && filterFn && items.length > 0) {
- items = items.filter(filterFn)
- }
- return items
- },
- sortedItems() {
- // Sorts the filtered items and returns a new array of the sorted items
- // or the original items array if not sorted.
- let items = this.filteredItems || []
- const sortBy = this.localSortBy
- const sortDesc = this.localSortDesc
- const sortCompare = this.sortCompare
- const localSorting = this.localSorting
- if (sortBy && localSorting) {
- // stableSort returns a new array, and leaves the original array intact
- return stableSort(items, (a, b) => {
- let result = null
- if (typeof sortCompare === 'function') {
- // Call user provided sortCompare routine
- result = sortCompare(a, b, sortBy, sortDesc)
- }
- if (result === null || result === undefined || result === false) {
- // Fallback to built-in defaultSortCompare if sortCompare
- // is not defined or returns null/false
- result = defaultSortCompare(a, b, sortBy)
- }
- // Negate result if sorting in descending order
- return (result || 0) * (sortDesc ? -1 : 1)
- })
- }
- return items
- },
- paginatedItems() {
- let items = this.sortedItems || []
- const currentPage = Math.max(parseInt(this.currentPage, 10) || 1, 1)
- const perPage = Math.max(parseInt(this.perPage, 10) || 0, 0)
- // Apply local pagination
- if (this.localPaging && !!perPage) {
- // Grab the current page of data (which may be past filtered items limit)
- items = items.slice((currentPage - 1) * perPage, currentPage * perPage)
- }
- // Return the items to display in the table
- return items
- },
computedItems() {
return this.paginatedItems || []
}
},
watch: {
- // Watch props for changes and update local values
- items(newItems) {
- if (this.hasProvider || newItems instanceof Function) {
- this.$nextTick(this._providerUpdate)
- } else if (isArray(newItems)) {
- // Set localItems/filteredItems to a copy of the provided array
- this.localItems = newItems.slice()
- } else {
- /* istanbul ignore next */
- this.localItems = []
- }
- },
- sortDesc(newVal, oldVal) {
- if (newVal === this.localSortDesc) {
- /* istanbul ignore next */
- return
- }
- this.localSortDesc = newVal || false
- },
- sortBy(newVal, oldVal) {
- if (newVal === this.localSortBy) {
- /* istanbul ignore next */
- return
- }
- this.localSortBy = newVal || null
- },
- // Update .sync props
- localSortDesc(newVal, oldVal) {
- // Emit update to sort-desc.sync
- if (newVal !== oldVal) {
- this.$emit('update:sortDesc', newVal)
- }
- },
- localSortBy(newVal, oldVal) {
- if (newVal !== oldVal) {
- this.$emit('update:sortBy', newVal)
- }
- },
// Watch for changes on computedItems and update the v-model
computedItems(newVal, oldVal) {
this.$emit('input', newVal)
},
- // Watch for changes to the filter criteria and filtered items vs localItems).
- // And set visual state and emit events as required
- filteredCheck({ filteredItems, localItems, localFilter }) {
- // Determine if the dataset is filtered or not
- let isFiltered
- if (!localFilter) {
- // If filter criteria is falsey
- isFiltered = false
- } else if (looseEqual(localFilter, []) || looseEqual(localFilter, {})) {
- // If filter criteria is an empty array or object
- isFiltered = false
- } else if (localFilter) {
- // if Filter criteria is truthy
- isFiltered = true
- } else {
- isFiltered = false
- }
- if (isFiltered) {
- this.$emit('filtered', filteredItems, filteredItems.length)
- }
- this.isFiltered = isFiltered
- },
- isFiltered(newVal, oldVal) {
- if (newVal === false && oldVal === true) {
- // We need to emit a filtered event if isFiltered transitions from true to
- // false so that users can update their pagination controls.
- this.$emit('filtered', this.localItems, this.localItems.length)
- }
- },
context(newVal, oldVal) {
// Emit context info for external paging/filtering/sorting handling
if (!looseEqual(newVal, oldVal)) {
@@ -433,73 +183,6 @@ export default {
// Initially update the v-model of displayed items
this.$emit('input', this.computedItems)
},
- methods: {
- // Filter Function factories
- filterFnFactory(filterFn, criteria) {
- // Wrapper factory for external filter functions.
- // Wrap the provided filter-function and return a new function.
- // returns null if no filter-function defined or if criteria is falsey.
- // Rather than directly grabbing this.computedLocalFilterFn or this.filterFunction
- // We have it passed, so that the caller computed prop will be reactive to changes
- // in the original filter-function (as this routine is a method)
- if (!filterFn || !criteria || typeof filterFn !== 'function') {
- return null
- }
-
- // Build the wrapped filter test function, passing the criteria to the provided function
- const fn = item => {
- // Generated function returns true if the criteria matches part
- // of the serialized data, otherwise false
- return filterFn(item, criteria)
- }
-
- // Return the wrapped function
- return fn
- },
- defaultFilterFnFactory(criteria) {
- // Generates the default filter function, using the given filter criteria
- if (!criteria || !(typeof criteria === 'string' || criteria instanceof RegExp)) {
- // Built in filter can only support strings or RegExp criteria (at the moment)
- return null
- }
-
- // Build the regexp needed for filtering
- let regexp = criteria
- if (typeof regexp === 'string') {
- // Escape special RegExp characters in the string and convert contiguous
- // whitespace to \s+ matches
- const pattern = criteria
- .replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
- .replace(/[\s\uFEFF\xA0]+/g, '\\s+')
- // Build the RegExp (no need for global flag, as we only need
- // to find the value once in the string)
- regexp = new RegExp(`.*${pattern}.*`, 'i')
- }
-
- // Generate the wrapped filter test function to use
- const fn = item => {
- // This searches all row values (and sub property values) in the entire (excluding
- // special _ prefixed keys), because we convert the record to a space-separated
- // string containing all the value properties (recursively), even ones that are
- // not visible (not specified in this.fields).
- //
- // TODO: Enable searching on formatted fields and scoped slots
- // TODO: Should we filter only on visible fields (i.e. ones in this.fields) by default?
- // TODO: Allow for searching on specific fields/key, this could be combined with the previous TODO
- // TODO: Give stringifyRecordValues extra options for filtering (i.e. passing the
- // fields definition and a reference to $scopedSlots)
- //
- // Generated function returns true if the criteria matches part of
- // the serialized data, otherwise false
- // We set lastIndex = 0 on regex in case someone uses the /g global flag
- regexp.lastIndex = 0
- return regexp.test(stringifyRecordValues(item))
- }
-
- // Return the generated function
- return fn
- }
- },
render(h) {
// Build the caption (from caption mixin)
const $caption = this.renderCaption()
diff --git a/src/components/table/table.spec.js b/src/components/table/table.spec.js
index ac4cca5f4f7..e67e06ad135 100644
--- a/src/components/table/table.spec.js
+++ b/src/components/table/table.spec.js
@@ -1,764 +1,643 @@
-import { loadFixture, testVM, setData, nextTick, sleep } from '../../../tests/utils'
+import Table from './table'
+import { mount } from '@vue/test-utils'
+
+const items1 = [{ a: 1, b: 2, c: 3 }, { a: 4, b: 5, c: 6 }]
+const fields1 = ['a', 'b', 'c']
describe('table', () => {
- beforeEach(loadFixture(__dirname, 'table'))
- testVM()
-
- it('all example tables should contain class names', async () => {
- const {
- app: { $refs }
- } = window
-
- expect($refs.table_basic).toHaveAllClasses(['table', 'b-table', 'table-striped', 'table-hover'])
-
- expect($refs.table_paginated).toHaveAllClasses([
- 'table',
- 'b-table',
- 'table-sm',
- 'table-striped',
- 'table-bordered',
- 'table-hover'
- ])
-
- expect($refs.table_dark).toHaveAllClasses([
- 'table',
- 'b-table',
- 'table-sm',
- 'table-bordered',
- 'table-dark'
- ])
- })
+ it('has expected default classes', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1
+ }
+ })
- it('table_responsive should be wrapped in a div', async () => {
- const {
- app: { $refs }
- } = window
- const table = $refs.table_responsive
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(2)
- expect(table.$el.tagName).toBe('DIV')
- expect(table).toHaveAllClasses(['table-responsive'])
- expect(table.$el.children.length).toBe(1)
- expect(table.$el.children[0].tagName).toBe('TABLE')
+ wrapper.destroy()
})
- it('should generate fields automatically from the first item', async () => {
- const {
- app: { $refs }
- } = window
- const table = $refs.table_without_fields
- const thead = $refs.table_without_fields.$el.children[0]
- const tr = thead.children[0]
-
- // The row should be equal to the items without any of Bootstrap Vue's
- // utility fields, like _rowVariant, or _cellVariants
- expect(tr.children.length).toBe(Object.keys(table.items[0]).length - 1)
- })
+ it('has class "table-striped" when striped=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ striped: true
+ }
+ })
- it('table_basic should have thead and tbody', async () => {
- const {
- app: { $refs }
- } = window
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('table-striped')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- const parts = [...$refs.table_basic.$el.children]
+ wrapper.destroy()
+ })
- const thead = parts.find(el => el.tagName && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
+ it('has class "table-bordered" when bordered=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ bordered: true
+ }
+ })
- const tbody = parts.find(el => el.tagName && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('table-bordered')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- const tfoot = parts.find(el => el.tagName && el.tagName === 'TFOOT')
- expect(tfoot).not.toBeDefined()
+ wrapper.destroy()
})
- it('table_paginated should have thead, tbody and tfoot', async () => {
- const {
- app: { $refs }
- } = window
-
- const parts = [...$refs.table_paginated.$el.children]
-
- const thead = parts.find(el => el.tagName && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
+ it('has class "table-borderless" when borderless=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ borderless: true
+ }
+ })
- const tbody = parts.find(el => el.tagName && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('table-borderless')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- const tfoot = parts.find(el => el.tagName && el.tagName === 'TFOOT')
- expect(tfoot).toBeDefined()
+ wrapper.destroy()
})
- it('table_dark should have thead and tbody', async () => {
- const {
- app: { $refs }
- } = window
-
- const parts = [...$refs.table_dark.$el.children]
-
- const thead = parts.find(el => el.tagName && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
+ it('has class "table-hover" when hover=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ hover: true
+ }
+ })
- const tbody = parts.find(el => el.tagName && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('table-hover')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- const tfoot = parts.find(el => el.tagName && el.tagName === 'TFOOT')
- expect(tfoot).not.toBeDefined()
+ wrapper.destroy()
})
- it('table_paginated thead should contain class thead-dark', async () => {
- const {
- app: { $refs }
- } = window
- const thead = [...$refs.table_paginated.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
- if (thead) {
- expect(thead.classList.contains('thead-dark')).toBe(true)
- }
- })
+ it('has class "table-sm" when small=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ small: true
+ }
+ })
- it('table_paginated tfoot should contain class thead-light', async () => {
- const {
- app: { $refs }
- } = window
- const tfoot = [...$refs.table_paginated.$el.children].find(el => el && el.tagName === 'TFOOT')
- expect(tfoot).toBeDefined()
- if (tfoot) {
- expect(tfoot.classList.contains('thead-light')).toBe(true)
- }
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('table-sm')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
+
+ wrapper.destroy()
})
- it('all examples have correct number of columns', async () => {
- const {
- app: { $refs }
- } = window
-
- const tables = ['table_basic', 'table_paginated', 'table_dark']
-
- tables.forEach((table, idx) => {
- const vm = $refs[table]
- const thead = [...vm.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
- if (thead) {
- const tr = [...thead.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- expect(tr.children.length).toBe(Object.keys(vm.fields).length)
- }
+ it('has class "table-dark" when dark=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ dark: true
}
})
- })
- it('all examples should show the correct number of visible rows', async () => {
- const {
- app: { $refs }
- } = window
- const app = window.app
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('table-dark')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- const tables = ['table_basic', 'table_paginated', 'table_dark']
+ wrapper.destroy()
+ })
- tables.forEach((table, idx) => {
- const vm = $refs[table]
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- if (tbody) {
- expect(tbody.children.length).toBe(vm.perPage || app.items.length)
+ it('has class "border" when outlined=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ outlined: true
}
})
+
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('border')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
+
+ wrapper.destroy()
})
- it('all examples have sortable & unsortable headers', async () => {
- const {
- app: { $refs }
- } = window
-
- const tables = ['table_basic', 'table_paginated', 'table_dark']
- // const sortables = [true, true, false, false]
-
- tables.forEach(table => {
- const vm = $refs[table]
- const thead = [...vm.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
- if (thead) {
- const tr = [...thead.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- const fieldKeys = Object.keys(vm.fields)
- const ths = [...tr.children]
- expect(ths.length).toBe(fieldKeys.length)
- ths.forEach((th, idx) => {
- expect(th.hasAttribute('aria-sort')).toBe(vm.fields[fieldKeys[idx]].sortable || false)
- })
- }
+ it('has class "b-table-fixed" when fixed=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ fixed: true
}
})
- })
- it('table_paginated has sortable & unsortable footers', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const fieldKeys = Object.keys(vm.fields)
-
- const tfoot = [...vm.$el.children].find(el => el && el.tagName === 'TFOOT')
- expect(tfoot).toBeDefined()
- if (tfoot) {
- const tr = [...tfoot.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- const ths = [...tr.children]
- expect(ths.length).toBe(fieldKeys.length)
- ths.forEach((th, idx) => {
- expect(th.hasAttribute('aria-sort')).toBe(vm.fields[fieldKeys[idx]].sortable || false)
- })
- }
- }
- })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('b-table-fixed')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- it('all example tables should have attribute aria-busy="false" when busy is false', async () => {
- const { app } = window
+ wrapper.destroy()
+ })
- const tables = ['table_basic', 'table_paginated', 'table_dark']
+ it('has class "b-table-stacked" when stacked=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ stacked: true
+ }
+ })
- await setData(app, 'isBusy', false)
- await nextTick()
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('b-table-stacked')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- tables.forEach(table => {
- expect(app.$refs[table].$el.getAttribute('aria-busy')).toBe('false')
- })
+ wrapper.destroy()
})
- it('table_paginated should have attribute aria-busy="true" when busy is true', async () => {
- const {
- app: { $refs }
- } = window
- const app = window.app
+ it('has class "b-table-stacked-md" when stacked=md', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ stacked: 'md'
+ }
+ })
- await setData(app, 'isBusy', true)
- await nextTick()
- expect($refs.table_paginated.$el.getAttribute('aria-busy')).toBe('true')
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).toContain('b-table-stacked-md')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
- await setData(app, 'isBusy', false)
- await nextTick()
- expect($refs.table_paginated.$el.getAttribute('aria-busy')).toBe('false')
+ wrapper.destroy()
})
- it('sortable columns should have ARIA labels in thead', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const ariaLabel = vm.labelSortDesc
-
- const thead = [...vm.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
- if (thead) {
- const tr = [...thead.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- expect(tr.children[0].getAttribute('aria-label')).toBe(ariaLabel)
- expect(tr.children[1].getAttribute('aria-label')).toBe(ariaLabel)
- expect(tr.children[2].getAttribute('aria-label')).toBe(null)
- expect(tr.children[3].getAttribute('aria-label')).toBe(null)
+ it('has class "table-responsive" when responsive=true', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ responsive: true
}
- }
- })
+ })
- it('sortable columns should have ARIA labels in tfoot', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const ariaLabel = vm.labelSortDesc
-
- const tfoot = [...vm.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(tfoot).toBeDefined()
- if (tfoot) {
- const tr = [...tfoot.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- expect(tr.children[0].getAttribute('aria-label')).toBe(ariaLabel)
- expect(tr.children[1].getAttribute('aria-label')).toBe(ariaLabel)
- expect(tr.children[2].getAttribute('aria-label')).toBe(null)
- expect(tr.children[3].getAttribute('aria-label')).toBe(null)
- }
- }
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('div')).toBe(true)
+ expect(wrapper.classes()).toContain('table-responsive')
+ expect(wrapper.classes().length).toBe(1)
+ expect(wrapper.find('table').classes()).toContain('table')
+ expect(wrapper.find('table').classes()).toContain('b-table')
+ expect(wrapper.find('table').classes().length).toBe(2)
+
+ wrapper.destroy()
})
- it('all examples should have variant "success" on 1st row', async () => {
- const {
- app: { $refs }
- } = window
- const app = window.app
-
- const tables = ['table_basic', 'table_paginated', 'table_dark']
-
- const items = app.items.slice()
- items[0]._rowVariant = 'success'
- await setData(app, 'items', items)
- await nextTick()
-
- tables.forEach((table, idx) => {
- const vm = $refs[table]
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- if (tbody) {
- const tr = tbody.children[0]
- const variant = vm.dark ? 'bg-success' : 'table-success'
- expect(Boolean(tr) && Boolean(tr.classList) && tr.classList.contains(variant)).toBe(true)
+ it('has class "table-responsive-md" when responsive=md', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ responsive: 'md'
}
})
- })
- it('table_basic should contain custom formatted columns', async () => {
- const { app } = window
- const vm = app.$refs.table_basic
-
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- if (tbody) {
- const tr = [...tbody.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- expect(tr.children[0].textContent).toContain(
- vm.items[0].name.first + ' ' + vm.items[0].name.last
- )
- expect(tr.children[1].textContent).toContain(String(vm.items[0].age))
- expect(tr.children[3].children[0].tagName).toBe('BUTTON')
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('div')).toBe(true)
+ expect(wrapper.classes()).toContain('table-responsive-md')
+ expect(wrapper.classes().length).toBe(1)
+ expect(wrapper.find('table').classes()).toContain('table')
+ expect(wrapper.find('table').classes()).toContain('b-table')
+ expect(wrapper.find('table').classes().length).toBe(2)
+
+ wrapper.destroy()
+ })
+
+ it('stacked has precedence over responsive', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ stacked: true,
+ responsive: true
}
- }
+ })
+
+ expect(wrapper).toBeDefined()
+ expect(wrapper.is(Table)).toBe(true)
+ expect(wrapper.is('table')).toBe(true)
+ expect(wrapper.classes()).not.toContain('table-responsive')
+ expect(wrapper.classes()).toContain('b-table-stacked')
+ expect(wrapper.classes()).toContain('table')
+ expect(wrapper.classes()).toContain('b-table')
+ expect(wrapper.classes().length).toBe(3)
+
+ wrapper.destroy()
})
- it('table_paginated should contain custom formatted columns', async () => {
- const { app } = window
- const vm = app.$refs.table_basic
-
- const tbody = [...app.$refs.table_paginated.$el.children].find(
- el => el && el.tagName === 'TBODY'
- )
- expect(tbody).toBeDefined()
- if (tbody) {
- const tr = [...tbody.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- expect(tr.children[0].textContent).toContain(
- vm.items[0].name.first + ' ' + vm.items[0].name.last
- )
- expect(tr.children[1].textContent).toContain(String(vm.items[0].age))
- expect(tr.children[3].children[0].tagName).toBe('INPUT')
+ it('stacked has data-label attribute on all tbody > tr td', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: fields1,
+ stacked: true
}
- }
+ })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').length).toBe(2)
+ const $trs = wrapper.findAll('tbody > tr').wrappers
+
+ // Labels will have run through startCase
+ expect(
+ $trs[0]
+ .findAll('td')
+ .at(0)
+ .attributes('data-label')
+ ).toBe('A')
+ expect(
+ $trs[1]
+ .findAll('td')
+ .at(0)
+ .attributes('data-label')
+ ).toBe('A')
+
+ expect(
+ $trs[0]
+ .findAll('td')
+ .at(1)
+ .attributes('data-label')
+ ).toBe('B')
+ expect(
+ $trs[1]
+ .findAll('td')
+ .at(1)
+ .attributes('data-label')
+ ).toBe('B')
+
+ expect(
+ $trs[0]
+ .findAll('td')
+ .at(2)
+ .attributes('data-label')
+ ).toBe('C')
+ expect(
+ $trs[1]
+ .findAll('td')
+ .at(2)
+ .attributes('data-label')
+ ).toBe('C')
+
+ wrapper.destroy()
+ })
+
+ it('item _rowVariant works', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: [{ a: 1, _rowVariant: 'primary' }],
+ fields: ['a'],
+ dark: false
+ }
+ })
+
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.find('tbody > tr').classes()).toContain('table-primary')
+
+ wrapper.setProps({
+ dark: true
+ })
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.find('tbody > tr').classes()).toContain('bg-primary')
+
+ wrapper.destroy()
})
- it('table_paginated should contain custom formatted headers', async () => {
- const {
- app: { $refs }
- } = window
-
- const thead = [...$refs.table_paginated.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
- if (thead) {
- const tr = [...thead.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- expect(tr.children[0].textContent).toContain('Person Full name')
- expect(tr.children[1].textContent).toContain('Person age')
- expect(tr.children[2].textContent).toContain('is Active')
- expect(tr.children[3].textContent).toContain('Select')
+ it('item _cellVariants works', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: [{ a: 1, _cellVariants: { a: 'info' } }],
+ fields: ['a'],
+ dark: false
}
- }
+ })
+
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.findAll('tbody > tr > td').length).toBe(1)
+ expect(wrapper.find('tbody > tr > td').classes()).toContain('table-info')
+
+ wrapper.setProps({
+ dark: true
+ })
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.findAll('tbody > tr > td').length).toBe(1)
+ expect(wrapper.find('tbody > tr > td').classes()).toContain('bg-info')
+
+ wrapper.destroy()
})
- it('table_paginated should contain custom formatted footers', async () => {
- const {
- app: { $refs }
- } = window
-
- await nextTick()
-
- const tfoot = [...$refs.table_paginated.$el.children].find(el => el && el.tagName === 'TFOOT')
- expect(tfoot).toBeDefined()
- if (tfoot) {
- const tr = [...tfoot.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- expect(tr.children[0].textContent).toContain('Showing 5 People')
- expect(tr.children[1].textContent).toContain('Person age')
- expect(tr.children[2].textContent).toContain('is Active')
- expect(tr.children[3].textContent).toContain('Selected: 0')
+ it('changing items array works', async () => {
+ const items1 = [{ a: 1, b: 2 }, { a: 3, b: 4 }]
+ const items2 = [{ a: 3, b: 4 }]
+ const wrapper = mount(Table, {
+ propsData: {
+ items: items1,
+ fields: ['a', 'b']
}
- }
+ })
+ expect(wrapper).toBeDefined()
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(2)
+ wrapper.setProps({
+ items: items2
+ })
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+
+ wrapper.destroy()
})
- it('sortable header th should emit a sort-changed event with context when clicked and sort changed', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const spy = jest.fn()
- const fieldKeys = Object.keys(vm.fields)
-
- vm.$on('sort-changed', spy)
- const thead = [...vm.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
- if (thead) {
- const tr = [...thead.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- let sortBy = null
- const ths = [...tr.children]
- expect(ths.length).toBe(fieldKeys.length)
- ths.forEach((th, idx) => {
- th.click()
- if (vm.fields[fieldKeys[idx]].sortable) {
- expect(spy).toHaveBeenCalledWith(vm.context)
- expect(vm.context.sortBy).toBe(fieldKeys[idx])
- sortBy = vm.context.sortBy
- } else {
- if (sortBy) {
- expect(spy).toHaveBeenCalledWith(vm.context)
- expect(vm.context.sortBy).toBe(null)
- sortBy = null
- } else {
- expect(spy).not.toHaveBeenCalled()
- expect(vm.context.sortBy).toBe(null)
- }
- }
- spy.mockClear()
- })
+ it('tbody-tr-class works', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: [{ a: 1, b: 2 }, { a: 3, b: 4 }],
+ fields: ['a', 'b'],
+ tbodyTrClass: 'foobar'
}
- }
- })
+ })
- it('sortable footer th should emit a sort-changed event with context when clicked and sort changed', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const spy = jest.fn()
- const fieldKeys = Object.keys(vm.fields)
-
- vm.$on('sort-changed', spy)
- const tfoot = [...vm.$el.children].find(el => el && el.tagName === 'TFOOT')
- expect(tfoot).toBeDefined()
- if (tfoot) {
- const tr = [...tfoot.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- let sortBy = null
- const ths = [...tr.children]
- expect(ths.length).toBe(fieldKeys.length)
- ths.forEach((th, idx) => {
- th.click()
- if (vm.fields[fieldKeys[idx]].sortable) {
- expect(spy).toHaveBeenCalledWith(vm.context)
- expect(vm.context.sortBy).toBe(fieldKeys[idx])
- sortBy = vm.context.sortBy
- } else {
- if (sortBy) {
- expect(spy).toHaveBeenCalledWith(vm.context)
- expect(vm.context.sortBy).toBe(null)
- sortBy = null
- } else {
- expect(spy).not.toHaveBeenCalled()
- expect(vm.context.sortBy).toBe(null)
- }
- }
- spy.mockClear()
- })
+ expect(wrapper).toBeDefined()
+
+ // prop as a string
+ expect(wrapper.findAll('tbody > tr').length).toBe(2)
+ let $trs = wrapper.findAll('tbody > tr')
+ expect($trs.at(0).classes()).toContain('foobar')
+ expect($trs.at(1).classes()).toContain('foobar')
+
+ // As a function
+ wrapper.setProps({
+ tbodyTrClass: item => {
+ return item.a === 1 ? 'foo' : 'bar'
}
- }
+ })
+
+ expect(wrapper.findAll('tbody > tr').length).toBe(2)
+ $trs = wrapper.findAll('tbody > tr')
+ expect($trs.at(0).classes()).toContain('foo')
+ expect($trs.at(0).classes()).not.toContain('bar')
+ expect($trs.at(1).classes()).toContain('bar')
+ expect($trs.at(1).classes()).not.toContain('foo')
+
+ wrapper.destroy()
})
- it('non-sortable header th should not emit a sort-changed event when clicked and prop no-sort-reset is set', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_no_sort_reset
- const spy = jest.fn()
- const fieldKeys = Object.keys(vm.fields)
-
- vm.$on('sort-changed', spy)
- const thead = [...vm.$el.children].find(el => el && el.tagName === 'THEAD')
- expect(thead).toBeDefined()
- if (thead) {
- const tr = [...thead.children].find(el => el && el.tagName === 'TR')
- expect(tr).toBeDefined()
- if (tr) {
- let sortBy = null
- const ths = [...tr.children]
- expect(ths.length).toBe(fieldKeys.length)
- ths.forEach((th, idx) => {
- th.click()
- if (vm.fields[fieldKeys[idx]].sortable) {
- expect(spy).toHaveBeenCalledWith(vm.context)
- expect(vm.context.sortBy).toBe(fieldKeys[idx])
- sortBy = vm.context.sortBy
- } else {
- expect(spy).not.toHaveBeenCalled()
- expect(vm.context.sortBy).toBe(sortBy)
- }
- spy.mockClear()
- })
+ it('thead and tfoot variant and classes work', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: [{ a: 1, b: 2 }],
+ fields: ['a', 'b'],
+ footClone: true
}
- }
- })
+ })
- it('table_paginated pagination works', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const app = window.app
- const spy = jest.fn()
-
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- if (tbody) {
- // We need between 11 and 14 ites for this test
- expect(app.items.length > 10).toBe(true)
- expect(app.items.length < 15).toBe(true)
-
- vm.$on('input', spy)
-
- // Page size to be less then number of items
- await setData(app, 'currentPage', 1)
- await setData(app, 'perPage', 10)
- await nextTick()
- expect(vm.perPage).toBe(10)
- expect(vm.value.length).toBe(10)
- expect(tbody.children.length).toBe(10)
-
- // Goto page 2, should have length 1
- await setData(app, 'currentPage', 2)
- await nextTick()
- expect(vm.value.length).toBe(app.items.length - 10)
- expect(tbody.children.length).toBe(app.items.length - 10)
-
- expect(spy).toHaveBeenCalled()
- }
- })
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('thead > tr').length).toBe(1)
+ expect(wrapper.findAll('tfoot > tr').length).toBe(1)
- it('table_paginated filtering works', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const app = window.app
- const spyInput = jest.fn()
- const spyFiltered = jest.fn()
-
- expect(vm.showEmpty).toBe(true)
- expect(app.items.length > 10).toBe(true)
- expect(app.items.length < 15).toBe(true)
-
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- if (tbody) {
- expect(app.items.length > 1).toBe(true)
-
- vm.$on('input', spyInput)
-
- // Set page size to max number of items
- await setData(app, 'currentPage', 1)
- await setData(app, 'perPage', 15)
- await nextTick()
- expect(vm.value.length).toBe(app.items.length)
- expect(tbody.children.length).toBe(app.items.length)
-
- // Apply Fiter
- await setData(app, 'filter', String(app.items[0].name.last))
- await nextTick()
- expect(vm.value.length < app.items.length).toBe(true)
- expect(tbody.children.length < app.items.length).toBe(true)
-
- // Empty filter alert
- vm.$on('filtered', spyFiltered)
- await setData(app, 'filter', 'ZZZZZZZZZZZZZZZZZzzzzzzzzzzzzzzzzz........')
- await nextTick()
-
- expect(spyFiltered).toHaveBeenCalled()
-
- expect(vm.value.length).toBe(0)
- expect(tbody.children.length).toBe(1)
- expect(tbody.children[0].children[0].textContent).toContain(vm.emptyFilteredText)
-
- expect(spyInput).toHaveBeenCalled()
- }
- })
+ expect(wrapper.find('thead').classes().length).toBe(0)
+ expect(wrapper.find('tfoot').classes().length).toBe(0)
- it('table_paginated shows empty message when no items', async () => {
- const {
- app: { $refs }
- } = window
- const vm = $refs.table_paginated
- const app = window.app
- const spy = jest.fn()
-
- expect(vm.showEmpty).toBe(true)
-
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- if (tbody) {
- expect(app.items.length > 10).toBe(true)
- expect(app.items.length < 15).toBe(true)
-
- vm.$on('input', spy)
-
- // Set page size to show all items
- await setData(app, 'currentPage', 1)
- await setData(app, 'perPage', 15)
- await nextTick()
- expect(vm.value.length).toBe(app.items.length)
- expect(tbody.children.length).toBe(app.items.length)
-
- // Set items to empty list
- await setData(app, 'items', [])
- await nextTick()
- expect(app.items.length).toBe(0)
- expect(vm.value.length).toBe(0)
- expect(tbody.children.length).toBe(1)
- expect(tbody.textContent).toContain(vm.emptyText)
-
- expect(spy).toHaveBeenCalled()
- }
- })
+ wrapper.setProps({
+ headVariant: 'light'
+ })
- it('table_provider should emit a refreshed event for providerArray', async () => {
- const { app } = window
- const vm = app.$refs.table_provider
- const spy = jest.fn()
+ expect(wrapper.find('thead').classes()).toContain('thead-light')
+ expect(wrapper.find('tfoot').classes()).toContain('thead-light')
- await setData(app, 'providerType', 'array')
- await nextTick()
- await sleep(100)
+ wrapper.setProps({
+ footVariant: 'dark'
+ })
- vm.$on('refreshed', spy)
- vm.refresh()
- await nextTick()
- await sleep(100)
+ expect(wrapper.find('thead').classes()).toContain('thead-light')
+ expect(wrapper.find('tfoot').classes()).toContain('thead-dark')
- expect(spy).toHaveBeenCalled()
- // expect(vm.value.length).toBe(app.items.length)
- })
+ wrapper.setProps({
+ theadClass: 'foo',
+ tfootClass: 'bar'
+ })
- it('table_provider should emit a refreshed event for providerCallback', async () => {
- const { app } = window
- const vm = app.$refs.table_provider
- const spy = jest.fn()
+ expect(wrapper.find('thead').classes()).toContain('thead-light')
+ expect(wrapper.find('thead').classes()).toContain('foo')
+ expect(wrapper.find('tfoot').classes()).toContain('thead-dark')
+ expect(wrapper.find('tfoot').classes()).toContain('bar')
- await setData(app, 'providerType', 'callback')
- await nextTick()
- await sleep(100)
+ wrapper.setProps({
+ theadTrClass: 'willy',
+ tfootTrClass: 'wonka'
+ })
- vm.$on('refreshed', spy)
- vm.refresh()
- await nextTick()
- await sleep(100)
+ expect(wrapper.find('thead > tr').classes()).toContain('willy')
+ expect(wrapper.find('tfoot > tr').classes()).toContain('wonka')
- expect(spy).toHaveBeenCalled()
+ wrapper.destroy()
})
- it('table_provider should emit a refreshed event for providerPromise', async () => {
- const { app } = window
- const vm = app.$refs.table_provider
- const spy = jest.fn()
+ it('item field isRowHeader works', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: [{ a: 1, b: 2 }],
+ fields: [{ key: 'a', isRowHeader: true }, 'b']
+ }
+ })
- await setData(app, 'providerType', 'promise')
- await nextTick()
- await sleep(100)
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.findAll('tbody > tr > *').length).toBe(2)
+
+ expect(
+ wrapper
+ .findAll('tbody > tr > *')
+ .at(0)
+ .is('th')
+ ).toBe(true)
+ expect(
+ wrapper
+ .findAll('tbody > tr > *')
+ .at(0)
+ .attributes('role')
+ ).toBe('rowheader')
+ expect(
+ wrapper
+ .findAll('tbody > tr > *')
+ .at(0)
+ .attributes('scope')
+ ).toBe('row')
+
+ expect(
+ wrapper
+ .findAll('tbody > tr > *')
+ .at(1)
+ .is('td')
+ ).toBe(true)
+ expect(
+ wrapper
+ .findAll('tbody > tr > *')
+ .at(1)
+ .attributes('role')
+ ).toBe('cell')
+ expect(
+ wrapper
+ .findAll('tbody > tr > *')
+ .at(1)
+ .attributes('scope')
+ ).not.toBeDefined()
+
+ wrapper.destroy()
+ })
+
+ it('item field tdAttr and tdClass works', async () => {
+ const Parent = {
+ methods: {
+ parentTdAttrs(value, key, item) {
+ return { 'data-parent': 'parent' }
+ }
+ }
+ }
+ const wrapper = mount(Table, {
+ parentComponent: Parent,
+ propsData: {
+ items: [{ a: 1, b: 2, c: 3 }],
+ fields: [
+ { key: 'a', tdAttr: { 'data-foo': 'bar' } },
+ { key: 'b', tdClass: () => 'baz' },
+ { key: 'c', tdAttr: 'parentTdAttrs' }
+ ]
+ }
+ })
- vm.$on('refreshed', spy)
- vm.refresh()
- await nextTick()
- await sleep(100)
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.findAll('tbody > tr > td').length).toBe(3)
- expect(spy).toHaveBeenCalled()
- })
+ const $tds = wrapper.findAll('tbody > tr > td')
+
+ expect($tds.at(0).attributes('data-foo')).toBe('bar')
+ expect($tds.at(0).attributes('data-parent')).not.toBeDefined()
+ expect($tds.at(0).classes().length).toBe(0)
- it('should render stacked table', async () => {
- const { app } = window
- const vm = app.$refs.table_stacked
+ expect($tds.at(1).classes()).toContain('baz')
+ expect($tds.at(1).attributes('data-foo')).not.toBeDefined()
+ expect($tds.at(1).attributes('data-parent')).not.toBeDefined()
- expect(vm).toHaveAllClasses(['b-table-stacked'])
+ expect($tds.at(2).attributes('data-parent')).toBe('parent')
+ expect($tds.at(2).attributes('data-foo')).not.toBeDefined()
+ expect($tds.at(2).classes().length).toBe(0)
+
+ wrapper.destroy()
})
- it('all example tables should have custom formatted cells', async () => {
- const {
- app: { $refs }
- } = window
-
- const tables = ['table_basic', 'table_paginated', 'table_dark']
- await nextTick()
-
- tables.forEach((table, idx) => {
- const vm = $refs[table]
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- if (tbody) {
- const tr = tbody.children[0]
- expect(tr).toBeDefined()
- expect(
- Boolean(tr.children[0]) &&
- Boolean(tr.children[0].classList) &&
- tr.children[0].classList.contains('bg-primary')
- ).toBe(true)
- expect(
- Boolean(tr.children[1]) &&
- Boolean(tr.children[1].classList) &&
- tr.children[1].classList.contains('bg-primary') &&
- tr.children[1].classList.contains('text-dark')
- ).toBe(true)
- expect(
- Boolean(tr.children[2]) &&
- Boolean(tr.children[2].classList) &&
- tr.children[2].classList.contains('bg-danger')
- ).toBe(true)
- expect(
- Boolean(tr.children[3]) &&
- Boolean(tr.children[3].classList) &&
- tr.children[3].classList.contains('bg-primary') &&
- tr.children[3].classList.contains('text-light')
- ).toBe(true)
- expect(
- Boolean(tr.children[0]) &&
- Boolean(tr.children[0].attributes) &&
- tr.children[0].getAttribute('title') === 'Person Full name'
- ).toBe(true)
- expect(
- Boolean(tr.children[2]) &&
- Boolean(tr.children[2].attributes) &&
- tr.children[2].getAttribute('title') === 'is Active'
- ).toBe(true)
- expect(
- Boolean(tr.children[3]) &&
- Boolean(tr.children[3].attributes) &&
- tr.children[3].getAttribute('title') === 'Actions'
- ).toBe(true)
+ it('item field formatter as function works', async () => {
+ const wrapper = mount(Table, {
+ propsData: {
+ items: [{ a: 1, b: 2 }],
+ fields: [
+ {
+ key: 'a',
+ formatter(value, key, item) {
+ return item.a + item.b
+ }
+ },
+ 'b'
+ ]
}
})
+
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.findAll('tbody > tr > td').length).toBe(2)
+ const $tds = wrapper.findAll('tbody > tr > td')
+ expect($tds.at(0).text()).toBe('3')
+ expect($tds.at(1).text()).toBe('2')
+
+ wrapper.destroy()
})
- it('should set row classes', async () => {
- // Classes that children rows must contain
- const classesTest = {
- 'tr-start-with-l': [1, 7],
- 'tr-last-name-macdonald': [0, 6]
- }
- const { app } = window
- const vm = app.$refs.table_style_row
- const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY')
- expect(tbody).toBeDefined()
- for (const className in classesTest) {
- const children = classesTest[className]
- for (let childIndex = 0, len = tbody.children.length - 1; childIndex < len; ++childIndex) {
- const hasClass = children.indexOf(childIndex) >= 0
- expect(
- Boolean(tbody.children[childIndex]) &&
- Boolean(tbody.children[childIndex].classList) &&
- tbody.children[childIndex].classList.contains(className)
- ).toBe(hasClass)
+ it('item field formatter as string works', async () => {
+ const Parent = {
+ methods: {
+ formatter(value, key, item) {
+ return item.a + item.b
+ }
}
}
+ const wrapper = mount(Table, {
+ parentComponent: Parent,
+ propsData: {
+ items: [{ a: 1, b: 2 }],
+ fields: [{ key: 'a', formatter: 'formatter' }, 'b']
+ }
+ })
+
+ expect(wrapper).toBeDefined()
+ expect(wrapper.findAll('tbody > tr').length).toBe(1)
+ expect(wrapper.findAll('tbody > tr > td').length).toBe(2)
+ const $tds = wrapper.findAll('tbody > tr > td')
+ expect($tds.at(0).text()).toBe('3')
+ expect($tds.at(1).text()).toBe('2')
+
+ wrapper.destroy()
})
})
diff --git a/src/utils/startcase.spec.js b/src/utils/startcase.spec.js
new file mode 100644
index 00000000000..0c17bdd78c9
--- /dev/null
+++ b/src/utils/startcase.spec.js
@@ -0,0 +1,11 @@
+import startCase from './startcase'
+
+describe('utils/startcase', () => {
+ it('works', async () => {
+ expect(startCase('foobar')).toBe('Foobar')
+ expect(startCase('Foobar')).toBe('Foobar')
+ expect(startCase('foo_bar')).toBe('Foo Bar')
+ expect(startCase('foo bar')).toBe('Foo Bar')
+ expect(startCase('fooBar')).toBe('Foo Bar')
+ })
+})
diff --git a/tests/utils.js b/tests/utils.js
index bd6a436d641..97114b32258 100644
--- a/tests/utils.js
+++ b/tests/utils.js
@@ -1,3 +1,4 @@
+/* istanbul ignore file */
import { readFileSync } from 'fs'
import { resolve } from 'path'
import BootstrapVue from '../src'
@@ -14,7 +15,7 @@ Vue.config.devtools = false
window.Vue = Vue
Vue.use(BootstrapVue)
-export function loadFixture(dirName, name) {
+export function loadFixture(dirName, name) /* istanbul ignore next */ {
const fixtureBase = resolve(dirName, 'fixtures')
const template = readFileSync(resolve(fixtureBase, name + '.html'), 'UTF-8')
const js = readFileSync(resolve(fixtureBase, name + '.js'), 'UTF-8')
@@ -33,25 +34,31 @@ export function loadFixture(dirName, name) {
}
export async function testVM() {
+ /* istanbul ignore next */
it(`vm mounts`, async () => {
return expect(window.app.$el).toBeDefined()
})
}
export function nextTick() {
+ /* istanbul ignore next */
return new Promise((resolve, reject) => {
Vue.nextTick(resolve)
})
}
export async function setData(app, key, value) {
+ /* istanbul ignore next */
app[key] = value
+ /* istanbul ignore next */
await nextTick()
}
// Usage: await sleep(1000);
export function sleep(ms) {
+ /* istanbul ignore next */
ms = ms || 0
+ /* istanbul ignore next */
return new Promise((resolve, reject) => setTimeout(resolve, ms))
}
@@ -76,13 +83,14 @@ const throwIfNotHTMLElement = el => {
}
}
+/* istanbul ignore next */
const throwIfNotArray = array => {
- /* istanbul ignore next */
if (!Array.isArray(array)) {
throw new TypeError(`The matcher requires an array. Given ${typeof array}`)
}
}
+/* istanbul ignore next */
const vmHasClass = (vm, className) => {
throwIfNotVueInstance(vm)
return vm.$el._prevClass.indexOf(className) !== -1
@@ -93,6 +101,7 @@ const vmHasClass = (vm, className) => {
* @param {string} className
* @return {boolean}
*/
+/* istanbul ignore next */
const elHasClass = (el, className) => {
throwIfNotHTMLElement(el)
return el.classList.contains(className)
@@ -103,14 +112,19 @@ const elHasClass = (el, className) => {
* @param {string} className
* @return {boolean}
*/
+/* istanbul ignore next */
const hasClass = (node, className) =>
isVueInstance(node) ? vmHasClass(node, className) : elHasClass(node, className)
+/* istanbul ignore next */
const getVmTag = vm => vm.$options._componentTag
+/* istanbul ignore next */
const getHTMLTag = el => String(el.tagName).toLowerCase()
+/* istanbul ignore next */
const getTagName = node => (isVueInstance(node) ? getVmTag(node) : getHTMLTag(node))
// Extend Jest marchers
+/* istanbul ignore next */
expect.extend({
toHaveClass(node, className) {
/* istanbul ignore next */