feat: 表格支持自动填充高度模式 Closes #2125 (#2907)

* chore: 更新版本

* feat: 表格支持自动填充高度模式 Closes #2125

* 改一下命名
This commit is contained in:
吴多益 2021-11-11 10:34:06 +08:00 committed by GitHub
parent 214067b79d
commit 11c69f928a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 515 additions and 44 deletions

View File

@ -1747,6 +1747,16 @@ order: 67
}
```
## 表格内容高度自适应
> 1.5.0 及以上版本
通过 `autoFillHeight` 可以让表格内容区自适应高度,具体效果请看这个[示例](../../../examples/crud/auto-fill)。
它的展现效果是整个内容区域高度自适应,表格内容较多时在内容区域内出滚动条,这样顶部筛选和底部翻页的位置都是固定的。
开启这个配置后会自动关闭 `affixHeader` 功能避免冲突。
## 属性表
| 属性名 | 类型 | 默认值 | 说明 |
@ -1773,6 +1783,7 @@ order: 67
| prefixRow | `Array` | | 顶部总结行 |
| affixRow | `Array` | | 底部总结行 |
| itemBadge | [`BadgeSchema`](./badge) | | 行角标配置 |
| autoFillHeight | `boolean` | | 内容区域自适应高度 |
## 列配置属性表

View File

@ -0,0 +1,329 @@
export default {
title: '表格内容区域自适应屏幕高度,内容超出时在内容区出现滚动条',
remark: 'bla bla bla',
toolbar: [
{
type: 'button',
actionType: 'dialog',
label: '新增',
icon: 'fa fa-plus pull-left',
primary: true,
dialog: {
title: '新增',
body: {
type: 'form',
name: 'sample-edit-form',
api: 'post:/api/sample',
body: [
{
type: 'input-text',
name: 'engine',
label: 'Engine',
required: true
},
{
type: 'divider'
},
{
type: 'input-text',
name: 'browser',
label: 'Browser',
required: true
},
{
type: 'divider'
},
{
type: 'input-text',
name: 'platform',
label: 'Platform(s)',
required: true
},
{
type: 'divider'
},
{
type: 'input-text',
name: 'version',
label: 'Engine version'
},
{
type: 'divider'
},
{
type: 'input-text',
name: 'grade',
label: 'CSS grade'
}
]
}
}
}
],
body: {
type: 'crud',
draggable: true,
api: '/api/sample',
perPage: 50,
keepItemSelectionOnPageChange: true,
maxKeepItemSelectionLength: 11,
autoFillHeight: true,
labelTpl: '${id} ${engine}',
autoGenerateFilter: true,
bulkActions: [
{
label: '批量删除',
actionType: 'ajax',
api: 'delete:/api/sample/${ids|raw}',
confirmText: '确定要批量删除?'
},
{
label: '批量修改',
actionType: 'dialog',
dialog: {
title: '批量编辑',
name: 'sample-bulk-edit',
body: {
type: 'form',
api: '/api/sample/bulkUpdate2',
body: [
{
type: 'hidden',
name: 'ids'
},
{
type: 'input-text',
name: 'engine',
label: 'Engine'
}
]
}
}
}
],
quickSaveApi: '/api/sample/bulkUpdate',
quickSaveItemApi: '/api/sample/$id',
filterTogglable: true,
headerToolbar: [
'bulkActions',
{
type: 'tpl',
tpl: '定制内容示例:当前有 ${count} 条数据。',
className: 'v-middle'
},
{
type: 'link',
href: 'https://www.baidu.com',
body: '百度一下',
htmlTarget: '_parent',
className: 'v-middle'
},
{
type: 'columns-toggler',
align: 'right'
},
{
type: 'drag-toggler',
align: 'right'
},
{
type: 'pagination',
align: 'right'
}
],
footerToolbar: ['statistics', 'switch-per-page', 'pagination'],
// rowClassNameExpr: '<%= data.id == 1 ? "bg-success" : "" %>',
columns: [
{
name: 'id',
label: 'ID',
searchable: {
type: 'input-text',
name: 'id',
label: '主键',
placeholder: '输入id'
}
},
{
name: 'engine',
label: 'Rendering engine'
},
{
name: 'browser',
label: 'Browser',
searchable: {
type: 'select',
name: 'browser',
label: '浏览器',
placeholder: '选择浏览器',
options: [
{
label: 'Internet Explorer ',
value: 'ie'
},
{
label: 'AOL browser',
value: 'aol'
},
{
label: 'Firefox',
value: 'firefox'
}
]
}
},
{
name: 'platform',
label: 'Platform(s)'
},
{
name: 'version',
label: 'Engine version',
searchable: {
type: 'input-number',
name: 'version',
label: '版本号',
placeholder: '输入版本号',
mode: 'horizontal'
}
},
{
name: 'grade',
label: 'CSS grade'
},
{
type: 'operation',
label: '操作',
width: 100,
buttons: [
{
type: 'button',
icon: 'fa fa-eye',
actionType: 'dialog',
tooltip: '查看',
dialog: {
title: '查看',
body: {
type: 'form',
body: [
{
type: 'static',
name: 'engine',
label: 'Engine'
},
{
type: 'divider'
},
{
type: 'static',
name: 'browser',
label: 'Browser'
},
{
type: 'divider'
},
{
type: 'static',
name: 'platform',
label: 'Platform(s)'
},
{
type: 'divider'
},
{
type: 'static',
name: 'version',
label: 'Engine version'
},
{
type: 'divider'
},
{
type: 'static',
name: 'grade',
label: 'CSS grade'
},
{
type: 'divider'
},
{
type: 'html',
html: '<p>添加其他 <span>Html 片段</span> 需要支持变量替换todo.</p>'
}
]
}
}
},
{
type: 'button',
icon: 'fa fa-pencil',
tooltip: '编辑',
actionType: 'drawer',
drawer: {
position: 'left',
size: 'lg',
title: '编辑',
body: {
type: 'form',
name: 'sample-edit-form',
api: '/api/sample/$id',
body: [
{
type: 'input-text',
name: 'engine',
label: 'Engine',
required: true
},
{
type: 'divider'
},
{
type: 'input-text',
name: 'browser',
label: 'Browser',
required: true
},
{
type: 'divider'
},
{
type: 'input-text',
name: 'platform',
label: 'Platform(s)',
required: true
},
{
type: 'divider'
},
{
type: 'input-text',
name: 'version',
label: 'Engine version'
},
{
type: 'divider'
},
{
type: 'select',
name: 'grade',
label: 'CSS grade',
options: ['A', 'B', 'C', 'D', 'X']
}
]
}
}
},
{
type: 'button',
icon: 'fa fa-times text-danger',
actionType: 'ajax',
tooltip: '删除',
confirmText: '您确认要删除?',
api: 'delete:/api/sample/$id'
}
],
toggled: true
}
]
}
};

View File

@ -32,6 +32,7 @@ import Definitions from './Form/Definitions';
import AnchorNav from './Form/AnchorNav';
import TableCrudSchema from './CRUD/Table';
import TableAutoFillSchema from './CRUD/TableAutoFill';
import ItemActionsSchema from './CRUD/ItemActions';
import GridCrudSchema from './CRUD/Grid';
import ListCrudSchema from './CRUD/List';
@ -285,6 +286,11 @@ export const examples = [
path: '/examples/crud/table',
component: makeSchemaRenderer(TableCrudSchema)
},
{
label: '表格高度自适应',
path: '/examples/crud/auto-fill',
component: makeSchemaRenderer(TableAutoFillSchema)
},
{
label: '卡片模式',
path: '/examples/crud/grid',

View File

@ -891,6 +891,21 @@
top: 0;
left: 0;
}
&--autoFillHeight {
> .#{$ns}Table-contentWrap {
overflow-y: scroll;
> .#{$ns}Table-content table {
border-top: none; // 不然会导致拖动时顶部露出内容
}
> .#{$ns}Table-content table thead {
position: sticky; // 简单实现表头吸顶效果不考虑 IE 11 不然太麻烦
top: 0;
z-index: 1; // 由于 badge 导致 tbody tr position: relative
}
}
}
}
.#{$ns}InputTable-toolbar {

View File

@ -287,6 +287,11 @@ export interface CRUDCommonSchema extends BaseSchema {
* searchable属性值
*/
autoGenerateFilter?: boolean;
/**
*
*/
autoFillHeight?: boolean;
}
export type CRUDCardsSchema = CRUDCommonSchema & {
@ -366,7 +371,8 @@ export default class CRUD extends React.Component<CRUDProps, any> {
'onInit',
'onSaved',
'onQuery',
'formStore'
'formStore',
'autoFillHeight'
];
static defaultProps = {
toolbarInline: true,
@ -382,7 +388,8 @@ export default class CRUD extends React.Component<CRUDProps, any> {
filterTogglable: false,
filterDefaultVisible: true,
loadDataOnce: false,
loadDataOnceFetchOnFilter: true
loadDataOnceFetchOnFilter: true,
autoFillHeight: false
};
control: any;
@ -2000,6 +2007,7 @@ export default class CRUD extends React.Component<CRUDProps, any> {
onQuery,
autoGenerateFilter,
onSelect,
autoFillHeight,
...rest
} = this.props;
@ -2051,6 +2059,7 @@ export default class CRUD extends React.Component<CRUDProps, any> {
className: cx('Crud-body', bodyClassName),
ref: this.controlRef,
autoGenerateFilter: !filter && autoGenerateFilter,
autoFillHeight: autoFillHeight,
selectable: !!(
(this.hasBulkActionsToolbar() && this.hasBulkActions()) ||
pickerMode

View File

@ -1,11 +1,12 @@
import React from 'react';
import {ClassNamesFn} from '../../theme';
import {IColumn, IRow} from '../../store/table';
import {IColumn, IRow, ITableStore} from '../../store/table';
import {SchemaNode, Action} from '../../types';
import {TableBody} from './TableBody';
import {LocaleProps} from '../../locale';
import {observer} from 'mobx-react';
import {ActionSchema} from '../Action';
import ItemActionsWrapper from './ItemActionsWrapper';
export interface TableContentProps extends LocaleProps {
className?: string;
@ -50,10 +51,45 @@ export interface TableContentProps extends LocaleProps {
prefixRow?: Array<any>;
affixRow?: Array<any>;
itemAction?: ActionSchema;
itemActions?: Array<Action>;
store: ITableStore;
}
@observer
export class TableContent extends React.Component<TableContentProps> {
renderItemActions() {
const {itemActions, render, store, classnames: cx} = this.props;
const finalActions = Array.isArray(itemActions)
? itemActions.filter(action => !action.hiddenOnHover)
: [];
if (!finalActions.length) {
return null;
}
return (
<ItemActionsWrapper store={store} classnames={cx}>
<div className={cx('Table-itemActions')}>
{finalActions.map((action, index) =>
render(
`itemAction/${index}`,
{
...(action as any),
isMenuItem: true
},
{
key: index,
item: store.hoverRow,
data: store.hoverRow!.locals,
rowIndex: store.hoverRow!.index
}
)
)}
</div>
</ItemActionsWrapper>
);
}
render() {
const {
placeholder,
@ -82,7 +118,8 @@ export class TableContent extends React.Component<TableContentProps> {
locale,
translate,
itemAction,
affixRow
affixRow,
store
} = this.props;
const tableClassName = cx('Table-table', this.props.tableClassName);
@ -94,6 +131,7 @@ export class TableContent extends React.Component<TableContentProps> {
className={cx('Table-content', className)}
onScroll={onScroll}
>
{store.hoverRow ? this.renderItemActions() : null}
<table ref={tableRef} className={tableClassName}>
<thead>
{columnsGroup.length ? (

View File

@ -53,9 +53,10 @@ import {TableBody} from './TableBody';
import {TplSchema} from '../Tpl';
import {MappingSchema} from '../Mapping';
import {isAlive, getSnapshot} from 'mobx-state-tree';
import ItemActionsWrapper from './ItemActionsWrapper';
import ColumnToggler from './ColumnToggler';
import {BadgeSchema} from '../../components/Badge';
import offset from '../../utils/offset';
import {getStyleNumber} from '../../utils/dom';
/**
*
@ -422,7 +423,8 @@ export default class Table extends React.Component<TableProps, object> {
'headerToolbarClassName',
'toolbarClassName',
'footerToolbarClassName',
'itemBadge'
'itemBadge',
'autoFillHeight'
];
static defaultProps: Partial<TableProps> = {
className: '',
@ -497,6 +499,7 @@ export default class Table extends React.Component<TableProps, object> {
this.subFormRef = this.subFormRef.bind(this);
this.handleColumnToggle = this.handleColumnToggle.bind(this);
this.renderAutoFilterForm = this.renderAutoFilterForm.bind(this);
this.updateAutoFillHeight = this.updateAutoFillHeight.bind(this);
const {
store,
@ -607,6 +610,79 @@ export default class Table extends React.Component<TableProps, object> {
this.affixDetect();
parent.addEventListener('scroll', this.affixDetect);
window.addEventListener('resize', this.affixDetect);
this.updateAutoFillHeight();
window.addEventListener('resize', this.updateAutoFillHeight);
}
/**
*
* css dom hack
*/
updateAutoFillHeight() {
const {autoFillHeight, footerToolbar, classPrefix: ns} = this.props;
if (!autoFillHeight) {
return;
}
const table = findDOMNode(this) as HTMLElement;
const tableContent = table.querySelector(
`.${ns}Table-content`
) as HTMLElement;
const tableContentWrap = table.querySelector(
`.${ns}Table-contentWrap`
) as HTMLElement;
const footToolbar = table.querySelector(
`.${ns}Table-footToolbar`
) as HTMLElement;
if (!tableContent) {
return;
}
// table 底部 margin
const tableMarginBottom = getStyleNumber(table, 'margin-bottom');
// 计算 table-content 在 dom 中的位置
const tableContentTop = offset(tableContent).top;
const viewportHeight = window.innerHeight;
// 有时候会拿不到 footToolbar
const footToolbarHeight = footToolbar ? offset(footToolbar).height : 0;
// 有时候会拿不到 footToolbar等一下在执行
if (!footToolbarHeight && footerToolbar && footerToolbar.length) {
setTimeout(() => {
this.updateAutoFillHeight();
}, 100);
return;
}
const footToolbarMarginBottom = getStyleNumber(
footToolbar,
'margin-bottom'
);
const tableContentWrapMarginButtom = getStyleNumber(
tableContentWrap,
'margin-bottom'
);
// 循环计算父级节点的 pddding这里不考虑父级节点还可能会有其它兄弟节点的情况了
let allParentPaddingButtom = 0;
let parentNode = tableContent.parentElement;
while (parentNode) {
const paddingButtom = getStyleNumber(parentNode, 'padding-bottom');
const borderBottom = getStyleNumber(parentNode, 'border-bottom-width');
allParentPaddingButtom =
allParentPaddingButtom + paddingButtom + borderBottom;
parentNode = parentNode.parentElement;
}
tableContent.style.height = `${
viewportHeight -
tableContentTop -
tableContentWrapMarginButtom -
footToolbarHeight -
Math.max(
footToolbarMarginBottom,
allParentPaddingButtom,
tableMarginBottom
)
}px`;
}
componentDidUpdate(prevProps: TableProps) {
@ -692,6 +768,7 @@ export default class Table extends React.Component<TableProps, object> {
const parent = this.parentNode;
parent && parent.removeEventListener('scroll', this.affixDetect);
window.removeEventListener('resize', this.affixDetect);
window.removeEventListener('resize', this.updateAutoFillHeight);
(this.updateTableInfoLazy as any).cancel();
this.unSensor && this.unSensor();
@ -881,7 +958,7 @@ export default class Table extends React.Component<TableProps, object> {
}
affixDetect() {
if (!this.props.affixHeader || !this.table) {
if (!this.props.affixHeader || !this.table || this.props.autoFillHeight) {
return;
}
@ -2584,39 +2661,6 @@ export default class Table extends React.Component<TableProps, object> {
: footerNode || toolbarNode || null;
}
renderItemActions() {
const {itemActions, render, store, classnames: cx} = this.props;
const finalActions = Array.isArray(itemActions)
? itemActions.filter(action => !action.hiddenOnHover)
: [];
if (!finalActions.length) {
return null;
}
return (
<ItemActionsWrapper store={store} classnames={cx}>
<div className={cx('Table-itemActions')}>
{finalActions.map((action, index) =>
render(
`itemAction/${index}`,
{
...(action as any),
isMenuItem: true
},
{
key: index,
item: store.hoverRow,
data: store.hoverRow!.locals,
rowIndex: store.hoverRow!.index
}
)
)}
</div>
</ItemActionsWrapper>
);
}
renderTableContent() {
const {
classnames: cx,
@ -2631,8 +2675,11 @@ export default class Table extends React.Component<TableProps, object> {
prefixRow,
locale,
affixRow,
tableContentClassName,
translate,
itemAction
itemAction,
autoFillHeight,
itemActions
} = this.props;
// 理论上来说 store.rows 应该也行啊
@ -2645,7 +2692,10 @@ export default class Table extends React.Component<TableProps, object> {
store.combineNum > 0 ? 'Table-table--withCombine' : '',
tableClassName
)}
className={tableContentClassName}
itemActions={itemActions}
itemAction={itemAction}
store={store}
classnames={cx}
columns={store.filteredColumns}
columnsGroup={store.columnGroup}
@ -2681,6 +2731,7 @@ export default class Table extends React.Component<TableProps, object> {
store,
classnames: cx,
affixColumns,
autoFillHeight,
autoGenerateFilter
} = this.props;
@ -2697,7 +2748,8 @@ export default class Table extends React.Component<TableProps, object> {
return (
<div
className={cx('Table', className, {
'Table--unsaved': !!store.modified || !!store.moved
'Table--unsaved': !!store.modified || !!store.moved,
'Table--autoFillHeight': autoFillHeight
})}
>
{autoGenerateFilter ? this.renderAutoFilterForm() : null}
@ -2734,7 +2786,6 @@ export default class Table extends React.Component<TableProps, object> {
: null}
</div>
{this.renderTableContent()}
{store.hoverRow ? this.renderItemActions() : null}
</div>
{this.renderAffixHeader(tableClassName)}
{footer}

View File

@ -198,7 +198,7 @@ export function responseAdaptor(ret: fetcherResult, api: ApiObject) {
let hasStatusField = true;
if (!data) {
throw new Error('Response is empty!');
throw new Error('Response is empty');
}
// 兼容几种常见写法

View File

@ -267,3 +267,15 @@ export function calculatePosition(
activePlacement
};
}
/**
* 0
*/
export function getStyleNumber(element: HTMLElement, styleName: string) {
if (!element) {
return 0;
}
return (
parseInt(getComputedStyle(element).getPropertyValue(styleName), 10) || 0
);
}