### 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
| @@ -149,7 +149,7 @@ export function FilterPopover({ | |||
| value, | |||
| onChange, | |||
| filters, | |||
| }: PropsWithChildren & CheckboxFormMultipleProps) { | |||
| }: PropsWithChildren & Omit<CheckboxFormMultipleProps, 'setOpen'>) { | |||
| const [open, setOpen] = useState(false); | |||
| return ( | |||
| @@ -38,7 +38,7 @@ export default function ListFilterBar({ | |||
| value, | |||
| onChange, | |||
| filters, | |||
| }: PropsWithChildren<IProps & CheckboxFormMultipleProps>) { | |||
| }: PropsWithChildren<IProps & Omit<CheckboxFormMultipleProps, 'setOpen'>>) { | |||
| const filterCount = useMemo(() => { | |||
| return typeof value === 'object' && value !== null | |||
| ? Object.values(value).reduce((pre, cur) => { | |||
| @@ -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<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> | |||
| ); | |||
| } | |||
| @@ -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<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; | |||
| @@ -126,7 +126,6 @@ export default function Files() { | |||
| onOk={onFolderCreateOk} | |||
| ></CreateFolderDialog> | |||
| )} | |||
| {moveFileVisible && ( | |||
| <MoveDialog | |||
| hideModal={hideMoveFileModal} | |||
| @@ -1,3 +1,7 @@ | |||
| import { | |||
| AsyncTreeSelect, | |||
| TreeNodeType, | |||
| } from '@/components/ui/async-tree-select'; | |||
| import { Button } from '@/components/ui/button'; | |||
| import { | |||
| Dialog, | |||
| @@ -6,37 +10,49 @@ import { | |||
| DialogHeader, | |||
| DialogTitle, | |||
| } 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 { IFile } from '@/interfaces/database/file-manager'; | |||
| import { isEmpty } from 'lodash'; | |||
| import { useCallback, useState } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| export function MoveDialog({ hideModal }: IModalProps<any>) { | |||
| export function MoveDialog({ hideModal, onOk }: IModalProps<any>) { | |||
| 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 ( | |||
| <Dialog open onOpenChange={hideModal}> | |||
| @@ -45,10 +61,21 @@ export function MoveDialog({ hideModal }: IModalProps<any>) { | |||
| <DialogTitle>{t('common.move')}</DialogTitle> | |||
| </DialogHeader> | |||
| <div> | |||
| <SingleTreeSelect treeData={treeData}></SingleTreeSelect> | |||
| <AsyncTreeSelect | |||
| treeData={treeData} | |||
| value={treeValue} | |||
| onChange={setTreeValue} | |||
| loadData={onLoadData} | |||
| ></AsyncTreeSelect> | |||
| </div> | |||
| <DialogFooter> | |||
| <Button type="submit">Save changes</Button> | |||
| <Button | |||
| type="submit" | |||
| onClick={handleSubmit} | |||
| disabled={isEmpty(treeValue)} | |||
| > | |||
| {t('common.save')} | |||
| </Button> | |||
| </DialogFooter> | |||
| </DialogContent> | |||
| </Dialog> | |||