This commit is contained in:
Josh Hawkins 2024-09-17 12:09:39 -05:00
parent b51af669d4
commit b75ea9cb45
3 changed files with 135 additions and 61 deletions

View File

@ -12,17 +12,27 @@ import { SearchFilter, SearchSource } from "@/types/search";
type FilterType = keyof SearchFilter; type FilterType = keyof SearchFilter;
const convertMMDDYYToTimestamp = (dateString: string): number => {
const match = dateString.match(/^(\d{2})(\d{2})(\d{2})$/);
if (!match) return 0;
const [, month, day, year] = match;
const date = new Date(`20${year}-${month}-${day}T00:00:00Z`);
return date.getTime();
};
// Custom hook for managing suggestions // Custom hook for managing suggestions
const useSuggestions = ( const useSuggestions = (
filters: SearchFilter, filters: SearchFilter,
allSuggestions: { [K in keyof SearchFilter]: string[] }, allSuggestions: { [K in keyof SearchFilter]: string[] },
searchHistory: string[],
) => { ) => {
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<string[]>([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const updateSuggestions = useCallback( const updateSuggestions = useCallback(
(value: string, currentFilterType: FilterType | null) => { (value: string, currentFilterType: FilterType | null) => {
if (currentFilterType) { if (currentFilterType && currentFilterType in allSuggestions) {
const filterValue = value.split(":").pop() || ""; const filterValue = value.split(":").pop() || "";
const currentFilterValues = filters[currentFilterType] || []; const currentFilterValues = filters[currentFilterType] || [];
setSuggestions( setSuggestions(
@ -49,10 +59,15 @@ const useSuggestions = (
return false; return false;
}, },
); );
setSuggestions([...availableFilters]); setSuggestions([
...searchHistory,
...availableFilters,
"before",
"after",
]);
} }
}, },
[filters, allSuggestions], [filters, allSuggestions, searchHistory],
); );
return { return {
@ -66,6 +81,8 @@ const useSuggestions = (
type InputWithTagsProps = { type InputWithTagsProps = {
filters: SearchFilter; filters: SearchFilter;
setFilters: (filter: SearchFilter) => void; setFilters: (filter: SearchFilter) => void;
search: string;
setSearch: (search: string) => void;
allSuggestions: { allSuggestions: {
[K in keyof SearchFilter]: string[]; [K in keyof SearchFilter]: string[];
}; };
@ -74,9 +91,11 @@ type InputWithTagsProps = {
export default function InputWithTags({ export default function InputWithTags({
filters, filters,
setFilters, setFilters,
search,
setSearch,
allSuggestions, allSuggestions,
}: InputWithTagsProps) { }: InputWithTagsProps) {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState(search || "");
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>( const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
@ -97,7 +116,7 @@ export default function InputWithTags({
selectedSuggestionIndex, selectedSuggestionIndex,
setSelectedSuggestionIndex, setSelectedSuggestionIndex,
updateSuggestions, updateSuggestions,
} = useSuggestions(filters, allSuggestions); } = useSuggestions(filters, allSuggestions, searchHistory);
const resetSuggestions = useCallback( const resetSuggestions = useCallback(
(value: string) => { (value: string) => {
@ -108,7 +127,7 @@ export default function InputWithTags({
); );
const removeFilter = useCallback( const removeFilter = useCallback(
(filterType: keyof SearchFilter, filterValue: string | number) => { (filterType: FilterType, filterValue: string | number) => {
const newFilters = { ...filters }; const newFilters = { ...filters };
if (Array.isArray(newFilters[filterType])) { if (Array.isArray(newFilters[filterType])) {
(newFilters[filterType] as string[]) = ( (newFilters[filterType] as string[]) = (
@ -154,13 +173,21 @@ export default function InputWithTags({
const createFilter = useCallback( const createFilter = useCallback(
(type: FilterType, value: string) => { (type: FilterType, value: string) => {
if (allSuggestions[type]?.includes(value)) { if (
allSuggestions[type as keyof SearchFilter]?.includes(value) ||
type === "before" ||
type === "after"
) {
const newFilters = { ...filters }; const newFilters = { ...filters };
let timestamp = 0;
switch (type) { switch (type) {
case "before": case "before":
case "after": case "after":
newFilters[type] = parseFloat(value); timestamp = convertMMDDYYToTimestamp(value);
if (timestamp > 0) {
newFilters[type] = timestamp;
}
break; break;
case "search_type": case "search_type":
if (!newFilters.search_type) newFilters.search_type = []; if (!newFilters.search_type) newFilters.search_type = [];
@ -197,13 +224,43 @@ export default function InputWithTags({
[filters, setFilters, allSuggestions], [filters, setFilters, allSuggestions],
); );
const handleFilterCreation = useCallback(
(filterType: FilterType, filterValue: string) => {
const trimmedValue = filterValue.trim();
if (
allSuggestions[filterType as keyof SearchFilter]?.includes(
trimmedValue,
) ||
((filterType === "before" || filterType === "after") &&
trimmedValue.match(/^\d{6}$/))
) {
createFilter(filterType, trimmedValue);
setInputValue((prev) => {
const regex = new RegExp(
`${filterType}:${filterValue.trim()}[,\\s]*`,
);
const newValue = prev.replace(regex, "").trim();
return newValue.endsWith(",")
? newValue.slice(0, -1).trim()
: newValue;
});
setCurrentFilterType(null);
}
},
[allSuggestions, createFilter],
);
const handleInputChange = useCallback( const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
setInputValue(value); setInputValue(value);
const words = value.split(" "); const words = value.split(/[\s,]+/);
const currentWord = words[words.length - 1]; const lastNonEmptyWordIndex = words
.map((word) => word.trim())
.lastIndexOf(words.filter((word) => word.trim() !== "").pop() || "");
const currentWord = words[lastNonEmptyWordIndex];
const isLastCharSpaceOrComma = value.endsWith(" ") || value.endsWith(",");
// Check if the current word is a filter type // Check if the current word is a filter type
const filterTypeMatch = currentWord.match(/^(\w+):(.*)$/); const filterTypeMatch = currentWord.match(/^(\w+):(.*)$/);
@ -215,19 +272,25 @@ export default function InputWithTags({
]; ];
// Check if filter type is valid // Check if filter type is valid
if (filterType in allSuggestions) { if (
filterType in allSuggestions ||
filterType === "before" ||
filterType === "after"
) {
setCurrentFilterType(filterType); setCurrentFilterType(filterType);
updateSuggestions(filterValue, filterType);
// If filter value is valid, apply the filter if (filterType === "before" || filterType === "after") {
if (allSuggestions[filterType]?.includes(filterValue.trim())) { // For before and after, we don't need to update suggestions
createFilter(filterType, filterValue.trim()); if (filterValue.match(/^\d{6}$/)) {
handleFilterCreation(filterType, filterValue);
}
} else {
updateSuggestions(filterValue, filterType);
// Remove the applied filter from the input // Check if the last character is a space or comma
setInputValue((prev) => if (isLastCharSpaceOrComma) {
prev.replace(`${filterType}:${filterValue}`, "").trim(), handleFilterCreation(filterType, filterValue);
); }
setCurrentFilterType(null);
} }
} else { } else {
resetSuggestions(value); resetSuggestions(value);
@ -244,7 +307,7 @@ export default function InputWithTags({
updateSuggestions, updateSuggestions,
resetSuggestions, resetSuggestions,
allSuggestions, allSuggestions,
createFilter, handleFilterCreation,
setSelectedSuggestionIndex, setSelectedSuggestionIndex,
], ],
); );
@ -253,13 +316,20 @@ export default function InputWithTags({
setShowSuggestions(true); setShowSuggestions(true);
setShowFilters(true); setShowFilters(true);
updateSuggestions(inputValue, currentFilterType); updateSuggestions(inputValue, currentFilterType);
}, [inputValue, currentFilterType, updateSuggestions]); setSelectedSuggestionIndex(-1);
}, [
inputValue,
currentFilterType,
updateSuggestions,
setSelectedSuggestionIndex,
]);
const handleClearInput = useCallback(() => { const handleClearInput = useCallback(() => {
setInputValue(""); setInputValue("");
setFilters({}); setFilters({});
setCurrentFilterType(null); setCurrentFilterType(null);
updateSuggestions("", null); updateSuggestions("", null);
setShowFilters(false);
setShowSuggestions(false); setShowSuggestions(false);
}, [setFilters, updateSuggestions]); }, [setFilters, updateSuggestions]);
@ -268,9 +338,10 @@ export default function InputWithTags({
if (!containerRef.current?.contains(document.activeElement)) { if (!containerRef.current?.contains(document.activeElement)) {
setShowSuggestions(false); setShowSuggestions(false);
setShowFilters(false); setShowFilters(false);
setSelectedSuggestionIndex(-1);
} }
}, 0); }, 0);
}, []); }, [setSelectedSuggestionIndex]);
const toggleFilters = useCallback(() => { const toggleFilters = useCallback(() => {
setShowFilters((prev) => !prev); setShowFilters((prev) => !prev);
@ -313,10 +384,13 @@ export default function InputWithTags({
handleSuggestionClick(suggestions[selectedSuggestionIndex]); handleSuggestionClick(suggestions[selectedSuggestionIndex]);
} else if (e.key === "Enter" && currentFilterType) { } else if (e.key === "Enter" && currentFilterType) {
e.preventDefault(); e.preventDefault();
const currentWord = inputValue.split(" ").pop() || ""; const currentWord = inputValue.split(/[\s,]+/).pop() || "";
if (allSuggestions[currentFilterType]?.includes(currentWord)) { handleFilterCreation(currentFilterType, currentWord);
createFilter(currentFilterType, currentWord); } else if (e.key === "Enter" && !currentFilterType) {
} e.preventDefault();
setSearch(inputValue);
inputRef.current?.blur();
handleInputBlur();
} }
}, },
[ [
@ -325,12 +399,17 @@ export default function InputWithTags({
handleSuggestionClick, handleSuggestionClick,
currentFilterType, currentFilterType,
inputValue, inputValue,
createFilter, handleFilterCreation,
setSelectedSuggestionIndex, setSelectedSuggestionIndex,
allSuggestions, setSearch,
handleInputBlur,
], ],
); );
useEffect(() => {
setInputValue(search || "");
}, [search]);
return ( return (
<div ref={containerRef}> <div ref={containerRef}>
<div className="relative my-2"> <div className="relative my-2">
@ -378,7 +457,7 @@ export default function InputWithTags({
</div> </div>
</div> </div>
{(showFilters || showSuggestions) && ( {(showFilters || showSuggestions) && (
<div className="absolute left-0 top-11 z-[100] w-full rounded-md border border-t-0 border-gray-200 bg-background p-2 text-primary shadow-md"> <div className="absolute left-0 top-12 z-[100] w-full rounded-md border border-t-0 border-secondary-foreground bg-background p-2 text-primary shadow-md">
{showFilters && Object.keys(filters).length > 0 && ( {showFilters && Object.keys(filters).length > 0 && (
<div ref={filterRef} className="my-2 flex flex-wrap gap-2"> <div ref={filterRef} className="my-2 flex flex-wrap gap-2">
{Object.entries(filters).map(([filterType, filterValues]) => {Object.entries(filters).map(([filterType, filterValues]) =>
@ -405,12 +484,15 @@ export default function InputWithTags({
key={filterType} key={filterType}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800" className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
> >
{filterType}:{filterValues} {filterType}:
{filterType === "before" || filterType === "after"
? new Date(filterValues as number).toLocaleDateString()
: filterValues}
<button <button
onClick={() => onClick={() =>
removeFilter( removeFilter(
filterType as FilterType, filterType as FilterType,
filterValues as string, filterValues as string | number,
) )
} }
className="ml-1 focus:outline-none" className="ml-1 focus:outline-none"
@ -432,46 +514,48 @@ export default function InputWithTags({
> >
{!currentFilterType && searchHistory.length > 0 && ( {!currentFilterType && searchHistory.length > 0 && (
<> <>
<h3 className="px-2 py-1 text-xs font-semibold text-gray-500"> <h3 className="px-2 py-1 text-xs font-semibold text-secondary-foreground">
Previous Searches Previous Searches
</h3> </h3>
{searchHistory.map((suggestion, index) => ( {searchHistory.map((suggestion, index) => (
<button <button
key={index} key={index}
className={`w-full rounded px-2 py-1 text-left text-sm ${ className={`w-full rounded px-2 py-1 text-left text-sm text-primary ${
index === selectedSuggestionIndex index === selectedSuggestionIndex
? "bg-blue-100" ? "bg-accent text-accent-foreground"
: "hover:bg-gray-100" : "hover:bg-accent hover:text-accent-foreground"
}`} }`}
onClick={() => handleSuggestionClick(suggestion)} onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setSelectedSuggestionIndex(index)}
role="option" role="option"
aria-selected={index === selectedSuggestionIndex} aria-selected={index === selectedSuggestionIndex}
> >
{suggestion} {suggestion}
</button> </button>
))} ))}
<div className="my-1 border-t border-gray-200" /> <div className="my-1 border-t border-secondary-foreground" />
</> </>
)} )}
<h3 className="px-2 py-1 text-xs font-semibold text-gray-500"> <h3 className="px-2 py-1 text-xs font-semibold text-secondary-foreground">
{currentFilterType ? "Filter Values" : "Filters"} {currentFilterType ? "Filter Values" : "Filters"}
</h3> </h3>
{suggestions {suggestions
// .filter((item) => !searchHistory.includes(item)) .filter((item) => !searchHistory.includes(item))
.map((suggestion, index) => ( .map((suggestion, index) => (
<button <button
key={index + (currentFilterType ? 0 : searchHistory.length)} key={index + searchHistory.length}
className={`w-full rounded px-2 py-1 text-left text-sm ${ className={`w-full rounded px-2 py-1 text-left text-sm text-primary ${
index + (currentFilterType ? 0 : searchHistory.length) === index + searchHistory.length === selectedSuggestionIndex
selectedSuggestionIndex ? "bg-accent text-accent-foreground"
? "bg-blue-100" : "hover:bg-accent hover:text-accent-foreground"
: "hover:bg-gray-100"
}`} }`}
onClick={() => handleSuggestionClick(suggestion)} onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() =>
setSelectedSuggestionIndex(index + searchHistory.length)
}
role="option" role="option"
aria-selected={ aria-selected={
index + (currentFilterType ? 0 : searchHistory.length) === index + searchHistory.length === selectedSuggestionIndex
selectedSuggestionIndex
} }
> >
{suggestion} {suggestion}

View File

@ -20,7 +20,6 @@ export default function Explore() {
// search field handler // search field handler
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -50,18 +49,7 @@ export default function Explore() {
}); });
useEffect(() => { useEffect(() => {
if (searchTimeout) { setSearchTerm(search);
clearTimeout(searchTimeout);
}
setSearchTimeout(
setTimeout(() => {
setSearchTimeout(undefined);
setSearchTerm(search);
}, 750),
);
// we only want to update the searchTerm when search changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]); }, [search]);
const searchQuery: SearchQuery = useMemo(() => { const searchQuery: SearchQuery = useMemo(() => {

View File

@ -219,13 +219,15 @@ export default function SearchView({
<InputWithTags <InputWithTags
filters={searchFilter ?? {}} filters={searchFilter ?? {}}
setFilters={setSearchFilter} setFilters={setSearchFilter}
search={search}
setSearch={setSearch}
allSuggestions={{ allSuggestions={{
cameras: ["ptzcam", "doorbellcam"], cameras: ["ptzcam", "doorbellcam"],
labels: ["person", "car"], labels: ["person", "car"],
}} }}
/> />
</div> </div>
{search && ( {search && false && (
<LuXCircle <LuXCircle
className="absolute right-2 top-1/2 h-5 w-5 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-primary" className="absolute right-2 top-1/2 h-5 w-5 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-primary"
onClick={() => setSearch("")} onClick={() => setSearch("")}