feat: TreeSelect & InputTree支持menuTpl, enableDefaultIcon (#6161)

This commit is contained in:
RUNZE LU 2023-02-23 16:46:27 +08:00 committed by GitHub
parent e60dcb7ba9
commit f9930900de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 472 additions and 95 deletions

View File

@ -946,12 +946,113 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A
}
```
## 自定义选项渲染
> `2.7.3` 及以上版本
使用`menuTpl`属性,自定义下拉选项的渲染内容。
```schema: scope="body"
{
"type": "form",
"api": "/api/mock2/form/saveForm",
"body": [
{
"type": "input-tree",
"name": "tree",
"label": "Tree",
"menuTpl": "<div class='flex justify-between'><span>${label}</span><span class='bg-gray-200 rounded p-1 text-xs text-center w-14'>${tag}</span></div>",
"iconField": "icon",
"options": [
{
"label": "采购单",
"value": "order",
"tag": "数据模型",
"icon": "fa fa-database",
"children": [
{
"label": "ID",
"value": "id",
"tag": "数字",
"icon": "fa fa-check",
},
{
"label": "采购人",
"value": "name",
"tag": "字符串",
"icon": "fa fa-check",
},
{
"label": "采购时间",
"value": "time",
"tag": "日期时间",
"icon": "fa fa-check",
}
]
}
]
}
]
}
```
## 选项搜索
> `2.7.3` 及以上版本
开启`"searchable": true`后,支持搜索当前数据源内的选项
```schema: scope="body"
{
"type": "form",
"api": "/api/mock2/form/saveForm",
"body": [
{
"type": "input-tree",
"name": "tree",
"label": "Tree",
"deferApi": "/api/mock2/form/deferOptions?label=${label}&waitSeconds=2",
"searchable": true,
"searchConfig": {
"sticky": true
},
"options": [
{
"label": "Folder A",
"value": 1,
"collapsed": true,
"children": [
{
"label": "file A",
"value": 2
},
{
"label": "file B",
"value": 3
}
]
},
{
"label": "这下面是懒加载的",
"value": 4,
"defer": true
},
{
"label": "file D",
"value": 5
}
]
}
]
}
```
## 属性表
当做选择器表单项使用时,除了支持 [普通表单项属性表](./formitem#%E5%B1%9E%E6%80%A7%E8%A1%A8) 中的配置以外,还支持下面一些配置
| 属性名 | 类型 | 默认值 | 说明 |
| ---------------------- | -------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 属性名 | 类型 | 默认值 | 说明 | 版本 |
| ---------------------- | -------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| options | `Array<object>`或`Array<string>` | | [选项组](./options#%E9%9D%99%E6%80%81%E9%80%89%E9%A1%B9%E7%BB%84-options) |
| source | `string`或 [API](../../../../docs/types/api) | | [动态选项组](./options#%E5%8A%A8%E6%80%81%E9%80%89%E9%A1%B9%E7%BB%84-source) |
| autoComplete | [API](../../../../docs/types/api) | | [自动提示补全](./options#%E8%87%AA%E5%8A%A8%E8%A1%A5%E5%85%A8-autocomplete) |
@ -970,7 +1071,7 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A
| editApi | [API](../../../docs/types/api) | | [配置编辑选项接口](./options#%E9%85%8D%E7%BD%AE%E7%BC%96%E8%BE%91%E6%8E%A5%E5%8F%A3-editapi) |
| removable | `boolean` | `false` | [删除选项](./options#%E5%88%A0%E9%99%A4%E9%80%89%E9%A1%B9) |
| deleteApi | [API](../../../docs/types/api) | | [配置删除选项接口](./options#%E9%85%8D%E7%BD%AE%E5%88%A0%E9%99%A4%E6%8E%A5%E5%8F%A3-deleteapi) |
| searchable | `boolean` | `false` | 是否可检索,仅在 type 为 `tree-select` 的时候生效 |
| searchable | `boolean` | `false` | 是否可检索 | `2.7.3`前仅`tree-select`支持 |
| hideRoot | `boolean` | `true` | 如果想要显示个顶级节点,请设置为 `false` |
| rootLabel | `boolean` | `"顶级"` | 当 `hideRoot` 不为 `false` 时有用,用来设置顶级节点的文字。 |
| showIcon | `boolean` | `true` | 是否显示图标 |
@ -993,6 +1094,8 @@ true false false [{label: 'A/B/C', value: 'a/b/c'},{label: 'A
| highlightTxt | `string` | | 标签中需要高亮的字符,支持变量 |
| itemHeight | `number` | `32` | 每个选项的高度,用于虚拟渲染 |
| virtualThreshold | `number` | `100` | 在选项数量超过多少时开启虚拟渲染 |
| menuTpl | `string` | | 选项自定义渲染 HTML 片段 | `2.7.3` |
| enableDefaultIcon | `boolean` | `true` | 是否为选项添加默认的前缀 Icon父节点默认为`folder`,叶节点默认为`file` | `2.7.3` |
## 事件表

View File

@ -322,14 +322,86 @@ order: 60
}
```
## 自定义选项渲染
> `2.7.3` 及以上版本
使用`menuTpl`属性,自定义下拉选项的渲染内容。
```schema: scope="body"
{
"type": "form",
"api": "/api/mock2/form/saveForm",
"body": [
{
"type": "tree-select",
"name": "tree",
"label": "Tree",
"menuTpl": "<div class='flex justify-between'><span>${label}</span><span class='bg-gray-200 rounded p-1 text-xs text-center w-24'>${tag}</span></div>",
"iconField": "icon",
"searchable": true,
"options": [
{
"label": "采购单",
"value": "order",
"tag": "数据模型",
"icon": "fa fa-database",
"children": [
{
"label": "ID",
"value": "id",
"tag": "数字",
"icon": "fa fa-check",
},
{
"label": "采购人",
"value": "name",
"tag": "字符串",
"icon": "fa fa-check",
},
{
"label": "采购时间",
"value": "time",
"tag": "日期时间",
"icon": "fa fa-check",
},
{
"label": "供应商",
"value": "vendor",
"tag": "数据模型(N:1)",
"icon": "fa fa-database",
"children": [
{
"label": "供应商ID",
"value": "vendor_id",
"tag": "数字",
"icon": "fa fa-check",
},
{
"label": "供应商名称",
"value": "vendor_name",
"tag": "字符串",
"icon": "fa fa-check",
}
]
}
]
}
]
}
]
}
```
## 属性表
更多用法,见 [InputTree](./input-tree)
下列属性为`tree-select`独占属性, 更多属性用法,参考[InputTree 树形选择框](./input-tree)
| 属性名 | 类型 | 默认值 | 说明 |
| ----------------- | --------- | ------- | ------------------------------------------- |
| hideNodePathLabel | `boolean` | `false` | 是否隐藏选择框中已选择节点的路径 label 信息 |
| onlyLeaf | `boolean` | `false` | 只允许选择叶子节点 |
| 属性名 | 类型 | 默认值 | 说明 | 版本 |
| ----------------- | --------- | ------- | ------------------------------------------------- | ---- |
| hideNodePathLabel | `boolean` | `false` | 是否隐藏选择框中已选择节点的路径 label 信息 | |
| onlyLeaf | `boolean` | `false` | 只允许选择叶子节点 | |
| searchable | `boolean` | `false` | 是否可检索,仅在 type 为 `tree-select` 的时候生效 | |
## 事件表

