This is an automated email from the ASF dual-hosted git repository. shuai pushed a commit to branch dev in repository https://gitbox.apache.org/repos/asf/answer.git
commit 9b0a86f4218327a284624a2a794b9d99c86301c7 Author: hgaol <dhan...@hotmail.com> AuthorDate: Tue Apr 22 09:21:02 2025 +0800 feat: enhance MergeTagModal with debounced tag search and replace with searchTag --- ui/src/components/Modal/MergeTagModal.tsx | 250 +++++++++++++++--------------- 1 file changed, 129 insertions(+), 121 deletions(-) diff --git a/ui/src/components/Modal/MergeTagModal.tsx b/ui/src/components/Modal/MergeTagModal.tsx index c35755c2..68e32699 100644 --- a/ui/src/components/Modal/MergeTagModal.tsx +++ b/ui/src/components/Modal/MergeTagModal.tsx @@ -17,15 +17,19 @@ * under the License. */ -import { FC, useState, useEffect, useCallback, useRef } from 'react'; +import { FC, useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Form, Dropdown } from 'react-bootstrap'; +import debounce from 'lodash/debounce'; + import { TagInfo } from '@/common/interface'; -import request from '@/utils/request'; +import { queryTags } from '@/services'; import Modal from './Modal'; +const DEBOUNCE_DELAY = 300; + interface Props { visible: boolean; sourceTag: TagInfo; @@ -59,122 +63,121 @@ const MergeTagModal: FC<Props> = ({ const [hasSearched, setHasSearched] = useState(false); const inputRef = useRef<HTMLInputElement>(null); - const searchTags = async (search: string) => { + const filteredTags = useMemo(() => { + return tags.filter((tag) => tag.slug_name !== sourceTag.slug_name); + }, [tags, sourceTag.slug_name]); + + const searchTags = useCallback(async (search: string) => { try { - const res = await request.get<SearchTagResp[]>( - '/answer/api/v1/question/tags', - { - params: { tag: search }, - }, - ); - // Filter out the source tag from results - const filteredTags = res.filter( - (tag) => tag.slug_name !== sourceTag.slug_name, - ); - setTags(filteredTags); + const res = await queryTags(search); + setTags(res || []); setHasSearched(true); - if (filteredTags.length > 0 && isFocused) { - setDropdownVisible(true); - } } catch (error) { console.error('Failed to search tags:', error); setTags([]); setHasSearched(true); } - }; - - // Debounced search function - const debouncedSearch = useCallback( - (() => { - let timeout: number | undefined; - return (search: string) => { - if (timeout) { - clearTimeout(timeout); - } - timeout = window.setTimeout(() => { - searchTags(search); - }, 1000); - }; - })(), - [isFocused], - ); + }, []); - useEffect(() => { - if (visible) { - searchTags(''); - setSearchValue(''); - setTargetTag(null); - setCurrentIndex(0); - setDropdownVisible(false); - setIsFocused(false); - setHasSearched(false); - } - }, [visible]); + const debouncedSearch = useMemo( + () => debounce(searchTags, DEBOUNCE_DELAY), + [searchTags], + ); - const handleConfirm = () => { + const handleConfirm = useCallback(() => { if (!targetTag) return; onConfirm(sourceTag.tag_id, targetTag.tag_id); - }; + }, [targetTag, sourceTag.tag_id, onConfirm]); - const handleSelect = (tag: SearchTagResp) => { + const handleSelect = useCallback((tag: SearchTagResp) => { setTargetTag(tag); setDropdownVisible(false); setSearchValue(tag.display_name); setIsFocused(false); inputRef.current?.blur(); - }; - - const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { - const { value } = e.target; - setSearchValue(value); - setHasSearched(false); - if (value) { - debouncedSearch(value); - } - }; + }, []); - const handleFocus = () => { + const handleSearch = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const { value } = e.target; + setSearchValue(value); + setHasSearched(false); + if (value) { + debouncedSearch(value); + } else { + searchTags(''); + } + }, + [debouncedSearch, searchTags], + ); + + const handleFocus = useCallback(() => { setIsFocused(true); setDropdownVisible(true); - }; + }, []); - const handleBlur = () => { - // Use setTimeout to allow click events on dropdown items to fire before closing + const handleBlur = useCallback(() => { setTimeout(() => { setIsFocused(false); if (!targetTag) { setDropdownVisible(false); } }, 200); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!tags.length) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setCurrentIndex((prev) => (prev < tags.length - 1 ? prev + 1 : prev)); - break; - case 'ArrowUp': - e.preventDefault(); - setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev)); - break; - case 'Enter': - e.preventDefault(); - if (currentIndex >= 0 && currentIndex < tags.length) { - handleSelect(tags[currentIndex]); - } - break; - case 'Escape': - e.preventDefault(); - inputRef.current?.blur(); - setDropdownVisible(false); - break; - default: - break; + }, [targetTag]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!filteredTags.length) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setCurrentIndex((prev) => + prev < filteredTags.length - 1 ? prev + 1 : prev, + ); + break; + case 'ArrowUp': + e.preventDefault(); + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev)); + break; + case 'Enter': + e.preventDefault(); + if (currentIndex >= 0 && currentIndex < filteredTags.length) { + handleSelect(filteredTags[currentIndex]); + } + break; + case 'Escape': + e.preventDefault(); + inputRef.current?.blur(); + setDropdownVisible(false); + break; + default: + break; + } + }, + [filteredTags, currentIndex, handleSelect], + ); + + useEffect(() => { + if (visible) { + searchTags(''); + setSearchValue(''); + setTargetTag(null); + setCurrentIndex(0); + setDropdownVisible(false); + setIsFocused(false); + setHasSearched(false); + } + return () => { + debouncedSearch.cancel(); + }; + }, [visible, searchTags, debouncedSearch]); + + useEffect(() => { + if (filteredTags.length > 0 && isFocused) { + setDropdownVisible(true); } - }; + }, [filteredTags, isFocused]); return ( <Modal @@ -197,37 +200,42 @@ const MergeTagModal: FC<Props> = ({ </Form.Group> <Form.Group> <Form.Label>{t('target_tag_title')}</Form.Label> - <Dropdown - show={dropdownVisible && (tags.length > 0 || Boolean(searchValue))} - onToggle={setDropdownVisible}> - <Form.Control - ref={inputRef} - type="text" - value={searchValue} - onChange={handleSearch} - onKeyDown={handleKeyDown} - onFocus={handleFocus} - onBlur={handleBlur} - autoComplete="off" - /> - {tags.length !== 0 && ( - <Dropdown.Menu className="w-100"> - {tags.map((tag, index) => ( - <Dropdown.Item - key={tag.slug_name} - active={index === currentIndex} - onClick={() => handleSelect(tag)}> - {tag.display_name} - </Dropdown.Item> - ))} - </Dropdown.Menu> - )} - {tags.length === 0 && searchValue && hasSearched && ( - <Dropdown.Menu className="w-100"> - <Dropdown.Item disabled>{t('no_results')}</Dropdown.Item> - </Dropdown.Menu> - )} - </Dropdown> + <div className="position-relative"> + <Dropdown + show={ + dropdownVisible && + (filteredTags.length > 0 || Boolean(searchValue)) + } + onToggle={setDropdownVisible}> + <Form.Control + ref={inputRef} + type="text" + value={searchValue} + onChange={handleSearch} + onKeyDown={handleKeyDown} + onFocus={handleFocus} + onBlur={handleBlur} + autoComplete="off" + /> + {filteredTags.length !== 0 && ( + <Dropdown.Menu className="w-100"> + {filteredTags.map((tag, index) => ( + <Dropdown.Item + key={tag.slug_name} + active={index === currentIndex} + onClick={() => handleSelect(tag)}> + {tag.display_name} + </Dropdown.Item> + ))} + </Dropdown.Menu> + )} + {filteredTags.length === 0 && searchValue && hasSearched && ( + <Dropdown.Menu className="w-100"> + <Dropdown.Item disabled>{t('no_results')}</Dropdown.Item> + </Dropdown.Menu> + )} + </Dropdown> + </div> <Form.Text className="text-muted"> {t('target_tag_description')} </Form.Text>