Table: add filter feature. (#684)

pull/691/head
FuryBean 2016-10-27 21:45:21 +08:00 committed by cinwell.li
parent 5a6cca14c6
commit 14495f6189
16 changed files with 569 additions and 70 deletions

View File

@ -14,6 +14,7 @@
- 修复 Switch 的 width 属性无效的问题
- Table 增加 rowClassName 属性
- TableColumn 增加 fixed 属性可选值true, false, left, right
- TableColumn 增加属性filters、filterMultiple、filterMethod、filteredValue
- TableColumn[type="selection"] 增加 selectable 属性
- 修复 Input textarea 在动态赋值时 autosize 没有触发的问题
- 修复 Input Number min max 属性设置后点击加减出现的崩溃的bug

View File

@ -9,28 +9,32 @@
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1518 弄',
zip: 200333
zip: 200333,
tag: '家'
}, {
date: '2016-05-02',
name: '王小虎',
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1518 弄',
zip: 200333
zip: 200333,
tag: '公司'
}, {
date: '2016-05-04',
name: '王小虎',
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1518 弄',
zip: 200333
zip: 200333,
tag: '家'
}, {
date: '2016-05-01',
name: '王小虎',
province: '上海',
city: '普陀区',
address: '上海市普陀区金沙江路 1518 弄',
zip: 200333
zip: 200333,
tag: '公司'
}],
tableData2: [{
date: '2016-05-02',
@ -119,6 +123,10 @@
return row.address;
},
filterTag(value, row) {
return row.tag === value;
},
tableRowClassName(row, index) {
if (index === 1) {
return 'info-row';
@ -810,6 +818,85 @@
```
:::
### 筛选
对表格进行筛选,可快速查找到自己想看的数据。
:::demo 在列中设置`filters``filter-method`属性即可开启该列的筛选filters 是一个数组,`filter-method`是一个方法,它用于决定某些数据是否显示,会传入两个参数:`value`和`row`。
```html
<template>
<el-table
:data="tableData"
border
style="width: 100%">
<el-table-column
prop="date"
label="日期"
sortable
width="180">
</el-table-column>
<el-table-column
prop="name"
label="姓名"
width="180">
</el-table-column>
<el-table-column
prop="address"
label="地址"
:formatter="formatter">
</el-table-column>
<el-table-column
prop="tag"
label="标签"
width="100"
:filters="[{ text: '家', value: '家' }, { text: '公司', value: '公司' }]"
:filter-method="filterTag"
inline-template>
<el-tag :type="row.tag === '家' ? 'primary' : 'success'" close-transition>{{row.tag}}</el-tag>
</el-table-column>
</el-table>
</template>
<script>
export default {
data() {
return {
tableData: [{
date: '2016-05-02',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄',
tag: '家'
}, {
date: '2016-05-04',
name: '王小虎',
address: '上海市普陀区金沙江路 1517 弄',
tag: '公司'
}, {
date: '2016-05-01',
name: '王小虎',
address: '上海市普陀区金沙江路 1519 弄',
tag: '家'
}, {
date: '2016-05-03',
name: '王小虎',
address: '上海市普陀区金沙江路 1516 弄',
tag: '公司'
}]
}
},
methods: {
formatter(row, column) {
return row.address;
},
filterTag(value, row) {
return row.tag === value;
}
}
}
</script>
```
:::
### Table Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|---------- |-------------- |---------- |-------------------------------- |-------- |
@ -853,3 +940,7 @@
| align | 对齐方式 | String | left, center, right | left |
| selectable | 仅对 type=selection 的列有效,类型为 FunctionFunction 的返回值用来决定这一行的 CheckBox 是否可以勾选 | Function(row, index) | - | - |
| reserve-selection | 仅对 type=selection 的列有效,类型为 Boolean为 true 则代表会保留之前数据的选项,需要配合 Table 的 clearSelection 方法使用。 | Boolean | - | false |
| filters | 数据过滤的选项,数组格式,数组中的元素需要有 text 和 value 属性。 | Array[{ text, value }] | — | — |
| filter-multiple | 数据过滤的选项是否多选 | Boolean | — | true |
| filter-method | 数据过滤使用的方法,如果是多选的筛选项,对每一条数据会执行多次,任意一次返回 true 就会显示。 | Function(value, row) | — | — |
| filteredValue | 选中的数据过滤项,如果需要自定义表头过滤的渲染方式,可能会需要此属性。 | Array | — | — |

View File

@ -0,0 +1,27 @@
var dropdowns = [];
document.addEventListener('click', function(event) {
dropdowns.forEach(function(dropdown) {
var target = event.target;
if (!dropdown || !dropdown.$el) return;
if (target === dropdown.$el || dropdown.$el.contains(target)) {
return;
}
dropdown.handleOutsideClick && dropdown.handleOutsideClick(event);
});
});
export default {
open(instance) {
if (instance) {
dropdowns.push(instance);
}
},
close(instance) {
var index = dropdowns.indexOf(instance);
if (index !== -1) {
dropdowns.splice(instance, 1);
}
}
};

View File

@ -0,0 +1,180 @@
<template>
<transition name="md-fade-bottom">
<div class="el-table-filter" v-if="multiple" v-show="showPopper">
<div class="el-table-filter__content">
<el-checkbox-group class="el-table-filter__checkbox-group" v-model="filteredValue">
<el-checkbox
v-for="filter in filters"
:label="filter.value">{{ filter.text }}</el-checkbox>
</el-checkbox-group>
</div>
<div class="el-table-filter__bottom">
<button @click="handleConfirm"
:class="{ 'is-disabled': filteredValue.length === 0 }"
:disabled="filteredValue.length === 0">{{ $t('el.table.confirmFilter') }}</button>
<button @click="handleReset">{{ $t('el.table.resetFilter') }}</button>
</div>
</div>
<div class="el-table-filter" v-else v-show="showPopper">
<ul class="el-table-filter__list">
<li class="el-table-filter__list-item"
:class="{ 'is-active': !filterValue }"
@click="handleSelect(null)">{{ $t('el.table.clearFilter') }}</li>
<li class="el-table-filter__list-item"
v-for="filter in filters"
:label="filter.value"
:class="{ 'is-active': isActive(filter) }"
@click="handleSelect(filter.value)" >{{ filter.text }}</li>
</ul>
</div>
</transition>
</template>
<script type="text/jsx">
import Popper from 'element-ui/src/utils/vue-popper';
import Locale from 'element-ui/src/mixins/locale';
import Clickoutside from 'element-ui/src/utils/clickoutside';
import Dropdown from './dropdown';
import ElCheckbox from 'element-ui/packages/checkbox';
import ElCheckboxGroup from 'element-ui/packages/checkbox-group';
export default {
name: 'el-table-filter-panel',
mixins: [Popper, Locale],
directives: {
Clickoutside
},
components: {
ElCheckbox,
ElCheckboxGroup
},
props: {
placement: {
type: String,
default: 'bottom-end'
}
},
customRender(h) {
return (<div class="el-table-filter">
<div class="el-table-filter__content">
</div>
<div class="el-table-filter__bottom">
<button on-click={ this.handleConfirm }>{ this.$t('el.table.confirmFilter') }</button>
<button on-click={ this.handleReset }>{ this.$t('el.table.resetFilter') }</button>
</div>
</div>);
},
methods: {
isActive(filter) {
return filter.value === this.filterValue;
},
handleOutsideClick() {
this.showPopper = false;
},
handleConfirm() {
this.confirmFilter(this.filteredValue);
this.handleOutsideClick();
},
handleReset() {
this.filteredValue = [];
this.confirmFilter(this.filteredValue);
this.handleOutsideClick();
},
handleSelect(filterValue) {
this.filterValue = filterValue;
if (filterValue) {
this.confirmFilter(this.filteredValue);
} else {
this.confirmFilter([]);
}
this.handleOutsideClick();
},
confirmFilter(filteredValue) {
this.table.store.commit('filterChange', {
column: this.column,
values: filteredValue
});
}
},
data() {
return {
table: null,
cell: null,
column: null
};
},
computed: {
filters() {
return this.column && this.column.filters;
},
filterValue: {
get() {
return (this.column.filteredValue || [])[0];
},
set(value) {
if (this.filteredValue) {
if (value) {
this.filteredValue.splice(0, 1, value);
} else {
this.filteredValue.splice(0, 1);
}
}
}
},
filteredValue: {
get() {
if (this.column) {
return this.column.filteredValue || [];
}
return [];
},
set(value) {
if (this.column) {
this.column.filteredValue = value;
}
}
},
multiple() {
if (this.column) {
return this.column.filterMultiple;
}
return true;
}
},
mounted() {
this.popperElm = this.$el;
this.referenceElm = this.cell;
this.table.$refs.bodyWrapper.addEventListener('scroll', () => {
this.updatePopper();
});
this.$watch('showPopper', (value) => {
if (this.column) this.column.filterOpened = value;
if (value) {
Dropdown.open(this);
} else {
Dropdown.close(this);
}
});
}
};
</script>

View File

@ -1,22 +1,4 @@
import { getValueByPath, getCell } from './util';
const getColumnById = function(table, columnId) {
let column = null;
table.columns.forEach(function(item) {
if (item.id === columnId) {
column = item;
}
});
return column;
};
const getColumnByCell = function(table, cell) {
const matches = (cell.className || '').match(/el-table_[^\s]+/gm);
if (matches) {
return getColumnById(table, matches[0]);
}
return null;
};
import { getValueByPath, getCell, getColumnById, getColumnByCell } from './util';
export default {
props: {

View File

@ -19,21 +19,16 @@ const defaults = {
minWidth: 48,
realWidth: 48,
direction: ''
},
filter: {
headerTemplate: function(h) { return <span>filter header</span>; },
direction: ''
}
};
const forced = {
selection: {
headerTemplate: function(h) {
return <div><el-checkbox
return <el-checkbox
nativeOn-click={ this.toggleAllSelection }
domProps-value={ this.isAllSelected }
on-input={ (value) => { this.$emit('allselectedchange', value); } } />
</div>;
on-input={ (value) => { this.$emit('allselectedchange', value); } } />;
},
template: function(h, { row, column, store, $index }) {
return <el-checkbox
@ -47,7 +42,7 @@ const forced = {
index: {
// headerTemplate: function(h) { return <div>#</div>; },
headerTemplate: function(h, label) {
return <div>{ label || '#' }</div>;
return label || '#';
},
template: function(h, { $index }) {
return <div>{ $index + 1 }</div>;
@ -56,7 +51,7 @@ const forced = {
},
filter: {
headerTemplate: function(h) {
return <div>#</div>;
return '#';
},
template: function(h, { row, column }) {
return <el-tag type="primary" style="height: 16px; line-height: 16px; min-width: 40px; text-align: center">{ row[column.property] }</el-tag>;
@ -118,7 +113,13 @@ export default {
fixed: [Boolean, String],
formatter: Function,
selectable: Function,
reserveSelection: Boolean
reserveSelection: Boolean,
filterMethod: Function,
filters: Array,
filterMultiple: {
type: Boolean,
default: true
}
},
render() {},
@ -205,7 +206,13 @@ export default {
formatter: this.formatter,
selectable: this.selectable,
reserveSelection: this.reserveSelection,
fixed: this.fixed
fixed: this.fixed,
filterMethod: this.filterMethod,
filters: this.filters,
filterable: this.filters || this.filterMethod,
filterMultiple: this.filterMultiple,
filterOpened: false,
filteredValue: []
});
objectAssign(column, forced[type] || {});

View File

@ -1,5 +1,7 @@
import ElCheckbox from 'element-ui/packages/checkbox';
import ElTag from 'element-ui/packages/tag';
import Vue from 'vue';
import FilterPanel from './filter-panel.vue';
export default {
name: 'el-table-header',
@ -31,21 +33,27 @@ export default {
on-mousemove={ ($event) => this.handleMouseMove($event, column) }
on-mouseout={ this.handleMouseOut }
on-mousedown={ ($event) => this.handleMouseDown($event, column) }
on-click={ ($event) => this.handleHeaderClick($event, column) }
class={ [column.id, column.direction, column.align, this.isCellHidden(cellIndex) ? 'hidden' : ''] }>
<div class={ ['cell', column.filteredValue && column.filteredValue.length > 0 ? 'highlight' : ''] }>
{
[
column.headerTemplate
? column.headerTemplate.call(this._renderProxy, h, column.label)
: <div>{ column.label }</div>,
column.sortable
? <div class="caret-wrapper">
<i class="sort-caret ascending"></i>
<i class="sort-caret descending"></i>
</div>
: ''
]
column.headerTemplate
? column.headerTemplate.call(this._renderProxy, h, column.label)
: column.label
}
{
column.sortable
? <span class="caret-wrapper" on-click={ ($event) => this.handleHeaderClick($event, column) }>
<i class="sort-caret ascending"></i>
<i class="sort-caret descending"></i>
</span>
: ''
}
{
column.filterable
? <span class="el-table__column-filter-trigger" on-click={ ($event) => this.handleFilterClick($event, column) }><i class={ ['el-icon-arrow-down', column.filterOpened ? 'el-icon-arrow-up' : ''] }></i></span>
: ''
}
</div>
</th>
)
}
@ -61,7 +69,6 @@ export default {
},
props: {
columns: {},
fixed: String,
store: {
required: true
@ -99,6 +106,19 @@ export default {
}
},
created() {
this.filterPanels = {};
},
beforeDestroy() {
const panels = this.filterPanels;
for (let prop in panels) {
if (panels.hasOwnProperty(prop) && panels[prop]) {
panels[prop].$destroy(true);
}
}
},
methods: {
isCellHidden(index) {
if (this.fixed === true || this.fixed === 'left') {
@ -114,6 +134,34 @@ export default {
this.store.commit('toggleAllSelection');
},
handleFilterClick(event, column) {
event.stopPropagation();
const target = event.target;
const cell = target.parentNode;
const table = this.$parent;
let filterPanel = this.filterPanels[column.id];
if (filterPanel && column.filterOpened) {
filterPanel.showPopper = false;
return;
}
if (!filterPanel) {
filterPanel = new Vue(FilterPanel);
this.filterPanels[column.id] = filterPanel;
filterPanel.table = table;
filterPanel.cell = cell;
filterPanel.column = column;
filterPanel.$mount(document.createElement('div'));
}
setTimeout(() => {
filterPanel.showPopper = true;
}, 16);
},
handleMouseDown(event, column) {
if (this.draggingColumn && this.border) {
this.dragging = true;
@ -180,7 +228,10 @@ export default {
},
handleMouseMove(event, column) {
const target = event.target;
let target = event.target;
while (target && target.tagName !== 'TH') {
target = target.parentNode;
}
if (!column || !column.resizable) return;
@ -194,7 +245,6 @@ export default {
} else if (!this.dragging) {
bodyStyle.cursor = '';
this.draggingColumn = null;
if (column.sortable) bodyStyle.cursor = 'pointer';
}
}
},

View File

@ -1,6 +1,6 @@
import Vue from 'vue';
import debounce from 'throttle-debounce/debounce';
import { orderBy } from './util';
import { orderBy, getColumnById } from './util';
const getRowIdentity = (row, rowKey) => {
if (!row) throw new Error('row is required when get row identity');
@ -24,6 +24,7 @@ const TableStore = function(table, initialState = {}) {
fixedColumns: [],
rightFixedColumns: [],
_data: null,
filteredData: null,
data: null,
sortCondition: {
column: null,
@ -34,7 +35,8 @@ const TableStore = function(table, initialState = {}) {
selection: [],
reserveSelection: false,
selectable: null,
hoverRow: null
hoverRow: null,
filters: {}
};
for (let prop in initialState) {
@ -80,7 +82,38 @@ TableStore.prototype.mutations = {
},
changeSortCondition(states) {
states.data = orderBy((states._data || []), states.sortCondition.property, states.sortCondition.direction);
states.data = orderBy((states.filteredData || states._data || []), states.sortCondition.property, states.sortCondition.direction);
Vue.nextTick(() => this.table.updateScrollY());
},
filterChange(states, options) {
let { column, values } = options;
if (values && !Array.isArray(values)) {
values = [values];
}
const prop = column.property;
if (prop) {
states.filters[column.id] = values;
}
let data = states._data;
const filters = states.filters;
Object.keys(filters).forEach((columnId) => {
const values = filters[columnId];
if (!values || values.length === 0) return;
const column = getColumnById(this.states, columnId);
if (column && column.filterMethod) {
data = data.filter((row) => {
return values.some(value => column.filterMethod.call(null, value, row));
});
}
});
states.filteredData = data;
states.data = orderBy(data, states.sortCondition.property, states.sortCondition.direction);
Vue.nextTick(() => this.table.updateScrollY());
},

View File

@ -91,6 +91,7 @@
import throttle from 'throttle-debounce/throttle';
import debounce from 'throttle-debounce/debounce';
import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
import { $t } from 'element-ui/src/locale';
import TableStore from './table-store';
import TableLayout from './table-layout';
import TableBody from './table-body';
@ -130,7 +131,7 @@
emptyText: {
type: String,
default: '暂无数据'
default: $t('el.table.emptyText')
}
},

View File

@ -75,3 +75,21 @@ export const orderBy = function(array, sortKey, reverse) {
return a === b ? 0 : a > b ? order : -order;
});
};
export const getColumnById = function(table, columnId) {
let column = null;
table.columns.forEach(function(item) {
if (item.id === columnId) {
column = item;
}
});
return column;
};
export const getColumnByCell = function(table, cell) {
const matches = (cell.className || '').match(/el-table_[^\s]+/gm);
if (matches) {
return getColumnById(table, matches[0]);
}
return null;
};

View File

@ -13,6 +13,7 @@
@import "./loading.css";
@import "./dialog.css";
@import "./table.css";
@import "./table-column.css";
@import "./pagination.css";
@import "./popover.css";
@import "./tooltip.css";

View File

@ -0,0 +1,85 @@
@charset "UTF-8";
@import "./checkbox.css";
@import "./tag.css";
@import "./common/var.css";
@component-namespace el {
@b table-filter {
border: solid 1px #d3dce6;
border-radius: 2px;
background-color: #fff;
box-shadow: var(--dropdown-menu-box-shadow);
box-sizing: border-box;
margin: 2px 0;
/** used for dropdown mode */
@e list {
padding: 5px 0;
margin: 0;
list-style: none;
min-width: 100px;
}
@e list-item {
line-height: 36px;
padding: 0 10px;
cursor: pointer;
font-size: var(--font-size-base);
&:hover {
background-color: var(--dropdown-menuItem-hover-fill);
color: var(--dropdown-menuItem-hover-color);
}
@when active {
background-color: #20a0ff;
color: #fff;
}
}
@e content {
min-width: 100px;
}
@e bottom {
border-top: 1px solid #d3dce6;
padding: 8px;
button {
background: transparent;
border: none;
color: #8492a6;
cursor: pointer;
font-size: var(--font-size-base);
padding: 0 3px;
&:hover {
color: #20a0ff;
}
&:focus {
outline: none;
}
&.is-disabled {
color: #c0ccda;
cursor: not-allowed;
}
}
}
@e checkbox-group {
padding: 10px;
.el-checkbox {
display: block;
margin-bottom: 8px;
margin-left: 5px;
}
.el-checkbox:last-child {
margin-bottom: 0;
}
}
}
}

View File

@ -213,16 +213,21 @@
vertical-align: middle;
width: 100%;
box-sizing: border-box;
&.highlight {
color: #20a0ff;
}
}
& div.caret-wrapper {
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 10px;
width: 10px;
height: 12px;
padding: 0;
& .caret-wrapper {
position: relative;
cursor: pointer;
display: inline-block;
vertical-align: middle;
margin-left: 5px;
margin-top: -2px;
width: 16px;
height: 34px;
overflow: initial;
}
@ -233,19 +238,20 @@
border: 0;
content: "";
position: absolute;
left: 3px;
z-index: 2;
&.ascending {
top: 0;
top: 11px;
border-top: none;
border-right: 5px solid transparent;
border-bottom: 5px solid #99A9BF;
border-bottom: 5px solid #99a9bf;
border-left: 5px solid transparent;
}
&.descending {
bottom: 0;
border-top: 5px solid #99A9BF;
bottom: 11px;
border-top: 5px solid #99a9bf;
border-right: 5px solid transparent;
border-bottom: none;
border-left: 5px solid transparent;
@ -333,7 +339,12 @@
z-index: -1;
}
@e column-filter-label {
@e column-filter-trigger {
display: inline-block;
line-height: 34px;
margin-left: 5px;
cursor: pointer;
& i {
color: #99a9bf;
}

View File

@ -68,6 +68,12 @@ export default {
delete: 'Delete',
preview: 'Preview',
continue: 'Continue'
},
table: {
emptyText: 'No Data',
confirmFilter: 'Confirm',
resetFilter: 'Reset',
clearFilter: 'All'
}
}
};

View File

@ -68,6 +68,12 @@ export default {
delete: '删除',
preview: '查看图片',
continue: '继续上传'
},
table: {
emptyText: '暂无数据',
confirmFilter: '筛选',
resetFilter: '重置',
clearFilter: '全部'
}
}
};

View File

@ -54,7 +54,7 @@ describe('Table', () => {
});
it('row data', () => {
const cells = toArray(vm.$el.querySelectorAll('.cell'))
const cells = toArray(vm.$el.querySelectorAll('td .cell'))
.map(node => node.textContent);
expect(cells).to.eql(testDataArr);
@ -591,7 +591,7 @@ describe('Table', () => {
it('ascending', done => {
const elm = vm.$el.querySelector('.caret-wrapper');
elm.parentNode.click();
elm.click();
setTimeout(_ => {
const lastCells = vm.$el.querySelectorAll('.el-table__body-wrapper tbody tr td:last-child');
expect(toArray(lastCells).map(node => node.textContent))
@ -603,7 +603,7 @@ describe('Table', () => {
it('descending', done => {
const elm = vm.$el.querySelector('.caret-wrapper');
elm.parentNode.click();
elm.click();
setTimeout(_ => {
const lastCells = vm.$el.querySelectorAll('.el-table__body-wrapper tbody tr td:last-child');
expect(toArray(lastCells).map(node => node.textContent))