View File

@ -1,6 +1,4 @@
{
"packages": [
"packages/*"
],
"packages": ["packages/*"],
"version": "2.7.2"
}

View File

@ -2780,6 +2780,7 @@
--ResultBox-value-color: var(--select-multiple-color);
--ResultBox-value-clear-bg: var(--colors-neutral-fill-8);
--ResultBox-value-clear-hover-bg: var(--colors-neutral-fill-9);
--Tree-max-height: 300px;
--Tree-indent: var(--gap-md);
--Tree-icon-gap: var(--sizes-size-5);
--Tree-icon-margin-right: #{px2rem(8px)};

View File

@ -2,7 +2,7 @@
border: 1px solid var(--Form-input-borderColor);
padding: 6px 12px;
border-radius: 2px;
max-height: 300px;
max-height: var(--Tree-max-height);
overflow: auto;
&.h-full {
@ -13,6 +13,25 @@
&.no-border {
border: 0;
}
&.is-sticky {
max-height: unset;
overflow: unset;
}
&-searchbox {
margin-top: var(--gap-sm);
margin-bottom: var(--gap-md);
&.is-active {
width: 100%;
}
}
& > .#{$ns}Tree {
max-height: var(--Tree-max-height);
overflow: auto;
}
}
.#{$ns}Tree {

View File

