diff --git a/packages/amis-editor/src/index.tsx b/packages/amis-editor/src/index.tsx index c8cf227a8..2e59af6d8 100644 --- a/packages/amis-editor/src/index.tsx +++ b/packages/amis-editor/src/index.tsx @@ -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'; diff --git a/packages/amis-editor/src/plugin/Form/NestedSelect.tsx b/packages/amis-editor/src/plugin/Form/NestedSelect.tsx index 733e33a7c..a52f50a1c 100644 --- a/packages/amis-editor/src/plugin/Form/NestedSelect.tsx +++ b/packages/amis-editor/src/plugin/Form/NestedSelect.tsx @@ -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}), diff --git a/packages/amis-editor/src/renderer/TreeOptionControl.tsx b/packages/amis-editor/src/renderer/TreeOptionControl.tsx new file mode 100644 index 000000000..3df758532 --- /dev/null +++ b/packages/amis-editor/src/renderer/TreeOptionControl.tsx @@ -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; + 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) { + 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 = { + 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 ( +
+ +
+ {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 + } + } + )} +
+
+ ); + } + + 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
+ {this.renderOptions(parent, key, indexes)} +
+ { + option.children.map((option: any, key: number) => { + return this.renderOptions(option, key, indexes.concat(key)) + }) + } +
+
+ } + return
+ + + + { // 这里使用onBlur替代onChange 减少渲染次数 + this.handleEditLabelOrValue(event.target.value, path, 'label'); + }} + /> + { + this.handleEditLabelOrValue(event.target.value, path, 'value'); + }} + /> +
+ {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); + } + } + ] + })} + +
+
+ } + @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 ( + { + this.hideModal(); + }} + > + { + this.hideModal(); + }} + > + 选项管理 + + +
+ {options.map((option, key) => this.renderOptions(option, key, [key]))} +
+
+ + + + +
+ ); + } + + @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 ( +
+ {this.renderHeader()} + + {source === 'custom' ? ( +
+
+ + {this.renderModal()} +
+
+ ) : null} + + {this.renderApiPanel()} +
+ ); + } +} + +@FormItem({ + type: 'ae-treeOptionControl', + renderLabel: false +}) +export class TreeOptionControlRenderer extends TreeOptionControl {} diff --git a/packages/amis-editor/src/tpl/options.tsx b/packages/amis-editor/src/tpl/options.tsx index d824df552..a9cba6941 100644 --- a/packages/amis-editor/src/tpl/options.tsx +++ b/packages/amis-editor/src/tpl/options.tsx @@ -298,3 +298,10 @@ setSchemaTpl('optionControlV2', { type: 'ae-optionControl', closeDefaultCheck: true // 关闭默认值设置 }); + +setSchemaTpl('treeOptionControl', { + label: '数据', + mode: 'normal', + name: 'options', + type: 'ae-treeOptionControl' +}); \ No newline at end of file