| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667 | import React from 'react';import cls from 'classnames';import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';import PropTypes from 'prop-types';import { isEqual, noop, omit, isEmpty, isArray } from 'lodash';import TransferFoundation, { TransferAdapter, BasicDataItem, OnSortEndProps } from '@douyinfe/semi-foundation/transfer/foundation';import { _generateDataByType, _generateSelectedItems } from '@douyinfe/semi-foundation/transfer/transferUtils';import { cssClasses, strings } from '@douyinfe/semi-foundation/transfer/constants';import '@douyinfe/semi-foundation/transfer/transfer.scss';import BaseComponent from '../_base/baseComponent';import LocaleConsumer from '../locale/localeConsumer';import { Locale } from '../locale/interface';import { Checkbox } from '../checkbox/index';import Input, { InputProps } from '../input/index';import Spin from '../spin';import Button from '../button';import Tree from '../tree';import { IconClose, IconSearch, IconHandle } from '@douyinfe/semi-icons';import { Value as TreeValue, TreeProps } from '../tree/interface';export interface DataItem extends BasicDataItem {    label?: React.ReactNode;    style?: React.CSSProperties}export interface GroupItem {    title?: string;    children?: Array<DataItem>}export interface TreeItem extends DataItem {    children: Array<TreeItem>}export interface RenderSourceItemProps extends DataItem {    checked: boolean;    onChange?: () => void}export interface RenderSelectedItemProps extends DataItem {    onRemove?: () => void;    sortableHandle?: typeof SortableHandle}export interface EmptyContent {    left?: React.ReactNode;    right?: React.ReactNode;    search?: React.ReactNode}export type Type = 'list' | 'groupList' | 'treeList';export interface SourcePanelProps {    value: Array<string | number>;    /* Loading */    loading: boolean;    /* Whether there are no items that match the current search value */    noMatch: boolean;    /* Items that match the current search value */    filterData: Array<DataItem>;    /* All items */    sourceData: Array<DataItem>;    /* transfer props' dataSource */    propsDataSource: DataSource;    /* Whether to select all */    allChecked: boolean;    /* Number of filtered results */    showNumber: number;    /* Input search box value */    inputValue: string;    /* The function that should be called when the search box changes */    onSearch: (searchString: string) => void;    /* The function that should be called when all the buttons on the left are clicked */    onAllClick: () => void;    /* Selected item on the left */    selectedItems: Map<string | number, DataItem>;    /* The function that should be called when selecting or deleting a single option */    onSelectOrRemove: (item: DataItem) => void;    /* The function that should be called when selecting an option, */    onSelect: (value: Array<string | number>) => void}export type OnSortEnd = ({ oldIndex, newIndex }: OnSortEndProps) => void;export interface SelectedPanelProps {    /* Number of selected options */    length: number;    /* Collection of all selected options */    selectedData: Array<DataItem>;    /* Callback function that should be called when click to clear */    onClear: () => void;    /* The function that should be called when a single option is deleted */    onRemove: (item: DataItem) => void;    /* The function that should be called when reordering the results */    onSortEnd: OnSortEnd}export interface ResolvedDataItem extends DataItem {    _parent?: {        title: string    };    _optionKey?: string | number}export interface DraggableResolvedDataItem {    key?: string | number;    index?: number;    item?: ResolvedDataItem}export type DataSource = Array<DataItem> | Array<GroupItem> | Array<TreeItem>;interface HeaderConfig {    totalContent: string;    allContent: string;    onAllClick: () => void;    type: string;    showButton: boolean}export interface TransferState {    data: Array<ResolvedDataItem>;    selectedItems: Map<number | string, ResolvedDataItem>;    searchResult: Set<number | string>;    inputValue: string}export interface TransferProps {    style?: React.CSSProperties;    className?: string;    disabled?: boolean;    dataSource?: DataSource;    filter?: boolean | ((sugInput: string, item: DataItem) => boolean);    defaultValue?: Array<string | number>;    value?: Array<string | number>;    inputProps?: InputProps;    type?: Type;    emptyContent?: EmptyContent;    draggable?: boolean;    treeProps?: Omit<TreeProps, 'value' | 'ref' | 'onChange'>;    showPath?: boolean;    loading?: boolean;    onChange?: (values: Array<string | number>, items: Array<DataItem>) => void;    onSelect?: (item: DataItem) => void;    onDeselect?: (item: DataItem) => void;    onSearch?: (sunInput: string) => void;    renderSourceItem?: (item: RenderSourceItemProps) => React.ReactNode;    renderSelectedItem?: (item: RenderSelectedItemProps) => React.ReactNode;    renderSourcePanel?: (sourcePanelProps: SourcePanelProps) => React.ReactNode;    renderSelectedPanel?: (selectedPanelProps: SelectedPanelProps) => React.ReactNode}const prefixcls = cssClasses.PREFIX;class Transfer extends BaseComponent<TransferProps, TransferState> {    static propTypes = {        style: PropTypes.object,        className: PropTypes.string,        disabled: PropTypes.bool,        dataSource: PropTypes.array,        filter: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),        onSearch: PropTypes.func,        inputProps: PropTypes.object,        value: PropTypes.array,        defaultValue: PropTypes.array,        onChange: PropTypes.func,        onSelect: PropTypes.func,        onDeselect: PropTypes.func,        renderSourceItem: PropTypes.func,        renderSelectedItem: PropTypes.func,        loading: PropTypes.bool,        type: PropTypes.oneOf(['list', 'groupList', 'treeList']),        treeProps: PropTypes.object,        showPath: PropTypes.bool,        emptyContent: PropTypes.shape({            search: PropTypes.node,            left: PropTypes.node,            right: PropTypes.node,        }),        renderSourcePanel: PropTypes.func,        renderSelectedPanel: PropTypes.func,        draggable: PropTypes.bool,    };    static defaultProps = {        type: strings.TYPE_LIST,        dataSource: [] as DataSource,        onSearch: noop,        onChange: noop,        onSelect: noop,        onDeselect: noop,        onClear: noop,        defaultValue: [] as Array<string | number>,        emptyContent: {},        showPath: false,    };    _treeRef: Tree = null;    constructor(props: TransferProps) {        super(props);        const { defaultValue = [], dataSource, type } = props;        this.foundation = new TransferFoundation<TransferProps, TransferState>(this.adapter);        this.state = {            data: [],            selectedItems: new Map(),            searchResult: new Set(),            inputValue: '',        };        if (Boolean(dataSource) && isArray(dataSource)) {            // eslint-disable-next-line @typescript-eslint/ban-ts-comment            // @ts-ignore Avoid reporting errors this.state.xxx is read-only            this.state.data = _generateDataByType(dataSource, type);        }        if (Boolean(defaultValue) && isArray(defaultValue)) {            // eslint-disable-next-line @typescript-eslint/ban-ts-comment            // @ts-ignore Avoid reporting errors this.state.xxx is read-only            this.state.selectedItems = _generateSelectedItems(defaultValue, this.state.data);        }        this.onSelectOrRemove = this.onSelectOrRemove.bind(this);        this.onInputChange = this.onInputChange.bind(this);        this.onSortEnd = this.onSortEnd.bind(this);    }    static getDerivedStateFromProps(props: TransferProps, state: TransferState) {        const { value, dataSource, type, filter } = props;        const mergedState = {} as TransferState;        let newData = state.data;        let newSelectedItems = state.selectedItems;        if (Boolean(dataSource) && Array.isArray(dataSource)) {            newData = _generateDataByType(dataSource, type);            mergedState.data = newData;        }        if (Boolean(value) && Array.isArray(value)) {            newSelectedItems = _generateSelectedItems(value, newData);            mergedState.selectedItems = newSelectedItems;        }        if (!isEqual(state.data, newData)) {            if (typeof state.inputValue === 'string' && state.inputValue !== '') {                const filterFunc = typeof filter === 'function' ?                    (item: DataItem) => filter(state.inputValue, item) :                    (item: DataItem) => typeof item.label === 'string' && item.label.includes(state.inputValue);                const searchData = newData.filter(filterFunc);                const searchResult = new Set(searchData.map(item => item.key));                mergedState.searchResult = searchResult;            }        }        return isEmpty(mergedState) ? null : mergedState;    }    get adapter(): TransferAdapter<TransferProps, TransferState> {        return {            ...super.adapter,            getSelected: () => new Map(this.state.selectedItems),            updateSelected: selectedItems => {                this.setState({ selectedItems });            },            notifyChange: (values, items) => {                this.props.onChange(values, items);            },            notifySearch: input => {                this.props.onSearch(input);            },            notifySelect: item => {                this.props.onSelect(item);            },            notifyDeselect: item => {                this.props.onDeselect(item);            },            updateInput: input => {                this.setState({ inputValue: input });            },            updateSearchResult: searchResult => {                this.setState({ searchResult });            },            searchTree: keyword => {                this._treeRef && (this._treeRef as any).search(keyword); // TODO check this._treeRef.current?            }        };    }    onInputChange(value: string) {        this.foundation.handleInputChange(value);    }    onSelectOrRemove(item: ResolvedDataItem) {        this.foundation.handleSelectOrRemove(item);    }    onSortEnd(callbackProps: OnSortEndProps) {        this.foundation.handleSortEnd(callbackProps);    }    renderFilter(locale: Locale['Transfer']) {        const { inputProps, filter, disabled } = this.props;        if (typeof filter === 'boolean' && !filter) {            return null;        }        return (            <div role="search" aria-label="Transfer filter" className={`${prefixcls}-filter`}>                <Input                    prefix={<IconSearch />}                    placeholder={locale.placeholder}                    showClear                    value={this.state.inputValue}                    disabled={disabled}                    onChange={this.onInputChange}                    {...inputProps}                />            </div>        );    }    renderHeader(headerConfig: HeaderConfig) {        const { disabled } = this.props;        const { totalContent, allContent, onAllClick, type, showButton } = headerConfig;        const headerCls = cls({            [`${prefixcls}-header`]: true,            [`${prefixcls}-right-header`]: type === 'right',            [`${prefixcls}-left-header`]: type === 'left',        });        return (            <div className={headerCls}>                <span className={`${prefixcls}-header-total`}>{totalContent}</span>                {showButton ? (                    <Button                        theme="borderless"                        disabled={disabled}                        type="tertiary"                        size="small"                        className={`${prefixcls}-header-all`}                        onClick={onAllClick}                    >                        {allContent}                    </Button>                ) : null}            </div>        );    }    renderLeftItem(item: ResolvedDataItem, index: number) {        const { renderSourceItem, disabled } = this.props;        const { selectedItems } = this.state;        const checked = selectedItems.has(item.key);        if (renderSourceItem) {            return renderSourceItem({ ...item, checked, onChange: () => this.onSelectOrRemove(item) });        }        const leftItemCls = cls({            [`${prefixcls}-item`]: true,            [`${prefixcls}-item-disabled`]: item.disabled,        });        return (            <Checkbox                key={index}                disabled={item.disabled || disabled}                className={leftItemCls}                checked={checked}                role="listitem"                onChange={() => this.onSelectOrRemove(item)}                x-semi-children-alias={`dataSource[${index}].label`}            >                {item.label}            </Checkbox>        );    }    renderLeft(locale: Locale['Transfer']) {        const { data, selectedItems, inputValue, searchResult } = this.state;        const { loading, type, emptyContent, renderSourcePanel, dataSource } = this.props;        const totalToken = locale.total;        const inSearchMode = inputValue !== '';        const showNumber = inSearchMode ? searchResult.size : data.length;        const filterData = inSearchMode ? data.filter(item => searchResult.has(item.key)) : data;        // Whether to select all should be a judgment, whether the filtered data on the left is a subset of the selected items        // For example, the filtered data on the left is 1, 3, 4;        // The selected option is 1,2,3,4, it is true        // The selected option is 2,3,4, then it is false        const leftContainesNotInSelected = Boolean(filterData.find(f => !selectedItems.has(f.key)));        const totalText = totalToken.replace('${total}', `${showNumber}`);        const headerConfig: HeaderConfig = {            totalContent: totalText,            allContent: leftContainesNotInSelected ? locale.selectAll : locale.clearSelectAll,            onAllClick: () => this.foundation.handleAll(leftContainesNotInSelected),            type: 'left',            showButton: type !== strings.TYPE_TREE_TO_LIST,        };        const inputCom = this.renderFilter(locale);        const headerCom = this.renderHeader(headerConfig);        const noMatch = inSearchMode && searchResult.size === 0;        const emptySearch = emptyContent.search ? emptyContent.search : locale.emptySearch;        const emptyLeft = emptyContent.left ? emptyContent.left : locale.emptyLeft;        const emptyDataCom = this.renderEmpty('left', emptyLeft);        const emptySearchCom = this.renderEmpty('left', emptySearch);        const loadingCom = <Spin />;        let content: React.ReactNode = null;        switch (true) {            case loading:                content = loadingCom;                break;            case noMatch:                content = emptySearchCom;                break;            case data.length === 0:                content = emptyDataCom;                break;            case type === strings.TYPE_TREE_TO_LIST:                content = (                    <>                        {headerCom}                        {this.renderLeftTree()}                    </>                );                break;            case !noMatch && (type === strings.TYPE_LIST || type === strings.TYPE_GROUP_LIST):                content = (                    <>                        {headerCom}                        {this.renderLeftList(filterData)}                    </>                );                break;            default:                content = null;                break;        }        const { values } = this.foundation.getValuesAndItemsFromMap(selectedItems);        const renderProps: SourcePanelProps = {            loading,            noMatch,            filterData,            sourceData: data,            propsDataSource: dataSource,            allChecked: !leftContainesNotInSelected,            showNumber,            inputValue,            selectedItems,            value: values,            onSelect: this.foundation.handleSelect.bind(this.foundation),            onAllClick: () => this.foundation.handleAll(leftContainesNotInSelected),            onSearch: this.onInputChange,            onSelectOrRemove: (item: ResolvedDataItem) => this.onSelectOrRemove(item),        };        if (renderSourcePanel) {            return renderSourcePanel(renderProps);        }        return (            <section className={`${prefixcls}-left`}>                {inputCom}                {content}            </section>        );    }    renderGroupTitle(group: GroupItem, index: number) {        const groupCls = cls(`${prefixcls }-group-title`);        return (            <div className={groupCls} key={`title-${index}`}>                {group.title}            </div>        );    }    renderLeftTree() {        const { selectedItems } = this.state;        const { disabled, dataSource, treeProps } = this.props;        const { values } = this.foundation.getValuesAndItemsFromMap(selectedItems);        const onChange = (value: TreeValue) => {            this.foundation.handleSelect(value);        };        const restTreeProps = omit(treeProps, ['value', 'ref', 'onChange']);        return (            <Tree                disabled={disabled}                treeData={dataSource as any}                multiple                disableStrictly                value={values}                defaultExpandAll                leafOnly                ref={tree => this._treeRef = tree}                filterTreeNode                searchRender={false}                searchStyle={{ padding: 0 }}                style={{ flex: 1, overflow: 'overlay' }}                onChange={onChange}                {...restTreeProps}            />        );    }    renderLeftList(visibileItems: Array<ResolvedDataItem>) {        const content = [] as Array<React.ReactNode>;        const groupStatus = new Map();        visibileItems.forEach((item, index) => {            const parentGroup = item._parent;            const optionContent = this.renderLeftItem(item, index);            if (parentGroup && groupStatus.has(parentGroup.title)) {                // group content already insert                content.push(optionContent);            } else if (parentGroup) {                const groupContent = this.renderGroupTitle(parentGroup, index);                groupStatus.set(parentGroup.title, true);                content.push(groupContent);                content.push(optionContent);            } else {                content.push(optionContent);            }        });        return <div className={`${prefixcls}-left-list`} role="list" aria-label="Option list">{content}</div>;    }    renderRightItem(item: ResolvedDataItem): React.ReactNode {        const { renderSelectedItem, draggable, type, showPath } = this.props;        const onRemove = () => this.foundation.handleSelectOrRemove(item);        const rightItemCls = cls({            [`${prefixcls}-item`]: true,            [`${prefixcls}-right-item`]: true,            [`${prefixcls}-right-item-draggable`]: draggable        });        const shouldShowPath = type === strings.TYPE_TREE_TO_LIST && showPath === true;        const label = shouldShowPath ? this.foundation._generatePath(item) : item.label;        if (renderSelectedItem) {            return renderSelectedItem({ ...item, onRemove, sortableHandle: SortableHandle });        }        const DragHandle = SortableHandle(() => (            <IconHandle role="button" aria-label="Drag and sort" className={`${prefixcls}-right-item-drag-handler`} />        ));        return (            // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex            <div role="listitem" className={rightItemCls} key={item.key}>                {draggable ? <DragHandle /> : null}                <div className={`${prefixcls}-right-item-text`}>{label}</div>                <IconClose                    onClick={onRemove}                    aria-disabled={item.disabled}                    className={cls(`${prefixcls}-item-close-icon`, {                        [`${prefixcls}-item-close-icon-disabled`]: item.disabled                    })}                />            </div>        );    }    renderEmpty(type: string, emptyText: React.ReactNode) {        const emptyCls = cls({            [`${prefixcls}-empty`]: true,            [`${prefixcls}-right-empty`]: type === 'right',            [`${prefixcls}-left-empty`]: type === 'left',        });        return <div aria-label="empty" className={emptyCls}>{emptyText}</div>;    }    renderRightSortableList(selectedData: Array<ResolvedDataItem>) {        // when choose some items && draggable is true        const SortableItem = SortableElement((            (props: DraggableResolvedDataItem) => this.renderRightItem(props.item)) as React.FC<DraggableResolvedDataItem>        );        const SortableList = SortableContainer(({ items }: { items: Array<ResolvedDataItem> }) => (            <div className={`${prefixcls}-right-list`} role="list" aria-label="Selected list">                {items.map((item, index: number) => (                    // @ts-ignore skip SortableItem type check                    <SortableItem key={item.label} index={index} item={item} />                ))}            </div>        // eslint-disable-next-line @typescript-eslint/ban-ts-comment        // @ts-ignore see reasons: https://github.com/clauderic/react-sortable-hoc/issues/206        ), { distance: 10 });        // @ts-ignore skip SortableItem type check        const sortList = <SortableList useDragHandle onSortEnd={this.onSortEnd} items={selectedData} />;        return sortList;    }    renderRight(locale: Locale['Transfer']) {        const { selectedItems } = this.state;        const { emptyContent, renderSelectedPanel, draggable } = this.props;        const selectedData = [...selectedItems.values()];        // when custom render panel        const renderProps: SelectedPanelProps = {            length: selectedData.length,            selectedData,            onClear: () => this.foundation.handleClear(),            onRemove: item => this.foundation.handleSelectOrRemove(item),            onSortEnd: props => this.onSortEnd(props)        };        if (renderSelectedPanel) {            return renderSelectedPanel(renderProps);        }        const selectedToken = locale.selected;        const selectedText = selectedToken.replace('${total}', `${selectedData.length}`);        const headerConfig = {            totalContent: selectedText,            allContent: locale.clear,            onAllClick: () => this.foundation.handleClear(),            type: 'right',            showButton: Boolean(selectedData.length),        };        const headerCom = this.renderHeader(headerConfig);        const emptyCom = this.renderEmpty('right', emptyContent.right ? emptyContent.right : locale.emptyRight);        const panelCls = `${prefixcls}-right`;        let content = null;        switch (true) {            // when empty            case !selectedData.length:                content = emptyCom;                break;            case selectedData.length && !draggable:                const list = (                    <div className={`${prefixcls}-right-list`} role="list" aria-label="Selected list">                        {selectedData.map(item => this.renderRightItem({ ...item }))}                    </div>                );                content = list;                break;            case selectedData.length && draggable:                content = this.renderRightSortableList(selectedData);                break;            default:                break;        }        return (            <section className={panelCls}>                {headerCom}                {content}            </section>        );    }    render() {        const { className, style, disabled, renderSelectedPanel, renderSourcePanel } = this.props;        const transferCls = cls(prefixcls, className, {            [`${prefixcls}-disabled`]: disabled,            [`${prefixcls}-custom-panel`]: renderSelectedPanel && renderSourcePanel,        });        return (            <LocaleConsumer componentName="Transfer">                {(locale: Locale['Transfer']) => (                    <div className={transferCls} style={style}>                        {this.renderLeft(locale)}                        {this.renderRight(locale)}                    </div>                )}            </LocaleConsumer>        );    }}export default Transfer;
 |