From b51af669d4633d2400eb1946ef9331e3f4c5ec66 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 17 Sep 2024 08:06:58 -0500 Subject: [PATCH] create input with tags component --- web/src/components/input/InputWithTags.tsx | 486 +++++++++++++++++++++ web/src/pages/Explore.tsx | 3 +- web/src/views/search/SearchView.tsx | 17 +- 3 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 web/src/components/input/InputWithTags.tsx diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx new file mode 100644 index 000000000..003030434 --- /dev/null +++ b/web/src/components/input/InputWithTags.tsx @@ -0,0 +1,486 @@ +import React, { + useState, + useRef, + useEffect, + useMemo, + useCallback, +} from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { LuX, LuFilter } from "react-icons/lu"; +import { SearchFilter, SearchSource } from "@/types/search"; + +type FilterType = keyof SearchFilter; + +// Custom hook for managing suggestions +const useSuggestions = ( + filters: SearchFilter, + allSuggestions: { [K in keyof SearchFilter]: string[] }, +) => { + const [suggestions, setSuggestions] = useState([]); + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); + + const updateSuggestions = useCallback( + (value: string, currentFilterType: FilterType | null) => { + if (currentFilterType) { + const filterValue = value.split(":").pop() || ""; + const currentFilterValues = filters[currentFilterType] || []; + setSuggestions( + allSuggestions[currentFilterType]?.filter( + (item) => + item.toLowerCase().startsWith(filterValue.toLowerCase()) && + !(currentFilterValues as (string | number)[]).includes(item), + ) ?? [], + ); + } else { + const availableFilters = Object.keys(allSuggestions).filter( + (filter) => { + const filterKey = filter as FilterType; + const filterValues = filters[filterKey]; + const suggestionValues = allSuggestions[filterKey]; + + if (!filterValues) return true; + if ( + Array.isArray(filterValues) && + Array.isArray(suggestionValues) + ) { + return filterValues.length < suggestionValues.length; + } + return false; + }, + ); + setSuggestions([...availableFilters]); + } + }, + [filters, allSuggestions], + ); + + return { + suggestions, + selectedSuggestionIndex, + setSelectedSuggestionIndex, + updateSuggestions, + }; +}; + +type InputWithTagsProps = { + filters: SearchFilter; + setFilters: (filter: SearchFilter) => void; + allSuggestions: { + [K in keyof SearchFilter]: string[]; + }; +}; + +export default function InputWithTags({ + filters, + setFilters, + allSuggestions, +}: InputWithTagsProps) { + const [inputValue, setInputValue] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); + const [showFilters, setShowFilters] = useState(false); + const [currentFilterType, setCurrentFilterType] = useState( + null, + ); + const inputRef = useRef(null); + const containerRef = useRef(null); + const suggestionRef = useRef(null); + const filterRef = useRef(null); + + const searchHistory = useMemo( + () => ["previous search 1", "previous search 2"], + [], + ); + + const { + suggestions, + selectedSuggestionIndex, + setSelectedSuggestionIndex, + updateSuggestions, + } = useSuggestions(filters, allSuggestions); + + const resetSuggestions = useCallback( + (value: string) => { + setCurrentFilterType(null); + updateSuggestions(value, null); + }, + [updateSuggestions], + ); + + const removeFilter = useCallback( + (filterType: keyof SearchFilter, filterValue: string | number) => { + const newFilters = { ...filters }; + if (Array.isArray(newFilters[filterType])) { + (newFilters[filterType] as string[]) = ( + newFilters[filterType] as string[] + ).filter((v) => v !== filterValue); + if ((newFilters[filterType] as string[]).length === 0) { + delete newFilters[filterType]; + } + } else if (filterType === "before" || filterType === "after") { + if (newFilters[filterType] === filterValue) { + delete newFilters[filterType]; + } + } else { + delete newFilters[filterType]; + } + setFilters(newFilters as SearchFilter); + }, + [filters, setFilters], + ); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + suggestionRef.current && + !suggestionRef.current.contains(event.target as Node) && + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setShowSuggestions(false); + setShowFilters(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + useEffect(() => { + updateSuggestions(inputValue, currentFilterType); + }, [currentFilterType, inputValue, updateSuggestions]); + + const createFilter = useCallback( + (type: FilterType, value: string) => { + if (allSuggestions[type]?.includes(value)) { + const newFilters = { ...filters }; + + switch (type) { + case "before": + case "after": + newFilters[type] = parseFloat(value); + break; + case "search_type": + if (!newFilters.search_type) newFilters.search_type = []; + if ( + !(newFilters.search_type as SearchSource[]).includes( + value as SearchSource, + ) + ) { + (newFilters.search_type as SearchSource[]).push( + value as SearchSource, + ); + } + break; + case "event_id": + newFilters.event_id = value; + break; + default: + // Handle array types (cameras, labels, subLabels, zones) + if (!newFilters[type]) newFilters[type] = []; + if (Array.isArray(newFilters[type])) { + if (!(newFilters[type] as string[]).includes(value)) { + (newFilters[type] as string[]).push(value); + } + } + break; + } + + setFilters(newFilters); + setInputValue((prev) => prev.replace(`${type}:${value}`, "").trim()); + setCurrentFilterType(null); + setShowSuggestions(false); + } + }, + [filters, setFilters, allSuggestions], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setInputValue(value); + + const words = value.split(" "); + const currentWord = words[words.length - 1]; + + // Check if the current word is a filter type + const filterTypeMatch = currentWord.match(/^(\w+):(.*)$/); + if (filterTypeMatch) { + const [_, filterType, filterValue] = filterTypeMatch as [ + string, + FilterType, + string, + ]; + + // Check if filter type is valid + if (filterType in allSuggestions) { + setCurrentFilterType(filterType); + updateSuggestions(filterValue, filterType); + + // If filter value is valid, apply the filter + if (allSuggestions[filterType]?.includes(filterValue.trim())) { + createFilter(filterType, filterValue.trim()); + + // Remove the applied filter from the input + setInputValue((prev) => + prev.replace(`${filterType}:${filterValue}`, "").trim(), + ); + setCurrentFilterType(null); + } + } else { + resetSuggestions(value); + } + } else { + resetSuggestions(value); + } + + // Reset suggestion state + setSelectedSuggestionIndex(-1); + setShowSuggestions(true); + }, + [ + updateSuggestions, + resetSuggestions, + allSuggestions, + createFilter, + setSelectedSuggestionIndex, + ], + ); + + const handleInputFocus = useCallback(() => { + setShowSuggestions(true); + setShowFilters(true); + updateSuggestions(inputValue, currentFilterType); + }, [inputValue, currentFilterType, updateSuggestions]); + + const handleClearInput = useCallback(() => { + setInputValue(""); + setFilters({}); + setCurrentFilterType(null); + updateSuggestions("", null); + setShowSuggestions(false); + }, [setFilters, updateSuggestions]); + + const handleInputBlur = useCallback(() => { + setTimeout(() => { + if (!containerRef.current?.contains(document.activeElement)) { + setShowSuggestions(false); + setShowFilters(false); + } + }, 0); + }, []); + + const toggleFilters = useCallback(() => { + setShowFilters((prev) => !prev); + }, []); + + const handleSuggestionClick = useCallback( + (suggestion: string) => { + if (currentFilterType) { + // Apply the selected suggestion to the current filter type + createFilter(currentFilterType, suggestion); + setInputValue((prev) => + prev.replace(`${currentFilterType}:`, "").trim(), + ); + } else if (suggestion in allSuggestions) { + // Set the suggestion as a new filter type + setCurrentFilterType(suggestion as FilterType); + setInputValue((prev) => `${prev}${suggestion}:`); + } else { + // Add the suggestion as a standalone word + setInputValue((prev) => `${prev}${suggestion} `); + } + + inputRef.current?.focus(); + }, + [createFilter, currentFilterType, allSuggestions], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedSuggestionIndex((prev) => (prev + 1) % suggestions.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedSuggestionIndex( + (prev) => (prev - 1 + suggestions.length) % suggestions.length, + ); + } else if (e.key === "Enter" && selectedSuggestionIndex !== -1) { + e.preventDefault(); + handleSuggestionClick(suggestions[selectedSuggestionIndex]); + } else if (e.key === "Enter" && currentFilterType) { + e.preventDefault(); + const currentWord = inputValue.split(" ").pop() || ""; + if (allSuggestions[currentFilterType]?.includes(currentWord)) { + createFilter(currentFilterType, currentWord); + } + } + }, + [ + suggestions, + selectedSuggestionIndex, + handleSuggestionClick, + currentFilterType, + inputValue, + createFilter, + setSelectedSuggestionIndex, + allSuggestions, + ], + ); + + return ( +
+
+ +
+ {Object.keys(filters).length > 0 && ( + + )} + {(inputValue || Object.keys(filters).length > 0) && ( + + )} +
+
+ {(showFilters || showSuggestions) && ( +
+ {showFilters && Object.keys(filters).length > 0 && ( +
+ {Object.entries(filters).map(([filterType, filterValues]) => + Array.isArray(filterValues) ? ( + filterValues.map((value, index) => ( + + {filterType}:{value} + + + )) + ) : ( + + {filterType}:{filterValues} + + + ), + )} +
+ )} + {showSuggestions && ( +
+ {!currentFilterType && searchHistory.length > 0 && ( + <> +

+ Previous Searches +

+ {searchHistory.map((suggestion, index) => ( + + ))} +
+ + )} +

+ {currentFilterType ? "Filter Values" : "Filters"} +

+ {suggestions + // .filter((item) => !searchHistory.includes(item)) + .map((suggestion, index) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 21ab01f71..9d078cb3a 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -148,7 +148,7 @@ export default function Explore() { const { data, size, setSize, isValidating } = useSWRInfinite( getKey, { - revalidateFirstPage: false, + revalidateFirstPage: true, revalidateAll: false, }, ); @@ -277,6 +277,7 @@ export default function Explore() { isLoading={(isLoadingInitialData || isLoadingMore) ?? true} setSearch={setSearch} setSimilaritySearch={(search) => setSearch(`similarity:${search.id}`)} + setSearchFilter={setSearchFilter} onUpdateFilter={setSearchFilter} onOpenSearch={onOpenSearch} loadMore={loadMore} diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index e031d211c..b578042ea 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -22,6 +22,7 @@ import useKeyboardListener, { KeyModifiers, } from "@/hooks/use-keyboard-listener"; import scrollIntoView from "scroll-into-view-if-needed"; +import InputWithTags from "@/components/input/InputWithTags"; type SearchViewProps = { search: string; @@ -31,6 +32,7 @@ type SearchViewProps = { isLoading: boolean; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; + setSearchFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void; onOpenSearch: (item: SearchResult) => void; loadMore: () => void; @@ -44,6 +46,7 @@ export default function SearchView({ isLoading, setSearch, setSimilaritySearch, + setSearchFilter, onUpdateFilter, loadMore, hasMore, @@ -206,12 +209,22 @@ export default function SearchView({ hasExistingSearch ? "lg:mr-3 lg:w-1/3" : "lg:ml-[25%] lg:w-1/2", )} > - setSearch(e.target.value)} - /> + /> */} +
+ +
{search && (