feat: Transfer组件支持分页 (#8512)

This commit is contained in:
RUNZE LU 2023-11-07 15:31:05 +08:00 committed by GitHub
parent a9eafda7a3
commit 729f599573
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1238 additions and 1445 deletions

View File

@ -878,12 +878,95 @@ icon:
}
```
## 分页
> `3.6.0`及以上版本
当数据量庞大时,可以开启数据源分页,此时左侧列表底部会出现分页控件,相关配置参考属性表。通常在提交表单中使用分页场景,处理数据量较大的数据源。如果需要在表单中回显已选值,建议同时设置`{"joinValues": false, "extractValue": false}`因为已选数据可能位于不同的分页如果仅使用value值作为提交值可能会导致右侧结果区无法正确渲染。
> 仅列表list和表格table展示模式支持分页接口的数据结构参考[CRUD数据源接口格式](../crud#数据结构)
```schema: scope="body"
{
"type": "form",
"debug": true,
"body": [
{
"label": "默认",
"type": "transfer",
"name": "transfer",
"joinValues": false,
"extractValue": false,
"source": "/api/mock2/options/transfer?page=${page}&perPage=${perPage}",
"pagination": {
"enable": true,
"layout": ["pager", "perpage", "total"],
"popOverContainerSelector": ".cxd-Panel--form"
},
"value": [
{"label": "Laura Lewis", "value": "1", "id": 1},
{"label": "Christopher Rodriguez", "value": "3", "id": 3},
{"label": "Laura Miller", "value": "12", "id": 12},
{"label": "Patricia Robinson", "value": "14", "id": 14}
]
}
]
}
```
### 前端分页
> `3.6.0`及以上版本
当使用数据域变量作为数据源时,支持实现前端一次性加载并分页
```schema: scope="body"
{
"type": "form",
"debug": true,
"body": [
{
"type": "service",
"api": {
"url": "/api/mock2/options/loadDataOnce",
"method": "get",
"responseData": {
"transferOptions": "${items}"
}
},
"body": [
{
"label": "默认",
"type": "transfer",
"name": "transfer",
"joinValues": false,
"extractValue": false,
"source": "${transferOptions}",
"pagination": {
"enable": true,
"layout": ["pager", "perpage", "total"],
"popOverContainerSelector": ".cxd-Panel--form"
},
"value": [
{"label": "Laura Lewis", "value": "1", "id": 1},
{"label": "Christopher Rodriguez", "value": "3", "id": 3},
{"label": "Laura Miller", "value": "12", "id": 12},
{"label": "Patricia Robinson", "value": "14", "id": 14}
]
}
]
}
]
}
```
## 属性表
除了支持 [普通表单项属性表](./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) |
| delimeter | `string` | `false` | [拼接符](./options#%E6%8B%BC%E6%8E%A5%E7%AC%A6-delimiter) |
@ -909,6 +992,13 @@ icon:
| valueTpl | `string` \| [SchemaNode](../../docs/types/schemanode) | | 用来自定义值的展示 |
| itemHeight | `number` | `32` | 每个选项的高度,用于虚拟渲染 |
| virtualThreshold | `number` | `100` | 在选项数量超过多少时开启虚拟渲染 |
| pagination | `object` | | 分页配置 | `3.6.0` |
| pagination.className | `string` | | 分页控件CSS类名 | `3.6.0` |
| pagination.enable | `boolean` | | 是否开启分页 | `3.6.0` |
| pagination.layout | `string` \| `string[]` | `["pager"]` | 通过控制 layout 属性的顺序,调整分页结构布局 | `3.6.0` |
| pagination.perPageAvailable | `number[]` | `[10, 20, 50, 100]` | 指定每页可以显示多少条 | `3.6.0` |
| pagination.maxButtons | `number` | `5` | 最多显示多少个分页按钮,最小为 5 | `3.6.0` |
| pagination.popOverContainerSelector | `string` | | 切换每页条数的控件挂载点 | `3.6.0` |
## 事件表

View File

@ -0,0 +1,238 @@
/** 前端分页的接口 */
module.exports = function (req, res) {
res.json({
status: 0,
msg: 'ok',
data: {
count: data.length,
items: data
}
});
};
const data = [
{
"label": "Laura Lewis",
"value": "1"
},
{
"label": "David Gonzalez",
"value": "2"
},
{
"label": "Christopher Rodriguez",
"value": "3"
},
{
"label": "Sarah Young",
"value": "4"
},
{
"label": "James Jones",
"value": "5"
},
{
"label": "Larry Robinson",
"value": "6"
},
{
"label": "Christopher Perez",
"value": "7"
},
{
"label": "Sharon Davis",
"value": "8"
},
{
"label": "Kenneth Anderson",
"value": "9"
},
{
"label": "Deborah Lewis",
"value": "10"
},
{
"label": "Jennifer Lewis",
"value": "11"
},
{
"label": "Laura Miller",
"value": "12"
},
{
"label": "Larry Harris",
"value": "13"
},
{
"label": "Patricia Robinson",
"value": "14"
},
{
"label": "Mark Davis",
"value": "15"
},
{
"label": "Jessica Harris",
"value": "16"
},
{
"label": "Anna Brown",
"value": "17"
},
{
"label": "Lisa Young",
"value": "18"
},
{
"label": "Donna Williams",
"value": "19"
},
{
"label": "Shirley Davis",
"value": "20"
},
{
"label": "Richard Clark",
"value": "21"
},
{
"label": "Cynthia Martinez",
"value": "22"
},
{
"label": "Kimberly Walker",
"value": "23"
},
{
"label": "Timothy Anderson",
"value": "24"
},
{
"label": "Betty Lee",
"value": "25"
},
{
"label": "Jeffrey Allen",
"value": "26"
},
{
"label": "Karen Martinez",
"value": "27"
},
{
"label": "Anna Lopez",
"value": "28"
},
{
"label": "Dorothy Anderson",
"value": "29"
},
{
"label": "David Perez",
"value": "30"
},
{
"label": "Dorothy Martin",
"value": "31"
},
{
"label": "George Johnson",
"value": "32"
},
{
"label": "Donald Jackson",
"value": "33"
},
{
"label": "Mary Brown",
"value": "34"
},
{
"label": "Deborah Martinez",
"value": "35"
},
{
"label": "Donald Jackson",
"value": "36"
},
{
"label": "Lisa Robinson",
"value": "37"
},
{
"label": "Laura Martinez",
"value": "38"
},
{
"label": "Timothy Taylor",
"value": "39"
},
{
"label": "Joseph Martinez",
"value": "40"
},
{
"label": "Karen Wilson",
"value": "41"
},
{
"label": "Karen Walker",
"value": "42"
},
{
"label": "William Martinez",
"value": "43"
},
{
"label": "Linda Brown",
"value": "44"
},
{
"label": "Elizabeth Brown",
"value": "45"
},
{
"label": "Anna Moore",
"value": "46"
},
{
"label": "Robert Martinez",
"value": "47"
},
{
"label": "Edward Hernandez",
"value": "48"
},
{
"label": "Elizabeth Hall",
"value": "49"
},
{
"label": "Linda Jackson",
"value": "50"
},
{
"label": "Brian Jones",
"value": "51"
},
{
"label": "Amy Thompson",
"value": "52"
},
{
"label": "Kimberly Wilson",
"value": "53"
},
{
"label": "Nancy Garcia",
"value": "54"
},
{
"label": "Mary Thompson",
"value": "55"
}
].map(function (item, index) {
return Object.assign({}, item, {
id: index + 1
});
});

View File

@ -0,0 +1,242 @@
/** Transfer分页接口 */
module.exports = function (req, res) {
const perPage = Number(req.query.perPage || 10);
const page = Number(req.query.page || 1);
res.json({
status: 0,
msg: 'ok',
data: {
count: data.length,
page: page,
items: data.concat().splice((page - 1) * perPage, perPage)
}
});
};
const data = [
{
"label": "Laura Lewis",
"value": "1"
},
{
"label": "David Gonzalez",
"value": "2"
},
{
"label": "Christopher Rodriguez",
"value": "3"
},
{
"label": "Sarah Young",
"value": "4"
},
{
"label": "James Jones",
"value": "5"
},
{
"label": "Larry Robinson",
"value": "6"
},
{
"label": "Christopher Perez",
"value": "7"
},
{
"label": "Sharon Davis",
"value": "8"
},
{
"label": "Kenneth Anderson",
"value": "9"
},
{
"label": "Deborah Lewis",
"value": "10"
},
{
"label": "Jennifer Lewis",
"value": "11"
},
{
"label": "Laura Miller",
"value": "12"
},
{
"label": "Larry Harris",
"value": "13"
},
{
"label": "Patricia Robinson",
"value": "14"
},
{
"label": "Mark Davis",
"value": "15"
},
{
"label": "Jessica Harris",
"value": "16"
},
{
"label": "Anna Brown",
"value": "17"
},
{
"label": "Lisa Young",
"value": "18"
},
{
"label": "Donna Williams",
"value": "19"
},
{
"label": "Shirley Davis",
"value": "20"
},
{
"label": "Richard Clark",
"value": "21"
},
{
"label": "Cynthia Martinez",
"value": "22"
},
{
"label": "Kimberly Walker",
"value": "23"
},
{
"label": "Timothy Anderson",
"value": "24"
},
{
"label": "Betty Lee",
"value": "25"
},
{
"label": "Jeffrey Allen",
"value": "26"
},
{
"label": "Karen Martinez",
"value": "27"
},
{
"label": "Anna Lopez",
"value": "28"
},
{
"label": "Dorothy Anderson",
"value": "29"
},
{
"label": "David Perez",
"value": "30"
},
{
"label": "Dorothy Martin",
"value": "31"
},
{
"label": "George Johnson",
"value": "32"
},
{
"label": "Donald Jackson",
"value": "33"
},
{
"label": "Mary Brown",
"value": "34"
},
{
"label": "Deborah Martinez",
"value": "35"
},
{
"label": "Donald Jackson",
"value": "36"
},
{
"label": "Lisa Robinson",
"value": "37"
},
{
"label": "Laura Martinez",
"value": "38"
},
{
"label": "Timothy Taylor",
"value": "39"
},
{
"label": "Joseph Martinez",
"value": "40"
},
{
"label": "Karen Wilson",
"value": "41"
},
{
"label": "Karen Walker",
"value": "42"
},
{
"label": "William Martinez",
"value": "43"
},
{
"label": "Linda Brown",
"value": "44"
},
{
"label": "Elizabeth Brown",
"value": "45"
},
{
"label": "Anna Moore",
"value": "46"
},
{
"label": "Robert Martinez",
"value": "47"
},
{
"label": "Edward Hernandez",
"value": "48"
},
{
"label": "Elizabeth Hall",
"value": "49"
},
{
"label": "Linda Jackson",
"value": "50"
},
{
"label": "Brian Jones",
"value": "51"
},
{
"label": "Amy Thompson",
"value": "52"
},
{
"label": "Kimberly Wilson",
"value": "53"
},
{
"label": "Nancy Garcia",
"value": "54"
},
{
"label": "Mary Thompson",
"value": "55"
}
].map(function (item, index) {
return Object.assign({}, item, {
id: index + 1
});
});

View File

@ -37,6 +37,7 @@ import {
FormBaseControl
} from './Item';
import {IFormItemStore} from '../store/formItem';
import {isObject} from 'amis-core';
export type OptionsControlComponent = React.ComponentType<FormControlProps>;
@ -230,7 +231,11 @@ export interface OptionsControlProps
selectedOptions: Array<Option>;
setOptions: (value: Array<any>, skipNormalize?: boolean) => void;
setLoading: (value: boolean) => void;
reloadOptions: (setError?: boolean) => void;
reloadOptions: (
setError?: boolean,
isInit?: boolean,
data?: Record<string, any>
) => void;
deferLoad: (option: Option) => void;
leftDeferLoad: (option: Option, leftOptions: Option) => void;
expandTreeOptions: (nodePathArr: any[]) => void;
@ -443,15 +448,12 @@ export function registerOptionsControl(config: OptionsConfig) {
);
if (prevOptions !== options) {
formItem.setOptions(
normalizeOptions(
options || [],
undefined,
props.valueField || 'value'
),
this.changeOptionValue,
props.data
formItem.loadOptionsFromDataScope(
props.source as string,
props.data,
this.changeOptionValue
);
this.normalizeValue();
}
} else if (
@ -792,20 +794,16 @@ export function registerOptionsControl(config: OptionsConfig) {
}
@autobind
reloadOptions(setError?: boolean, isInit = false) {
const {source, formItem, data, onChange, setPrinstineValue, valueField} =
reloadOptions(setError?: boolean, isInit = false, data = this.props.data) {
const {source, formItem, onChange, setPrinstineValue, valueField} =
this.props;
if (formItem && isPureVariable(source as string)) {
isAlive(formItem) &&
formItem.setOptions(
normalizeOptions(
resolveVariableAndFilter(source as string, data, '| raw') || [],
undefined,
valueField
),
this.changeOptionValue,
data
formItem.loadOptionsFromDataScope(
source as string,
data,
this.changeOptionValue
);
return;
} else if (!formItem || !isEffectiveApi(source, data)) {

View File

@ -156,7 +156,8 @@ export function wrapControl<
minLength,
maxLength,
validateOnChange,
label
label,
pagination
}
} = this.props;
@ -230,7 +231,8 @@ export function wrapControl<
validateOnChange,
label,
inputGroupControl,
extraName
extraName,
pagination
});
// issue 这个逻辑应该在 combo 里面自己实现。
@ -380,7 +382,8 @@ export function wrapControl<
'minLength',
'maxLength',
'label',
'extraName'
'extraName',
'pagination'
],
prevProps.$schema,
props.$schema,

View File

@ -7,11 +7,13 @@ import {
Instance
} from 'mobx-state-tree';
import isEqualWith from 'lodash/isEqualWith';
import uniqWith from 'lodash/uniqWith';
import {FormStore, IFormStore} from './form';
import {str2rules, validate as doValidate} from '../utils/validations';
import {Api, Payload, fetchOptions, ApiObject} from '../types';
import {ComboStore, IComboStore, IUniqueGroup} from './combo';
import {evalExpression} from '../utils/tpl';
import {resolveVariableAndFilter} from '../utils/tpl-builtin';
import {buildApi, isEffectiveApi} from '../utils/api';
import findIndex from 'lodash/findIndex';
import {
@ -98,6 +100,7 @@ export const FormItemStore = StoreNode.named('FormItemStore')
joinValues: true,
extractValue: false,
options: types.optional(types.frozen<Array<any>>(), []),
optionsRaw: types.optional(types.frozen<Array<any>>(), []),
expressionsInOptions: false,
selectFirst: false,
autoFill: types.frozen(),
@ -113,7 +116,18 @@ export const FormItemStore = StoreNode.named('FormItemStore')
/** 当前表单项所属的InputGroup父元素, 用于收集InputGroup的子元素 */
inputGroupControl: types.optional(types.frozen(), {}),
colIndex: types.frozen(),
rowIndex: types.frozen()
rowIndex: types.frozen(),
/** Transfer组件分页模式 */
pagination: types.optional(types.frozen(), {
enable: false,
/** 当前页数 */
page: 1,
/** 每页显示条数 */
perPage: 10,
/** 总条数 */
total: 0
}),
accumulatedOptions: types.optional(types.frozen<Array<any>>(), [])
})
.views(self => {
function getForm(): any {
@ -175,6 +189,26 @@ export const FormItemStore = StoreNode.named('FormItemStore')
return getLastOptionValue();
},
/** 数据源接口数据是否开启分页 */
get enableSourcePagination(): boolean {
return !!self.pagination.enable;
},
/** 数据源接口开启分页时当前页码 */
get sourcePageNum(): number {
return self.pagination.page ?? 1;
},
/** 数据源接口开启分页时每页显示条数 */
get sourcePerPageNum(): number {
return self.pagination.perPage ?? 10;
},
/** 数据源接口开启分页时数据总条数 */
get sourceTotalNum(): number {
return self.pagination.total ?? 0;
},
getSelectedOptions: (
value: any = self.tmpValue,
nodeValueArray?: any[] | undefined
@ -308,7 +342,8 @@ export const FormItemStore = StoreNode.named('FormItemStore')
minLength,
validateOnChange,
label,
inputGroupControl
inputGroupControl,
pagination
}: {
extraName?: string;
required?: boolean;
@ -338,6 +373,11 @@ export const FormItemStore = StoreNode.named('FormItemStore')
path: string;
[propsName: string]: any;
};
pagination?: {
enable?: boolean;
page?: number;
perPage?: number;
};
}) {
if (typeof rules === 'string') {
rules = str2rules(rules);
@ -372,6 +412,15 @@ export const FormItemStore = StoreNode.named('FormItemStore')
inputGroupControl?.name != null &&
(self.inputGroupControl = inputGroupControl);
if (pagination && isObject(pagination) && !!pagination.enable) {
self.pagination = {
enable: true,
page: pagination.page ? pagination.page || 1 : 1,
perPage: pagination.perPage ? pagination.perPage || 10 : 10,
total: 0
};
}
if (
typeof rules !== 'undefined' ||
typeof required !== 'undefined' ||
@ -556,6 +605,23 @@ export const FormItemStore = StoreNode.named('FormItemStore')
}
}
function setPagination(params: {
page?: number;
perPage?: number;
total?: number;
}) {
const {page, perPage, total} = params || {};
if (self.enableSourcePagination) {
self.pagination = {
...self.pagination,
...(page != null && typeof page === 'number' ? {page} : {}),
...(perPage != null && typeof perPage === 'number' ? {perPage} : {}),
...(total != null && typeof total === 'number' ? {total} : {})
};
}
}
function setOptions(
options: Array<object>,
onChange?: (value: any) => void,
@ -567,6 +633,15 @@ export const FormItemStore = StoreNode.named('FormItemStore')
options = filterTree(options, item => item);
const originOptions = self.options.concat();
self.options = options;
/** 开启分页后当前选项内容需要累加 */
self.accumulatedOptions = self.enableSourcePagination
? uniqWith(
[...originOptions, ...options],
(lhs, rhs) =>
lhs[self.valueField ?? 'value'] ===
rhs[self.valueField ?? 'value']
)
: options;
syncOptions(originOptions, data);
let selectedOptions;
@ -722,6 +797,14 @@ export const FormItemStore = StoreNode.named('FormItemStore')
options = normalizeOptions(options as any, undefined, self.valueField);
if (self.enableSourcePagination) {
self.pagination = {
...self.pagination,
page: parseInt(json.data?.page, 10) || 1,
total: parseInt(json.data?.total ?? json.data?.count, 10) || 0
};
}
if (config?.extendsOptions && self.selectedOptions.length > 0) {
self.selectedOptions.forEach((item: any) => {
const exited = findTree(
@ -752,6 +835,41 @@ export const FormItemStore = StoreNode.named('FormItemStore')
return json;
});
/**
* source变量解析后是全量的数据源
*/
function loadOptionsFromDataScope(
source: string,
ctx: Record<string, any>,
onChange?: (value: any) => void
) {
let options: any[] = resolveVariableAndFilter(source, ctx, '| raw');
if (!Array.isArray(options)) {
return [];
}
options = normalizeOptions(options, undefined, self.valueField);
if (self.enableSourcePagination) {
self.pagination = {
...self.pagination,
...(ctx?.page ? {page: ctx?.page} : {}),
...(ctx?.perPage ? {perPage: ctx?.perPage} : {}),
total: options.length
};
}
options = options.slice(
(self.pagination.page - 1) * self.pagination.perPage,
self.pagination.page * self.pagination.perPage
);
setOptions(options, onChange, ctx);
return options;
}
const loadAutoUpdateData: (
api: Api,
data?: object,
@ -1377,8 +1495,10 @@ export const FormItemStore = StoreNode.named('FormItemStore')
setError,
addError,
clearError,
setPagination,
setOptions,
loadOptions,
loadOptionsFromDataScope,
deferLoadOptions,
deferLoadLeftOptions,
expandTreeOptions,

View File

@ -786,6 +786,7 @@
--transfer-base-header-paddingBottom: var(--sizes-size-5);
--transfer-base-header-paddingLeft: var(--sizes-size-8);
--transfer-base-header-paddingRight: var(--sizes-size-8);
--transfer-base-footer-border-color: var(--colors-neutral-line-8);
--transfer-base-body-paddingTop: var(--sizes-size-0);
--transfer-base-body-paddingBottom: var(--sizes-size-0);
--transfer-base-body-paddingLeft: var(--sizes-size-0);

View File

@ -39,6 +39,42 @@
}
}
&-footer {
border-top: 1px solid var(--transfer-base-footer-border-color);
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
padding: var(--gap-sm);
/* 底部空间较小让Pagination紧凑一些 */
&-pagination {
& > ul {
&.#{$ns}Pagination-item {
margin-left: 0;
}
& > li {
--Pagination-minWidth: #{px2rem(22px)};
--Pagination-height: #{px2rem(22px)};
--Pagination-padding: 0 #{px2rem(6px)};
}
}
.#{$ns}Pagination-perpage {
--select-base-default-paddingTop: 0;
--select-base-default-paddingBottom: 0;
--select-base-default-paddingLeft: #{px2rem(6px)};
--select-base-default-paddingRight: #{px2rem(6px)};
margin-left: 0;
.#{$ns}Select-valueWrap {
line-height: #{px2rem(22px)};
}
}
}
}
&-select,
&-result {
overflow: hidden;
@ -64,6 +100,10 @@
var(--transfer-base-top-right-border-radius)
var(--transfer-base-bottom-right-border-radius)
var(--transfer-base-bottom-left-border-radius);
&--pagination {
max-height: px2rem(475px);
}
}
&-select > &-selection,

View File

@ -4,7 +4,6 @@ import includes from 'lodash/includes';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import unionWith from 'lodash/unionWith';
import {ThemeProps, themeable, findTree, differenceFromAll} from 'amis-core';
import {BaseSelectionProps, BaseSelection, ItemRenderStates} from './Selection';
import {Options, Option} from './Select';
@ -24,6 +23,7 @@ import {ItemRenderStates as ResultItemRenderStates} from './ResultList';
import ResultTableList from './ResultTableList';
import ResultTreeList from './ResultTreeList';
import {SpinnerExtraProps} from './Spinner';
import Pagination from './Pagination';
export type SelectMode =
| 'table'
@ -113,6 +113,44 @@ export interface TransferProps
checkAllLabel?: string;
/** 树形模式下,给 tree 的属性 */
onlyChildren?: boolean;
/** 分页模式下累积的选项值,用于右侧回显 */
accumulatedOptions?: Option[];
/** 分页配置 */
pagination?: {
/** 是否开启分页 */
enable: boolean;
/** 分页组件CSS类名 */
className?: string;
/**
* layout属性的顺序 total,perPage,pager,go
* @default 'pager'
*/
layout?: string | Array<string>;
/**
*
* @default [10, 20, 50, 100]
*/
perPageAvailable?: Array<number>;
/**
*
*
* @default 5
*/
maxButtons?: number;
page?: number;
perPage?: number;
total?: number;
popOverContainer?: any;
popOverContainerSelector?: string;
};
/** 切换分页事件 */
onPageChange?: (
page: number,
perPage?: number,
direction?: 'forward' | 'backward'
) => void;
}
export interface TransferState {
@ -549,10 +587,33 @@ export class Transfer<
{this.state.searchResult !== null
? this.renderSearchResult(props)
: this.renderOptions(props)}
{this.renderFooter()}
</>
);
}
renderFooter() {
const {classnames: cx, pagination, onPageChange} = this.props;
return pagination?.enable ? (
<div className={cx('Transfer-footer')}>
<Pagination
className={cx('Transfer-footer-pagination', pagination.className)}
activePage={pagination.page}
perPage={pagination.perPage}
total={pagination.total}
layout={pagination.layout}
maxButtons={pagination.maxButtons}
perPageAvailable={pagination.perPageAvailable}
popOverContainer={pagination.popOverContainer}
popOverContainerSelector={pagination.popOverContainerSelector}
onPageChange={onPageChange}
/>
</div>
) : null;
}
renderSearchResult(props: TransferProps) {
const {
searchResultMode,
@ -827,9 +888,10 @@ export class Transfer<
virtualThreshold,
itemHeight,
loadingConfig,
showInvalidMatch
showInvalidMatch,
pagination,
accumulatedOptions
} = this.props;
const {resultSelectMode, isTreeDeferLoad} = this.state;
const searchable = !isTreeDeferLoad && resultSearchable;
@ -840,7 +902,7 @@ export class Transfer<
ref={this.domResultRef}
classnames={cx}
columns={columns!}
options={options || []}
options={(pagination?.enable ? accumulatedOptions : options) || []}
value={value}
disabled={disabled}
option2value={option2value}
@ -862,7 +924,7 @@ export class Transfer<
loadingConfig={loadingConfig}
classnames={cx}
className={cx('Transfer-value')}
options={options}
options={(pagination?.enable ? accumulatedOptions : options) || []}
valueField={'value'}
value={value || []}
onChange={onChange!}
@ -915,7 +977,8 @@ export class Transfer<
selectMode = 'list',
translate: __,
valueField = 'value',
mobileUI
mobileUI,
pagination
} = this.props as any;
const {searchResult} = this.state;
@ -939,7 +1002,11 @@ export class Transfer<
<div
className={cx('Transfer', className, inline ? 'Transfer--inline' : '')}
>
<div className={cx('Transfer-select')}>
<div
className={cx('Transfer-select', {
'Transfer-select--pagination': !!pagination?.enable
})}
>
{this.renderSelect(this.props)}
</div>
<div className={cx('Transfer-mid', {'is-mobile': mobileUI})}>
@ -949,7 +1016,12 @@ export class Transfer<
</div>
) : null}
</div>
<div className={cx('Transfer-result', {'is-mobile': mobileUI})}>
<div
className={cx('Transfer-result', {
'is-mobile': mobileUI,
'Transfer-select--pagination': !!pagination?.enable
})}
>
<div
className={cx(
'Transfer-title',

View File

@ -1392,7 +1392,6 @@ test('Renderer:transfer search highlight', async () => {
});
test('Renderer:transfer tree search', async () => {
const onSubmit = jest.fn();
const {container, findByText, getByText} = render(
amisRender(
@ -1486,7 +1485,7 @@ test('Renderer:transfer tree search', async () => {
});
await(300);
const libai = getByText('李白');
expect(libai).not.toBeNull();
fireEvent.click(libai);
@ -1501,4 +1500,310 @@ test('Renderer:transfer tree search', async () => {
expect(onSubmit.mock.calls[0][0]).toEqual({
transfer: "caocao,libai"
});
});
});
test('Renderer:Transfer with pagination', async () => {
const mockData = [
{
"label": "Laura Lewis",
"value": "1"
},
{
"label": "David Gonzalez",
"value": "2"
},
{
"label": "Christopher Rodriguez",
"value": "3"
},
{
"label": "Sarah Young",
"value": "4"
},
{
"label": "James Jones",
"value": "5"
},
{
"label": "Larry Robinson",
"value": "6"
},
{
"label": "Christopher Perez",
"value": "7"
},
{
"label": "Sharon Davis",
"value": "8"
},
{
"label": "Kenneth Anderson",
"value": "9"
},
{
"label": "Deborah Lewis",
"value": "10"
},
{
"label": "Jennifer Lewis",
"value": "11"
},
{
"label": "Laura Miller",
"value": "12"
},
{
"label": "Larry Harris",
"value": "13"
},
{
"label": "Patricia Robinson",
"value": "14"
},
{
"label": "Mark Davis",
"value": "15"
},
{
"label": "Jessica Harris",
"value": "16"
},
{
"label": "Anna Brown",
"value": "17"
},
{
"label": "Lisa Young",
"value": "18"
},
{
"label": "Donna Williams",
"value": "19"
},
{
"label": "Shirley Davis",
"value": "20"
}
];
const fetcher = jest.fn().mockImplementation((api) => {
const perPage = 10; /** 锁死10个方便测试 */
const page = Number(api.query.page || 1);
return Promise.resolve({
data: {
status: 0,
msg: 'ok',
data: {
count: mockData.length,
page: page,
items: mockData.concat().splice((page - 1) * perPage, perPage)
}
}
});
});
const {container} = render(
amisRender(
{
"type": "form",
"debug": true,
"body": [
{
"label": "默认",
"type": "transfer",
"name": "transfer",
"joinValues": false,
"extractValue": false,
"source": "/api/mock2/options/transfer?page=${page}&perPage=${perPage}",
"pagination": {
"enable": true,
"layout": ["pager", "perpage", "total"],
"popOverContainerSelector": ".cxd-Panel--form"
},
"value": [
{"label": "Laura Lewis", "value": "1", id: 1},
{"label": "Christopher Rodriguez", "value": "3", id: 3},
{"label": "Laura Miller", "value": "12", id: 12},
{"label": "Patricia Robinson", "value": "14", id: 14}
]
}
]
}, {}, makeEnv({fetcher})));
await wait(500);
expect(container.querySelector('.cxd-Transfer-footer-pagination')).toBeInTheDocument();
const checkboxes = container.querySelectorAll('input[type=checkbox]')!;
expect(checkboxes.length).toEqual(11); /** 包括顶部全选 */
expect((checkboxes[1] as HTMLInputElement)?.checked).toEqual(true);
expect((checkboxes[2] as HTMLInputElement)?.checked).toEqual(false);
expect((checkboxes[3] as HTMLInputElement)?.checked).toEqual(true);
expect((checkboxes[4] as HTMLInputElement)?.checked).toEqual(false);
const nextBtn = container.querySelector('.cxd-Pagination-next')!;
fireEvent.click(nextBtn);
await wait(500);
const checkboxes2 = container.querySelectorAll('input[type=checkbox]')!;
expect(checkboxes2.length).toEqual(11);
expect((checkboxes2[1] as HTMLInputElement)?.checked).toEqual(false);
expect((checkboxes2[2] as HTMLInputElement)?.checked).toEqual(true);
expect((checkboxes2[3] as HTMLInputElement)?.checked).toEqual(false);
expect((checkboxes2[4] as HTMLInputElement)?.checked).toEqual(true);
})
test.only('Renderer:Transfer with pagination and data source from data scope', async () => {
const mockData = [
{
"label": "Laura Lewis",
"value": "1"
},
{
"label": "David Gonzalez",
"value": "2"
},
{
"label": "Christopher Rodriguez",
"value": "3"
},
{
"label": "Sarah Young",
"value": "4"
},
{
"label": "James Jones",
"value": "5"
},
{
"label": "Larry Robinson",
"value": "6"
},
{
"label": "Christopher Perez",
"value": "7"
},
{
"label": "Sharon Davis",
"value": "8"
},
{
"label": "Kenneth Anderson",
"value": "9"
},
{
"label": "Deborah Lewis",
"value": "10"
},
{
"label": "Jennifer Lewis",
"value": "11"
},
{
"label": "Laura Miller",
"value": "12"
},
{
"label": "Larry Harris",
"value": "13"
},
{
"label": "Patricia Robinson",
"value": "14"
},
{
"label": "Mark Davis",
"value": "15"
},
{
"label": "Jessica Harris",
"value": "16"
},
{
"label": "Anna Brown",
"value": "17"
},
{
"label": "Lisa Young",
"value": "18"
},
{
"label": "Donna Williams",
"value": "19"
},
{
"label": "Shirley Davis",
"value": "20"
}
];
const fetcher = jest.fn().mockImplementation((api) => {
return Promise.resolve({
data: {
status: 0,
msg: 'ok',
data: {
count: mockData.length,
items: mockData
}
}
});
});
const {container} = render(
amisRender(
{
"type": "form",
"debug": true,
"body": [
{
"type": "service",
"api": {
"url": "/api/mock2/options/loadDataOnce",
"method": "get",
"responseData": {
"transferOptions": "${items}"
}
},
body: [
{
"label": "默认",
"type": "transfer",
"name": "transfer",
"joinValues": false,
"extractValue": false,
"source": "${transferOptions}",
"pagination": {
"enable": true,
"layout": ["pager", "perpage", "total"],
"popOverContainerSelector": ".cxd-Panel--form"
},
"value": [
{"label": "Laura Lewis", "value": "1", id: 1},
{"label": "Christopher Rodriguez", "value": "3", id: 3},
{"label": "Laura Miller", "value": "12", id: 12},
{"label": "Patricia Robinson", "value": "14", id: 14}
]
}
]
}
]
}, {}, makeEnv({fetcher})));
await wait(500);
expect(container.querySelector('.cxd-Transfer-footer-pagination')).toBeInTheDocument();
const checkboxes = container.querySelectorAll('input[type=checkbox]')!;
expect(checkboxes.length).toEqual(11); /** 包括顶部全选 */
expect((checkboxes[1] as HTMLInputElement)?.checked).toEqual(true);
expect((checkboxes[2] as HTMLInputElement)?.checked).toEqual(false);
expect((checkboxes[3] as HTMLInputElement)?.checked).toEqual(true);
expect((checkboxes[4] as HTMLInputElement)?.checked).toEqual(false);
const nextBtn = container.querySelector('.cxd-Pagination-next')!;
fireEvent.click(nextBtn);
await wait(500);
const checkboxes2 = container.querySelectorAll('input[type=checkbox]')!;
expect(checkboxes2.length).toEqual(11);
expect((checkboxes2[1] as HTMLInputElement)?.checked).toEqual(false);
expect((checkboxes2[2] as HTMLInputElement)?.checked).toEqual(true);
expect((checkboxes2[3] as HTMLInputElement)?.checked).toEqual(false);
expect((checkboxes2[4] as HTMLInputElement)?.checked).toEqual(true);
})

View File

@ -1,17 +1,17 @@
import React from 'react';
import find from 'lodash/find';
import pick from 'lodash/pick';
import {isAlive} from 'mobx-state-tree';
import {matchSorter} from 'match-sorter';
import {
OptionsControlProps,
OptionsControl,
FormOptionsControl,
resolveEventData,
str2function,
getOptionValueBindField
} from 'amis-core';
import {SpinnerExtraProps, Transfer} from 'amis-ui';
import type {Option} from 'amis-core';
import {
getOptionValueBindField,
isEffectiveApi,
isPureVariable,
resolveVariableAndFilter,
autobind,
filterTree,
string2regExp,
@ -20,18 +20,25 @@ import {
findTreeIndex,
getTree,
spliceTree,
mapTree
mapTree,
optionValueCompare,
resolveVariable,
ActionObject,
toNumber
} from 'amis-core';
import {Spinner} from 'amis-ui';
import {optionValueCompare} from 'amis-core';
import {resolveVariable} from 'amis-core';
import {FormOptionsSchema, SchemaApi, SchemaObject} from '../../Schema';
import {Selection as BaseSelection} from 'amis-ui';
import {ResultList} from 'amis-ui';
import {ActionObject, toNumber} from 'amis-core';
import type {ItemRenderStates} from 'amis-ui/lib/components/Selection';
import {SpinnerExtraProps, Transfer, Spinner, ResultList} from 'amis-ui';
import {
FormOptionsSchema,
SchemaApi,
SchemaObject,
SchemaExpression,
SchemaClassName
} from '../../Schema';
import {supportStatic} from './StaticHoc';
import {matchSorter} from 'match-sorter';
import type {ItemRenderStates} from 'amis-ui/lib/components/Selection';
import type {Option} from 'amis-core';
import type {PaginationSchema} from '../Pagination';
/**
* Transfer
@ -161,6 +168,22 @@ export interface TransferControlSchema
*
*/
onlyChildren?: boolean;
/**
* selectMode为默认和table才会生效
* @since 3.6.0
*/
pagination?: {
/** 是否左侧选项分页,默认不开启 */
enable: SchemaExpression;
/** 分页组件CSS类名 */
className?: SchemaClassName;
/** 是否开启前端分页 */
loadDataOnce?: boolean;
} & Pick<
PaginationSchema,
'layout' | 'maxButtons' | 'perPageAvailable' | 'popOverContainerSelector'
>;
}
export interface BaseTransferProps
@ -427,6 +450,30 @@ export class BaseTransferRenderer<
return regexp.test(labelTest) || regexp.test(valueTest);
}
@autobind
handlePageChange(
page: number,
perPage?: number,
direction?: 'forward' | 'backward'
) {
const {source, data, formItem, onChange} = this.props;
const ctx = createObject(data, {
page: page ?? 1,
perPage: perPage ?? 10,
...(direction ? {pageDir: direction} : {})
});
if (!formItem || !isAlive(formItem)) {
return;
}
if (isPureVariable(source)) {
formItem.loadOptionsFromDataScope(source, ctx, onChange);
} else if (isEffectiveApi(source, ctx)) {
formItem.loadOptions(source, ctx, undefined, false, onChange, false);
}
}
@autobind
optionItemRender(option: Option, states: ItemRenderStates) {
const {menuTpl, render, data} = this.props;
@ -544,7 +591,11 @@ export class BaseTransferRenderer<
showInvalidMatch,
onlyChildren,
mobileUI,
noResultsText
noResultsText,
pagination,
formItem,
env,
popOverContainer
} = this.props;
// 目前 LeftOptions 没有接口可以动态加载
@ -570,6 +621,7 @@ export class BaseTransferRenderer<
onlyChildren={onlyChildren}
value={selectedOptions}
options={options}
accumulatedOptions={formItem?.accumulatedOptions ?? []}
disabled={disabled}
onChange={this.handleChange}
option2value={this.option2value}
@ -607,6 +659,28 @@ export class BaseTransferRenderer<
showInvalidMatch={showInvalidMatch}
mobileUI={mobileUI}
noResultsText={noResultsText}
pagination={{
...pick(pagination, [
'className',
'layout',
'perPageAvailable',
'popOverContainerSelector'
]),
enable:
!!formItem?.enableSourcePagination &&
(!selectMode ||
selectMode === 'list' ||
selectMode === 'table') &&
options.length > 0,
maxButtons: Number.isInteger(pagination?.maxButtons)
? pagination.maxButtons
: 5,
page: formItem?.sourcePageNum,
perPage: formItem?.sourcePerPageNum,
total: formItem?.sourceTotalNum,
popOverContainer: popOverContainer ?? env?.getModalContainer
}}
onPageChange={this.handlePageChange}
/>
<Spinner