diff --git a/web/src/components/ui/async-tree-select.tsx b/web/src/components/ui/async-tree-select.tsx index 036113569c..3cc10707c3 100644 --- a/web/src/components/ui/async-tree-select.tsx +++ b/web/src/components/ui/async-tree-select.tsx @@ -9,6 +9,7 @@ import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from './button'; +import { SearchInput } from './input'; type TreeId = number | string; @@ -26,6 +27,13 @@ type AsyncTreeSelectProps = { loadData?(node: TreeNodeType): Promise; }; +function getNodeText(node: ReactNode): string { + if (typeof node === 'string') return node; + if (typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(getNodeText).join(''); + return ''; +} + export function AsyncTreeSelect({ treeData, value, @@ -33,6 +41,7 @@ export function AsyncTreeSelect({ onChange, }: AsyncTreeSelectProps) { const [open, setOpen] = useState(false); + const [searchText, setSearchText] = useState(''); const { t } = useTranslation(); const [expandedKeys, setExpandedKeys] = useState([]); @@ -42,14 +51,59 @@ export function AsyncTreeSelect({ return treeData.find((x) => x.id === value)?.title; }, [treeData, value]); + const visibleNodeIds = useMemo(() => { + const trimmed = searchText.trim().toLowerCase(); + if (!trimmed) { + return new Set(treeData.map((n) => n.id)); + } + + const matchedIds = new Set(); + treeData.forEach((node) => { + if (getNodeText(node.title).toLowerCase().includes(trimmed)) { + matchedIds.add(node.id); + } + }); + + const visibleIds = new Set(matchedIds); + + const addAncestors = (nodeId: TreeId) => { + const node = treeData.find((n) => n.id === nodeId); + if (node && node.parentId) { + visibleIds.add(node.parentId); + addAncestors(node.parentId); + } + }; + matchedIds.forEach((id) => addAncestors(id)); + + const queue = Array.from(matchedIds); + let i = 0; + while (i < queue.length) { + const currentId = queue[i++]; + treeData.forEach((node) => { + if (node.parentId === currentId) { + visibleIds.add(node.id); + queue.push(node.id); + } + }); + } + + return visibleIds; + }, [treeData, searchText]); + const isExpanded = useCallback( (id: TreeId | undefined) => { if (id === undefined) { return true; } + if (searchText.trim()) { + const hasVisibleChild = treeData.some( + (n) => n.parentId === id && visibleNodeIds.has(n.id), + ); + if (hasVisibleChild) return true; + } return expandedKeys.indexOf(id) !== -1; }, - [expandedKeys], + [expandedKeys, searchText, treeData, visibleNodeIds], ); const handleNodeClick = useCallback( @@ -57,6 +111,7 @@ export function AsyncTreeSelect({ e.stopPropagation(); onChange?.(id); setOpen(false); + setSearchText(''); }, [onChange], ); @@ -85,11 +140,24 @@ export function AsyncTreeSelect({ [isExpanded, loadData, treeData], ); + const handleOpenChange = useCallback((open: boolean) => { + setOpen(open); + if (!open) { + setSearchText(''); + } + }, []); + const renderNodes = useCallback( (parentId?: TreeId) => { const currentLevelList = parentId - ? treeData.filter((x) => x.parentId === parentId) - : treeData.filter((x) => treeData.every((y) => x.parentId !== y.id)); + ? treeData.filter( + (x) => x.parentId === parentId && visibleNodeIds.has(x.id), + ) + : treeData.filter( + (x) => + treeData.every((y) => x.parentId !== y.id) && + visibleNodeIds.has(x.id), + ); if (currentLevelList.length === 0) return null; @@ -99,15 +167,15 @@ export function AsyncTreeSelect({
  • - {x.title} + {x.title} {x.isLeaf || (