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)
This commit is contained in:
balibabu 2025-04-28 14:58:33 +08:00 committed by GitHub
parent 1a5608d0f8
commit af393b0003
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 213 additions and 125 deletions

View File

@ -149,7 +149,7 @@ export function FilterPopover({
value,
onChange,
filters,
}: PropsWithChildren & CheckboxFormMultipleProps) {
}: PropsWithChildren & Omit<CheckboxFormMultipleProps, 'setOpen'>) {
const [open, setOpen] = useState(false);
return (

View File

@ -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) => {

View File

@ -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>
);
}

View File

@ -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;

View File

@ -126,7 +126,6 @@ export default function Files() {
onOk={onFolderCreateOk}
></CreateFolderDialog>
)}
{moveFileVisible && (
<MoveDialog
hideModal={hideMoveFileModal}

View File

@ -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' },
],
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,
})),
);
});
}
},
{
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 (
<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>