From af393b00038acdefabaa0e6ca6a38139bd47980c Mon Sep 17 00:00:00 2001 From: balibabu Date: Mon, 28 Apr 2025 14:58:33 +0800 Subject: [PATCH] Feat: Add AsyncTreeSelect component #3221 (#7377) ### What problem does this PR solve? Feat: Add AsyncTreeSelect component #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality) --- .../list-filter-bar/filter-popover.tsx | 2 +- web/src/components/list-filter-bar/index.tsx | 2 +- web/src/components/ui/async-tree-select.tsx | 157 ++++++++++++++++++ web/src/components/ui/single-tree-select.tsx | 95 ----------- web/src/pages/files/index.tsx | 1 - web/src/pages/files/move-dialog.tsx | 81 ++++++--- 6 files changed, 213 insertions(+), 125 deletions(-) create mode 100644 web/src/components/ui/async-tree-select.tsx delete mode 100644 web/src/components/ui/single-tree-select.tsx diff --git a/web/src/components/list-filter-bar/filter-popover.tsx b/web/src/components/list-filter-bar/filter-popover.tsx index 6baa7dee4..056f2e115 100644 --- a/web/src/components/list-filter-bar/filter-popover.tsx +++ b/web/src/components/list-filter-bar/filter-popover.tsx @@ -149,7 +149,7 @@ export function FilterPopover({ value, onChange, filters, -}: PropsWithChildren & CheckboxFormMultipleProps) { +}: PropsWithChildren & Omit) { const [open, setOpen] = useState(false); return ( diff --git a/web/src/components/list-filter-bar/index.tsx b/web/src/components/list-filter-bar/index.tsx index d05a6d5f7..d24f6cd4e 100644 --- a/web/src/components/list-filter-bar/index.tsx +++ b/web/src/components/list-filter-bar/index.tsx @@ -38,7 +38,7 @@ export default function ListFilterBar({ value, onChange, filters, -}: PropsWithChildren) { +}: PropsWithChildren>) { const filterCount = useMemo(() => { return typeof value === 'object' && value !== null ? Object.values(value).reduce((pre, cur) => { diff --git a/web/src/components/ui/async-tree-select.tsx b/web/src/components/ui/async-tree-select.tsx new file mode 100644 index 000000000..157b20c07 --- /dev/null +++ b/web/src/components/ui/async-tree-select.tsx @@ -0,0 +1,157 @@ +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { isEmpty } from 'lodash'; +import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from './button'; + +type TreeId = number | string; + +export type TreeNodeType = { + id: TreeId; + title: ReactNode; + parentId: TreeId; + isLeaf?: boolean; +}; + +type AsyncTreeSelectProps = { + treeData: TreeNodeType[]; + value?: TreeId; + onChange?(value: TreeId): void; + loadData?(node: TreeNodeType): Promise; +}; + +export function AsyncTreeSelect({ + treeData, + value, + loadData, + onChange, +}: AsyncTreeSelectProps) { + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + + const [expandedKeys, setExpandedKeys] = useState([]); + const [loadingId, setLoadingId] = useState(''); + + const selectedTitle = useMemo(() => { + return treeData.find((x) => x.id === value)?.title; + }, [treeData, value]); + + const isExpanded = useCallback( + (id: TreeId | undefined) => { + if (id === undefined) { + return true; + } + return expandedKeys.indexOf(id) !== -1; + }, + [expandedKeys], + ); + + const handleNodeClick = useCallback( + (id: TreeId) => (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(id); + setOpen(false); + }, + [onChange], + ); + + const handleArrowClick = useCallback( + (node: TreeNodeType) => async (e: React.MouseEvent) => { + e.stopPropagation(); + const { id } = node; + if (isExpanded(id)) { + setExpandedKeys((keys) => { + return keys.filter((x) => x !== id); + }); + } else { + const hasChild = treeData.some((x) => x.parentId === id); + setExpandedKeys((keys) => { + return [...keys, id]; + }); + + if (!hasChild) { + setLoadingId(id); + await loadData?.(node); + setLoadingId(''); + } + } + }, + [isExpanded, loadData, treeData], + ); + + const renderNodes = useCallback( + (parentId?: TreeId) => { + const currentLevelList = parentId + ? treeData.filter((x) => x.parentId === parentId) + : treeData.filter((x) => treeData.every((y) => x.parentId !== y.id)); + + if (currentLevelList.length === 0) return null; + + return ( +
    + {currentLevelList.map((x) => ( +
  • +
    + + {x.title} + + {x.isLeaf || ( + + )} +
    + {renderNodes(x.id)} +
  • + ))} +
