Filter rework (#623)

* 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- <philon-@users.noreply.github.com>
This commit is contained in:
philon-
2025-05-22 15:11:32 -04:00
committed by GitHub
co-authored by philon-
parent 9cb5cffdb1
commit a642080b90
13 changed files with 1077 additions and 676 deletions
+58
View File
@@ -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>
);
};
+2 -2
View File
@@ -16,7 +16,7 @@ export const AccordionTrigger = forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex justify-between items-center w-full p-4 border-b border-slat-200 dark:border-slat-800 group",
"flex justify-between items-center w-full p-4 border-b border-slate-200 dark:border-slate-800 group",
className,
)}
{...props}
@@ -36,7 +36,7 @@ export const AccordionContent = forwardRef<
<AccordionPrimitive.Content
ref={ref}
className={cn(
"p-4 border-b border-slat-200 dark:border-slat-800",
"p-4 border-b border-slate-200 dark:border-slate-800",
className,
)}
{...props}
+48
View File
@@ -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 };
}
+4 -2
View File
@@ -114,7 +114,7 @@ export const Table = ({ headings, rows }: TableProps) => {
});
return (
<table className="min-w-full">
<table className="min-w-full" style={{ contentVisibility: "auto" }}>
<thead className="text-xs font-semibold">
<tr>
{headings.map((heading) => (
@@ -128,7 +128,9 @@ export const Table = ({ headings, rows }: TableProps) => {
}`}
onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={(e) => {
if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
if (
heading.sortable && (e.key === "Enter" || e.key === " ")
) {
headingSort(heading.title);
}
}}