Browse Source
* Rework filtering Created common FilterComponents Created common FilterControl Abstracted common map logic into Map component Reworked Node filtering for map page Added Node filtering for nodes list Added test for node filtering Added toggle group UI package * Debounce filterState change * UI adjustments --------- Co-authored-by: philon- <[email protected]>pull/626/head
committed by
GitHub
13 changed files with 1078 additions and 677 deletions
@ -0,0 +1,58 @@ |
|||
import MapGl, { |
|||
AttributionControl, |
|||
GeolocateControl, |
|||
type MapRef, |
|||
NavigationControl, |
|||
ScaleControl, |
|||
} from "react-map-gl/maplibre"; |
|||
import { useTheme } from "@core/hooks/useTheme.ts"; |
|||
import { useEffect, useRef } from "react"; |
|||
|
|||
interface MapProps { |
|||
children?: React.ReactNode; |
|||
onLoad?: (map: MapRef) => void; |
|||
} |
|||
|
|||
export const Map = ({ children, onLoad }: MapProps) => { |
|||
const { theme } = useTheme(); |
|||
const darkMode = theme === "dark"; |
|||
const mapRef = useRef<MapRef | null>(null); |
|||
|
|||
useEffect(() => { |
|||
const map = mapRef.current; |
|||
if (map && onLoad) onLoad(map); |
|||
}, [onLoad]); |
|||
|
|||
return ( |
|||
<MapGl |
|||
ref={mapRef} |
|||
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json" |
|||
attributionControl={false} |
|||
renderWorldCopies={false} |
|||
maxPitch={0} |
|||
dragRotate={false} |
|||
touchZoomRotate={false} |
|||
initialViewState={{ |
|||
zoom: 1.8, |
|||
latitude: 35, |
|||
longitude: 0, |
|||
}} |
|||
style={{ filter: darkMode ? "brightness(0.9)" : undefined }} |
|||
> |
|||
<AttributionControl |
|||
style={{ |
|||
background: darkMode ? "#ffffff" : undefined, |
|||
color: darkMode ? "black" : undefined, |
|||
}} |
|||
/> |
|||
<GeolocateControl |
|||
position="top-right" |
|||
positionOptions={{ enableHighAccuracy: true }} |
|||
trackUserLocation |
|||
/> |
|||
<NavigationControl position="top-right" showCompass={false} /> |
|||
<ScaleControl /> |
|||
{children} |
|||
</MapGl> |
|||
); |
|||
}; |
|||
@ -0,0 +1,48 @@ |
|||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; |
|||
import * as React from "react"; |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
|
|||
const toggleGroupItemClasses = [ |
|||
"flex flex-1 h-10 items-center justify-center first:rounded-l last:rounded-r ", |
|||
"bg-slate-100", |
|||
"dark:bg-slate-800", |
|||
"data-[state=on]:bg-slate-600 data-[state=on]:text-white", |
|||
"data-[state=on]:dark:bg-slate-950 data-[state=on]:text-white data-[state=on]:dark:text-slate-200", |
|||
"data-[state=on]:hover:bg-slate-700 hover:bg-slate-700 hover:text-white hover:z-10 hover:shadow-[0_0_1px_2px] hover:outline-1 hover:outline-slate-700 hover:shadow-white/10", |
|||
"data-[state=on]:dark:hover:bg-slate-700 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:hover:outline-slate-700 dark:hover:shadow-black/20", |
|||
"disabled:text-slate-300 disabled:hover:bg-slate-100 disabled:hover:outline-none hover:shadow-none disabled:dark:text-slate-600 disabled:dark:hover:bg-slate-800 disabled:dark:hover:outline-none disabled:shadow-none", |
|||
]; |
|||
|
|||
const ToggleGroup = React.forwardRef< |
|||
React.ComponentRef<typeof ToggleGroupPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToggleGroupPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
"flex rounded shadow-md space-x-[1px] bg-slate-300 dark:bg-slate-800", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; |
|||
|
|||
const ToggleGroupItem = React.forwardRef< |
|||
React.ComponentRef<typeof ToggleGroupPrimitive.Item>, |
|||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<ToggleGroupPrimitive.Item |
|||
ref={ref} |
|||
className={cn( |
|||
...toggleGroupItemClasses, |
|||
className, |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
</ToggleGroupPrimitive.Item> |
|||
)); |
|||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; |
|||
|
|||
export { ToggleGroup, ToggleGroupItem }; |
|||
@ -0,0 +1,218 @@ |
|||
import { |
|||
AccordionContent, |
|||
AccordionHeader, |
|||
AccordionItem, |
|||
AccordionTrigger, |
|||
} from "@components/UI/Accordion.tsx"; |
|||
import { Checkbox } from "@components/UI/Checkbox/index.tsx"; |
|||
import { Slider } from "@components/UI/Slider.tsx"; |
|||
import { ScrollArea } from "@components/UI/ScrollArea.tsx"; |
|||
import { ToggleGroup, ToggleGroupItem } from "@components/UI/ToggleGroup.tsx"; |
|||
|
|||
import { ReactNode } from "react"; |
|||
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts"; |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
|
|||
interface FilterAccordionItemProps { |
|||
label: string; |
|||
children?: ReactNode; |
|||
} |
|||
|
|||
type RangeKeys<T> = { |
|||
[K in keyof T]: T[K] extends [number, number] ? K : never; |
|||
}[keyof T]; |
|||
interface FilterSliderProps<K extends RangeKeys<FilterState>> { |
|||
filterKey: K; |
|||
filterState: FilterState; |
|||
defaultFilterValues: FilterState; |
|||
onChange: (key: K) => (value: number[]) => void; |
|||
labelContent?: React.ReactNode; |
|||
label?: string; |
|||
step?: number; |
|||
} |
|||
|
|||
type EnumArrayKeys<T> = { |
|||
[K in keyof T]: T[K] extends number[] ? K : never; |
|||
}[keyof T]; |
|||
interface FilterMultiProps<K extends EnumArrayKeys<FilterState>> { |
|||
filterKey: K; |
|||
options: number[]; |
|||
filterState: FilterState; |
|||
setFilterState: React.Dispatch<React.SetStateAction<FilterState>>; |
|||
getLabel?: (value: number) => string; |
|||
} |
|||
|
|||
interface FilterToggleProps<K extends keyof FilterState> { |
|||
label: string; |
|||
alternativeLabels: [string, string]; |
|||
filterKey: K; |
|||
filterState: FilterState; |
|||
onChange: (key: K, value: string) => void; |
|||
} |
|||
|
|||
export const FilterAccordionItem = ({ |
|||
label, |
|||
children, |
|||
}: FilterAccordionItemProps) => { |
|||
return ( |
|||
<AccordionItem value={label}> |
|||
<AccordionHeader> |
|||
<AccordionTrigger |
|||
className={cn( |
|||
"w-full text-left font-bold text-sm px-1 py-2 dark:border-slate-700 text-slate-800 dark:text-slate-200", |
|||
)} |
|||
> |
|||
{label} |
|||
</AccordionTrigger> |
|||
</AccordionHeader> |
|||
<AccordionContent |
|||
className={cn( |
|||
"px-1 pb-4 pt-2 space-y-3 dark:border-slate-700", |
|||
)} |
|||
> |
|||
{children} |
|||
</AccordionContent> |
|||
</AccordionItem> |
|||
); |
|||
}; |
|||
|
|||
export const FilterSlider = <K extends RangeKeys<FilterState>>({ |
|||
filterKey, |
|||
filterState, |
|||
defaultFilterValues, |
|||
onChange, |
|||
labelContent, |
|||
label, |
|||
step, |
|||
}: FilterSliderProps<K>) => { |
|||
const value: [number, number] = filterState[filterKey]; |
|||
const defaultValue: [number, number] = defaultFilterValues[filterKey]; |
|||
|
|||
const showRange = value[0] !== value[1]; |
|||
const defaultLabel = ( |
|||
<> |
|||
{label}: {value[0]} |
|||
{showRange ? ` — ${value[1]}` : ""} |
|||
</> |
|||
); |
|||
|
|||
return ( |
|||
<div className="space-y-2"> |
|||
<label className="block text-sm font-medium"> |
|||
{labelContent ?? defaultLabel} |
|||
</label> |
|||
<Slider |
|||
value={value} |
|||
min={defaultValue[0]} |
|||
max={defaultValue[1]} |
|||
step={step ?? 1} |
|||
onValueChange={onChange(filterKey)} |
|||
className="w-full pb-3" |
|||
trackClassName="h-1 bg-slate-200 dark:bg-slate-700" |
|||
rangeClassName="bg-blue-500 dark:bg-blue-600" |
|||
thumbClassName="w-3 h-3 bg-white dark:bg-slate-300 border border-slate-400 dark:border-slate-100" |
|||
aria-label={label ?? String(filterKey)} |
|||
/> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
function getNumberArray<T extends FilterState, K extends EnumArrayKeys<T>>( |
|||
state: T, |
|||
key: K, |
|||
): number[] { |
|||
return state[key] as unknown as number[]; |
|||
} |
|||
export const FilterMulti = <K extends EnumArrayKeys<FilterState>>({ |
|||
filterKey, |
|||
options, |
|||
filterState, |
|||
setFilterState, |
|||
getLabel = (v) => String(v), |
|||
}: FilterMultiProps<K>) => { |
|||
const selected = getNumberArray(filterState, filterKey); |
|||
|
|||
const allSelected = options.length > 0 && |
|||
options.every((opt) => selected.includes(opt)); |
|||
|
|||
const toggleAll = () => { |
|||
setFilterState((prev) => ({ |
|||
...prev, |
|||
[filterKey]: allSelected ? [] : [...options], |
|||
})); |
|||
}; |
|||
|
|||
const toggleValue = (val: number, checked: boolean) => { |
|||
setFilterState((prev) => { |
|||
const current = getNumberArray(prev, filterKey); |
|||
return { |
|||
...prev, |
|||
[filterKey]: checked |
|||
? [...current, val] |
|||
: current.filter((v) => v !== val), |
|||
}; |
|||
}); |
|||
}; |
|||
|
|||
return ( |
|||
<div className="space-y-2"> |
|||
<ScrollArea className="h-64 border rounded-md dark:border-slate-700"> |
|||
<div className="space-y-2 px-2 py-3"> |
|||
<button |
|||
type="button" |
|||
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950" |
|||
onClick={toggleAll} |
|||
> |
|||
{allSelected ? "Uncheck All" : "Check All"} |
|||
</button> |
|||
{options.map((val) => ( |
|||
<Checkbox |
|||
key={val} |
|||
checked={selected.includes(val)} |
|||
onChange={(checked) => toggleValue(val, checked)} |
|||
> |
|||
<span className="dark:text-slate-200">{getLabel(val)}</span> |
|||
</Checkbox> |
|||
))} |
|||
</div> |
|||
</ScrollArea> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export const FilterToggle = <K extends keyof FilterState>({ |
|||
label, |
|||
alternativeLabels, |
|||
filterKey, |
|||
filterState, |
|||
onChange, |
|||
}: FilterToggleProps<K>) => ( |
|||
<div className="space-y-1 pb-1"> |
|||
<label className="block text-sm font-medium"> |
|||
{label} |
|||
</label> |
|||
<ToggleGroup |
|||
type="single" |
|||
aria-label={label} |
|||
onValueChange={(value) => onChange(filterKey, value)} |
|||
value={typeof filterState[filterKey] === "undefined" |
|||
? "" |
|||
: filterState[filterKey].toString()} |
|||
> |
|||
<ToggleGroupItem |
|||
value="false" |
|||
aria-label={alternativeLabels[0]} |
|||
className="text-sm h-7 dark:bg-slate-900" |
|||
> |
|||
{alternativeLabels[0]} |
|||
</ToggleGroupItem> |
|||
<ToggleGroupItem |
|||
value="true" |
|||
aria-label={alternativeLabels[1]} |
|||
className="text-sm h-7 dark:bg-slate-900" |
|||
> |
|||
{alternativeLabels[1]} |
|||
</ToggleGroupItem> |
|||
</ToggleGroup> |
|||
</div> |
|||
); |
|||
@ -0,0 +1,329 @@ |
|||
import { |
|||
type ComponentProps, |
|||
ReactNode, |
|||
useEffect, |
|||
useRef, |
|||
useState, |
|||
} from "react"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { debounce } from "@core/utils/debounce.ts"; |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
|||
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts"; |
|||
|
|||
import { |
|||
Popover, |
|||
PopoverContent, |
|||
PopoverTrigger, |
|||
} from "@components/UI/Popover.tsx"; |
|||
import { Input } from "@components/UI/Input.tsx"; |
|||
import { Accordion } from "@components/UI/Accordion.tsx"; |
|||
import { FunnelIcon } from "lucide-react"; |
|||
|
|||
import { |
|||
FilterAccordionItem, |
|||
FilterMulti, |
|||
FilterSlider, |
|||
FilterToggle, |
|||
} from "@components/generic/Filter/FilterComponents.tsx"; |
|||
|
|||
type PopoverContentProps = ComponentProps<typeof PopoverContent>; |
|||
|
|||
interface FilterControlProps { |
|||
filterState: FilterState; |
|||
defaultFilterValues: FilterState; |
|||
setFilterState: React.Dispatch<React.SetStateAction<FilterState>>; |
|||
isDirty?: boolean; |
|||
parameters?: { |
|||
popoverContentProps?: Partial<PopoverContentProps>; |
|||
triggerIcon?: ReactNode; |
|||
popoverTriggerClassName?: string; |
|||
showTextSearch?: boolean; |
|||
}; |
|||
|
|||
children?: ReactNode; |
|||
} |
|||
|
|||
export function FilterControl({ |
|||
filterState, |
|||
defaultFilterValues, |
|||
setFilterState, |
|||
isDirty, |
|||
parameters, |
|||
children, |
|||
}: FilterControlProps) { |
|||
// Copy of the state that we only use for rendering sliders and their labels directly, rest is debounced
|
|||
const [localFilterState, setLocalFilterState] = useState(filterState); |
|||
const skipNextSync = useRef(false); |
|||
useEffect(() => { |
|||
if (skipNextSync.current) { |
|||
skipNextSync.current = false; |
|||
return; |
|||
} |
|||
setLocalFilterState(filterState); |
|||
}, [filterState]); |
|||
|
|||
const handleTextChange = |
|||
<K extends keyof FilterState>(key: K) => |
|||
(e: React.ChangeEvent<HTMLInputElement>) => { |
|||
setFilterState((prev) => ({ |
|||
...prev, |
|||
[key]: e.target.value, |
|||
})); |
|||
}; |
|||
const handleRangeChange = |
|||
<K extends keyof FilterState>(key: K) => (value: number[]) => { |
|||
// immediate slider update
|
|||
setLocalFilterState((prev) => ({ |
|||
...prev, |
|||
[key]: value, |
|||
})); |
|||
|
|||
// debounced write to filterState (table/map render)
|
|||
debounce( |
|||
() => { |
|||
skipNextSync.current = true; |
|||
setFilterState((prev) => ({ |
|||
...prev, |
|||
[key]: value, |
|||
})); |
|||
}, |
|||
250, |
|||
)(); |
|||
}; |
|||
const handleBoolChange = <K extends keyof FilterState>( |
|||
key: K, |
|||
value: string, |
|||
) => { |
|||
const typedValue = value === "" |
|||
? undefined |
|||
: JSON.parse(value.toLowerCase()); |
|||
|
|||
setFilterState((prev) => ({ |
|||
...prev, |
|||
[key]: typedValue, |
|||
})); |
|||
}; |
|||
|
|||
const resetFilters = () => { |
|||
setFilterState(defaultFilterValues); |
|||
}; |
|||
|
|||
function formatTS(ts: number): ReactNode { |
|||
return <TimeAgo timestamp={Date.now() - ts * 1000} />; |
|||
} |
|||
function formatEnumLabel(label: string): string { |
|||
return label.replace(/_/g, " "); |
|||
} |
|||
|
|||
return ( |
|||
<Popover> |
|||
<PopoverTrigger asChild> |
|||
<button |
|||
type="button" |
|||
className={cn( |
|||
"rounded", |
|||
"text-slate-600 hover:text-slate-700 bg-slate-100 hover:bg-slate-200 active:bg-slate-300", |
|||
"dark:text-slate-400 hover:dark:text-slate-400 dark:bg-slate-700 hover:dark:bg-slate-800 dark:active:bg-slate-950", |
|||
isDirty |
|||
? "text-slate-100 dark:text-slate-300 bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 hover:text-slate-200 dark:hover:text-slate-300 active:bg-green-800 dark:active:bg-green-900" |
|||
: "", |
|||
parameters?.popoverTriggerClassName, |
|||
)} |
|||
aria-label="Filter" |
|||
> |
|||
{parameters?.triggerIcon ?? <FunnelIcon />} |
|||
</button> |
|||
</PopoverTrigger> |
|||
<PopoverContent |
|||
{...parameters?.popoverContentProps} |
|||
className={cn( |
|||
"dark:text-slate-300", |
|||
parameters?.popoverContentProps?.className, |
|||
)} |
|||
> |
|||
<form className="space-y-4"> |
|||
<Accordion |
|||
type="single" |
|||
defaultValue="General" |
|||
collapsible |
|||
> |
|||
<FilterAccordionItem label="General"> |
|||
{(parameters?.showTextSearch ?? true) && ( |
|||
<div className="flex flex-col space-y-1 pb-2"> |
|||
<label htmlFor="nodeName" className="font-medium text-sm"> |
|||
Node name/number |
|||
</label> |
|||
<Input |
|||
type="text" |
|||
value={filterState.nodeName} |
|||
onChange={handleTextChange("nodeName")} |
|||
showClearButton |
|||
placeholder="Meshtastic 1234" |
|||
/> |
|||
</div> |
|||
)} |
|||
|
|||
<FilterSlider |
|||
label="Number of hops" |
|||
filterKey="hopsAway" |
|||
filterState={localFilterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
onChange={handleRangeChange} |
|||
labelContent={ |
|||
<> |
|||
Number of hops: {localFilterState.hopsAway[0] === 0 |
|||
? "Direct" |
|||
: localFilterState.hopsAway[0]} |
|||
{localFilterState.hopsAway[0] !== |
|||
localFilterState.hopsAway[1] |
|||
? " — " + localFilterState.hopsAway[1] |
|||
: ""} |
|||
</> |
|||
} |
|||
/> |
|||
|
|||
<FilterSlider |
|||
label="Last heard" |
|||
filterKey="lastHeard" |
|||
filterState={localFilterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
onChange={handleRangeChange} |
|||
labelContent={ |
|||
<> |
|||
Last heard: <br /> |
|||
{localFilterState.lastHeard[0] === 0 ? "Now" : ( |
|||
<> |
|||
{localFilterState.lastHeard[0] === |
|||
defaultFilterValues.lastHeard[1] && ">"} |
|||
{formatTS(localFilterState.lastHeard[0])} |
|||
</> |
|||
)} |
|||
{localFilterState.lastHeard[0] !== |
|||
localFilterState.lastHeard[1] && ( |
|||
<> |
|||
{" — "} |
|||
{localFilterState.lastHeard[1] === |
|||
defaultFilterValues.lastHeard[1] && ">"} |
|||
{formatTS(localFilterState.lastHeard[1])} |
|||
</> |
|||
)} |
|||
</> |
|||
} |
|||
/> |
|||
<FilterToggle |
|||
label="Favorites" |
|||
filterKey="isFavorite" |
|||
alternativeLabels={["Hide", "Show Only"]} |
|||
filterState={filterState} |
|||
onChange={handleBoolChange} |
|||
/> |
|||
<FilterToggle |
|||
label="Connected via MQTT" |
|||
filterKey="viaMqtt" |
|||
alternativeLabels={["Hide", "Show Only"]} |
|||
filterState={filterState} |
|||
onChange={handleBoolChange} |
|||
/> |
|||
</FilterAccordionItem> |
|||
|
|||
<FilterAccordionItem label="Metrics"> |
|||
<FilterSlider |
|||
label="SNR (db)" |
|||
filterKey="snr" |
|||
filterState={localFilterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
onChange={handleRangeChange} |
|||
/> |
|||
<FilterSlider |
|||
label="Channel Utilization (%)" |
|||
filterKey="channelUtilization" |
|||
filterState={localFilterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
onChange={handleRangeChange} |
|||
/> |
|||
<FilterSlider |
|||
label="Airtime Utilization (%)" |
|||
filterKey="airUtilTx" |
|||
filterState={localFilterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
onChange={handleRangeChange} |
|||
/> |
|||
<FilterSlider |
|||
label="Battery level (%)" |
|||
filterKey="batteryLevel" |
|||
filterState={localFilterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
onChange={handleRangeChange} |
|||
labelContent={ |
|||
<> |
|||
Battery level (%): {localFilterState.batteryLevel[0] === 101 |
|||
? "Plugged in" |
|||
: localFilterState.batteryLevel[0]} |
|||
{localFilterState.batteryLevel[0] !== |
|||
localFilterState.batteryLevel[1] && ( |
|||
<> |
|||
{" – "} |
|||
{localFilterState.batteryLevel[1] === 101 |
|||
? "Plugged in" |
|||
: localFilterState.batteryLevel[1]} |
|||
</> |
|||
)} |
|||
</> |
|||
} |
|||
/> |
|||
<FilterSlider |
|||
label="Battery voltage (V)" |
|||
filterKey="voltage" |
|||
filterState={localFilterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
onChange={handleRangeChange} |
|||
/> |
|||
</FilterAccordionItem> |
|||
|
|||
<FilterAccordionItem label="Role"> |
|||
<FilterMulti |
|||
filterKey="role" |
|||
filterState={filterState} |
|||
setFilterState={setFilterState} |
|||
options={Object.values(Protobuf.Config.Config_DeviceConfig_Role) |
|||
.filter( |
|||
(v): v is number => typeof v === "number", |
|||
)} |
|||
getLabel={(val) => |
|||
formatEnumLabel( |
|||
Protobuf.Config.Config_DeviceConfig_Role[val], |
|||
)} |
|||
/> |
|||
</FilterAccordionItem> |
|||
<FilterAccordionItem label="Hardware"> |
|||
<FilterMulti |
|||
filterKey="hwModel" |
|||
filterState={filterState} |
|||
setFilterState={setFilterState} |
|||
options={Object.values(Protobuf.Mesh.HardwareModel) |
|||
.filter( |
|||
(v): v is number => typeof v === "number", |
|||
)} |
|||
getLabel={(val) => |
|||
formatEnumLabel(Protobuf.Mesh.HardwareModel[val])} |
|||
/> |
|||
</FilterAccordionItem> |
|||
</Accordion> |
|||
<button |
|||
type="button" |
|||
onClick={resetFilters} |
|||
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 dark:bg-slate-900 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-950" |
|||
> |
|||
Reset Filters |
|||
</button> |
|||
{children && ( |
|||
<div className="mt-4 border-t pt-4"> |
|||
{children} |
|||
</div> |
|||
)} |
|||
</form> |
|||
</PopoverContent> |
|||
</Popover> |
|||
); |
|||
} |
|||
@ -0,0 +1,160 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
import { useFilterNode } from "@components/generic/Filter/useFilterNode.ts"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { renderHook } from "@testing-library/react"; |
|||
|
|||
export function createMockNode(): Protobuf.Mesh.NodeInfo { |
|||
return { |
|||
$typeName: "meshtastic.NodeInfo", |
|||
num: 1234567890, |
|||
snr: -10.2, |
|||
lastHeard: 1747519674, |
|||
channel: 0, |
|||
viaMqtt: false, |
|||
isFavorite: true, |
|||
isIgnored: false, |
|||
hopsAway: 2, |
|||
user: { |
|||
$typeName: "meshtastic.User", |
|||
id: "!12345678", |
|||
longName: "longName", |
|||
shortName: "lN", |
|||
macaddr: new Uint8Array(0), |
|||
hwModel: Protobuf.Mesh.HardwareModel.TLORA_T3_S3, |
|||
isLicensed: false, |
|||
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT, |
|||
publicKey: new Uint8Array(32), |
|||
}, |
|||
deviceMetrics: { |
|||
$typeName: "meshtastic.DeviceMetrics", |
|||
batteryLevel: 101, |
|||
voltage: 4.21, |
|||
channelUtilization: 7.50, |
|||
airUtilTx: 2.57, |
|||
uptimeSeconds: 528092, |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
describe("useFilterNode", () => { |
|||
const { result } = renderHook(() => useFilterNode()); |
|||
const { nodeFilter, defaultFilterValues, isFilterDirty } = result.current; |
|||
|
|||
describe("nodeFilter", () => { |
|||
const node = createMockNode(); |
|||
|
|||
it("filters by nodeName", () => { |
|||
expect(nodeFilter(node, { nodeName: "lon" })).toBe(true); |
|||
expect(nodeFilter(node, { nodeName: "xxx" })).toBe(false); |
|||
}); |
|||
|
|||
it("filters by hopsAway", () => { |
|||
expect(nodeFilter(node, { hopsAway: [0, 1] })).toBe(false); |
|||
expect(nodeFilter(node, { hopsAway: [2, 2] })).toBe(true); |
|||
}); |
|||
|
|||
it("filters by snr", () => { |
|||
expect(nodeFilter(node, { snr: [-12, -10] })).toBe(true); |
|||
expect(nodeFilter(node, { snr: [-17, -16] })).toBe(false); |
|||
}); |
|||
|
|||
it("filters by batteryLevel", () => { |
|||
expect(nodeFilter(node, { batteryLevel: [0, 100] })).toBe(false); |
|||
expect(nodeFilter(node, { batteryLevel: [100, 101] })).toBe(true); |
|||
}); |
|||
|
|||
it("filters by isFavorite", () => { |
|||
expect(nodeFilter(node, { isFavorite: true })).toBe(true); |
|||
expect(nodeFilter(node, { isFavorite: false })).toBe(false); |
|||
expect(nodeFilter(node, { isFavorite: undefined })).toBe(true); |
|||
}); |
|||
|
|||
it("filters by viaMqtt", () => { |
|||
expect(nodeFilter(node, { viaMqtt: true })).toBe(false); |
|||
expect(nodeFilter(node, { viaMqtt: false })).toBe(true); |
|||
expect(nodeFilter(node, { viaMqtt: undefined })).toBe(true); |
|||
}); |
|||
|
|||
it("filters by airUtilTx", () => { |
|||
expect(nodeFilter(node, { airUtilTx: [2, 3] })).toBe(true); |
|||
expect(nodeFilter(node, { airUtilTx: [3, 4] })).toBe(false); |
|||
}); |
|||
|
|||
it("filters by channelUtilization", () => { |
|||
expect(nodeFilter(node, { channelUtilization: [7, 8] })).toBe(true); |
|||
expect(nodeFilter(node, { channelUtilization: [8, 9] })).toBe(false); |
|||
}); |
|||
|
|||
it("filters by voltage", () => { |
|||
expect(nodeFilter(node, { voltage: [4, 4.3] })).toBe(true); |
|||
expect(nodeFilter(node, { voltage: [4.3, 5] })).toBe(false); |
|||
}); |
|||
|
|||
it("filters by role", () => { |
|||
expect( |
|||
nodeFilter(node, { |
|||
role: [Protobuf.Config.Config_DeviceConfig_Role.CLIENT], |
|||
}), |
|||
).toBe(true); |
|||
expect( |
|||
nodeFilter(node, { |
|||
role: [Protobuf.Config.Config_DeviceConfig_Role.REPEATER], |
|||
}), |
|||
).toBe(false); |
|||
}); |
|||
|
|||
it("filters by hwModel", () => { |
|||
expect( |
|||
nodeFilter(node, { |
|||
hwModel: [Protobuf.Mesh.HardwareModel.TLORA_T3_S3], |
|||
}), |
|||
).toBe(true); |
|||
expect( |
|||
nodeFilter(node, { hwModel: [Protobuf.Mesh.HardwareModel.HELTEC_V3] }), |
|||
).toBe(false); |
|||
}); |
|||
|
|||
it("returns false when current matches defaults", () => { |
|||
expect(isFilterDirty(defaultFilterValues)).toBe(false); |
|||
}); |
|||
|
|||
it("detects dirty string field", () => { |
|||
const modified = { ...defaultFilterValues, nodeName: "abc" }; |
|||
expect(isFilterDirty(modified)).toBe(true); |
|||
}); |
|||
|
|||
it("detects dirty numeric tuple field", () => { |
|||
const modified = { |
|||
...defaultFilterValues, |
|||
snr: [-10, 5] as [number, number], |
|||
}; |
|||
expect(isFilterDirty(modified)).toBe(true); |
|||
}); |
|||
|
|||
it("detects dirty boolean field (isFavorite)", () => { |
|||
const modified = { ...defaultFilterValues, isFavorite: true }; |
|||
expect(isFilterDirty(modified)).toBe(true); |
|||
}); |
|||
|
|||
it("detects dirty boolean field (viaMqtt)", () => { |
|||
const modified = { ...defaultFilterValues, viaMqtt: true }; |
|||
expect(isFilterDirty(modified)).toBe(true); |
|||
}); |
|||
|
|||
it("detects dirty enum array field (role)", () => { |
|||
const modified = { |
|||
...defaultFilterValues, |
|||
role: [Protobuf.Config.Config_DeviceConfig_Role.REPEATER], |
|||
}; |
|||
expect(isFilterDirty(modified)).toBe(true); |
|||
}); |
|||
|
|||
it("detects dirty enum array field (hwModel)", () => { |
|||
const modified = { |
|||
...defaultFilterValues, |
|||
hwModel: [Protobuf.Mesh.HardwareModel.HELTEC_V3], |
|||
}; |
|||
expect(isFilterDirty(modified)).toBe(true); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,153 @@ |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import { useMemo } from "react"; |
|||
|
|||
export type FilterState = { |
|||
nodeName: string; |
|||
hopsAway: [number, number]; |
|||
lastHeard: [number, number]; |
|||
isFavorite: boolean | undefined; // undefined -> don't filter
|
|||
viaMqtt: boolean | undefined; // undefined -> don't filter
|
|||
snr: [number, number]; |
|||
channelUtilization: [number, number]; |
|||
airUtilTx: [number, number]; |
|||
batteryLevel: [number, number]; |
|||
voltage: [number, number]; |
|||
role: (Protobuf.Config.Config_DeviceConfig_Role)[]; |
|||
hwModel: (Protobuf.Mesh.HardwareModel)[]; |
|||
}; |
|||
|
|||
export function useFilterNode() { |
|||
const defaultFilterValues = useMemo<FilterState>(() => ({ |
|||
nodeName: "", |
|||
hopsAway: [0, 7], |
|||
lastHeard: [0, 864000], // 0-10 days
|
|||
isFavorite: undefined, |
|||
viaMqtt: undefined, |
|||
snr: [-20, 10], |
|||
channelUtilization: [0, 100], |
|||
airUtilTx: [0, 100], |
|||
batteryLevel: [0, 101], |
|||
voltage: [0, 5], |
|||
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( |
|||
(v): v is Protobuf.Config.Config_DeviceConfig_Role => |
|||
typeof v === "number", |
|||
), |
|||
hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( |
|||
(v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", |
|||
), |
|||
}), []); |
|||
|
|||
function nodeFilter( |
|||
node: Protobuf.Mesh.NodeInfo, |
|||
filterOverrides?: Partial<FilterState>, |
|||
): boolean { |
|||
const filterState: FilterState = { |
|||
...defaultFilterValues, |
|||
...filterOverrides, |
|||
}; |
|||
|
|||
if (!node.user) return false; |
|||
|
|||
const nodeName = filterState.nodeName.toLowerCase(); |
|||
if ( |
|||
!( |
|||
node.user?.shortName.toLowerCase().includes(nodeName) || |
|||
node.user?.longName.toLowerCase().includes(nodeName) || |
|||
node?.num.toString().includes(nodeName) || |
|||
numberToHexUnpadded(node?.num).includes( |
|||
nodeName.replace(/!/g, ""), |
|||
) |
|||
) |
|||
) return false; |
|||
|
|||
const hops = node?.hopsAway ?? 7; |
|||
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { |
|||
return false; |
|||
} |
|||
|
|||
const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0); |
|||
if ( |
|||
secondsAgo < filterState.lastHeard[0] || |
|||
( |
|||
secondsAgo > filterState.lastHeard[1] && |
|||
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1] |
|||
) |
|||
) return false; |
|||
|
|||
if ( |
|||
typeof filterState.isFavorite !== "undefined" && |
|||
node.isFavorite !== filterState.isFavorite |
|||
) return false; |
|||
|
|||
if ( |
|||
typeof filterState.viaMqtt !== "undefined" && |
|||
node.viaMqtt !== filterState.viaMqtt |
|||
) return false; |
|||
|
|||
const snr = node?.snr ?? -20; |
|||
if ( |
|||
snr < filterState.snr[0] || |
|||
snr > filterState.snr[1] |
|||
) return false; |
|||
|
|||
const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0; |
|||
if ( |
|||
channelUtilization < filterState.channelUtilization[0] || |
|||
channelUtilization > filterState.channelUtilization[1] |
|||
) return false; |
|||
|
|||
const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0; |
|||
if ( |
|||
airUtilTx < filterState.airUtilTx[0] || |
|||
airUtilTx > filterState.airUtilTx[1] |
|||
) return false; |
|||
|
|||
const batt = node?.deviceMetrics?.batteryLevel ?? 101; |
|||
if ( |
|||
batt < filterState.batteryLevel[0] || |
|||
batt > filterState.batteryLevel[1] |
|||
) return false; |
|||
|
|||
const voltage = node?.deviceMetrics?.voltage ?? 0; |
|||
if ( |
|||
voltage < filterState.voltage[0] || |
|||
voltage > filterState.voltage[1] |
|||
) return false; |
|||
|
|||
const role: Protobuf.Config.Config_DeviceConfig_Role = node.user?.role ?? |
|||
Protobuf.Config.Config_DeviceConfig_Role.CLIENT; |
|||
if (!filterState.role.includes(role)) return false; |
|||
|
|||
const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ?? |
|||
Protobuf.Mesh.HardwareModel.UNSET; |
|||
if (!filterState.hwModel.includes(hwModel)) return false; |
|||
|
|||
// All conditions are true
|
|||
return true; |
|||
} |
|||
|
|||
// deno-lint-ignore no-explicit-any
|
|||
function shallowEqualArray(a: any[], b: any[]) { |
|||
return a.length === b.length && a.every((v, i) => v === b[i]); |
|||
} |
|||
|
|||
function isFilterDirty( |
|||
current: FilterState, |
|||
overrides?: Partial<FilterState>, |
|||
): boolean { |
|||
const base: FilterState = overrides |
|||
? { ...defaultFilterValues, ...overrides } |
|||
: defaultFilterValues; |
|||
|
|||
return (Object.keys(base) as (keyof FilterState)[]).some((key) => { |
|||
const curr = current[key]; |
|||
const def = base[key]; |
|||
return Array.isArray(def) && Array.isArray(curr) |
|||
? !shallowEqualArray(curr, def) |
|||
: curr !== def; |
|||
}); |
|||
} |
|||
|
|||
return { nodeFilter, defaultFilterValues, isFilterDirty }; |
|||
} |
|||
@ -1,294 +0,0 @@ |
|||
import { useCallback, useMemo, useState } from "react"; |
|||
import { Protobuf } from "@meshtastic/core"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
|
|||
interface BooleanFilter { |
|||
key: string; |
|||
label: string; |
|||
group: string; |
|||
type: "boolean"; |
|||
predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean; |
|||
} |
|||
|
|||
interface RangeFilter { |
|||
key: string; |
|||
label: string; |
|||
group: string; |
|||
type: "range"; |
|||
bounds: [number, number]; |
|||
predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean; |
|||
} |
|||
|
|||
interface SearchFilter { |
|||
key: string; |
|||
label: string; |
|||
group: string; |
|||
type: "search"; |
|||
predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean; |
|||
} |
|||
|
|||
interface MultiFilter { |
|||
key: string; |
|||
label: string; |
|||
group: string; |
|||
type: "multi"; |
|||
options: string[]; |
|||
predicate: (node: Protobuf.Mesh.NodeInfo, value: string[]) => boolean; |
|||
} |
|||
|
|||
export type FilterConfig = |
|||
| BooleanFilter |
|||
| RangeFilter |
|||
| SearchFilter |
|||
| MultiFilter; |
|||
|
|||
export type FilterValueMap = { |
|||
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean |
|||
: C extends RangeFilter ? [number, number] |
|||
: C extends SearchFilter ? string |
|||
: C extends MultiFilter ? string[] |
|||
: never; |
|||
}; |
|||
|
|||
// Defines all node filters in this object
|
|||
export const filterConfigs: FilterConfig[] = [ |
|||
{ |
|||
key: "searchText", |
|||
label: "Node name/number", |
|||
group: "General", |
|||
type: "search", |
|||
predicate: (node, text: string) => { |
|||
if (!text) return true; |
|||
const shortName = node.user?.shortName?.toString().toLowerCase() ?? ""; |
|||
const longName = node.user?.longName?.toString().toLowerCase() ?? ""; |
|||
const nodeNum = node.num?.toString() ?? ""; |
|||
const nodeNumHex = numberToHexUnpadded(node.num) ?? ""; |
|||
const search = text.toLowerCase(); |
|||
return shortName.includes(search) || longName.includes(search) || |
|||
nodeNum.includes(search) || |
|||
nodeNumHex.includes(search.replace(/!/g, "")); |
|||
}, |
|||
}, |
|||
{ |
|||
key: "hopRange", |
|||
label: "Number of hops", |
|||
group: "General", |
|||
type: "range", |
|||
bounds: [0, 7], |
|||
predicate: (node, [min, max]: [number, number]) => { |
|||
const hops = node.hopsAway ?? 7; |
|||
return hops >= min && hops <= max; |
|||
}, |
|||
}, |
|||
{ |
|||
key: "lastHeard", |
|||
label: "Last heard", |
|||
group: "General", |
|||
type: "range", |
|||
bounds: [0, 864000], // 10 days
|
|||
predicate: (node, [min, max]: [number, number]) => { |
|||
const secondsAgo = Date.now() / 1000 - node.lastHeard; |
|||
return (secondsAgo >= min && secondsAgo <= max) || |
|||
(secondsAgo >= min && max == 864000); |
|||
}, |
|||
}, |
|||
{ |
|||
key: "favOnly", |
|||
label: "Show favourites only", |
|||
group: "General", |
|||
type: "boolean", |
|||
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite, |
|||
}, |
|||
{ |
|||
key: "viaMqtt", |
|||
label: "Hide MQTT-connected nodes", |
|||
group: "General", |
|||
type: "boolean", |
|||
predicate: (node, hide: boolean) => !hide || !node.viaMqtt, |
|||
}, |
|||
{ |
|||
key: "snr", |
|||
label: "SNR (db)", |
|||
group: "Metrics", |
|||
type: "range", |
|||
bounds: [-20, 10], |
|||
predicate: (node, [min, max]: [number, number]) => { |
|||
const snr = node.snr ?? -20; |
|||
return snr >= min && snr <= max; |
|||
}, |
|||
}, |
|||
{ |
|||
key: "channelUtilization", |
|||
label: "Channel Utilization (%)", |
|||
group: "Metrics", |
|||
type: "range", |
|||
bounds: [0, 100], |
|||
predicate: (node, [min, max]: [number, number]) => { |
|||
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; |
|||
return channelUtilization >= min && channelUtilization <= max; |
|||
}, |
|||
}, |
|||
{ |
|||
key: "airUtilTx", |
|||
label: "Airtime Utilization (%)", |
|||
group: "Metrics", |
|||
type: "range", |
|||
bounds: [0, 100], |
|||
predicate: (node, [min, max]: [number, number]) => { |
|||
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; |
|||
return airUtilTx >= min && airUtilTx <= max; |
|||
}, |
|||
}, |
|||
{ |
|||
key: "battery", |
|||
label: "Battery level (%)", |
|||
group: "Metrics", |
|||
type: "range", |
|||
bounds: [0, 101], |
|||
predicate: (node, [min, max]: [number, number]) => { |
|||
const batt = node.deviceMetrics?.batteryLevel ?? 101; |
|||
return batt >= min && batt <= max; |
|||
}, |
|||
}, |
|||
{ |
|||
key: "voltage", |
|||
label: "Battery voltage (V)", |
|||
group: "Metrics", |
|||
type: "range", |
|||
bounds: [0.1, 5.0], |
|||
predicate: (node, [min, max]: [number, number]) => { |
|||
const batt = node.deviceMetrics?.voltage ?? 5; |
|||
return batt >= min && batt <= max; |
|||
}, |
|||
}, |
|||
{ |
|||
key: "role", |
|||
label: "Role", |
|||
group: "Role", |
|||
type: "multi", |
|||
options: Object.keys(Protobuf.Config.Config_DeviceConfig_Role) |
|||
.filter((k) => isNaN(Number(k))) |
|||
.map((k) => { |
|||
const spaced = k.replace(/_/g, " "); |
|||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); |
|||
}), |
|||
predicate: (node, selected) => { |
|||
return selected.map((k) => { |
|||
const unSpaced = k.replace(/ /g, "_"); |
|||
return unSpaced.toUpperCase(); |
|||
}).includes( |
|||
Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0], |
|||
); |
|||
}, |
|||
}, |
|||
{ |
|||
key: "hwModel", |
|||
label: "Hardware model", |
|||
group: "Hardware", |
|||
type: "multi", |
|||
options: Object.keys(Protobuf.Mesh.HardwareModel) |
|||
.filter((k) => isNaN(Number(k))) |
|||
.map((k) => { |
|||
return k.replace(/_/g, " "); |
|||
}), |
|||
predicate: (node, selected) => { |
|||
return selected.map((k) => { |
|||
const unSpaced = k.replace(/ /g, "_"); |
|||
return unSpaced.toUpperCase(); |
|||
}).includes(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]); |
|||
}, |
|||
}, |
|||
]; |
|||
|
|||
export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { |
|||
const defaultState = useMemo(() => { |
|||
return filterConfigs.reduce((acc, cfg) => { |
|||
switch (cfg.type) { |
|||
case "boolean": |
|||
acc[cfg.key] = false; |
|||
break; |
|||
case "range": |
|||
acc[cfg.key] = cfg.bounds; |
|||
break; |
|||
case "search": |
|||
acc[cfg.key] = ""; |
|||
break; |
|||
case "multi": |
|||
acc[cfg.key] = cfg.options; |
|||
break; |
|||
} |
|||
return acc; |
|||
}, {} as FilterValueMap); |
|||
}, []); |
|||
|
|||
const [filters, setFilters] = useState<FilterValueMap>( |
|||
defaultState, |
|||
); |
|||
|
|||
const groupedFilterConfigs = useMemo(() => { |
|||
return filterConfigs.reduce<Record<string, FilterConfig[]>>((acc, cfg) => { |
|||
const g = "group" in cfg ? cfg.group : "General"; |
|||
if (!acc[g]) acc[g] = []; |
|||
acc[g].push(cfg); |
|||
return acc; |
|||
}, {}); |
|||
}, [filterConfigs]); |
|||
|
|||
const resetFilters = useCallback(() => { |
|||
setFilters(defaultState); |
|||
}, [defaultState]); |
|||
|
|||
const onFilterChange = useCallback( |
|||
<K extends keyof FilterValueMap>(key: K, value: FilterValueMap[K]) => { |
|||
setFilters((f) => ({ ...f, [key]: value })); |
|||
}, |
|||
[], |
|||
); |
|||
|
|||
const filteredNodes = useMemo( |
|||
() => |
|||
nodes.filter((node) => |
|||
filterConfigs.every((cfg) => { |
|||
const val = filters[cfg.key]; |
|||
switch (cfg.type) { |
|||
case "boolean": |
|||
if (typeof val !== "boolean") return true; |
|||
return cfg.predicate(node, val); |
|||
|
|||
case "range": { |
|||
if ( |
|||
!Array.isArray(val) || |
|||
val.length !== 2 || |
|||
typeof val[0] !== "number" || |
|||
typeof val[1] !== "number" |
|||
) { |
|||
return true; |
|||
} |
|||
const tuple: [number, number] = [val[0], val[1]]; |
|||
return cfg.predicate(node, tuple); |
|||
} |
|||
case "multi": { |
|||
const safeArray = (() => { |
|||
if (!Array.isArray(val)) return []; |
|||
return val.filter((x): x is string => typeof x === "string"); |
|||
})(); |
|||
return cfg.predicate(node, safeArray); |
|||
} |
|||
case "search": |
|||
if (typeof val !== "string") return true; |
|||
return cfg.predicate(node, val); |
|||
} |
|||
}) |
|||
), |
|||
[nodes, filters], |
|||
); |
|||
|
|||
return { |
|||
filters, |
|||
defaultState, |
|||
onFilterChange, |
|||
resetFilters, |
|||
filteredNodes, |
|||
groupedFilterConfigs, |
|||
}; |
|||
} |
|||
@ -1,265 +0,0 @@ |
|||
import { |
|||
Popover, |
|||
PopoverContent, |
|||
PopoverTrigger, |
|||
} from "@components/UI/Popover.tsx"; |
|||
import { FunnelIcon } from "lucide-react"; |
|||
import { Checkbox } from "@components/UI/Checkbox/index.tsx"; |
|||
import { Slider } from "@components/UI/Slider.tsx"; |
|||
import { ScrollArea } from "@components/UI/ScrollArea.tsx"; |
|||
import { |
|||
Accordion, |
|||
AccordionContent, |
|||
AccordionHeader, |
|||
AccordionItem, |
|||
AccordionTrigger, |
|||
} from "@components/UI/Accordion.tsx"; |
|||
import type { |
|||
FilterConfig, |
|||
FilterValueMap, |
|||
} from "@core/hooks/useNodeFilters.ts"; |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
|||
|
|||
interface FilterControlProps { |
|||
groupedFilterConfigs: Record<string, FilterConfig[]>; |
|||
values: FilterValueMap; |
|||
onChange: <K extends keyof FilterValueMap>( |
|||
key: K, |
|||
value: FilterValueMap[K], |
|||
) => void; |
|||
resetFilters: () => void; |
|||
isDirty: boolean; |
|||
children?: React.ReactNode; |
|||
} |
|||
|
|||
export function FilterControl( |
|||
{ groupedFilterConfigs, values, onChange, resetFilters, isDirty, children }: |
|||
FilterControlProps, |
|||
) { |
|||
return ( |
|||
<Popover> |
|||
<PopoverTrigger asChild> |
|||
<button |
|||
type="button" |
|||
className={cn( |
|||
"fixed bottom-17 right-2 px-1 py-1 rounded shadow-md", |
|||
isDirty |
|||
? " text-slate-100 bg-green-600 hover:bg-green-700 hover:text-slate-200 active:bg-green-800" |
|||
: "text-slate-600 bg-slate-100 hover:bg-slate-200 hover:text-slate-700 active:bg-slate-300", |
|||
)} |
|||
aria-label="Filter" |
|||
> |
|||
<FunnelIcon /> |
|||
</button> |
|||
</PopoverTrigger> |
|||
<PopoverContent |
|||
side="bottom" |
|||
align="end" |
|||
sideOffset={12} |
|||
className="dark:bg-slate-100 dark:border-slate-300" |
|||
> |
|||
<div className="space-y-4"> |
|||
<Accordion |
|||
className="AccordionRoot" |
|||
type="single" |
|||
defaultValue={Object.entries(groupedFilterConfigs)[0][0]} |
|||
collapsible |
|||
> |
|||
{Object.entries(groupedFilterConfigs).map(( |
|||
[groupName, groupConfigs], |
|||
) => ( |
|||
<AccordionItem key={groupName} value={groupName}> |
|||
<AccordionHeader> |
|||
<AccordionTrigger className="w-full text-left font-bold text-sm px-1 py-2"> |
|||
{groupName} |
|||
</AccordionTrigger> |
|||
</AccordionHeader> |
|||
<AccordionContent className="px-1 pb-4 pt-2 space-y-3"> |
|||
{groupConfigs.map((cfg) => { |
|||
const val = values[cfg.key]; |
|||
switch (cfg.type) { |
|||
case "boolean": |
|||
if (typeof val !== "boolean") return null; |
|||
return ( |
|||
<Checkbox |
|||
key={cfg.key} |
|||
checked={val} |
|||
onChange={(v) => onChange(cfg.key, v)} |
|||
className="pb-1" |
|||
labelClassName="dark:text-slate-900" |
|||
> |
|||
{cfg.label} |
|||
</Checkbox> |
|||
); |
|||
case "range": { |
|||
if ( |
|||
!Array.isArray(val) || |
|||
val.length !== 2 || |
|||
typeof val[0] !== "number" || |
|||
typeof val[1] !== "number" |
|||
) { |
|||
return null; |
|||
} |
|||
const [min, max] = val; |
|||
const [lo, hi] = cfg.bounds; |
|||
|
|||
let formattedMin = null; |
|||
let formattedMax = null; |
|||
|
|||
// Some filters require special formatting for min/max values
|
|||
if (cfg.key == "battery" && min == hi) { |
|||
formattedMin = "Charging"; |
|||
} |
|||
if (cfg.key == "battery" && max == hi) { |
|||
formattedMax = "Charging"; |
|||
} |
|||
if (cfg.key == "hopRange" && min == lo) { |
|||
formattedMin = "Direct"; |
|||
} |
|||
if (cfg.key == "lastHeard") { |
|||
formattedMin = ( |
|||
<> |
|||
<br /> |
|||
{min === lo ? "now" : ( |
|||
<TimeAgo |
|||
timestamp={Date.now() - min * 1000} |
|||
/> |
|||
)} |
|||
</> |
|||
); |
|||
|
|||
formattedMax = ( |
|||
<> |
|||
{max === hi ? ">" : ""} |
|||
<TimeAgo timestamp={Date.now() - max * 1000} /> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div key={cfg.key} className="space-y-2"> |
|||
<label className="block text-sm font-medium"> |
|||
{cfg.label}:{" "} |
|||
{min === max ? formattedMin ?? min : ( |
|||
<> |
|||
{formattedMin ?? min} – {formattedMax ?? max} |
|||
</> |
|||
)} |
|||
</label> |
|||
<Slider |
|||
value={[min, max]} |
|||
min={lo} |
|||
max={hi} |
|||
step={Number.isInteger(lo) ? 1 : 0.1} |
|||
onValueChange={(newRange) => { |
|||
const [newMin, newMax] = newRange; |
|||
onChange(cfg.key, [newMin, newMax]); |
|||
}} |
|||
className="w-full pb-3" |
|||
trackClassName="h-1 bg-slate-200 dark:bg-slate-700" |
|||
rangeClassName="bg-blue-500" |
|||
thumbClassName="w-3 h-3 bg-white border border-slate-400 dark:border-slate-600" |
|||
aria-label={`Slider - ${cfg.label}`} |
|||
/> |
|||
</div> |
|||
); |
|||
} |
|||
case "multi": { |
|||
const safeArray = (() => { |
|||
if (!Array.isArray(val)) return []; |
|||
return val.filter((x): x is string => |
|||
typeof x === "string" |
|||
); |
|||
})(); |
|||
|
|||
const allSelected = cfg.options.length > 0 && |
|||
cfg.options.every((opt) => safeArray.includes(opt)); |
|||
|
|||
return ( |
|||
<ScrollArea className="h-64 border rounded-md"> |
|||
<div |
|||
key={cfg.key} |
|||
className="space-y-2 px-2 py-3" |
|||
> |
|||
<button |
|||
type="button" |
|||
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-800" |
|||
onClick={() => |
|||
onChange( |
|||
cfg.key, |
|||
allSelected ? [] : [...cfg.options], |
|||
)} |
|||
> |
|||
{allSelected ? "Uncheck All" : "Check All"} |
|||
</button> |
|||
{cfg.options.map((opt) => ( |
|||
<Checkbox |
|||
key={opt.replace(/ /g, "_")} |
|||
checked={safeArray.includes(opt)} |
|||
onChange={(checked) => |
|||
onChange( |
|||
cfg.key, |
|||
checked |
|||
? [...safeArray, opt] |
|||
: safeArray.filter((s) => s !== opt), |
|||
)} |
|||
> |
|||
{opt} |
|||
</Checkbox> |
|||
))} |
|||
</div> |
|||
</ScrollArea> |
|||
); |
|||
} |
|||
case "search": |
|||
if (typeof val !== "string") return null; |
|||
return ( |
|||
<div |
|||
key={`${cfg.key}_div`} |
|||
className="flex flex-col space-y-1 pb-2" |
|||
> |
|||
<label |
|||
htmlFor={cfg.key} |
|||
className="font-medium text-sm" |
|||
> |
|||
{cfg.label} |
|||
</label> |
|||
<input |
|||
id={cfg.key} |
|||
type="text" |
|||
value={val} |
|||
onChange={(e) => |
|||
onChange(cfg.key, e.target.value)} |
|||
placeholder="Search phrase" |
|||
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600" |
|||
/> |
|||
</div> |
|||
); |
|||
|
|||
default: |
|||
return null; |
|||
} |
|||
})} |
|||
</AccordionContent> |
|||
</AccordionItem> |
|||
))} |
|||
</Accordion> |
|||
<button |
|||
type="button" |
|||
onClick={resetFilters} |
|||
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-800" |
|||
> |
|||
Reset Filters |
|||
</button> |
|||
|
|||
{children && ( |
|||
<div className="mt-4 border-t pt-4"> |
|||
{children} |
|||
</div> |
|||
)} |
|||
</div> |
|||
</PopoverContent> |
|||
</Popover> |
|||
); |
|||
} |
|||
Loading…
Reference in new issue