Improve LPR regex support (#19767)

* add regex support to events api for recognized_license_plate

* frontend

add ability to use regexes in the plate search box and add select all/clear all links to quickly select all filtered plates
This commit is contained in:
Josh Hawkins
2025-08-26 08:11:37 -05:00
committed by GitHub
parent 22e981c38c
commit 6c3f99150c
4 changed files with 133 additions and 67 deletions

View File

@@ -127,6 +127,8 @@
"loading": "Loading recognized license plates…",
"placeholder": "Type to search license plates…",
"noLicensePlatesFound": "No license plates found.",
"selectPlatesFromList": "Select one or more plates from the list."
"selectPlatesFromList": "Select one or more plates from the list.",
"selectAll": "Select all",
"clearAll": "Clear all"
}
}

View File

@@ -41,7 +41,7 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import { LuCheck } from "react-icons/lu";
import { LuCheck, LuSquareCheck, LuX } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type SearchFilterDialogProps = {
@@ -923,13 +923,19 @@ export function RecognizedLicensePlatesFilterContent({
}
};
if (allRecognizedLicensePlates && allRecognizedLicensePlates.length === 0) {
return null;
}
const filterItems = (value: string, search: string) => {
if (!search) return 1; // Show all items if no search input
// If wrapped in /.../, treat as raw regex
if (search.startsWith("/") && search.endsWith("/") && search.length > 2) {
try {
const regex = new RegExp(search.slice(1, -1), "i");
return regex.test(value) ? 1 : -1;
} catch {
return -1;
}
}
if (search.includes("*") || search.includes("?")) {
const escapedSearch = search
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
@@ -943,6 +949,46 @@ export function RecognizedLicensePlatesFilterContent({
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
const filteredPlates = useMemo(() => {
if (!allRecognizedLicensePlates) return [];
return allRecognizedLicensePlates.filter(
(plate) => filterItems(plate, inputValue) > 0,
);
}, [allRecognizedLicensePlates, inputValue]);
const handleSelectAllVisible = () => {
const allVisibleSelected = filteredPlates.every((plate) =>
selectedRecognizedLicensePlates.includes(plate),
);
let newSelected;
if (allVisibleSelected) {
// clear all
newSelected = selectedRecognizedLicensePlates.filter(
(plate) => !filteredPlates.includes(plate),
);
} else {
// select all
newSelected = Array.from(
new Set([...selectedRecognizedLicensePlates, ...filteredPlates]),
);
}
setSelectedRecognizedLicensePlates(newSelected);
setRecognizedLicensePlates(
newSelected.length === 0 ? undefined : newSelected,
);
};
const handleClearAll = () => {
setSelectedRecognizedLicensePlates([]);
setRecognizedLicensePlates(undefined);
};
if (allRecognizedLicensePlates && allRecognizedLicensePlates.length === 0) {
return null;
}
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
@@ -1010,6 +1056,30 @@ export function RecognizedLicensePlatesFilterContent({
<p className="mt-1 text-sm text-muted-foreground">
{t("recognizedLicensePlates.selectPlatesFromList")}
</p>
<div className="mt-2 flex items-center justify-between text-sm text-muted-foreground">
{filteredPlates.length > 0 &&
!filteredPlates.every((plate) =>
selectedRecognizedLicensePlates.includes(plate),
) ? (
<button
onClick={handleSelectAllVisible}
className="flex items-center gap-1 text-sm text-primary hover:underline"
>
<LuSquareCheck className="size-4" />
{t("recognizedLicensePlates.selectAll")}
</button>
) : null}
{selectedRecognizedLicensePlates.length > 0 && (
<button
onClick={handleClearAll}
className="flex items-center gap-1 text-sm text-primary hover:underline"
>
<LuX className="size-4" />
{t("recognizedLicensePlates.clearAll")}
</button>
)}
</div>
</>
)}
</div>