Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion components/LiveFeedDateRangeFilter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function LiveFeedDateRangeFilter({
onChange,
onInteractionChange = () => {},
embedded = false,
triggerClassName = "",
}) {
const fromMs = value?.from?.getTime?.() ?? null;
const toMs = value?.to?.getTime?.() ?? null;
Expand Down Expand Up @@ -66,7 +67,7 @@ export default function LiveFeedDateRangeFilter({
<PopoverTrigger asChild>
<Button
variant="outline"
className="hidden gap-2 dark:bg-[#161618] sm:flex"
className={`hidden gap-2 dark:bg-[#161618] sm:flex ${triggerClassName}`}
>
<CalendarDays className="h-4 w-4" />
{value?.from ? (
Expand Down
101 changes: 43 additions & 58 deletions components/PlateTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1614,7 +1614,12 @@ export default function PlateTable({
return `${numericConfidence * 100}%`; // Keep formatting consistent
};

const HourRangeFilter = ({ timeFormat, value = {}, onChange }) => {
const HourRangeFilter = ({
timeFormat,
value = {},
onChange,
triggerClassName = "",
}) => {
const [isOpen, setIsOpen] = useState(false);
// Local display state - what the user actually entered
const [displayHours, setDisplayHours] = useState({
Expand Down Expand Up @@ -1685,7 +1690,7 @@ export default function PlateTable({
<PopoverTrigger asChild>
<Button
variant="outline"
className="hidden sm:flex gap-2 dark:bg-[#161618]"
className={`hidden sm:flex gap-2 dark:bg-[#161618] ${triggerClassName}`}
>
<Clock className="h-4 w-4" />
{getTimeRangeLabel()}
Expand Down Expand Up @@ -2055,18 +2060,18 @@ export default function PlateTable({
>
{/* Search and Filters section - Desktop and Mobile */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex w-full flex-wrap items-start sm:items-center gap-2">
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-5">
{/* Search bar - Full Width on Mobile */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 w-full sm:w-auto">
<div className="flex items-center w-full sm:w-auto">
<div className="contents">
<div className="flex w-full items-center sm:col-span-2">
<Input
placeholder="Search plates or speed..."
icon={
<Search className="text-gray-400 dark:text-gray-500 absolute left-1.5 top-1/2 transform -translate-y-1/2 h-4 w-4" />
}
value={searchInput}
onChange={handleSearchChange}
className="w-full sm:w-64 h-9 dark:bg-[#161618]"
className="h-9 w-full dark:bg-[#161618]"
/>

{/* Mobile Filter Button */}
Expand Down Expand Up @@ -2102,57 +2107,81 @@ export default function PlateTable({
</div>

{/* Plate matching - Desktop only */}
<div className="hidden w-[310px] sm:block">
<div className="hidden w-full sm:block">
<PlateMatchModeSelect
id="match-mode"
value={filters.matchMode}
onValueChange={handleMatchModeChange}
settings={matchingSettings}
prefixLabel="Plate matching"
ariaLabel="Plate matching"
className="h-9 dark:bg-[#161618]"
className="h-9 w-full dark:bg-[#161618]"
/>
</div>
</div>

{/* Desktop Filters */}
<div className="hidden sm:flex flex-wrap gap-2">
<div className="hidden sm:contents">
<MultiSelectFilter
ariaLabel="Filter by tags"
allLabel="All tags"
value={selectedTags}
options={tagFilterOptions}
exclusiveValues={["untagged"]}
onChange={handleTagChange}
className="h-9 w-[180px] dark:bg-[#161618]"
className="h-9 w-full dark:bg-[#161618]"
/>
<MultiSelectFilter
ariaLabel="Filter by cameras"
allLabel="All cameras"
value={selectedCameras}
options={cameraFilterOptions}
onChange={handleCameraChange}
className="h-9 w-[180px] dark:bg-[#161618]"
className="h-9 w-full dark:bg-[#161618]"
/>
<LiveFeedDateRangeFilter
value={filters.dateRange}
onChange={handleDateRangeChange}
onInteractionChange={onFilterInteractionChange}
triggerClassName="w-full justify-start"
/>

<HourRangeFilter
timeFormat={timeFormat}
value={filters.hourRange || {}}
onChange={(hourRange) =>
onUpdateFilters({
hourFrom:
typeof hourRange.from === "number"
? hourRange.from.toString()
: undefined,
hourTo:
typeof hourRange.to === "number"
? hourRange.to.toString()
: undefined,
})
}
triggerClassName="w-full justify-start"
/>
<MultiSelectFilter
ariaLabel="Filter by review status"
allLabel="All review statuses"
value={selectedReviewStatuses}
options={reviewStatusFilterOptions}
onChange={handleReviewStatusChange}
className="h-9 w-[210px] dark:bg-[#161618]"
className="h-9 w-full dark:bg-[#161618]"
/>
<MultiSelectFilter
ariaLabel="Filter by direction"
allLabel="All directions"
value={selectedDirections}
options={directionFilterOptions}
onChange={handleDirectionChange}
className="h-9 w-[190px] dark:bg-[#161618]"
className="h-9 w-full dark:bg-[#161618]"
/>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 gap-2 dark:bg-[#161618]">
<Button variant="outline" className="h-9 w-full justify-start gap-2 dark:bg-[#161618]">
Speed{filters.minimumSpeed || filters.maximumSpeed
? `: ${filters.minimumSpeed || "0"}–${filters.maximumSpeed || "200"} mph`
: ""}
Expand All @@ -2168,50 +2197,6 @@ export default function PlateTable({
</div>
</PopoverContent>
</Popover>

<LiveFeedDateRangeFilter
value={filters.dateRange}
onChange={handleDateRangeChange}
onInteractionChange={onFilterInteractionChange}
/>

<HourRangeFilter
timeFormat={timeFormat}
value={filters.hourRange || {}}
onChange={(hourRange) =>
onUpdateFilters({
hourFrom:
typeof hourRange.from === "number"
? hourRange.from.toString()
: undefined,
hourTo:
typeof hourRange.to === "number"
? hourRange.to.toString()
: undefined,
})
}
/>
{(filters.readId ||
filters.search ||
selectedTags.length > 0 ||
selectedDirections.length > 0 ||
filters.minimumSpeed ||
filters.maximumSpeed ||
filters.dashboardTimeFrame ||
filters.dashboardMetric ||
filters.dateRange.from ||
(filters.hourRange?.from !== undefined &&
filters.hourRange?.to !== undefined)) && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="gap-2"
>
<X className="h-4 w-4" />
Clear Filters
</Button>
)}
</div>
</div>

Expand Down
35 changes: 35 additions & 0 deletions test/recognition-feed-filter-preference.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,38 @@ test("Recognition Feed restores saved state before querying and keeps explicit l
"clearing filters must preserve the independently saved rows-per-page preference"
);
});

test("Recognition Feed desktop search and filters use one aligned five-column grid", async () => {
const table = await readFile(
new URL("../components/PlateTable.jsx", import.meta.url),
"utf8"
);
const desktopSearchGrid = table.slice(
table.indexOf("{/* Search bar - Full Width on Mobile */}"),
table.indexOf("{/* Active filters display */}")
);

assert.match(table, /className="grid w-full grid-cols-1 gap-2 sm:grid-cols-5"/);
assert.match(desktopSearchGrid, /className="flex w-full items-center sm:col-span-2"/);
assert.match(desktopSearchGrid, /className="hidden w-full sm:block"/);
assert.match(desktopSearchGrid, /className="hidden sm:contents"/);
const labels = [
'placeholder="Search plates or speed..."',
'prefixLabel="Plate matching"',
'allLabel="All tags"',
'allLabel="All cameras"',
"<LiveFeedDateRangeFilter",
"<HourRangeFilter",
'allLabel="All review statuses"',
'allLabel="All directions"',
"Speed{filters.minimumSpeed",
];
let previousIndex = -1;
for (const label of labels) {
const index = desktopSearchGrid.indexOf(label);
assert.ok(index > previousIndex, `${label} must remain in the requested grid order`);
previousIndex = index;
}
assert.match(desktopSearchGrid, /triggerClassName="w-full justify-start"/);
assert.doesNotMatch(desktopSearchGrid, />\s*Clear Filters\s*</);
});
Loading