diff --git a/packages/amis-editor/src/renderer/OptionControl.tsx b/packages/amis-editor/src/renderer/OptionControl.tsx index c3832b8d1..5392bb8b9 100644 --- a/packages/amis-editor/src/renderer/OptionControl.tsx +++ b/packages/amis-editor/src/renderer/OptionControl.tsx @@ -8,15 +8,16 @@ import cx from 'classnames'; import DeepDiff from 'deep-diff'; import uniqBy from 'lodash/uniqBy'; import omit from 'lodash/omit'; -import get from 'lodash/get'; import Sortable from 'sortablejs'; import { - FormItem, + Renderer, Button, Checkbox, Icon, InputBox, - render as amisRender + RendererProps, + render as amisRender, + normalizeApi } from 'amis'; import {value2array} from 'amis-ui/lib/components/Select'; @@ -39,182 +40,46 @@ export interface PopoverForm { export type OptionControlItem = Option & {checked: boolean}; -export interface OptionControlProps extends FormControlProps { - className?: string; -} - -export type SourceType = 'custom' | 'api' | 'apicenter' | 'variable'; - export interface OptionControlState { options: Array; api: SchemaApi; labelField: string; valueField: string; + source: SourceType; } -export default class OptionControl extends React.Component< - OptionControlProps, - OptionControlState -> { +export interface OptionSourceControlProps + extends OptionControlState, + RendererProps { + onChange: (value: Partial) => void; +} + +export interface OptionSource { + label: string; + value: SourceType; + test?: (value: Omit) => boolean; + render?: (props: OptionSourceControlProps) => JSX.Element; + component?: React.ComponentType; +} + +export interface OptionControlProps extends RendererProps { + className?: string; + + // 允许的选项源类型 + enabledOptionSourceType?: Array; + + // 额外扩充的选项源 + extraOptionSources: Array; +} + +export type SourceType = 'custom' | 'api' | 'apicenter' | 'variable' | string; + +// 仅负责静态选项的配置 +class CustomOptionControl extends React.Component { sortable?: Sortable; drag?: HTMLElement | null; target: HTMLElement | null; - $comp: string; // 记录一下路径,不再从外部同步内部,只从内部同步外部 - lastOptions: OptionControlProps; - - internalProps = ['checked', 'editing']; - - constructor(props: OptionControlProps) { - super(props); - - 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 || '' - : ''; - - 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'); - // 左侧code手动更新时,同步配置面板 - if (DeepDiff.diff(options, this.lastOptions)) { - this.setState({ - options: this.transformOptions(nextProps) - }); - } - } - - transformOptions(props: OptionControlProps) { - const {data: ctx, value: options} = props; - let defaultValue: Array | 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; - - if (!checkedOptions.length) { - return undefined; - } - - 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, options} = this.state; - const {onBulkChange} = this.props; - const defaultValue = this.normalizeValue(); - const data: Partial = { - source: undefined, - options: undefined, - labelField: undefined, - valueField: undefined - }; - - if (source === 'custom') { - 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; - this.lastOptions = data.options; - } - - if (source === 'api' || source === 'apicenter' || source === 'variable') { - const {api, labelField, valueField} = this.state; - data.source = api; - data.labelField = labelField || undefined; - data.valueField = valueField || undefined; - this.lastOptions = data.source; - } - - onBulkChange && onBulkChange(data); - return; - } @autobind targetRef(ref: any) { @@ -233,6 +98,7 @@ export default class OptionControl extends React.Component< } initDragging() { + const {onChange} = this.props; const dom = findDOMNode(this) as HTMLElement; this.sortable = new Sortable( @@ -261,10 +127,10 @@ export default class OptionControl extends React.Component< parent.appendChild(e.item); } - const options = this.state.options.concat(); + const options = this.props.options.concat(); options.splice(e.newIndex, 0, options.splice(e.oldIndex, 1)[0]); - this.setState({options}, () => this.onChange()); + onChange({options}); } } ); @@ -274,38 +140,23 @@ export default class OptionControl extends React.Component< this.sortable && this.sortable.destroy(); } - scroll2Bottom() { - this.drag && - this.drag?.lastElementChild?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'start' - }); - } - - /** - * 切换选项类型 - */ - @autobind - handleSourceChange(source: SourceType) { - this.setState({api: '', source: source}, this.onChange); - } - /** * 删除选项 */ handleDelete(index: number) { - const options = this.state.options.concat(); + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); options.splice(index, 1); - this.setState({options}, () => this.onChange()); + onChange({options}); } /** * 设置默认选项 */ handleToggleDefaultValue(index: number, checked: any, shift?: boolean) { - let options = this.state.options.concat(); + const {onChange, options: originOptions} = this.props; + let options = originOptions.concat(); const isMultiple = this.props?.data?.multiple || this.props?.multiple; if (isMultiple) { @@ -317,69 +168,79 @@ export default class OptionControl extends React.Component< })); } - this.setState({options}, () => this.onChange()); + onChange({options}); } /** * 编辑选项 */ toggleEdit(index: number) { - const {options} = this.state; - options[index].editing = !options[index].editing; - this.setState({options}); + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); + options.splice(index, 1, { + ...options[index], + editing: !options[index].editing + }); + onChange({options}); } /** * 编辑角标 */ toggleBadge(index: number, value: string) { - const {options} = this.state; - options[index].badge = value; - - this.setState({options}, () => this.onChange()); + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); + options.splice(index, 1, { + ...options[index], + badge: value + }); + onChange({options}); } @autobind handleEditLabel(index: number, value: string) { - const options = this.state.options.concat(); + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); options.splice(index, 1, {...options[index], label: value}); - this.setState({options}, () => this.onChange()); + onChange({options}); } @autobind handleHiddenValueChange(index: number, value: string) { - const options = this.state.options.concat(); + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); const {hiddenOn, ...option} = options[index]; options.splice(index, 1, { ...option, ...(!value ? {} : {hiddenOn: value}) }); - this.setState({options}, () => this.onChange()); + onChange({options}); } @autobind handleAdd() { - const {options} = this.state; + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); options.push({ label: '', value: null, checked: false }); - this.setState({options}, () => { - this.onChange(); - }); + onChange({options}); } handleValueChange(index: number, value: string) { - const options = this.state.options.concat(); + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); options[index].value = value; - this.setState({options}, () => this.onChange()); + onChange({options}); } @autobind handleBatchAdd(values: {batchOption: string}[], action: any) { - const options = this.state.options.concat(); + const {onChange, options: originOptions} = this.props; + const options = originOptions.concat(); const addedOptions: Array = values[0].batchOption .split('\n') .map(option => { @@ -392,88 +253,7 @@ export default class OptionControl extends React.Component< }); 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' - }, - ...(hasApiCenter ? [{label: 'API中心', value: 'apicenter'}] : []), - { - label: '上下文变量', - value: 'variable' - } - // { - // label: '表单实体', - // value: 'form' - // } - ] as Array<{ - label: string; - value: SourceType; - }> - ).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 - } - } - )} -
-
- ); + onChange({options: newOptions}); } renderOption(props: any) { @@ -689,132 +469,514 @@ export default class OptionControl extends React.Component< }; } - @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[] { - 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: 'this.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; - + const {options, multiple: multipleProps, render} = this.props; return ( -
- {this.renderHeader()} - - {/* 自定义选项 */} - {source === 'custom' ? ( -
- {Array.isArray(options) && options.length ? ( -
    - {options.map((option, index) => - this.renderOption({...option, index, multipleProps}) - )} -
- ) : ( -
无选项
+
+ {Array.isArray(options) && options.length ? ( +
    + {options.map((option, index) => + this.renderOption({...option, index, multipleProps}) )} -
    - - {/* {render('option-control-batchAdd', this.buildBatchAddSchema())} */} - {render('inner', this.buildBatchAddSchema())} -
    +
+ ) : ( +
无选项
+ )} +
+ + {/* {render('option-control-batchAdd', this.buildBatchAddSchema())} */} + {render('inner', this.buildBatchAddSchema())} +
- {/* {this.renderPopover()} */} -
- ) : null} - - {/* API 接口 */} - {source === 'api' || source === 'apicenter' - ? this.renderApiPanel() - : null} - - {/* 上下文变量 */} - {source === 'variable' - ? render('variable', { - type: 'control', - label: false, - className: 'ae-ExtendMore', - body: [ - getSchemaTpl('sourceBindControl', { - label: false, - onChange: debounce(this.handleAPIChange, 1000) - }) - ].concat(this.getFuncFieldSchema()) - }) - : null} + {/* {this.renderPopover()} */}
); } } -@FormItem({ - type: 'ae-optionControl', - renderLabel: false +// 负责 api 和 apicenter 的配置 +function APIOptionControl({ + render, + source, + api, + onChange, + labelField, + valueField +}: OptionSourceControlProps) { + const handleAPIChange = React.useCallback((source: SchemaApi) => { + onChange({api: source}); + }, []); + const handleLabelFieldChange = React.useCallback((value: string) => { + onChange({labelField: value}); + }, []); + const handleValueFieldChange = React.useCallback((value: string) => { + onChange({valueField: value}); + }, []); + + const footer = React.useMemo(() => { + return [ + { + children: ({render, labelField}: any) => { + return render( + 'inner', + { + label: tipedLabel( + '显示字段', + '选项文本对应的数据字段,多字段合并请通过模板配置' + ), + type: 'input-text', + name: 'labelField', + clearable: true, + placeholder: '选项文本对应的字段' + }, + { + value: labelField, + onChange: handleLabelFieldChange + } + ); + } + }, + { + children: ({render, valueField}: any) => { + return render( + 'inner', + { + label: '值字段', + type: 'input-text', + name: 'valueField', + clearable: true, + placeholder: '值对应的字段' + }, + { + value: valueField, + onChange: handleValueFieldChange + } + ); + } + } + ]; + }, []); + const schema = React.useMemo(() => { + return getSchemaTpl('apiControl', { + label: '接口', + name: 'source', + mode: 'normal', + className: 'ae-ExtendMore', + visibleOn: 'this.autoComplete !== false', + footer + }); + }, [footer]); + + return render('api', schema, { + value: api, + onChange: handleAPIChange, + sourceType: source, + labelField, + valueField + }); +} + +// 负责上下文变量绑定的配置 +function variableOptionControl({ + render, + api, + onChange, + labelField, + valueField +}: OptionSourceControlProps) { + const handleAPIChange = React.useCallback((source: SchemaApi) => { + onChange({api: source}); + }, []); + const handleLabelFieldChange = React.useCallback((value: string) => { + onChange({labelField: value}); + }, []); + const handleValueFieldChange = React.useCallback((value: string) => { + onChange({valueField: value}); + }, []); + + const footer = React.useMemo(() => { + return [ + { + children: ({render, controlledValue}: any) => { + return render( + 'inner', + { + label: tipedLabel( + '显示字段', + '选项文本对应的数据字段,多字段合并请通过模板配置' + ), + type: 'input-text', + name: 'labelField', + clearable: true, + placeholder: '选项文本对应的字段' + }, + { + value: controlledValue.labelField, + onChange: handleLabelFieldChange + } + ); + } + }, + { + children: ({render, controlledValue}: any) => { + return render( + 'inner', + { + label: '值字段', + type: 'input-text', + name: 'valueField', + clearable: true, + placeholder: '值对应的字段' + }, + { + value: controlledValue.valueField, + onChange: handleValueFieldChange + } + ); + } + } + ]; + }, []); + + const schema = React.useMemo(() => { + return { + type: 'control', + label: false, + className: 'ae-ExtendMore', + body: [ + { + children: ({render, controlledValue}: any) => + render( + 'inner', + getSchemaTpl('sourceBindControl', { + label: false + }), + { + value: controlledValue.api, + onChange: handleAPIChange + } + ) + } + ].concat(footer) + }; + }, [footer]); + return render('api', schema, { + controlledValue: { + api, + labelField, + valueField + } + }); +} + +const builtinOptionSource: Array = [ + { + label: '自定义选项', + value: 'custom', + component: CustomOptionControl + }, + { + label: '外部接口', + value: 'api', + component: APIOptionControl, + test: ({api}) => { + const url = normalizeApi(api).url; + return !!( + typeof url === 'string' && + url && + !(typeof api === 'string' && /\$\{(.*?)\}/g.test(api)) + ); + } + }, + { + label: 'API中心', + value: 'apicenter', + component: APIOptionControl, + test: ({api}) => { + const url = normalizeApi(api).url; + return typeof url === 'string' && url.startsWith('api://'); + } + }, + { + label: '上下文变量', + value: 'variable', + component: variableOptionControl, + test: ({api}) => typeof api === 'string' && /\$\{(.*?)\}/g.test(api) + } +]; + +export default class OptionControl extends React.Component< + OptionControlProps, + OptionControlState +> { + internalProps = ['checked', 'editing']; + optionSources: Array = []; + lastOptions: OptionControlProps; + + constructor(props: OptionControlProps) { + super(props); + + this.optionSources = builtinOptionSource.concat( + Array.isArray(props.extraOptionSources) ? props.extraOptionSources : [] + ); + const state = { + options: this.transformOptions(props) || [], + api: props.data.source, + labelField: props.data.labelField, + valueField: props.data.valueField + }; + + let source: SourceType = + this.enabledOptionSources.reduce( + (type: string | undefined, source) => + type ?? (source.test?.(state) === true ? source.value : type), + undefined + ) || 'custom'; + + this.state = { + ...state, + source + }; + } + + /** + * 数据更新 + */ + componentWillReceiveProps(nextProps: OptionControlProps) { + const options = nextProps.data.options; + // 左侧code手动更新时,同步配置面板 + if (DeepDiff.diff(options, this.lastOptions)) { + this.setState({ + options: this.transformOptions(nextProps) + }); + } + } + + get enabledOptionSources() { + const {hasApiCenter, enabledOptionSourceType} = this.props; + let options = this.optionSources; + + if (!hasApiCenter) { + options = options.filter(item => item.value !== 'apicenter'); + } + + if (Array.isArray(enabledOptionSourceType)) { + options = enabledOptionSourceType + .map(type => options.find(a => a.value === type)!) + .filter(item => item); + } + + return options; + } + + transformOptions(props: OptionControlProps) { + const {data: ctx} = props; + const options = ctx.options; + let defaultValue: Array | 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; + + if (!checkedOptions.length) { + return undefined; + } + + 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字段的统一出口 + */ + @autobind + emitChange() { + const {source, options} = this.state; + const {onBulkChange} = this.props; + const defaultValue = this.normalizeValue(); + const data: Partial = { + source: undefined, + options: undefined, + labelField: undefined, + valueField: undefined + }; + + if (source === 'custom') { + 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; + this.lastOptions = data.options; + } + + if (source === 'api' || source === 'apicenter' || source === 'variable') { + const {api, labelField, valueField} = this.state; + data.source = api; + data.labelField = labelField || undefined; + data.valueField = valueField || undefined; + this.lastOptions = data.source; + } + + onBulkChange && onBulkChange(data); + } + + /** + * 切换选项类型 + */ + @autobind + handleSourceChange(source: SourceType) { + if (this.state.source === source) { + return; + } + this.setState({api: '', source: source}, this.emitChange); + } + + @autobind + handleSourceControlChange(value: OptionControlState) { + this.setState(value, this.emitChange); + } + + renderHeader() { + const {render, label, labelRemark, useMobileUI, env, popOverContainer} = + this.props; + const classPrefix = env?.theme?.classPrefix; + const {source} = this.state; + let optionSourceList = this.enabledOptionSources.map(item => ({ + label: item.label, + value: item.value, + 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 + } + } + )} +
+
+ ); + } + + render() { + const {source} = this.state; + const {className} = this.props; + const sourceControl = this.optionSources.find( + item => item.value === source + ); + const sourceControlProps = { + ...this.props, + ...this.state, + onChange: this.handleSourceControlChange + }; + + return ( +
+ {this.renderHeader()} + + {sourceControl ? ( + sourceControl.component ? ( + + ) : ( + sourceControl.render!(sourceControlProps) + ) + ) : null} +
+ ); + } +} + +@Renderer({ + type: 'ae-optionControl' }) export class OptionControlRenderer extends OptionControl {}