Add map filter groups / more filters / update UI
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
|
||||||
interface BooleanFilter {
|
interface BooleanFilter {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
group: string;
|
||||||
type: "boolean";
|
type: "boolean";
|
||||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean;
|
predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean;
|
||||||
}
|
}
|
||||||
@@ -12,6 +13,7 @@ interface BooleanFilter {
|
|||||||
interface RangeFilter {
|
interface RangeFilter {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
group: string;
|
||||||
type: "range";
|
type: "range";
|
||||||
bounds: [number, number];
|
bounds: [number, number];
|
||||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean;
|
predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean;
|
||||||
@@ -20,16 +22,31 @@ interface RangeFilter {
|
|||||||
interface SearchFilter {
|
interface SearchFilter {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
group: string;
|
||||||
type: "search";
|
type: "search";
|
||||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean;
|
predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterConfig = BooleanFilter | RangeFilter | SearchFilter;
|
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 = {
|
export type FilterValueMap = {
|
||||||
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean
|
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean
|
||||||
: C extends RangeFilter ? [number, number]
|
: C extends RangeFilter ? [number, number]
|
||||||
: C extends SearchFilter ? string
|
: C extends SearchFilter ? string
|
||||||
|
: C extends MultiFilter ? string[]
|
||||||
: never;
|
: never;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +55,7 @@ export const filterConfigs: FilterConfig[] = [
|
|||||||
{
|
{
|
||||||
key: "searchText",
|
key: "searchText",
|
||||||
label: "Node name/number",
|
label: "Node name/number",
|
||||||
|
group: "General",
|
||||||
type: "search",
|
type: "search",
|
||||||
predicate: (node, text: string) => {
|
predicate: (node, text: string) => {
|
||||||
if (!text) return true;
|
if (!text) return true;
|
||||||
@@ -51,15 +69,10 @@ export const filterConfigs: FilterConfig[] = [
|
|||||||
nodeNumHex.includes(search.replace(/!/g, ""));
|
nodeNumHex.includes(search.replace(/!/g, ""));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "favOnly",
|
|
||||||
label: "Show favourites only",
|
|
||||||
type: "boolean",
|
|
||||||
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "hopRange",
|
key: "hopRange",
|
||||||
label: "Number of hops",
|
label: "Number of hops",
|
||||||
|
group: "General",
|
||||||
type: "range",
|
type: "range",
|
||||||
bounds: [0, 7],
|
bounds: [0, 7],
|
||||||
predicate: (node, [min, max]: [number, number]) => {
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
@@ -67,9 +80,47 @@ export const filterConfigs: FilterConfig[] = [
|
|||||||
return hops >= min && hops <= max;
|
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",
|
key: "channelUtilization",
|
||||||
label: "Channel Utilization (%)",
|
label: "Channel Utilization (%)",
|
||||||
|
group: "Metrics",
|
||||||
type: "range",
|
type: "range",
|
||||||
bounds: [0, 100],
|
bounds: [0, 100],
|
||||||
predicate: (node, [min, max]: [number, number]) => {
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
@@ -80,6 +131,7 @@ export const filterConfigs: FilterConfig[] = [
|
|||||||
{
|
{
|
||||||
key: "airUtilTx",
|
key: "airUtilTx",
|
||||||
label: "Airtime Utilization (%)",
|
label: "Airtime Utilization (%)",
|
||||||
|
group: "Metrics",
|
||||||
type: "range",
|
type: "range",
|
||||||
bounds: [0, 100],
|
bounds: [0, 100],
|
||||||
predicate: (node, [min, max]: [number, number]) => {
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
@@ -90,6 +142,7 @@ export const filterConfigs: FilterConfig[] = [
|
|||||||
{
|
{
|
||||||
key: "battery",
|
key: "battery",
|
||||||
label: "Battery level (%)",
|
label: "Battery level (%)",
|
||||||
|
group: "Metrics",
|
||||||
type: "range",
|
type: "range",
|
||||||
bounds: [0, 101],
|
bounds: [0, 101],
|
||||||
predicate: (node, [min, max]: [number, number]) => {
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
@@ -98,10 +151,52 @@ export const filterConfigs: FilterConfig[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "viaMqtt",
|
key: "voltage",
|
||||||
label: "Hide MQTT-connected nodes",
|
label: "Battery voltage (V)",
|
||||||
type: "boolean",
|
group: "Metrics",
|
||||||
predicate: (node, hide: boolean) => !hide || !node.viaMqtt,
|
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]);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -118,6 +213,9 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
|||||||
case "search":
|
case "search":
|
||||||
acc[cfg.key] = "";
|
acc[cfg.key] = "";
|
||||||
break;
|
break;
|
||||||
|
case "multi":
|
||||||
|
acc[cfg.key] = cfg.options;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as FilterValueMap);
|
}, {} as FilterValueMap);
|
||||||
@@ -127,6 +225,15 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
|||||||
defaultState,
|
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(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setFilters(defaultState);
|
setFilters(defaultState);
|
||||||
}, [defaultState]);
|
}, [defaultState]);
|
||||||
@@ -148,7 +255,7 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
|||||||
if (typeof val !== "boolean") return true;
|
if (typeof val !== "boolean") return true;
|
||||||
return cfg.predicate(node, val);
|
return cfg.predicate(node, val);
|
||||||
|
|
||||||
case "range":
|
case "range": {
|
||||||
if (
|
if (
|
||||||
!Array.isArray(val) ||
|
!Array.isArray(val) ||
|
||||||
val.length !== 2 ||
|
val.length !== 2 ||
|
||||||
@@ -157,8 +264,16 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return cfg.predicate(node, val);
|
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":
|
case "search":
|
||||||
if (typeof val !== "string") return true;
|
if (typeof val !== "string") return true;
|
||||||
return cfg.predicate(node, val);
|
return cfg.predicate(node, val);
|
||||||
@@ -174,6 +289,6 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
filteredNodes,
|
filteredNodes,
|
||||||
filterConfigs,
|
groupedFilterConfigs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-72
@@ -6,14 +6,23 @@ import {
|
|||||||
import { FunnelIcon } from "lucide-react";
|
import { FunnelIcon } from "lucide-react";
|
||||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||||
import { Slider } from "@components/UI/Slider.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 {
|
import type {
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
FilterValueMap,
|
FilterValueMap,
|
||||||
} from "@core/hooks/useNodeFilters.ts";
|
} from "@core/hooks/useNodeFilters.ts";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||||
|
|
||||||
interface FilterControlProps {
|
interface FilterControlProps {
|
||||||
configs: FilterConfig[];
|
groupedFilterConfigs: Record<string, FilterConfig[]>;
|
||||||
values: FilterValueMap;
|
values: FilterValueMap;
|
||||||
onChange: <K extends keyof FilterValueMap>(
|
onChange: <K extends keyof FilterValueMap>(
|
||||||
key: K,
|
key: K,
|
||||||
@@ -25,7 +34,7 @@ interface FilterControlProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FilterControl(
|
export function FilterControl(
|
||||||
{ configs, values, onChange, resetFilters, isDirty, children }:
|
{ groupedFilterConfigs, values, onChange, resetFilters, isDirty, children }:
|
||||||
FilterControlProps,
|
FilterControlProps,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -51,78 +60,191 @@ export function FilterControl(
|
|||||||
className="dark:bg-slate-100 dark:border-slate-300"
|
className="dark:bg-slate-100 dark:border-slate-300"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{configs.map((cfg) => {
|
<Accordion
|
||||||
const val = values[cfg.key];
|
className="AccordionRoot"
|
||||||
switch (cfg.type) {
|
type="single"
|
||||||
case "boolean":
|
defaultValue={Object.entries(groupedFilterConfigs)[0][0]}
|
||||||
if (typeof val !== "boolean") return null;
|
collapsible
|
||||||
return (
|
>
|
||||||
<Checkbox
|
{Object.entries(groupedFilterConfigs).map((
|
||||||
key={cfg.key}
|
[groupName, groupConfigs],
|
||||||
checked={val}
|
) => (
|
||||||
onChange={(v) => onChange(cfg.key, v)}
|
<AccordionItem key={groupName} value={groupName}>
|
||||||
labelClassName="dark:text-gray-900"
|
<AccordionHeader>
|
||||||
>
|
<AccordionTrigger className="w-full text-left font-bold text-sm px-1 py-2">
|
||||||
{cfg.label}
|
{groupName}
|
||||||
</Checkbox>
|
</AccordionTrigger>
|
||||||
);
|
</AccordionHeader>
|
||||||
case "range": {
|
<AccordionContent className="px-1 pb-4 pt-2 space-y-3">
|
||||||
if (
|
{groupConfigs.map((cfg) => {
|
||||||
!Array.isArray(val) ||
|
const val = values[cfg.key];
|
||||||
val.length !== 2 ||
|
switch (cfg.type) {
|
||||||
typeof val[0] !== "number" ||
|
case "boolean":
|
||||||
typeof val[1] !== "number"
|
if (typeof val !== "boolean") return null;
|
||||||
) {
|
return (
|
||||||
return null;
|
<Checkbox
|
||||||
}
|
key={cfg.key}
|
||||||
const [min, max] = val;
|
checked={val}
|
||||||
const [lo, hi] = cfg.bounds;
|
onChange={(v) => onChange(cfg.key, v)}
|
||||||
return (
|
className="pb-1"
|
||||||
<div key={cfg.key} className="space-y-2">
|
labelClassName="dark:text-gray-900"
|
||||||
<label className="block text-sm font-medium">
|
>
|
||||||
{cfg.label}: {min} – {max}
|
{cfg.label}
|
||||||
</label>
|
</Checkbox>
|
||||||
<Slider
|
);
|
||||||
value={[min, max]}
|
case "range": {
|
||||||
min={lo}
|
if (
|
||||||
max={hi}
|
!Array.isArray(val) ||
|
||||||
step={1}
|
val.length !== 2 ||
|
||||||
onValueChange={(newRange) => {
|
typeof val[0] !== "number" ||
|
||||||
const [newMin, newMax] = newRange;
|
typeof val[1] !== "number"
|
||||||
onChange(cfg.key, [newMin, newMax]);
|
) {
|
||||||
}}
|
return null;
|
||||||
className="w-full"
|
}
|
||||||
trackClassName="h-1 bg-gray-200 dark:bg-slate-700"
|
const [min, max] = val;
|
||||||
rangeClassName="bg-blue-500"
|
const [lo, hi] = cfg.bounds;
|
||||||
thumbClassName="w-3 h-3 bg-white border border-gray-400 dark:border-slate-600"
|
|
||||||
aria-label={`Slider - ${cfg.label}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "search":
|
|
||||||
if (typeof val !== "string") return null;
|
|
||||||
return (
|
|
||||||
<div key={cfg.key} className="flex flex-col space-y-1">
|
|
||||||
<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:
|
let formattedMin = null;
|
||||||
return 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-gray-200 dark:bg-slate-700"
|
||||||
|
rangeClassName="bg-blue-500"
|
||||||
|
thumbClassName="w-3 h-3 bg-white border border-gray-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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const MapPage = () => {
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
filteredNodes,
|
filteredNodes,
|
||||||
filterConfigs,
|
groupedFilterConfigs,
|
||||||
} = useNodeFilters(validNodes);
|
} = useNodeFilters(validNodes);
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
@@ -221,7 +221,7 @@ const MapPage = () => {
|
|||||||
</MapGl>
|
</MapGl>
|
||||||
|
|
||||||
<FilterControl
|
<FilterControl
|
||||||
configs={filterConfigs}
|
groupedFilterConfigs={groupedFilterConfigs}
|
||||||
values={filters}
|
values={filters}
|
||||||
onChange={onFilterChange}
|
onChange={onFilterChange}
|
||||||
resetFilters={resetFilters}
|
resetFilters={resetFilters}
|
||||||
|
|||||||
Reference in New Issue
Block a user