@ -143,6 +143,7 @@ interface TreeSelectorProps extends ThemeProps, LocaleProps, SpinnerExtraProps {
draggable?: boolean;
onMove?: (dropInfo: IDropInfo) => void;
itemRender?: (option: Option, states: ItemRenderStates) => JSX.Element;
enableDefaultIcon?: boolean;
}
interface TreeSelectorState {
@ -197,7 +198,8 @@ export class TreeSelector extends React.Component<
pathSeparator: '/',
nodePath: [],
virtualThreshold: 100,
itemHeight: 32
itemHeight: 32,
enableDefaultIcon: true
};
// 展开的节点
unfolded: WeakMap<Object, boolean> = new WeakMap();
@ -1046,7 +1048,8 @@ export class TreeSelector extends React.Component<
translate: __,
itemRender,
draggable,
loadingConfig
loadingConfig,
enableDefaultIcon
} = this.props;
const item = this.state.flattenedOptions[index];
@ -1081,9 +1084,7 @@ export class TreeSelector extends React.Component<
const isLeaf =
(!item.children || !item.children.length) && !item.placeholder;
const iconValue = item[iconField] || (item.children ? 'folder' : 'file');
const iconValue = item[iconField] || (enableDefaultIcon !== false ? (item.children ? 'folder' : 'file') : false);
const level = item.level ? item.level - 1 : 0;
let body = null;
@ -1153,13 +1154,15 @@ export class TreeSelector extends React.Component<
: this.handleSelect(item))
}
>
{getIcon(iconValue) ? (
<Icon icon={iconValue} className="icon" />
) : React.isValidElement(iconValue) ? (
iconValue
) : (
<i className={iconValue}></i>
)}
{iconValue ? (
getIcon(iconValue) ? (
<Icon icon={iconValue} className="icon" />
) : React.isValidElement(iconValue) ? (
iconValue
) : (
<i className={iconValue}></i>
)
) : null}
</i>
) : null}
@ -1173,17 +1176,15 @@ export class TreeSelector extends React.Component<
}
title={item[labelField]}
>
{highlightTxt
? highlight(`${item[labelField]}`, highlightTxt)
: itemRender
? itemRender(item, {
index: item.key,
multiple: multiple,
checked: checked,
onChange: () => this.handleCheck(item, !checked),
disabled: disabled || item.disabled
})
: `${item[labelField]}`}
{
itemRender ? itemRender(item, {
index: item.key,
multiple: multiple,
checked: checked,
onChange: () => this.handleCheck(item, !checked),
disabled: disabled || item.disabled
}) : (highlightTxt ? highlight(`${item[labelField]}`, highlightTxt) : `${item[labelField]}`)
}
</span>
{!disabled &&

View File

@ -1,7 +1,11 @@
import React from 'react';
import omit from 'lodash/omit';
import debounce from 'lodash/debounce';
import cx from 'classnames';
import {matchSorter} from 'match-sorter';
import {SpinnerExtraProps, Tree as TreeSelector} from 'amis-ui';
import {
Option,
OptionsControl,
OptionsControlProps,
autobind,
@ -12,9 +16,10 @@ import {
resolveEventData,
toNumber
} from 'amis-core';
import {Spinner} from 'amis-ui';
import {Spinner, SearchBox} from 'amis-ui';
import {FormOptionsSchema, SchemaApi} from '../../Schema';
import {supportStatic} from './StaticHoc';
import type {ItemRenderStates} from 'amis-ui/lib/components/Selection';
/**
* Tree
@ -98,6 +103,55 @@ export interface TreeControlSchema extends FormOptionsSchema {
*
*/
highlightTxt?: string;
/**
* Icontrue
*/
enableDefaultIcon?: boolean;
/**
*
*/
searchable?: boolean;
/**
*
*/
searchConfig?: {
/**
* CSS样式类
*/
className?: string;
/**
*
*/
placeholder?: string;
/**
* Mini
*/
mini?: boolean;
/**
*
*/
enhance?: boolean;
/**
*
*/
clearable?: boolean;
/**
*
*/
searchImediately?: boolean;
/**
*
*/
sticky?: boolean;
};
}
export interface TreeProps
@ -116,9 +170,14 @@ export interface TreeProps
pathSeparator?: string;
}
export default class TreeControl extends React.Component<TreeProps> {
interface TreeState {
filteredOptions: Option[];
keyword: string;
}
export default class TreeControl extends React.Component<TreeProps, TreeState> {
static defaultProps: Partial<TreeProps> = {
placeholder: 'loading',
placeholder: 'placeholder.noData',
multiple: false,
rootLabel: 'Tree.root',
rootValue: '',
@ -128,6 +187,35 @@ export default class TreeControl extends React.Component<TreeProps> {
};
treeRef: any;
constructor(props: TreeProps) {
super(props);
this.state = {
keyword: '',
filteredOptions: this.props.options ?? []
};
this.handleSearch = debounce(this.handleSearch.bind(this), 250, {
trailing: true,
leading: false
});
}
componentDidUpdate(prevProps: TreeProps) {
const props = this.props;
const keyword = this.state.keyword;
if (
prevProps.options !== props.options ||
prevProps.searchable !== props.searchable
) {
const {options, searchable} = props;
this.setState({
filteredOptions:
searchable && keyword ? this.filterOptions(options, keyword) : options
});
}
}
reload() {
const reload = this.props.reloadOptions;
reload && reload();
@ -148,6 +236,30 @@ export default class TreeControl extends React.Component<TreeProps> {
}
}
filterOptions(options: Array<Option>, keywords: string): Array<Option> {
const {labelField, valueField} = this.props;
return options.map(option => {
option = {
...option
};
option.visible = !!matchSorter([option], keywords, {
keys: [labelField || 'label', valueField || 'value']
}).length;
if (!option.visible && option.children) {
option.children = this.filterOptions(option.children, keywords);
const visibleCount = option.children.filter(
item => item.visible
).length;
option.visible = !!visibleCount;
}
option.visible && (option.collapsed = false);
return option;
});
}
@autobind
async handleChange(value: any) {
const {onChange, dispatchEvent} = this.props;
@ -164,6 +276,16 @@ export default class TreeControl extends React.Component<TreeProps> {
onChange && onChange(value);
}
handleSearch(keyword: string) {
const {options} = this.props;
const filterOptions = this.filterOptions(options, keyword);
this.setState({
keyword,
filteredOptions: keyword ? filterOptions : options
});
}
@autobind
domRef(ref: any) {
this.treeRef = ref;
@ -182,6 +304,15 @@ export default class TreeControl extends React.Component<TreeProps> {
}
}
@autobind
renderOptionItem(option: Option, states: ItemRenderStates) {
const {menuTpl, render, data} = this.props;
return render(`option/${states.index}`, menuTpl, {
data: createObject(createObject(data, {...states}), option)
});
}
@supportStatic()
render() {
const {
@ -236,17 +367,80 @@ export default class TreeControl extends React.Component<TreeProps> {
data,
virtualThreshold,
itemHeight,
loadingConfig
loadingConfig,
menuTpl,
enableDefaultIcon,
searchable,
searchConfig = {}
} = this.props;
let {highlightTxt} = this.props;
const {filteredOptions, keyword} = this.state;
if (isPureVariable(highlightTxt)) {
highlightTxt = resolveVariableAndFilter(highlightTxt, data);
}
const TreeCmpt = (
<TreeSelector
classPrefix={ns}
onRef={this.domRef}
labelField={labelField}
valueField={valueField}
iconField={iconField}
disabled={disabled}
onChange={this.handleChange}
joinValues={joinValues}
extractValue={extractValue}
delimiter={delimiter}
placeholder={__(placeholder)}
options={searchable ? filteredOptions : options}
highlightTxt={searchable ? keyword : highlightTxt}
multiple={multiple}
initiallyOpen={initiallyOpen}
unfoldedLevel={unfoldedLevel}
withChildren={withChildren}
onlyChildren={onlyChildren}
onlyLeaf={onlyLeaf}
hideRoot={hideRoot}
rootLabel={__(rootLabel)}
rootValue={rootValue}
showIcon={showIcon}
showRadio={showRadio}
showOutline={showOutline}
autoCheckChildren={autoCheckChildren}
cascade={cascade}
foldedField="collapsed"
value={value || ''}
nodePath={nodePath}
enableNodePath={enableNodePath}
pathSeparator={pathSeparator}
selfDisabledAffectChildren={false}
onAdd={onAdd}
creatable={creatable}
createTip={createTip}
rootCreatable={rootCreatable}
rootCreateTip={rootCreateTip}
onEdit={onEdit}
editable={editable}
editTip={editTip}
removable={removable}
removeTip={removeTip}
onDelete={onDelete}
bultinCUD={!addControls && !editControls}
onDeferLoad={deferLoad}
onExpandTree={expandTreeOptions}
virtualThreshold={virtualThreshold}
itemHeight={toNumber(itemHeight) > 0 ? toNumber(itemHeight) : undefined}
itemRender={menuTpl ? this.renderOptionItem : undefined}
enableDefaultIcon={enableDefaultIcon}
/>
);
return (
<div
className={cx(`${ns}TreeControl`, className, treeContainerClassName)}
className={cx(`${ns}TreeControl`, className, treeContainerClassName, {
'is-sticky': searchable && searchConfig?.sticky
})}
>
<Spinner
size="sm"
@ -254,60 +448,23 @@ export default class TreeControl extends React.Component<TreeProps> {
show={loading}
loadingConfig={loadingConfig}
/>
{loading ? null : (
<TreeSelector
classPrefix={ns}
onRef={this.domRef}
labelField={labelField}
valueField={valueField}
iconField={iconField}
disabled={disabled}
onChange={this.handleChange}
joinValues={joinValues}
extractValue={extractValue}
delimiter={delimiter}
placeholder={__(placeholder)}
options={options}
highlightTxt={highlightTxt}
multiple={multiple}
initiallyOpen={initiallyOpen}
unfoldedLevel={unfoldedLevel}
withChildren={withChildren}
onlyChildren={onlyChildren}
onlyLeaf={onlyLeaf}
hideRoot={hideRoot}
rootLabel={__(rootLabel)}
rootValue={rootValue}
showIcon={showIcon}
showRadio={showRadio}
showOutline={showOutline}
autoCheckChildren={autoCheckChildren}
cascade={cascade}
foldedField="collapsed"
value={value || ''}
nodePath={nodePath}
enableNodePath={enableNodePath}
pathSeparator={pathSeparator}
selfDisabledAffectChildren={false}
onAdd={onAdd}
creatable={creatable}
createTip={createTip}
rootCreatable={rootCreatable}
rootCreateTip={rootCreateTip}
onEdit={onEdit}
editable={editable}
editTip={editTip}
removable={removable}
removeTip={removeTip}
onDelete={onDelete}
bultinCUD={!addControls && !editControls}
onDeferLoad={deferLoad}
onExpandTree={expandTreeOptions}
virtualThreshold={virtualThreshold}
itemHeight={
toNumber(itemHeight) > 0 ? toNumber(itemHeight) : undefined
}
/>
{loading ? null : searchable ? (
<>
<SearchBox
className={cx(
`${ns}TreeControl-searchbox`,
searchConfig?.className,
{'is-sticky': searchConfig?.sticky}
)}
mini={false}
clearable={true}
{...omit(searchConfig, 'className', 'sticky')}
onSearch={this.handleSearch}
/>
{TreeCmpt}
</>
) : (
TreeCmpt
)}
</div>
);

View File

@ -26,6 +26,7 @@ import {ActionObject} from 'amis-core';
import {FormOptionsSchema} from '../../Schema';
import {supportStatic} from './StaticHoc';
import {TooltipWrapperSchema} from '../TooltipWrapper';
import type {ItemRenderStates} from 'amis-ui/lib/components/Selection';
/**
* Tree
@ -108,6 +109,16 @@ export interface TreeSelectControlSchema extends FormOptionsSchema {
* Popover配置
*/
overflowTagPopover?: TooltipWrapperSchema;
/**
*
*/
menuTpl?: string;
/**
* Icontrue
*/
enableDefaultIcon?: boolean;
}
export interface TreeSelectProps
@ -484,6 +495,17 @@ export default class TreeSelectControl extends React.Component<
onChange && onChange(value);
}
/** 下拉框选项渲染 */
@autobind
renderOptionItem(option: Option, states: ItemRenderStates) {
const {menuTpl, render, data} = this.props;
return render(`option/${states.index}`, menuTpl, {
data: createObject(createObject(data, {...states}), option)
});
}
/** 输入框选项渲染 */
@autobind
renderItem(item: Option) {
const {labelField, options, hideNodePathLabel} = this.props;
@ -559,7 +581,9 @@ export default class TreeSelectControl extends React.Component<
autoCheckChildren,
hideRoot,
virtualThreshold,
itemHeight
itemHeight,
menuTpl,
enableDefaultIcon
} = this.props;
let filtedOptions =
@ -619,6 +643,8 @@ export default class TreeSelectControl extends React.Component<
selfDisabledAffectChildren={selfDisabledAffectChildren}
virtualThreshold={virtualThreshold}
itemHeight={toNumber(itemHeight) > 0 ? toNumber(itemHeight) : undefined}
itemRender={menuTpl ? this.renderOptionItem : undefined}
enableDefaultIcon={enableDefaultIcon}
/>
);
}