Browse Source

Fix default filter behaviour (#820)

Co-authored-by: philon- <[email protected]>
pull/822/head
Jeremy Gallant 9 months ago
committed by GitHub
parent
commit
01fa030ef9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 231
      packages/web/src/components/generic/Filter/FilterControl.tsx
  2. 52
      packages/web/src/components/generic/Filter/useFilterNode.test.ts
  3. 10
      packages/web/src/components/generic/Filter/useFilterNode.ts

231
packages/web/src/components/generic/Filter/FilterControl.tsx

@ -16,20 +16,19 @@ import {
import { cn } from "@core/utils/cn.ts";
import { debounce } from "@core/utils/debounce.ts";
import { Protobuf } from "@meshtastic/core";
import type { TFunction } from "i18next";
import { FunnelIcon } from "lucide-react";
import {
type ComponentProps,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
const DEBOUNCE_DELAY_MS = 250;
const BATTERY_STATUS_PLUGGED_IN_VALUE = 101;
type PopoverContentProps = ComponentProps<typeof PopoverContent>;
@ -47,88 +46,57 @@ interface FilterControlProps {
children?: ReactNode;
}
interface HopsLabelProps {
hopsAway: number[];
t: TFunction<"ui", undefined>;
interface RangeLabelContentProps {
range: [number, number];
defaultRange: [number, number];
format?: (ts: number) => ReactNode;
initialLabel?: ReactNode;
customLabel?: { start?: string; end?: string };
}
function HopsLabelContent({ hopsAway, t }: HopsLabelProps) {
const startHops = hopsAway[0];
const endHops = hopsAway[1];
return (
<>
{t("hops.text", {
value: startHops === 0 ? t("hops.direct") : startHops,
})}
{startHops !== endHops ? `${endHops}` : ""}
</>
);
}
interface LastHeardLabelProps {
lastHeardRange: number[];
defaultMaxLastHeard: number;
formatTS: (ts: number) => ReactNode;
t: TFunction<"ui", undefined>;
}
function LastHeardLabelContent({
lastHeardRange,
defaultMaxLastHeard,
formatTS,
t,
}: LastHeardLabelProps) {
const [start, end] = lastHeardRange;
return (
<>
{t("lastHeard.labelText", { value: "" })}
<br />
{start === 0 ? (
t("lastHeard.nowLabel")
) : (
<>
{start === defaultMaxLastHeard && ">"}
{formatTS(start)}
</>
)}
{start !== end && (
<>
{" — "}
{end === defaultMaxLastHeard && ">"}
{formatTS(end)}
</>
)}
</>
);
}
function RangeLabelContent({
range,
defaultRange,
format,
initialLabel,
customLabel,
}: RangeLabelContentProps) {
const [start, end] = range;
const [min, max] = defaultRange;
const unequal = start !== end;
interface BatteryLevelLabelProps {
batteryLevelRange: (number | undefined)[];
t: TFunction<"ui", undefined>;
}
function BatteryLevelLabelContent({
batteryLevelRange,
t,
}: BatteryLevelLabelProps) {
const [start, end] = batteryLevelRange;
const formatBatteryValue = (value: number | undefined) => {
if (value === undefined) {
return "";
}
return value === BATTERY_STATUS_PLUGGED_IN_VALUE
? t("batteryStatus.pluggedIn")
: `${value}%`;
};
const fmtStart = format ? format(start) : start;
const fmtEnd = format ? format(end) : end;
return (
<>
{t("batteryLevel.labelText", {
value: formatBatteryValue(start),
})}
{start !== end && typeof end !== "undefined" && (
{initialLabel}
{start === min
? (customLabel?.start ?? (
<>
{"<"}
{fmtStart}
</>
))
: start === max
? (customLabel?.end ?? (
<>
{">"}
{fmtEnd}
</>
))
: fmtStart}
{unequal && (
<>
{" – "}
{formatBatteryValue(end)}
{" — "}
{end === max
? (customLabel?.end ?? (
<>
{">"}
{fmtEnd}
</>
))
: fmtEnd}
</>
)}
</>
@ -190,18 +158,20 @@ export function FilterControl({
);
const handleBoolChange = useCallback(
<K extends keyof FilterState>(key: K, value: string | boolean) => {
const typedValue =
value === true || value === "true"
? true
: value === false || value === "false"
? false
: undefined;
<K extends keyof FilterState>(
key: K,
value: string | boolean | undefined,
) => {
let typedValue: boolean | undefined;
if (value === true || value === "true") {
typedValue = true;
} else if (value === false || value === "false") {
typedValue = false;
} else {
typedValue = undefined;
}
setFilterState((prev) => ({
...prev,
[key]: typedValue,
}));
setFilterState((prev) => ({ ...prev, [key]: typedValue }));
},
[setFilterState],
);
@ -220,6 +190,21 @@ export function FilterControl({
[],
);
const roleOptions = useMemo(
() =>
Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter(
(v): v is number => typeof v === "number",
),
[],
);
const hwModelOptions = useMemo(
() =>
Object.values(Protobuf.Mesh.HardwareModel).filter(
(v): v is number => typeof v === "number",
),
[],
);
return (
<Popover>
<PopoverTrigger asChild>
@ -229,9 +214,8 @@ export function FilterControl({
"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"
: "",
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={t("filter.label")}
@ -269,30 +253,31 @@ export function FilterControl({
</div>
)}
<FilterSlider
label={t("hops.label")}
filterKey="hopsAway"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<HopsLabelContent
hopsAway={localFilterState.hopsAway}
t={t}
<RangeLabelContent
range={localFilterState.hopsAway}
defaultRange={defaultFilterValues.hopsAway}
initialLabel={`${t("hops.label")}: `}
customLabel={{ start: "0", end: "7" }}
/>
}
/>
<FilterSlider
label={t("lastHeard.label")}
filterKey="lastHeard"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<LastHeardLabelContent
lastHeardRange={localFilterState.lastHeard}
defaultMaxLastHeard={defaultFilterValues.lastHeard[1]}
formatTS={formatTS}
t={t}
<RangeLabelContent
range={localFilterState.lastHeard}
defaultRange={defaultFilterValues.lastHeard}
format={formatTS}
initialLabel={`${t("lastHeard.label")}: `}
customLabel={{ start: t("lastHeard.nowLabel") }}
/>
}
/>
@ -314,11 +299,17 @@ export function FilterControl({
<FilterAccordionItem label={t("metrics.label")}>
<FilterSlider
label={t("snr.label")}
filterKey="snr"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<RangeLabelContent
range={localFilterState.snr}
defaultRange={defaultFilterValues.snr}
initialLabel={`${t("snr.label")}: `}
/>
}
/>
<FilterSlider
label={t("channelUtilization.label")}
@ -335,24 +326,36 @@ export function FilterControl({
onChange={handleRangeChange}
/>
<FilterSlider
label={t("batteryLevel.label")}
filterKey="batteryLevel"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
labelContent={
<BatteryLevelLabelContent
batteryLevelRange={localFilterState.batteryLevel}
t={t}
<RangeLabelContent
range={localFilterState.batteryLevel}
defaultRange={defaultFilterValues.batteryLevel}
initialLabel={`${t("batteryLevel.label")}: `}
customLabel={{
start: "0",
end: t("batteryStatus.pluggedIn"),
}}
/>
}
/>
<FilterSlider
label={t("batteryVoltage.label")}
filterKey="voltage"
filterState={localFilterState}
defaultFilterValues={defaultFilterValues}
onChange={handleRangeChange}
step={0.1}
labelContent={
<RangeLabelContent
range={localFilterState.voltage}
defaultRange={defaultFilterValues.voltage}
initialLabel={`${t("batteryVoltage.label")}: `}
customLabel={{ start: "0" }}
/>
}
/>
</FilterAccordionItem>
@ -361,11 +364,11 @@ export function FilterControl({
filterKey="role"
filterState={filterState}
setFilterState={setFilterState}
options={Object.values(
Protobuf.Config.Config_DeviceConfig_Role,
).filter((v): v is number => typeof v === "number")}
options={roleOptions}
getLabel={(val) =>
formatEnumLabel(Protobuf.Config.Config_DeviceConfig_Role[val])
formatEnumLabel(
Protobuf.Config.Config_DeviceConfig_Role[val] ?? "UNSET",
)
}
/>
</FilterAccordionItem>
@ -375,11 +378,9 @@ export function FilterControl({
filterKey="hwModel"
filterState={filterState}
setFilterState={setFilterState}
options={Object.values(Protobuf.Mesh.HardwareModel).filter(
(v): v is number => typeof v === "number",
)}
options={hwModelOptions}
getLabel={(val) =>
formatEnumLabel(Protobuf.Mesh.HardwareModel[val])
formatEnumLabel(Protobuf.Mesh.HardwareModel[val] ?? "UNKNOWN")
}
/>
</FilterAccordionItem>

52
packages/web/src/components/generic/Filter/useFilterNode.test.ts

@ -180,4 +180,56 @@ describe("useFilterNode", () => {
expect(isFilterDirty(modified)).toBe(true);
});
});
describe("default-boundary semantics", () => {
const node = createMockNode();
it("lastHeard: end at default max means 'any age'", () => {
const {
lastHeard: [lastHeardMin, lastHeardMax],
} = defaultFilterValues;
const veryOld = { ...node, lastHeard: lastHeardMax + 1 }; // older than slider max
expect(
nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax] }),
).toBe(true); // open upper
expect(
nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax - 1] }),
).toBe(false); // now bounded
});
it("snr: max at default means no upper bound", () => {
const {
snr: [snrMin, snrMax],
} = defaultFilterValues;
const hiSnr = { ...node, snr: snrMax + 1 }; // above slider max
expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax] })).toBe(true); // open upper
expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax - 1] })).toBe(false); // bounded
});
it("snr: min at default means no lower bound", () => {
const {
snr: [snrMin, snrMax],
} = defaultFilterValues;
const loSnr = { ...node, snr: snrMin - 1 }; // below slider min
expect(nodeFilter(loSnr, { snr: [snrMin, snrMax] })).toBe(true); // open lower
expect(nodeFilter(loSnr, { snr: [snrMin + 1, snrMax] })).toBe(false); // bounded
});
it("voltage: max at default means no upper bound", () => {
const {
voltage: [voltageMin, voltageMax],
} = defaultFilterValues;
const hiV = {
...node,
deviceMetrics: {
...node.deviceMetrics!,
voltage: voltageMax + 1,
},
} satisfies Protobuf.Mesh.NodeInfo;
expect(nodeFilter(hiV, { voltage: [voltageMin, voltageMax] })).toBe(true); // open upper
expect(
nodeFilter(hiV, { voltage: [voltageMin, voltageMax - 0.01] }),
).toBe(false); // bounded
});
});
});

10
packages/web/src/components/generic/Filter/useFilterNode.ts

@ -127,7 +127,12 @@ export function useFilterNode() {
}
const snr = node.snr ?? -20;
if (snr < filterState.snr[0] || snr > filterState.snr[1]) {
if (
(snr < filterState.snr[0] &&
filterState.snr[0] !== defaultFilterValues.snr[0]) ||
(snr > filterState.snr[1] &&
filterState.snr[1] !== defaultFilterValues.snr[1])
) {
return false;
}
@ -158,7 +163,8 @@ export function useFilterNode() {
const voltage = node.deviceMetrics?.voltage ?? 0;
if (
voltage < filterState.voltage[0] ||
voltage > filterState.voltage[1]
(voltage > filterState.voltage[1] &&
filterState.voltage[1] !== defaultFilterValues.voltage[1])
) {
return false;
}

Loading…
Cancel
Save