### What problem does this PR solve? Feat: Add AsyncTreeSelect component #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.19.0
| value, | value, | ||||
| onChange, | onChange, | ||||
| filters, | filters, | ||||
| }: PropsWithChildren & CheckboxFormMultipleProps) { | |||||
| }: PropsWithChildren & Omit<CheckboxFormMultipleProps, 'setOpen'>) { | |||||
| const [open, setOpen] = useState(false); | const [open, setOpen] = useState(false); | ||||
| return ( | return ( |
| value, | value, | ||||
| onChange, | onChange, | ||||
| filters, | filters, | ||||
| }: PropsWithChildren<IProps & CheckboxFormMultipleProps>) { | |||||
| }: PropsWithChildren<IProps & Omit<CheckboxFormMultipleProps, 'setOpen'>>) { | |||||
| const filterCount = useMemo(() => { | const filterCount = useMemo(() => { | ||||
| return typeof value === 'object' && value !== null | return typeof value === 'object' && value !== null | ||||
| ? Object.values(value).reduce((pre, cur) => { | ? Object.values(value).reduce((pre, cur) => { |
| 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<any>; | |||||
| }; | |||||
| export function AsyncTreeSelect({ | |||||
| treeData, | |||||
| value, | |||||
| loadData, | |||||
| onChange, | |||||
| }: AsyncTreeSelectProps) { | |||||
| const [open, setOpen] = useState(false); | |||||
| const { t } = useTranslation(); | |||||
| const [expandedKeys, setExpandedKeys] = useState<TreeId[]>([]); | |||||
| const [loadingId, setLoadingId] = useState<TreeId>(''); | |||||
| 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<HTMLLIElement>) => { | |||||
| e.stopPropagation(); | |||||
| onChange?.(id); | |||||
| setOpen(false); | |||||
| }, | |||||
| [onChange], | |||||
| ); | |||||
| const handleArrowClick = useCallback( | |||||
| (node: TreeNodeType) => async (e: React.MouseEvent<HTMLButtonElement>) => { | |||||
| 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 ( | |||||
| <ul className={cn('pl-2', { hidden: !isExpanded(parentId) })}> | |||||
| {currentLevelList.map((x) => ( | |||||
| <li | |||||
| key={x.id} | |||||
| onClick={handleNodeClick(x.id)} | |||||
| className="cursor-pointer hover:bg-slate-50 " | |||||
| > | |||||
| <div className={cn('flex justify-between items-center')}> | |||||
| <span | |||||
| className={cn({ 'bg-cyan-50': value === x.id }, 'flex-1')} | |||||
| > | |||||
| {x.title} | |||||
| </span> | |||||
| {x.isLeaf || ( | |||||
| <Button | |||||
| variant={'ghost'} | |||||
| className="size-7" | |||||
| onClick={handleArrowClick(x)} | |||||
| disabled={loadingId === x.id} | |||||
| > | |||||
| {loadingId === x.id ? ( | |||||
| <Loader2 className="animate-spin" /> | |||||
| ) : isExpanded(x.id) ? ( | |||||
| <ChevronDown /> | |||||
| ) : ( | |||||
| <ChevronRight /> | |||||
| )} | |||||
| </Button> | |||||
| )} | |||||
| </div> | |||||
| {renderNodes(x.id)} | |||||
| </li> | |||||
| ))} | |||||
| </ul> | |||||
| ); | |||||
| }, | |||||
| [handleArrowClick, handleNodeClick, isExpanded, loadingId, treeData, value], | |||||
| ); | |||||
| useEffect(() => { | |||||
| if (isEmpty(treeData)) { | |||||
| loadData?.({ id: '', parentId: '', title: '' }); | |||||
| } | |||||
| }, [loadData, treeData]); | |||||
| return ( | |||||
| <Popover open={open} onOpenChange={setOpen}> | |||||
| <PopoverTrigger asChild> | |||||
| <div className="flex justify-between border px-2 py-1.5 rounded-md gap-2 items-center w-full"> | |||||
| {selectedTitle || ( | |||||
| <span className="text-slate-400">{t('common.pleaseSelect')}</span> | |||||
| )} | |||||
| <ChevronDown className="size-5" /> | |||||
| </div> | |||||
| </PopoverTrigger> | |||||
| <PopoverContent className="p-1"> | |||||
| <ul>{renderNodes()}</ul> | |||||
| </PopoverContent> | |||||
| </Popover> | |||||
| ); | |||||
| } |
| 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<SingleSelectTreeDropdownProps> = ({ | |||||
| allowDelete = false, | |||||
| treeData, | |||||
| }) => { | |||||
| const [selectedOption, setSelectedOption] = useState<TreeNode | null>(null); | |||||
| const handleSelect = (option: TreeNode) => { | |||||
| setSelectedOption(option); | |||||
| }; | |||||
| const handleDelete = (e: React.MouseEvent<HTMLButtonElement>) => { | |||||
| 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) => ( | |||||
| <div key={node.id} className="pl-4"> | |||||
| <DropdownMenuItem | |||||
| onClick={() => handleSelect(node)} | |||||
| className={`flex items-center ${ | |||||
| selectedOption?.id === node.id ? 'bg-gray-500' : '' | |||||
| }`} | |||||
| > | |||||
| <span>{node.label}</span> | |||||
| {node.children && ( | |||||
| <ChevronDown className="ml-2 h-4 w-4 text-gray-400" /> | |||||
| )} | |||||
| </DropdownMenuItem> | |||||
| {node.children && renderTree(node.children)} | |||||
| </div> | |||||
| )); | |||||
| }; | |||||
| return ( | |||||
| <div className="relative"> | |||||
| <DropdownMenu> | |||||
| <DropdownMenuTrigger asChild> | |||||
| <button | |||||
| type="button" | |||||
| className="flex items-center justify-between space-x-1 p-2 border rounded-md focus:outline-none w-full" | |||||
| > | |||||
| {selectedOption ? ( | |||||
| <> | |||||
| <span>{selectedOption.label}</span> | |||||
| {allowDelete && ( | |||||
| <button | |||||
| type="button" | |||||
| className="ml-2 text-gray-500 hover:text-red-500 focus:outline-none" | |||||
| onClick={handleDelete} | |||||
| > | |||||
| <X className="h-4 w-4" /> | |||||
| </button> | |||||
| )} | |||||
| </> | |||||
| ) : ( | |||||
| 'Select an option' | |||||
| )} | |||||
| <ChevronDown className="h-4 w-4" /> | |||||
| </button> | |||||
| </DropdownMenuTrigger> | |||||
| <DropdownMenuContent className=" mt-2 w-56 origin-top-right rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> | |||||
| {renderTree(treeData)} | |||||
| </DropdownMenuContent> | |||||
| </DropdownMenu> | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| export default SingleTreeSelect; |
| onOk={onFolderCreateOk} | onOk={onFolderCreateOk} | ||||
| ></CreateFolderDialog> | ></CreateFolderDialog> | ||||
| )} | )} | ||||
| {moveFileVisible && ( | {moveFileVisible && ( | ||||
| <MoveDialog | <MoveDialog | ||||
| hideModal={hideMoveFileModal} | hideModal={hideMoveFileModal} |
| import { | |||||
| AsyncTreeSelect, | |||||
| TreeNodeType, | |||||
| } from '@/components/ui/async-tree-select'; | |||||
| import { Button } from '@/components/ui/button'; | import { Button } from '@/components/ui/button'; | ||||
| import { | import { | ||||
| Dialog, | Dialog, | ||||
| DialogHeader, | DialogHeader, | ||||
| DialogTitle, | DialogTitle, | ||||
| } from '@/components/ui/dialog'; | } from '@/components/ui/dialog'; | ||||
| import SingleTreeSelect, { TreeNode } from '@/components/ui/single-tree-select'; | |||||
| import { useFetchPureFileList } from '@/hooks/file-manager-hooks'; | |||||
| import { IModalProps } from '@/interfaces/common'; | import { IModalProps } from '@/interfaces/common'; | ||||
| import { IFile } from '@/interfaces/database/file-manager'; | |||||
| import { isEmpty } from 'lodash'; | |||||
| import { useCallback, useState } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| export function MoveDialog({ hideModal }: IModalProps<any>) { | |||||
| export function MoveDialog({ hideModal, onOk }: IModalProps<any>) { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const treeData: TreeNode[] = [ | |||||
| { | |||||
| id: 1, | |||||
| label: 'Node 1', | |||||
| children: [ | |||||
| { id: 11, label: 'Node 1.1' }, | |||||
| { id: 12, label: 'Node 1.2' }, | |||||
| ], | |||||
| }, | |||||
| { | |||||
| 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' }, | |||||
| ], | |||||
| }, | |||||
| ], | |||||
| const { fetchList } = useFetchPureFileList(); | |||||
| const [treeValue, setTreeValue] = useState<number | string>(''); | |||||
| 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, | |||||
| })), | |||||
| ); | |||||
| }); | |||||
| } | |||||
| }, | }, | ||||
| ]; | |||||
| [fetchList], | |||||
| ); | |||||
| const handleSubmit = useCallback(() => { | |||||
| onOk?.(treeValue); | |||||
| }, [onOk, treeValue]); | |||||
| return ( | return ( | ||||
| <Dialog open onOpenChange={hideModal}> | <Dialog open onOpenChange={hideModal}> | ||||
| <DialogTitle>{t('common.move')}</DialogTitle> | <DialogTitle>{t('common.move')}</DialogTitle> | ||||
| </DialogHeader> | </DialogHeader> | ||||
| <div> | <div> | ||||
| <SingleTreeSelect treeData={treeData}></SingleTreeSelect> | |||||
| <AsyncTreeSelect | |||||
| treeData={treeData} | |||||
| value={treeValue} | |||||
| onChange={setTreeValue} | |||||
| loadData={onLoadData} | |||||
| ></AsyncTreeSelect> | |||||
| </div> | </div> | ||||
| <DialogFooter> | <DialogFooter> | ||||
| <Button type="submit">Save changes</Button> | |||||
| <Button | |||||
| type="submit" | |||||
| onClick={handleSubmit} | |||||
| disabled={isEmpty(treeValue)} | |||||
| > | |||||
| {t('common.save')} | |||||
| </Button> | |||||
| </DialogFooter> | </DialogFooter> | ||||
| </DialogContent> | </DialogContent> | ||||
| </Dialog> | </Dialog> |