mirror of
https://git.mirrors.martin98.com/https://github.com/infiniflow/ragflow.git
synced 2025-08-12 04:29:10 +08:00
### 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:
parent
1a5608d0f8
commit
af393b0003
@ -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) => {
|
||||
|
157
web/src/components/ui/async-tree-select.tsx
Normal file
157
web/src/components/ui/async-tree-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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' },
|
||||
],
|
||||
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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user