+ ); + }, + [handleArrowClick, handleNodeClick, isExpanded, loadingId, treeData, value], + ); + + useEffect(() => { + if (isEmpty(treeData)) { + loadData?.({ id: '', parentId: '', title: '' }); + } + }, [loadData, treeData]); + + return ( + + +
+ {selectedTitle || ( + {t('common.pleaseSelect')} + )} + +
+
+ +
    {renderNodes()}
+
+
+ ); +} diff --git a/web/src/components/ui/single-tree-select.tsx b/web/src/components/ui/single-tree-select.tsx deleted file mode 100644 index 87acf6959..000000000 --- a/web/src/components/ui/single-tree-select.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ChevronDown, X } from 'lucide-react'; -import React, { ReactNode, useState } from 'react'; - -export type TreeNode = { - id: number; - label: ReactNode; - children?: TreeNode[]; -}; - -type SingleSelectTreeDropdownProps = { - allowDelete?: boolean; - treeData: TreeNode[]; -}; - -const SingleTreeSelect: React.FC = ({ - allowDelete = false, - treeData, -}) => { - const [selectedOption, setSelectedOption] = useState(null); - - const handleSelect = (option: TreeNode) => { - setSelectedOption(option); - }; - - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation(); - console.log( - 'Delete button clicked. Current selected option:', - selectedOption, - ); - setSelectedOption(null); - console.log('After deletion, selected option:', selectedOption); - }; - - const renderTree = (nodes: TreeNode[]) => { - return nodes.map((node) => ( -
- handleSelect(node)} - className={`flex items-center ${ - selectedOption?.id === node.id ? 'bg-gray-500' : '' - }`} - > - {node.label} - {node.children && ( - - )} - - {node.children && renderTree(node.children)} -
- )); - }; - - return ( -
- - - - )} - - ) : ( - 'Select an option' - )} - - - - - {renderTree(treeData)} - - -
- ); -}; - -export default SingleTreeSelect; diff --git a/web/src/pages/files/index.tsx b/web/src/pages/files/index.tsx index 81bd5ae28..316dcba03 100644 --- a/web/src/pages/files/index.tsx +++ b/web/src/pages/files/index.tsx @@ -126,7 +126,6 @@ export default function Files() { onOk={onFolderCreateOk} > )} - {moveFileVisible && ( ) { +export function MoveDialog({ hideModal, onOk }: IModalProps) { const { t } = useTranslation(); - const treeData: TreeNode[] = [ - { - id: 1, - label: 'Node 1', - children: [ - { id: 11, label: 'Node 1.1' }, - { id: 12, label: 'Node 1.2' }, - ], + const { fetchList } = useFetchPureFileList(); + + const [treeValue, setTreeValue] = useState(''); + + const [treeData, setTreeData] = useState([]); + + const onLoadData = useCallback( + async ({ id }: TreeNodeType) => { + const ret = await fetchList(id as string); + if (ret.code === 0) { + setTreeData((tree) => { + return tree.concat( + ret.data.files + .filter((x: IFile) => x.type === 'folder') + .map((x: IFile) => ({ + id: x.id, + parentId: x.parent_id, + title: x.name, + isLeaf: + typeof x.has_child_folder === 'boolean' + ? !x.has_child_folder + : false, + })), + ); + }); + } }, - { - id: 2, - label: 'Node 2', - children: [ - { - id: 21, - label: 'Node 2.1', - children: [ - { id: 211, label: 'Node 2.1.1' }, - { id: 212, label: 'Node 2.1.2' }, - ], - }, - ], - }, - ]; + [fetchList], + ); + + const handleSubmit = useCallback(() => { + onOk?.(treeValue); + }, [onOk, treeValue]); return ( @@ -45,10 +61,21 @@ export function MoveDialog({ hideModal }: IModalProps) { {t('common.move')}
- +
- +