Merge pull request #629 from 2betop/master

Transfer 中的 chained、tree 模式,选项支持延时加载
This commit is contained in:
RickCole 2020-05-28 18:30:10 +08:00 committed by GitHub
commit a0b2370d09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 399 additions and 104 deletions

View File

@ -386,6 +386,46 @@ export default {
]
}
]
},
{
label: '延时加载',
type: 'transfer',
name: 'transfer7',
selectMode: 'tree',
deferApi: '/api/mock2/form/deferOptions?label=${label}',
options: [
{
label: '法师',
children: [
{
label: '诸葛亮',
value: 'zhugeliang'
}
]
},
{
label: '战士',
defer: true
},
{
label: '打野',
children: [
{
label: '李白',
value: 'libai'
},
{
label: '韩信',
value: 'hanxin'
},
{
label: '云中君',
value: 'yunzhongjun'
}
]
}
]
}
]
}

23
mock/form/deferOptions.js Normal file
View File

@ -0,0 +1,23 @@
module.exports = function (req, res) {
let repeat = 2 + Math.round(Math.random() * 5);
const options = [];
while (repeat--) {
const value = Math.round(Math.random() * 1000000);
const label = value + '';
options.push({
label: label,
value: value,
defer: Math.random() > 0.7
});
}
res.json({
status: 0,
msg: '',
data: {
options: options
}
});
};

View File

