yangdx
commited on
Commit
·
467ab64
1
Parent(s):
9f83e85
Fix reslectiton problem by efactor graph search input box handling logic
Browse files
lightrag_webui/src/components/ui/AsyncSearch.tsx
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import React, { useState, useEffect, useCallback } from 'react'
|
2 |
import { Loader2 } from 'lucide-react'
|
3 |
import { useDebounce } from '@/hooks/useDebounce'
|
4 |
|
@@ -81,100 +81,97 @@ export function AsyncSearch<T>({
|
|
81 |
const [options, setOptions] = useState<T[]>([])
|
82 |
const [loading, setLoading] = useState(false)
|
83 |
const [error, setError] = useState<string | null>(null)
|
84 |
-
const [selectedValue, setSelectedValue] = useState(value)
|
85 |
-
const [focusedValue, setFocusedValue] = useState<string | null>(null)
|
86 |
const [searchTerm, setSearchTerm] = useState('')
|
87 |
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
88 |
-
const
|
89 |
|
90 |
useEffect(() => {
|
91 |
setMounted(true)
|
92 |
-
|
93 |
-
}, [value])
|
94 |
|
95 |
-
//
|
96 |
useEffect(() => {
|
97 |
-
const
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
setOptions(data)
|
105 |
-
} catch (err) {
|
106 |
-
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
107 |
-
} finally {
|
108 |
-
setLoading(false)
|
109 |
}
|
110 |
}
|
111 |
|
112 |
-
|
113 |
-
|
|
|
114 |
}
|
115 |
-
}, [
|
116 |
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
} finally {
|
128 |
-
setLoading(false)
|
129 |
-
}
|
130 |
}
|
|
|
|
|
|
|
|
|
|
|
131 |
|
132 |
-
if (
|
133 |
-
fetchOptions()
|
134 |
-
} else if (!preload) {
|
135 |
-
fetchOptions()
|
136 |
-
} else if (preload) {
|
137 |
if (debouncedSearchTerm) {
|
138 |
-
setOptions(
|
139 |
-
|
140 |
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
141 |
)
|
142 |
)
|
143 |
-
} else {
|
144 |
-
setOptions(originalOptions)
|
145 |
}
|
|
|
|
|
146 |
}
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
(
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
156 |
setOpen(false)
|
157 |
-
}
|
158 |
-
|
159 |
-
)
|
160 |
|
161 |
-
const handleFocus = useCallback(
|
162 |
-
(
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
|
|
|
|
|
|
170 |
|
171 |
return (
|
172 |
<div
|
|
|
173 |
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
|
174 |
-
|
175 |
-
setOpen(true)
|
176 |
-
}}
|
177 |
-
onBlur={() => setOpen(false)}
|
178 |
>
|
179 |
<Command shouldFilter={false} className="bg-transparent">
|
180 |
<div>
|
@@ -182,12 +179,13 @@ export function AsyncSearch<T>({
|
|
182 |
placeholder={placeholder}
|
183 |
value={searchTerm}
|
184 |
className="max-h-8"
|
|
|
185 |
onValueChange={(value) => {
|
186 |
setSearchTerm(value)
|
187 |
-
if (
|
188 |
}}
|
189 |
/>
|
190 |
-
{loading &&
|
191 |
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
192 |
<Loader2 className="h-4 w-4 animate-spin" />
|
193 |
</div>
|
@@ -209,8 +207,8 @@ export function AsyncSearch<T>({
|
|
209 |
key={getOptionValue(option) + `${idx}`}
|
210 |
value={getOptionValue(option)}
|
211 |
onSelect={handleSelect}
|
212 |
-
|
213 |
-
className="truncate"
|
214 |
>
|
215 |
{renderOption(option)}
|
216 |
</CommandItem>
|
|
|
1 |
+
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
2 |
import { Loader2 } from 'lucide-react'
|
3 |
import { useDebounce } from '@/hooks/useDebounce'
|
4 |
|
|
|
81 |
const [options, setOptions] = useState<T[]>([])
|
82 |
const [loading, setLoading] = useState(false)
|
83 |
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
84 |
const [searchTerm, setSearchTerm] = useState('')
|
85 |
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
86 |
+
const containerRef = useRef<HTMLDivElement>(null)
|
87 |
|
88 |
useEffect(() => {
|
89 |
setMounted(true)
|
90 |
+
}, [])
|
|
|
91 |
|
92 |
+
// Handle clicks outside of the component
|
93 |
useEffect(() => {
|
94 |
+
const handleClickOutside = (event: MouseEvent) => {
|
95 |
+
if (
|
96 |
+
containerRef.current &&
|
97 |
+
!containerRef.current.contains(event.target as Node) &&
|
98 |
+
open
|
99 |
+
) {
|
100 |
+
setOpen(false)
|
|
|
|
|
|
|
|
|
|
|
101 |
}
|
102 |
}
|
103 |
|
104 |
+
document.addEventListener('mousedown', handleClickOutside)
|
105 |
+
return () => {
|
106 |
+
document.removeEventListener('mousedown', handleClickOutside)
|
107 |
}
|
108 |
+
}, [open])
|
109 |
|
110 |
+
const fetchOptions = useCallback(async (query: string) => {
|
111 |
+
try {
|
112 |
+
setLoading(true)
|
113 |
+
setError(null)
|
114 |
+
const data = await fetcher(query)
|
115 |
+
setOptions(data)
|
116 |
+
} catch (err) {
|
117 |
+
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
118 |
+
} finally {
|
119 |
+
setLoading(false)
|
|
|
|
|
|
|
120 |
}
|
121 |
+
}, [fetcher])
|
122 |
+
|
123 |
+
// Load options when search term changes
|
124 |
+
useEffect(() => {
|
125 |
+
if (!mounted) return
|
126 |
|
127 |
+
if (preload) {
|
|
|
|
|
|
|
|
|
128 |
if (debouncedSearchTerm) {
|
129 |
+
setOptions((prev) =>
|
130 |
+
prev.filter((option) =>
|
131 |
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
132 |
)
|
133 |
)
|
|
|
|
|
134 |
}
|
135 |
+
} else {
|
136 |
+
fetchOptions(debouncedSearchTerm)
|
137 |
}
|
138 |
+
}, [mounted, debouncedSearchTerm, preload, filterFn, fetchOptions])
|
139 |
+
|
140 |
+
// Load initial value
|
141 |
+
useEffect(() => {
|
142 |
+
if (!mounted || !value) return
|
143 |
+
fetchOptions(value)
|
144 |
+
}, [mounted, value, fetchOptions])
|
145 |
+
|
146 |
+
const handleSelect = useCallback((currentValue: string) => {
|
147 |
+
onChange(currentValue)
|
148 |
+
requestAnimationFrame(() => {
|
149 |
+
// Blur the input to ensure focus event triggers on next click
|
150 |
+
const input = document.activeElement as HTMLElement
|
151 |
+
input?.blur()
|
152 |
+
// Close the dropdown
|
153 |
setOpen(false)
|
154 |
+
})
|
155 |
+
}, [onChange])
|
|
|
156 |
|
157 |
+
const handleFocus = useCallback(() => {
|
158 |
+
setOpen(true)
|
159 |
+
// Use current search term to fetch options
|
160 |
+
fetchOptions(searchTerm)
|
161 |
+
}, [searchTerm, fetchOptions])
|
162 |
+
|
163 |
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
164 |
+
const target = e.target as HTMLElement
|
165 |
+
if (target.closest('.cmd-item')) {
|
166 |
+
e.preventDefault()
|
167 |
+
}
|
168 |
+
}, [])
|
169 |
|
170 |
return (
|
171 |
<div
|
172 |
+
ref={containerRef}
|
173 |
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
|
174 |
+
onMouseDown={handleMouseDown}
|
|
|
|
|
|
|
175 |
>
|
176 |
<Command shouldFilter={false} className="bg-transparent">
|
177 |
<div>
|
|
|
179 |
placeholder={placeholder}
|
180 |
value={searchTerm}
|
181 |
className="max-h-8"
|
182 |
+
onFocus={handleFocus}
|
183 |
onValueChange={(value) => {
|
184 |
setSearchTerm(value)
|
185 |
+
if (!open) setOpen(true)
|
186 |
}}
|
187 |
/>
|
188 |
+
{loading && (
|
189 |
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
190 |
<Loader2 className="h-4 w-4 animate-spin" />
|
191 |
</div>
|
|
|
207 |
key={getOptionValue(option) + `${idx}`}
|
208 |
value={getOptionValue(option)}
|
209 |
onSelect={handleSelect}
|
210 |
+
onMouseMove={() => onFocus(getOptionValue(option))}
|
211 |
+
className="truncate cmd-item"
|
212 |
>
|
213 |
{renderOption(option)}
|
214 |
</CommandItem>
|