diff --git a/scss/components/form/_tree.scss b/scss/components/form/_tree.scss index f48c75a4b..0a03d2c03 100644 --- a/scss/components/form/_tree.scss +++ b/scss/components/form/_tree.scss @@ -1,3 +1,33 @@ +@mixin tree-input { + > svg { + display: inline-block; + cursor: pointer; + position: relative; + top: 2px; + width: px2rem(16px); + height: px2rem(16px); + margin-left: px2rem(5px); + } + + > input { + margin-left: px2rem(15px); + padding: px2rem(5px); + width: px2rem(150px); + height: px2rem(25px); + color: $Form-input-color; + + &::placeholder { + color: $Form-input-placeholderColor; + user-select: none; + } + + &:focus { + outline: none; + border: $borderWidth solid $info; + } + } +} + // todo .#{$ns}TreeControl { border: 1px solid $Form-input-borderColor; @@ -29,27 +59,79 @@ &.is-folded { display: none; } + + > li { + @include tree-input; + } } &-item { line-height: px2rem(30px); position: relative; + .#{$ns}Tree-item-icons { + visibility: hidden; + transition: visibility .1s ease; + } + > a { color: inherit; &:hover { text-decoration: none; + + > span.#{$ns}Tree-item-icons { + visibility: visible; + } + } + + > span > svg { + display: inline-block; + cursor: pointer; + position: relative; + top: 2px; + width: px2rem(16px); + height: px2rem(16px); + margin-left: px2rem(5px); } } &--isLeaf > a { padding-left: $Tree-itemArrowWidth + $gap-xs; } + + &--isEdit { + display: inline-block; + @include tree-input; + } } - &-rootItem > a > i { - margin-left: 0 !important; + &-rootItem { + > a > i { + margin-left: 0 !important; + } + + .#{$ns}Tree-addTop { + height: px2rem(25px); + line-height: px2rem(25px); + cursor: pointer; + padding-left: $Tree-indent; + > p { + > svg { + position: relative; + top: 2px; + width: px2rem(16px); + height: px2rem(16px); + } + > span { + padding-left: px2rem(5px); + } + } + + &-input { + @include tree-input + } + } } &-itemArrow { diff --git a/src/components/Tree.tsx b/src/components/Tree.tsx index 161d031e2..a0a120474 100644 --- a/src/components/Tree.tsx +++ b/src/components/Tree.tsx @@ -5,16 +5,17 @@ */ import React from 'react'; -import {eachTree, isVisible} from '../utils/helper'; +import {eachTree, isVisible, isObject, autobind} from '../utils/helper'; import {Option, Options, value2array} from './Checkboxes'; import {ClassNamesFn, themeable} from '../theme'; import {highlight} from '../renderers/Form/Options'; +import {Icon} from './icons'; interface TreeSelectorProps { classPrefix: string; classnames: ClassNamesFn; - highlightTxt: string; + highlightTxt?: string; showIcon?: boolean; // 是否默认都展开 @@ -53,11 +54,26 @@ interface TreeSelectorProps { selfDisabledAffectChildren?: boolean; minLength?: number; maxLength?: number; + addMode?: 'dialog' | 'normal'; + addable?: boolean; + onAdd?: Function; + openAddDialog?: Function; + editMode?: 'dialog' | 'normal'; + onEdit?: Function; + editable?: boolean; + openEditDialog?: Function; + deletable?: boolean; + onRemove?: Function; } interface TreeSelectorState { value: Array; unfolded: {[propName: string]: string}; + editItem: Option | null; + addItem: Option | null; + addingItem: Option | null; + editingItem: Option | null; + addTop: boolean; } export class TreeSelector extends React.Component { @@ -87,12 +103,6 @@ export class TreeSelector extends React.Component { @@ -333,8 +484,8 @@ export class TreeSelector extends React.Component= maxLength) || - (minLength && selfChecked && this.state.value.length <= minLength)) + ((maxLength && !selfChecked && stateValue.length >= maxLength) || + (minLength && selfChecked && stateValue.length <= minLength)) ) { nodeDisabled = true; } @@ -370,47 +521,70 @@ export class TreeSelector extends React.Component - - {!isLeaf ? ( - this.toggleUnfolded(item)} - className={cx('Tree-itemArrow', { - 'is-folded': !this.state.unfolded[item[valueField]] - })} - /> - ) : null} + {!editItem || isObject(editItem) && (editItem as Option)[valueField] !== item[valueField] ? ( + + {!isLeaf ? ( + this.toggleUnfolded(item)} + className={cx('Tree-itemArrow', { + 'is-folded': !unfolded[item[valueField]], + })} + /> + ) : null} - {showIcon ? ( - - ) : null} + )} + /> + ) : null} - {checkbox} + {checkbox} - - !nodeDisabled && - (multiple ? this.handleCheck(item, !selfChecked) : this.handleSelect(item)) - } - > - {highlightTxt ? highlight(item[nameField], highlightTxt) : item[nameField]} - - - {childrenItems ? ( + + !nodeDisabled && + (multiple ? this.handleCheck(item, !selfChecked) : this.handleSelect(item)) + } + > + {highlightTxt ? highlight(item[nameField], highlightTxt) : item[nameField]} + + {!addTop + && !addItem + && !editItem ? ( + + {addable ? this.handleAdd(item, !isLeaf)}/> : null} + {deletable ? this.handleRemove(item)}/> : null} + {editable ? this.handleEdit(item)}/> : null} + + ) : null} + + ) : ( +
+ this.handleChangeOnEdit(item, e.currentTarget.value)}/> + + +
+ )} + {/* 有children而且为展开状态 或者 添加child时 */} + {((childrenItems && unfolded[item[valueField]]) || addItem && (addItem[valueField] === item[valueField])) ? (
    + {addItem && addItem[valueField] === item[valueField] ? ( +
  • + this.handleChangeOnAdd(e.currentTarget.value)}/> + + +
  • + ) : null} {childrenItems}
) : null} @@ -425,10 +599,10 @@ export class TreeSelector extends React.Component {data && data.length ? ( @@ -450,6 +624,23 @@ export class TreeSelector extends React.Component + {addable ? ( +
+ {!addTop ? ( +

this.handleAdd(null, false)}> + + 添加一级 +

+ ) : null} + {addTop ? ( +
+ this.handleChangeOnAdd(e.currentTarget.value)}/> + + +
+ ) : null} +
+ ) : null}
    {this.renderList(data, value, false).dom}
)} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 150ae7b47..131630f5a 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -25,6 +25,14 @@ import PauseIcon from '../icons/pause.svg'; import LeftArrowIcon from '../icons/left-arrow.svg'; // @ts-ignore import RightArrowIcon from '../icons/right-arrow.svg'; +// @ts-ignore +import CheckIcon from '../icons/check.svg'; +// @ts-ignore +import PlusIcon from '../icons/plus.svg'; +// @ts-ignore +import MinusIcon from '../icons/minus.svg'; +// @ts-ignore +import PencilIcon from '../icons/pencil.svg'; // 兼容原来的用法,后续不直接试用。 // @ts-ignore @@ -70,6 +78,10 @@ registerIcon('play', PlayIcon); registerIcon('pause', PauseIcon); registerIcon('left-arrow', LeftArrowIcon); registerIcon('right-arrow', RightArrowIcon); +registerIcon('check', CheckIcon); +registerIcon('plus', PlusIcon); +registerIcon('minus', MinusIcon); +registerIcon('pencil', PencilIcon); export function Icon({ icon, @@ -91,5 +103,9 @@ export { PlayIcon, PauseIcon, LeftArrowIcon, - RightArrowIcon + RightArrowIcon, + CheckIcon, + PlusIcon, + MinusIcon, + PencilIcon }; diff --git a/src/icons/check.svg b/src/icons/check.svg new file mode 100644 index 000000000..304805e88 --- /dev/null +++ b/src/icons/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/minus.svg b/src/icons/minus.svg new file mode 100644 index 000000000..67a9eb059 --- /dev/null +++ b/src/icons/minus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/pencil.svg b/src/icons/pencil.svg new file mode 100644 index 000000000..6edc00937 --- /dev/null +++ b/src/icons/pencil.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/plus.svg b/src/icons/plus.svg new file mode 100644 index 000000000..c6c279275 --- /dev/null +++ b/src/icons/plus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/renderers/Form/Tree.tsx b/src/renderers/Form/Tree.tsx index c595d50f1..37eeec84a 100644 --- a/src/renderers/Form/Tree.tsx +++ b/src/renderers/Form/Tree.tsx @@ -2,6 +2,11 @@ import React from 'react'; import cx from 'classnames'; import TreeSelector from '../../components/Tree'; import {OptionsControl, OptionsControlProps} from './Options'; +import {autobind, createObject} from '../../utils/helper'; +import {Action, Schema, PlainObject, Api, Payload} from '../../types'; +import {isEffectiveApi} from '../../utils/api'; +import {filter} from '../../utils/tpl'; +import {Option} from '../../components/Checkboxes'; export interface TreeProps extends OptionsControlProps { placeholder?: any; @@ -12,9 +17,24 @@ export interface TreeProps extends OptionsControlProps { cascade?: boolean; // 父子之间是否完全独立。 withChildren?: boolean; // 选父级的时候是否把子节点的值也包含在内。 onlyChildren?: boolean; // 选父级的时候,是否只把子节点的值包含在内 + addApi?: Api; + addMode?: 'dialog' | 'normal'; + addDialog?: Schema; + editApi?: Api; + editMode?: 'dialog' | 'normal'; + editDialog?: Schema; + deleteApi?: Api; + deleteConfirmText?: string; } -export default class TreeControl extends React.Component { +export interface TreeState { + isAddModalOpened: boolean, + isEditModalOpened: boolean, + parent: Option | null, + prev: Option | null +} + +export default class TreeControl extends React.Component { static defaultProps: Partial = { placeholder: '选项加载中...', multiple: false, @@ -24,11 +44,123 @@ export default class TreeControl extends React.Component { showIcon: true }; + state: TreeState = { + isAddModalOpened: false, + isEditModalOpened: false, + parent: null, + prev: null + } + reload() { const reload = this.props.reloadOptions; reload && reload(); } + @autobind + handleAdd(values: PlainObject) { + this.saveRemote(values, 'add'); + } + + @autobind + handleAddModalConfirm(values: Array, action: Action, ctx: any, components: Array) { + this.saveRemote({ + ...values, + parent: this.state.parent + }, 'add'); + this.closeAddDialog(); + } + + @autobind + handleEdit(values: PlainObject) { + this.saveRemote(values, 'edit'); + } + + @autobind + handleEditModalConfirm(values: Array, action: Action, ctx: any, components: Array) { + this.saveRemote({ + ...values, + prev: this.state.prev + }, 'edit'); + this.closeEditDialog(); + } + + @autobind + async saveRemote(item: any, type: 'add' | 'edit') { + const { + addApi, + editApi, + data, + env + } = this.props; + + let remote: Payload | null = null; + if (type == 'add' && isEffectiveApi(addApi, createObject(data, item))) { + remote = await env.fetcher(addApi, createObject(data, item)); + } else if (type == 'edit' && isEffectiveApi(editApi, createObject(data, item))) { + remote = await env.fetcher(editApi, createObject(data, item)); + } + + if (remote && !remote.ok) { + env.notify('error', remote.msg || '保存失败'); + return; + } + + this.reload(); + } + + @autobind + async handleRemove(item: any) { + const {deleteConfirmText, deleteApi, data, env} = this.props; + const ctx = createObject(data, item); + if (isEffectiveApi(deleteApi, ctx)) { + const confirmed = await env.confirm(deleteConfirmText ? filter(deleteConfirmText, ctx) : '确认要删除?'); + if (!confirmed) { + return; + } + + const result = await env.fetcher(deleteApi, ctx); + + if (!result.ok) { + env.notify('error', '删除失败'); + return; + } + + this.reload(); + } + } + + @autobind + openAddDialog(parent: Option | null) { + this.setState({ + isAddModalOpened: true, + parent + }); + } + + @autobind + closeAddDialog() { + this.setState({ + isAddModalOpened: false, + parent: null + }); + } + + @autobind + openEditDialog(prev: Option) { + this.setState({ + isEditModalOpened: true, + prev + }) + } + + @autobind + closeEditDialog() { + this.setState({ + isEditModalOpened: false, + prev: null + }); + } + render() { const { className, @@ -55,7 +187,14 @@ export default class TreeControl extends React.Component { rootValue, showIcon, showRadio, - render + render, + addMode, + addApi, + addDialog, + editMode, + editApi, + editDialog, + deleteApi } = this.props; return ( @@ -90,8 +229,46 @@ export default class TreeControl extends React.Component { value={value || ''} nameField="label" selfDisabledAffectChildren={false} + addMode={addMode} + addable={isEffectiveApi(addApi)} + onAdd={this.handleAdd} + openAddDialog={this.openAddDialog} + editMode={editMode} + editable={isEffectiveApi(editApi)} + onEdit={this.handleEdit} + openEditDialog={this.openEditDialog} + onRemove={this.handleRemove} + deletable={isEffectiveApi(deleteApi)} /> )} + + {addMode && render( + 'modal', + { + type: 'dialog', + ...addDialog + }, + { + key: 'addModal', + onConfirm: this.handleAddModalConfirm, + onClose: this.closeAddDialog, + show: this.state.isAddModalOpened + } + )} + + {editMode && render( + 'modal', + { + type: 'dialog', + ...editDialog + }, + { + key: 'editModal', + onConfirm: this.handleEditModalConfirm, + onClose: this.closeEditDialog, + show: this.state.isEditModalOpened + } + )} ); }