├── _config.yml ├── .browserslistrc ├── babel.config.js ├── .eslintignore ├── public ├── favicon.ico └── index.html ├── src ├── assets │ └── css │ │ └── tailwind.css ├── main.js ├── index.js ├── services │ ├── debounce.js │ ├── defaultClasses.js │ └── CSSProcessor.js ├── components │ ├── Cell.vue │ ├── ChildrenButton.vue │ ├── GroupCell.vue │ ├── GroupRow.vue │ ├── Row.vue │ ├── HeaderCell.vue │ ├── Table.vue │ └── TableContainer.vue ├── mixins │ ├── pagination.js │ ├── flatten.js │ ├── styling.js │ ├── slots.js │ ├── cell │ │ ├── sortCell.js │ │ └── cell.js │ ├── exportData.js │ ├── rows.js │ ├── selection.js │ ├── filter.js │ ├── sort.js │ ├── groupBy.js │ ├── async.js │ └── columns.js ├── App.vue ├── AsyncTableApp.vue ├── TableContainerApp.vue └── BasicTableApp.vue ├── tailwind.config.js ├── tests └── unit │ ├── .eslintrc.js │ ├── components │ ├── ChildrenButton.test.js │ ├── Row.test.js │ ├── HeaderCell.test.js │ ├── Cell.test.js │ └── Table.test.js │ └── services │ └── CSSProcessor.test.js ├── .gitignore ├── .eslintrc.js ├── CONTRIBUTING.md ├── postcss.config.js ├── jest.config.js ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | tests/coverage 4 | babel.config.js 5 | tailwind.config.js 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnedesmedt/vue-ads-table-tree/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prefix: 'vue-ads-', 3 | theme: {}, 4 | variants: {}, 5 | plugins: [], 6 | }; 7 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './TableContainerApp'; 3 | 4 | Vue.config.productionTip = false; 5 | 6 | new Vue({ 7 | render: h => h(App), 8 | }).$mount('#app'); 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './assets/css/tailwind.css'; 2 | 3 | import VueAdsTableContainer from './components/TableContainer'; 4 | import VueAdsTable from './components/Table'; 5 | 6 | export default VueAdsTableContainer; 7 | export { VueAdsTable }; 8 | -------------------------------------------------------------------------------- /src/services/debounce.js: -------------------------------------------------------------------------------- 1 | export default (fn, time = 300) => { 2 | // Store active timeout 3 | let timeout; 4 | 5 | return (...args) => { 6 | clearTimeout(timeout); 7 | 8 | // Start a new timeout 9 | timeout = setTimeout(fn.bind(null, ...args), time); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true, 4 | }, 5 | rules: { 6 | 'no-new': 'off', 7 | 'no-unused-expressions': 'off', 8 | 'no-template-curly-in-string': 'off', 9 | 'import/no-extraneous-dependencies': 'off', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | 5 | # test coverage 6 | tests/coverage 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw* 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true, 6 | }, 7 | 8 | extends: [ 9 | 'plugin:vue/essential', 10 | '@vue/standard', 11 | 'eslint-config-ads', 12 | 'eslint-config-ads/vue', 13 | ], 14 | 15 | parserOptions: { 16 | parser: 'babel-eslint', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/Cell.vue: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | 4 | If you would like to contribute to the current project, follow these rules: 5 | 6 | - One pull request per issue (bug, feature, ...). 7 | - Create feature branches. 8 | - Test the changes if possible. Pull requests that doesn't pass the tests, will not be accepted (`npm run test:unit`). 9 | - Update the [README.md](README.md) file if necessary. 10 | - Update the [CHANGELOG.md](CHANGELOG.md) if necessary. 11 | 12 | Do you want to start now? Check the [issues tab](https://gitlab.com/arnedesmedt/vue-ads-table-tree/issues) in gitlab, fork and start coding! 13 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | tailwindcss: './tailwind.config.js', 5 | '@fullhuman/postcss-purgecss': { 6 | content: [ 7 | './src/components/*.vue', 8 | './src/services/*.js', 9 | './node_modules/vue-ads-pagination/dist/vue-ads-pagination.common.js', 10 | './node_modules/vue-ads-form-builder/dist/vue-ads-form-builder.common.js', 11 | ], 12 | whitelistPatterns: [ 13 | /^fa-sort(.*)$/, 14 | /^vue-ads-w-(\d+)\/4$/, 15 | ], 16 | }, 17 | 'postcss-import': {}, 18 | 'postcss-url': {}, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/mixins/pagination.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | start: { 4 | type: Number, 5 | validator: start => { 6 | return start >= 0 || start === null; 7 | }, 8 | }, 9 | 10 | end: { 11 | type: Number, 12 | validator: end => { 13 | return end >= 0 || end === null; 14 | }, 15 | }, 16 | }, 17 | 18 | computed: { 19 | paginatedRows () { 20 | if (this.unresolved || (this.start === null && this.end === null)) { 21 | return this.sortedRows; 22 | } 23 | 24 | return this.sortedRows.slice(this.start, this.end); 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | vue-ads-table-tree 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | 'jsx', 5 | 'json', 6 | 'vue', 7 | ], 8 | transform: { 9 | '^.+\\.vue$': 'vue-jest', 10 | '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', 11 | '^.+\\.jsx?$': 'babel-jest', 12 | }, 13 | moduleNameMapper: { 14 | '^@/(.*)$': '/src/$1', 15 | }, 16 | snapshotSerializers: [ 17 | 'jest-serializer-vue', 18 | ], 19 | testMatch: [ 20 | '/(tests/unit/**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))', 21 | ], 22 | collectCoverage: true, 23 | collectCoverageFrom: [ 24 | 'src/**/*.{js,vue}', 25 | '!src/*.{js,vue}', 26 | '!**/node_modules/**', 27 | ], 28 | coverageDirectory: '/tests/coverage', 29 | }; 30 | -------------------------------------------------------------------------------- /src/mixins/flatten.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | export default { 4 | computed: { 5 | flattenedRows () { 6 | return this.flatten(this.groupedRows); 7 | }, 8 | }, 9 | 10 | methods: { 11 | async toggleChildren (row) { 12 | row._showChildren = !row._showChildren; 13 | 14 | if (!row._hasChildren) { 15 | return; 16 | } 17 | 18 | row._meta.loading = true; 19 | row._children = this.initRows(await this.callChildren(row), row); 20 | Vue.delete(row, '_hasChildren'); 21 | row._meta.loading = false; 22 | }, 23 | 24 | flatten (rows) { 25 | return rows 26 | .reduce((flattenedRows, row) => { 27 | return flattenedRows.concat([ 28 | row, 29 | ...(row && row._showChildren ? this.flatten(row._meta.visibleChildren) : []), 30 | ]); 31 | }, []); 32 | }, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/mixins/styling.js: -------------------------------------------------------------------------------- 1 | import defaultClasses from '../services/defaultClasses'; 2 | import CSSProcessor from '../services/CSSProcessor'; 3 | 4 | export default { 5 | props: { 6 | classes: { 7 | type: Object, 8 | default: () => defaultClasses, 9 | }, 10 | }, 11 | 12 | watch: { 13 | classes: { 14 | handler: 'classesChanged', 15 | }, 16 | }, 17 | 18 | data () { 19 | return { 20 | cssProcessor: new CSSProcessor(this.columns.length, this.classes), 21 | }; 22 | }, 23 | 24 | computed: { 25 | tableClasses () { 26 | return this.classes.table || {}; 27 | }, 28 | 29 | headerRowClasses () { 30 | return this.cssProcessor.process(0); 31 | }, 32 | 33 | infoClasses () { 34 | return this.classes.info || {}; 35 | }, 36 | }, 37 | 38 | methods: { 39 | classesChanged (classes) { 40 | this.cssProcessor.classes = classes; 41 | }, 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Arne De Smedt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/services/defaultClasses.js: -------------------------------------------------------------------------------- 1 | export default { 2 | table: { 3 | 'vue-ads-border': true, 4 | 'vue-ads-w-full': true, 5 | }, 6 | info: { 7 | 'vue-ads-text-center': true, 8 | 'vue-ads-py-6': true, 9 | 'vue-ads-text-sm': true, 10 | 'vue-ads-border-t': true, 11 | }, 12 | group: { 13 | 'vue-ads-font-bold': true, 14 | 'vue-ads-border-b': true, 15 | 'vue-ads-italic': true, 16 | }, 17 | selected: { 18 | 'vue-ads-bg-teal-100': true, 19 | }, 20 | 'all/': { 21 | 'hover:vue-ads-bg-gray-200': true, 22 | }, 23 | 'all/all': { 24 | 'vue-ads-px-4': true, 25 | 'vue-ads-py-2': true, 26 | 'vue-ads-text-sm': true, 27 | }, 28 | 'even/': { 29 | 'vue-ads-bg-gray-100': true, 30 | }, 31 | 'odd/': { 32 | 'vue-ads-bg-white': true, 33 | }, 34 | '0/': { 35 | 'vue-ads-bg-gray-100': false, 36 | 'hover:vue-ads-bg-gray-200': false, 37 | }, 38 | '0/all': { 39 | 'vue-ads-px-4': true, 40 | 'vue-ads-py-2': true, 41 | 'vue-ads-text-left': true, 42 | }, 43 | '0_-1/': { 44 | 'vue-ads-border-b': true, 45 | }, 46 | '/0_-1': { 47 | 'vue-ads-border-r': true, 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/mixins/slots.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | slots: { 4 | type: Object, 5 | default: () => { 6 | return {}; 7 | }, 8 | }, 9 | }, 10 | 11 | computed: { 12 | currentSlots () { 13 | if (Object.keys(this.slots).length === 0) { 14 | return Object.assign(this.$slots, this.$scopedSlots); 15 | } 16 | 17 | return this.slots; 18 | }, 19 | 20 | sortIconSlot () { 21 | return this.currentSlots['sort-icon'] || null; 22 | }, 23 | 24 | toggleChildrenIconSlot () { 25 | return this.currentSlots['toggle-children-icon'] || null; 26 | }, 27 | 28 | rowSlots () { 29 | let regexCell = new RegExp('^(' + this.columnProperties.join('|') + ')_', 'i'); 30 | let regexRow = new RegExp('^_.+$', 'i'); 31 | let slots = {}; 32 | 33 | Object.keys(this.currentSlots) 34 | .forEach(slotKey => { 35 | if (this.columnProperties.includes(slotKey) || regexCell.test(slotKey) || regexRow.test(slotKey)) { 36 | slots[slotKey] = this.currentSlots[slotKey]; 37 | } 38 | }); 39 | 40 | return slots; 41 | }, 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/mixins/cell/sortCell.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | sortIconSlot: { 4 | type: Function, 5 | default: null, 6 | }, 7 | }, 8 | 9 | computed: { 10 | sortable () { 11 | return [ 12 | null, 13 | true, 14 | false, 15 | ].includes(this.column.direction); 16 | }, 17 | 18 | sortIconClasses () { 19 | if (!this.sortable) { 20 | return {}; 21 | } 22 | 23 | return { 24 | fa: true, 25 | 'vue-ads-ml-2': true, 26 | 'fa-sort': this.column.direction === null, 27 | 'fa-sort-down': this.column.direction === false, 28 | 'fa-sort-up': this.column.direction === true, 29 | }; 30 | }, 31 | }, 32 | 33 | methods: { 34 | sortIcon (createElement) { 35 | return this.sortIconSlot ? 36 | this.sortIconSlot({ 37 | direction: this.column.direction, 38 | }) : 39 | createElement( 40 | 'i', 41 | { 42 | class: this.sortIconClasses, 43 | on: { 44 | click: (event) => { 45 | event.stopPropagation(); 46 | this.$emit('sort', this.column); 47 | }, 48 | }, 49 | }, 50 | ); 51 | }, 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /src/mixins/exportData.js: -------------------------------------------------------------------------------- 1 | export default { 2 | computed: { 3 | exportColumns () { 4 | return this.columns.filter(column => column.export); 5 | }, 6 | }, 7 | 8 | methods: { 9 | exportTable (name, full) { 10 | if (!name) { 11 | return; 12 | } 13 | 14 | this.$emit( 15 | 'export', 16 | { 17 | fields: Object.assign({ 18 | '#': '_order', 19 | }, this.exportFields()), 20 | data: this.exportData(full ? this.loadedRows : this.sortedRows, full), 21 | title: name, 22 | } 23 | ); 24 | }, 25 | 26 | exportFields () { 27 | return this.exportColumns 28 | .reduce((result, column) => { 29 | result[column.title] = column.property; 30 | 31 | return result; 32 | }, {}); 33 | }, 34 | 35 | exportData (rows, full, parent = '') { 36 | return rows 37 | .reduce((exportRows, row, index) => { 38 | let order = parent + (index + 1).toString(); 39 | row._order = order + (parent === '' ? '-0' : ''); 40 | return exportRows.concat([ 41 | row, 42 | ...(full ? 43 | (row && row._children ? this.exportData(row._children, full, order + '-') : []) : 44 | (row && row._showChildren ? this.exportData(row._meta.visibleChildren, full, order + '-') : []) 45 | ), 46 | ]); 47 | }, []); 48 | }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/ChildrenButton.vue: -------------------------------------------------------------------------------- 1 | 71 | -------------------------------------------------------------------------------- /src/mixins/rows.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import uuid from 'uuid'; 3 | 4 | export default { 5 | props: { 6 | rows: { 7 | type: Array, 8 | default: () => [], 9 | }, 10 | }, 11 | 12 | watch: { 13 | rows: { 14 | handler: 'rowsChanged', 15 | immediate: true, 16 | }, 17 | }, 18 | 19 | computed: { 20 | loadedRows () { 21 | return this.rows.filter(row => row); 22 | }, 23 | }, 24 | 25 | methods: { 26 | rowsChanged (rows, oldRows, parent) { 27 | this.initRows(rows, parent); 28 | }, 29 | 30 | initRows (rows, parent) { 31 | rows 32 | .forEach((row, index) => this.initRow(row, parent, index)); 33 | rows 34 | .filter(row => row._children.length > 0) 35 | .forEach(row => this.rowsChanged(row._children, null, row)); 36 | 37 | return rows; 38 | }, 39 | 40 | initRow (row, parent, index, groupColumn = null) { 41 | if (!row.hasOwnProperty('_children')) { 42 | Vue.set(row, '_children', []); 43 | } 44 | 45 | if (!row.hasOwnProperty('_showChildren')) { 46 | Vue.set(row, '_showChildren', false); 47 | } 48 | 49 | if (!row.hasOwnProperty('_selectable')) { 50 | let selectable = parent && parent.hasOwnProperty('_selectable') ? parent._selectable : this.selectable; 51 | Vue.set(row, '_selectable', selectable); 52 | } 53 | 54 | if (!row.hasOwnProperty('_meta')) { 55 | Vue.set(row, '_meta', { 56 | groupParent: 0, 57 | parent: parent ? parent._meta.parent + 1 : 0, 58 | uniqueIndex: uuid(), 59 | loading: false, 60 | visibleChildren: row._children, 61 | index, 62 | groupColumn, 63 | selected: false, 64 | }); 65 | } 66 | }, 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /src/mixins/selection.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | selectable: { 4 | type: Boolean, 5 | default: false, 6 | }, 7 | }, 8 | 9 | data () { 10 | return { 11 | firstSelectedRowId: undefined, 12 | }; 13 | }, 14 | 15 | methods: { 16 | clearSelection () { 17 | this.flatten(this.currentRows).forEach(row => row._meta.selected = false); 18 | }, 19 | 20 | selectRows (rows) { 21 | rows.forEach(row => { 22 | if (row._selectable) { 23 | row._meta.selected = true; 24 | } 25 | }); 26 | }, 27 | 28 | selectRow (event, row, key) { 29 | if (! row._selectable) { 30 | return; 31 | } 32 | 33 | if (event.shiftKey) { 34 | let flatten = this.flatten(this.currentRows); 35 | let indexes = [ 36 | row._meta.uniqueIndex, 37 | this.firstSelectedRowIndex, 38 | ]; 39 | let minKey = Object.keys(flatten).find((key) => flatten[key]._meta.uniqueIndex === indexes[0]); 40 | let maxKey = Object.keys(flatten).find((key) => flatten[key]._meta.uniqueIndex === indexes[1]); 41 | let keys = [ 42 | +minKey, 43 | +maxKey, 44 | ]; 45 | [ 46 | minKey, 47 | maxKey, 48 | ] = keys.sort((a, b) => a - b); 49 | 50 | this.clearSelection(); 51 | this.selectRows(flatten.slice(minKey, maxKey + 1)); 52 | } else { 53 | let oldSelected = row._meta.selected; 54 | if (! event.ctrlKey) { 55 | this.clearSelection(); 56 | this.firstSelectedRowIndex = row._meta.uniqueIndex; 57 | } 58 | 59 | row._meta.selected = ! oldSelected; 60 | 61 | } 62 | 63 | this.$emit('selection-change', this.flatten(this.currentRows).filter(row => row._meta.selected)); 64 | }, 65 | }, 66 | }; -------------------------------------------------------------------------------- /src/components/GroupCell.vue: -------------------------------------------------------------------------------- 1 | 84 | -------------------------------------------------------------------------------- /src/mixins/filter.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | filter: { 4 | type: String, 5 | default: '', 6 | }, 7 | }, 8 | 9 | watch: { 10 | filter: { 11 | handler: 'filterChanged', 12 | immediate: true, 13 | }, 14 | }, 15 | 16 | computed: { 17 | isFiltering () { 18 | return this.filter !== '' && this.filterColumnProperties.length > 0; 19 | }, 20 | 21 | filterRegex () { 22 | return new RegExp(this.filter, 'i'); 23 | }, 24 | 25 | filteredCurrentRows () { 26 | return this.unresolved ? this.currentRows.filter(row => row) : this.currentRows; 27 | }, 28 | 29 | filteredRows () { 30 | if (this.unresolved) { 31 | return this.filteredCurrentRows; 32 | } 33 | 34 | // Always execute because of the children filtering. 35 | let filteredRows = Array.from(this.filteredCurrentRows).filter(this.rowMatch); 36 | 37 | if (this.isFiltering) { 38 | return filteredRows; 39 | } 40 | 41 | return this.filteredCurrentRows; 42 | }, 43 | }, 44 | 45 | methods: { 46 | async filterChanged () { 47 | this.clearSelection(); 48 | 49 | this.totalFilteredRowsChanged(this.filteredRows.length); 50 | 51 | if (this.unresolved) { 52 | await this.handleUnresolved(); 53 | } 54 | }, 55 | 56 | totalFilteredRowsChanged (total) { 57 | this.$emit('total-filtered-rows-change', total); 58 | }, 59 | 60 | rowMatch (row) { 61 | if (row === undefined) { 62 | return true; 63 | } 64 | 65 | row._meta.visibleChildren = row._children.filter(this.rowMatch); 66 | 67 | if (!this.isFiltering) { 68 | return true; 69 | } 70 | 71 | if (row._meta.visibleChildren.length > 0) { 72 | row._showChildren = true; 73 | 74 | return true; 75 | } 76 | 77 | return Object.keys(row) 78 | .filter(rowKey => this.filterColumnProperties.includes(rowKey)) 79 | .filter(filterKey => this.filterRegex.test(row[filterKey])) 80 | .length > 0; 81 | }, 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/GroupRow.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 87 | -------------------------------------------------------------------------------- /tests/unit/components/ChildrenButton.test.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import ChildrenButton from '../../../src/components/ChildrenButton'; 3 | 4 | describe('ChildrenButton', () => { 5 | it('show the + sign by default', () => { 6 | const childrenButton = shallowMount(ChildrenButton, { 7 | propsData: { 8 | expanded: false, 9 | loading: false, 10 | }, 11 | }); 12 | 13 | expect(childrenButton.vm.classes['fa-plus-square']).toBeTruthy(); 14 | expect(childrenButton.vm.classes['fa-minus-square']).toBeFalsy(); 15 | }); 16 | 17 | it('shows the - sign if the component is expanded', () => { 18 | const childrenButton = shallowMount(ChildrenButton, { 19 | propsData: { 20 | expanded: true, 21 | loading: false, 22 | }, 23 | }); 24 | 25 | expect(childrenButton.vm.classes['fa-plus-square']).toBeFalsy(); 26 | expect(childrenButton.vm.classes['fa-minus-square']).toBeTruthy(); 27 | }); 28 | 29 | it('shows the loading sign if the component is loading', () => { 30 | const childrenButton = shallowMount(ChildrenButton, { 31 | propsData: { 32 | expanded: false, 33 | loading: true, 34 | }, 35 | }); 36 | 37 | expect(childrenButton.vm.classes['fa-ellipsis-h']).toBeTruthy(); 38 | }); 39 | 40 | it('uses the slot icon if the component is expanded', () => { 41 | const childrenButton = shallowMount(ChildrenButton, { 42 | propsData: { 43 | expanded: true, 44 | loading: false, 45 | iconSlot: props => { 46 | return `Test ${props.expanded ? 'open' : 'closed'}`; 47 | }, 48 | }, 49 | }); 50 | 51 | expect(childrenButton.text()).toBe('Test open'); 52 | }); 53 | 54 | it('uses the slot icon if the component is closed', () => { 55 | const childrenButton = shallowMount(ChildrenButton, { 56 | propsData: { 57 | expanded: false, 58 | loading: false, 59 | iconSlot: props => { 60 | return `Test ${props.expanded ? 'open' : 'closed'}`; 61 | }, 62 | }, 63 | }); 64 | 65 | expect(childrenButton.text()).toBe('Test closed'); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": false, 3 | "name": "vue-ads-table-tree", 4 | "description": "A vue table tree plugin.", 5 | "license": "MIT", 6 | "author": "Arne De Smedt (https://twitter.com/ArneSmedt)", 7 | "homepage": "https://github.com/arnedesmedt/vue-ads-table-tree", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/arnedesmedt/vue-ads-table-tree.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/arnedesmedt/vue-ads-table-tree/issues" 14 | }, 15 | "version": "2.4.2", 16 | "main": "./dist/vue-ads-table-tree.common.js", 17 | "files": [ 18 | "/dist" 19 | ], 20 | "scripts": { 21 | "build": "vue-cli-service build", 22 | "build:bundle": "vue-cli-service build --target lib --name vue-ads-table-tree ./src/index.js", 23 | "lint": "vue-cli-service lint", 24 | "package-lint": "prettier-package-json --write --tab-width=4 ./package.json", 25 | "serve": "vue-cli-service serve", 26 | "test:unit": "vue-cli-service test:unit", 27 | "preversion": "export NODE_ENV=production && npm run lint && npm run package-lint", 28 | "version": "npm run build:bundle", 29 | "postversion": "git push && git push --tags" 30 | }, 31 | "dependencies": { 32 | "@fortawesome/fontawesome-free": "^5.11.2", 33 | "vue": "^2.6.10", 34 | "vue-ads-form-builder": "^0.1.15", 35 | "vue-ads-pagination": "^2.1.7", 36 | "vue-json-excel": "^0.2.98" 37 | }, 38 | "devDependencies": { 39 | "@fullhuman/postcss-purgecss": "^1.3.0", 40 | "@vue/cli-plugin-babel": "^4.0.5", 41 | "@vue/cli-plugin-eslint": "^4.0.5", 42 | "@vue/cli-plugin-unit-jest": "^4.0.5", 43 | "@vue/cli-service": "^4.0.5", 44 | "@vue/eslint-config-standard": "^4.0.0", 45 | "@vue/test-utils": "^1.0.0-beta.29", 46 | "babel-core": "7.0.0-bridge.0", 47 | "babel-eslint": "^10.0.3", 48 | "babel-jest": "^24.9.0", 49 | "eslint": "^4.19.1", 50 | "eslint-config-ads": "^1.0.7", 51 | "eslint-plugin-vue": "^6.0.1", 52 | "postcss-import": "^12.0.1", 53 | "postcss-url": "^8.0.0", 54 | "prettier-package-json": "^2.1.0", 55 | "tailwindcss": "^1.1.3", 56 | "vue-template-compiler": "^2.6.10" 57 | }, 58 | "keywords": [ 59 | "component", 60 | "table", 61 | "tree", 62 | "vue" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/components/Row.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 90 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | #### v2.3.0 - 07/03/2019 4 | 5 | - Reset the sort. The next click after sorting the rows ascending and descending will reset the sorting to 6 | the initial sort. 7 | - Group rows by a specific column value or use a closure that processes the column value first. 8 | 9 | #### v2.1.0 - 15/02/2019 10 | 11 | - Combined the basic table component and the async table component back to one component 12 | - Added an excel export 13 | 14 | #### v2.0.0 - 04/02/2019 15 | 16 | - Split the table tree in 3 components: 17 | - the basic table component 18 | - the async table component 19 | - the full component (with a filter box and a pagination component) 20 | 21 | #### v1.5.0 - 27/01/2019 22 | 23 | - Make it possible to show/hide columns. 24 | 25 | #### v1.4.0 - 27/01/2019 26 | 27 | - Add underscores as a prefix to the meta data of row objects. 28 | - Removed the possibility to don't store async items in the cache. 29 | - Don't create new objects from rows and columns but use them as they were created. 30 | 31 | 32 | #### v1.3.0 - 04/01/2019 33 | 34 | - make the properties of the rows reactive. 35 | 36 | #### v1.2.5 - 18/12/2018 37 | 38 | - Use system ui as the table tree font 39 | - Remove the table title by default because you will not use a title with the value 'title'. 40 | 41 | #### v1.2.4 - 07/12/2018 42 | 43 | - Bugfix on formatting the cell 44 | - Add the collapse/expand icon in a preferred column. 45 | 46 | #### v1.2.3 - 07/12/2018 47 | 48 | - Bugfix if an item was loaded on the zero index. 49 | - Only remove the pagination on filtering and loading. 50 | 51 | #### v1.2.2 - 27/11/2018 52 | 53 | - Small improvements 54 | - Update pagination component 55 | 56 | #### v1.2.1 - 24/11/2018 57 | 58 | - Removed the width property for columns because it's now possible with the styling object. 59 | - Removed the start and end properties used by the pagination template. Now you have to call the pagination pageChange method. 60 | - Seperate config files from the package.json 61 | - Update eslint 62 | 63 | #### v1.2.0 - 23/11/2018 64 | 65 | - Make a template of the filter input field and label. 66 | - Make a template of the pagination component. 67 | - Review the way of styling the table. 68 | - Don't call a method to execute the render of the table, but use computed properties. 69 | 70 | #### v1.1.0 - 23/08/2018 71 | 72 | - A table cell can be overwritten with a custom template. So it's possible to use components in the cell. 73 | - Added a timeout when typing in the filter field, if an async call has to be made. 74 | 75 | #### v1.0.0 - 15/08/2018 76 | 77 | - Initial release. 78 | -------------------------------------------------------------------------------- /src/mixins/sort.js: -------------------------------------------------------------------------------- 1 | export default { 2 | computed: { 3 | sortedRows () { 4 | if (this.unresolved) { 5 | return this.filteredRows; 6 | } 7 | 8 | return this.sortRows(this.filteredRows); 9 | }, 10 | }, 11 | 12 | methods: { 13 | maxSortOrder () { 14 | return this.visibleColumns.reduce((max, column) => { 15 | return max < column.order ? column.order : max; 16 | }, 0); 17 | }, 18 | 19 | sortRows (rows) { 20 | rows.sort((rowA, rowB) => { 21 | return rowA._meta.index - rowB._meta.index; 22 | }); 23 | 24 | this.sortColumns 25 | .forEach(column => { 26 | let direction = column.direction ? 1 : -1; 27 | rows.sort((rowA, rowB) => { 28 | let sortValueA = rowA[column.property]; 29 | let sortValueB = rowB[column.property]; 30 | 31 | if (column.grouped && column.groupBy instanceof Function) { 32 | sortValueA = column.groupBy(sortValueA); 33 | sortValueB = column.groupBy(sortValueB); 34 | } 35 | 36 | if (typeof sortValueA === 'string' && typeof sortValueB === 'string') { 37 | return direction * ('' + sortValueA.localeCompare(sortValueB)); 38 | } 39 | 40 | if (sortValueA < sortValueB) { 41 | return -direction; 42 | } 43 | 44 | if (sortValueA > sortValueB) { 45 | return direction; 46 | } 47 | 48 | return 0; 49 | }); 50 | }); 51 | 52 | rows 53 | .filter(row => row._meta.visibleChildren.length > 0) 54 | .forEach(row => { 55 | row._meta.visibleChildren = this.sortRows(row._meta.visibleChildren); 56 | }); 57 | 58 | return rows; 59 | }, 60 | 61 | async sort (column) { 62 | let wasFalse = column.direction === false; 63 | column.direction = wasFalse && !column.grouped ? null : !column.direction; 64 | if (! column.grouped) { 65 | column.order = this.maxSortOrder() + 1; 66 | } 67 | 68 | if (this.unresolved) { 69 | await this.handleUnresolved(); 70 | } 71 | }, 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /tests/unit/components/Row.test.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | 3 | import Row from '../../../src/components/Row'; 4 | import CSSProcessor from '../../../src/services/CSSProcessor'; 5 | 6 | describe('Row', () => { 7 | let row; 8 | let columnA; 9 | let columnB; 10 | let cssProcessor; 11 | 12 | 13 | beforeEach(() => { 14 | columnA = { 15 | property: 'firstName', 16 | }; 17 | columnB = { 18 | property: 'lastName', 19 | }; 20 | 21 | cssProcessor = new CSSProcessor(2, {}); 22 | cssProcessor.totalRows = 1; 23 | 24 | row = shallowMount(Row, { 25 | propsData: { 26 | row: { 27 | _id: 'arne', 28 | firstName: 'Arne', 29 | lastName: 'De Smedt', 30 | }, 31 | columns: [ 32 | columnA, 33 | columnB, 34 | ], 35 | rowIndex: 0, 36 | cssProcessor, 37 | }, 38 | }); 39 | }); 40 | 41 | it('generates classes via the row index', () => { 42 | cssProcessor = new CSSProcessor(2, { 43 | '1/': { 44 | test: true, 45 | }, 46 | }); 47 | cssProcessor.totalRows = 1; 48 | 49 | row.setProps({ 50 | cssProcessor, 51 | }); 52 | 53 | expect(row.vm.rowClasses).toEqual({ 54 | test: true, 55 | }); 56 | }); 57 | 58 | it('generates classes via the fixed row classes attribute', () => { 59 | row.setProps({ 60 | row: { 61 | firstName: 'Arne', 62 | lastName: 'De Smedt', 63 | _classes: { 64 | row: { 65 | test: true, 66 | }, 67 | }, 68 | }, 69 | }); 70 | 71 | expect(row.vm.rowClasses).toEqual({ 72 | test: true, 73 | }); 74 | }); 75 | 76 | it('selects the correct column slot', () => { 77 | row.setProps({ 78 | slots: { 79 | firstName: 'test', 80 | lastName: 'test2', 81 | }, 82 | }); 83 | 84 | expect(row.vm.columnSlot(columnA)).toBe('test'); 85 | }); 86 | 87 | it('selects the correct cell slot', () => { 88 | row.setProps({ 89 | slots: { 90 | firstName_arne: 'testcell', 91 | firstName: 'test', 92 | }, 93 | }); 94 | 95 | expect(row.vm.columnSlot(columnA)).toBe('testcell'); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/mixins/groupBy.js: -------------------------------------------------------------------------------- 1 | // TODO how to handle grouped data for async data 2 | 3 | export default { 4 | computed: { 5 | groupedRows () { 6 | if (this.paginatedRows.length === 0) { 7 | return this.paginatedRows; 8 | } 9 | 10 | return this.groupingRows(this.paginatedRows, 0); 11 | }, 12 | }, 13 | 14 | methods: { 15 | groupingRows (rows, groupColumnIndex) { 16 | if (groupColumnIndex === this.groupColumns.length) { 17 | return rows; 18 | } 19 | 20 | let column = this.groupColumns[groupColumnIndex]; 21 | 22 | let previousValue = null; 23 | let groups = []; 24 | let groupedRows = []; 25 | let value; 26 | 27 | 28 | rows.forEach(row => { 29 | value = this.rowValue(row, column); 30 | 31 | if (previousValue === null) { 32 | previousValue = value; 33 | } 34 | 35 | if (value !== previousValue) { 36 | groups.push(this.createGroupRow(previousValue, column, groupedRows, groups.length, groupColumnIndex + 1)); 37 | 38 | previousValue = value; 39 | groupedRows = []; 40 | } 41 | 42 | groupedRows.push(row); 43 | }); 44 | 45 | groups.push(this.createGroupRow(value, column, groupedRows, groups.length, groupColumnIndex + 1)); 46 | 47 | if (groupColumnIndex > 0) { 48 | groups.forEach(row => row._meta.groupParent += 1); 49 | } 50 | 51 | return groups; 52 | }, 53 | 54 | rowValue (row, column) { 55 | let value = row[column.property]; 56 | 57 | if (column.groupBy instanceof Function) { 58 | value = column.groupBy(value); 59 | } 60 | 61 | return value; 62 | }, 63 | 64 | createGroupRow (value, column, groupedRows, groupLength, groupColumnIndex) { 65 | groupedRows.forEach(row => row._meta.groupParent = groupColumnIndex); 66 | groupedRows = this.groupingRows(groupedRows, groupColumnIndex); 67 | 68 | let groupRow = { 69 | [column.property]: value, 70 | _children: groupedRows, 71 | _showChildren: true, 72 | }; 73 | 74 | this.initRow(groupRow, 0, groupLength, column); 75 | 76 | return groupRow; 77 | }, 78 | 79 | async group (column) { 80 | column.grouped = !column.grouped; 81 | column.direction = column.grouped ? !column.direction : null; 82 | column.order = this.maxSortOrder() + 1; 83 | 84 | // Todo For now, no async data 85 | // if (this.unresolved) { 86 | // await this.handleUnresolved(); 87 | // } 88 | }, 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/HeaderCell.vue: -------------------------------------------------------------------------------- 1 | 122 | -------------------------------------------------------------------------------- /src/mixins/async.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | export default { 4 | props: { 5 | callRows: { 6 | type: Function, 7 | default: () => [], 8 | }, 9 | 10 | callTempRows: { 11 | type: Function, 12 | default: () => [], 13 | }, 14 | 15 | callChildren: { 16 | type: Function, 17 | default: () => [], 18 | }, 19 | }, 20 | 21 | data () { 22 | return { 23 | tempRows: [], 24 | loading: false, 25 | }; 26 | }, 27 | 28 | watch: { 29 | async indexesToLoad () { 30 | if (this.indexesToLoad.length === 0) { 31 | return; 32 | } 33 | 34 | if (!this.unresolved) { 35 | return await this.loadRows(); 36 | } 37 | 38 | return await this.handleUnresolved(); 39 | }, 40 | }, 41 | 42 | computed: { 43 | allRowsLoaded () { 44 | return this.loadedRows.length === this.rows.length; 45 | }, 46 | 47 | allRowsFullyLoaded () { 48 | return this.allRowsLoaded && !this.rows.find(this.noChildrenLoaded); 49 | }, 50 | 51 | unresolved () { 52 | return ( 53 | (this.isFiltering && !this.allRowsFullyLoaded) || 54 | (this.sortColumns.length > 0 && !this.allRowsLoaded) 55 | ); 56 | }, 57 | 58 | currentRows () { 59 | // this.loading because tempRows is empty while loading 60 | // this will trigger an total-filtered-rows-change of 0 61 | if (!this.unresolved || this.loading) { 62 | return this.rows; 63 | } 64 | 65 | return this.tempRows; 66 | }, 67 | 68 | indexesToLoad () { 69 | let paginatedRows = this.currentRows.slice(this.start, this.end); 70 | 71 | return Array.from(paginatedRows) 72 | .map((row, index) => row === undefined ? index + this.start : undefined) 73 | .filter(index => index); 74 | }, 75 | }, 76 | 77 | methods: { 78 | noChildrenLoaded (row) { 79 | return row.hasOwnProperty('_hasChildren') && row._hasChildren; 80 | }, 81 | 82 | async handleUnresolved () { 83 | this.loading = true; 84 | let result = await this.callTempRows( 85 | this.filter, 86 | this.sortColumns, 87 | this.start, 88 | this.end 89 | ); 90 | 91 | let rows = Array.apply(null, Array(result.total || result.rows.length)); 92 | rows.splice( 93 | this.start, 94 | this.end, 95 | ...result.rows 96 | ); 97 | this.tempRows = rows; 98 | this.totalFilteredRowsChanged(result.total || result.rows.length); 99 | this.loading = false; 100 | }, 101 | 102 | async loadRows () { 103 | this.loading = true; 104 | let rows = this.initRows(await this.callRows(this.indexesToLoad)); 105 | 106 | let key; 107 | for (key in rows) { 108 | this.rows[this.indexesToLoad[key]] = rows[key]; 109 | } 110 | 111 | Vue.set(this.rows, this.indexesToLoad[key], rows[key]); 112 | this.loading = false; 113 | }, 114 | }, 115 | }; 116 | -------------------------------------------------------------------------------- /tests/unit/services/CSSProcessor.test.js: -------------------------------------------------------------------------------- 1 | import CSSProcessor from '../../../src/services/CSSProcessor'; 2 | 3 | describe('ClassProcessor', () => { 4 | let processor; 5 | 6 | beforeEach(() => { 7 | processor = new CSSProcessor(0, {}); 8 | processor.totalRows = 5; 9 | }); 10 | 11 | it('initializes the default params', () => { 12 | expect(processor.classes).toEqual({}); 13 | expect(processor.totalColumns).toBe(0); 14 | }); 15 | 16 | it('styles all rows', () => { 17 | processor.classes = { 18 | 'all/': { 19 | test: true, 20 | }, 21 | }; 22 | 23 | expect(processor.processedClasses[0].rows).toEqual([ 24 | 0, 25 | 1, 26 | 2, 27 | 3, 28 | 4, 29 | ]); 30 | 31 | expect(processor.process(3)).toEqual({ 32 | test: true, 33 | }); 34 | }); 35 | 36 | it('doesn\'t style rows out of range', () => { 37 | processor.classes = { 38 | 'all/': { 39 | test: true, 40 | }, 41 | }; 42 | 43 | expect(processor.process(5)).toEqual({}); 44 | }); 45 | 46 | it('styles odd rows', () => { 47 | processor.classes = { 48 | 'odd/': { 49 | test: true, 50 | }, 51 | }; 52 | 53 | expect(processor.processedClasses[0].rows).toEqual([ 54 | 1, 55 | 3, 56 | ]); 57 | }); 58 | 59 | it('styles even rows', () => { 60 | processor.classes = { 61 | 'even/': { 62 | test: true, 63 | }, 64 | }; 65 | 66 | expect(processor.processedClasses[0].rows).toEqual([ 67 | 0, 68 | 2, 69 | 4, 70 | ]); 71 | }); 72 | 73 | it('doesn\'t style any row if the total rows is not given', () => { 74 | processor = new CSSProcessor(0, { 75 | 'even/': { 76 | test: true, 77 | }, 78 | }); 79 | 80 | expect(processor.processedClasses[0].rows).toEqual([]); 81 | expect(processor.process(0)).toEqual({}); 82 | }); 83 | 84 | it('styles a specific row range', () => { 85 | processor.classes = { 86 | '1_4/': { 87 | test: true, 88 | }, 89 | }; 90 | 91 | expect(processor.processedClasses[0].rows).toEqual([ 92 | 1, 93 | 2, 94 | 3, 95 | ]); 96 | }); 97 | 98 | it('styles a some rows', () => { 99 | processor.classes = { 100 | '1,4/': { 101 | test: true, 102 | }, 103 | }; 104 | 105 | expect(processor.processedClasses[0].rows).toEqual([ 106 | 1, 107 | 4, 108 | ]); 109 | }); 110 | 111 | it('doesn\'t return a range if the start is lower than the end', function () { 112 | processor.classes = { 113 | '3_2/': { 114 | test: true, 115 | }, 116 | }; 117 | 118 | expect(processor.processedClasses[0].rows).toEqual([]); 119 | }); 120 | 121 | it('doesn\'t return a range if the start and/or end are lower than 0', function () { 122 | processor.classes = { 123 | '-1_-2/': { 124 | test: true, 125 | }, 126 | }; 127 | 128 | expect(processor.processedClasses[0].rows).toEqual([]); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/mixins/cell/cell.js: -------------------------------------------------------------------------------- 1 | import VueAdsChildrenButton from '../../components/ChildrenButton'; 2 | import CSSProcessor from '../../services/CSSProcessor'; 3 | 4 | 5 | export default { 6 | components: { 7 | VueAdsChildrenButton, 8 | }, 9 | 10 | props: { 11 | row: { 12 | type: Object, 13 | required: true, 14 | }, 15 | 16 | rowIndex: { 17 | type: Number, 18 | required: true, 19 | }, 20 | 21 | column: { 22 | type: Object, 23 | required: true, 24 | }, 25 | 26 | columnIndex: { 27 | type: Number, 28 | required: true, 29 | }, 30 | 31 | cssProcessor: { 32 | type: CSSProcessor, 33 | required: true, 34 | }, 35 | 36 | columnSlot: { 37 | type: Function, 38 | default: null, 39 | }, 40 | 41 | toggleChildrenIconSlot: { 42 | type: Function, 43 | default: null, 44 | }, 45 | }, 46 | 47 | computed: { 48 | cellClasses () { 49 | return Object.assign( 50 | this.cssProcessor.process(null, this.columnIndex, this.column), 51 | this.cssProcessor.process(this.rowIndex + 1, this.columnIndex, this.row, this.column), 52 | this.cssProcessor.processFixed(this.row._classes, this.columnIndex, this.row, this.column) 53 | ); 54 | }, 55 | 56 | titleClasses () { 57 | return { 58 | 'vue-ads-cursor-pointer': this.hasCollapseIcon, 59 | }; 60 | }, 61 | 62 | style () { 63 | return { 64 | 'padding-left': (1 + (this.parent * 1.5)) + 'rem', 65 | }; 66 | }, 67 | 68 | parent () { 69 | let parent = 0; 70 | 71 | if (this.columnIndex === 0) { 72 | parent += this.row._meta.groupParent; 73 | } 74 | 75 | if (this.column.collapseIcon) { 76 | parent += this.row._meta.parent; 77 | } 78 | 79 | return parent; 80 | }, 81 | 82 | collapsable () { 83 | return this.column.collapseIcon || this.groupCollapsable; 84 | }, 85 | 86 | groupCollapsable () { 87 | return this.column.groupCollapsable && this.row._meta.groupColumn; 88 | }, 89 | 90 | hasCollapseIcon () { 91 | return this.collapsable && 92 | (this.row._meta.visibleChildren.length > 0 || this.row._hasChildren); 93 | }, 94 | 95 | clickEvents () { 96 | return this.hasCollapseIcon ? { 97 | click: this.toggleChildren, 98 | } : {}; 99 | }, 100 | }, 101 | 102 | methods: { 103 | value (createElement) { 104 | let elements = []; 105 | 106 | if (this.hasCollapseIcon) { 107 | elements.push(createElement(VueAdsChildrenButton, { 108 | props: { 109 | expanded: this.row._showChildren || false, 110 | loading: this.row._meta.loading || false, 111 | iconSlot: this.toggleChildrenIconSlot, 112 | }, 113 | nativeOn: this.clickEvents, 114 | })); 115 | } 116 | 117 | if (this.columnSlot) { 118 | elements.push(this.columnSlot({ 119 | row: this.row, 120 | column: this.column, 121 | })); 122 | } else if (this.column.property && this.row.hasOwnProperty(this.column.property)) { 123 | elements.push(this.row[this.column.property]); 124 | } 125 | 126 | return elements.length > 0 ? elements : [ 127 | '', 128 | ]; 129 | }, 130 | 131 | toggleChildren (event) { 132 | event.stopPropagation(); 133 | this.$emit('toggle-children'); 134 | }, 135 | }, 136 | }; 137 | -------------------------------------------------------------------------------- /src/mixins/columns.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | export default { 4 | props: { 5 | columns: { 6 | type: Array, 7 | required: true, 8 | }, 9 | }, 10 | 11 | watch: { 12 | columns: { 13 | handler: 'columnsChanged', 14 | immediate: true, 15 | }, 16 | }, 17 | 18 | computed: { 19 | visibleColumns () { 20 | return this.columns.filter(column => column.visible); 21 | }, 22 | 23 | columnProperties () { 24 | return this.visibleColumns.map(column => column.property); 25 | }, 26 | 27 | sortColumns () { 28 | return this.visibleColumns 29 | .filter(column => column.hasOwnProperty('direction') && column.direction !== null) 30 | .sort((columnA, columnB) => { 31 | if (columnA.grouped !== columnB.grouped) { 32 | return (!columnB.grouped | 0) - (!columnA.grouped | 0); 33 | } 34 | 35 | return columnA.grouped ? (columnB.order - columnA.order) : (columnA.order - columnB.order); 36 | }); 37 | }, 38 | 39 | nonGroupedColumns () { 40 | return this.visibleColumns.filter(column => !column.grouped || !column.hideOnGroup || column.collapseIcon); 41 | }, 42 | 43 | groupColumns () { 44 | return this.visibleColumns 45 | .filter(column => column.groupable && column.grouped) 46 | .sort((columnA, columnB) => columnA.order - columnB.order); 47 | }, 48 | 49 | filterColumnProperties () { 50 | return this.visibleColumns 51 | .filter(column => { 52 | return column.filterable; 53 | }) 54 | .map(column => column.property); 55 | }, 56 | }, 57 | 58 | methods: { 59 | columnsChanged (columns) { 60 | let maxSortOrder = this.maxSortOrder(); 61 | 62 | columns.forEach(column => { 63 | this.initColumn(column, maxSortOrder); 64 | if (column.order === maxSortOrder) { 65 | maxSortOrder++; 66 | } 67 | }); 68 | 69 | if (columns.length > 0) { 70 | if (!columns.find(column => column.collapseIcon)) { 71 | Vue.set(columns[0], 'collapseIcon', true); 72 | } 73 | } 74 | 75 | // todo check to remove this to the styling mixin 76 | this.cssProcessor.totalColumns = this.nonGroupedColumns.length; 77 | }, 78 | 79 | initColumn (column, order) { 80 | if (typeof column.property !== 'string') { 81 | Vue.set(column, 'property', ''); 82 | } 83 | 84 | if (!column.hasOwnProperty('visible')) { 85 | Vue.set(column, 'visible', true); 86 | } 87 | 88 | if (!column.hasOwnProperty('export')) { 89 | Vue.set(column, 'export', true); 90 | } 91 | 92 | if (column.hasOwnProperty('order') || column.hasOwnProperty('direction')) { 93 | if (!Number.isInteger(column.order) || column.order < 0) { 94 | column.order = order; 95 | } 96 | 97 | if (!column.hasOwnProperty('direction')) { 98 | Vue.set(column, 'direction', null); 99 | } 100 | } 101 | 102 | if (!column.hasOwnProperty('groupable')) { 103 | Vue.set( 104 | column, 105 | 'groupable', 106 | ( 107 | column.hasOwnProperty('grouped') || 108 | column.hasOwnProperty('groupBy') || 109 | column.hasOwnProperty('groupCollapsable') || 110 | column.hasOwnProperty('hideOnGroup') 111 | ) 112 | ); 113 | } 114 | 115 | if (column.groupable && !column.hasOwnProperty('grouped')) { 116 | Vue.set(column, 'grouped', false); 117 | } 118 | 119 | if (column.groupable && !column.hasOwnProperty('groupCollapsable')) { 120 | Vue.set(column, 'groupCollapsable', true); 121 | } 122 | 123 | if (column.groupable && !column.hasOwnProperty('hideOnGroup')) { 124 | Vue.set(column, 'hideOnGroup', !(column.groupBy instanceof Function)); 125 | } 126 | }, 127 | }, 128 | }; 129 | -------------------------------------------------------------------------------- /src/services/CSSProcessor.js: -------------------------------------------------------------------------------- 1 | 2 | export default class CSSProcessor { 3 | constructor (totalColumns, classes) { 4 | this._totalColumns = totalColumns; 5 | this._classes = classes; 6 | this.processClasses(); 7 | } 8 | 9 | set classes (classes) { 10 | this._classes = classes; 11 | this.processClasses(); 12 | } 13 | 14 | get classes () { 15 | return this._classes || {}; 16 | } 17 | 18 | set totalRows (totalRows) { 19 | if (this._totalRows !== totalRows) { 20 | this._totalRows = totalRows; 21 | this.processClasses(); 22 | } 23 | } 24 | 25 | get totalRows () { 26 | return this._totalRows || 0; 27 | } 28 | 29 | set totalColumns (totalColumns) { 30 | if (this._totalColumns !== totalColumns) { 31 | this._totalColumns = totalColumns; 32 | this.processClasses(); 33 | } 34 | } 35 | 36 | get totalColumns () { 37 | return this._totalColumns || 0; 38 | } 39 | 40 | get processedClasses () { 41 | return this._processedClasses; 42 | } 43 | 44 | processClasses () { 45 | this._processedClasses = Object.keys(this.classes) 46 | .filter(key => key.includes('/')) 47 | .map(key => { 48 | let type = key.split('/'); 49 | return { 50 | rows: this.toRange(type[0], this.totalRows), 51 | columns: this.toRange(type[1], this.totalColumns), 52 | value: this.classes[key], 53 | }; 54 | }); 55 | } 56 | 57 | toRange (selector, total) { 58 | if (selector === '' || total === 0) { 59 | return []; 60 | } 61 | 62 | switch (selector) { 63 | case 'all': 64 | return Array.from(Array(total).keys()); 65 | case 'even': 66 | return Array.from(Array(total).keys()).filter(item => (item % 2) === 0); 67 | case 'odd': 68 | return Array.from(Array(total).keys()).filter(item => (item % 2) === 1); 69 | } 70 | 71 | return [].concat(...selector.split(',') 72 | .map(selector => selector.trim()) 73 | .map(selector => { 74 | if (selector.includes('_')) { 75 | let range = selector.split('_') 76 | .map((index, key) => { 77 | if (index !== '') { 78 | return index; 79 | } 80 | 81 | return key === 0 ? 0 : total; 82 | }) 83 | .map(index => Number.parseInt(index)) 84 | .map(index => index < 0 ? total + index : index); 85 | 86 | if (range[0] < 0 || range[1] < 0 || range[0] > range[1]) { 87 | return null; 88 | } 89 | 90 | return Array.from(Array(range[1] - range[0]).keys()) 91 | .map(number => number + range[0]); 92 | } 93 | 94 | return Number.parseInt(selector); 95 | }) 96 | .filter(selector => selector !== null)); 97 | } 98 | 99 | process (rowIndex = null, columnIndex = null, ...args) { 100 | return this.processedClasses 101 | .filter(classes => { 102 | return !((rowIndex === null && columnIndex === null) || 103 | (columnIndex === null && classes.columns.length > 0) || 104 | (rowIndex === null && classes.rows.length > 0) || 105 | (columnIndex !== null && !classes.columns.includes(columnIndex)) || 106 | (rowIndex !== null && !classes.rows.includes(rowIndex))); 107 | }) 108 | .map(classes => CSSProcessor.processValue(classes.value, ...args)) 109 | .reduce((result, classes) => Object.assign(result, classes), {}); 110 | } 111 | 112 | static processValue (classes, ...args) { 113 | if (classes instanceof Function) { 114 | return classes(...args); 115 | } 116 | 117 | if (classes) { 118 | return classes; 119 | } 120 | 121 | return {}; 122 | } 123 | 124 | processFixed (classes, columnIndex, ...args) { 125 | if (!classes) { 126 | return {}; 127 | } 128 | 129 | return Object.keys(classes) 130 | .filter(key => key !== 'row') 131 | .filter(key => this.toRange(key, this.totalColumns).includes(columnIndex)) 132 | .map(key => CSSProcessor.processValue(classes[key], ...args)) 133 | .reduce((result, classes) => Object.assign(result, classes), {}); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/Table.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 151 | -------------------------------------------------------------------------------- /tests/unit/components/HeaderCell.test.js: -------------------------------------------------------------------------------- 1 | import {shallowMount} from '@vue/test-utils'; 2 | 3 | import HeaderCell from '../../../src/components/HeaderCell'; 4 | import CSSProcessor from '../../../src/services/CSSProcessor'; 5 | 6 | describe('HeaderCell', () => { 7 | let headerCell; 8 | let cssProcessor; 9 | 10 | beforeEach(() => { 11 | cssProcessor = new CSSProcessor(2, {}); 12 | cssProcessor.totalRows = 1; 13 | 14 | headerCell = shallowMount(HeaderCell, { 15 | propsData: { 16 | title: 'Title', 17 | columnIndex: 0, 18 | cssProcessor, 19 | }, 20 | }); 21 | }); 22 | 23 | it('shows no sort icon if it\'s not sortable', () => { 24 | expect(headerCell.vm.sortIconClasses).toEqual({}); 25 | }); 26 | 27 | it('shows the sort icon if the column is sortable', () => { 28 | headerCell.setProps({ 29 | sortable: true, 30 | direction: null, 31 | }); 32 | 33 | expect(headerCell.vm.sortIconClasses['fa-sort']).toBeTruthy(); 34 | expect(headerCell.vm.sortIconClasses['fa-sort-up']).toBeFalsy(); 35 | expect(headerCell.vm.sortIconClasses['fa-sort-down']).toBeFalsy(); 36 | }); 37 | 38 | it('shows the desc sort icon if the column is desc sorted', () => { 39 | headerCell.setProps({ 40 | sortable: true, 41 | direction: false, 42 | }); 43 | 44 | expect(headerCell.vm.sortIconClasses['fa-sort']).toBeFalsy(); 45 | expect(headerCell.vm.sortIconClasses['fa-sort-up']).toBeFalsy(); 46 | expect(headerCell.vm.sortIconClasses['fa-sort-down']).toBeTruthy(); 47 | }); 48 | 49 | it('shows the asc sort icon if the column is asc sorted', () => { 50 | headerCell.setProps({ 51 | sortable: true, 52 | direction: true, 53 | }); 54 | 55 | expect(headerCell.vm.sortIconClasses['fa-sort']).toBeFalsy(); 56 | expect(headerCell.vm.sortIconClasses['fa-sort-up']).toBeTruthy(); 57 | expect(headerCell.vm.sortIconClasses['fa-sort-down']).toBeFalsy(); 58 | }); 59 | 60 | it('adds a cursor pointer if the column is sortable', () => { 61 | headerCell.setProps({ 62 | sortable: true, 63 | direction: null, 64 | }); 65 | 66 | expect(headerCell.vm.headerClasses).toEqual({ 67 | 'vue-ads-cursor-pointer': true, 68 | }); 69 | }); 70 | 71 | it('doesn\'t add a cursor pointer if the column is not sortable', () => { 72 | headerCell.setProps({ 73 | sortable: false, 74 | }); 75 | 76 | expect(headerCell.vm.headerClasses).toEqual({ 77 | 'vue-ads-cursor-pointer': false, 78 | }); 79 | }); 80 | 81 | it('returns the header classes with a column class', () => { 82 | cssProcessor = new CSSProcessor(2, { 83 | '/0': { 84 | test: true, 85 | }, 86 | }); 87 | cssProcessor.totalRows = 1; 88 | 89 | headerCell.setProps({ 90 | cssProcessor, 91 | }); 92 | 93 | expect(headerCell.vm.headerClasses).toEqual({ 94 | 'vue-ads-cursor-pointer': false, 95 | test: true, 96 | }); 97 | }); 98 | 99 | it('returns the header classes without a column class if they don\'t match', () => { 100 | cssProcessor = new CSSProcessor(2, { 101 | '/1': { 102 | test: true, 103 | }, 104 | }); 105 | cssProcessor.totalRows = 1; 106 | 107 | headerCell.setProps({ 108 | cssProcessor, 109 | }); 110 | 111 | expect(headerCell.vm.headerClasses).toEqual({ 112 | 'vue-ads-cursor-pointer': false, 113 | }); 114 | }); 115 | 116 | it('returns the header classes with a specific row class', () => { 117 | cssProcessor = new CSSProcessor(2, { 118 | '0/all': { 119 | test: true, 120 | }, 121 | }); 122 | cssProcessor.totalRows = 1; 123 | 124 | headerCell.setProps({ 125 | cssProcessor, 126 | }); 127 | 128 | expect(headerCell.vm.headerClasses).toEqual({ 129 | 'vue-ads-cursor-pointer': false, 130 | test: true, 131 | }); 132 | }); 133 | 134 | it('emits a sort event if the header cell is clicked', () => { 135 | headerCell.trigger('click'); 136 | 137 | expect(headerCell.emitted().sort).toBeTruthy(); 138 | }); 139 | 140 | it('uses the toggle children icon slot if direction is null', () => { 141 | headerCell.setProps({ 142 | sortable: true, 143 | direction: null, 144 | sortIconSlot: props => `Test ${props.direction === null ? 'null' : (props.direction ? 'true' : 'false')}`, 145 | }); 146 | 147 | expect(headerCell.text()).toContain('Test null'); 148 | }); 149 | 150 | it('uses the toggle children icon slot if direction is true', () => { 151 | headerCell.setProps({ 152 | sortable: true, 153 | direction: true, 154 | sortIconSlot: props => `Test ${props.direction === null ? 'null' : (props.direction ? 'true' : 'false')}`, 155 | }); 156 | 157 | expect(headerCell.text()).toContain('Test true'); 158 | }); 159 | 160 | it('uses the toggle children icon slot if direction is false', () => { 161 | headerCell.setProps({ 162 | sortable: true, 163 | direction: false, 164 | sortIconSlot: props => `Test ${props.direction === null ? 'null' : (props.direction ? 'true' : 'false')}`, 165 | }); 166 | 167 | expect(headerCell.text()).toContain('Test false'); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 218 | -------------------------------------------------------------------------------- /src/components/TableContainer.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 257 | -------------------------------------------------------------------------------- /tests/unit/components/Cell.test.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | 3 | import Cell from '../../../src/components/Cell'; 4 | import CSSProcessor from '../../../src/services/CSSProcessor'; 5 | 6 | describe('Cell', () => { 7 | let cell; 8 | let row; 9 | let cssProcessor; 10 | let column; 11 | 12 | beforeEach(() => { 13 | row = { 14 | firstName: 'arne', 15 | _meta: { 16 | parent: 0, 17 | visibleChildren: [ 18 | 'test', 19 | ], 20 | }, 21 | }; 22 | 23 | column = { 24 | property: 'firstName', 25 | collapseIcon: true, 26 | }; 27 | 28 | cssProcessor = new CSSProcessor(2, {}); 29 | cssProcessor.totalRows = 1; 30 | 31 | cell = shallowMount(Cell, { 32 | propsData: { 33 | row, 34 | column, 35 | rowIndex: 0, 36 | columnIndex: 0, 37 | cssProcessor, 38 | }, 39 | }); 40 | }); 41 | 42 | it('returns the default cell classes', () => { 43 | expect(cell.vm.cellClasses).toEqual({ 44 | 'vue-ads-px-4': true, 45 | 'vue-ads-py-2': true, 46 | 'vue-ads-text-sm': true, 47 | }); 48 | }); 49 | 50 | it('returns the cell classes with a specific column class', () => { 51 | cssProcessor = new CSSProcessor(2, { 52 | '/0': { 53 | test: true, 54 | }, 55 | }); 56 | cssProcessor.totalRows = 1; 57 | 58 | cell.setProps({ 59 | cssProcessor, 60 | }); 61 | 62 | expect(cell.vm.cellClasses).toEqual({ 63 | 'vue-ads-px-4': true, 64 | 'vue-ads-py-2': true, 65 | 'vue-ads-text-sm': true, 66 | test: true, 67 | }); 68 | }); 69 | 70 | it('only returns the default classes if the column class doesn\'t match', () => { 71 | cssProcessor = new CSSProcessor(2, { 72 | '/1': { 73 | test: true, 74 | }, 75 | }); 76 | cssProcessor.totalRows = 1; 77 | 78 | cell.setProps({ 79 | cssProcessor, 80 | }); 81 | 82 | expect(cell.vm.cellClasses).toEqual({ 83 | 'vue-ads-px-4': true, 84 | 'vue-ads-py-2': true, 85 | 'vue-ads-text-sm': true, 86 | }); 87 | }); 88 | 89 | it('returns the cell classes with a specific cell class', () => { 90 | cssProcessor = new CSSProcessor(2, { 91 | '1/0': { 92 | test: true, 93 | }, 94 | }); 95 | cssProcessor.totalRows = 1; 96 | 97 | cell.setProps({ 98 | cssProcessor, 99 | }); 100 | 101 | expect(cell.vm.cellClasses).toEqual({ 102 | 'vue-ads-px-4': true, 103 | 'vue-ads-py-2': true, 104 | 'vue-ads-text-sm': true, 105 | test: true, 106 | }); 107 | }); 108 | 109 | it('only returns the default classes if the cell class doesn\'t match', () => { 110 | cssProcessor = new CSSProcessor(2, { 111 | '1/1': { 112 | test: true, 113 | }, 114 | }); 115 | cssProcessor.totalRows = 1; 116 | 117 | cell.setProps({ 118 | cssProcessor, 119 | }); 120 | 121 | expect(cell.vm.cellClasses).toEqual({ 122 | 'vue-ads-px-4': true, 123 | 'vue-ads-py-2': true, 124 | 'vue-ads-text-sm': true, 125 | }); 126 | }); 127 | 128 | it('returns the cell classes with fixed row classes', () => { 129 | cell.setProps({ 130 | row: { 131 | _classes: { 132 | 0: { 133 | test: true, 134 | }, 135 | }, 136 | firstName: 'arne', 137 | _meta: { 138 | parent: 0, 139 | visibleChildren: [ 140 | 'test', 141 | ], 142 | }, 143 | }, 144 | }); 145 | 146 | expect(cell.vm.cellClasses).toEqual({ 147 | 'vue-ads-px-4': true, 148 | 'vue-ads-py-2': true, 149 | 'vue-ads-text-sm': true, 150 | test: true, 151 | }); 152 | }); 153 | 154 | it('only returns the default cell classes if the column doesn\'t match the fixed row classes', () => { 155 | cell.setProps({ 156 | row: { 157 | _classes: { 158 | 1: { 159 | test: true, 160 | }, 161 | }, 162 | firstName: 'arne', 163 | _meta: { 164 | parent: 0, 165 | visibleChildren: [ 166 | 'test', 167 | ], 168 | }, 169 | }, 170 | }); 171 | 172 | expect(cell.vm.cellClasses).toEqual({ 173 | 'vue-ads-px-4': true, 174 | 'vue-ads-py-2': true, 175 | 'vue-ads-text-sm': true, 176 | }); 177 | }); 178 | 179 | it('changes the padding if the number of parents changes', () => { 180 | cell.setProps({ 181 | row: { 182 | firstName: 'arne', 183 | _hasChildren: false, 184 | _meta: { 185 | parent: 1, 186 | visibleChildren: [], 187 | }, 188 | }, 189 | column: { 190 | collapseIcon: true, 191 | }, 192 | }); 193 | 194 | expect(cell.vm.style['padding-left']).toBe('2.5rem'); 195 | }); 196 | 197 | it('doesn\'t add padding if the cell has no collapse icon', () => { 198 | cell.setProps({ 199 | row: { 200 | firstName: 'arne', 201 | _meta: { 202 | parent: 1, 203 | visibleChildren: [ 204 | 'test', 205 | ], 206 | }, 207 | }, 208 | column: { 209 | collapseIcon: false, 210 | }, 211 | }); 212 | 213 | expect(cell.vm.style['padding-left']).toBe('1rem'); 214 | }); 215 | 216 | it('adds a toggle children button if the row has children and the column owns the button', () => { 217 | cell.setProps({ 218 | row: { 219 | firstName: 'arne', 220 | _hasChildren: true, 221 | _meta: { 222 | parent: 0, 223 | visibleChildren: [], 224 | }, 225 | }, 226 | column: { 227 | property: 'firstName', 228 | collapseIcon: true, 229 | }, 230 | }); 231 | 232 | expect(cell.html()).toContain(''); 233 | }); 234 | 235 | it('creates a column slot', () => { 236 | cell.setProps({ 237 | columnSlot: props => { 238 | return `Test: ${props.row.firstName}`; 239 | }, 240 | }); 241 | 242 | expect(cell.text()).toBe('Test: arne'); 243 | }); 244 | 245 | it('is empty with no matching properties', () => { 246 | cell.setProps({ 247 | row: { 248 | lastName: 'de smedt', 249 | _meta: { 250 | parent: 0, 251 | visibleChildren: [ 252 | 'test', 253 | ], 254 | }, 255 | }, 256 | }); 257 | 258 | expect(cell.text()).toBe(''); 259 | }); 260 | 261 | it('emits toggle children, when clicking the toggle children button', () => { 262 | cell.find('span').trigger('click'); 263 | 264 | expect(cell.emitted().toggleChildren).toBeTruthy(); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /src/AsyncTableApp.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 271 | -------------------------------------------------------------------------------- /tests/unit/components/Table.test.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | 3 | import Table from '../../../src/components/Table'; 4 | 5 | describe('Table', () => { 6 | let table; 7 | let rows; 8 | let columns; 9 | let columnA; 10 | let columnB; 11 | let columnC; 12 | 13 | beforeEach(() => { 14 | rows = [ 15 | { 16 | firstName: 'Arne', 17 | lastName: 'De Smedt', 18 | age: 27, 19 | }, 20 | { 21 | firstName: 'Bruno', 22 | lastName: 'Vandenhende', 23 | age: 27, 24 | }, 25 | { 26 | firstName: 'Tanja', 27 | lastName: 'Vandersyp', 28 | age: 25, 29 | }, 30 | { 31 | firstName: 'Kris', 32 | lastName: 'Dejagere', 33 | age: 34, 34 | }, 35 | ]; 36 | 37 | columnA = { 38 | property: 'firstName', 39 | title: 'First Name', 40 | }; 41 | 42 | columnB = { 43 | property: 'lastName', 44 | title: 'Last Name', 45 | }; 46 | 47 | columnC = { 48 | property: 'age', 49 | title: 'Age', 50 | }; 51 | 52 | columns = [ 53 | columnA, 54 | columnB, 55 | columnC, 56 | ]; 57 | 58 | table = shallowMount(Table, { 59 | propsData: { 60 | rows, 61 | columns, 62 | start: 0, 63 | end: 10, 64 | }, 65 | }); 66 | }); 67 | 68 | it('adds the table classes', () => { 69 | table.setProps({ 70 | classes: { 71 | table: { 72 | test: true, 73 | }, 74 | }, 75 | }); 76 | 77 | expect(table.vm.tableClasses).toEqual({ 78 | test: true, 79 | }); 80 | expect(table.vm.infoClasses).toEqual({}); 81 | }); 82 | 83 | it('adds the header row classes', () => { 84 | table.setProps({ 85 | classes: { 86 | '0/': { 87 | test: true, 88 | }, 89 | }, 90 | }); 91 | 92 | expect(table.vm.headerRowClasses).toEqual({ 93 | test: true, 94 | }); 95 | }); 96 | 97 | it('adds the info classes', () => { 98 | table.setProps({ 99 | classes: { 100 | info: { 101 | test: true, 102 | }, 103 | }, 104 | }); 105 | 106 | expect(table.vm.infoClasses).toEqual({ 107 | test: true, 108 | }); 109 | }); 110 | 111 | it('has no results if the rows property is not set', () => { 112 | table = shallowMount(Table, { 113 | propsData: { 114 | columns, 115 | start: 0, 116 | end: 10, 117 | }, 118 | }); 119 | 120 | expect(table.vm.totalVisibleRows).toBe(0); 121 | }); 122 | 123 | it('info is visible if loading is true', () => { 124 | table.setProps({ 125 | loading: true, 126 | }); 127 | 128 | expect(table.vm.infoVisible).toBeTruthy(); 129 | }); 130 | 131 | it('selects slots of the visible columns', () => { 132 | table = shallowMount(Table, { 133 | propsData: { 134 | rows, 135 | columns, 136 | start: 0, 137 | end: 10, 138 | }, 139 | scopedSlots: { 140 | firstName: 'test - {{ props.row.firstName}}', 141 | }, 142 | }); 143 | 144 | expect(Object.keys(table.vm.rowSlots)).toEqual([ 145 | 'firstName', 146 | ]); 147 | }); 148 | 149 | it('doesn\'t select slots of the invisible columns', () => { 150 | columnA.visible = false; 151 | table = shallowMount(Table, { 152 | propsData: { 153 | rows, 154 | columns, 155 | start: 0, 156 | end: 10, 157 | }, 158 | scopedSlots: { 159 | firstName: 'test - {{ props.row.firstName}}', 160 | lastName: 'test - {{ props.row.firstName}}', 161 | }, 162 | }); 163 | 164 | expect(Object.keys(table.vm.rowSlots)).toEqual([ 165 | 'lastName', 166 | ]); 167 | }); 168 | 169 | it('shows only the visible columns', () => { 170 | columnB.visible = false; 171 | 172 | table = shallowMount(Table, { 173 | propsData: { 174 | rows, 175 | columns, 176 | }, 177 | }); 178 | 179 | expect(table.vm.visibleColumns.map(column => column.property)).toEqual([ 180 | 'firstName', 181 | 'age', 182 | ]); 183 | }); 184 | 185 | it('shows all rows without children or pagination', () => { 186 | expect(table.vm.flattenedRows).toHaveLength(4); 187 | expect(table.vm.flattenedRows.map(row => row.firstName)).toEqual([ 188 | 'Arne', 189 | 'Bruno', 190 | 'Tanja', 191 | 'Kris', 192 | ]); 193 | }); 194 | 195 | it('shows just the first 2 rows if the total rows per page is 2', () => { 196 | table.setProps({ 197 | start: 0, 198 | end: 2, 199 | }); 200 | 201 | expect(table.vm.flattenedRows).toHaveLength(2); 202 | expect(table.vm.flattenedRows.map(row => row.firstName)).toEqual([ 203 | 'Arne', 204 | 'Bruno', 205 | ]); 206 | }); 207 | 208 | it('sorts the first column by desc', () => { 209 | columnA.direction = false; 210 | 211 | table = shallowMount(Table, { 212 | propsData: { 213 | rows, 214 | columns, 215 | }, 216 | }); 217 | 218 | expect(table.vm.flattenedRows.map(row => row.firstName)).toEqual([ 219 | 'Tanja', 220 | 'Kris', 221 | 'Bruno', 222 | 'Arne', 223 | ]); 224 | }); 225 | 226 | it('sorts non string types', () => { 227 | columnC.direction = false; 228 | 229 | table = shallowMount(Table, { 230 | propsData: { 231 | rows, 232 | columns, 233 | }, 234 | }); 235 | 236 | expect(table.vm.flattenedRows.map(row => row.age)).toEqual([ 237 | 34, 238 | 27, 239 | 27, 240 | 25, 241 | ]); 242 | }); 243 | 244 | it('sorts child rows', () => { 245 | columnA.direction = true; 246 | 247 | rows[0]._showChildren = true; 248 | rows[0]._children = [ 249 | { 250 | firstName: 'Bruno', 251 | lastName: 'Vandenhende', 252 | age: 25, 253 | }, 254 | { 255 | firstName: 'Arne', 256 | lastName: 'Vanleem', 257 | age: 26, 258 | }, 259 | ]; 260 | 261 | table = shallowMount(Table, { 262 | propsData: { 263 | rows, 264 | columns, 265 | }, 266 | }); 267 | 268 | expect(table.vm.flattenedRows.map(row => row.firstName)).toEqual([ 269 | 'Arne', 270 | 'Arne', 271 | 'Bruno', 272 | 'Bruno', 273 | 'Kris', 274 | 'Tanja', 275 | ]); 276 | }); 277 | 278 | it('filters the table based on the first column', () => { 279 | columnA.filterable = true; 280 | 281 | table = shallowMount(Table, { 282 | propsData: { 283 | rows, 284 | columns, 285 | filter: 'n', 286 | }, 287 | }); 288 | 289 | expect(table.vm.flattenedRows.map(row => row.firstName)).toEqual([ 290 | 'Arne', 291 | 'Bruno', 292 | 'Tanja', 293 | ]); 294 | }); 295 | 296 | it('filters child and parent if the parent doesn\'t match but only the child', () => { 297 | columnA.filterable = true; 298 | rows[0]._children = [ 299 | { 300 | firstName: 'Bruno', 301 | lastName: 'Vandenhende', 302 | age: 25, 303 | }, 304 | ]; 305 | 306 | table = shallowMount(Table, { 307 | propsData: { 308 | rows, 309 | columns, 310 | filter: 'un', 311 | }, 312 | }); 313 | 314 | expect(table.vm.flattenedRows.map(row => row.firstName)).toEqual([ 315 | 'Arne', 316 | 'Bruno', 317 | 'Bruno', 318 | ]); 319 | }); 320 | 321 | it('initializes columns without a property', () => { 322 | columns = [ 323 | { 324 | title: 'Empty', 325 | }, 326 | columnA, 327 | columnB, 328 | ]; 329 | 330 | table = shallowMount(Table, { 331 | propsData: { 332 | rows, 333 | columns, 334 | }, 335 | }); 336 | 337 | expect(columns[0].property).toBe(''); 338 | }); 339 | 340 | it('initializes columns with an order but without a direction', () => { 341 | columnA.order = 1; 342 | 343 | table = shallowMount(Table, { 344 | propsData: { 345 | rows, 346 | columns, 347 | }, 348 | }); 349 | 350 | expect(columns[0].direction).toBeNull(); 351 | }); 352 | 353 | it('sorts a column and emits a sort event', () => { 354 | columnA.direction = null; 355 | 356 | table = shallowMount(Table, { 357 | propsData: { 358 | rows, 359 | columns, 360 | }, 361 | }); 362 | 363 | table.vm.sort(columnA); 364 | 365 | expect(table.emitted().sort).toBeTruthy(); 366 | expect(columnA.direction).toBeTruthy(); 367 | expect(columnA.order).toBe(1); 368 | }); 369 | 370 | it('toggles the children', () => { 371 | rows[0]._showChildren = false; 372 | 373 | table = shallowMount(Table, { 374 | propsData: { 375 | rows, 376 | columns, 377 | }, 378 | }); 379 | 380 | table.vm.toggleChildren(rows[0]); 381 | 382 | expect(table.emitted()['toggle-children']).toBeTruthy(); 383 | expect(rows[0]._showChildren).toBeTruthy(); 384 | }); 385 | 386 | it('temp rows are not filtered sorted or paginated', () => { 387 | columnA.direction = false; 388 | table = shallowMount(Table, { 389 | propsData: { 390 | rows, 391 | columns, 392 | filter: 'testje', 393 | temp: true, 394 | start: 0, 395 | end: 1, 396 | }, 397 | }); 398 | 399 | expect(table.vm.flattenedRows.map(row => row.firstName)).toEqual([ 400 | 'Arne', 401 | 'Bruno', 402 | 'Tanja', 403 | 'Kris', 404 | ]); 405 | }); 406 | 407 | it('doesn\'t filter rows with holes', () => { 408 | rows.length = 100; 409 | table = shallowMount(Table, { 410 | propsData: { 411 | rows, 412 | columns, 413 | }, 414 | }); 415 | 416 | expect(table.vm.filteredRows.length).toBe(100); 417 | }); 418 | }); 419 | -------------------------------------------------------------------------------- /src/TableContainerApp.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 418 | -------------------------------------------------------------------------------- /src/BasicTableApp.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 599 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-ads-table-tree 2 | 3 | Vue ads table tree is a vue js table component, with a tree functionality. 4 | Here is a list of all the features of this table: 5 | 6 | - Filter rows based on the row values from specific columns or all columns. 7 | - Sort rows on one or multiple columns. The sortable columns are configurable. 8 | - Paginate the rows. 9 | - Group rows on one or multiple columns. You can group by value or by a custom range 10 | (therefore you have to use a custom callback) 11 | - Create a tree structure: Parent rows can have child rows, which in turn can have their own children, ... 12 | Child rows can be collapsed and expanded. 13 | - Not all rows has to be loaded on initialization. You can do async calls to load a range of parent rows or 14 | all the children of one parent row. 15 | - Select rows as in file manager. 16 | - Export all your table data, or the current state, to excel. 17 | - A lot of templates are used, so you can use custom components in rows, columns and or cells. 18 | All icons can also be customized. Be aware that the icon library [FontAwesome](https://fontawesome.com/) is not imported by the library. 19 | You will have to import it by yourself. 20 | - The layout of the table can fully customized. All rows, columns, cells could have their own layout. 21 | You can even apply a layout for one specific row that will stay fixed after sorting. 22 | - The columns and rows are reactive. The only restriction is that you can't add child rows 23 | after you've added them initial or via the async child loader. 24 | 25 | You can use 2 components: 26 | - The full component (VueAdsTableTree). It provides the table with all side input components. 27 | - The table component (VueAdsTable): It has no external input components, like a filter or paginator, 28 | but you can write your own container around it that suits your own needs. 29 | All functionality of the full component is available in the table component. 30 | 31 | ## Demo 32 | 33 | I've written a demo in [JSFiddle](https://jsfiddle.net/arnedesmedt/7my8L42q) 34 | 35 | ## Installation 36 | 37 | You can install the package via npm or yarn. 38 | 39 | #### NPM 40 | 41 | ```npm install vue-ads-table-tree --save``` 42 | 43 | #### YARN 44 | 45 | ```yarn add vue-ads-table-tree``` 46 | 47 | ## General objects for the components 48 | 49 | ### Columns 50 | Columns are listed in a column array as plain objects and can have the following arguments: 51 | 52 | - `property`: *(type: string, default: `''`)* The corresponding value will be shown in the column for the given row property. 53 | - `title`: *(type: string, default: `''`)* The title that will be shown in the header. 54 | - `filterable`: *(type: boolean, default: `false`)* Is this column filterable? 55 | - `visible`: *(type: boolean, default: `true`)* Is the column visible? Non-visible columns will not interact. 56 | That means if your column is filterable/sortable but not visible it won't filter or sort on it. 57 | - `direction`: *(type: boolean or null, default: `null`)* The initial sort direction. If null, the rows are not sorted by this column. 58 | If true, the sorting is ascending. If false, the sorting is descending. If all columns are null the rows will be 59 | sorted based on their `_order` value. 60 | - `order`: *(type: number)* Column order to sort the rows. 61 | - `collapseIcon`: *(type: boolean, default: `false`)* Indicates if this column will contain the collapse icon. 62 | - `groupable`: *(type: boolean, default: `false`)* Indicates if this column can be used to group rows. 63 | - `grouped`: *(type: boolean, default: `false`)* Group the rows by this column. 64 | - `groupCollapsable`: *(type: boolean, default: `true`)* Can the subrows of the groups be expanded and collapsed. 65 | - `hideOnGroup`: *(type: boolean, default: `true` if no `groupedBy` property is set)* Hide the column if the rows are grouped by this column. 66 | - `groupBy`: *(type: Function)* This function convert the cell value to another value. The row will be grouped by the returned value. 67 | It has one parameter: 68 | - `value`: *(type: mixed)* The default cell value. 69 | 70 | ### Rows 71 | Rows are listed in a row array as plain objects. Each object contains the row data and meta data. Meta data is prefixed with a `_`: 72 | - `_id`: *(type: string, default: `''`)* An identifier for this row. This is used to create slots for certain cells on the row. 73 | - `_children`: *(type: array, default: `[]`)* An array with plain child objects. The structure is the same as the parent list of rows. 74 | - `_hasChildren`: *(type: boolean, default: `false`)* This is usefull if you want to indicate that the row has children, but you will load them later via an async call. 75 | - `_showChildren`: *(type: boolean, default: `false`)* Indicates if the children has to be shown on initialization. 76 | If this is true, and `_hasChildren` is true, but no `_children` attribute is found, an async call will be initiated to call the children. 77 | - `_selectable`: *(type: boolean, default: The parent `_selectable` property or if no parent the `selectable` property of the table)* 78 | Mark if this row is selectable or not. 79 | - `_classes`: *(type: Object, default: `{}`)* Contains styling for the current row. It contains the following attributes: 80 | - `row`: *(type: Object)* A vue based object for adding classes. 81 | - ``: *(type: Object)* A vue based object for adding classes. The column selector works as explained in [styling](#styling), 82 | without the use of slashes to distinguish columns and rows. 83 | 84 | ### Styling 85 | All styling is done via a plain object. It contains a selector for a row/column/cell as key and a vue based object that contains classes. 86 | The latter ones can override the earlier ones: 87 | - The key is a selector. You have two type of selectors: fixed selectors and row/column/cell selectors: 88 | - fixed selectors: 89 | - `table`: Style the whole table. 90 | - `info`: Style the info row (shown while loading or no rows are found). 91 | - `group`: Style the group row. 92 | - `selected`: Style rows selected by end users. 93 | - row/column/cell selectors: These selectors are divided by a slash. So you can have a row selector and a column selector. 94 | The header row has index 0. The first data row has index 1. Some examples: 95 | - 'all': select all rows/columns. 96 | - 'even': select all even rows/columns. 97 | - 'odd': select all odd rows/columns. 98 | - '3': select row/column with index 3, 99 | - '5_7': select row/column 5 and 6, 100 | - '0_-1': select all rows/columns except the last one. 101 | - '1_': select all rows/columns except the first one. 102 | - '_4': select rows/columns 0,1,2,3. 103 | - '1_4,5_8': select rows/columns 1,2,3,5,6,7 104 | - The value is a vue based class object or a callable function that generates that vue based class object, depending on some given parameters. 105 | Those parameters depends on the key (the selector of rows, columns or rows and columns => cells). 106 | - Note that there is a difference between `1_3/all` and `1_3/`. The first will add the classes to all the cells of row 1 and 2. 107 | The latter will add the classes on the `` tags of row 1 and 2. 108 | 109 | #### Examples 110 | - `'0_-1/': {'test-row': true}` => will add the test class for all rows except the last one for all columns. 111 | - `'/1_3,5': {'test-column': true}` => will add the test-column class for column 1,2,5 on all rows. 112 | - `'even/1': {'cell': true}` => will add the cell class for column 1 on all even rows. 113 | - `'1_/3': (row, column) => {...}` => a callable function that returns a vue based class object, depending on the parameters row and column. 114 | 115 | ## Table component 116 | 117 | If you just need a normal table that you can customize fully by yourself, you can use the table component. It won't provide you a paginator, 118 | filter textbox, ... It's just a Vue component and all interaction happens via the properties or event handlers. 119 | 120 | If you want to use the table in a predefined container with a paginator, filter textbox see the [full component section](#fullComponent) 121 | 122 | ### Async features 123 | 124 | It is also possible to use this component without all data is already loaded. Therefore you will have to use the 125 | `call-` properties that will hold a callback function to load some additional data asynchronously. 126 | 127 | #### Load your root rows asynchronously 128 | 129 | In previous versions of the table tree, you needed to pass the total rows as a property. Now, you don't need 130 | that anymore. Just change the length of the rows array. For example you already have 3 root rows in your JSON array, 131 | but you want to load another 3 root rows asynchronously, well then set the length of your rows property to 6: `this.rows.length = 6`. 132 | Don't forget to add the `call-rows` property to your component. See [properties](#table_properties) 133 | 134 | #### Load your child rows asynchronously 135 | 136 | If you want to load your child rows later, just add the `_hasChildren` attribute to your row with `true` as value. 137 | It will add a toggle children icon and when you click it, the children will be loaded. 138 | Don't forget to add the `call-children` property to your component. See [properties](#table_properties) 139 | 140 | #### Sort or filter on a not fully loaded table 141 | 142 | If you're sorting/filtering while not all root/child rows are loaded. There is a small problem. We don't know if your sort 143 | or filter result will be the right one, because not all the data is loaded. 144 | Therefore the component will call the `call-temp-rows` function you need to pass via the property. See [properties](#table_properties) 145 | 146 | Be aware that the result of the function call is send to the table without any additional filtering, sorting or pagination. 147 | The rows will only expand if needed. You can see it as you pass temporarily rows to the table 148 | 149 | ### Properties 150 | 151 | - `columns`: *(type: array, required)* see [columns](#columns) 152 | - `rows`: *(type: array, default: `[]`)* see [rows](#rows) 153 | - `classes`: *(type: object, default: see [file](https://github.com/arnedesmedt/vue-ads-table-tree/blob/develop/src/services/defaultClasses.js))* see [styling](#styling) 154 | - `filter`: *(type: string, default: `''`)* Filter all row values on by this regex. Beware that if you change the filter, 155 | the total number of rows will decrease. So it's wisely to set the start index on 0 after you change the filter value. 156 | Then you will be able to properly display all the filtered rows. 157 | - `start`: *(type: number, default: `undefined`)* The start index to show only a slice of the rows. 158 | - `end`: *(type: number, default: `undefined`)* The end index to show only a slice of the rows. 159 | - `selectable`: *(type: boolean, default: true)* Enable/disable row selection. 160 | - `slots`: *(type: Object, default: {})* A list of slots that are passed from parent components. 161 | If this object doesn't contain any attributes, the default component slots will be used. 162 | - `export-name`: *(type: string, default: `''`)* The name of the export file to download. This is by default an empty string. 163 | If you change the name an export will be triggered. After the export happened, you can change the name back to an empty string. 164 | - `full-export`: *(type: boolean, default: true)* If this is true, all the known data of the table will be downloaded. 165 | If this is false, only the filtered and sorted data will be downloaded. 166 | - `call-rows`: *(type: Function, default: `() => []`)* This function will be called if additional root rows needs to be loaded. 167 | It will give you only one parameter: 168 | - `indexes`: *(type: array)* A list of indexes in the rows array you need to load. 169 | If you're sure all the items needs to be loaded, just take the first and last one and use them as a paramter. 170 | But in some cases, some rows will already been loaded. That's the reason why a list is taken instead of 171 | the first and last one. 172 | - `call-children`: *(type: Function, default: `() => []`)* This function will be called if child rows needs to be loaded. 173 | All child rows has to be loaded at once. There is no possibility to load a part of it later.It will give you only one parameter: 174 | - `parent`: *(type: Object)* The parent row that needs children. 175 | - `call-temp-rows`: *(type: Function, default: `() => []`)* The function will be called if the table is sorted and not all 176 | root rows are loaded or the table is filtered and not all root rows or child rows are loaded. The following parameters are passed to the function: 177 | - `filter`: *(type: string)* The current filter. 178 | - `sortColumns`: *(type: array)* The list of currently sorted columns ordered by sorting order. 179 | - `start`: *(type: Number)* The current start index, used for pagination. 180 | - `end`: *(type: Number)* The current end index, used for pagination. 181 | 182 | You need to return an simple javascript object with the following attributes: 183 | - `rows`: *(type: array)* A list of founded rows for the current parameters. 184 | - `total`: *(type: Number)* The total number of rows found with filtering and sorting, but without pagination. 185 | If this attribute is not set, the length of the rows attribute is taken. 186 | 187 | 188 | Start and end using the Array.slice method to show only a part of the rows. If you don't add them as properties, 189 | their value will be undefined and all the rows will be visible. 190 | 191 | ### Events 192 | 193 | - `total-filtered-rows-change`: This event will be triggered if due to filtering the total amount of rows changes. 194 | It contains one parameter: 195 | - `total`: *(type: Number)* The total number of filtered rows. 196 | - `export`: This event will be triggered if an export is initiated. It contains the following parameter that you can use with the [vue-json-excel package](https://www.npmjs.com/package/vue-json-excel) 197 | - `fields`: *(type: Object)* The fields of the export file. 198 | - `data`: *(type: array)* The rows of the export file. 199 | - `title`: *(type: string)* The name of the export file. 200 | - `selection-change`: This event will be triggered if the `selectable` property is `true` and one or more rows are selected. It contains one parameter: 201 | - `rows`: *(type: array)* The selected rows. 202 | 203 | ### Slots 204 | 205 | #### 1. Row/Column/Cell slot 206 | 207 | If you want to use your own template for all cells in a row, for all cells in a column or for a specific cell, 208 | you can use your own scoped slot with the following names (the first will overrule the latter): 209 | - `_`: Replace a specific cell for the column `` (assigned by the `property` attribute in the column object) 210 | and row `` (assigned by the `_id` attibute in the row object). 211 | - ``: Replace all column cells for the column `` (assigned by the `property` attribute in the column object). 212 | - `_`: Replace all row cells for the row `` (assigned by the `_id` attibute in the row object). 213 | 214 | All three contains a slot-scope which contains the following parameters: 215 | - `row`: The row object. 216 | - `column`: The column object. 217 | 218 | #### 2. No rows slot 219 | 220 | If no rows are loaded, the table displays 'No results found.'. You can replace this message by the scoped slot `no-rows`. 221 | 222 | #### 3. Sort icon slot 223 | 224 | If you want to customize the sort icon add a scoped slot with the name `sort-icon`. 225 | The scope contains only one parameter: 226 | 227 | - `direction`: *(type: boolean or null)* The direction is null if the column is not sorted, true if it's sorted asc and false if it's sorted desc. 228 | 229 | #### 4. Toggle children icon slot 230 | 231 | If you want to customize the toggle children icon add a scoped slot with the name `toggle-children-icon`. 232 | The scope contains two parameters: 233 | 234 | - `expanded`: *(type: boolean)* Indicates if the children are visible or not. 235 | - `loading`: *(type: boolean)* Indicates if the chilrend are currently loading. 236 | 237 | ### Example 238 | 239 | ```vue 240 | 266 | 267 | 456 | 457 | 462 | ``` 463 | 464 | ## Full component 465 | 466 | The table container is the complete component it adds a filter box and and a paginator to the table. 467 | If your `call-rows` property is not empty, an async table component will be used. If the property is empty and basic table component will be used. 468 | 469 | ### Properties 470 | 471 | You can use the `columns`, `rows`, `filter`, `classes`, `selectable`, `full-export`, `call-rows`, `call-temp-rows` and `call-children` properties from the base and async table. 472 | But their are some additional properties: 473 | 474 | - `debounced-filter-time`: *(type: Number, default: 500)* The time in milliseconds to wait before the input value of the filter box is used. 475 | - `page`: *(type: Number, default: 0)* The initial page of the paginator. 476 | - `items-per-page`: *(type: Number, default: 10)* The number of items per page. 477 | - `export-name`: *(type: string, default: `''`)* The name of the export file to download. The trigger to export the table will be a click on the export button. 478 | 479 | ### Events 480 | 481 | The table container has 2 event: 482 | 483 | - `filter-change`: This is used to update the filter property outside the component. 484 | - `page-change`: This is used to update the page property outside the component. 485 | 486 | ### Slots 487 | 488 | All the slots of the [basic table](#basic_table_slots) can be used. 489 | 490 | And their are 2 additional slots: 491 | 492 | #### 1. Top 493 | 494 | A scoped slot that can be used to overwrite everything that is on the top of the table. It has the following scope: 495 | - `filter`: *(type: string)* The filter value. 496 | - `filterChanged`: *(type: Function)* Execute this function if the filter is changed. It will emit and filter-change and page-change event. 497 | 498 | #### 2. Bottom 499 | 500 | A scoped slot that can be used to overwrite everything that is on the bottom of the table. It has the following scope: 501 | - `total`: *(type: Number)* The total amount of rows. 502 | - `page`: *(type: Number)* The current page. 503 | - `itemsPerPage`: *(type: Number)* The number of rows per page. 504 | - `pageChanged`: *(type: Function)* Execute this function if the page is changed. 505 | - `rangeChanged`: *(type: Function)* Execute this function if the range is changed. 506 | 507 | ## Testing 508 | 509 | We use the jest framework for testing the table tree component. Run the following command to test it: 510 | 511 | ``` 512 | npm run test:unit 513 | ``` 514 | 515 | ## Changelog 516 | 517 | Read the [CHANGELOG](CHANGELOG.md) file to check what has changed. 518 | 519 | ## Issues 520 | 521 | If you have any issues (bugs, features, ...) on the current project, add them [here](https://gitlab.com/arnedesmedt/vue-ads-table-tree/issues/new). 522 | 523 | ## Contributing 524 | 525 | Do you like to contribute to this project? Please, read the [CONTRIBUTING](CONTRIBUTING.md) file. 526 | 527 | ## Social 528 | 529 | [1]: http://www.twitter.com/arnesmedt 530 | [1.1]: http://i.imgur.com/wWzX9uB.png (@ArneSmedt) 531 | - Follow me on [![alt text][1.1]][1] 532 | 533 | ## Donate 534 | 535 | Want to make a donation? 536 | That would be highly appreciated! 537 | 538 | Make a donation via [PayPal](https://www.paypal.me/arnedesmedt). 539 | --------------------------------------------------------------------------------