amis2/packages/amis-editor/src/renderer/OptionControl.tsx

933 lines
26 KiB
TypeScript
Raw Normal View History

/**
* @file
*/
import React from 'react';
import {findDOMNode} from 'react-dom';
import cx from 'classnames';
import uniqBy from 'lodash/uniqBy';
import omit from 'lodash/omit';
import get from 'lodash/get';
import Sortable from 'sortablejs';
import {
FormItem,
Button,
Checkbox,
Icon,
InputBox,
render as amisRender
} from 'amis';
import {value2array} from 'amis-ui/lib/components/Select';
import {autobind, getI18nEnabled} from 'amis-editor-core';
import {getSchemaTpl} from 'amis-editor-core';
import {tipedLabel} from 'amis-editor-core';
import type {Option} from 'amis';
import {createObject, FormControlProps} from 'amis-core';
import type {OptionValue} from 'amis-core';
import type {SchemaApi} from 'amis';
import debounce from 'lodash/debounce';
export type valueType = 'text' | 'boolean' | 'number';
export interface PopoverForm {
optionLabel: string;
optionValue: any;
optionValueType: valueType;
}
export type OptionControlItem = Option & {checked: boolean};
export interface OptionControlProps extends FormControlProps {
className?: string;
}
2023-05-05 14:01:27 +08:00
export type SourceType = 'custom' | 'api' | 'apicenter' | 'variable';
export interface OptionControlState {
options: Array<OptionControlItem>;
api: SchemaApi;
labelField: string;
valueField: string;
2023-05-05 14:01:27 +08:00
source: SourceType;
}
export default class OptionControl extends React.Component<
OptionControlProps,
OptionControlState
> {
sortable?: Sortable;
drag?: HTMLElement | null;
target: HTMLElement | null;
$comp: string; // 记录一下路径,不再从外部同步内部,只从内部同步外部
internalProps = ['checked', 'editing'];
constructor(props: OptionControlProps) {
super(props);
2023-05-05 14:01:27 +08:00
let source: SourceType = 'custom';
if (props.data.hasOwnProperty('source') && props.data.source) {
const api = props.data.source;
const url =
typeof api === 'string'
? api
: typeof api === 'object'
? api.url || ''
: '';
2023-05-05 14:01:27 +08:00
source = /\$\{(.*?)\}/g.test(props.data.source)
? 'variable'
: !url.indexOf('api://')
? 'apicenter'
: 'api';
}
this.state = {
options: this.transformOptions(props),
api: props.data.source,
labelField: props.data.labelField,
valueField: props.data.valueField,
source
};
}
/**
*
*/
componentWillReceiveProps(nextProps: OptionControlProps) {
const options = get(nextProps, 'data.options')
? this.transformOptions(nextProps)
: [];
if (
JSON.stringify(
this.state.options.map(item => ({
...item,
editing: undefined
}))
) !== JSON.stringify(options)
) {
this.setState({
options
});
}
}
/**
*
*/
getOptionValueType(value: any): valueType {
if (typeof value === 'string') {
return 'text';
}
if (typeof value === 'boolean') {
return 'boolean';
}
if (typeof value === 'number') {
return 'number';
}
return 'text';
}
/**
*
*/
normalizeOptionValue(value: any, valueType: valueType) {
if (valueType === 'text') {
return String(value);
}
if (valueType === 'number') {
const convertTo = Number(value);
if (isNaN(convertTo)) {
return 0;
}
return convertTo;
}
if (valueType === 'boolean') {
return !value || value === 'false' ? false : true;
}
return '';
}
/**
*
*/
transformOptionValue(value: any) {
return typeof value === 'undefined' || value === null
? ''
: typeof value === 'string'
? value
: JSON.stringify(value);
}
transformOptions(props: OptionControlProps) {
const {data: ctx, value: options} = props;
let defaultValue: Array<OptionValue> | OptionValue = ctx.value;
const valueArray = value2array(defaultValue, ctx as any).map(
(item: Option) => item[ctx?.valueField ?? 'value']
);
return Array.isArray(options)
? options.map((item: Option) => ({
label: item.label,
// 为了使用户编写label时同时生效到value
value: item.label === item.value ? null : item.value,
checked: !!~valueArray.indexOf(item[ctx?.valueField ?? 'value']),
...(item?.badge ? {badge: item.badge} : {}),
...(item.hidden !== undefined ? {hidden: item.hidden} : {}),
...(item.hiddenOn !== undefined ? {hiddenOn: item.hiddenOn} : {})
}))
: [];
}
/**
*
*/
normalizeValue() {
const {data: ctx = {}, multiple: multipleProps} = this.props;
const {
joinValues = true,
extractValue,
multiple,
delimiter,
valueField
} = ctx;
const checkedOptions = this.state.options
.filter(item => item.checked && item?.hidden !== true)
.map(item => omit(item, this.internalProps));
let value: Array<OptionValue> | OptionValue;
if (!checkedOptions.length) {
return '';
}
if (multiple || multipleProps) {
value = checkedOptions;
if (joinValues) {
value = checkedOptions
.map(
(item: any) =>
item[valueField || 'value'] || item[valueField || 'label']
)
.join(delimiter || ',');
} else if (extractValue) {
value = checkedOptions.map(
(item: Option) =>
item[valueField || 'value'] || item[valueField || 'label']
);
}
} else {
value = checkedOptions[0];
if (joinValues || extractValue) {
value = value[valueField || 'value'] || value[valueField || 'label'];
}
}
return value;
}
/**
* options字段的统一出口
*/
onChange() {
const {source} = this.state;
const {onBulkChange} = this.props;
const defaultValue = this.normalizeValue();
const data: Partial<OptionControlProps> = {
source: undefined,
options: undefined,
labelField: undefined,
valueField: undefined
};
if (source === 'custom') {
const {options} = this.state;
data.options = options.map(item => ({
...(item?.badge ? {badge: item.badge} : {}),
label: item.label,
value:
item.value == null || item.value === '' ? item.label : item.value,
...(item.hiddenOn !== undefined ? {hiddenOn: item.hiddenOn} : {})
}));
data.value = defaultValue;
}
2023-05-05 14:01:27 +08:00
if (source === 'api' || source === 'apicenter' || source === 'variable') {
const {api, labelField, valueField} = this.state;
data.source = api;
data.labelField = labelField || undefined;
data.valueField = valueField || undefined;
}
onBulkChange && onBulkChange(data);
return;
}
@autobind
targetRef(ref: any) {
this.target = ref ? (findDOMNode(ref) as HTMLElement) : null;
}
@autobind
dragRef(ref: any) {
if (!this.drag && ref) {
this.initDragging();
} else if (this.drag && !ref) {
this.destroyDragging();
}
this.drag = ref;
}
initDragging() {
const dom = findDOMNode(this) as HTMLElement;
this.sortable = new Sortable(
dom.querySelector('.ae-OptionControl-content') as HTMLElement,
{
group: 'OptionControlGroup',
animation: 150,
handle: '.ae-OptionControlItem-dragBar',
ghostClass: 'ae-OptionControlItem--dragging',
onEnd: (e: any) => {
// 没有移动
if (e.newIndex === e.oldIndex) {
return;
}
// 换回来
const parent = e.to as HTMLElement;
if (
e.newIndex < e.oldIndex &&
e.oldIndex < parent.childNodes.length - 1
) {
parent.insertBefore(e.item, parent.childNodes[e.oldIndex + 1]);
} else if (e.oldIndex < parent.childNodes.length - 1) {
parent.insertBefore(e.item, parent.childNodes[e.oldIndex]);
} else {
parent.appendChild(e.item);
}
const options = this.state.options.concat();
options[e.oldIndex] = options.splice(
e.newIndex,
1,
options[e.oldIndex]
)[0];
this.setState({options}, () => this.onChange());
}
}
);
}
destroyDragging() {
this.sortable && this.sortable.destroy();
}
scroll2Bottom() {
this.drag &&
this.drag?.lastElementChild?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'start'
});
}
/**
*
*/
@autobind
2023-05-05 14:01:27 +08:00
handleSourceChange(source: SourceType) {
this.setState({api: '', source: source}, this.onChange);
}
/**
*
*/
handleDelete(index: number) {
const options = this.state.options.concat();
options.splice(index, 1);
this.setState({options}, () => this.onChange());
}
/**
*
*/
handleToggleDefaultValue(index: number, checked: any, shift?: boolean) {
let options = this.state.options.concat();
const isMultiple = this.props?.data?.multiple || this.props?.multiple;
if (isMultiple) {
options.splice(index, 1, {...options[index], checked});
} else {
options = options.map((item, itemIndex) => ({
...item,
checked: itemIndex === index ? checked : false // 支持重复点击取消选中
}));
}
this.setState({options}, () => this.onChange());
}
/**
*
*/
toggleEdit(index: number) {
const {options} = this.state;
options[index].editing = !options[index].editing;
this.setState({options});
}
/**
*
*/
toggleBadge(index: number, value: string) {
const {options} = this.state;
options[index].badge = value;
this.setState({options}, () => this.onChange());
}
@autobind
handleEditLabel(index: number, value: string) {
const options = this.state.options.concat();
options.splice(index, 1, {...options[index], label: value});
this.setState({options}, () => this.onChange());
}
@autobind
handleHiddenValueChange(index: number, value: string) {
const options = this.state.options.concat();
const {hiddenOn, ...option} = options[index];
options.splice(index, 1, {
...option,
...(!value ? {} : {hiddenOn: value})
});
this.setState({options}, () => this.onChange());
}
@autobind
handleAdd() {
const {options} = this.state;
options.push({
label: '',
value: null,
checked: false
});
this.setState({options}, () => {
this.onChange();
});
}
handleValueTypeChange(index: number, type: valueType) {
const options = this.state.options.concat();
options[index].value = this.normalizeOptionValue(
options[index].value,
type
);
this.setState({options}, () => this.onChange());
}
handleValueChange(index: number, value: string) {
const options = this.state.options.concat();
const type = this.getOptionValueType(options[index].value);
options[index].value = this.normalizeOptionValue(value, type);
this.setState({options}, () => this.onChange());
}
@autobind
handleBatchAdd(values: {batchOption: string}[], action: any) {
const options = this.state.options.concat();
const addedOptions: Array<OptionControlItem> = values[0].batchOption
.split('\n')
.map(option => {
const item = option.trim();
if (~item.indexOf(' ')) {
let [label, value] = item.split(' ');
return {label: label.trim(), value: value.trim(), checked: false};
}
return {label: item, value: item, checked: false};
});
const newOptions = uniqBy([...options, ...addedOptions], 'value');
this.setState({options: newOptions}, () => this.onChange());
}
renderHeader() {
const {
render,
label,
labelRemark,
useMobileUI,
env,
popOverContainer,
hasApiCenter
} = this.props;
const classPrefix = env?.theme?.classPrefix;
const {source} = this.state;
const optionSourceList = (
[
{
label: '自定义选项',
value: 'custom'
},
{
label: '外部接口',
value: 'api'
},
2023-05-05 14:01:27 +08:00
...(hasApiCenter ? [{label: 'API中心', value: 'apicenter'}] : []),
{
label: '上下文变量',
value: 'variable'
}
// {
// label: '表单实体',
// value: 'form'
// }
] as Array<{
label: string;
2023-05-05 14:01:27 +08:00
value: SourceType;
}>
).map(item => ({
...item,
onClick: () => this.handleSourceChange(item.value)
}));
return (
<header className="ae-OptionControl-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 || env.getModalContainer
})
: 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>
);
}
renderOption(props: any) {
const {checked, index, editing, multipleProps, closeDefaultCheck} = props;
const {render, data: ctx, node} = this.props;
const isMultiple = ctx?.multiple === true || multipleProps;
const i18nEnabled = getI18nEnabled();
const label = this.transformOptionValue(props.label);
const value = this.transformOptionValue(props.value);
const valueType = this.getOptionValueType(props.value);
const showBadge = node.type === 'button-group-select';
const editDom = editing ? (
<div className="ae-OptionControlItem-extendMore">
{render(
'option',
{
type: 'container',
className: 'ae-ExtendMore right mb-2',
body: [
{
type: 'button',
className: 'ae-OptionControlItem-closeBtn',
label: '×',
level: 'link',
onClick: () => this.toggleEdit(index)
},
{
type: i18nEnabled ? 'input-text-i18n' : 'input-text',
placeholder: '请输入显示文本',
label: '文本',
mode: 'horizontal',
value: label,
name: 'optionLabel',
labelClassName: 'ae-OptionControlItem-EditLabel',
valueClassName: 'ae-OptionControlItem-EditValue',
onChange: (v: string) => this.handleEditLabel(index, v)
},
{
type: 'input-group',
name: 'input-group',
label: '值',
labelClassName: 'ae-OptionControlItem-EditLabel',
valueClassName: 'ae-OptionControlItem-EditValue',
mode: 'horizontal',
body: [
{
type: 'select',
name: 'optionValueType',
value: valueType,
options: [
{
label: '文本',
value: 'text'
},
{
label: '数字',
value: 'number'
},
{
label: '布尔',
value: 'boolean'
}
],
checkAll: false,
onChange: (v: valueType) =>
this.handleValueTypeChange(index, v)
},
{
type: 'input-text',
placeholder: '默认与文本一致',
name: 'optionValue',
value,
visibleOn: "this.optionValueType !== 'boolean'",
onChange: (v: string) => this.handleValueChange(index, v)
},
{
type: 'input-text',
placeholder: '默认与文本一致',
name: 'optionValue',
value,
visibleOn: "this.optionValueType === 'boolean'",
onChange: (v: string) => this.handleValueChange(index, v),
options: [
{label: 'true', value: true},
{label: 'false', value: false}
]
}
]
},
2023-06-12 18:56:18 +08:00
getSchemaTpl('expressionFormulaControl', {
name: 'optionHiddenOn',
label: '隐藏',
labelClassName: 'ae-OptionControlItem-EditLabel',
valueClassName: 'ae-OptionControlItem-EditValue',
onChange: (v: string) => this.handleHiddenValueChange(index, v)
2023-06-12 18:56:18 +08:00
}),
{
type: i18nEnabled ? 'input-text-i18n' : 'input-text',
placeholder: '请输入角标文本',
label: '角标',
mode: 'horizontal',
visible: showBadge,
value: props?.badge,
name: 'optionBadge',
labelClassName: 'ae-OptionControlItem-EditLabel',
valueClassName: 'ae-OptionControlItem-EditValue',
onChange: (v: string) => this.toggleBadge(index, v)
}
]
},
{data: createObject(ctx, {option: props})}
)}
</div>
) : null;
const operationBtn = [
{
type: 'button',
className: 'ae-OptionControlItem-action',
label: '编辑',
onClick: () => this.toggleEdit(index)
},
{
type: 'button',
className: 'ae-OptionControlItem-action',
label: '删除',
onClick: () => this.handleDelete(index)
}
];
// 单选模式,选中时增加取消操作
if (!closeDefaultCheck && !isMultiple && checked) {
operationBtn.unshift({
type: 'button',
className: 'ae-OptionControlItem-action',
label: '取消选中',
onClick: () => this.handleToggleDefaultValue(index, false)
});
}
const disabled = props?.hidden === true;
const tooltip = disabled ? '隐藏选项不能设为默认值' : '默认选中此项';
return (
<li className="ae-OptionControlItem" key={index}>
<div className="ae-OptionControlItem-Main">
<a className="ae-OptionControlItem-dragBar">
<Icon icon="drag-bar" className="icon" />
</a>
{!this.props.closeDefaultCheck &&
this.props.data.defaultCheckAll !== true && (
<span className="inline-flex" data-tooltip={tooltip}>
<Checkbox
className="ae-OptionControlItem-checkbox"
checked={checked}
disabled={disabled}
type={isMultiple ? 'checkbox' : 'radio'}
onChange={(newChecked: any, shift?: boolean) =>
this.handleToggleDefaultValue(index, newChecked, shift)
}
/>
</span>
)}
{/* <InputBox
className="ae-OptionControlItem-input"
value={label}
placeholder="请输入文本/值"
clearable={false}
onChange={(value: string) => this.handleEditLabel(index, value)}
/> */}
{amisRender({
type: i18nEnabled ? 'input-text-i18n' : 'input-text',
className: 'ae-OptionControlItem-input',
value: label,
placeholder: '请输入文本/值',
clearable: false,
onChange: (value: string) => {
this.handleEditLabel(index, value);
}
})}
{render(
'dropdown',
{
type: 'dropdown-button',
className: 'ae-OptionControlItem-dropdown',
btnClassName: 'px-2',
icon: 'fa fa-ellipsis-h',
hideCaret: true,
closeOnClick: true,
align: 'right',
menuClassName: 'ae-OptionControlItem-ulmenu',
buttons: operationBtn
},
{
popOverContainer: null // amis 渲染挂载节点会使用 this.target
}
)}
</div>
{editDom}
</li>
);
}
buildBatchAddSchema() {
return {
type: 'action',
actionType: 'dialog',
label: '批量添加',
dialog: {
title: '批量添加选项',
headerClassName: 'font-bold',
closeOnEsc: true,
closeOnOutside: false,
showCloseButton: true,
onConfirm: this.handleBatchAdd,
body: [
{
type: 'alert',
level: 'warning',
body: [
{
type: 'tpl',
tpl: '每个选项单列一行,将所有值不重复的项加为新的选项;<br/>每行可通过空格来分别设置label和value,例:"张三 zhangsan"'
}
],
showIcon: true,
className: 'mb-2.5'
},
{
type: 'form',
wrapWithPanel: false,
mode: 'normal',
wrapperComponent: 'div',
resetAfterSubmit: true,
autoFocus: true,
preventEnterSubmit: true,
horizontal: {
left: 0,
right: 12
},
body: [
{
name: 'batchOption',
type: 'textarea',
label: '',
placeholder: '请输入选项内容',
trimContents: true,
minRows: 10,
maxRows: 50,
required: true
}
]
}
]
}
};
}
@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);
}
/** 获取功能性字段控件 schema */
getFuncFieldSchema(): Record<string, any>[] {
const {labelField, valueField} = this.state;
return [
{
label: tipedLabel(
'显示字段',
'选项文本对应的数据字段,多字段合并请通过模板配置'
),
type: 'input-text',
name: 'labelField',
clearable: true,
value: labelField,
placeholder: '选项文本对应的字段',
onChange: this.handleLableFieldChange
},
{
label: '值字段',
type: 'input-text',
name: 'valueField',
clearable: true,
value: valueField,
placeholder: '值对应的字段',
onChange: this.handleValueFieldChange
}
];
}
renderApiPanel() {
const {render} = this.props;
const {source, api} = this.state;
return render(
'api',
getSchemaTpl('apiControl', {
label: '接口',
name: 'source',
mode: 'normal',
className: 'ae-ExtendMore',
visibleOn: 'data.autoComplete !== false',
value: api,
onChange: this.handleAPIChange,
sourceType: source,
footer: this.getFuncFieldSchema()
})
);
}
render() {
const {options, source} = this.state;
const {render, className, multiple: multipleProps} = this.props;
return (
<div className={cx('ae-OptionControl', className)}>
{this.renderHeader()}
{/* 自定义选项 */}
{source === 'custom' ? (
<div className="ae-OptionControl-wrapper">
{Array.isArray(options) && options.length ? (
<ul className="ae-OptionControl-content" ref={this.dragRef}>
{options.map((option, index) =>
this.renderOption({...option, index, multipleProps})
)}
</ul>
) : (
<div className="ae-OptionControl-placeholder"></div>
)}
<div className="ae-OptionControl-footer">
<Button
level="enhance"
onClick={this.handleAdd}
ref={this.targetRef}
>
</Button>
{/* {render('option-control-batchAdd', this.buildBatchAddSchema())} */}
{render('inner', this.buildBatchAddSchema())}
</div>
{/* {this.renderPopover()} */}
</div>
) : null}
{/* API 接口 */}
2023-05-05 14:01:27 +08:00
{source === 'api' || source === 'apicenter'
? this.renderApiPanel()
: null}
{/* 上下文变量 */}
2023-05-05 14:01:27 +08:00
{source === 'variable'
? render('variable', {
type: 'control',
label: false,
className: 'ae-ExtendMore',
body: [
getSchemaTpl('sourceBindControl', {
label: false,
onChange: debounce(this.handleAPIChange, 1000)
})
].concat(this.getFuncFieldSchema())
})
2023-05-05 14:01:27 +08:00
: null}
</div>
);
}
}
@FormItem({
type: 'ae-optionControl',
renderLabel: false
})
export class OptionControlRenderer extends OptionControl {}