Explore sorting (#15342)

* backend

* add type and params

* radio group in ui

* ensure search_type is cleared on reset
This commit is contained in:
Josh Hawkins 2024-12-04 09:54:10 -06:00 committed by GitHub
parent a5a7cd3107
commit c0ba98e26f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 241 additions and 15 deletions

View File

@ -248,6 +248,8 @@ def events(params: EventsQueryParams = Depends()):
order_by = Event.start_time.asc() order_by = Event.start_time.asc()
elif sort == "date_desc": elif sort == "date_desc":
order_by = Event.start_time.desc() order_by = Event.start_time.desc()
else:
order_by = Event.start_time.desc()
else: else:
order_by = Event.start_time.desc() order_by = Event.start_time.desc()
@ -582,19 +584,17 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
processed_events.append(processed_event) processed_events.append(processed_event)
# Sort by search distance if search_results are available, otherwise by start_time as default if (sort is None or sort == "relevance") and search_results:
if search_results:
processed_events.sort(key=lambda x: x.get("search_distance", float("inf"))) processed_events.sort(key=lambda x: x.get("search_distance", float("inf")))
elif min_score is not None and max_score is not None and sort == "score_asc":
processed_events.sort(key=lambda x: x["score"])
elif min_score is not None and max_score is not None and sort == "score_desc":
processed_events.sort(key=lambda x: x["score"], reverse=True)
elif sort == "date_asc":
processed_events.sort(key=lambda x: x["start_time"])
else: else:
if sort == "score_asc": # "date_desc" default
processed_events.sort(key=lambda x: x["score"]) processed_events.sort(key=lambda x: x["start_time"], reverse=True)
elif sort == "score_desc":
processed_events.sort(key=lambda x: x["score"], reverse=True)
elif sort == "date_asc":
processed_events.sort(key=lambda x: x["start_time"])
else:
# "date_desc" default
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# Limit the number of events returned # Limit the number of events returned
processed_events = processed_events[:limit] processed_events = processed_events[:limit]

View File

@ -15,13 +15,15 @@ import {
SearchFilter, SearchFilter,
SearchFilters, SearchFilters,
SearchSource, SearchSource,
SearchSortType,
} from "@/types/search"; } from "@/types/search";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MdLabel } from "react-icons/md"; import { MdLabel, MdSort } from "react-icons/md";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog"; import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type SearchFilterGroupProps = { type SearchFilterGroupProps = {
className: string; className: string;
@ -107,6 +109,25 @@ export default function SearchFilterGroup({
[config, allLabels, allZones], [config, allLabels, allZones],
); );
const availableSortTypes = useMemo(() => {
const sortTypes = ["date_asc", "date_desc"];
if (filter?.min_score || filter?.max_score) {
sortTypes.push("score_desc", "score_asc");
}
if (filter?.event_id || filter?.query) {
sortTypes.push("relevance");
}
return sortTypes as SearchSortType[];
}, [filter]);
const defaultSortType = useMemo<SearchSortType>(() => {
if (filter?.query || filter?.event_id) {
return "relevance";
} else {
return "date_desc";
}
}, [filter]);
const groups = useMemo(() => { const groups = useMemo(() => {
if (!config) { if (!config) {
return []; return [];
@ -179,6 +200,16 @@ export default function SearchFilterGroup({
filterValues={filterValues} filterValues={filterValues}
onUpdateFilter={onUpdateFilter} onUpdateFilter={onUpdateFilter}
/> />
{filters.includes("sort") && Object.keys(filter ?? {}).length > 0 && (
<SortTypeButton
availableSortTypes={availableSortTypes ?? []}
defaultSortType={defaultSortType}
selectedSortType={filter?.sort}
updateSortType={(newSort) => {
onUpdateFilter({ ...filter, sort: newSort });
}}
/>
)}
</div> </div>
); );
} }
@ -362,3 +393,176 @@ export function GeneralFilterContent({
</> </>
); );
} }
type SortTypeButtonProps = {
availableSortTypes: SearchSortType[];
defaultSortType: SearchSortType;
selectedSortType: SearchSortType | undefined;
updateSortType: (sortType: SearchSortType | undefined) => void;
};
function SortTypeButton({
availableSortTypes,
defaultSortType,
selectedSortType,
updateSortType,
}: SortTypeButtonProps) {
const [open, setOpen] = useState(false);
const [currentSortType, setCurrentSortType] = useState<
SearchSortType | undefined
>(selectedSortType as SearchSortType);
// ui
useEffect(() => {
setCurrentSortType(selectedSortType);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSortType]);
const trigger = (
<Button
size="sm"
variant={
selectedSortType != defaultSortType && selectedSortType != undefined
? "select"
: "default"
}
className="flex items-center gap-2 capitalize"
aria-label="Labels"
>
<MdSort
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-primary"}`}
>
Sort
</div>
</Button>
);
const content = (
<SortTypeContent
availableSortTypes={availableSortTypes ?? []}
defaultSortType={defaultSortType}
selectedSortType={selectedSortType}
currentSortType={currentSortType}
setCurrentSortType={setCurrentSortType}
updateSortType={updateSortType}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName={
isDesktop
? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
: "max-h-[75dvh] overflow-hidden p-4"
}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentSortType(selectedSortType);
}
setOpen(open);
}}
/>
);
}
type SortTypeContentProps = {
availableSortTypes: SearchSortType[];
defaultSortType: SearchSortType;
selectedSortType: SearchSortType | undefined;
currentSortType: SearchSortType | undefined;
updateSortType: (sort_type: SearchSortType | undefined) => void;
setCurrentSortType: (sort_type: SearchSortType | undefined) => void;
onClose: () => void;
};
export function SortTypeContent({
availableSortTypes,
defaultSortType,
selectedSortType,
currentSortType,
updateSortType,
setCurrentSortType,
onClose,
}: SortTypeContentProps) {
const sortLabels = {
date_asc: "Date (Ascending)",
date_desc: "Date (Descending)",
score_asc: "Object Score (Ascending)",
score_desc: "Object Score (Descending)",
relevance: "Relevance",
};
return (
<>
<div className="overflow-x-hidden">
<div className="my-2.5 flex flex-col gap-2.5">
<RadioGroup
value={
Array.isArray(currentSortType)
? currentSortType?.[0]
: (currentSortType ?? defaultSortType)
}
defaultValue={defaultSortType}
onValueChange={(value) =>
setCurrentSortType(value as SearchSortType)
}
className="w-full space-y-1"
>
{availableSortTypes.map((value) => (
<div className="flex flex-row gap-2">
<RadioGroupItem
key={value}
value={value}
id={`sort-${value}`}
className={
value == (currentSortType ?? defaultSortType)
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<Label
htmlFor={`sort-${value}`}
className="flex cursor-pointer items-center space-x-2"
>
<span>{sortLabels[value]}</span>
</Label>
</div>
))}
</RadioGroup>
</div>
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label="Apply"
variant="select"
onClick={() => {
if (selectedSortType != currentSortType) {
updateSortType(currentSortType);
}
onClose();
}}
>
Apply
</Button>
<Button
aria-label="Reset"
onClick={() => {
setCurrentSortType(undefined);
updateSortType(undefined);
}}
>
Reset
</Button>
</div>
</>
);
}

View File

@ -18,6 +18,7 @@ import {
FilterType, FilterType,
SavedSearchQuery, SavedSearchQuery,
SearchFilter, SearchFilter,
SearchSortType,
SearchSource, SearchSource,
} from "@/types/search"; } from "@/types/search";
import useSuggestions from "@/hooks/use-suggestions"; import useSuggestions from "@/hooks/use-suggestions";
@ -323,6 +324,9 @@ export default function InputWithTags({
case "event_id": case "event_id":
newFilters.event_id = value; newFilters.event_id = value;
break; break;
case "sort":
newFilters.sort = value as SearchSortType;
break;
default: default:
// Handle array types (cameras, labels, subLabels, zones) // Handle array types (cameras, labels, subLabels, zones)
if (!newFilters[type]) newFilters[type] = []; if (!newFilters[type]) newFilters[type] = [];

View File

@ -175,7 +175,7 @@ export default function SearchFilterDialog({
time_range: undefined, time_range: undefined,
zones: undefined, zones: undefined,
sub_labels: undefined, sub_labels: undefined,
search_type: ["thumbnail", "description"], search_type: undefined,
min_score: undefined, min_score: undefined,
max_score: undefined, max_score: undefined,
has_snapshot: undefined, has_snapshot: undefined,

View File

@ -116,6 +116,7 @@ export default function Explore() {
is_submitted: searchSearchParams["is_submitted"], is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"], has_clip: searchSearchParams["has_clip"],
event_id: searchSearchParams["event_id"], event_id: searchSearchParams["event_id"],
sort: searchSearchParams["sort"],
limit: limit:
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined, Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
timezone, timezone,
@ -148,6 +149,7 @@ export default function Explore() {
is_submitted: searchSearchParams["is_submitted"], is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"], has_clip: searchSearchParams["has_clip"],
event_id: searchSearchParams["event_id"], event_id: searchSearchParams["event_id"],
sort: searchSearchParams["sort"],
timezone, timezone,
include_thumbnails: 0, include_thumbnails: 0,
}, },
@ -165,12 +167,17 @@ export default function Explore() {
const [url, params] = searchQuery; const [url, params] = searchQuery;
// If it's not the first page, use the last item's start_time as the 'before' parameter const isAscending = params.sort?.includes("date_asc");
if (pageIndex > 0 && previousPageData) { if (pageIndex > 0 && previousPageData) {
const lastDate = previousPageData[previousPageData.length - 1].start_time; const lastDate = previousPageData[previousPageData.length - 1].start_time;
return [ return [
url, url,
{ ...params, before: lastDate.toString(), limit: API_LIMIT }, {
...params,
[isAscending ? "after" : "before"]: lastDate.toString(),
limit: API_LIMIT,
},
]; ];
} }

View File

@ -6,6 +6,7 @@ const SEARCH_FILTERS = [
"zone", "zone",
"sub", "sub",
"source", "source",
"sort",
] as const; ] as const;
export type SearchFilters = (typeof SEARCH_FILTERS)[number]; export type SearchFilters = (typeof SEARCH_FILTERS)[number];
export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [ export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
@ -16,10 +17,18 @@ export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
"zone", "zone",
"sub", "sub",
"source", "source",
"sort",
]; ];
export type SearchSource = "similarity" | "thumbnail" | "description"; export type SearchSource = "similarity" | "thumbnail" | "description";
export type SearchSortType =
| "date_asc"
| "date_desc"
| "score_asc"
| "score_desc"
| "relevance";
export type SearchResult = { export type SearchResult = {
id: string; id: string;
camera: string; camera: string;
@ -65,6 +74,7 @@ export type SearchFilter = {
time_range?: string; time_range?: string;
search_type?: SearchSource[]; search_type?: SearchSource[];
event_id?: string; event_id?: string;
sort?: SearchSortType;
}; };
export const DEFAULT_TIME_RANGE_AFTER = "00:00"; export const DEFAULT_TIME_RANGE_AFTER = "00:00";
@ -86,6 +96,7 @@ export type SearchQueryParams = {
query?: string; query?: string;
page?: number; page?: number;
time_range?: string; time_range?: string;
sort?: SearchSortType;
}; };
export type SearchQuery = [string, SearchQueryParams] | null; export type SearchQuery = [string, SearchQueryParams] | null;