diff --git a/components/vc-tree-select2/Context.tsx b/components/vc-tree-select2/Context.tsx new file mode 100644 index 000000000..95746f792 --- /dev/null +++ b/components/vc-tree-select2/Context.tsx @@ -0,0 +1,68 @@ +import type { + FlattenDataNode, + InternalDataEntity, + Key, + LegacyDataNode, + RawValueType, +} from './interface'; +import type { SkipType } from './hooks/useKeyValueMapping'; +import type { ComputedRef, InjectionKey, PropType } from 'vue'; +import { computed, defineComponent, inject, provide } from 'vue'; + +interface ContextProps { + checkable: boolean; + customCheckable: () => any; + checkedKeys: Key[]; + halfCheckedKeys: Key[]; + treeExpandedKeys: Key[]; + treeDefaultExpandedKeys: Key[]; + onTreeExpand: (keys: Key[]) => void; + treeDefaultExpandAll: boolean; + treeIcon: any; + showTreeIcon: boolean; + switcherIcon: any; + treeLine: boolean; + treeNodeFilterProp: string; + treeLoadedKeys: Key[]; + treeMotion: any; + loadData: (treeNode: LegacyDataNode) => Promise; + onTreeLoad: (loadedKeys: Key[]) => void; + + // Cache help content. These can be generated by parent component. + // Let's reuse this. + getEntityByKey: (key: Key, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode; + getEntityByValue: ( + value: RawValueType, + skipType?: SkipType, + ignoreDisabledCheck?: boolean, + ) => FlattenDataNode; + + slots: { + title?: (data: InternalDataEntity) => any; + titleRender?: (data: InternalDataEntity) => any; + [key: string]: ((...args: any[]) => any) | undefined; + }; +} + +const SelectContextKey: InjectionKey> = Symbol('SelectContextKey'); + +export const SelectContext = defineComponent({ + name: 'SelectContext', + props: { + value: { type: Object as PropType }, + }, + setup(props, { slots }) { + provide( + SelectContextKey, + computed(() => props.value), + ); + return () => slots.default?.(); + }, +}); + +export const useInjectTreeSelectContext = () => { + return inject( + SelectContextKey, + computed(() => ({} as ContextProps)), + ); +}; diff --git a/components/vc-tree-select2/OptionList.tsx b/components/vc-tree-select2/OptionList.tsx new file mode 100644 index 000000000..6d3ec67e4 --- /dev/null +++ b/components/vc-tree-select2/OptionList.tsx @@ -0,0 +1,262 @@ +import type { DataNode, TreeDataNode, Key } from './interface'; +import { useInjectTreeSelectContext } from './Context'; +import type { RefOptionListProps } from '../vc-select/OptionList'; +import type { ScrollTo } from '../vc-virtual-list/List'; +import { computed, defineComponent, nextTick, ref, shallowRef, watch } from 'vue'; +import { optionListProps } from './props'; +import useMemo from '../_util/hooks/useMemo'; +import type { EventDataNode } from '../tree'; +import KeyCode from '../_util/KeyCode'; +import Tree from '../vc-tree/Tree'; +import type { TreeProps } from '../vc-tree/props'; + +const HIDDEN_STYLE = { + width: 0, + height: 0, + display: 'flex', + overflow: 'hidden', + opacity: 0, + border: 0, + padding: 0, + margin: 0, +}; + +interface TreeEventInfo { + node: { key: Key }; + selected?: boolean; + checked?: boolean; +} + +type ReviseRefOptionListProps = Omit & { scrollTo: ScrollTo }; + +export default defineComponent({ + name: 'OptionList', + inheritAttrs: false, + props: optionListProps(), + slots: ['notFoundContent', 'menuItemSelectedIcon'], + setup(props, { slots, expose }) { + const context = useInjectTreeSelectContext(); + + const treeRef = ref(); + const memoOptions = useMemo( + () => props.options, + [() => props.open, () => props.options], + next => next[0], + ); + + const valueKeys = computed(() => { + const { checkedKeys, getEntityByValue } = context.value; + return checkedKeys.map(val => { + const entity = getEntityByValue(val); + return entity ? entity.key : null; + }); + }); + + const mergedCheckedKeys = computed(() => { + const { checkable, halfCheckedKeys } = context.value; + if (!checkable) { + return null; + } + + return { + checked: valueKeys.value, + halfChecked: halfCheckedKeys, + }; + }); + + watch( + () => props.open, + () => { + nextTick(() => { + if (props.open && !props.multiple && valueKeys.value.length) { + treeRef.value?.scrollTo({ key: valueKeys.value[0] }); + } + }); + }, + { immediate: true, flush: 'post' }, + ); + + // ========================== Search ========================== + const lowerSearchValue = computed(() => String(props.searchValue).toLowerCase()); + const filterTreeNode = (treeNode: EventDataNode) => { + if (!lowerSearchValue.value) { + return false; + } + return String(treeNode[context.value.treeNodeFilterProp]) + .toLowerCase() + .includes(lowerSearchValue.value); + }; + + // =========================== Keys =========================== + const expandedKeys = shallowRef(context.value.treeDefaultExpandedKeys); + const searchExpandedKeys = shallowRef(null); + + watch( + () => props.searchValue, + () => { + if (props.searchValue) { + searchExpandedKeys.value = props.flattenOptions.map(o => o.key); + } + }, + { + immediate: true, + }, + ); + const mergedExpandedKeys = computed(() => { + if (context.value.treeExpandedKeys) { + return [...context.value.treeExpandedKeys]; + } + return props.searchValue ? searchExpandedKeys.value : expandedKeys.value; + }); + + const onInternalExpand = (keys: Key[]) => { + expandedKeys.value = keys; + searchExpandedKeys.value = keys; + + context.value.onTreeExpand?.(keys); + }; + + // ========================== Events ========================== + const onListMouseDown = (event: MouseEvent) => { + event.preventDefault(); + }; + + const onInternalSelect = (_: Key[], { node: { key } }: TreeEventInfo) => { + const { getEntityByKey, checkable, checkedKeys } = context.value; + const entity = getEntityByKey(key, checkable ? 'checkbox' : 'select'); + if (entity !== null) { + props.onSelect?.(entity.data.value, { + selected: !checkedKeys.includes(entity.data.value), + }); + } + + if (!props.multiple) { + props.onToggleOpen?.(false); + } + }; + + // ========================= Keyboard ========================= + const activeKey = ref(null); + const activeEntity = computed(() => context.value.getEntityByKey(activeKey.value)); + + const setActiveKey = (key: Key) => { + activeKey.value = key; + }; + expose({ + scrollTo: (...args: any[]) => treeRef.value?.scrollTo?.(...args), + onKeydown: (event: KeyboardEvent) => { + const { which } = event; + switch (which) { + // >>> Arrow keys + case KeyCode.UP: + case KeyCode.DOWN: + case KeyCode.LEFT: + case KeyCode.RIGHT: + treeRef.value?.onKeydown(event); + break; + + // >>> Select item + case KeyCode.ENTER: { + const { selectable, value } = activeEntity.value?.data.node || {}; + if (selectable !== false) { + onInternalSelect(null, { + node: { key: activeKey.value }, + selected: !context.value.checkedKeys.includes(value), + }); + } + break; + } + + // >>> Close + case KeyCode.ESC: { + props.onToggleOpen(false); + } + } + }, + onKeyup: () => {}, + } as ReviseRefOptionListProps); + + return () => { + const { + prefixCls, + height, + itemHeight, + virtual, + multiple, + searchValue, + open, + notFoundContent = slots.notFoundContent?.(), + onMouseenter, + } = props; + const { + checkable, + treeDefaultExpandAll, + treeIcon, + showTreeIcon, + switcherIcon, + treeLine, + loadData, + treeLoadedKeys, + treeMotion, + onTreeLoad, + } = context.value; + // ========================== Render ========================== + if (memoOptions.value.length === 0) { + return ( +
+ {notFoundContent} +
+ ); + } + + const treeProps: Partial = {}; + if (treeLoadedKeys) { + treeProps.loadedKeys = treeLoadedKeys; + } + if (mergedExpandedKeys.value) { + treeProps.expandedKeys = mergedExpandedKeys.value; + } + return ( +
+ {activeEntity.value && open && ( + + {activeEntity.value.data.value} + + )} + + +
+ ); + }; + }, +}); diff --git a/components/vc-tree-select2/TreeNode.tsx b/components/vc-tree-select2/TreeNode.tsx new file mode 100644 index 000000000..af6b752e0 --- /dev/null +++ b/components/vc-tree-select2/TreeNode.tsx @@ -0,0 +1,15 @@ +/* istanbul ignore file */ + +import type { FunctionalComponent } from 'vue'; +import type { DataNode, Key } from './interface'; + +export interface TreeNodeProps extends Omit { + value: Key; +} + +/** This is a placeholder, not real render in dom */ +const TreeNode: FunctionalComponent & { isTreeSelectNode: boolean } = () => null; +TreeNode.inheritAttrs = false; +TreeNode.displayName = 'ATreeSelectNode'; +TreeNode.isTreeSelectNode = true; +export default TreeNode; diff --git a/components/vc-tree-select2/TreeSelect.tsx b/components/vc-tree-select2/TreeSelect.tsx new file mode 100644 index 000000000..1f2d1684c --- /dev/null +++ b/components/vc-tree-select2/TreeSelect.tsx @@ -0,0 +1,6 @@ +import generate from './generate'; +import OptionList from './OptionList'; + +const TreeSelect = generate({ prefixCls: 'vc-tree-select', optionList: OptionList as any }); + +export default TreeSelect; diff --git a/components/vc-tree-select2/generate.tsx b/components/vc-tree-select2/generate.tsx new file mode 100644 index 000000000..228dddc0f --- /dev/null +++ b/components/vc-tree-select2/generate.tsx @@ -0,0 +1,514 @@ +import type { GenerateConfig } from '../vc-select/generate'; +import generateSelector from '../vc-select/generate'; +import TreeNode from './TreeNode'; +import type { + DefaultValueType, + DataNode, + LabelValueType, + RawValueType, + ChangeEventExtra, + SelectSource, + FlattenDataNode, +} from './interface'; +import { + flattenOptions, + filterOptions, + isValueDisabled, + findValueOption, + addValue, + removeValue, + getRawValueLabeled, + toArray, + fillFieldNames, +} from './utils/valueUtil'; +import warningProps from './utils/warningPropsUtil'; +import { SelectContext } from './Context'; +import useTreeData from './hooks/useTreeData'; +import useKeyValueMap from './hooks/useKeyValueMap'; +import useKeyValueMapping from './hooks/useKeyValueMapping'; +import { formatStrategyKeys, SHOW_ALL, SHOW_PARENT, SHOW_CHILD } from './utils/strategyUtil'; +import { fillAdditionalInfo } from './utils/legacyUtil'; +import useSelectValues from './hooks/useSelectValues'; +import type { TreeSelectProps } from './props'; +import { treeSelectProps } from './props'; +import { getLabeledValue } from '../vc-select/utils/valueUtil'; +import omit from '../_util/omit'; +import { computed, defineComponent, ref, shallowRef, toRef, watch, watchEffect } from 'vue'; +import { convertDataToEntities } from '../vc-tree/utils/treeUtil'; +import { conductCheck } from '../vc-tree/utils/conductUtil'; +import { warning } from '../vc-util/warning'; +import { INTERNAL_PROPS_MARK } from '../vc-select/interface/generator'; + +const OMIT_PROPS: (keyof TreeSelectProps)[] = [ + 'expandedKeys' as any, + 'treeData', + 'treeCheckable', + 'showCheckedStrategy', + 'searchPlaceholder', + 'treeLine', + 'treeIcon', + 'showTreeIcon', + 'switcherIcon', + 'treeNodeFilterProp', + 'filterTreeNode', + 'dropdownPopupAlign', + 'treeDefaultExpandAll', + 'treeCheckStrictly', + 'treeExpandedKeys', + 'treeLoadedKeys', + 'treeMotion', + 'onTreeExpand', + 'onTreeLoad', + 'labelRender', + 'loadData', + 'treeDataSimpleMode', + 'treeNodeLabelProp', + 'treeDefaultExpandedKeys', + 'bordered', +]; + +export default function generate(config: { + prefixCls: string; + optionList: GenerateConfig['components']['optionList']; +}) { + const { prefixCls, optionList } = config; + + const RefSelect = generateSelector({ + prefixCls, + components: { + optionList, + }, + // Not use generate since we will handle ourself + convertChildrenToData: () => null, + flattenOptions, + // Handle `optionLabelProp` in TreeSelect component + getLabeledValue: getLabeledValue as any, + filterOptions, + isValueDisabled, + findValueOption, + omitDOMProps: (props: TreeSelectProps) => omit(props, OMIT_PROPS), + }); + + return defineComponent({ + name: 'TreeSelect', + props: treeSelectProps(), + slots: [ + 'title', + 'placeholder', + 'maxTagPlaceholder', + 'treeIcon', + 'switcherIcon', + 'notFoundContent', + 'treeCheckable', + ], + TreeNode, + SHOW_ALL, + SHOW_PARENT, + SHOW_CHILD, + setup(props, { expose, slots, attrs }) { + const mergedCheckable = computed(() => props.treeCheckable || props.treeCheckStrictly); + const mergedMultiple = computed(() => props.multiple || mergedCheckable.value); + const treeConduction = computed(() => props.treeCheckable && !props.treeCheckStrictly); + const mergedLabelInValue = computed(() => props.treeCheckStrictly || props.labelInValue); + + // ======================= Tree Data ======================= + // FieldNames + const mergedFieldNames = computed(() => fillFieldNames(props.fieldNames, true)); + // Legacy both support `label` or `title` if not set. + // We have to fallback to function to handle this + const getTreeNodeTitle = (node: DataNode) => { + if (!props.treeData) { + return node.title; + } + + if (mergedFieldNames.value?.label) { + return node[mergedFieldNames.value.label]; + } + + return node.label || node.title; + }; + + const getTreeNodeLabelProp = (entity: FlattenDataNode) => { + const { labelRender, treeNodeLabelProp } = props; + const { node } = entity.data; + + if (labelRender) { + return labelRender(entity); + } + + if (treeNodeLabelProp) { + return node[treeNodeLabelProp]; + } + + return getTreeNodeTitle(node); + }; + + const mergedTreeData = useTreeData(toRef(props, 'treeData'), toRef(props, 'children'), { + getLabelProp: getTreeNodeTitle, + simpleMode: toRef(props, 'treeDataSimpleMode'), + fieldNames: mergedFieldNames, + }); + + const flattedOptions = computed(() => flattenOptions(mergedTreeData.value)); + const [cacheKeyMap, cacheValueMap] = useKeyValueMap(flattedOptions); + const [getEntityByKey, getEntityByValue] = useKeyValueMapping(cacheKeyMap, cacheValueMap); + + // Only generate keyEntities for check conduction when is `treeCheckable` + const conductKeyEntities = computed(() => { + if (treeConduction.value) { + return convertDataToEntities(mergedTreeData.value).keyEntities; + } + return null; + }); + + // ========================== Ref ========================== + const selectRef = ref(); + + expose({ + scrollTo: (...args: any[]) => selectRef.value.scrollTo?.(...args), + focus: () => selectRef.value.focus?.(), + blur: () => selectRef.value?.blur(), + + /** @private Internal usage. It's save to remove if `rc-cascader` not use it any longer */ + getEntityByValue, + }); + + const valueRef = ref( + props.value === undefined ? props.defaultValue : props.value, + ); + + watch( + () => props.value, + () => { + valueRef.value = props.value; + }, + ); + + /** Get `missingRawValues` which not exist in the tree yet */ + const splitRawValues = (newRawValues: RawValueType[]) => { + const missingRawValues = []; + const existRawValues = []; + + // Keep missing value in the cache + newRawValues.forEach(val => { + if (getEntityByValue(val)) { + existRawValues.push(val); + } else { + missingRawValues.push(val); + } + }); + + return { missingRawValues, existRawValues }; + }; + + const rawValues = shallowRef([]); + const rawHalfCheckedKeys = shallowRef([]); + + watchEffect(() => { + const valueHalfCheckedKeys: RawValueType[] = []; + const newRawValues: RawValueType[] = []; + + toArray(valueRef.value).forEach(item => { + if (item && typeof item === 'object' && 'value' in item) { + if (item.halfChecked && props.treeCheckStrictly) { + const entity = getEntityByValue(item.value); + valueHalfCheckedKeys.push(entity ? entity.key : item.value); + } else { + newRawValues.push(item.value); + } + } else { + newRawValues.push(item as RawValueType); + } + }); + + // We need do conduction of values + if (treeConduction.value) { + const { missingRawValues, existRawValues } = splitRawValues(newRawValues); + const keyList = existRawValues.map(val => getEntityByValue(val).key); + + const { checkedKeys, halfCheckedKeys } = conductCheck( + keyList, + true, + conductKeyEntities.value, + ); + rawValues.value = [ + ...missingRawValues, + ...checkedKeys.map(key => getEntityByKey(key).data.value), + ]; + rawHalfCheckedKeys.value = halfCheckedKeys; + } else { + [rawValues.value, rawHalfCheckedKeys.value] = [newRawValues, valueHalfCheckedKeys]; + } + }); + + const selectValues = useSelectValues(rawValues, { + treeConduction, + value: valueRef, + showCheckedStrategy: toRef(props, 'showCheckedStrategy'), + conductKeyEntities, + getEntityByValue, + getEntityByKey, + getLabelProp: getTreeNodeLabelProp, + }); + + const triggerChange = ( + newRawValues: RawValueType[], + extra: { triggerValue: RawValueType; selected: boolean }, + source: SelectSource, + ) => { + const { onChange, showCheckedStrategy, treeCheckStrictly } = props; + const preValue = valueRef.value; + valueRef.value = mergedMultiple.value ? newRawValues : newRawValues[0]; + if (onChange) { + let eventValues: RawValueType[] = newRawValues; + if (treeConduction.value && showCheckedStrategy !== 'SHOW_ALL') { + const keyList = newRawValues.map(val => { + const entity = getEntityByValue(val); + return entity ? entity.key : val; + }); + const formattedKeyList = formatStrategyKeys( + keyList, + showCheckedStrategy, + conductKeyEntities.value, + ); + + eventValues = formattedKeyList.map(key => { + const entity = getEntityByKey(key); + return entity ? entity.data.value : key; + }); + } + + const { triggerValue, selected } = extra || { + triggerValue: undefined, + selected: undefined, + }; + + let returnValues = mergedLabelInValue.value + ? getRawValueLabeled(eventValues, preValue, getEntityByValue, getTreeNodeLabelProp) + : eventValues; + + // We need fill half check back + if (treeCheckStrictly) { + const halfValues = rawHalfCheckedKeys.value + .map(key => { + const entity = getEntityByKey(key); + return entity ? entity.data.value : key; + }) + .filter(val => !eventValues.includes(val)); + + returnValues = [ + ...(returnValues as LabelValueType[]), + ...getRawValueLabeled(halfValues, preValue, getEntityByValue, getTreeNodeLabelProp), + ]; + } + + const additionalInfo = { + // [Legacy] Always return as array contains label & value + preValue: selectValues.value, + triggerValue, + } as ChangeEventExtra; + + // [Legacy] Fill legacy data if user query. + // This is expansive that we only fill when user query + // https://github.com/react-component/tree-select/blob/fe33eb7c27830c9ac70cd1fdb1ebbe7bc679c16a/src/Select.jsx + let showPosition = true; + if (treeCheckStrictly || (source === 'selection' && !selected)) { + showPosition = false; + } + + fillAdditionalInfo( + additionalInfo, + triggerValue, + newRawValues, + mergedTreeData.value, + showPosition, + ); + + if (mergedCheckable.value) { + additionalInfo.checked = selected; + } else { + additionalInfo.selected = selected; + } + + onChange( + mergedMultiple.value ? returnValues : returnValues[0], + mergedLabelInValue.value + ? null + : eventValues.map(val => { + const entity = getEntityByValue(val); + return entity ? entity.data.title : null; + }), + additionalInfo, + ); + } + }; + + const onInternalSelect = ( + selectValue: RawValueType, + option: DataNode, + source: SelectSource, + ) => { + const eventValue = mergedLabelInValue.value ? selectValue : selectValue; + + if (!mergedMultiple.value) { + // Single mode always set value + triggerChange([selectValue], { selected: true, triggerValue: selectValue }, source); + } else { + let newRawValues = addValue(rawValues.value, selectValue); + + // Add keys if tree conduction + if (treeConduction.value) { + // Should keep missing values + const { missingRawValues, existRawValues } = splitRawValues(newRawValues); + const keyList = existRawValues.map(val => getEntityByValue(val).key); + const { checkedKeys } = conductCheck(keyList, true, conductKeyEntities.value); + newRawValues = [ + ...missingRawValues, + ...checkedKeys.map(key => getEntityByKey(key).data.value), + ]; + } + + triggerChange(newRawValues, { selected: true, triggerValue: selectValue }, source); + } + + props.onSelect?.(eventValue, option); + }; + + const onInternalDeselect = ( + selectValue: RawValueType, + option: DataNode, + source: SelectSource, + ) => { + const eventValue = selectValue; + + let newRawValues = removeValue(rawValues.value, selectValue); + + // Remove keys if tree conduction + if (treeConduction.value) { + const { missingRawValues, existRawValues } = splitRawValues(newRawValues); + const keyList = existRawValues.map(val => getEntityByValue(val).key); + const { checkedKeys } = conductCheck( + keyList, + { checked: false, halfCheckedKeys: rawHalfCheckedKeys.value }, + conductKeyEntities.value, + ); + newRawValues = [ + ...missingRawValues, + ...checkedKeys.map(key => getEntityByKey(key).data.value), + ]; + } + + triggerChange(newRawValues, { selected: false, triggerValue: selectValue }, source); + + props.onDeselect?.(eventValue, option); + }; + + const onInternalClear = () => { + triggerChange([], null, 'clear'); + }; + + // ========================= Open ========================== + const onInternalDropdownVisibleChange = (open: boolean) => { + if (props.onDropdownVisibleChange) { + const legacyParam = {}; + + Object.defineProperty(legacyParam, 'documentClickClose', { + get() { + warning(false, 'Second param of `onDropdownVisibleChange` has been removed.'); + return false; + }, + }); + + (props.onDropdownVisibleChange as any)(open, legacyParam); + } + }; + + // ======================== Warning ======================== + if (process.env.NODE_ENV !== 'production') { + warningProps(props); + } + + return () => { + const { + treeNodeFilterProp, + dropdownPopupAlign, + filterTreeNode, + treeDefaultExpandAll, + treeExpandedKeys, + treeDefaultExpandedKeys, + onTreeExpand, + treeIcon, + treeMotion, + showTreeIcon, + switcherIcon, + treeLine, + loadData, + treeLoadedKeys, + onTreeLoad, + } = props; + // ======================== Render ========================= + // We pass some props into select props style + const selectProps = { + optionLabelProp: null, + optionFilterProp: treeNodeFilterProp, + dropdownAlign: dropdownPopupAlign, + internalProps: { + mark: INTERNAL_PROPS_MARK, + onClear: onInternalClear, + skipTriggerChange: true, + skipTriggerSelect: true, + onRawSelect: onInternalSelect, + onRawDeselect: onInternalDeselect, + }, + filterOption: filterTreeNode, + }; + + if (props.filterTreeNode === undefined) { + delete selectProps.filterOption; + } + const selectContext = { + checkable: mergedCheckable.value, + loadData, + treeLoadedKeys, + onTreeLoad, + checkedKeys: rawValues.value, + halfCheckedKeys: rawHalfCheckedKeys.value, + treeDefaultExpandAll, + treeExpandedKeys, + treeDefaultExpandedKeys, + onTreeExpand, + treeIcon, + treeMotion, + showTreeIcon, + switcherIcon, + treeLine, + treeNodeFilterProp, + getEntityByKey, + getEntityByValue, + customCheckable: slots.treeCheckable, + slots, + }; + return ( + + + + ); + }; + }, + }); +} diff --git a/components/vc-tree-select2/hooks/useCache.ts b/components/vc-tree-select2/hooks/useCache.ts new file mode 100644 index 000000000..fa2814533 --- /dev/null +++ b/components/vc-tree-select2/hooks/useCache.ts @@ -0,0 +1,36 @@ +import type { Ref } from 'vue'; +import { computed, shallowRef } from 'vue'; +import type { LabeledValueType, RawValueType } from '../TreeSelect'; + +/** + * This function will try to call requestIdleCallback if available to save performance. + * No need `getLabel` here since already fetch on `rawLabeledValue`. + */ +export default (values: Ref): [Ref] => { + const cacheRef = shallowRef({ + valueLabels: new Map(), + }); + + const newFilledValues = computed(() => { + const { valueLabels } = cacheRef.value; + const valueLabelsCache = new Map(); + + const filledValues = values.value.map(item => { + const { value } = item; + const mergedLabel = item.label ?? valueLabels.get(value); + + // Save in cache + valueLabelsCache.set(value, mergedLabel); + + return { + ...item, + label: mergedLabel, + }; + }); + + cacheRef.value.valueLabels = valueLabelsCache; + + return filledValues; + }); + return [newFilledValues]; +}; diff --git a/components/vc-tree-select2/hooks/useCheckedKeys.ts b/components/vc-tree-select2/hooks/useCheckedKeys.ts new file mode 100644 index 000000000..1731c42c3 --- /dev/null +++ b/components/vc-tree-select2/hooks/useCheckedKeys.ts @@ -0,0 +1,30 @@ +import type { Key } from '../../_util/type'; +import type { DataEntity } from '../../vc-tree/interface'; +import { conductCheck } from '../../vc-tree/utils/conductUtil'; +import type { LabeledValueType, RawValueType } from '../TreeSelect'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; + +export default ( + rawLabeledValues: Ref, + rawHalfCheckedValues: Ref, + treeConduction: Ref, + keyEntities: Ref>, +) => + computed(() => { + let checkedKeys: RawValueType[] = rawLabeledValues.value.map(({ value }) => value); + let halfCheckedKeys: RawValueType[] = rawHalfCheckedValues.value.map(({ value }) => value); + + const missingValues = checkedKeys.filter(key => !keyEntities[key]); + + if (treeConduction.value) { + ({ checkedKeys, halfCheckedKeys } = conductCheck(checkedKeys, true, keyEntities.value)); + } + + return [ + // Checked keys should fill with missing keys which should de-duplicated + Array.from(new Set([...missingValues, ...checkedKeys])), + // Half checked keys + halfCheckedKeys, + ]; + }); diff --git a/components/vc-tree-select2/hooks/useDataEntities.ts b/components/vc-tree-select2/hooks/useDataEntities.ts new file mode 100644 index 000000000..9c7152ae9 --- /dev/null +++ b/components/vc-tree-select2/hooks/useDataEntities.ts @@ -0,0 +1,40 @@ +import { convertDataToEntities } from '../../vc-tree/utils/treeUtil'; +import type { DataEntity } from '../../vc-tree/interface'; +import type { FieldNames, RawValueType } from '../TreeSelect'; + +import { isNil } from '../utils/valueUtil'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import { warning } from '../../vc-util/warning'; + +export default (treeData: Ref, fieldNames: Ref) => + computed<{ + valueEntities: Map; + keyEntities: Record; + }>(() => { + const collection = convertDataToEntities(treeData.value, { + fieldNames: fieldNames.value, + initWrapper: wrapper => ({ + ...wrapper, + valueEntities: new Map(), + }), + processEntity: (entity, wrapper: any) => { + const val = entity.node[fieldNames.value.value]; + + // Check if exist same value + if (process.env.NODE_ENV !== 'production') { + const key = entity.node.key; + + warning(!isNil(val), 'TreeNode `value` is invalidate: undefined'); + warning(!wrapper.valueEntities.has(val), `Same \`value\` exist in the tree: ${val}`); + warning( + !key || String(key) === String(val), + `\`key\` or \`value\` with TreeNode must be the same or you can remove one of them. key: ${key}, value: ${val}.`, + ); + } + wrapper.valueEntities.set(val, entity); + }, + }); + + return collection as any; + }); diff --git a/components/vc-tree-select2/hooks/useFilterTreeData.ts b/components/vc-tree-select2/hooks/useFilterTreeData.ts new file mode 100644 index 000000000..5f19b10aa --- /dev/null +++ b/components/vc-tree-select2/hooks/useFilterTreeData.ts @@ -0,0 +1,61 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { DefaultOptionType, InternalFieldName, TreeSelectProps } from '../TreeSelect'; +import { fillLegacyProps } from '../utils/legacyUtil'; + +type GetFuncType = T extends boolean ? never : T; +type FilterFn = GetFuncType; + +export default ( + treeData: Ref, + searchValue: Ref, + { + treeNodeFilterProp, + filterTreeNode, + fieldNames, + }: { + fieldNames: Ref; + treeNodeFilterProp: Ref; + filterTreeNode: Ref; + }, +) => { + return computed(() => { + const { children: fieldChildren } = fieldNames.value; + if (!searchValue.value || filterTreeNode.value === false) { + return treeData.value; + } + + let filterOptionFunc: FilterFn; + if (typeof filterTreeNode.value === 'function') { + filterOptionFunc = filterTreeNode.value; + } else { + const upperStr = searchValue.value.toUpperCase(); + filterOptionFunc = (_, dataNode) => { + const value = dataNode[treeNodeFilterProp.value]; + + return String(value).toUpperCase().includes(upperStr); + }; + } + + function dig(list: DefaultOptionType[], keepAll = false) { + return list + .map(dataNode => { + const children = dataNode[fieldChildren]; + + const match = keepAll || filterOptionFunc(searchValue.value, fillLegacyProps(dataNode)); + const childList = dig(children || [], match); + + if (match || childList.length) { + return { + ...dataNode, + [fieldChildren]: childList, + }; + } + return null; + }) + .filter(node => node); + } + + return dig(treeData.value); + }); +}; diff --git a/components/vc-tree-select2/hooks/useKeyValueMap.ts b/components/vc-tree-select2/hooks/useKeyValueMap.ts new file mode 100644 index 000000000..c0ee006ae --- /dev/null +++ b/components/vc-tree-select2/hooks/useKeyValueMap.ts @@ -0,0 +1,25 @@ +import type { ComputedRef, Ref } from 'vue'; +import { shallowRef, watchEffect } from 'vue'; +import type { FlattenDataNode, Key, RawValueType } from '../interface'; + +/** + * Return cached Key Value map with DataNode. + * Only re-calculate when `flattenOptions` changed. + */ +export default function useKeyValueMap(flattenOptions: ComputedRef) { + const cacheKeyMap: Ref> = shallowRef(new Map()); + const cacheValueMap: Ref> = shallowRef(new Map()); + + watchEffect(() => { + const newCacheKeyMap = new Map(); + const newCacheValueMap = new Map(); + // Cache options by key + flattenOptions.value.forEach((dataNode: FlattenDataNode) => { + newCacheKeyMap.set(dataNode.key, dataNode); + newCacheValueMap.set(dataNode.data.value, dataNode); + }); + cacheKeyMap.value = newCacheKeyMap; + cacheValueMap.value = newCacheValueMap; + }); + return [cacheKeyMap, cacheValueMap]; +} diff --git a/components/vc-tree-select2/hooks/useKeyValueMapping.ts b/components/vc-tree-select2/hooks/useKeyValueMapping.ts new file mode 100644 index 000000000..e385a035a --- /dev/null +++ b/components/vc-tree-select2/hooks/useKeyValueMapping.ts @@ -0,0 +1,58 @@ +import type { Ref } from 'vue'; +import type { FlattenDataNode, Key, RawValueType } from '../interface'; + +export type SkipType = null | 'select' | 'checkbox'; + +export function isDisabled(dataNode: FlattenDataNode, skipType: SkipType): boolean { + if (!dataNode) { + return true; + } + + const { disabled, disableCheckbox } = dataNode.data.node; + + switch (skipType) { + case 'checkbox': + return disabled || disableCheckbox; + + default: + return disabled; + } +} + +export default function useKeyValueMapping( + cacheKeyMap: Ref>, + cacheValueMap: Ref>, +): [ + (key: Key, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode, + (value: RawValueType, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode, +] { + const getEntityByKey = ( + key: Key, + skipType: SkipType = 'select', + ignoreDisabledCheck?: boolean, + ) => { + const dataNode = cacheKeyMap.value.get(key); + + if (!ignoreDisabledCheck && isDisabled(dataNode, skipType)) { + return null; + } + + return dataNode; + }; + + const getEntityByValue = ( + value: RawValueType, + skipType: SkipType = 'select', + ignoreDisabledCheck?: boolean, + ) => { + const dataNode = cacheValueMap.value.get(value); + + if (!ignoreDisabledCheck && isDisabled(dataNode, skipType)) { + return null; + } + + return dataNode; + }; + + return [getEntityByKey, getEntityByValue]; +} diff --git a/components/vc-tree-select2/hooks/useSelectValues.ts b/components/vc-tree-select2/hooks/useSelectValues.ts new file mode 100644 index 000000000..a8cc663fc --- /dev/null +++ b/components/vc-tree-select2/hooks/useSelectValues.ts @@ -0,0 +1,67 @@ +import type { RawValueType, FlattenDataNode, Key, LabelValueType } from '../interface'; +import type { SkipType } from './useKeyValueMapping'; +import { getRawValueLabeled } from '../utils/valueUtil'; +import type { CheckedStrategy } from '../utils/strategyUtil'; +import { formatStrategyKeys } from '../utils/strategyUtil'; +import type { DefaultValueType } from '../../vc-select/interface/generator'; +import type { DataEntity } from '../../vc-tree/interface'; +import type { Ref } from 'vue'; +import { shallowRef, watchEffect } from 'vue'; + +interface Config { + treeConduction: Ref; + /** Current `value` of TreeSelect */ + value: Ref; + showCheckedStrategy: Ref; + conductKeyEntities: Ref>; + getEntityByKey: (key: Key, skipType?: SkipType, ignoreDisabledCheck?: boolean) => FlattenDataNode; + getEntityByValue: ( + value: RawValueType, + skipType?: SkipType, + ignoreDisabledCheck?: boolean, + ) => FlattenDataNode; + getLabelProp: (entity: FlattenDataNode) => any; +} + +/** Return */ +export default function useSelectValues( + rawValues: Ref, + { + value, + getEntityByValue, + getEntityByKey, + treeConduction, + showCheckedStrategy, + conductKeyEntities, + getLabelProp, + }: Config, +): Ref { + const rawValueLabeled = shallowRef([]); + watchEffect(() => { + let mergedRawValues = rawValues.value; + + if (treeConduction.value) { + const rawKeys = formatStrategyKeys( + rawValues.value.map(val => { + const entity = getEntityByValue(val); + return entity ? entity.key : val; + }), + showCheckedStrategy.value, + conductKeyEntities.value, + ); + + mergedRawValues = rawKeys.map(key => { + const entity = getEntityByKey(key); + return entity ? entity.data.value : key; + }); + } + + rawValueLabeled.value = getRawValueLabeled( + mergedRawValues, + value.value, + getEntityByValue, + getLabelProp, + ); + }); + return rawValueLabeled; +} diff --git a/components/vc-tree-select2/hooks/useTreeData.ts b/components/vc-tree-select2/hooks/useTreeData.ts new file mode 100644 index 000000000..6f9eee41e --- /dev/null +++ b/components/vc-tree-select2/hooks/useTreeData.ts @@ -0,0 +1,67 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { DataNode, SimpleModeConfig } from '../interface'; +import { convertChildrenToData } from '../utils/legacyUtil'; +import type { DefaultOptionType } from '../TreeSelect'; +import type { VueNode } from 'ant-design-vue/es/_util/type'; + +function parseSimpleTreeData( + treeData: DataNode[], + { id, pId, rootPId }: SimpleModeConfig, +): DataNode[] { + const keyNodes = {}; + const rootNodeList = []; + + // Fill in the map + const nodeList = treeData.map(node => { + const clone = { ...node }; + const key = clone[id]; + keyNodes[key] = clone; + clone.key = clone.key || key; + return clone; + }); + + // Connect tree + nodeList.forEach(node => { + const parentKey = node[pId]; + const parent = keyNodes[parentKey]; + + // Fill parent + if (parent) { + parent.children = parent.children || []; + parent.children.push(node); + } + + // Fill root tree node + if (parentKey === rootPId || (!parent && rootPId === null)) { + rootNodeList.push(node); + } + }); + + return rootNodeList; +} + +/** + * Convert `treeData` or `children` into formatted `treeData`. + * Will not re-calculate if `treeData` or `children` not change. + */ +export default function useTreeData( + treeData: Ref, + children: Ref, + simpleMode: Ref, +): Ref { + return computed(() => { + if (treeData.value) { + return simpleMode.value + ? parseSimpleTreeData(treeData.value, { + id: 'id', + pId: 'pId', + rootPId: null, + ...(simpleMode.value !== true ? simpleMode.value : {}), + }) + : treeData.value; + } + + return convertChildrenToData(children.value); + }); +} diff --git a/components/vc-tree-select2/index.tsx b/components/vc-tree-select2/index.tsx new file mode 100644 index 000000000..d3e52682a --- /dev/null +++ b/components/vc-tree-select2/index.tsx @@ -0,0 +1,11 @@ +// base rc-tree-select@4.6.1 +import TreeSelect from './TreeSelect'; +import TreeNode from './TreeNode'; +import { SHOW_ALL, SHOW_CHILD, SHOW_PARENT } from './utils/strategyUtil'; +import type { TreeSelectProps } from './props'; +import { treeSelectProps } from './props'; + +export { TreeNode, SHOW_ALL, SHOW_CHILD, SHOW_PARENT, treeSelectProps }; +export type { TreeSelectProps }; + +export default TreeSelect; diff --git a/components/vc-tree-select2/interface.ts b/components/vc-tree-select2/interface.ts new file mode 100644 index 000000000..c39d58239 --- /dev/null +++ b/components/vc-tree-select2/interface.ts @@ -0,0 +1,101 @@ +export type SelectSource = 'option' | 'selection' | 'input' | 'clear'; + +export type Key = string | number; + +export type RawValueType = string | number; + +export interface LabelValueType { + key?: Key; + value?: RawValueType; + label?: any; + /** Only works on `treeCheckStrictly` */ + halfChecked?: boolean; +} + +export type DefaultValueType = RawValueType | RawValueType[] | LabelValueType | LabelValueType[]; + +export interface DataNode { + value?: RawValueType; + title?: any; + label?: any; + key?: Key; + disabled?: boolean; + disableCheckbox?: boolean; + checkable?: boolean; + selectable?: boolean; + children?: DataNode[]; + + /** Customize data info */ + [prop: string]: any; +} + +export interface InternalDataEntity { + key: Key; + value: RawValueType; + title?: any; + checkable: boolean; + disableCheckbox: boolean; + disabled: boolean; + selectable: boolean; + isLeaf: boolean; + children?: InternalDataEntity[]; + + /** Origin DataNode */ + node: DataNode; + + dataRef: DataNode; + + slots?: Record; // 兼容 V2 +} + +export interface LegacyDataNode extends DataNode { + props: any; +} + +export interface TreeDataNode extends DataNode { + key: Key; + children?: TreeDataNode[]; +} + +export interface FlattenDataNode { + data: InternalDataEntity; + key: Key; + value: RawValueType; + level: number; + parent?: FlattenDataNode; +} + +export interface SimpleModeConfig { + id?: Key; + pId?: Key; + rootPId?: Key; +} + +/** @deprecated This is only used for legacy compatible. Not works on new code. */ +export interface LegacyCheckedNode { + pos: string; + node: any; + children?: LegacyCheckedNode[]; +} + +export interface ChangeEventExtra { + /** @deprecated Please save prev value by control logic instead */ + preValue: LabelValueType[]; + triggerValue: RawValueType; + /** @deprecated Use `onSelect` or `onDeselect` instead. */ + selected?: boolean; + /** @deprecated Use `onSelect` or `onDeselect` instead. */ + checked?: boolean; + + // Not sure if exist user still use this. We have to keep but not recommend user to use + /** @deprecated This prop not work as react node anymore. */ + triggerNode: any; + /** @deprecated This prop not work as react node anymore. */ + allCheckedNodes: LegacyCheckedNode[]; +} + +export interface FieldNames { + value?: string; + label?: string; + children?: string; +} diff --git a/components/vc-tree-select2/props.ts b/components/vc-tree-select2/props.ts new file mode 100644 index 000000000..98b40c432 --- /dev/null +++ b/components/vc-tree-select2/props.ts @@ -0,0 +1,140 @@ +import type { ExtractPropTypes, PropType } from 'vue'; +import type { + DataNode, + ChangeEventExtra, + DefaultValueType, + FieldNames, + FlattenDataNode, + LabelValueType, + LegacyDataNode, + RawValueType, + SimpleModeConfig, +} from './interface'; +import { selectBaseProps } from '../vc-select'; +import type { FilterFunc } from '../vc-select/interface/generator'; +import omit from '../_util/omit'; +import type { Key } from '../_util/type'; +import PropTypes from '../_util/vue-types'; +import type { CheckedStrategy } from './utils/strategyUtil'; + +export function optionListProps() { + return { + prefixCls: String, + id: String, + options: { type: Array as PropType }, + flattenOptions: { type: Array as PropType }, + height: Number, + itemHeight: Number, + virtual: { type: Boolean, default: undefined }, + values: { type: Set as PropType> }, + multiple: { type: Boolean, default: undefined }, + open: { type: Boolean, default: undefined }, + defaultActiveFirstOption: { type: Boolean, default: undefined }, + notFoundContent: PropTypes.any, + menuItemSelectedIcon: PropTypes.any, + childrenAsData: { type: Boolean, default: undefined }, + searchValue: String, + + onSelect: { + type: Function as PropType<(value: RawValueType, option: { selected: boolean }) => void>, + }, + onToggleOpen: { type: Function as PropType<(open?: boolean) => void> }, + /** Tell Select that some value is now active to make accessibility work */ + onActiveValue: { type: Function as PropType<(value: RawValueType, index: number) => void> }, + onScroll: { type: Function as PropType<(e: UIEvent) => void> }, + + onMouseenter: { type: Function as PropType<() => void> }, + }; +} + +export function treeSelectProps() { + const selectProps = omit(selectBaseProps(), [ + 'onChange', + 'mode', + 'menuItemSelectedIcon', + 'dropdownAlign', + 'backfill', + 'getInputElement', + 'optionLabelProp', + 'tokenSeparators', + 'filterOption', + ]); + return { + ...selectProps, + + multiple: { type: Boolean, default: undefined }, + showArrow: { type: Boolean, default: undefined }, + showSearch: { type: Boolean, default: undefined }, + open: { type: Boolean, default: undefined }, + defaultOpen: { type: Boolean, default: undefined }, + value: { type: [String, Number, Object, Array] as PropType }, + defaultValue: { type: [String, Number, Object, Array] as PropType }, + disabled: { type: Boolean, default: undefined }, + + placeholder: PropTypes.any, + /** @deprecated Use `searchValue` instead */ + inputValue: String, + searchValue: String, + autoClearSearchValue: { type: Boolean, default: undefined }, + + maxTagPlaceholder: { type: Function as PropType<(omittedValues: LabelValueType[]) => any> }, + + fieldNames: { type: Object as PropType }, + loadData: { type: Function as PropType<(dataNode: LegacyDataNode) => Promise> }, + treeNodeFilterProp: String, + treeNodeLabelProp: String, + treeDataSimpleMode: { + type: [Boolean, Object] as PropType, + default: undefined, + }, + treeExpandedKeys: { type: Array as PropType }, + treeDefaultExpandedKeys: { type: Array as PropType }, + treeLoadedKeys: { type: Array as PropType }, + treeCheckable: { type: Boolean, default: undefined }, + treeCheckStrictly: { type: Boolean, default: undefined }, + showCheckedStrategy: { type: String as PropType }, + treeDefaultExpandAll: { type: Boolean, default: undefined }, + treeData: { type: Array as PropType }, + treeLine: { type: Boolean, default: undefined }, + treeIcon: PropTypes.any, + showTreeIcon: { type: Boolean, default: undefined }, + switcherIcon: PropTypes.any, + treeMotion: PropTypes.any, + children: Array, + + filterTreeNode: { + type: [Boolean, Function] as PropType>, + default: undefined, + }, + dropdownPopupAlign: PropTypes.any, + + // Event + onSearch: { type: Function as PropType<(value: string) => void> }, + onChange: { + type: Function as PropType< + (value: ValueType, labelList: any[], extra: ChangeEventExtra) => void + >, + }, + onTreeExpand: { type: Function as PropType<(expandedKeys: Key[]) => void> }, + onTreeLoad: { type: Function as PropType<(loadedKeys: Key[]) => void> }, + onDropdownVisibleChange: { type: Function as PropType<(open: boolean) => void> }, + + // Legacy + /** `searchPlaceholder` has been removed since search box has been merged into input box */ + searchPlaceholder: PropTypes.any, + + /** @private This is not standard API since we only used in `rc-cascader`. Do not use in your production */ + labelRender: { type: Function as PropType<(entity: FlattenDataNode) => any> }, + }; +} + +class Helper { + ReturnOptionListProps = optionListProps(); + ReturnTreeSelectProps = treeSelectProps(); +} + +export type OptionListProps = Partial['ReturnOptionListProps']>>; + +export type TreeSelectProps = Partial< + ExtractPropTypes['ReturnTreeSelectProps']> +>; diff --git a/components/vc-tree-select2/utils/legacyUtil.tsx b/components/vc-tree-select2/utils/legacyUtil.tsx new file mode 100644 index 000000000..9bafb1969 --- /dev/null +++ b/components/vc-tree-select2/utils/legacyUtil.tsx @@ -0,0 +1,181 @@ +import { filterEmpty } from '../../_util/props-util'; +import { camelize } from 'vue'; +import { warning } from '../../vc-util/warning'; +import type { + DataNode, + LegacyDataNode, + ChangeEventExtra, + InternalDataEntity, + RawValueType, + LegacyCheckedNode, +} from '../interface'; +import TreeNode from '../TreeNode'; +import type { VueNode } from '../../_util/type'; + +function isTreeSelectNode(node: any) { + return node && node.type && (node.type as any).isTreeSelectNode; +} +export function convertChildrenToData(rootNodes: VueNode[]): DataNode[] { + function dig(treeNodes: any[] = []): DataNode[] { + return filterEmpty(treeNodes).map(treeNode => { + // Filter invalidate node + if (!isTreeSelectNode(treeNode)) { + warning(!treeNode, 'TreeSelect/TreeSelectNode can only accept TreeSelectNode as children.'); + return null; + } + const slots = (treeNode.children as any) || {}; + const key = treeNode.key as string | number; + const props: any = {}; + for (const [k, v] of Object.entries(treeNode.props)) { + props[camelize(k)] = v; + } + const { isLeaf, checkable, selectable, disabled, disableCheckbox } = props; + // 默认值为 undefined + const newProps = { + isLeaf: isLeaf || isLeaf === '' || undefined, + checkable: checkable || checkable === '' || undefined, + selectable: selectable || selectable === '' || undefined, + disabled: disabled || disabled === '' || undefined, + disableCheckbox: disableCheckbox || disableCheckbox === '' || undefined, + }; + const slotsProps = { ...props, ...newProps }; + const { + title = slots.title?.(slotsProps), + switcherIcon = slots.switcherIcon?.(slotsProps), + ...rest + } = props; + const children = slots.default?.(); + const dataNode: DataNode = { + ...rest, + title, + switcherIcon, + key, + isLeaf, + ...newProps, + }; + + const parsedChildren = dig(children); + if (parsedChildren.length) { + dataNode.children = parsedChildren; + } + + return dataNode; + }); + } + + return dig(rootNodes as any[]); +} + +export function fillLegacyProps(dataNode: DataNode): LegacyDataNode { + // Skip if not dataNode exist + if (!dataNode) { + return dataNode as LegacyDataNode; + } + + const cloneNode = { ...dataNode }; + + if (!('props' in cloneNode)) { + Object.defineProperty(cloneNode, 'props', { + get() { + warning( + false, + 'New `rc-tree-select` not support return node instance as argument anymore. Please consider to remove `props` access.', + ); + return cloneNode; + }, + }); + } + + return cloneNode as LegacyDataNode; +} + +export function fillAdditionalInfo( + extra: ChangeEventExtra, + triggerValue: RawValueType, + checkedValues: RawValueType[], + treeData: InternalDataEntity[], + showPosition: boolean, +) { + let triggerNode = null; + let nodeList: LegacyCheckedNode[] = null; + + function generateMap() { + function dig(list: InternalDataEntity[], level = '0', parentIncluded = false) { + return list + .map((dataNode, index) => { + const pos = `${level}-${index}`; + const included = checkedValues.includes(dataNode.value); + const children = dig(dataNode.children || [], pos, included); + const node = {children.map(child => child.node)}; + + // Link with trigger node + if (triggerValue === dataNode.value) { + triggerNode = node; + } + + if (included) { + const checkedNode: LegacyCheckedNode = { + pos, + node, + children, + }; + + if (!parentIncluded) { + nodeList.push(checkedNode); + } + + return checkedNode; + } + return null; + }) + .filter(node => node); + } + + if (!nodeList) { + nodeList = []; + + dig(treeData); + + // Sort to keep the checked node length + nodeList.sort( + ( + { + node: { + props: { value: val1 }, + }, + }, + { + node: { + props: { value: val2 }, + }, + }, + ) => { + const index1 = checkedValues.indexOf(val1); + const index2 = checkedValues.indexOf(val2); + return index1 - index2; + }, + ); + } + } + + Object.defineProperty(extra, 'triggerNode', { + get() { + warning(false, '`triggerNode` is deprecated. Please consider decoupling data with node.'); + generateMap(); + + return triggerNode; + }, + }); + Object.defineProperty(extra, 'allCheckedNodes', { + get() { + warning(false, '`allCheckedNodes` is deprecated. Please consider decoupling data with node.'); + generateMap(); + + if (showPosition) { + return nodeList; + } + + return nodeList.map(({ node }) => node); + }, + }); +} diff --git a/components/vc-tree-select2/utils/strategyUtil.ts b/components/vc-tree-select2/utils/strategyUtil.ts new file mode 100644 index 000000000..ad47e768a --- /dev/null +++ b/components/vc-tree-select2/utils/strategyUtil.ts @@ -0,0 +1,46 @@ +import type { DataEntity } from '../../vc-tree/interface'; +import type { RawValueType, Key, DataNode } from '../interface'; +import { isCheckDisabled } from './valueUtil'; + +export const SHOW_ALL = 'SHOW_ALL'; +export const SHOW_PARENT = 'SHOW_PARENT'; +export const SHOW_CHILD = 'SHOW_CHILD'; + +export type CheckedStrategy = typeof SHOW_ALL | typeof SHOW_PARENT | typeof SHOW_CHILD; + +export function formatStrategyKeys( + keys: Key[], + strategy: CheckedStrategy, + keyEntities: Record, +): RawValueType[] { + const keySet = new Set(keys); + + if (strategy === SHOW_CHILD) { + return keys.filter(key => { + const entity = keyEntities[key]; + + if ( + entity && + entity.children && + entity.children.every( + ({ node }) => isCheckDisabled(node) || keySet.has((node as DataNode).key), + ) + ) { + return false; + } + return true; + }); + } + if (strategy === SHOW_PARENT) { + return keys.filter(key => { + const entity = keyEntities[key]; + const parent = entity ? entity.parent : null; + + if (parent && !isCheckDisabled(parent.node) && keySet.has((parent.node as DataNode).key)) { + return false; + } + return true; + }); + } + return keys; +} diff --git a/components/vc-tree-select2/utils/valueUtil.ts b/components/vc-tree-select2/utils/valueUtil.ts new file mode 100644 index 000000000..33f71a74f --- /dev/null +++ b/components/vc-tree-select2/utils/valueUtil.ts @@ -0,0 +1,244 @@ +import type { + FlattenDataNode, + Key, + RawValueType, + DataNode, + DefaultValueType, + LabelValueType, + LegacyDataNode, + FieldNames, + InternalDataEntity, +} from '../interface'; +import { fillLegacyProps } from './legacyUtil'; +import type { SkipType } from '../hooks/useKeyValueMapping'; +import type { FlattenNode } from '../../vc-tree/interface'; +import { flattenTreeData } from '../../vc-tree/utils/treeUtil'; +import type { FilterFunc } from '../../vc-select/interface/generator'; + +type CompatibleDataNode = Omit; + +export function toArray(value: T | T[]): T[] { + if (Array.isArray(value)) { + return value; + } + return value !== undefined ? [value] : []; +} + +/** + * Fill `fieldNames` with default field names. + * + * @param fieldNames passed props + * @param skipTitle Skip if no need fill `title`. This is useful since we have 2 name as same title level + * @returns + */ +export function fillFieldNames(fieldNames?: FieldNames, skipTitle = false) { + const { label, value, children } = fieldNames || {}; + + const filledNames: FieldNames = { + value: value || 'value', + children: children || 'children', + }; + + if (!skipTitle || label) { + filledNames.label = label || 'label'; + } + + return filledNames; +} + +export function findValueOption(values: RawValueType[], options: CompatibleDataNode[]): DataNode[] { + const optionMap: Map = new Map(); + + options.forEach(flattenItem => { + const { data, value } = flattenItem; + optionMap.set(value, data.node); + }); + + return values.map(val => fillLegacyProps(optionMap.get(val))); +} + +export function isValueDisabled(value: RawValueType, options: CompatibleDataNode[]): boolean { + const option = findValueOption([value], options)[0]; + if (option) { + return option.disabled; + } + + return false; +} + +export function isCheckDisabled(node: DataNode) { + return node.disabled || node.disableCheckbox || node.checkable === false; +} + +interface TreeDataNode extends InternalDataEntity { + key: Key; + children?: TreeDataNode[]; +} + +function getLevel({ parent }: FlattenNode): number { + let level = 0; + let current = parent; + + while (current) { + current = current.parent; + level += 1; + } + + return level; +} + +/** + * Before reuse `rc-tree` logic, we need to add key since TreeSelect use `value` instead of `key`. + */ +export function flattenOptions(options: any): FlattenDataNode[] { + const typedOptions = options as InternalDataEntity[]; + + // Add missing key + function fillKey(list: InternalDataEntity[]): TreeDataNode[] { + return (list || []).map(node => { + const { value, key, children } = node; + + const clone: TreeDataNode = { + ...node, + key: 'key' in node ? key : value, + }; + + if (children) { + clone.children = fillKey(children); + } + + return clone; + }); + } + + const flattenList = flattenTreeData(fillKey(typedOptions), true, null); + + const cacheMap = new Map(); + const flattenDateNodeList: (FlattenDataNode & { parentKey?: Key })[] = flattenList.map(option => { + const { data, key, value } = option as any as Omit & { + value: RawValueType; + data: InternalDataEntity; + }; + + const flattenNode = { + key, + value, + data, + level: getLevel(option), + parentKey: option.parent?.data.key, + }; + + cacheMap.set(key, flattenNode); + + return flattenNode; + }); + + // Fill parent + flattenDateNodeList.forEach(flattenNode => { + // eslint-disable-next-line no-param-reassign + flattenNode.parent = cacheMap.get(flattenNode.parentKey); + }); + + return flattenDateNodeList; +} + +function getDefaultFilterOption(optionFilterProp: string) { + return (searchValue: string, dataNode: LegacyDataNode) => { + const value = dataNode[optionFilterProp]; + + return String(value).toLowerCase().includes(String(searchValue).toLowerCase()); + }; +} + +/** Filter options and return a new options by the search text */ +export function filterOptions( + searchValue: string, + options: DataNode[], + { + optionFilterProp, + filterOption, + }: { + optionFilterProp: string; + filterOption: boolean | FilterFunc; + }, +): DataNode[] { + if (filterOption === false) { + return options; + } + + let filterOptionFunc: FilterFunc; + if (typeof filterOption === 'function') { + filterOptionFunc = filterOption; + } else { + filterOptionFunc = getDefaultFilterOption(optionFilterProp); + } + + function dig(list: DataNode[], keepAll = false) { + return list + .map(dataNode => { + const { children } = dataNode; + + const match = keepAll || filterOptionFunc(searchValue, fillLegacyProps(dataNode)); + const childList = dig(children || [], match); + + if (match || childList.length) { + return { + ...dataNode, + children: childList, + }; + } + return null; + }) + .filter(node => node); + } + + return dig(options); +} + +export function getRawValueLabeled( + values: RawValueType[], + prevValue: DefaultValueType, + getEntityByValue: ( + value: RawValueType, + skipType?: SkipType, + ignoreDisabledCheck?: boolean, + ) => FlattenDataNode, + getLabelProp: (entity: FlattenDataNode) => any, +): LabelValueType[] { + const valueMap = new Map(); + + toArray(prevValue).forEach(item => { + if (item && typeof item === 'object' && 'value' in item) { + valueMap.set(item.value, item); + } + }); + + return values.map(val => { + const item: LabelValueType = { value: val }; + const entity = getEntityByValue(val, 'select', true); + const label = entity ? getLabelProp(entity) : val; + + if (valueMap.has(val)) { + const labeledValue = valueMap.get(val); + item.label = 'label' in labeledValue ? labeledValue.label : label; + if ('halfChecked' in labeledValue) { + item.halfChecked = labeledValue.halfChecked; + } + } else { + item.label = label; + } + + return item; + }); +} + +export function addValue(rawValues: RawValueType[], value: RawValueType) { + const values = new Set(rawValues); + values.add(value); + return Array.from(values); +} +export function removeValue(rawValues: RawValueType[], value: RawValueType) { + const values = new Set(rawValues); + values.delete(value); + return Array.from(values); +} diff --git a/components/vc-tree-select2/utils/warningPropsUtil.ts b/components/vc-tree-select2/utils/warningPropsUtil.ts new file mode 100644 index 000000000..2b58b8936 --- /dev/null +++ b/components/vc-tree-select2/utils/warningPropsUtil.ts @@ -0,0 +1,34 @@ +import { warning } from '../../vc-util/warning'; +import { toArray } from './valueUtil'; + +function warningProps(props: any) { + const { searchPlaceholder, treeCheckStrictly, treeCheckable, labelInValue, value, multiple } = + props; + + warning( + !searchPlaceholder, + '`searchPlaceholder` has been removed, please use `placeholder` instead', + ); + + if (treeCheckStrictly && labelInValue === false) { + warning(false, '`treeCheckStrictly` will force set `labelInValue` to `true`.'); + } + + if (labelInValue || treeCheckStrictly) { + warning( + toArray(value).every(val => val && typeof val === 'object' && 'value' in val), + 'Invalid prop `value` supplied to `TreeSelect`. You should use { label: string, value: string | number } or [{ label: string, value: string | number }] instead.', + ); + } + + if (treeCheckStrictly || multiple || treeCheckable) { + warning( + !value || Array.isArray(value), + '`value` should be an array when `TreeSelect` is checkable or multiple.', + ); + } else { + warning(!Array.isArray(value), '`value` should not be array when `TreeSelect` is single mode.'); + } +} + +export default warningProps;