import React from 'react'; import {findDOMNode} from 'react-dom'; import { FormItem, FormControlProps, FormBaseControl, FormControlSchemaAlias } from './Item'; import {Schema, Action, Api} from '../../types'; import {ComboStore, IComboStore} from '../../store/combo'; import {default as CTabs, Tab} from '../../components/Tabs'; import { guid, anyChanged, isObject, createObject, extendObject, autobind, isObjectShallowModified } from '../../utils/helper'; import Sortable from 'sortablejs'; import {evalExpression, filter} from '../../utils/tpl'; import find from 'lodash/find'; import Select from '../../components/Select'; import {dataMapping, resolveVariable} from '../../utils/tpl-builtin'; import {isEffectiveApi} from '../../utils/api'; import {Alert2} from '../../components'; import memoize from 'lodash/memoize'; import {Icon} from '../../components/icons'; import {isAlive} from 'mobx-state-tree'; import { SchemaApi, SchemaClassName, SchemaCollection, SchemaIcon, SchemaObject, SchemaTpl } from '../../Schema'; export type ComboCondition = { test: string; items: Array; label: string; scaffold?: any; mode?: string; }; export type ComboSubControl = SchemaObject & { /** * 是否唯一, 只有在 combo 里面才有用 */ unique?: boolean; /** * 列类名,可以用来修改这类宽度。 */ columnClassName?: SchemaClassName; }; /** * Combo 组合输入框类型 * 文档:https://baidu.gitee.io/amis/docs/components/form/combo */ export interface ComboControlSchema extends FormBaseControl { /** * 指定为组合输入框类型 */ type: 'combo'; /** * 单组表单项初始值。默认为 `{}` * * @default {} */ scaffold?: any; /** * 是否含有边框 */ noBorder?: boolean; /** * 确认删除时的提示 */ deleteConfirmText?: string; /** * 删除时调用的api */ deleteApi?: SchemaApi; /** * 是否可切换条件,配合`conditions`使用 */ typeSwitchable?: boolean; /** * 符合某类条件后才渲染的schema */ conditions?: Array; /** * 内部单组表单项的类名 */ formClassName?: SchemaClassName; /** * 新增按钮CSS类名 */ addButtonClassName?: SchemaClassName; /** * 新增按钮文字 * @default 新增 */ addButtonText?: string; /** * 是否可新增 */ addable?: boolean; /** * 数组输入框的子项 */ items?: Array; /** * 是否可拖拽排序 */ draggable?: boolean; /** * 可拖拽排序的提示信息。 * * @default 可拖拽排序 */ draggableTip?: string; /** * 是否将结果扁平化(去掉name),只有当controls的length为1且multiple为true的时候才有效 */ flat?: boolean; /** * 当扁平化开启并且joinValues为true时,用什么分隔符 * * @deprecated */ delimiter?: string; /** * 当扁平化开启的时候,是否用分隔符的形式发送给后端,否则采用array的方式 * @deprecated */ joinValues?: boolean; /** * 限制最大个数 */ maxLength?: number; /** * 限制最小个数 */ minLength?: number; /** * 是否多行模式,默认一行展示完 */ multiLine?: boolean; /** * 是否可多选 */ multiple?: boolean; /** * 是否可删除 */ removable?: boolean; /** * 子表单的模式。 */ subFormMode?: 'normal' | 'horizontal' | 'inline'; /** * 没有成员时显示。 * @default empty */ placeholder?: string; /** * 是否可以访问父级数据,正常 combo 已经关联到数组成员,是不能访问父级数据的。 */ canAccessSuperData?: boolean; /** * 采用 Tabs 展示方式? */ tabsMode?: boolean; /** * Tabs 的展示模式。 */ tabsStyle?: '' | 'line' | 'card' | 'radio'; /** * 选项卡标题的生成模板。 */ tabsLabelTpl?: SchemaTpl; /** * 数据比较多,比较卡时,可以试试开启。 */ lazyLoad?: boolean; /** * 严格模式,为了性能默认不开的。 */ strictMode?: boolean; /** * 允许为空,如果子表单项里面配置验证器,且又是单条模式。可以允许用户选择清空(不填)。 */ nullable?: boolean; /** * 提示信息 */ messages?: { /** * 验证错误提示 */ validateFailed?: string; /** * 最小值验证错误提示 */ minLengthValidateFailed?: string; /** * 最大值验证错误提示 */ maxLengthValidateFailed?: string; }; } function pickVars(vars: any, fields: Array) { return fields.reduce((data: any, key: string) => { data[key] = resolveVariable(key, vars); return data; }, {}); } export interface ComboProps extends FormControlProps, Omit< ComboControlSchema, 'type' | 'className' | 'descriptionClassName' | 'inputClassName' > { store: IComboStore; changeImmediately?: boolean; } export default class ComboControl extends React.Component { static defaultProps: Pick< ComboProps, | 'minLength' | 'maxLength' | 'multiple' | 'multiLine' | 'addButtonClassName' | 'formClassName' | 'subFormMode' | 'draggableTip' | 'addButtonText' | 'canAccessSuperData' | 'addIcon' | 'dragIcon' | 'deleteIcon' | 'tabsMode' | 'tabsStyle' | 'placeholder' > = { minLength: 0, maxLength: 0, multiple: false, multiLine: false, addButtonClassName: '', formClassName: '', subFormMode: 'normal', draggableTip: '', addButtonText: 'Combo.add', canAccessSuperData: false, addIcon: true, dragIcon: '', deleteIcon: '', tabsMode: false, tabsStyle: '', placeholder: 'placeholder.empty' }; static propsList: Array = [ 'minLength', 'maxLength', 'multiple', 'multiLine', 'addButtonClassName', 'subFormMode', 'draggableTip', 'addButtonText', 'draggable', 'scaffold', 'canAccessSuperData', 'addIcon', 'dragIcon', 'deleteIcon', 'noBorder', 'conditions', 'tabsMode', 'tabsStyle', 'lazyLoad', 'changeImmediately', 'strictMode', 'items', 'conditions', 'messages' ]; subForms: Array = []; subFormDefaultValues: Array<{ index: number; values: any; setted: boolean; }> = []; keys: Array = []; dragTip?: HTMLElement; sortable?: Sortable; defaultValue?: any; toDispose: Array = []; id: string = guid(); constructor(props: ComboProps) { super(props); this.handleChange = this.handleChange.bind(this); this.handleSingleFormChange = this.handleSingleFormChange.bind(this); this.handleSingleFormInit = this.handleSingleFormInit.bind(this); this.handleFormInit = this.handleFormInit.bind(this); this.handleAction = this.handleAction.bind(this); this.addItem = this.addItem.bind(this); this.removeItem = this.removeItem.bind(this); this.dragTipRef = this.dragTipRef.bind(this); this.flush = this.flush.bind(this); this.handleComboTypeChange = this.handleComboTypeChange.bind(this); this.defaultValue = { ...props.scaffold }; } componentWillMount() { const { store, value, multiple, minLength, maxLength, formItem, addHook } = this.props; store.config({ multiple, minLength, maxLength, length: this.getValueAsArray().length }); formItem && isAlive(formItem) && formItem.setSubStore(store); addHook && this.toDispose.push(addHook(this.flush, 'flush')); } componentWillReceiveProps(nextProps: ComboProps) { const props = this.props; if (anyChanged(['minLength', 'maxLength', 'value'], props, nextProps)) { const {store, minLength, maxLength, multiple} = nextProps; const values = this.getValueAsArray(nextProps); store.config({ multiple, minLength, maxLength, length: values.length }); if (store.activeKey >= values.length) { store.setActiveKey(Math.max(0, values.length - 1)); } // combo 进来了新的值,且这次 form 初始化时带来的新值变化,但是之前的值已经 onInit 过了 // 所以,之前 onInit 设置进去的初始值是过时了的。这个时候修复一下。 if ( nextProps.value !== props.value && !props.formInited && this.subFormDefaultValues.length ) { this.subFormDefaultValues = this.subFormDefaultValues.map( (item, index) => { return { ...item, values: values[index] }; } ); } } } componentWillUnmount() { const {formItem} = this.props; formItem && isAlive(formItem) && formItem.setSubStore(null); this.toDispose.forEach(fn => fn()); this.toDispose = []; this.memoizedFormatValue.cache.clear?.(); this.makeFormRef.cache.clear?.(); } getValueAsArray(props = this.props) { const {flat, joinValues, delimiter} = props; let value = props.value; if (joinValues && flat && typeof value === 'string') { value = value.split(delimiter || ','); } else if (!Array.isArray(value)) { value = []; } else { value = value.concat(); } return value; } addItemWith(condition: ComboCondition) { const { flat, joinValues, delimiter, scaffold, disabled, submitOnChange } = this.props; if (disabled) { return; } let value = this.getValueAsArray(); value.push( flat ? condition.scaffold || scaffold || '' : { ...(condition.scaffold || scaffold) } ); this.keys.push(guid()); if (flat && joinValues) { value = value.join(delimiter || ','); } this.props.onChange(value, submitOnChange, true); } addItem() { const { flat, joinValues, delimiter, scaffold, disabled, submitOnChange } = this.props; if (disabled) { return; } let value = this.getValueAsArray(); value.push( flat ? scaffold || '' : { ...scaffold } ); this.keys.push(guid()); if (flat && joinValues) { value = value.join(delimiter || ','); } this.props.onChange(value, submitOnChange, true); } async removeItem(key: number) { const { flat, joinValues, delimiter, disabled, deleteApi, deleteConfirmText, data, env, translate: __ } = this.props; if (disabled) { return; } let value = this.getValueAsArray(); const ctx = createObject(data, value[key]); if (isEffectiveApi(deleteApi, ctx)) { const confirmed = await env.confirm( deleteConfirmText ? filter(deleteConfirmText, ctx) : __('deleteConfirm') ); if (!confirmed) { // 如果不确认,则跳过! return; } const result = await env.fetcher(deleteApi as Api, ctx); if (!result.ok) { env.notify('error', __('deleteFailed')); return; } } value.splice(key, 1); this.keys.splice(key, 1); if (flat && joinValues) { value = value.join(delimiter || ','); } this.props.onChange(value); } handleChange(values: any, diff: any, {index}: any) { const { flat, store, joinValues, delimiter, disabled, submitOnChange } = this.props; if (disabled) { return; } let value = this.getValueAsArray(); value[index] = flat ? values.flat : {...values}; if (flat && joinValues) { value = value.join(delimiter || ','); } this.props.onChange(value, submitOnChange, true); store.forms.forEach( item => isAlive(item) && item.items.forEach(item => item.unique && item.syncOptions()) ); } handleSingleFormChange(values: object) { this.props.onChange( { ...values }, this.props.submitOnChange, true ); } handleFormInit(values: any, {index}: any) { const { syncDefaultValue, flat, joinValues, delimiter, formInited, onChange, submitOnChange, setPrinstineValue } = this.props; this.subFormDefaultValues.push({ index, values, setted: false }); if ( syncDefaultValue === false || this.subFormDefaultValues.length !== this.subForms.length ) { return; } let value = this.getValueAsArray(); let isModified = false; this.subFormDefaultValues = this.subFormDefaultValues.map( ({index, values, setted}) => { const newValue = flat ? values.flat : {...values}; if (!setted && isObjectShallowModified(value[index], newValue)) { value[index] = flat ? values.flat : {...values}; isModified = true; } return { index, values, setted: true }; } ); if (!isModified) { return; } if (flat && joinValues) { value = value.join(delimiter || ','); } formInited ? onChange(value, submitOnChange, true) : setPrinstineValue(value); } handleSingleFormInit(values: any) { const {syncDefaultValue, setPrinstineValue, value, nullable} = this.props; if ( syncDefaultValue !== false && !nullable && isObjectShallowModified(value, values) && setPrinstineValue ) { setPrinstineValue({ ...values }); } } handleAction(action: Action): any { const {onAction} = this.props; if (action.actionType === 'delete') { action.index !== void 0 && this.removeItem(action.index); return; } onAction && onAction.apply(null, arguments); } validate(): any { const { value, minLength, maxLength, messages, nullable, translate: __ } = this.props; if (minLength && (!Array.isArray(value) || value.length < minLength)) { return __( (messages && messages.minLengthValidateFailed) || 'Combo.minLength', {minLength} ); } else if (maxLength && Array.isArray(value) && value.length > maxLength) { return __( (messages && messages.maxLengthValidateFailed) || 'Combo.maxLength', {maxLength} ); } else if (this.subForms.length && (!nullable || value)) { return Promise.all(this.subForms.map(item => item.validate())).then( values => { if (~values.indexOf(false)) { return __( (messages && messages.validateFailed) || 'validateFailed' ); } return; } ); } } flush() { this.subForms.forEach(form => form.flush()); } dragTipRef(ref: any) { if (!this.dragTip && ref) { this.initDragging(); } else if (this.dragTip && !ref) { this.destroyDragging(); } this.dragTip = ref; } initDragging() { const ns = this.props.classPrefix; const submitOnChange = this.props.submitOnChange; const dom = findDOMNode(this) as HTMLElement; this.sortable = new Sortable( dom.querySelector(`.${ns}Combo-items`) as HTMLElement, { group: `combo-${this.id}`, animation: 150, handle: `.${ns}Combo-itemDrager`, ghostClass: `${ns}Combo-item--dragging`, onEnd: (e: any) => { // 没有移动 if (e.newIndex === e.oldIndex) { return; } // 换回来 const parent = e.to as HTMLElement; if (e.oldIndex < parent.childNodes.length - 1) { parent.insertBefore(e.item, parent.childNodes[e.oldIndex]); } else { parent.appendChild(e.item); } const value = this.props.value; if (!Array.isArray(value)) { return; } const newValue = value.concat(); newValue.splice(e.newIndex, 0, newValue.splice(e.oldIndex, 1)[0]); this.keys.splice(e.newIndex, 0, this.keys.splice(e.oldIndex, 1)[0]); this.props.onChange(newValue, submitOnChange, true); } } ); } destroyDragging() { this.sortable && this.sortable.destroy(); } refsMap: { [propName: number]: any; } = {}; makeFormRef = memoize((index: number) => (ref: any) => this.formRef(ref, index) ); formRef(ref: any, index: number = 0) { if (ref) { while (ref && ref.getWrappedInstance) { ref = ref.getWrappedInstance(); } this.subForms[index] = ref; this.refsMap[index] = ref; } else { const form = this.refsMap[index]; this.subForms = this.subForms.filter(item => item !== form); this.subFormDefaultValues = this.subFormDefaultValues.filter( ({index: dIndex}) => dIndex !== index ); delete this.refsMap[index]; } } memoizedFormatValue = memoize( ( strictMode: boolean, syncFields: Array | void, value: any, index: number, data: any ) => { return createObject( extendObject(data, {index, __index: index, ...data}), { ...value, ...(Array.isArray(syncFields) ? pickVars(data, syncFields!) : null) } ); }, ( strictMode: boolean, syncFields: Array | void, value: any, index: number, data: any ) => Array.isArray(syncFields) ? JSON.stringify([value, index, data, pickVars(data, syncFields)]) : strictMode ? JSON.stringify([value, index]) : JSON.stringify([value, index, data]) ); formatValue(value: any, index: number = -1) { const {flat, data, strictMode, syncFields} = this.props; if (flat) { value = { flat: value }; } value = value || this.defaultValue; return this.memoizedFormatValue( strictMode !== false, syncFields, value, index, data ); } pickCondition(value: any): ComboCondition | null { const conditions: Array = this.props.conditions!; return find( conditions, item => item.test && evalExpression(item.test, value) ) as ComboCondition | null; } handleComboTypeChange(index: number, selection: any) { const {multiple, onChange, value, flat, submitOnChange} = this.props; const conditions: Array = this.props .conditions as Array; const condition = find(conditions, item => item.label === selection.label); if (!condition) { return; } if (multiple) { const newValue = this.getValueAsArray(); newValue.splice(index, 1, { ...dataMapping(condition.scaffold || {}, newValue[index]) }); // todo 支持 flat onChange(newValue, submitOnChange, true); } else { onChange( { ...dataMapping(condition.scaffold || {}, value) }, submitOnChange, true ); } } @autobind handleTabSelect(key: number) { const {store} = this.props; store.setActiveKey(key); } @autobind setNull(e: React.MouseEvent) { e.preventDefault(); const {onChange} = this.props; onChange(null); Array.isArray(this.subForms) && this.subForms.forEach(subForm => { subForm.clearErrors(); }); } renderPlaceholder() { const {placeholder, translate: __} = this.props; return ( {__(placeholder || 'placeholder.noData')} ); } renderTabsMode() { const { classPrefix: ns, classnames: cx, tabsStyle, formClassName, render, disabled, store, flat, subFormMode, addButtonText, addable, removable, typeSwitchable, itemRemovableOn, delimiter, canAccessSuperData, addIcon, deleteIcon, tabsLabelTpl, conditions, changeImmediately, translate: __ } = this.props; let items = this.props.items; let value = this.props.value; if (flat && typeof value === 'string') { value = value.split(delimiter || ','); } const finnalRemovable = store.removable !== false && // minLength ? !disabled && // 控件自身是否禁用 removable !== false; // 是否可以删除 if (!Array.isArray(value)) { return this.renderPlaceholder(); } // todo 支持拖拽排序。 return ( {store.addable && addable !== false ? ( Array.isArray(conditions) && conditions.length ? ( render( 'add-button', { type: 'dropdown-button', icon: addIcon ? ( ) : ( '' ), label: __(addButtonText || 'Combo.add'), level: 'info', size: 'sm', closeOnClick: true }, { buttons: conditions.map(item => ({ label: item.label, onClick: (e: any) => { this.addItemWith(item); return false; } })) } ) ) : ( {addIcon ? : null} {__(addButtonText || 'Combo.add')} ) ) : null} ) : null } > {value.map((value, index) => { const data = this.formatValue(value, index); let condition: ComboCondition | null | undefined = null; let toolbar = undefined; if ( finnalRemovable && // 表达式判断单条是否可删除 (!itemRemovableOn || evalExpression(itemRemovableOn, value) !== false) ) { toolbar = (
{deleteIcon ? ( ) : ( )}
); } if (Array.isArray(conditions) && conditions.length) { condition = this.pickCondition(data); items = condition ? condition.items : undefined; } let finnalControls = flat && items ? [ { ...(items && items[0]), name: 'flat' } ] : items; const hasUnique = Array.isArray(finnalControls) && finnalControls.some((item: any) => item.unique); return ( {condition && typeSwitchable !== false ? (
).map( item => ({ label: item.label, value: item.label }) )} value={condition.label} clearable={false} />
) : null}
{finnalControls ? ( render( `multiple/${index}`, { type: 'form', body: finnalControls, wrapperComponent: 'div', wrapWithPanel: false, mode: multiLine ? subFormMode : 'row', className: cx(`Combo-form`, formClassName) }, { index, disabled, data, onChange: this.handleChange, onInit: this.handleFormInit, onAction: this.handleAction, ref: this.makeFormRef(index), lazyChange: changeImmediately ? false : true, formLazyChange: false, lazyLoad, canAccessSuperData, value: undefined, formItemValue: undefined } ) ) : ( {__('Combo.invalidData')} )}
{delBtn} ); }) ) : placeholder ? (
{__(placeholder)}
) : null} {!disabled ? (
{store.addable && addable !== false ? ( Array.isArray(conditions) && conditions.length ? ( render( 'add-button', { type: 'dropdown-button', label: __(addButtonText || 'Combo.add'), level: 'info', size: 'sm', closeOnClick: true }, { buttons: conditions.map(item => ({ label: item.label, onClick: (e: any) => { this.addItemWith(item); return false; } })) } ) ) : ( ) ) : null} {draggable ? ( {Array.isArray(value) && value.length > 1 ? __(draggableTip) : ''} ) : null}
) : null} ); } renderSingle() { const { conditions, classnames: cx, render, value, multiLine, formClassName, canAccessSuperData, noBorder, disabled, typeSwitchable, nullable, translate: __ } = this.props; let items = this.props.items; const data = isObject(value) ? this.formatValue(value) : this.defaultValue; let condition: ComboCondition | null = null; if (Array.isArray(conditions) && conditions.length) { condition = this.pickCondition(data); items = condition ? condition.items : undefined; } return (
{condition && typeSwitchable !== false ? (