From 3dce031f8e5238ff09a7077a201467b4f109e4d2 Mon Sep 17 00:00:00 2001 From: Jeremy Gallant Date: Tue, 22 Apr 2025 23:18:26 +0200 Subject: [PATCH] Stricter typing, adjuster colors, mandatory props --- src/components/UI/Slider.tsx | 34 +++++++----- src/core/hooks/useNodeFilters.ts | 74 ++++++++++++++++--------- src/pages/Map/FilterControl.tsx | 94 +++++++++++++++++++++----------- src/pages/Map/index.tsx | 1 - 4 files changed, 131 insertions(+), 72 deletions(-) diff --git a/src/components/UI/Slider.tsx b/src/components/UI/Slider.tsx index 429224c3..5e692e9a 100644 --- a/src/components/UI/Slider.tsx +++ b/src/components/UI/Slider.tsx @@ -3,11 +3,10 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { cn } from "@core/utils/cn.ts"; export interface SliderProps { - value?: number[]; - defaultValue?: number[]; + value: number[]; step?: number; min?: number; - max?: number; + max: number; onValueChange?: (value: number[]) => void; onValueCommit?: (value: number[]) => void; disabled?: boolean; @@ -19,10 +18,9 @@ export interface SliderProps { export function Slider({ value, - defaultValue = [0], step = 1, min = 0, - max = 100, + max, onValueChange, onValueCommit, disabled = false, @@ -30,8 +28,8 @@ export function Slider({ trackClassName, rangeClassName, thumbClassName, -}:SliderProps) { - const [internalValue, setInternalValue] = useState(defaultValue); +}: SliderProps) { + const [internalValue, setInternalValue] = useState(value); const isControlled = value !== undefined; const currentValue = isControlled ? value! : internalValue; @@ -46,9 +44,11 @@ export function Slider({ return ( {currentValue.map((_, i) => ( ))} ); -}; +} diff --git a/src/core/hooks/useNodeFilters.ts b/src/core/hooks/useNodeFilters.ts index 9f3d9869..85039b16 100644 --- a/src/core/hooks/useNodeFilters.ts +++ b/src/core/hooks/useNodeFilters.ts @@ -1,24 +1,40 @@ -import { useState, useMemo, useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; import type { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -export type FilterValue = - | boolean - | [number, number] - | string[] - | string; +interface BooleanFilter { + key: string; + label: string; + type: "boolean"; + predicate: (node: Node, value: boolean) => boolean; +} + +interface RangeFilter { + key: string; + label: string; + type: "range"; + bounds: [number, number]; + predicate: (node: Node, value: [number, number]) => boolean; +} -export interface FilterConfig { +interface SearchFilter { key: string; label: string; - type: "boolean" | "range" | "search"; - bounds?: [number, number]; - options?: string[]; - predicate: (node: Protobuf.Mesh.NodeInfo, value: T) => boolean; + type: "search"; + predicate: (node: Node, value: string) => boolean; } +export type FilterConfig = BooleanFilter | RangeFilter | SearchFilter; + +export type FilterValueMap = { + [C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean + : C extends RangeFilter ? [number, number] + : C extends SearchFilter ? string + : never; +}; + // Defines all node filters in this object -export const filterConfigs: FilterConfig[] = [ +export const filterConfigs: FilterConfig[] = [ { key: "searchText", label: "Node name/number", @@ -30,7 +46,9 @@ export const filterConfigs: FilterConfig[] = [ 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, "")); + return shortName.includes(search) || longName.includes(search) || + nodeNum.includes(search) || + nodeNumHex.includes(search.replace(/!/g, "")); }, }, { @@ -84,11 +102,11 @@ export const filterConfigs: FilterConfig[] = [ label: "Hide MQTT-connected nodes", type: "boolean", predicate: (node, hide: boolean) => !hide || !node.viaMqtt, - } + }, ]; export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { - const defaultState = useMemo>(() => { + const defaultState = useMemo(() => { return filterConfigs.reduce((acc, cfg) => { switch (cfg.type) { case "boolean": @@ -102,31 +120,37 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { break; } return acc; - }, {} as Record); + }, {} as FilterValueMap); }, []); - const [filters, setFilters] = useState>(defaultState); + const [filters, setFilters] = useState( + defaultState, + ); const resetFilters = useCallback(() => { setFilters(defaultState); }, [defaultState]); const onFilterChange = useCallback( - (key: string, value: FilterValue) => { + (key: K, value: FilterValueMap[K]) => { setFilters((f) => ({ ...f, [key]: value })); }, - [] + [], ); const filteredNodes = useMemo( () => nodes.filter((node) => - filterConfigs.every((cfg) => - cfg.predicate(node, filters[cfg.key]) - ) + filterConfigs.every((cfg) => cfg.predicate(node, filters[cfg.key])) ), - [nodes, filters] + [nodes, filters], ); - return { filters, onFilterChange, resetFilters, filteredNodes, filterConfigs }; -} \ No newline at end of file + return { + filters, + onFilterChange, + resetFilters, + filteredNodes, + filterConfigs, + }; +} diff --git a/src/pages/Map/FilterControl.tsx b/src/pages/Map/FilterControl.tsx index 0d13f94c..56e00986 100644 --- a/src/pages/Map/FilterControl.tsx +++ b/src/pages/Map/FilterControl.tsx @@ -1,16 +1,30 @@ -import { Popover, PopoverTrigger, PopoverContent } from "@components/UI/Popover.tsx"; +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 type { FilterConfig, FilterValue } from "@core/hooks/useNodeFilters.ts"; +import type { + FilterConfig, + FilterValueMap, +} from "@core/hooks/useNodeFilters.ts"; interface FilterControlProps { configs: FilterConfig[]; - values: Record; - onChange: (key: string, value: FilterValue) => void; + values: FilterValueMap; + onChange: ( + key: K, + value: FilterValueMap[K], + ) => void; + resetFilters: () => void; + children?: React.ReactNode; } -export function FilterControl({ configs, values, onChange, resetFilters, children }: FilterControlProps) { +export function FilterControl( + { configs, values, onChange, resetFilters, children }: FilterControlProps, +) { return ( @@ -22,24 +36,38 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre - +
{configs.map((cfg) => { const val = values[cfg.key]; switch (cfg.type) { case "boolean": + if (typeof val !== "boolean") return null; return ( onChange(cfg.key, v as boolean)} + checked={val} + onChange={(v) => onChange(cfg.key, v)} labelClassName="dark:text-gray-900" - > + > {cfg.label} ); case "range": { - const [min, max] = val as [number, number]; + 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!; return (
@@ -51,9 +79,10 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre min={lo} max={hi} step={1} - onValueChange={(newRange) => - onChange(cfg.key, newRange as [number, number]) - } + onValueChange={(newRange) => { + const [newMin, newMax] = newRange; + onChange(cfg.key, [newMin, newMax]); + }} className="w-full" trackClassName="h-1 bg-gray-200 dark:bg-slate-700" rangeClassName="bg-blue-500" @@ -63,24 +92,25 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre ); } case "search": - return ( -
- - 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" - /> -
- ); + if (typeof val !== "string") return null; + return ( +
+ + 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" + /> +
+ ); - default: - return null; + default: + return null; } })} @@ -89,7 +119,7 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre onClick={resetFilters} className="w-full py-1 bg-slate-600 text-white rounded text-sm" > - Reset Filters + Reset Filters {children && ( @@ -101,4 +131,4 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre ); -} \ No newline at end of file +} diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index 6c11ee30..d0ba54fb 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -214,7 +214,6 @@ const MapPage = () => { onChange={onFilterChange} resetFilters={resetFilters} /> - );