diff --git a/src/components/form-select/form-select.spec.js b/src/components/form-select/form-select.spec.js index dcc693582ff..27bd8a9924d 100644 --- a/src/components/form-select/form-select.spec.js +++ b/src/components/form-select/form-select.spec.js @@ -433,6 +433,35 @@ describe('form-select', () => { wrapper.destroy() }) + it('updating v-model (value) when selects correct option', async () => { + const wrapper = mount(Select, { + propsData: { + options: ['one', 'two', { text: 'three', value: { three: 3 } }], + value: 'one' + } + }) + const $options = wrapper.findAll('option') + expect($options.length).toBe(3) + + expect($options.at(0).element.selected).toBe(true) + + // select 2nd option + wrapper.setProps({ + value: 'two' + }) + + expect($options.at(1).element.selected).toBe(true) + + // select 3rd option + wrapper.setProps({ + value: { three: 3 } + }) + + expect($options.at(2).element.selected).toBe(true) + + wrapper.destroy() + }) + it('updates v-model when option selected in single mode with complex values', async () => { const wrapper = mount(Select, { propsData: { diff --git a/src/components/pagination/pagination.spec.js b/src/components/pagination/pagination.spec.js index 4c4f5a2c5c9..092b74f1f25 100644 --- a/src/components/pagination/pagination.spec.js +++ b/src/components/pagination/pagination.spec.js @@ -134,6 +134,35 @@ describe('pagination', () => { wrapper.destroy() }) + it('renders corerct number of elements when total-rows changes', async () => { + const wrapper = mount(Pagination, { + propsData: { + size: 'sm', + totalRows: 1, + perPage: 1, + limit: 10 + } + }) + expect(wrapper.is('ul')).toBe(true) + expect(wrapper.findAll('li').length).toBe(5) + + wrapper.setProps({ + totalRows: 4 + }) + + expect(wrapper.is('ul')).toBe(true) + expect(wrapper.findAll('li').length).toBe(8) + + wrapper.setProps({ + perPage: 2 + }) + + expect(wrapper.is('ul')).toBe(true) + expect(wrapper.findAll('li').length).toBe(6) + + wrapper.destroy() + }) + it('has class "pagination-sm" when prop size="sm"', async () => { const wrapper = mount(Pagination, { propsData: { diff --git a/src/components/table/README.md b/src/components/table/README.md index 47406c46df2..6ed400ea9fe 100644 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -323,14 +323,14 @@ The following field properties are recognized: | Property | Type | Description | | --------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `key` | String | The key for selecting data from the record in the items array. Required when setting the `fields` from as an array of objects. | +| `key` | String | The key for selecting data from the record in the items array. Required when setting the `fields` via an array of objects. | | `label` | String | Appears in the columns table header (and footer if `foot-clone` is set). Defaults to the field's key (in humanized format) if not provided. It's possible to use empty labels by assigning an empty string `""` but be sure you also set `headerTitle` to provide non-sighted users a hint about the column contents. | | `headerTitle` | String | Text to place on the fields header `` attribute `title`. Defaults to no `title` attribute. | | `headerAbbr` | String | Text to place on the fields header `` attribute `abbr`. Set this to the unabbreviated version of the label (or title) if label (or title) is an abbreviation. Defaults to no `abbr` attribute. | | `class` | String or Array | Class name (or array of class names) to add to `` **and** `` in the column. | | `formatter` | String or Function | A formatter callback function, can be used instead of (or in conjunction with) slots for real table fields (i.e. fields, that have corresponding data at items array). Refer to [**Custom Data Rendering**](#custom-data-rendering) for more details. | | `sortable` | Boolean | Enable sorting on this column. Refer to the [**Sorting**](#sorting) Section for more details. | -| `sortDirection` | String | Change sort direction on this column. Refer to the [**Change sort direction**](#change-sort-direction) Section for more details. | +| `sortDirection` | String | Set the initial sort direction on this column when it becomes sorted. Refer to the [**Change initial sort direction**](#Change-initial-sort-direction) Section for more details. | | `tdClass` | String or Array or Function | Class name (or array of class names) to add to `` data `` cells in the column. If custom classes per cell are required, a callback function can be specified instead. | | `thClass` | String or Array | Class name (or array of class names) to add to ``/`` heading `` cell. | | `thStyle` | Object | JavaScript object representing CSS styles you would like to apply to the table ``/`` field ``. | @@ -413,7 +413,7 @@ place a unique `:key` on your element/components in your custom formatted field | `small` | Boolean | To make tables more compact by cutting cell padding in half. | | `hover` | Boolean | To enable a hover highlighting state on table rows within a `` | | `dark` | Boolean | Invert the colors — with light text on dark backgrounds (equivalent to Bootstrap V4 class `.table-dark`) | -| `fixed` | Boolean | Generate a table with equal fixed-width columns (`table-layout: fixed`) | +| `fixed` | Boolean | Generate a table with equal fixed-width columns (`table-layout: fixed;`) | | `foot-clone` | Boolean | Turns on the table footer, and defaults with the same contents a the table header | | `no-footer-sorting` | Boolean | When `foot-clone` is true and the table is sortable, disables the sorting icons and click behaviour on the footer heading cells. Refer to the [**Sorting**](#sorting) section below for more details. | | `responsive` | Boolean or String | Generate a responsive table to make it scroll horizontally. Set to `true` for an always responsive table, or set it to one of the breakpoints `'sm'`, `'md'`, `'lg'`, or `'xl'` to make the table responsive (horizontally scroll) only on screens smaller than the breakpoint. See [**Responsive tables**](#responsive-tables) below for details. | @@ -522,7 +522,7 @@ You can also style every row using the `tbody-tr-class` prop ``` -## Responsive tables +### Responsive tables Responsive tables allow tables to be scrolled horizontally with ease. Make any table responsive across all viewports by setting the prop `responsive` to `true`. Or, pick a maximum breakpoint with @@ -594,7 +594,7 @@ values: `sm`, `md`, `lg`, or `xl`. clips off any content that goes beyond the bottom or top edges of the table. In particular, this may clip off dropdown menus and other third-party widgets. -## Stacked tables +### Stacked tables An alternative to responsive tables, BootstrapVue includes the stacked table option (using custom SCSS/CSS), which allow tables to be rendered in a visually stacked format. Make any table stacked @@ -647,7 +647,78 @@ The prop `stacked` takes precedence over the `responsive` prop. - In an always stacked table, the table header and footer, and the fixed top and bottom row slots will not be rendered. -## Table caption +### Table busy state + +`` provides a `busy` prop that will flag the table as busy, which you can set to `true` +just before you update your items, and then set it to `false` once you have your items. When in the +busy state, the table will have the attribute `aria-busy="true"`. + +During the busy state, the table will be rendered in a "muted" look (`opacity: 0.6`), using the +following custom CSS: + +```css +/* Busy table styling */ +table.b-table[aria-busy='false'] { + opacity: 1; +} +table.b-table[aria-busy='true'] { + opacity: 0.6; +} +``` + +You can override this styling using your own CSS. + +You may optionally provide a `table-busy` slot to show a custom loading message or spinner whenever +the table's busy state is `true`. The slot will be placed in a `` 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 + + + + + +``` + +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. + +### Table caption Add an optional caption to your table via the prop `caption` or the named slot `table-caption` (the slot takes precedence over the prop). The default Bootstrap V4 styling places the caption at the @@ -712,7 +783,7 @@ You can have the caption placed at the top of the table by setting the `caption- You can also use [custom CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/caption-side) to control the caption positioning. -## Table colgroup +### Table colgroup Use the named slot `table-colgroup` to specify `` and `` elements for optional grouping and styling of table columns. Note the styles available via `` elements are limited. @@ -726,77 +797,6 @@ Slot `table-colgroup` can be optionally scoped, receiving an object with the fol | `columns` | Number | The number of columns in the rendered table | | `fields` | Array | Array of field defintion objects (normalized to the array of objects format) | -## Table busy state - -`` provides a `busy` prop that will flag the table as busy, which you can set to `true` -just before you update your items, and then set it to `false` once you have your items. When in the -busy state, the table will have the attribute `aria-busy="true"`. - -During the busy state, the table will be rendered in a "muted" look (`opacity: 0.6`), using the -following custom CSS: - -```css -/* Busy table styling */ -table.b-table[aria-busy='false'] { - opacity: 1; -} -table.b-table[aria-busy='true'] { - opacity: 0.6; -} -``` - -You can override this styling using your own CSS. - -You may optionally provide a `table-busy` slot to show a custom loading message or spinner whenever -the table's busy state is `true`. The slot will be placed in a `` 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 - - - - - -``` - -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

- - - - - - - - - - - - - - - - - - Table Caption - - - - - - -

Paginated Table

-
-
- - - - -
-
- - - -
-
- -
-
- - - - - - - - - -

Dark Table

-
-
- -
-
- - - - - - -

Provider Test Table

-
- - - -
- - - - - - - -
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: '', 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: '', 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 */