Merge "amis-saas-4924:增加选项管理及API配置" into feat-optimize-4

This commit is contained in:
liuguihua 2022-07-05 19:45:24 +08:00 committed by iCode
commit 0237345427
4 changed files with 613 additions and 12 deletions

View File

@ -154,6 +154,7 @@ import './renderer/DataBindingControl';
import './renderer/DataMappingControl';
import './renderer/DataPickerControl';
import './renderer/event-control/index';
import './renderer/TreeOptionControl';
export * from './component/BaseControl';
export * from './icons/index';

View File

@ -23,7 +23,7 @@ export class NestedSelectControlPlugin extends BasePlugin {
$schema = '/schemas/NestedSelectControlSchema.json';
// 组件名称
name = '级联选择';
name = '级联选择';
isBaseComponent = true;
icon = 'fa fa-indent';
pluginIcon = 'nested-select-plugin';
@ -32,7 +32,7 @@ export class NestedSelectControlPlugin extends BasePlugin {
tags = ['表单项'];
scaffold = {
type: 'nested-select',
label: '级联选择',
label: '级联选择',
name: 'nestedSelect',
onlyChildren: true,
options: [
@ -46,12 +46,26 @@ export class NestedSelectControlPlugin extends BasePlugin {
value: 'B',
children: [
{
label: '选项C',
value: 'C'
label: '选项b1',
value: 'b1'
},
{
label: '选项D',
value: 'D'
label: '选项b2',
value: 'b2'
}
]
},
{
label: '选项C',
value: 'C',
children: [
{
label: '选项c1',
value: 'c1'
},
{
label: '选项c2',
value: 'c2'
}
]
}
@ -69,7 +83,7 @@ export class NestedSelectControlPlugin extends BasePlugin {
]
};
panelTitle = '级联选择';
panelTitle = '级联选择';
notRenderFormZone = true;
panelDefinitions = {
options: {
@ -281,6 +295,9 @@ export class NestedSelectControlPlugin extends BasePlugin {
]
}
],
getSchemaTpl('valueFormula', {
rendererSchema: context?.schema
}),
getSchemaTpl('hideNodePathLabel'),
getSchemaTpl('labelRemark'),
getSchemaTpl('remark'),
@ -291,11 +308,7 @@ export class NestedSelectControlPlugin extends BasePlugin {
{
title: '选项',
body: [
// getSchemaTpl('optionControl'), // 备注:级联选择框 不适合用这种方式添加选项
getSchemaTpl('valueFormula', {
rendererSchema: context?.schema,
mode: 'vertical' // 改成上下展示模式
})
getSchemaTpl('treeOptionControl')
]
},
getSchemaTpl('status', {isFormItem: true}),

View File

@ -0,0 +1,580 @@
/**
* @file
*/
import React from 'react';
import cx from 'classnames';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import set from 'lodash/set';
import Sortable from 'sortablejs';
import {
render as amisRender,
FormItem,
Button,
Icon,
InputBox,
Modal,
toast
} from 'amis';
import {autobind} from 'amis-editor-core';
import {getSchemaTpl} from 'amis-editor-core';
import {tipedLabel} from '../component/BaseControl';
import type {Option} from 'amis';
import type {FormControlProps} from 'amis-core';
import {SchemaApi} from 'amis/lib/Schema';
export type OptionControlItem = Option & {checked?: boolean, _key?: string};
export interface OptionControlProps extends FormControlProps {
className?: string;
}
export interface OptionControlState {
options: Array<OptionControlItem>;
api: SchemaApi;
labelField: string;
valueField: string;
source: 'custom' | 'api';
modalVisible: boolean
}
const defaultOption: OptionControlItem = {
label: '',
value: ''
}
export default class TreeOptionControl extends React.Component<
OptionControlProps,
OptionControlState
> {
sortables: Sortable[];
drag?: HTMLElement | null;
internalProps = ['checked', 'editing'];
constructor(props: OptionControlProps) {
super(props);
this.state = {
options: this.transformOptions(props),
api: props.data.source,
labelField: props.data.labelField,
valueField: props.data.valueField,
source: props.data.source ? 'api' : 'custom',
modalVisible: false
};
this.sortables = [];
}
transformOptions(props: OptionControlProps) {
const {data: {options}} = props;
if (!options || !options.length) {
return [{...defaultOption}]
}
return options;
}
/**
* value的情况
*/
pretreatOptions(options: Array<OptionControlItem>) {
if (!Array.isArray(options)) {
return [];
}
return options.map(option => {
if (option.children && option.children.length) {
option.children = this.pretreatOptions(option.children);
}
option.value = option.value == null || option.value === '' ? option.label : option.value;
return option;
});
}
/**
* options字段的统一出口
*/
onChange() {
const {source} = this.state;
const {onBulkChange} = this.props;
const data: Partial<OptionControlProps> = {
source: undefined,
options: undefined,
labelField: undefined,
valueField: undefined
};
if (source === 'custom') {
const options = this.state.options.concat();
data.options = this.pretreatOptions(options);
}
if (source === 'api') {
const {api, labelField, valueField} = this.state;
data.source = api;
data.labelField = labelField;
data.valueField = valueField;
}
onBulkChange && onBulkChange(data);
return;
}
/**
*
*/
@autobind
handleSourceChange(source: 'custom' | 'api') {
this.setState({source: source}, this.onChange);
}
renderHeader() {
const {
render,
label,
labelRemark,
useMobileUI,
env,
popOverContainer
} = this.props;
const classPrefix = env?.theme?.classPrefix;
const {source} = this.state;
const optionSourceList = ([
{
label: '自定义选项',
value: 'custom'
},
{
label: '接口获取',
value: 'api'
}
] as Array<{
label: string;
value: 'custom' | 'api';
}>).map(item => ({
...item,
onClick: () => this.handleSourceChange(item.value)
}));
return (
<header className="ae-TreeOptionControl-header">
<label className={cx(`${classPrefix}Form-label`)}>
{label || ''}
{labelRemark
? render('label-remark', {
type: 'remark',
icon: labelRemark.icon || 'warning-mark',
tooltip: labelRemark,
className: cx(`Form-lableRemark`, labelRemark?.className),
useMobileUI,
container: popOverContainer
? popOverContainer
: env && env.getModalContainer
? env.getModalContainer
: undefined
})
: null}
</label>
<div>
{render(
'validation-control-addBtn',
{
type: 'dropdown-button',
level: 'link',
size: 'sm',
label: '${selected}',
align: 'right',
closeOnClick: true,
closeOnOutside: true,
buttons: optionSourceList
},
{
popOverContainer: null,
data: {
selected: optionSourceList.find(item => item.value === source)!
.label
}
}
)}
</div>
</header>
);
}
handleEditLabelOrValue (value: string, path: string, key: string) {
const options = cloneDeep(this.state.options);
const {path: nodePath} = this.getNodePath(path);
set(options, `${nodePath}.${key}`, value);
this.setState({options});
}
@autobind
handleDelete(pathStr: string, index: number) {
const options = cloneDeep(this.state.options);
if (options.length === 1) {
toast.warning('至少保留一个节点', {closeButton: true});
return;
}
const path = pathStr.split('-');
if (path.length === 1) {
options.splice(index, 1);
}
else {
const {parentPath} = this.getNodePath(pathStr);
const parentNode = get(options, parentPath, {});
parentNode?.children?.splice(index, 1);
if (!parentNode?.children.length) { // 去除僵尸子节点
delete parentNode.children;
}
set(options, parentPath, parentNode);
}
this.setState({options});
}
@autobind
getNodePath(pathStr: string) {
let pathArr = pathStr.split('-');
if(pathArr.length === 1) {
return {
path: pathArr,
parentPath: ''
};
}
const path = `[${pathArr.join('].children[')}]`;
pathArr = pathArr.slice(0, pathArr.length - 1);
const parentPath = `[${pathArr.join('].children[')}]`;
return {
path,
parentPath
};
}
@autobind
addOption(pathStr: string) {
const options = cloneDeep(this.state.options);
const path = pathStr.split('-');
if (path.length === 1) {
options.splice(+path[0] + 1, 0, {...defaultOption}); // 加在后面一项
}
else {
const index = path[path.length - 1];
const {parentPath} = this.getNodePath(pathStr);
const parentNode = get(options, parentPath, {});
parentNode.children?.splice(+index + 1, 0, {...defaultOption});
set(options, parentPath, parentNode);
}
this.setState({options});
}
@autobind
addChildOption(pathStr: string) {
const options = cloneDeep(this.state.options);
const {path} = this.getNodePath(pathStr);
const node = get(options, path) || [];
if (node.children) {
node.children.push({...defaultOption});
}
else {
node.children = [{...defaultOption}];
}
set(options, path, node);
this.setState({options});
}
@autobind
hideModal(notResetOptions?: boolean) {
this.setState({modalVisible: false});
if (!notResetOptions) {
this.setState({options: this.transformOptions(this.props)});
}
}
@autobind
renderOptions(option: any, key: number, indexes: number[]): React.ReactNode {
const path = indexes.join('-');
if (option.children && option.children.length) {
const parent = cloneDeep(option);
delete parent.children;
return <div className={cx('ae-TreeOptionControlItem-parent')} key={`parent${path}${key}${option.label}`}>
{this.renderOptions(parent, key, indexes)}
<div
className={cx('ae-TreeOptionControlItem-son')}
key={`son${path}${key}${option.label}`}
data-level={path}
>
{
option.children.map((option: any, key: number) => {
return this.renderOptions(option, key, indexes.concat(key))
})
}
</div>
</div>
}
return <div
className="ae-TreeOptionControlItem"
key={`child${path}${key}${option.label}`}
data-path={path}
>
<a className="ae-TreeOptionControlItem-dragBar">
<Icon icon="drag-bar" className="icon" />
</a>
<InputBox
className="ae-TreeOptionControlItem-input-label"
value={option.label}
placeholder="选项名称"
clearable={false}
onBlur={(event: any) => { // 这里使用onBlur替代onChange 减少渲染次数
this.handleEditLabelOrValue(event.target.value, path, 'label');
}}
/>
<InputBox
className="ae-TreeOptionControlItem-input-value"
value={option.value}
placeholder="选项值"
clearable={false}
onBlur={(event: any) => {
this.handleEditLabelOrValue(event.target.value, path, 'value');
}}
/>
<div className="ae-TreeOptionControlItem-btns">
{amisRender({
type: 'dropdown-button',
className: 'ae-TreeOptionControlItem-dropdown fa-sm',
btnClassName: 'px-2',
icon: 'fa fa-plus',
hideCaret: true,
closeOnClick: true,
trigger: 'hover',
align: 'right',
menuClassName: 'ae-TreeOptionControlItem-ulmenu',
buttons: [
{
type: 'button',
className: 'ae-OptionControlItem-action',
label: '添加选项',
onClick: () => {
this.addOption(path);
}
},
{
type: 'button',
className: 'ae-OptionControlItem-action',
label: '添加子选项',
onClick: () => {
this.addChildOption(path);
}
}
]
})}
<Button
size="sm"
onClick={() => {
this.handleDelete(path, key);
}}
>
<Icon className="icon" icon="delete-bold-btn" />
</Button>
</div>
</div>
}
@autobind
dragRef(ref: any) {
if (!this.drag && ref) {
this.drag = ref;
this.initDragging();
} else if (this.drag && !ref) {
this.destroyDragging();
}
}
initDragging() {
const rootSortable = new Sortable(
this.drag as HTMLElement,
{
group: 'TreeOptionControlGroup',
animation: 150,
handle: '.ae-TreeOptionControlItem-dragBar',
ghostClass: 'ae-TreeOptionControlItem-dragging',
onEnd: (e: any) => {
const options = this.state.options.concat();
const {oldIndex, newIndex} = e;
[options[newIndex], options[oldIndex]] = [options[oldIndex], options[newIndex]];
this.setState({options});
},
onMove: (e: any) => {
const {from, to} = e;
// 暂时不支持跨级拖拽
return from.dataset.level === to.dataset.level;
}
}
);
this.sortables.push(rootSortable);
const parents = this.drag?.querySelectorAll('.ae-TreeOptionControlItem-son');
if (!parents) {
return;
}
Array.from(parents).forEach(parent => {
const sortable = new Sortable(parent, {
group: 'TreeOptionControlGroup',
animation: 150,
handle: '.ae-TreeOptionControlItem-dragBar',
ghostClass: 'ae-TreeOptionControlItem-dragging',
// fallbackOnBody: true,
onEnd: (e: any) => {
const {item, oldIndex, newIndex} = e;
const options = this.state.options.concat();
const nodePath = item.dataset.path;
if (!nodePath) {
return;
}
const {parentPath} = this.getNodePath(nodePath);
const children = get(options, `${parentPath}.children`) || [];
if (children) {
[children[oldIndex], children[newIndex]] = [children[newIndex], children[oldIndex]];
set(options, `${parentPath}.children`, children);
this.setState({options});
}
},
onMove: (e: any) => {
const {from, to} = e;
// 暂时不支持跨级拖拽
return from.dataset.level === to.dataset.level;
}
});
this.sortables.push(sortable);
});
}
@autobind
destroyDragging() {
this.sortables.forEach(sortable => {
sortable?.destroy();
});
this.sortables = [];
this.drag = null;
}
@autobind
renderModal() {
const {modalVisible, options} = this.state;
return (
<Modal
className="ae-TreeOptionControl-Modal"
show={modalVisible}
onHide={() => {
this.hideModal();
}}
>
<Modal.Header
onClose={() => {
this.hideModal();
}}
>
</Modal.Header>
<Modal.Body>
<div className="ae-TreeOptionControl-content" ref={this.dragRef}>
{options.map((option, key) => this.renderOptions(option, key, [key]))}
</div>
</Modal.Body>
<Modal.Footer>
<Button
onClick={() => {
this.hideModal();
}}
></Button>
<Button
level="primary"
onClick={() => {
this.onChange();
this.hideModal(true);
}}
></Button>
</Modal.Footer>
</Modal>
);
}
@autobind
handleAPIChange(source: SchemaApi) {
this.setState({api: source}, this.onChange);
}
@autobind
handleLableFieldChange(labelField: string) {
this.setState({labelField}, this.onChange);
}
@autobind
handleValueFieldChange(valueField: string) {
this.setState({valueField}, this.onChange);
}
renderApiPanel() {
const {render} = this.props;
const {source, api, labelField, valueField} = this.state;
if (source !== 'api') {
return null;
}
return render(
'api',
getSchemaTpl('apiControl', {
label: '接口',
name: 'source',
className: 'ae-ExtendMore',
visibleOn: 'data.autoComplete !== false',
value: api,
onChange: this.handleAPIChange,
footer: [
{
label: tipedLabel(
'显示字段',
'选项文本对应的数据字段,多字段合并请通过模板配置'
),
type: 'input-text',
name: 'labelField',
value: labelField,
placeholder: '选项文本对应的字段',
onChange: this.handleLableFieldChange
},
{
label: '值字段',
type: 'input-text',
name: 'valueField',
value: valueField,
placeholder: '值对应的字段',
onChange: this.handleValueFieldChange
}
]
})
);
}
render() {
const {source} = this.state;
const {className} = this.props;
return (
<div className={cx('ae-TreeOptionControl', className)}>
{this.renderHeader()}
{source === 'custom' ? (
<div className="ae-TreeOptionControl-wrapper">
<div>
<Button
block={true}
onClick={() => {
this.setState({
modalVisible: true
})
}}
></Button>
{this.renderModal()}
</div>
</div>
) : null}
{this.renderApiPanel()}
</div>
);
}
}
@FormItem({
type: 'ae-treeOptionControl',
renderLabel: false
})
export class TreeOptionControlRenderer extends TreeOptionControl {}

View File

@ -298,3 +298,10 @@ setSchemaTpl('optionControlV2', {
type: 'ae-optionControl',
closeDefaultCheck: true // 关闭默认值设置
});
setSchemaTpl('treeOptionControl', {
label: '数据',
mode: 'normal',
name: 'options',
type: 'ae-treeOptionControl'
});