diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx new file mode 100644 index 000000000..48e6f249c --- /dev/null +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx @@ -0,0 +1,172 @@ +import { ReactNode, useMemo } from 'react' + +import { IconChevronRight } from '../../icons/index.js' +import { + ActionIcon, + Box, + Checkbox, + Divider, + Group, + Stack, + Text, + TreeNodeData, + useMantineTheme +} from '../../primitive/index.js' + +import { useTreeContext } from './useTree.js' + +import type { OptionProps } from './index.js' + +export const DEFAULT_PANEL_HEIGHT = 240 +export const DEFAULT_PANEL_WIDTH = 260 + +export interface CascaderPanelProps extends Omit { + data: TreeNodeData[] + fixedGroup: number + optionGroupTitle?: (index: number) => ReactNode +} + +export const CascaderPanel = (props: CascaderPanelProps) => { + const { expandedState } = useTreeContext() + const { data, optionGroupTitle, fixedGroup, optionProps } = props + const optionGroups = useMemo(() => { + const groups: TreeNodeData[][] = [data] + const walk = (tree: TreeNodeData[]) => { + tree.some((option) => { + if (expandedState[option.value]) { + groups.push(option.children || []) + walk(option.children || []) + return true + } + return false + }) + } + + walk(data) + + if (fixedGroup > 1 && groups.length < fixedGroup) { + groups.push(...new Array(fixedGroup - groups.length).fill([])) + } + + return groups + }, [data, expandedState]) + + return ( + + {optionGroups.map((group, index) => ( + <> + {index > 0 && } + + {!!optionGroupTitle && optionGroupTitle(index)} + {group.map((option) => ( + + ))} + + + ))} + + ) +} + +export interface CascaderItemProps { + multiple?: boolean + option: TreeNodeData + siblings: TreeNodeData[] + optionProps?: OptionProps + onCheck?: (target: TreeNodeData, prev: boolean, next: boolean) => void +} + +const CascaderItem = ({ multiple, option, siblings, optionProps, onCheck }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { + loadNodes, + toggleExpanded, + toggleCheck, + loadingState, + disabledState, + expandedState, + isNodeChecked, + isNodeIndeterminate, + collapse + } = useTreeContext() + const { label, value, children, nodeProps } = option + const isParent = nodeProps?.isParent + const isLoading = loadingState[value] + const disabled = disabledState[value] + const expanded = expandedState[value] + const isChecked = isNodeChecked(value) + const isIndeterminate = isNodeIndeterminate(value) + + const collapseSiblings = (nodes: TreeNodeData[]) => { + nodes.forEach((node) => { + if (node.value !== value && expandedState[node.value]) { + collapse(node.value) + collapseSiblings(node.children || []) + } + }) + } + + return ( + + { + if (disabled) { + return + } + toggleCheck?.(value) + onCheck?.(option, isChecked, !isChecked) + }} + > + + + + {multiple && ( + + )} + + {label} + + + + + {(isParent || children?.length) && ( + { + e.stopPropagation() + + if (!isLoading && !children?.length) { + await loadNodes(value) + } + + collapseSiblings(siblings) + toggleExpanded(value) + }} + > + + + )} + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx.bk b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx.bk new file mode 100644 index 000000000..93de67d34 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx.bk @@ -0,0 +1,150 @@ +import { ReactNode, useMemo } from 'react' + +import { IconChevronRight } from '../../icons/index.js' +import { ActionIcon, Box, Checkbox, Divider, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' + +import { TreeSelectOption, useTreeContext } from './useTreeStore.tsx.bk' +import { getAllLeafNodes } from './utils.ts.bk' + +import type { OptionProps, RenderOption } from './index.tsx.bk' + +export const DEFAULT_PANEL_HEIGHT = 240 +export const DEFAULT_PANEL_WIDTH = 260 + +export interface CascaderPanelProps extends Omit { + fixedGroup: number + optionGroupTitle?: (index: number) => ReactNode +} + +export const CascaderPanel = (props: CascaderPanelProps) => { + const { options } = useTreeContext() + const { optionGroupTitle, fixedGroup, optionProps } = props + const optionGroups = useMemo(() => { + const groups: TreeSelectOption[][] = [options] + const walk = (tree: TreeSelectOption[]) => { + tree.forEach((option) => { + if (option.expanded) { + groups.push(option.children || []) + walk(option.children || []) + } + }) + } + + walk(options) + + if (fixedGroup > 1 && groups.length < fixedGroup) { + groups.push(...new Array(fixedGroup - groups.length).fill([])) + } + + return groups + }, [options]) + + return ( + + {optionGroups.map((group, index) => ( + <> + {index > 0 && } + + {!!optionGroupTitle && optionGroupTitle(index)} + {group.map((option) => ( + + ))} + + + ))} + + ) +} + +export interface CascaderItemProps { + multiple?: boolean + option: TreeSelectOption + optionProps?: OptionProps + renderOption?: RenderOption + onClick?: (target: TreeSelectOption, newValue: string[]) => void +} + +const CascaderItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { loadChildren, toggleExpand, toggleCheck } = useTreeContext() + const { label, value, disabled, isChecked, isLeaf, isLoading, expanded, children } = option + const everyChildrenChecked = !!children?.length && getAllLeafNodes(children).every((child) => child.isChecked) + const someChildrenChecked = !!children?.length && getAllLeafNodes(children).some((child) => child.isChecked) + const isIndeterminate = !isLeaf && !everyChildrenChecked && someChildrenChecked + const _isChecked = (isLeaf && isChecked) || (!isLeaf && everyChildrenChecked) + + return ( + + { + if (disabled) { + return + } + const updatedOptions = toggleCheck?.(option) + onClick?.( + option, + multiple + ? getAllLeafNodes(updatedOptions) + .filter((n) => n.isChecked) + .map((n) => n.value) + : [value] + ) + }} + > + + + + {multiple && ( + + )} + {!!renderOption ? ( + renderOption({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + {!isLeaf ? ( + { + e.stopPropagation() + + if (!isLoading && !children?.length) { + await loadChildren(option) + } + toggleExpand(option, true) + }} + > + + + ) : ( + <> + )} + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/SearchPanel.tsx b/packages/uikit/src/biz/Cascader/SearchPanel.tsx new file mode 100644 index 000000000..9a5cbaa75 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/SearchPanel.tsx @@ -0,0 +1,83 @@ +import { useMemo } from 'react' + +import { Box, Checkbox, Group, Stack, Text, TreeNodeData, useMantineTheme } from '../../primitive/index.js' + +import { CascaderItemProps, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from './CascaderPanel.js' +import { useTreeContext } from './useTree.js' + +interface SearchPanelProps extends Omit { + currentValue: string[] + searchData?: TreeNodeData[] +} + +export const SearchPanel = (props: SearchPanelProps) => { + const { searchData = [], optionProps } = props + + return ( + + {searchData.map((option) => ( + + ))} + + ) +} + +const SearchItem = ({ multiple, option, optionProps, onCheck }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { toggleCheck, isNodeChecked, disabledState, expandedState } = useTreeContext() + const { label, value } = option + const disabled = disabledState[option.value] + const expanded = expandedState[option.value] + const isChecked = isNodeChecked(option.value) + + return ( + + { + if (disabled) { + return + } + const updatedOptions = toggleCheck?.(option.value) + onCheck?.( + option, + multiple + ? getAllLeafNodes(updatedOptions) + .filter((n) => n.isChecked) + .map((n) => n.value) + : [value] + ) + }} + > + + + + {multiple && } + {!!renderOption ? ( + renderOption({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/SearchPanel.tsx.bk b/packages/uikit/src/biz/Cascader/SearchPanel.tsx.bk new file mode 100644 index 000000000..c2b75531e --- /dev/null +++ b/packages/uikit/src/biz/Cascader/SearchPanel.tsx.bk @@ -0,0 +1,108 @@ +import { useMemo } from 'react' + +import { Box, Checkbox, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' + +import { CascaderItemProps, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from './CascaderPanel.tsx.bk' +import { SelectOption, TreeSelectOption, useTreeContext } from './useTreeStore.js' +import { getAllLeafNodes } from './utils.js' + +interface SearchPanelProps extends Omit { + currentValue: string[] + searchOptions?: SelectOption[] +} + +export const SearchPanel = (props: SearchPanelProps) => { + const { options } = useTreeContext() + const { searchOptions = [], optionProps, currentValue } = props + const displayedOptions = useMemo(() => { + const currentOptionMap = new Map() + const walk = (tree: TreeSelectOption[]) => { + tree.forEach((option) => { + currentOptionMap.set(option.value, option) + walk(option.children || []) + }) + } + + walk(options) + + return searchOptions.map((opt) => { + // value not in options(lazy load) + const currentValueChecked = currentValue.includes(opt.value) + + // leaf + const currentOpt = currentOptionMap.get(opt.value) + const currentChecked = currentOpt?.isChecked + + // all children checked + const allChildren = !!currentOpt && getAllLeafNodes([currentOpt]) + const allChildrenChecked = allChildren && !!allChildren.length && allChildren.every((n) => n.isChecked) + + return { ...opt, isChecked: currentValueChecked || currentChecked || allChildrenChecked } + }) + }, [currentValue, optionProps]) + + return ( + + {displayedOptions.map((option) => ( + + ))} + + ) +} + +const SearchItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { toggleCheck } = useTreeContext() + const { label, value, disabled, isChecked, expanded } = option + + return ( + + { + if (disabled) { + return + } + const updatedOptions = toggleCheck?.(option) + onClick?.( + option, + multiple + ? getAllLeafNodes(updatedOptions) + .filter((n) => n.isChecked) + .map((n) => n.value) + : [value] + ) + }} + > + + + + {multiple && } + {!!renderOption ? ( + renderOption({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/index.tsx b/packages/uikit/src/biz/Cascader/index.tsx new file mode 100644 index 000000000..8f397da41 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/index.tsx @@ -0,0 +1,291 @@ +import { IconSearch } from '@tabler/icons-react' +import { ReactNode, Ref, useEffect, useImperativeHandle, useState } from 'react' + +import { IconChevronSelectorVertical, IconXCircle } from '../../icons/index.js' +import { + Box, + BoxProps, + Button, + Combobox, + ComboboxProps, + ComboboxStore, + Divider, + ElementProps, + Flex, + Group, + Input, + InputProps, + LoadingOverlay, + TextProps, + ComboboxSearchProps, + useCombobox, + ActionIcon, + TreeNodeData +} from '../../primitive/index.js' + +import { CascaderPanel, DEFAULT_PANEL_HEIGHT } from './CascaderPanel.js' +// import { SearchPanel } from './SearchPanel.js' +import { TreeProvider, useTreeStore, TreeStore } from './useTree.js' + +export interface CascaderProps { + value: string[] + // target will be null when changeTrigger is onConfirm + onChange?: (target: string | null, value: string[]) => void + // works when multiple is true + changeTrigger?: 'onSelect' | 'onConfirm' + + data: TreeNodeData[] + treeStore?: TreeStore + + // multi-selection or single-selection + multiple?: boolean + emptyMessage?: string + // should the empty array be check all status + allWithEmpty?: boolean + loading?: boolean + + // combobox + comboboxProps?: Omit + comboboxRef?: Ref + + // target + target?: ReactNode + defaultTargetProps?: InputProps & ElementProps<'input'> + + // options + fixedGroup?: number + optionGroupTitle?: (index: number) => ReactNode + optionProps?: OptionProps + + // search + searchable?: boolean + searchProps?: ComboboxSearchProps & ElementProps<'input', 'onChange'> & { onChange?: (search: string) => void } + searchData?: TreeNodeData[] +} + +export interface OptionProps { + wrapperProps?: BoxProps + textProps?: TextProps + panelHeight?: number + panelWidth?: number +} + +export const Cascader = ({ + value, + onChange, + changeTrigger = 'onSelect', + + data = [], + treeStore, + + multiple, + emptyMessage, + allWithEmpty, + loading, + + comboboxProps, + comboboxRef, + + target, + defaultTargetProps, + + fixedGroup = 1, + optionGroupTitle, + optionProps, + + searchable, + searchProps, + searchData +}: CascaderProps) => { + let controller = useTreeStore({ multiple, initialCheckedState: value }) + if (!!treeStore) { + controller = treeStore + } + const { collapseAllNodes, getCheckedNodes } = controller + + // const resetCheckedStatusByValues = () => { + // const isCheckAll = value.length === 0 && allWithEmpty + // checkByValues(isCheckAll ? getAllLeafNodes(treeOptions).map((n) => n.value) : value) + // } + + const combobox = useCombobox({ + onDropdownOpen: () => { + combobox.focusSearchInput() + // resetCheckedStatusByValues() + }, + onDropdownClose: () => { + collapseAllNodes() + } + }) + useImperativeHandle(comboboxRef, () => combobox, [combobox]) + + const [search, setSearch] = useState('') + const { onChange: onSearchChange } = searchProps || {} + const onSearch = (search: string) => { + setSearch(search) + onSearchChange?.(search) + } + + useEffect(() => { + if (!!data) { + controller.initialize(data) + } + }, [data]) + + // useEffect(() => { + // if (!multiple) { + // return + // } + // resetCheckedStatusByValues() + // }, [value]) + + return ( + + + + {target ? ( + target + ) : ( + } + onClick={() => { + combobox.toggleDropdown() + combobox.focusSearchInput() + }} + {...defaultTargetProps} + /> + )} + + + {searchable && ( + + { + onSearch(event.currentTarget.value) + }} + leftSection={} + rightSectionPointerEvents="all" + rightSection={ + !!search && ( + { + onSearch('') + combobox.focusSearchInput() + }} + > + + + ) + } + /> + + + )} + + + {data.length ? ( + <> + + {searchable && search && searchData?.length ? ( + <> + ) : ( + // { + // onChange?.(target , newValue as T[]) + // if (!multiple) { + // combobox.closeDropdown() + // } + // }} + // /> + { + if (!multiple) { + onChange?.(target.value, [target.value]) + combobox.closeDropdown() + } else if (multiple && changeTrigger === 'onSelect') { + onChange?.( + target.value, + getCheckedNodes().map((n) => n.value) + ) + } + }} + /> + )} + + {multiple && changeTrigger === 'onConfirm' && ( + <> + + + + + + + )} + + ) : ( + + {emptyMessage} + + )} + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/index.tsx.bk b/packages/uikit/src/biz/Cascader/index.tsx.bk new file mode 100644 index 000000000..5793b3f77 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/index.tsx.bk @@ -0,0 +1,293 @@ +import { IconSearch } from '@tabler/icons-react' +import { ReactNode, Ref, useEffect, useImperativeHandle, useState } from 'react' + +import { IconChevronSelectorVertical, IconXCircle } from '../../icons/index.js' +import { + Box, + BoxProps, + Button, + Combobox, + ComboboxProps, + ComboboxStore, + Divider, + ElementProps, + Flex, + Group, + Input, + InputProps, + LoadingOverlay, + TextProps, + ComboboxSearchProps, + useCombobox, + ActionIcon +} from '../../primitive/index.js' + +import { CascaderPanel, DEFAULT_PANEL_HEIGHT } from './CascaderPanel.tsx.bk' +import { SearchPanel } from './SearchPanel.tsx.bk' +import { useCascader } from './useCascader.ts.bk' +import { SelectionProtectType, SelectOption, TreeProvider, TreeSelectOption, TreeStore } from './useTreeStore.tsx.bk' +import { getAllLeafNodes } from './utils.ts.bk' + +export interface CascaderProps { + value: T[] + // target will be null when changeTrigger is onConfirm + onChange?: (target: TreeSelectOption | null, value: T[]) => void + // works when multiple is true + changeTrigger?: 'onSelect' | 'onConfirm' + + options?: SelectOption[] + store?: TreeStore + + // multi-selection or single-selection + multiple?: boolean + emptyMessage?: string + // should the empty array be check all status + allWithEmpty?: boolean + loading?: boolean + + // combobox + comboboxProps?: Omit + comboboxRef?: Ref + + // target + target?: ReactNode + defaultTargetProps?: InputProps & ElementProps<'input'> + + // options + fixedGroup?: number + optionGroupTitle?: (index: number) => ReactNode + optionProps?: OptionProps + renderOption?: RenderOption + + // search + searchable?: boolean + searchProps?: ComboboxSearchProps & ElementProps<'input', 'onChange'> & { onChange?: (search: string) => void } + searchOptions?: SelectOption[] + renderSearchOption?: RenderOption +} + +export interface OptionProps { + wrapperProps?: BoxProps + textProps?: TextProps + panelHeight?: number + panelWidth?: number +} + +export interface RenderOption { + (currentOption: TreeSelectOption): React.ReactNode +} + +export const Cascader = ({ + value, + onChange, + changeTrigger = 'onSelect', + + options = [], + store, + + multiple, + emptyMessage, + allWithEmpty, + loading, + + comboboxProps, + comboboxRef, + + target, + defaultTargetProps, + + fixedGroup = 1, + optionGroupTitle, + optionProps, + renderOption, + + searchable, + searchProps, + searchOptions, + renderSearchOption +}: CascaderProps) => { + let cascader = useCascader({ options }) + if (store) { + cascader = store + } + const { options: treeOptions, checkByValues, foldAll } = cascader + + const resetCheckedStatusByValues = () => { + const isCheckAll = value.length === 0 && allWithEmpty + checkByValues(isCheckAll ? getAllLeafNodes(treeOptions).map((n) => n.value) : value) + } + + const combobox = useCombobox({ + onDropdownOpen: () => { + combobox.focusSearchInput() + resetCheckedStatusByValues() + }, + onDropdownClose: () => { + foldAll() + } + }) + useImperativeHandle(comboboxRef, () => combobox, [combobox]) + + const [search, setSearch] = useState('') + const { onChange: onSearchChange } = searchProps || {} + const onSearch = (search: string) => { + setSearch(search) + onSearchChange?.(search) + } + + useEffect(() => { + if (!multiple) { + return + } + resetCheckedStatusByValues() + }, [value]) + + return ( + + + + {target ? ( + target + ) : ( + } + onClick={() => { + combobox.toggleDropdown() + combobox.focusSearchInput() + }} + {...defaultTargetProps} + /> + )} + + + {searchable && ( + + { + onSearch(event.currentTarget.value) + }} + leftSection={} + rightSectionPointerEvents="all" + rightSection={ + !!search && ( + { + onSearch('') + combobox.focusSearchInput() + }} + > + + + ) + } + /> + + + )} + + + {treeOptions.length ? ( + <> + + {searchable && search && searchOptions?.length ? ( + []} + multiple={multiple} + optionProps={optionProps} + renderOption={renderSearchOption as RenderOption} + onClick={(target, newValue) => { + onChange?.(target as TreeSelectOption, newValue as T[]) + if (!multiple) { + combobox.closeDropdown() + } + }} + /> + ) : ( + } + optionGroupTitle={optionGroupTitle} + onClick={(target, newValue) => { + if (changeTrigger === 'onSelect' || !multiple) { + onChange?.(target as TreeSelectOption, newValue as T[]) + } + if (!multiple) { + combobox.closeDropdown() + } + }} + /> + )} + + {multiple && changeTrigger === 'onConfirm' && ( + <> + + + + + + + )} + + ) : ( + + {emptyMessage} + + )} + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/useCascader.ts.bk b/packages/uikit/src/biz/Cascader/useCascader.ts.bk new file mode 100644 index 000000000..981c027bc --- /dev/null +++ b/packages/uikit/src/biz/Cascader/useCascader.ts.bk @@ -0,0 +1,131 @@ +import { useState } from 'react' + +import type { + ToggleExpand, + LoadChildren, + OnOptionChange, + SelectionProtectType, + ToggleLoading, + TreeSelectOption, + TreeStore, + TreeStoreConfig, + UpdateChildren, + ToggleCheck +} from './useTreeStore.tsx.bk' +import { flatArrayToTree, getAllLeafNodes } from './utils.js' + +export const useCascader = ({ + options, + onOptionChange, + onLoadChildren, + onLoadChildrenAsync +}: TreeStoreConfig): TreeStore => { + const [_options, setOptions] = useState(flatArrayToTree(options)) + const _updateOptions = ( + tree: TreeSelectOption[], + identifier: T | ((opt: TreeSelectOption) => boolean), + newData: Partial> | ((opt: TreeSelectOption) => Partial>), + preprocess?: (node: TreeSelectOption) => TreeSelectOption, + postprocess?: (node: TreeSelectOption) => TreeSelectOption + ): TreeSelectOption[] => { + return tree.map((opt) => { + let _opt = opt + if (preprocess) { + _opt = preprocess(opt) + } + + const shouldUpdate = typeof identifier === 'function' ? identifier(_opt) : _opt.value === identifier + if (shouldUpdate) { + const _newData = typeof newData === 'function' ? newData(_opt) : newData + _opt = { ..._opt, ..._newData } + } + + if (_opt.children) { + _opt = { ..._opt, children: _updateOptions(_opt.children, identifier, newData, preprocess, postprocess) } + } + + return _opt + }) + } + + const toggleCheck: ToggleCheck = (target) => { + const allLeafNodes = getAllLeafNodes([target]) + const allValues = allLeafNodes.map((n) => n.value) + const isAllLeaesChecked = allLeafNodes.every((n) => n.isChecked) + const newData = { isChecked: !isAllLeaesChecked } + const updatedOptions = _updateOptions( + _options, + (opt) => !opt.disabled && allValues.includes(opt.value), + newData, + undefined + ) + + setOptions((prev) => ({ ...prev, ...updatedOptions })) + onOptionChange?.({ type: 'check', target, newData }) + + return updatedOptions + } + const checkByValues = (values: T[]) => { + const updatedOptions = _updateOptions( + _options, + (opt) => !opt.disabled, + (opt) => ({ isChecked: values.includes(opt.value) }) + ) + setOptions(updatedOptions) + + return updatedOptions + } + + const updateOption: OnOptionChange = (evt, preprocess) => { + const { target, newData } = evt + const updatedOptions = _updateOptions(_options, target.value, newData, preprocess) + + setOptions(updatedOptions) + onOptionChange?.(evt) + } + const updateChildren: UpdateChildren = (target, children) => + updateOption({ type: 'updateChildren', target, newData: { children } }) + const toggleLoading: ToggleLoading = (target) => { + updateOption({ type: 'loading', target, newData: { isLoading: !target.isLoading } }) + } + const toggleExpand: ToggleExpand = (target, reset) => { + updateOption( + { + type: 'expand', + target, + newData: { expanded: !target.expanded } + }, + (node) => { + if (reset && node.parentValue === target.parentValue) { + return { ...node, expanded: false } + } + return node + } + ) + } + const foldAll = () => { + const updatedOptions = _updateOptions(_options, () => true, { expanded: false }) + setOptions(updatedOptions) + } + const loadChildren: LoadChildren = async (target) => { + onLoadChildren?.(target) + if (onLoadChildrenAsync) { + toggleLoading(target) + const children = await onLoadChildrenAsync(target) + updateChildren(target, children) + toggleLoading(target) + } + } + + return { + options: _options, + updateOption, + updateChildren, + toggleLoading, + loadChildren, + toggleExpand, + foldAll, + toggleCheck, + checkByValues + } +} diff --git a/packages/uikit/src/biz/Cascader/useTree.tsx b/packages/uikit/src/biz/Cascader/useTree.tsx new file mode 100644 index 000000000..72872d383 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/useTree.tsx @@ -0,0 +1,111 @@ +import { useState, useCallback, createContext, useContext, PropsWithChildren } from 'react' + +import { useTree as useTreePrimitive, UseTreeInput, UseTreeReturnType, TreeNodeData } from '../../primitive/index.js' + +export interface TreeStoreProps extends UseTreeInput { + loadNodesFn?: ( + parent: string, + updator: (tree: TreeNodeData[], newChildren: TreeNodeData[]) => TreeNodeData[] + ) => Promise +} + +export interface TreeStore extends UseTreeReturnType { + loadNodes: (parent: string) => Promise + loadingState: { [key: string]: boolean } + disabledState: { [key: string]: boolean } + setLoading: (parent: string, value: boolean) => void + setDisabled: (parent: string, value: boolean) => void + toggleLoading: (parent: string) => void + toggleDisabled: (parent: string) => void + toggleCheck: (value: string) => void +} + +export const useTreeStore = (props: TreeStoreProps): TreeStore => { + const { loadNodesFn, ...treeProps } = props + const treeMethods = useTreePrimitive(treeProps) + const { + initialize: _initialize, + checkNode, + uncheckNode, + isNodeChecked, + toggleExpanded: _toggleExpanded + } = treeMethods + + const [loadingState, setLoadingState] = useState<{ [key: string]: boolean }>({}) + const [disabledState, setDisabledState] = useState<{ [key: string]: boolean }>({}) + + const setLoading = useCallback((parent: string, value: boolean) => { + setLoadingState((prev) => ({ ...prev, [parent]: value })) + }, []) + + const setDisabled = useCallback((parent: string, value: boolean) => { + setDisabledState((prev) => ({ ...prev, [parent]: value })) + }, []) + + const toggleLoading = useCallback((parent: string) => { + setLoadingState((prev) => ({ ...prev, [parent]: !prev[parent] })) + }, []) + + const toggleDisabled = useCallback((parent: string) => { + setDisabledState((prev) => ({ ...prev, [parent]: !prev[parent] })) + }, []) + + const toggleCheck = (value: string) => { + if (isNodeChecked(value)) { + uncheckNode(value) + } else { + checkNode(value) + } + } + + const loadNodes = useCallback( + async (parent: string) => { + if (!loadNodesFn) { + return + } + + toggleLoading(parent) + + await loadNodesFn(parent, (tree: TreeNodeData[], newChildren: TreeNodeData[]) => { + const updateNodes = (nodeList: TreeNodeData[]): TreeNodeData[] => { + return nodeList.map((node) => { + if (node.value === parent) { + return { + ...node, + children: node.children ? [...node.children, ...newChildren] : newChildren + } + } + if (node.children) { + return { ...node, children: updateNodes(node.children) } + } + return node + }) + } + return updateNodes(tree) + }) + + toggleLoading(parent) + }, + [loadNodesFn] + ) + + return { + ...treeMethods, + loadingState, + disabledState, + loadNodes, + toggleCheck, + setLoading, + setDisabled, + toggleLoading, + toggleDisabled + } +} + +const TreeContext = createContext(null as any) + +export const TreeProvider = ({ value, children }: PropsWithChildren<{ value: TreeStore }>) => ( + {children} +) + +export const useTreeContext = () => useContext(TreeContext) diff --git a/packages/uikit/src/biz/Cascader/useTreeStore.tsx.bk b/packages/uikit/src/biz/Cascader/useTreeStore.tsx.bk new file mode 100644 index 000000000..078d14aa3 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/useTreeStore.tsx.bk @@ -0,0 +1,88 @@ +import { createContext, PropsWithChildren, useContext } from 'react' + +export type SelectionProtectType = string | number + +// Tree Option +export interface TreeSelectOption { + label: string + value: T + disabled?: boolean + isChecked?: boolean + isLeaf?: boolean + isLoading?: boolean + expanded?: boolean + children?: TreeSelectOption[] + parentValue?: T +} + +export interface SelectOption extends Omit, 'children' | 'parentValue'> { + parentValue: T +} + +// Tree Store +export interface TreeStoreConfig { + options: SelectOption[] + onOptionChange?: OnOptionChange + onLoadChildren?: LoadChildren + // Load children asynchronously function, return a promise with children options. + // Automatically update the children of the target option. + onLoadChildrenAsync?: LoadChildrenAsync +} + +export interface LoadChildren { + (option: TreeSelectOption): void +} + +export interface LoadChildrenAsync { + (option: TreeSelectOption): Promise[]> +} + +// Tree Store Returns +export interface TreeStore { + options: TreeSelectOption[] + updateOption: OnOptionChange + updateChildren: UpdateChildren + toggleLoading: ToggleLoading + loadChildren: LoadChildren + toggleExpand: ToggleExpand + foldAll: () => void + toggleCheck: ToggleCheck + checkByValues: (values: T[]) => TreeSelectOption[] +} + +export interface OnOptionChange { + (evt: OptionChangeEvent, preprocess?: (node: TreeSelectOption) => TreeSelectOption): void +} +export type StatusChangeType = 'check' | 'expand' | 'loading' | 'updateChildren' +export interface OptionChangeEvent { + type: StatusChangeType + target: TreeSelectOption + newData: Partial> +} + +export interface UpdateChildren { + (target: TreeSelectOption, children: TreeSelectOption[]): void +} + +export interface ToggleLoading { + (target: TreeSelectOption): void +} + +export interface ToggleExpand { + (target: TreeSelectOption, reset?: boolean): void +} + +export interface ToggleCheck { + (target: TreeSelectOption): TreeSelectOption[] +} + +const TreeContext = createContext>(null as any) + +export const TreeProvider = ({ + value, + children +}: PropsWithChildren<{ value: TreeStore }>) => ( + }>{children} +) + +export const useTreeContext = () => useContext(TreeContext) diff --git a/packages/uikit/src/biz/Cascader/utils.ts.bk b/packages/uikit/src/biz/Cascader/utils.ts.bk new file mode 100644 index 000000000..187134002 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/utils.ts.bk @@ -0,0 +1,78 @@ +import type { SelectOption, TreeSelectOption } from './useTreeStore.tsx.bk' + +/** + * Flatten all leaf nodes from a TreeSelectOption array. + * + * A leaf node is a node that has no children and is not disabled. + * + * @param value - TreeSelectOption array + * @returns An array of leaf nodes + */ +export const getAllLeafNodes = (value: TreeSelectOption[], parent?: TreeSelectOption) => + value.reduce((prev, cur) => { + const { children, ...rest } = cur + if (cur.isLeaf && !cur.disabled) { + prev.push({ ...rest, parentValue: parent?.value! }) + } + if (children) { + prev.push(...getAllLeafNodes(children, rest)) + } + return prev + }, [] as SelectOption[]) + +/** + * Flatten a TreeSelectOption array into a plain array of SelectOption. + * + * This function will traverse the tree recursively and return all the leaf + * nodes in a single array. Each leaf node will have a parent field pointing to + * its parent node's value. + * + * @param value - The TreeSelectOption array to be flattened + * @param parent - The parent node of the current node + * @returns An array of SelectOption + */ +export const treeToFlatArray = (value: TreeSelectOption[], parent?: TreeSelectOption) => { + return value.reduce((prev, cur) => { + const { children, ...rest } = cur + prev.push({ ...rest, parentValue: parent?.value! }) + if (children) { + prev.push(...treeToFlatArray(children, cur)) + } + return prev + }, [] as SelectOption[]) +} + +/** + * Converts a flat array of SelectOption items into a tree structure of TreeSelectOption. + * + * This function organizes the flat array by mapping each option to its parent based on the parent field. + * Options without a parent are considered root nodes and are directly added to the tree array. + * Children are recursively added to their respective parent nodes. + * + * @param value - The flat array of SelectOption items to be converted into a tree structure. + * @returns A TreeSelectOption array representing the hierarchical tree structure. + */ + +export const flatArrayToTree = (value: SelectOption[]) => { + const treeArr: TreeSelectOption[] = [] + const treeMap = new Map>() + value.forEach((v) => { + treeMap.set(v.value, { ...v, children: [] }) + }) + value.forEach((v) => { + const cur = treeMap.get(v.value)! + if (!v.parentValue) { + treeArr.push(cur) + return + } + + const parent = treeMap.get(v.parentValue) + if (!parent) { + return + } + + parent.children!.push(cur) + }) + + return treeArr +} diff --git a/packages/uikit/src/biz/index.ts b/packages/uikit/src/biz/index.ts index 59a870cae..c504e3f59 100644 --- a/packages/uikit/src/biz/index.ts +++ b/packages/uikit/src/biz/index.ts @@ -13,3 +13,5 @@ export * from './PageShell/index.js' export * from './TimeRangePicker/index.js' export * from './DateTimePicker/index.js' export * from './ProMultiSelect/index.js' +export * from './Cascader/index.js' +export * from './Cascader/useTree.js' diff --git a/packages/uikit/src/primitive/index.ts b/packages/uikit/src/primitive/index.ts index 477c72460..b5a2c2945 100644 --- a/packages/uikit/src/primitive/index.ts +++ b/packages/uikit/src/primitive/index.ts @@ -54,8 +54,10 @@ export type { TextareaProps, AutocompleteProps, ComboboxProps, + ComboboxSearchProps, ComboboxItem, ComboboxData, + ComboboxStore, PillProps, PillsInputProps, OptionsFilter, @@ -240,6 +242,13 @@ export { Title, TypographyStylesProvider, + // Tree + useTree, + type UseTreeInput, + type UseTreeReturnType, + type TreeNodeData, + type CheckedNodeStatus, + // Misc Box, Collapse, diff --git a/stories/uikit/biz/Cascader.stories.tsx b/stories/uikit/biz/Cascader.stories.tsx new file mode 100644 index 000000000..69d6423bc --- /dev/null +++ b/stories/uikit/biz/Cascader.stories.tsx @@ -0,0 +1,457 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Box, TreeNodeData } from '@tidbcloud/uikit' +import { Cascader, useTreeStore } from '@tidbcloud/uikit/biz' +import { useMemo, useState } from 'react' + +type Story = StoryObj + +const meta: Meta = { + title: 'Biz/Cascader', + component: Cascader, + tags: ['autodocs'], + parameters: {} +} + +export default meta + +function getTreeData(): TreeNodeData[] { + return [ + { + label: 'TiDB Serverless', + value: 'TiDB Serverless', + nodeProps: { + isParent: true + }, + children: [] + }, + { + label: 'TiDB Dedicated', + value: 'TiDB Dedicated', + children: [ + { + label: 'Node Compute', + value: 'Node Compute', + children: [ + { + label: 'TiDB', + value: 'TiDB Dedicated - Node Compute - TiDB' + }, + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Compute - TiKV' + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Compute - TiFlash' + } + ] + }, + { + label: 'Node Storage', + value: 'Node Storage', + children: [ + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Storage - TiKV' + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Storage - TiFlash' + } + ] + }, + { + label: 'Backup', + value: 'Backup', + children: [ + { + label: 'Single Region Storage', + value: 'TiDB Dedicated - Backup - Single Region Storage' + }, + { + label: 'Dual Region Storage', + value: 'TiDB Dedicated - Backup - Dual Region Storage' + }, + { + label: 'Replication', + value: 'TiDB Dedicated - Backup - Replication' + } + ] + }, + { + label: 'Data Migration', + value: 'Data Migration', + children: [ + { + label: 'Replication Capacity Units (RCU)', + value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)' + } + ] + }, + { + label: 'Changefeed', + value: 'Changefeed', + children: [ + { + label: 'Replication Capacity Units', + value: 'TiDB Dedicated - Changefeed - Replication Capacity Units' + } + ] + }, + { + label: 'Data Transfer', + value: 'Data Transfer', + children: [ + { + label: 'Internet', + value: 'TiDB Dedicated - Data Transfer - Internet' + }, + { + label: 'Cross Region', + value: 'TiDB Dedicated - Data Transfer - Cross Region' + }, + { + label: 'Same Region', + value: 'TiDB Dedicated - Data Transfer - Same Region' + }, + { + label: 'Load Balancing', + value: 'TiDB Dedicated - Data Transfer - Load Balancing' + }, + { + label: 'DM NAT Gateway', + value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway' + }, + { + label: 'Private Data Link', + value: 'TiDB Dedicated - Data Transfer - Private Data Link' + } + ] + }, + { + label: 'Recovery Group', + value: 'Recovery Group', + children: [ + { + label: 'Recovery Group Service', + value: 'TiDB Dedicated - Recovery Group - Recovery Group Service' + }, + { + label: 'Same Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing' + }, + { + label: 'Cross Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing' + } + ] + } + ] + }, + { + label: 'Support Plan', + value: 'Support Plan' + } + ] +} + +const TITLES = ['Group 1', 'Group 2', 'Group 3'] +function MultipleDemo() { + const [data, setData] = useState(() => getTreeData()) + const treeStore = useTreeStore({ + loadNodesFn: async (target, updator) => { + const dp = Promise.resolve([ + { + label: 'Row-based Storage', + value: 'TiDB Serverless - Row-based Storage' + }, + { + label: 'Columnar Storage', + value: 'TiDB Serverless - Columnar Storage' + }, + { + label: 'Request Units', + value: 'TiDB Serverless - Request Units' + } + ]) + const d = await dp + setData(updator(data, d)) + } + }) + const [value, setValue] = useState([]) + return ( + { + console.log(`target:`, target) + console.log(`checked:`, v) + setValue(v) + }} + fixedGroup={2} + multiple + searchable + // searchOptions={treeToFlatArray(getTreeData())} + allWithEmpty + changeTrigger="onConfirm" + optionGroupTitle={(index) => ( + + {TITLES[index]} + + )} + /> + ) +} + +// function SingleDemo() { +// const [value, setValue] = useState([]) +// const treeSelectRef = useRef(null) + +// return ( +// +// Selected: {value.join(', ')} +// { +// console.log(`checked:`, v, target) +// setValue(v) +// }} +// loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} +// allowSelectAll={false} +// target={} +// /> +// +// ) +// } + +const code = ` +import { TreeSelect, TreeSelectOption } from '@tidbcloud/uikit/biz' + +function getTreeData(): TreeSelectOption[] { + return [ + { + label: 'TiDB Serverless', + value: 'TiDB Serverless', + isLeaf: false, + children: [ + { + label: 'Row-based Storage', + value: 'TiDB Serverless - Row-based Storage', + isLeaf: true + }, + { + label: 'Columnar Storage', + value: 'TiDB Serverless - Columnar Storage', + isLeaf: true + }, + { + label: 'Request Units', + value: 'TiDB Serverless - Request Units', + isLeaf: true + } + ] + }, + { + label: 'TiDB Dedicated', + value: 'TiDB Dedicated', + isLeaf: false, + children: [ + { + label: 'Node Compute', + value: 'Node Compute', + isLeaf: false, + children: [ + { + label: 'TiDB', + value: 'TiDB Dedicated - Node Compute - TiDB', + isLeaf: true + }, + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Compute - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Compute - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Node Storage', + value: 'Node Storage', + isLeaf: false, + children: [ + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Storage - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Storage - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Backup', + value: 'Backup', + isLeaf: false, + children: [ + { + label: 'Single Region Storage', + value: 'TiDB Dedicated - Backup - Single Region Storage', + isLeaf: true + }, + { + label: 'Dual Region Storage', + value: 'TiDB Dedicated - Backup - Dual Region Storage', + isLeaf: true + }, + { + label: 'Replication', + value: 'TiDB Dedicated - Backup - Replication', + isLeaf: true + } + ] + }, + { + label: 'Data Migration', + value: 'Data Migration', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units (RCU)', + value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)', + isLeaf: true + } + ] + }, + { + label: 'Changefeed', + value: 'Changefeed', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units', + value: 'TiDB Dedicated - Changefeed - Replication Capacity Units', + isLeaf: true + } + ] + }, + { + label: 'Data Transfer', + value: 'Data Transfer', + isLeaf: false, + children: [ + { + label: 'Internet', + value: 'TiDB Dedicated - Data Transfer - Internet', + isLeaf: true + }, + { + label: 'Cross Region', + value: 'TiDB Dedicated - Data Transfer - Cross Region', + isLeaf: true + }, + { + label: 'Same Region', + value: 'TiDB Dedicated - Data Transfer - Same Region', + isLeaf: true + }, + { + label: 'Load Balancing', + value: 'TiDB Dedicated - Data Transfer - Load Balancing', + isLeaf: true + }, + { + label: 'DM NAT Gateway', + value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway', + isLeaf: true + }, + { + label: 'Private Data Link', + value: 'TiDB Dedicated - Data Transfer - Private Data Link', + isLeaf: true + } + ] + }, + { + label: 'Recovery Group', + value: 'Recovery Group', + isLeaf: false, + children: [ + { + label: 'Recovery Group Service', + value: 'TiDB Dedicated - Recovery Group - Recovery Group Service', + isLeaf: true + }, + { + label: 'Same Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing', + isLeaf: true + }, + { + label: 'Cross Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing', + isLeaf: true + } + ] + } + ] + }, + { + label: 'Support Plan', + value: 'Support Plan', + isLeaf: true + } + ] +} + +function Demo() { + return + new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + /> +} +` + +// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing +export const Multiple: Story = { + parameters: { + controls: { expanded: true }, + docs: { + source: { + language: 'jsx', + code + } + } + }, + render: () => , + args: {} +} + +// export const Single: Story = { +// parameters: { +// controls: { expanded: true }, +// docs: { +// source: { +// language: 'jsx', +// code +// } +// } +// }, +// render: () => , +// args: {} +// }