Add monorepo support to project (#682)
* feat: add monorepo support * fix: correct relatedProjects structure in vercel.json * update github actions with monorepo support * removed legacy dep * moved jsr config into web package
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export const Blur = () => {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-md backdrop-brightness-press"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface DeviceImageProps {
|
||||
deviceType: string;
|
||||
className?: React.HTMLAttributes<HTMLImageElement>["className"];
|
||||
}
|
||||
|
||||
const hardwareModelToFilename: { [key: string]: string } = {
|
||||
DIY_V1: "diy.svg",
|
||||
NANO_G2_ULTRA: "nano-g2-ultra.svg",
|
||||
TBEAM: "tbeam.svg",
|
||||
HELTEC_HT62: "heltec-ht62-esp32c3-sx1262.svg",
|
||||
RPI_PICO: "pico.svg",
|
||||
T_DECK: "t-deck.svg",
|
||||
HELTEC_MESH_NODE_T114: "heltec-mesh-node-t114.svg",
|
||||
HELTEC_MESH_NODE_T114_CASE: "heltec-mesh-node-t114-case.svg",
|
||||
HELTEC_V3: "heltec-v3.svg",
|
||||
HELTEC_V3_CASE: "heltec-v3-case.svg",
|
||||
HELTEC_VISION_MASTER_E213: "heltec-vision-master-e213.svg",
|
||||
HELTEC_VISION_MASTER_E290: "heltec-vision-master-e290.svg",
|
||||
HELTEC_VISION_MASTER_T190: "heltec-vision-master-t190.svg",
|
||||
HELTEC_WIRELESS_PAPER: "heltec-wireless-paper.svg",
|
||||
HELTEC_WIRELESS_PAPER_V1_0: "heltec-wireless-paper-V1_0.svg",
|
||||
HELTEC_WIRELESS_TRACKER: "heltec-wireless-tracker.svg",
|
||||
HELTEC_WIRELESS_TRACKER_V1_0: "heltec-wireless-tracker-V1-0.svg",
|
||||
HELTEC_WSL_V3: "heltec-wsl-v3.svg",
|
||||
TLORA_C6: "tlora-c6.svg",
|
||||
TLORA_T3_S3: "tlora-t3s3-v1.svg",
|
||||
TLORA_T3_S3_EPAPER: "tlora-t3s3-epaper.svg",
|
||||
TLORA_V2: "tlora-v2-1-1_6.svg",
|
||||
TLORA_V2_1_1P6: "tlora-v2-1-1_6.svg",
|
||||
TLORA_V2_1_1P8: "tlora-v2-1-1_8.svg",
|
||||
RAK11310: "rak11310.svg",
|
||||
RAK2560: "rak2560.svg",
|
||||
RAK4631: "rak4631.svg",
|
||||
RAK4631_CASE: "rak4631_case.svg",
|
||||
WIO_WM1110: "wio-tracker-wm1110.svg",
|
||||
WM1110_DEV_KIT: "wm1110_dev_kit.svg",
|
||||
STATION_G2: "station-g2.svg",
|
||||
TBEAM_V0P7: "tbeam-s3-core.svg",
|
||||
T_ECHO: "t-echo.svg",
|
||||
TRACKER_T1000_E: "tracker-t1000-e.svg",
|
||||
T_WATCH_S3: "t-watch-s3.svg",
|
||||
SEEED_XIAO_S3: "seeed-xiao-s3.svg",
|
||||
SENSECAP_INDICATOR: "seeed-sensecap-indicator.svg",
|
||||
PROMICRO: "promicro.svg",
|
||||
RPIPICOW: "rpipicow.svg",
|
||||
UNKNOWN: "unknown.svg",
|
||||
};
|
||||
|
||||
export const DeviceImage = ({ deviceType, className }: DeviceImageProps) => {
|
||||
const getPath = (device: string) => `/${device}`;
|
||||
const device = hardwareModelToFilename[deviceType] || "unknown.svg";
|
||||
return <img className={className} src={getPath(device)} alt={device} />;
|
||||
};
|
||||
@@ -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,426 @@
|
||||
import {
|
||||
type ComponentProps,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { FunnelIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { debounce } from "@core/utils/debounce.ts";
|
||||
import { cn } from "@core/utils/cn.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 { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import {
|
||||
FilterAccordionItem,
|
||||
FilterMulti,
|
||||
FilterSlider,
|
||||
FilterToggle,
|
||||
} from "@components/generic/Filter/FilterComponents.tsx";
|
||||
|
||||
import type { FilterState } from "@components/generic/Filter/useFilterNode.ts";
|
||||
|
||||
const DEBOUNCE_DELAY_MS = 250;
|
||||
const BATTERY_STATUS_PLUGGED_IN_VALUE = 101;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface HopsLabelProps {
|
||||
hopsAway: number[];
|
||||
t: TFunction<"ui", undefined>;
|
||||
}
|
||||
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)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{t("batteryLevel.labelText", {
|
||||
value: formatBatteryValue(start),
|
||||
})}
|
||||
{start !== end && typeof end !== "undefined" && (
|
||||
<>
|
||||
{" – "}
|
||||
{formatBatteryValue(end)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterControl({
|
||||
filterState,
|
||||
defaultFilterValues,
|
||||
setFilterState,
|
||||
isDirty,
|
||||
parameters,
|
||||
children,
|
||||
}: FilterControlProps) {
|
||||
const { t } = useTranslation("ui");
|
||||
const [localFilterState, setLocalFilterState] = useState(filterState);
|
||||
const skipNextFilterStateSync = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextFilterStateSync.current) {
|
||||
skipNextFilterStateSync.current = false;
|
||||
return;
|
||||
}
|
||||
setLocalFilterState(filterState);
|
||||
}, [filterState]);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
<K extends keyof FilterState>(key: K) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
[key]: e.target.value,
|
||||
}));
|
||||
},
|
||||
[setFilterState],
|
||||
);
|
||||
|
||||
const debouncedSetFilterState = useCallback(
|
||||
debounce(<K extends keyof FilterState>(key: K, value: number[]) => {
|
||||
skipNextFilterStateSync.current = true;
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
}, DEBOUNCE_DELAY_MS),
|
||||
[setFilterState],
|
||||
);
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
<K extends keyof FilterState>(key: K) => (value: number[]) => {
|
||||
setLocalFilterState((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
debouncedSetFilterState(key, value);
|
||||
},
|
||||
[debouncedSetFilterState],
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
setFilterState((prev) => ({
|
||||
...prev,
|
||||
[key]: typedValue,
|
||||
}));
|
||||
},
|
||||
[setFilterState],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilterState(defaultFilterValues);
|
||||
}, [defaultFilterValues, setFilterState]);
|
||||
|
||||
const formatTS = useCallback(
|
||||
(ts: number): ReactNode => <TimeAgo timestamp={Date.now() - ts * 1000} />,
|
||||
[],
|
||||
);
|
||||
|
||||
const formatEnumLabel = useCallback(
|
||||
(label: string): string => 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={t("filter.label")}
|
||||
>
|
||||
{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={t("general.label")}
|
||||
collapsible
|
||||
>
|
||||
<FilterAccordionItem label={t("general.label")}>
|
||||
{(parameters?.showTextSearch ?? true) && (
|
||||
<div className="flex flex-col space-y-1 pb-2">
|
||||
<label htmlFor="nodeName" className="font-medium text-sm">
|
||||
{t("nodeName.label")}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="nodeName"
|
||||
value={filterState.nodeName}
|
||||
onChange={handleTextChange("nodeName")}
|
||||
showClearButton
|
||||
placeholder={t("nodeName.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FilterSlider
|
||||
label={t("hops.label")}
|
||||
filterKey="hopsAway"
|
||||
filterState={localFilterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
onChange={handleRangeChange}
|
||||
labelContent={
|
||||
<HopsLabelContent
|
||||
hopsAway={localFilterState.hopsAway}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FilterToggle
|
||||
label={t("favorites.label")}
|
||||
filterKey="isFavorite"
|
||||
alternativeLabels={[
|
||||
t("hide.label"),
|
||||
t("showOnly.label"),
|
||||
]}
|
||||
filterState={filterState}
|
||||
onChange={handleBoolChange}
|
||||
/>
|
||||
<FilterToggle
|
||||
label={t("viaMqtt.label")}
|
||||
filterKey="viaMqtt"
|
||||
alternativeLabels={[
|
||||
t("hide.label"),
|
||||
t("showOnly.label"),
|
||||
]}
|
||||
filterState={filterState}
|
||||
onChange={handleBoolChange}
|
||||
/>
|
||||
</FilterAccordionItem>
|
||||
|
||||
<FilterAccordionItem label={t("metrics.label")}>
|
||||
<FilterSlider
|
||||
label={t("snr.label")}
|
||||
filterKey="snr"
|
||||
filterState={localFilterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
onChange={handleRangeChange}
|
||||
/>
|
||||
<FilterSlider
|
||||
label={t("channelUtilization.label")}
|
||||
filterKey="channelUtilization"
|
||||
filterState={localFilterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
onChange={handleRangeChange}
|
||||
/>
|
||||
<FilterSlider
|
||||
label={t("airtimeUtilization.label")}
|
||||
filterKey="airUtilTx"
|
||||
filterState={localFilterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
onChange={handleRangeChange}
|
||||
/>
|
||||
<FilterSlider
|
||||
label={t("batteryLevel.label")}
|
||||
filterKey="batteryLevel"
|
||||
filterState={localFilterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
onChange={handleRangeChange}
|
||||
labelContent={
|
||||
<BatteryLevelLabelContent
|
||||
batteryLevelRange={localFilterState.batteryLevel}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FilterSlider
|
||||
label={t("batteryVoltage.label")}
|
||||
filterKey="voltage"
|
||||
filterState={localFilterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
onChange={handleRangeChange}
|
||||
/>
|
||||
</FilterAccordionItem>
|
||||
|
||||
<FilterAccordionItem label={t("role.label")}>
|
||||
<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={t("hardware.label")}>
|
||||
<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>
|
||||
<FilterAccordionItem label={t("advanced.label")}>
|
||||
<FilterToggle
|
||||
label={t("hopsUnknown.label")}
|
||||
filterKey="hopsUnknown"
|
||||
alternativeLabels={[
|
||||
t("hide.label"),
|
||||
t("showOnly.label"),
|
||||
]}
|
||||
filterState={filterState}
|
||||
onChange={handleBoolChange}
|
||||
/>
|
||||
<FilterToggle
|
||||
label={t("showUnheard.label")}
|
||||
filterKey="showUnheard"
|
||||
alternativeLabels={[
|
||||
t("hide.label"),
|
||||
t("showOnly.label"),
|
||||
]}
|
||||
filterState={filterState}
|
||||
onChange={handleBoolChange}
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
{t("button.reset")}
|
||||
</button>
|
||||
{children && <div className="mt-4 border-t pt-4">{children}</div>}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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("filters by hopsUnknown", () => {
|
||||
expect(nodeFilter(node, { hopsUnknown: true })).toBe(false);
|
||||
expect(nodeFilter(node, { hopsUnknown: false })).toBe(true);
|
||||
expect(nodeFilter(node, { hopsUnknown: undefined })).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by showUnheard", () => {
|
||||
expect(nodeFilter(node, { showUnheard: true })).toBe(false);
|
||||
expect(nodeFilter(node, { showUnheard: false })).toBe(true);
|
||||
expect(nodeFilter(node, { showUnheard: undefined })).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("detects dirty boolean field (hopsUnknown)", () => {
|
||||
const modified = { ...defaultFilterValues, hopsUnknown: true };
|
||||
expect(isFilterDirty(modified)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects dirty boolean field (showUnheard)", () => {
|
||||
const modified = { ...defaultFilterValues, showUnheard: true };
|
||||
expect(isFilterDirty(modified)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
export type FilterState = {
|
||||
nodeName: string;
|
||||
hopsAway: [number, number];
|
||||
lastHeard: [number, number];
|
||||
isFavorite: boolean | undefined;
|
||||
viaMqtt: boolean | undefined;
|
||||
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[];
|
||||
showUnheard: boolean | undefined;
|
||||
hopsUnknown: boolean | undefined;
|
||||
};
|
||||
|
||||
const shallowEqualArray = <T>(a: T[], b: T[]): boolean => {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
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",
|
||||
),
|
||||
hopsUnknown: undefined,
|
||||
showUnheard: undefined,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const nodeFilter = useCallback(
|
||||
(
|
||||
node: Protobuf.Mesh.NodeInfo,
|
||||
filterOverrides?: Partial<FilterState>,
|
||||
): boolean => {
|
||||
const filterState: FilterState = {
|
||||
...defaultFilterValues,
|
||||
...filterOverrides,
|
||||
};
|
||||
|
||||
if (!node.user) return false;
|
||||
|
||||
const nodeName = filterState.nodeName.toLowerCase();
|
||||
if (
|
||||
nodeName &&
|
||||
!(
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
(filterState.hopsUnknown === true && node.hopsAway !== undefined) ||
|
||||
filterState.hopsUnknown === false && node.hopsAway === undefined
|
||||
) {
|
||||
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 (
|
||||
(filterState.showUnheard === true && (node.lastHeard ?? 0) !== 0) ||
|
||||
filterState.showUnheard === false && (node.lastHeard ?? 0) === 0
|
||||
) {
|
||||
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;
|
||||
|
||||
return true;
|
||||
},
|
||||
[defaultFilterValues],
|
||||
);
|
||||
|
||||
const isFilterDirty = useCallback(
|
||||
(
|
||||
current: FilterState,
|
||||
overrides?: Partial<FilterState>,
|
||||
): boolean => {
|
||||
const base: FilterState = overrides
|
||||
? { ...defaultFilterValues, ...overrides }
|
||||
: defaultFilterValues;
|
||||
|
||||
for (const key of Object.keys(base) as (keyof FilterState)[]) {
|
||||
const currentValue = current[key];
|
||||
const defaultValue = base[key];
|
||||
|
||||
if (Array.isArray(defaultValue) && Array.isArray(currentValue)) {
|
||||
if (!shallowEqualArray(currentValue, defaultValue)) {
|
||||
return true;
|
||||
}
|
||||
} else if (currentValue !== defaultValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[defaultFilterValues],
|
||||
);
|
||||
|
||||
return { nodeFilter, defaultFilterValues, isFilterDirty };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
interface MonoProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export const Mono = ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: MonoProps) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("font-mono text-sm text-text-secondary", className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
// @ts-types="react"
|
||||
|
||||
describe("Generic Table", () => {
|
||||
it("Can render an empty table.", () => {
|
||||
render(<Table headings={[]} rows={[]} />);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Can render a table with headers and no rows.", async () => {
|
||||
const headings: Heading[] = [
|
||||
{ title: "Short Name", sortable: true },
|
||||
{ title: "Last Heard", sortable: true },
|
||||
{ title: "Connection", sortable: true },
|
||||
];
|
||||
render(<Table headings={headings} rows={[]} />);
|
||||
await screen.findByRole("table");
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(3);
|
||||
});
|
||||
|
||||
// Mock data representing devices
|
||||
const mockDevices = [
|
||||
{
|
||||
id: "TST1",
|
||||
shortName: "TST1",
|
||||
hopsAway: 1,
|
||||
lastHeard: Date.now() - 3000,
|
||||
viaMqtt: false,
|
||||
},
|
||||
{
|
||||
id: "TST2",
|
||||
shortName: "TST2",
|
||||
hopsAway: 0,
|
||||
lastHeard: Date.now() - 1000,
|
||||
viaMqtt: true,
|
||||
isFavorite: true, // Favorite device
|
||||
},
|
||||
{
|
||||
id: "TST3",
|
||||
shortName: "TST3",
|
||||
hopsAway: 4,
|
||||
lastHeard: Date.now() - 5000,
|
||||
viaMqtt: false,
|
||||
},
|
||||
{
|
||||
id: "TST4",
|
||||
shortName: "TST4",
|
||||
hopsAway: 3,
|
||||
lastHeard: Date.now() - 2000,
|
||||
viaMqtt: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Transform mock data into the format expected by the Table component
|
||||
const mockRows: DataRow[] = mockDevices.map((node) => ({
|
||||
id: node.id,
|
||||
isFavorite: node.isFavorite,
|
||||
cells: [
|
||||
{
|
||||
content: <b data-testid="short-name">{node.shortName}</b>,
|
||||
sortValue: node.shortName,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
<TimeAgo timestamp={node.lastHeard} />
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.lastHeard,
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<Mono>
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away`
|
||||
: "-"}
|
||||
{node.viaMqtt ? ", via MQTT" : ""}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.hopsAway,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const headings: Heading[] = [
|
||||
{ title: "Short Name", sortable: true },
|
||||
{ title: "Last Heard", sortable: true },
|
||||
{ title: "Connection", sortable: true },
|
||||
];
|
||||
|
||||
it("Can sort rows, keeping favorites at the top", async () => {
|
||||
render(<Table headings={headings} rows={mockRows} />);
|
||||
const renderedTable = await screen.findByRole("table");
|
||||
const columnHeaders = screen.getAllByRole("columnheader");
|
||||
expect(columnHeaders).toHaveLength(3);
|
||||
|
||||
const getRenderedOrder = () =>
|
||||
[...renderedTable.querySelectorAll("[data-testid='short-name']")].map(
|
||||
(el) => el.textContent?.trim(),
|
||||
);
|
||||
|
||||
// Default sort: "Last Heard" desc. TST2 is favorite, so it's first.
|
||||
// Then the rest are sorted by lastHeard timestamp (most recent first).
|
||||
// Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest).
|
||||
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]);
|
||||
|
||||
// Click "Short Name" to sort asc
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
// TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically.
|
||||
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]);
|
||||
|
||||
// Click "Short Name" again to sort desc
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
|
||||
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]);
|
||||
|
||||
// Click "Connection" to sort by hops asc
|
||||
fireEvent.click(columnHeaders[2]);
|
||||
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
|
||||
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]);
|
||||
});
|
||||
});
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import React from "react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
export interface Heading {
|
||||
title: string;
|
||||
sortable: boolean;
|
||||
}
|
||||
|
||||
interface Cell {
|
||||
content: React.ReactNode;
|
||||
sortValue: string | number;
|
||||
}
|
||||
|
||||
export interface DataRow {
|
||||
id: string | number;
|
||||
isFavorite?: boolean;
|
||||
cells: Cell[];
|
||||
}
|
||||
|
||||
export interface TableProps {
|
||||
headings: Heading[];
|
||||
rows: DataRow[];
|
||||
}
|
||||
|
||||
function numericHops(hopsAway: string | number): number {
|
||||
if (typeof hopsAway === "number") {
|
||||
return hopsAway;
|
||||
}
|
||||
if (hopsAway.match(/direct/i)) {
|
||||
return 0;
|
||||
}
|
||||
const match = hopsAway.match(/(\d+)\s+hop/i);
|
||||
return Number(match?.[1] ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
export const Table = ({ headings, rows }: TableProps) => {
|
||||
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const handleSort = (title: string) => {
|
||||
if (sortColumn === title) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortColumn(title);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sortColumn) return rows;
|
||||
|
||||
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
|
||||
if (columnIndex === -1) return rows;
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
if (a.isFavorite !== b.isFavorite) {
|
||||
return a.isFavorite ? -1 : 1;
|
||||
}
|
||||
|
||||
const aCell = a.cells[columnIndex];
|
||||
const bCell = b.cells[columnIndex];
|
||||
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
|
||||
if (sortColumn === "Connection") {
|
||||
aValue = numericHops(aCell.sortValue);
|
||||
bValue = numericHops(bCell.sortValue);
|
||||
} else {
|
||||
aValue = aCell.sortValue;
|
||||
bValue = bCell.sortValue;
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortOrder === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [rows, sortColumn, sortOrder, headings]);
|
||||
|
||||
return (
|
||||
<table className="min-w-full" style={{ contentVisibility: "auto" }}>
|
||||
<thead className="text-xs font-semibold">
|
||||
<tr>
|
||||
{headings.map((heading) => (
|
||||
<th
|
||||
key={heading.title}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"py-2 pr-3 text-left",
|
||||
heading.sortable &&
|
||||
"cursor-pointer hover:brightness-hover active:brightness-press",
|
||||
)}
|
||||
onClick={() => heading.sortable && handleSort(heading.title)}
|
||||
onKeyUp={(e) => {
|
||||
if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
|
||||
handleSort(heading.title);
|
||||
}
|
||||
}}
|
||||
tabIndex={heading.sortable ? 0 : -1}
|
||||
role="columnheader"
|
||||
aria-sort={sortColumn === heading.title
|
||||
? sortOrder === "asc" ? "ascending" : "descending"
|
||||
: "none"}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{heading.title}
|
||||
{heading.sortable && sortColumn === heading.title && (
|
||||
sortOrder === "asc"
|
||||
? <ChevronUpIcon size={16} aria-hidden="true" />
|
||||
: <ChevronDownIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="max-w-fit">
|
||||
{sortedRows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
row.isFavorite
|
||||
? "bg-yellow-100/30 dark:bg-slate-800 odd:bg-yellow-200/30 dark:odd:bg-slate-600/40"
|
||||
: "bg-white dark:bg-slate-900 odd:bg-slate-200/40 dark:odd:bg-slate-800/40",
|
||||
)}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) =>
|
||||
cellIndex === 0
|
||||
? (
|
||||
<th
|
||||
key={`${row.id}_${cellIndex}`}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
|
||||
scope="row"
|
||||
>
|
||||
{cell.content}
|
||||
</th>
|
||||
)
|
||||
: (
|
||||
<td
|
||||
key={`${row.id}_${cellIndex}`}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
|
||||
>
|
||||
{cell.content}
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface TimeAgoProps {
|
||||
timestamp: number | Date;
|
||||
locale?: string;
|
||||
tooltipOptions?: Intl.DateTimeFormatOptions;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TIME_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
["second", 1],
|
||||
];
|
||||
|
||||
const getRelativeTimeParts = (
|
||||
date: Date | number,
|
||||
): { value: number; unit: Intl.RelativeTimeFormatUnit } => {
|
||||
const diffInSeconds = (new Date(date).getTime() - Date.now()) / 1000;
|
||||
|
||||
for (const [unit, secondsInUnit] of TIME_UNITS) {
|
||||
if (Math.abs(diffInSeconds) >= secondsInUnit) {
|
||||
const value = Math.round(diffInSeconds / secondsInUnit);
|
||||
return { value, unit };
|
||||
}
|
||||
}
|
||||
|
||||
return { value: Math.round(diffInSeconds), unit: "second" };
|
||||
};
|
||||
|
||||
const UPDATE_INTERVALS: Partial<Record<Intl.RelativeTimeFormatUnit, number>> = {
|
||||
// For long-term units, an hourly update is more than sufficient.
|
||||
year: 1000 * 60 * 60,
|
||||
month: 1000 * 60 * 60,
|
||||
|
||||
// When the unit is 'day', check hourly to catch the change to the next day.
|
||||
day: 1000 * 60 * 60,
|
||||
|
||||
// When the unit is 'hour', check every thiry seconds to catch the change to the next hour.
|
||||
hour: 1000 * 30,
|
||||
|
||||
// When the unit is 'minute', a 15-second check is a good balance.
|
||||
minute: 1000 * 15,
|
||||
|
||||
// For 'second', a 3-second check keeps it feeling "live" without being excessive.
|
||||
second: 1000 * 3,
|
||||
};
|
||||
|
||||
export const TimeAgo = ({
|
||||
timestamp,
|
||||
locale: localeProp,
|
||||
tooltipOptions,
|
||||
className,
|
||||
}: TimeAgoProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [timeAgo, setTimeAgo] = useState<string>("");
|
||||
|
||||
const locale = useMemo(
|
||||
() =>
|
||||
localeProp ||
|
||||
i18n.language ||
|
||||
(typeof navigator !== "undefined" ? navigator.language : "en-US"),
|
||||
[localeProp, i18n.language],
|
||||
);
|
||||
|
||||
const date = useMemo(() => new Date(timestamp), [timestamp]);
|
||||
|
||||
const fullDate = useMemo(() => {
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
dateStyle: "full",
|
||||
timeStyle: "medium",
|
||||
};
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
...defaultOptions,
|
||||
...tooltipOptions,
|
||||
});
|
||||
return formatter.format(date);
|
||||
}, [date, locale, tooltipOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
let timerId: number;
|
||||
|
||||
const update = () => {
|
||||
const { value, unit } = getRelativeTimeParts(date);
|
||||
setTimeAgo(rtf.format(value, unit));
|
||||
|
||||
const interval = UPDATE_INTERVALS[unit] || 60000;
|
||||
timerId = globalThis.setTimeout(update, interval);
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, [date, locale]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<time dateTime={date.toISOString()} className={className}>
|
||||
{timeAgo}
|
||||
</time>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{fullDate}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface UptimeProps {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
const getUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
|
||||
const secondsLeft = Math.floor(((seconds % 86400) % 3600) % 60);
|
||||
return `${days}d ${hours}h ${minutes}m ${secondsLeft}s`;
|
||||
};
|
||||
|
||||
export const Uptime = ({ seconds }: UptimeProps) => {
|
||||
return <span>{getUptime(seconds)}</span>;
|
||||
};
|
||||
Reference in New Issue
Block a user