saved searches with confirmation dialogs

This commit is contained in:
Josh Hawkins 2024-09-18 10:32:41 -05:00
parent 176dc0cf3a
commit eb6d2c8983
5 changed files with 388 additions and 161 deletions

View File

@ -0,0 +1,41 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
type DeleteSearchDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
searchName: string;
};
export function DeleteSearchDialog({
isOpen,
onClose,
onConfirm,
searchName,
}: DeleteSearchDialogProps) {
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the saved search "{searchName}".
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -1,12 +1,19 @@
import { useState, useRef, useEffect, useMemo, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { import {
LuX, LuX,
LuFilter, LuFilter,
LuImage, LuImage,
LuChevronDown, LuChevronDown,
LuChevronUp, LuChevronUp,
LuSave,
LuTrash2,
} from "react-icons/lu"; } from "react-icons/lu";
import { FilterType, SearchFilter, SearchSource } from "@/types/search"; import {
FilterType,
SavedSearchQuery,
SearchFilter,
SearchSource,
} from "@/types/search";
import useSuggestions from "@/hooks/use-suggestions"; import useSuggestions from "@/hooks/use-suggestions";
import { import {
Command, Command,
@ -18,6 +25,9 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { usePersistence } from "@/hooks/use-persistence";
import { SaveSearchDialog } from "./SaveSearchDialog";
import { DeleteSearchDialog } from "./DeleteSearchDialog";
const convertMMDDYYToTimestamp = (dateString: string): number => { const convertMMDDYYToTimestamp = (dateString: string): number => {
const match = dateString.match(/^(\d{2})(\d{2})(\d{2})$/); const match = dateString.match(/^(\d{2})(\d{2})(\d{2})$/);
@ -28,6 +38,14 @@ const convertMMDDYYToTimestamp = (dateString: string): number => {
return date.getTime(); return date.getTime();
}; };
const emptyObject = Object.freeze([
{
name: "",
search: "",
filter: undefined,
},
]) as SavedSearchQuery[];
type InputWithTagsProps = { type InputWithTagsProps = {
filters: SearchFilter; filters: SearchFilter;
setFilters: (filter: SearchFilter) => void; setFilters: (filter: SearchFilter) => void;
@ -56,11 +74,64 @@ export default function InputWithTags({
// TODO: search history from browser storage // TODO: search history from browser storage
const searchHistory = useMemo( const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
() => ["previous search 1", "previous search 2"], SavedSearchQuery[]
[], >("frigate-search-history", emptyObject);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [searchToDelete, setSearchToDelete] = useState<string | null>(null);
const handleSetSearchHistory = useCallback(() => {
setIsSaveDialogOpen(true);
}, []);
const handleSaveSearch = useCallback(
(name: string) => {
if (searchHistoryLoaded) {
setSearchHistory([
...(searchHistory ?? []),
{
name: name,
search: search,
filter: filters,
},
]);
}
},
[search, filters, searchHistory, setSearchHistory, searchHistoryLoaded],
); );
const handleLoadSavedSearch = useCallback(
(name: string) => {
if (searchHistoryLoaded) {
const savedSearchEntry = searchHistory?.find(
(entry) => entry.name === name,
);
if (savedSearchEntry) {
setFilters(savedSearchEntry.filter!);
setSearch(savedSearchEntry.search);
}
}
},
[searchHistory, searchHistoryLoaded, setFilters, setSearch],
);
const handleDeleteSearch = useCallback((name: string) => {
setSearchToDelete(name);
setIsDeleteDialogOpen(true);
}, []);
const confirmDeleteSearch = useCallback(() => {
if (searchToDelete && searchHistory) {
setSearchHistory(
searchHistory.filter((item) => item.name !== searchToDelete) ?? [],
);
setSearchToDelete(null);
setIsDeleteDialogOpen(false);
}
}, [searchToDelete, searchHistory, setSearchHistory]);
// suggestions // suggestions
const { suggestions, updateSuggestions } = useSuggestions( const { suggestions, updateSuggestions } = useSuggestions(
@ -342,175 +413,226 @@ export default function InputWithTags({
}, [search]); }, [search]);
return ( return (
<Command shouldFilter={false} ref={commandRef} className="rounded-md"> <>
<div className="relative"> <Command shouldFilter={false} ref={commandRef} className="rounded-md">
<CommandInput <div className="relative">
ref={inputRef} <CommandInput
value={inputValue} ref={inputRef}
onValueChange={handleInputChange} value={inputValue}
onFocus={handleInputFocus} onValueChange={handleInputChange}
onBlur={handleInputBlur} onFocus={handleInputFocus}
onKeyDown={handleInputKeyDown} onBlur={handleInputBlur}
className="text-md h-10 pr-24" onKeyDown={handleInputKeyDown}
placeholder="Search..." className="text-md h-10 pr-24"
/> placeholder="Search..."
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5"> />
{(search || Object.keys(filters).length > 0) && ( <div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
<Tooltip> {(search || Object.keys(filters).length > 0) && (
<TooltipTrigger> <Tooltip>
<LuX <TooltipTrigger>
className="size-4 cursor-pointer text-secondary-foreground" <LuX
onClick={handleClearInput} className="size-4 cursor-pointer text-secondary-foreground"
/> onClick={handleClearInput}
</TooltipTrigger> />
<TooltipPortal> </TooltipTrigger>
<TooltipContent>Clear search</TooltipContent> <TooltipPortal>
</TooltipPortal> <TooltipContent>Clear search</TooltipContent>
</Tooltip> </TooltipPortal>
)} </Tooltip>
)}
{(search || Object.keys(filters).length > 0) && (
<Tooltip>
<TooltipTrigger>
<LuSave
className="size-4 cursor-pointer text-secondary-foreground"
onClick={handleSetSearchHistory}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Save search</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
{isSimilaritySearch && (
<Tooltip>
<TooltipTrigger className="cursor-default">
<LuImage
aria-label="Similarity search active"
className="size-4 text-selected"
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Similarity search active</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
{isSimilaritySearch && (
<Tooltip> <Tooltip>
<TooltipTrigger className="cursor-default"> <TooltipTrigger className="cursor-default">
<LuImage <LuFilter
aria-label="Similarity search active" aria-label="Filters active"
className="size-4 text-selected" className={cn(
"size-4",
Object.keys(filters).length > 0
? "text-selected"
: "text-secondary-foreground",
)}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent>Similarity search active</TooltipContent> <TooltipContent>
Filters{" "}
{Object.keys(filters).length > 0 ? "active" : "inactive"}
</TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>
)}
<Tooltip> {inputFocused ? (
<TooltipTrigger className="cursor-default"> <LuChevronUp
<LuFilter onClick={() => setInputFocused(false)}
aria-label="Filters active" className="size-4 cursor-pointer text-secondary-foreground"
className={cn(
"size-4",
Object.keys(filters).length > 0
? "text-selected"
: "text-secondary-foreground",
)}
/> />
</TooltipTrigger> ) : (
<TooltipPortal> <LuChevronDown
<TooltipContent> onClick={() => setInputFocused(true)}
Filters{" "} className="size-4 cursor-pointer text-secondary-foreground"
{Object.keys(filters).length > 0 ? "active" : "inactive"} />
</TooltipContent> )}
</TooltipPortal> </div>
</Tooltip>
{inputFocused ? (
<LuChevronUp
onClick={() => setInputFocused(false)}
className="size-4 cursor-pointer text-secondary-foreground"
/>
) : (
<LuChevronDown
onClick={() => setInputFocused(true)}
className="size-4 cursor-pointer text-secondary-foreground"
/>
)}
</div> </div>
</div>
<CommandList <CommandList
className={cn( className={cn(
"scrollbar-container border-t duration-200 animate-in fade-in", "scrollbar-container border-t duration-200 animate-in fade-in",
inputFocused ? "visible" : "hidden", inputFocused ? "visible" : "hidden",
)} )}
> >
{(Object.keys(filters).length > 0 || isSimilaritySearch) && ( {(Object.keys(filters).length > 0 || isSimilaritySearch) && (
<CommandGroup heading="Active Filters"> <CommandGroup heading="Active Filters">
<div className="my-2 flex flex-wrap gap-2 px-2"> <div className="my-2 flex flex-wrap gap-2 px-2">
{isSimilaritySearch && ( {isSimilaritySearch && (
<span className="inline-flex items-center whitespace-nowrap rounded-full bg-blue-100 px-2 py-0.5 text-sm text-blue-800"> <span className="inline-flex items-center whitespace-nowrap rounded-full bg-blue-100 px-2 py-0.5 text-sm text-blue-800">
Similarity Search Similarity Search
<button
onClick={handleClearInput}
className="ml-1 focus:outline-none"
aria-label="Clear similarity search"
>
<LuX className="h-3 w-3" />
</button>
</span>
)}
{Object.entries(filters).map(([filterType, filterValues]) =>
Array.isArray(filterValues) ? (
filterValues.map((value, index) => (
<span
key={`${filterType}-${index}`}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
>
{filterType}:{value}
<button
onClick={() =>
removeFilter(filterType as FilterType, value)
}
className="ml-1 focus:outline-none"
aria-label={`Remove ${filterType}:${value} filter`}
>
<LuX className="h-3 w-3" />
</button>
</span>
))
) : (
<span
key={filterType}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
>
{filterType}:
{filterType === "before" || filterType === "after"
? new Date(filterValues as number).toLocaleDateString()
: filterValues}
<button <button
onClick={() => onClick={handleClearInput}
removeFilter(
filterType as FilterType,
filterValues as string | number,
)
}
className="ml-1 focus:outline-none" className="ml-1 focus:outline-none"
aria-label={`Remove ${filterType}:${filterValues} filter`} aria-label="Clear similarity search"
> >
<LuX className="h-3 w-3" /> <LuX className="h-3 w-3" />
</button> </button>
</span> </span>
), )}
)} {Object.entries(filters).map(([filterType, filterValues]) =>
</div> Array.isArray(filterValues) ? (
filterValues.map((value, index) => (
<span
key={`${filterType}-${index}`}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
>
{filterType}:{value}
<button
onClick={() =>
removeFilter(filterType as FilterType, value)
}
className="ml-1 focus:outline-none"
aria-label={`Remove ${filterType}:${value} filter`}
>
<LuX className="h-3 w-3" />
</button>
</span>
))
) : (
<span
key={filterType}
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800"
>
{filterType}:
{filterType === "before" || filterType === "after"
? new Date(filterValues as number).toLocaleDateString()
: filterValues}
<button
onClick={() =>
removeFilter(
filterType as FilterType,
filterValues as string | number,
)
}
className="ml-1 focus:outline-none"
aria-label={`Remove ${filterType}:${filterValues} filter`}
>
<LuX className="h-3 w-3" />
</button>
</span>
),
)}
</div>
</CommandGroup>
)}
{!currentFilterType &&
!inputValue &&
(searchHistory?.length ?? 0) > 0 && (
<CommandGroup heading="Previous Searches">
{searchHistory?.map((suggestion, index) => (
<CommandItem
key={index}
className="flex cursor-pointer items-center justify-between"
onSelect={() => handleLoadSavedSearch(suggestion.name)}
>
<span>{suggestion.name}</span>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSearch(suggestion.name);
}}
className="focus:outline-none"
>
<LuTrash2 className="h-4 w-4 text-secondary-foreground" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Delete saved search</TooltipContent>
</TooltipPortal>
</Tooltip>
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup
heading={currentFilterType ? "Filter Values" : "Filters"}
>
{filterSuggestions(suggestions)
.filter(
(item) =>
!searchHistory?.some((history) => history.name === item),
)
.map((suggestion, index) => (
<CommandItem
key={index + (searchHistory?.length ?? 0)}
className="cursor-pointer"
onSelect={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</CommandItem>
))}
</CommandGroup> </CommandGroup>
)} </CommandList>
{!currentFilterType && !inputValue && searchHistory.length > 0 && ( </Command>
<CommandGroup heading="Previous Searches"> <SaveSearchDialog
{searchHistory.map((suggestion, index) => ( isOpen={isSaveDialogOpen}
<CommandItem onClose={() => setIsSaveDialogOpen(false)}
key={index} onSave={handleSaveSearch}
className="cursor-pointer" />
onSelect={() => handleSuggestionClick(suggestion)} <DeleteSearchDialog
> isOpen={isDeleteDialogOpen}
{suggestion} onClose={() => setIsDeleteDialogOpen(false)}
</CommandItem> onConfirm={confirmDeleteSearch}
))} searchName={searchToDelete || ""}
</CommandGroup> />
)} </>
<CommandGroup heading={currentFilterType ? "Filter Values" : "Filters"}>
{filterSuggestions(suggestions)
.filter((item) => !searchHistory.includes(item))
.map((suggestion, index) => (
<CommandItem
key={index + searchHistory.length}
className="cursor-pointer"
onSelect={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
); );
} }

View File

@ -0,0 +1,58 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
type SaveSearchDialogProps = {
isOpen: boolean;
onClose: () => void;
onSave: (name: string) => void;
};
export function SaveSearchDialog({
isOpen,
onClose,
onSave,
}: SaveSearchDialogProps) {
const [searchName, setSearchName] = useState("");
const handleSave = () => {
if (searchName.trim()) {
onSave(searchName.trim());
setSearchName("");
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Search</DialogTitle>
<DialogDescription className="sr-only">
Provide a name for this saved search.
</DialogDescription>
</DialogHeader>
<Input
value={searchName}
onChange={(e) => setSearchName(e.target.value)}
placeholder="Enter a name for your search"
/>
<DialogFooter>
<Button onClick={onClose} variant="select">
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,18 +1,18 @@
import { FilterType, SearchFilter } from "@/types/search"; import { FilterType, SavedSearchQuery, SearchFilter } from "@/types/search";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
// Custom hook for managing suggestions // Custom hook for managing suggestions
export type UseSuggestionsType = ( export type UseSuggestionsType = (
filters: SearchFilter, filters: SearchFilter,
allSuggestions: { [K in keyof SearchFilter]: string[] }, allSuggestions: { [K in keyof SearchFilter]: string[] },
searchHistory: string[], searchHistory: SavedSearchQuery[],
) => ReturnType<typeof useSuggestions>; ) => ReturnType<typeof useSuggestions>;
// Define and export the useSuggestions hook // Define and export the useSuggestions hook
export default function useSuggestions( export default function useSuggestions(
filters: SearchFilter, filters: SearchFilter,
allSuggestions: { [K in keyof SearchFilter]: string[] }, allSuggestions: { [K in keyof SearchFilter]: string[] },
searchHistory: string[], searchHistory?: SavedSearchQuery[],
) { ) {
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<string[]>([]);
@ -46,7 +46,7 @@ export default function useSuggestions(
}, },
); );
setSuggestions([ setSuggestions([
...searchHistory, ...(searchHistory?.map((search) => search.name) ?? []),
...availableFilters, ...availableFilters,
"before", "before",
"after", "after",

View File

@ -56,3 +56,9 @@ export type SearchQueryParams = {
export type SearchQuery = [string, SearchQueryParams] | null; export type SearchQuery = [string, SearchQueryParams] | null;
export type FilterType = keyof SearchFilter; export type FilterType = keyof SearchFilter;
export type SavedSearchQuery = {
name: string;
search: string;
filter: SearchFilter | undefined;
};