mirror of
https://git.mirrors.martin98.com/https://github.com/infiniflow/ragflow.git
synced 2025-08-12 04:19:01 +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,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
filters,
|
filters,
|
||||||
}: PropsWithChildren & CheckboxFormMultipleProps) {
|
}: PropsWithChildren & Omit<CheckboxFormMultipleProps, 'setOpen'>) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -38,7 +38,7 @@ export default function ListFilterBar({
|
|||||||
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) => {
|
||||||
|
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}
|
onOk={onFolderCreateOk}
|
||||||
></CreateFolderDialog>
|
></CreateFolderDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{moveFileVisible && (
|
{moveFileVisible && (
|
||||||
<MoveDialog
|
<MoveDialog
|
||||||
hideModal={hideMoveFileModal}
|
hideModal={hideMoveFileModal}
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
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,
|
||||||
@ -6,37 +10,49 @@ import {
|
|||||||
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[] = [
|
const { fetchList } = useFetchPureFileList();
|
||||||
{
|
|
||||||
id: 1,
|
const [treeValue, setTreeValue] = useState<number | string>('');
|
||||||
label: 'Node 1',
|
|
||||||
children: [
|
const [treeData, setTreeData] = useState([]);
|
||||||
{ id: 11, label: 'Node 1.1' },
|
|
||||||
{ id: 12, label: 'Node 1.2' },
|
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],
|
||||||
id: 2,
|
);
|
||||||
label: 'Node 2',
|
|
||||||
children: [
|
const handleSubmit = useCallback(() => {
|
||||||
{
|
onOk?.(treeValue);
|
||||||
id: 21,
|
}, [onOk, treeValue]);
|
||||||
label: 'Node 2.1',
|
|
||||||
children: [
|
|
||||||
{ id: 211, label: 'Node 2.1.1' },
|
|
||||||
{ id: 212, label: 'Node 2.1.2' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={hideModal}>
|
<Dialog open onOpenChange={hideModal}>
|
||||||
@ -45,10 +61,21 @@ export function MoveDialog({ hideModal }: IModalProps<any>) {
|
|||||||
<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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user