@ -404,6 +404,7 @@
&-sublist {
position: relative;
margin: 0 0 0 px2rem(35px);
display: none;
&:before {
width: 1px;
@ -420,8 +421,8 @@
&-item {
position: relative;
&.is-collapsed > .#{$ns}TreeCheckboxes-sublist {
display: none;
&.is-expanded > .#{$ns}TreeCheckboxes-sublist {
display: block;
}
}
@ -442,6 +443,7 @@
&-itemInner {
display: flex;
align-items: center;
height: $Form-input-height;
line-height: $Form-input-lineHeight;
font-size: $Form-input-fontSize;
@ -452,6 +454,7 @@
> .#{$ns}Checkbox {
margin-right: 0;
margin-left: $gap-sm;
}
cursor: pointer;
user-select: none;
@ -481,7 +484,7 @@
&-col {
flex-grow: 1;
min-width: 120px;
min-width: 150px;
}
&-col:not(:last-child) {

View File

@ -6,9 +6,10 @@ import Checkbox from './Checkbox';
import {Option} from './Select';
import {getTreeDepth} from '../utils/helper';
import times from 'lodash/times';
import Spinner from './Spinner';
export interface ChainedCheckboxesState {
selected: Array<Option>;
selected: Array<string>;
}
export class ChainedCheckboxes extends Checkboxes<
@ -20,17 +21,22 @@ export class ChainedCheckboxes extends Checkboxes<
selected: []
};
selectOption(option: Option, depth: number) {
selectOption(option: Option, depth: number, id: string) {
const {onDeferLoad} = this.props;
const selected = this.state.selected.concat();
selected.splice(depth, selected.length - depth);
selected.push(option);
selected.push(id);
this.setState({
this.setState(
{
selected
});
},
option.defer && onDeferLoad ? () => onDeferLoad(option) : undefined
);
}
renderOption(option: Option, index: number, depth: number) {
renderOption(option: Option, index: number, depth: number, id: string) {
const {
labelClassName,
disabled,
@ -40,7 +46,7 @@ export class ChainedCheckboxes extends Checkboxes<
} = this.props;
const valueArray = this.valueArray;
if (Array.isArray(option.children)) {
if (Array.isArray(option.children) || option.defer) {
return (
<div
key={index}
@ -49,13 +55,15 @@ export class ChainedCheckboxes extends Checkboxes<
itemClassName,
option.className,
disabled || option.disabled ? 'is-disabled' : '',
~this.state.selected.indexOf(option) ? 'is-active' : ''
~this.state.selected.indexOf(id) ? 'is-active' : ''
)}
onClick={() => this.selectOption(option, depth)}
onClick={() => this.selectOption(option, depth, id)}
>
<div className={cx('ChainedCheckboxes-itemLabel')}>
{itemRender(option)}
</div>
{option.defer && option.loading ? <Spinner size="sm" show /> : null}
</div>
);
}
@ -101,26 +109,29 @@ export class ChainedCheckboxes extends Checkboxes<
let body: Array<React.ReactNode> = [];
if (Array.isArray(options) && options.length) {
const selected: Array<Option | null> = this.state.selected.concat();
const depth = getTreeDepth(options);
times(depth - selected.length, () => selected.push(null));
const selected: Array<string | null> = this.state.selected.concat();
const depth = Math.min(getTreeDepth(options), 3);
times(Math.max(depth - selected.length, 1), () => selected.push(null));
selected.reduce(
(
{
body,
options,
subTitle
subTitle,
indexes
}: {
body: Array<React.ReactNode>;
options: Array<Option> | null;
subTitle?: string;
indexes: Array<number>;
},
selected,
depth
) => {
let nextOptions: Array<Option> = [];
let nextSubTitle: string = '';
let nextIndexes = indexes;
body.push(
<div key={depth} className={cx('ChainedCheckboxes-col')}>
@ -131,12 +142,15 @@ export class ChainedCheckboxes extends Checkboxes<
) : null}
{Array.isArray(options) && options.length
? options.map((option, index) => {
if (option === selected) {
const id = indexes.concat(index).join('-');
if (id === selected) {
nextSubTitle = option.subTitle;
nextOptions = option.children!;
nextIndexes = indexes.concat(index);
}
return this.renderOption(option, index, depth);
return this.renderOption(option, index, depth, id);
})
: null}
</div>
@ -145,12 +159,14 @@ export class ChainedCheckboxes extends Checkboxes<
return {
options: nextOptions,
subTitle: nextSubTitle,
indexes: nextIndexes,
body: body
};
},
{
options,
body
body,
indexes: []
}
);
}

View File

@ -21,6 +21,7 @@ export interface CheckboxesProps extends ThemeProps {
placeholder?: string;
value?: Array<any>;
onChange?: (value: Array<Option>) => void;
onDeferLoad?: (option: Option) => void;
inline?: boolean;
labelClassName?: string;
option2value?: (option: Option) => any;

View File

@ -18,6 +18,7 @@ export class ListCheckboxes extends Checkboxes {
} = this.props;
const valueArray = this.valueArray;
// todo 支持 option.defer 延时加载
if (Array.isArray(option.children)) {
return (
<div

View File

@ -22,15 +22,45 @@ import {findDOMNode} from 'react-dom';
import {ClassNamesFn, themeable} from '../theme';
import Checkbox from './Checkbox';
import Input from './Input';
import {Api} from '../types';
export interface Option {
label?: string;
// 可以用来给 Option 标记个范围,让数据展示更清晰。
// 这个只有在数值展示的时候显示。
scopeLabel?: string;
// 请保证数值唯一,多个选项值一致会认为是同一个选项。
value?: any;
// 是否禁用
disabled?: boolean;
// 支持嵌套
children?: Options;
// 是否可见
visible?: boolean;
// 最好不要用!因为有 visible 就够了。
hidden?: boolean;
// 描述
description?: string;
// 标记后数据延时加载
defer?: boolean;
// 如果设置了,优先级更高,不设置走 source 接口加载。
deferApi?: Api;
// 标记正在加载。只有 defer 为 true 时有意义。内部字段不可以外部设置
loading?: boolean;
// 只有设置了 defer 才有意义,内部字段不可以外部设置
loaded?: boolean;
[propName: string]: any;
}
export interface Options extends Array<Option> {}

View File

@ -85,7 +85,8 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
value,
onChange,
onSearch: searchable,
option2value
option2value,
onDeferLoad
} = this.props;
if (!Array.isArray(options) || !options.length) {
@ -130,6 +131,7 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
) : option.selectMode === 'tree' ? (
<TreeCheckboxes
@ -138,6 +140,7 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
) : option.selectMode === 'chained' ? (
<ChainedCheckboxes
@ -146,6 +149,7 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
) : (
<ListCheckboxes
@ -154,6 +158,7 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
)}
</Tab>

View File

@ -299,7 +299,8 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
value,
onChange,
option2value,
classnames: cx
classnames: cx,
onDeferLoad
} = this.props;
return selectMode === 'table' ? (
@ -310,6 +311,7 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
) : selectMode === 'tree' ? (
<TreeCheckboxes
@ -318,6 +320,7 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
) : selectMode === 'chained' ? (
<ChainedCheckboxes
@ -326,6 +329,7 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
) : (
<ListCheckboxes
@ -334,6 +338,7 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
);
}
@ -368,7 +373,7 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
>
<div className={cx('Transfer-select')}>{this.renderSelect()}</div>
<div className={cx('Transfer-mid')}>
{showArrow ? (
{showArrow /*todo 需要改成确认模式,即:点了按钮才到右边 */ ? (
<div className={cx('Transfer-arrow')}>
<Icon icon="right-arrow" />
</div>

View File

@ -4,12 +4,15 @@ import React from 'react';
import uncontrollable from 'uncontrollable';
import Checkbox from './Checkbox';
import {Option} from './Select';
import {autobind} from '../utils/helper';
import {autobind, eachTree, everyTree} from '../utils/helper';
import Spinner from './Spinner';
export interface TreeCheckboxesProps extends CheckboxesProps {}
export interface TreeCheckboxesProps extends CheckboxesProps {
expand?: 'all' | 'first' | 'root' | 'none';
}
export interface TreeCheckboxesState {
collapsed: Array<Option>;
expanded: Array<string>;
}
export class TreeCheckboxes extends Checkboxes<
@ -18,11 +21,60 @@ export class TreeCheckboxes extends Checkboxes<
> {
valueArray: Array<Option>;
state: TreeCheckboxesState = {
collapsed: []
expanded: []
};
static defaultProps = {
...Checkboxes.defaultProps,
expand: 'first' as 'first'
};
componentDidMount() {
this.syncExpanded();
}
componentDidUpdate(prevProps: TreeCheckboxesProps) {
const props = this.props;
if (
!this.state.expanded.length &&
(props.expand !== prevProps.expand || props.options !== prevProps.options)
) {
this.syncExpanded();
}
}
syncExpanded() {
const options = this.props.options;
const mode = this.props.expand;
const expanded: Array<string> = [];
if (!Array.isArray(options)) {
return;
}
if (mode === 'first' || mode === 'root') {
options.every((option, index) => {
if (Array.isArray(option.children)) {
expanded.push(`${index}`);
return mode === 'root';
}
return true;
});
} else if (mode === 'all') {
everyTree(options, (option, index, level, paths, indexes) => {
if (Array.isArray(option.children)) {
expanded.push(`${indexes.concat(index).join('-')}`);
}
return true;
});
}
this.setState({expanded});
}
toggleOption(option: Option) {
const {value, onChange, option2value, options} = this.props;
const {value, onChange, option2value, options, onDeferLoad} = this.props;
if (option.disabled) {
return;
@ -74,22 +126,26 @@ export class TreeCheckboxes extends Checkboxes<
onChange && onChange(newValue);
}
toggleCollapsed(option: Option) {
const collapsed = this.state.collapsed.concat();
const idx = collapsed.indexOf(option);
toggleCollapsed(option: Option, index: string) {
const onDeferLoad = this.props.onDeferLoad;
const expanded = this.state.expanded.concat();
const idx = expanded.indexOf(index);
if (~idx) {
collapsed.splice(idx, 1);
expanded.splice(idx, 1);
} else {
collapsed.push(option);
expanded.push(index);
}
this.setState({
collapsed
});
this.setState(
{
expanded: expanded
},
option.defer && onDeferLoad ? () => onDeferLoad(option) : undefined
);
}
renderItem(option: Option, index: number) {
renderItem(option: Option, index: number, indexes: Array<number> = []) {
const {
labelClassName,
disabled,
@ -97,6 +153,7 @@ export class TreeCheckboxes extends Checkboxes<
itemClassName,
itemRender
} = this.props;
const id = indexes.join('-');
const valueArray = this.valueArray;
let partial = false;
let checked = false;
@ -129,15 +186,17 @@ export class TreeCheckboxes extends Checkboxes<
checked = !!~valueArray.indexOf(option);
}
const collapsed = !!~this.state.collapsed.indexOf(option);
const expaned = !!~this.state.expanded.indexOf(id);
return (
<div
key={index}
className={cx(
'TreeCheckboxes-item',
disabled || option.disabled ? 'is-disabled' : '',
collapsed ? 'is-collapsed' : ''
disabled || option.disabled || (option.defer && option.loading)
? 'is-disabled'
: '',
expaned ? 'is-expanded' : ''
)}
>
<div
@ -148,13 +207,13 @@ export class TreeCheckboxes extends Checkboxes<
)}
onClick={() => this.toggleOption(option)}
>
{hasChildren ? (
{hasChildren || option.defer ? (
<a
onClick={(e: React.MouseEvent<any>) => {
e.stopPropagation();
this.toggleCollapsed(option);
this.toggleCollapsed(option, id);
}}
className={cx('Table-expandBtn', !collapsed ? 'is-active' : '')}
className={cx('Table-expandBtn', expaned ? 'is-active' : '')}
>
<i />
</a>
@ -164,6 +223,9 @@ export class TreeCheckboxes extends Checkboxes<
{itemRender(option)}
</div>
{option.defer && option.loading ? <Spinner show size="sm" /> : null}
{!option.defer || option.loaded ? (
<Checkbox
size="sm"
checked={checked}
@ -172,10 +234,13 @@ export class TreeCheckboxes extends Checkboxes<
labelClassName={labelClassName}
description={option.description}
/>
) : null}
</div>
{Array.isArray(option.children) && option.children.length ? (
{hasChildren ? (
<div className={cx('TreeCheckboxes-sublist')}>
{option.children.map((option, key) => this.renderItem(option, key))}
{option.children!.map((option, key) =>
this.renderItem(option, key, indexes.concat(key))
)}
</div>
) : null}
</div>
@ -196,7 +261,7 @@ export class TreeCheckboxes extends Checkboxes<
let body: Array<React.ReactNode> = [];
if (Array.isArray(options) && options.length) {
body = options.map((option, key) => this.renderItem(option, key));
body = options.map((option, key) => this.renderItem(option, key, [key]));
}
return (

View File

@ -7,11 +7,7 @@ import Downshift, {StateChangeOptions} from 'downshift';
import {autobind} from '../../utils/helper';
import {ICONS} from './IconPickerIcons';
import {FormItem, FormControlProps} from './Item';
export interface Option {
label?: string;
value?: any;
}
import {Option} from '../../components/Select';
export interface IconPickerProps extends FormControlProps {
placeholder?: string;

View File

@ -62,6 +62,7 @@ export interface OptionsControlProps extends FormControlProps, OptionProps {
setOptions: (value: Array<any>, skipNormalize?: boolean) => void;
setLoading: (value: boolean) => void;
reloadOptions: () => void;
deferLoad: (option: Option) => void;
creatable?: boolean;
onAdd?: (
idx?: number | Array<number>,
@ -79,6 +80,7 @@ export interface OptionsControlProps extends FormControlProps, OptionProps {
// 自己接收的属性。
export interface OptionsProps extends FormControlProps, OptionProps {
source?: Api;
deferApi?: Api;
creatable?: boolean;
addApi?: Api;
addControls?: Array<any>;
@ -449,6 +451,27 @@ export function registerOptionsControl(config: OptionsConfig) {
return formItem.loadOptions(source, data, undefined, false, onChange);
}
@autobind
deferLoad(option: Option) {
const {deferApi, source, env, formItem, data} = this.props;
if (option.loaded) {
return;
}
const api = option.deferApi || deferApi || source;
if (!api) {
env.notify(
'error',
'请在选项中设置 `deferApi` 或者表单项中设置 `deferApi`,用来加载子选项。'
);
return;
}
formItem?.deferLoadOptions(option, api, createObject(data, option));
}
@autobind
async initOptions(data: any) {
await this.reload();
@ -777,6 +800,7 @@ export function registerOptionsControl(config: OptionsConfig) {
setOptions={this.setOptions}
syncOptions={this.syncOptions}
reloadOptions={this.reload}
deferLoad={this.deferLoad}
creatable={
creatable || (creatable !== false && isEffectiveApi(addApi))
}

View File

@ -149,7 +149,8 @@ export class TransferRenderer<
columns,
loading,
searchable,
searchResultMode
searchResultMode,
deferLoad
} = this.props;
return (
@ -165,6 +166,7 @@ export class TransferRenderer<
searchResultMode={searchResultMode}
columns={columns}
onSearch={searchable ? this.handleSearch : undefined}
onDeferLoad={deferLoad}
/>
<Spinner overlay key="info" show={loading} />

View File

@ -17,7 +17,9 @@ import {
isObject,
createObject,
isObjectShallowModified,
findTree
findTree,
findTreeIndex,
spliceTree
} from '../utils/helper';
import {flattenTree} from '../utils/helper';
import {IRendererStore} from '.';
@ -170,13 +172,13 @@ export const FormItemStore = types
unMatched = {
[self.valueField || 'value']: item,
[self.labelField || 'label']: item,
'__unmatched': true
__unmatched: true
};
} else if (unMatched && self.extractValue) {
unMatched = {
[self.valueField || 'value']: item,
[self.labelField || 'label']: 'UnKnown',
'__unmatched': true
__unmatched: true
};
}
@ -349,22 +351,14 @@ export const FormItemStore = types
}
let loadCancel: Function | null = null;
const loadOptions: (
const fetchOptions: (
api: Api,
data?: object,
options?: fetchOptions,
clearValue?: boolean,
onChange?: (value: any) => void
config?: fetchOptions
) => Promise<Payload | null> = flow(function* getInitData(
api: string,
data: object,
options?: fetchOptions,
clearValue?: any,
onChange?: (
value: any,
submitOnChange: boolean,
changeImmediately: boolean
) => void
config?: fetchOptions
) {
try {
if (loadCancel) {
@ -381,15 +375,15 @@ export const FormItemStore = types
{
autoAppend: false,
cancelExecutor: (executor: Function) => (loadCancel = executor),
...options
...config
}
);
loadCancel = null;
let result: any = null;
if (!json.ok) {
setError(
`加载选项失败,原因:${json.msg ||
(options && options.errorMessage)}`
`加载选项失败,原因:${json.msg || (config && config.errorMessage)}`
);
(getRoot(self) as IRendererStore).notify(
'error',
@ -402,29 +396,11 @@ export const FormItemStore = types
: undefined
);
} else {
clearError();
self.validated = false; // 拉完数据应该需要再校验一下
let options: Array<IOption> =
json.data?.options ||
json.data.items ||
json.data.rows ||
json.data ||
[];
options = normalizeOptions(options as any);
setOptions(options);
if (json.data && typeof (json.data as any).value !== 'undefined') {
onChange && onChange((json.data as any).value, false, true);
} else if (clearValue) {
self.selectedOptions.some((item: any) => item.__unmatched) &&
onChange &&
onChange('', false, true);
}
result = json;
}
self.loading = false;
return json;
return result;
} catch (e) {
const root = getRoot(self) as IRendererStore;
if (root.storeType !== 'RendererStore') {
@ -441,10 +417,103 @@ export const FormItemStore = types
console.error(e.stack);
getRoot(self) &&
(getRoot(self) as IRendererStore).notify('error', e.message);
return null;
return;
}
} as any);
const loadOptions: (
api: Api,
data?: object,
config?: fetchOptions,
clearValue?: boolean,
onChange?: (value: any) => void
) => Promise<Payload | null> = flow(function* getInitData(
api: string,
data: object,
config?: fetchOptions,
clearValue?: any,
onChange?: (
value: any,
submitOnChange: boolean,
changeImmediately: boolean
) => void
) {
let json = yield fetchOptions(api, data, config);
if (!json) {
return;
}
clearError();
self.validated = false; // 拉完数据应该需要再校验一下
let options: Array<IOption> =
json.data?.options ||
json.data.items ||
json.data.rows ||
json.data ||
[];
options = normalizeOptions(options as any);
setOptions(options);
if (json.data && typeof (json.data as any).value !== 'undefined') {
onChange && onChange((json.data as any).value, false, true);
} else if (clearValue) {
self.selectedOptions.some((item: any) => item.__unmatched) &&
onChange &&
onChange('', false, true);
}
return json;
});
const deferLoadOptions: (
option: any,
api: Api,
data?: object,
config?: fetchOptions
) => Promise<Payload | null> = flow(function* getInitData(
option: any,
api: string,
data: object,
config?: fetchOptions
) {
const indexes = findTreeIndex(self.options, item => item === option);
if (!indexes) {
return;
}
setOptions(
spliceTree(self.options, indexes, 1, {
...option,
loading: true
})
);
let json = yield fetchOptions(api, data, config);
if (!json) {
return;
}
let options: Array<IOption> =
json.data?.options ||
json.data.items ||
json.data.rows ||
json.data ||
[];
setOptions(
spliceTree(self.options, indexes, 1, {
...option,
loading: false,
loaded: true,
children: options
})
);
return json;
});
function syncOptions(originOptions?: Array<any>) {
if (!self.options.length && typeof self.value === 'undefined') {
self.selectedOptions = [];
@ -527,7 +596,7 @@ export const FormItemStore = types
unMatched = {
[self.valueField || 'value']: item,
[self.labelField || 'label']: item,
'__unmatched': true
__unmatched: true
};
const orgin: any =
@ -545,7 +614,7 @@ export const FormItemStore = types
unMatched = {
[self.valueField || 'value']: item,
[self.labelField || 'label']: 'UnKnown',
'__unmatched': true
__unmatched: true
};
}
@ -631,6 +700,7 @@ export const FormItemStore = types
clearError,
setOptions,
loadOptions,
deferLoadOptions,
syncOptions,
setLoading,
setSubStore,

View File

@ -892,15 +892,28 @@ export function filterTree<T extends TreeItem>(
*/
export function everyTree<T extends TreeItem>(
tree: Array<T>,
iterator: (item: T, key: number, level: number, paths: Array<T>) => boolean,
iterator: (
item: T,
key: number,
level: number,
paths: Array<T>,
indexes: Array<number>
) => boolean,
level: number = 1,
paths: Array<T> = []
paths: Array<T> = [],
indexes: Array<number> = []
): boolean {
return tree.every((item, index) => {
const value: any = iterator(item, index, level, paths);
const value: any = iterator(item, index, level, paths, indexes);
if (value && item.children && item.children.splice) {
return everyTree(item.children, iterator, level + 1, paths.concat(item));
return everyTree(
item.children,
iterator,
level + 1,
paths.concat(item),
indexes.concat(index)
);
}
return value;
@ -975,6 +988,7 @@ export function spliceTree<T extends TreeItem>(
if (typeof idx === 'number') {
list.splice(idx, deleteCount, ...items);
} else if (Array.isArray(idx) && idx.length) {
idx = idx.concat();
const lastIdx = idx.pop()!;
let host = idx.reduce((list: Array<T>, idx) => {
const child = {