Fix tsc errors (#649)

* fixed tsc errors

* fixed tsc errors

* fixed tsc errors

* fixing tsc errors

* fixing more tsc errors

* fixing more tsc errors

* fixed tsc errors

* fixing tsc errors

* fixing PR issues

* commented out tsc check

* completing tsc fixes

* updating lockfile

* removed react-hooks
This commit is contained in:
Dan Ditomaso
2025-06-12 19:00:30 -04:00
committed by GitHub
parent 851da0707c
commit 47f8264c31
59 changed files with 1140 additions and 2799 deletions

View File

@@ -46,4 +46,4 @@ Check all that apply. If an item doesn't apply to your PR, you can leave it unch
- [ ] Code follows project style guidelines
- [ ] Documentation has been updated or added
- [ ] Tests have been added or updated
- [ ] All i18n translation labels have bee added
- [ ] All i18n translation labels have been added/updated

View File

@@ -24,18 +24,33 @@ jobs:
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Install Dependencies
run: deno install
- name: Cache Dependencies
run: deno cache src/index.tsx
- name: Run linter
run: deno task lint
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files: |
**/*.ts
**/*.tsx
- name: Check formatter
run: deno task format --check
# Uncomment the following lines when you have figured out how to ignore files
# - name: Type check changed files
# if: steps.changed-files.outputs.all_changed_files != ''
# run: deno check ${{ steps.changed-files.outputs.all_changed_files }}
- name: Run linter on changed files
if: steps.changed-files.outputs.all_changed_files != ''
run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }}
- name: Check format on changed files
if: steps.changed-files.outputs.all_changed_files != ''
run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }}
- name: Run tests
run: deno task test

View File

@@ -7,6 +7,7 @@
"@layouts/": "./src/layouts/",
"@std/path": "jsr:@std/path@^1.1.0"
},
"include": ["src", "./vite-env.d.ts"],
"compilerOptions": {
"lib": [
"DOM",
@@ -24,26 +25,35 @@
"types": [
"vite/client",
"node",
"@types/web-bluetooth",
"@types/w3c-web-serial"
"npm:@types/w3c-web-serial",
"npm:@types/web-bluetooth"
],
"strictPropertyInitialization": false
},
"fmt": {
"exclude": [
"src/*.gen.ts",
"src/routeTree.gen.ts",
"*.test.ts",
"*.test.tsx"
]
},
"lint": {
"exclude": [
"src/*.gen.ts",
"src/routeTree.gen.ts",
"*.test.ts",
"*.test.tsx"
],
"report": "pretty"
},
"exclude": [
"routeTree.gen.ts",
"node_modules/",
"dist",
"build",
"coverage",
"out",
".vscode-test"
],
"unstable": [
"sloppy-imports"
]

715
deno.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"dev": "deno task dev:ui",
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev",
"test": "deno run -A npm:vitest",
"check": "deno check",
"preview": "deno run -A npm:vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
},
@@ -35,10 +36,11 @@
"homepage": "https://meshtastic.org",
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http@0.2.1",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth@0.1.2",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial@0.2.1",
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4",
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.2.3",
@@ -60,6 +62,7 @@
"@tanstack/react-router-devtools": "^1.120.16",
"@tanstack/router-devtools": "^1.120.15",
"@turf/turf": "^7.2.0",
"@types/web-bluetooth": "^0.0.21",
"base64-js": "^1.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -81,10 +84,8 @@
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4",
"vite-plugin-i18n-ally": "^6.0.1",
"vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.25.0",
"zustand": "5.0.4"
"zod": "^3.25.62",
"zustand": "5.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.5",
@@ -93,13 +94,12 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.318",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@types/serviceworker": "^0.0.133",
"@types/js-cookie": "^3.0.6",
"@types/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",

View File

@@ -8,56 +8,45 @@ import {
import { useTranslation } from "react-i18next";
import { DeviceMetrics } from "./types.ts";
interface BatteryStateConfig {
condition: (level: number) => boolean;
Icon: React.ElementType;
className: string;
text: (level: number) => string;
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
const getBatteryStates = (
t: (key: string, options?: object) => string,
): BatteryStateConfig[] => {
return [
{
condition: (level) => level > 100,
Icon: PlugZapIcon,
className: "text-gray-500",
text: () => t("batteryStatus.pluggedIn"),
},
{
condition: (level) => level > 80,
Icon: BatteryFullIcon,
className: "text-green-500",
text: (level) => t("batteryStatus.charging", { level }),
},
{
condition: (level) => level > 20,
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: (level) => t("batteryStatus.charging", { level }),
},
{
condition: () => true,
Icon: BatteryLowIcon,
className: "text-red-500",
text: (level) => t("batteryStatus.charging", { level }),
},
];
};
interface StatusConfig {
Icon: React.ElementType;
className: string;
text: string;
}
const getBatteryState = (
level: number,
batteryStates: BatteryStateConfig[],
) => {
return batteryStates.find((state) => state.condition(level));
const BATTERY_STATUS = {
PLUGGED_IN: "PLUGGED_IN",
FULL: "FULL",
MEDIUM: "MEDIUM",
LOW: "LOW",
} as const;
export const getBatteryStatus = (level: number): BatteryStatusKey => {
if (level > 100) {
return BATTERY_STATUS.PLUGGED_IN;
}
if (level > 80) {
return BATTERY_STATUS.FULL;
}
if (level > 20) {
return BATTERY_STATUS.MEDIUM;
}
return BATTERY_STATUS.LOW;
};
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
const { t } = useTranslation();
if (
deviceMetrics?.batteryLevel === undefined ||
deviceMetrics?.batteryLevel === null
@@ -65,16 +54,39 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
return null;
}
const { t } = useTranslation();
const batteryStates = getBatteryStates(t);
const { batteryLevel } = deviceMetrics;
const currentState = getBatteryState(batteryLevel, batteryStates) ??
batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel);
const statusKey = getBatteryStatus(batteryLevel);
const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
[BATTERY_STATUS.PLUGGED_IN]: {
Icon: PlugZapIcon,
className: "text-gray-500",
text: t("batteryStatus.pluggedIn"),
},
[BATTERY_STATUS.FULL]: {
Icon: BatteryFullIcon,
className: "text-green-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.MEDIUM]: {
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.LOW]: {
Icon: BatteryLowIcon,
className: "text-red-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
};
// 3. Use the key to get the current state configuration
const {
Icon: BatteryIcon,
className: iconClassName,
text: statusText,
} = statusConfigMap[statusKey];
return (
<div

View File

@@ -55,10 +55,14 @@ export const DeviceNameDialog = ({
MAX_SHORT_NAME_BYTE_LENGTH,
);
if (!myNode?.user) {
console.warn("DeviceNameDialog: No user data available");
return null;
}
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...(myNode?.user ?? {}),
...data,
}),
);

View File

@@ -72,17 +72,19 @@ export const ImportDialog = ({
}, [importDialogInput]);
const apply = () => {
channelSet?.settings.map((ch: unknown, index: number) => {
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role: index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
});
channelSet?.settings.map(
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role: index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
},
);
if (channelSet?.loraConfig) {
connection?.setConfig(

View File

@@ -12,7 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
open: boolean;
onOpenChange: () => void;
}
@@ -33,6 +33,13 @@ export const LocationResponseDialog = ({
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const position = location?.data;
const hasCoordinates = position &&
typeof position.latitudeI === "number" &&
typeof position.longitudeI === "number" &&
typeof position.altitude === "number";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -45,31 +52,40 @@ export const LocationResponseDialog = ({
</DialogTitle>
</DialogHeader>
<DialogDescription>
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{location?.data.latitudeI / 1e7},{" "}
{location?.data.longitudeI / 1e7}
</a>
{hasCoordinates
? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
position.latitudeI ?? 0 / 1e7
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{" "}
{position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a>
</p>
<p>
{t("locationResponse.altitude")} {position.altitude}
{(position.altitude ?? 0) < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
<p>
{t("locationResponse.altitude")}
{location?.data.altitude}
{location?.data.altitde < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
)}
</DialogDescription>
</DialogContent>
</Dialog>

View File

@@ -1,149 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import type { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/appStore");
const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore);
vi.mock("@tanstack/react-router", () => ({
useNavigate: vi.fn(),
}));
describe("NodeDetailsDialog", () => {
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
shortName: "TN",
hwModel: 1,
role: 1,
},
lastHeard: 1697500000,
position: {
latitudeI: 450000000,
longitudeI: -750000000,
altitude: 200,
},
deviceMetrics: {
airUtilTx: 50.123,
channelUtilization: 75.456,
batteryLevel: 88.789,
voltage: 4.2,
uptimeSeconds: 3600,
},
} as unknown as Protobuf.Mesh.NodeInfo;
beforeEach(() => {
vi.resetAllMocks();
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === 1234) {
return mockNode;
}
return undefined;
},
});
mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234,
});
});
it("renders node details correctly", () => {
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
.toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole("link", { name: /^45, -75$/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
expect.stringContaining("openstreetmap.org"),
);
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i))
.toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
});
it("renders null if node is undefined", () => {
const requestedNodeNum = 5678;
mockUseAppStore.mockReturnValue({
nodeNumDetails: requestedNodeNum,
});
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === requestedNodeNum) {
return undefined;
}
if (nodeNum === 1234) {
return mockNode;
}
return undefined;
},
});
const { container } = render(
<NodeDetailsDialog open onOpenChange={() => {}} />,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
});
it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockNode, position: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders 'Never' for lastHeard when timestamp is 0", () => {
const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
});
});

View File

@@ -13,7 +13,6 @@ import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core";
import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next";
@@ -92,7 +91,7 @@ export const QRDialog = ({
<Checkbox
key={channel.index}
checked={selectedChannels.includes(channel.index)}
onCheckedChange={() => {
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) =>
@@ -144,13 +143,6 @@ export const QRDialog = ({
<Input
value={qrCodeUrl}
disabled
action={{
key: "copy-value",
icon: ClipboardIcon,
onClick() {
void navigator.clipboard.writeText(qrCodeUrl);
},
}}
/>
</DialogFooter>
</DialogContent>

View File

@@ -9,7 +9,7 @@ import {
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { RefreshCwIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
@@ -45,12 +45,6 @@ export const RebootDialog = ({
className="dark:text-slate-900"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{
icon: ClockIcon,
onClick() {
connection?.reboot(time * 60).then(() => onOpenChange(false));
},
}}
/>
<Button
className="w-24"

View File

@@ -1,7 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import { ReactNode } from "react";
import {
ButtonHTMLAttributes,
ClassAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { JSX } from "react/jsx-runtime";
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
@@ -18,7 +24,12 @@ vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual("@components/UI/Button.tsx");
return {
...actual,
Button: (props) => <button {...props} />,
Button: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLButtonElement>
& ButtonHTMLAttributes<HTMLButtonElement>,
) => <button {...props} />,
};
});
@@ -26,7 +37,12 @@ vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual("@components/UI/Input.tsx");
return {
...actual,
Input: (props) => <input {...props} />,
Input: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLInputElement>
& InputHTMLAttributes<HTMLInputElement>,
) => <input {...props} />,
};
});

View File

@@ -1,86 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
}));
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(() => ({
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
getNodeError: vi.fn(),
clearNodeError: vi.fn(),
})),
}));
describe("useRefreshKeysDialog Hook", () => {
let removeNodeMock: Mock;
let setDialogOpenMock: Mock;
let getNodeErrorMock: Mock;
let clearNodeErrorMock: Mock;
beforeEach(() => {
vi.clearAllMocks();
removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
clearNodeErrorMock = vi.fn();
vi.mocked(useDevice).mockReturnValue({
removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock,
});
vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123",
});
});
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledTimes(1);
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
it("handleNodeRemove should do nothing if there is no error", () => {
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).not.toHaveBeenCalled();
expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled();
});
it("handleCloseDialog should close the dialog", () => {
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleCloseDialog();
});
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
});

View File

@@ -1,4 +1,4 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
@@ -31,13 +31,19 @@ export const TracerouteResponseDialog = ({
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ??
const fromLongName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.shortName ??
const fromShortName = from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const to = getNode(traceroute?.to ?? 0);
const toUser = getNode(traceroute?.to ?? 0);
if (!toUser || !from) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -45,7 +51,7 @@ export const TracerouteResponseDialog = ({
<DialogHeader>
<DialogTitle>
{t("tracerouteResponse.title", {
identifier: `${longName} (${shortName})`,
identifier: `${fromLongName} (${fromShortName})`,
})}
</DialogTitle>
</DialogHeader>
@@ -53,8 +59,8 @@ export const TracerouteResponseDialog = ({
<TraceRoute
route={route}
routeBack={routeBack}
from={from}
to={to}
from={{ user: from.user }}
to={{ user: toUser.user }}
snrTowards={snrTowards}
snrBack={snrBack}
/>

View File

@@ -1,134 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { eventBus } from "@core/utils/eventBus.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
const rootRoute = createRootRoute();
describe.skip("UnsafeRolesDialog", () => {
const mockDevice = {
setDialogOpen: vi.fn(),
};
const renderWithProviders = (ui: React.ReactNode) => {
const testRouter = createRouter({
routeTree: rootRoute,
history: createMemoryHistory(),
});
return render(
<RouterProvider router={testRouter}>
<DeviceWrapper device={mockDevice}>
{ui}
</DeviceWrapper>
</RouterProvider>,
);
};
it("renders the dialog when open is true", () => {
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
expect(screen.getByText(/understand the implications/i))
.toBeInTheDocument();
const links = screen.getAllByRole("link");
expect(links).toHaveLength(2);
expect(links[0]).toHaveTextContent("Device Role Documentation");
expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
});
it("displays the correct links", () => {
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const docLink = screen.getByRole("link", {
name: /Device Role Documentation/i,
});
const blogLink = screen.getByRole("link", {
name: /Choosing The Right Device Role/i,
});
expect(docLink).toHaveAttribute(
"to",
"https://meshtastic.org/docs/configuration/radio/device/",
);
expect(blogLink).toHaveAttribute(
"to",
"https://meshtastic.org/blog/choosing-the-right-device-role/",
);
});
it("does not allow confirmation until checkbox is checked", () => {
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
expect(confirmButton).toBeDisabled();
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(confirmButton).toBeEnabled();
});
it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /close/i });
fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
});
it("emits the correct event when dismissing", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
});
it("emits the correct event when confirming", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const checkbox = screen.getByRole("checkbox");
const confirmButton = screen.getByRole("button", { name: /confirm/i });
fireEvent.click(checkbox);
fireEvent.click(confirmButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "confirm",
});
});
});

View File

@@ -1,314 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx";
import { DynamicForm } from "./DynamicForm.tsx";
import { z } from "zod/v4";
import { useAppStore } from "@core/stores/appStore.ts";
import userEvent from "@testing-library/user-event";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
}),
}));
const addErrorMock = vi.fn();
const removeErrorMock = vi.fn();
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: () => ({
addError: addErrorMock,
removeError: removeErrorMock,
}),
}));
describe.skip("DynamicForm", () => {
const schema = z.object({
name: z.string().min(3, { message: "Too short" }),
});
const fieldGroups = [
{
label: "Test Group",
description: "Testing validation",
fields: [
{
type: "text",
id: "name",
name: "name",
label: "Name",
description: "Enter your name",
properties: {},
},
],
},
];
it("shows validation error when input is too short", async () => {
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.input(input, { target: { value: "ab" } });
const error = await screen.findByText(
"formValidation.tooSmall.string",
);
expect(error).toBeVisible();
});
it("clears validation error when input becomes valid", async () => {
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.input(input, { target: { value: "ab" } });
expect(
await screen.findByText("formValidation.tooSmall.string"),
).toBeVisible();
fireEvent.input(input, { target: { value: "abcd" } });
await waitFor(() =>
expect(
screen.queryByText("formValidation.tooSmall.string"),
).toBeNull()
);
});
it("calls onSubmit when form is valid onChange", async () => {
const onSubmit = vi.fn();
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={onSubmit}
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.input(input, { target: { value: "ab" } });
expect(
await screen.findByText("formValidation.tooSmall.string"),
).toBeVisible();
fireEvent.input(input, { target: { value: "abcd" } });
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit).toHaveBeenCalledWith(
{ name: "abcd" },
expect.any(Object),
);
});
it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => {
// Use the userEvent setup
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={onSubmit}
submitType="onSubmit"
hasSubmitButton
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const nameInput = screen.getByLabelText("Name");
const submitButton = screen.getByRole("button", { name: /submit/i });
expect(submitButton).toBeInTheDocument();
await user.type(nameInput, "ab");
expect(await screen.findByText("formValidation.tooSmall.string"))
.toBeInTheDocument();
await user.click(submitButton);
expect(onSubmit).not.toHaveBeenCalled();
await user.clear(nameInput);
await user.type(nameInput, "abcd");
await waitFor(() => {
expect(screen.queryByText("formValidation.tooSmall.string")).not
.toBeInTheDocument();
});
await user.click(submitButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object));
});
it("renders defaultValues correctly", () => {
render(
<DynamicForm<{ name: string }>
onSubmit={vi.fn()}
// no validationSchema
defaultValues={{ name: "Alice" }}
fieldGroups={[
{
label: "Group",
description: "",
fields: [
{
type: "text",
name: "name",
label: "Name",
description: "",
properties: {},
},
],
},
]}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
expect(input.value).toBe("Alice");
});
it("toggles disabled state based on disabledBy rules", async () => {
const schema = z.object({
enable: z.boolean(),
follow: z.string(),
});
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
validationSchema={schema}
defaultValues={{ enable: false, follow: "" }}
fieldGroups={[
{
label: "Group",
description: "",
fields: [
{
type: "toggle",
name: "enable",
label: "enable",
description: "",
},
{
type: "text",
name: "follow",
label: "follow",
description: "",
disabledBy: [{ fieldName: "enable" }],
properties: {},
},
],
},
]}
/>,
);
const enable = screen.getByRole("switch", {
name: "enable",
}) as HTMLInputElement;
const follow = screen.getByLabelText("follow") as HTMLInputElement;
await waitFor(() => {
expect(enable.getAttribute("aria-checked")).toBe("false");
expect(follow).toBeDisabled();
});
fireEvent.click(enable);
await waitFor(() => {
expect(enable.getAttribute("aria-checked")).toBe("true");
expect(follow).not.toBeDisabled();
});
});
it("always calls onSubmit onChange when no validationSchema is provided", async () => {
const onSubmit = vi.fn();
render(
<DynamicForm<{ foo: string }>
onSubmit={onSubmit}
// no validationSchema
defaultValues={{ foo: "" }}
fieldGroups={[
{
label: "G",
description: "",
fields: [
{
type: "text",
name: "foo",
label: "Foo",
description: "",
properties: {},
},
],
},
]}
/>,
);
const input = screen.getByLabelText("Foo") as HTMLInputElement;
fireEvent.input(input, { target: { value: "bar" } });
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object));
});
});
it("syncs errors to appStore when formId is set", async () => {
const { addError, removeError } = useAppStore();
const schema = z.object({ foo: z.string().min(2) });
const groups = [
{
label: "G",
description: "",
fields: [
{
type: "text",
name: "foo",
label: "Foo",
description: "",
properties: {},
},
],
},
];
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
formId="myForm"
validationSchema={schema}
defaultValues={{ foo: "" }}
fieldGroups={groups}
/>,
);
const input = screen.getByLabelText("Foo") as HTMLInputElement;
fireEvent.input(input, { target: { value: "a" } });
await screen.findByText(/tooSmall/i);
expect(addError).toHaveBeenCalledWith("foo", "");
expect(addError).toHaveBeenCalledWith("myForm", "");
fireEvent.input(input, { target: { value: "abc" } });
await waitFor(() => {
expect(removeError).toHaveBeenCalledWith("foo");
expect(removeError).toHaveBeenCalledWith("myForm");
});
});
});

View File

@@ -40,13 +40,13 @@ export function SelectInput<T extends FieldValues>({
field,
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
const {
field: { value, onChange, ...rest },
field: { value, onChange, ref, onBlur, ...rest },
} = useController({
name: field.name,
control,
});
const { enumValue, formatEnumName, ...remainingProperties } =
const { enumValue, formatEnumName, defaultValue, ...remainingProperties } =
field.properties;
const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = [];
@@ -77,10 +77,15 @@ export function SelectInput<T extends FieldValues>({
onValueChange={handleValueChange}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}
defaultValue={defaultValue?.toString()}
{...rest}
>
<SelectTrigger id={field.name}>
<SelectTrigger
id={field.name}
ref={ref}
onBlur={onBlur}
{...remainingProperties}
>
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -1,131 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
useUnsafeRolesDialog: vi.fn(),
}));
// Mock the DynamicForm component since we're testing the Device component,
// not the DynamicForm implementation
vi.mock("@components/Form/DynamicForm", () => ({
DynamicForm: vi.fn(({ onSubmit }) => {
// Render a simplified version of the form for testing
return (
<div data-testid="dynamic-form">
<select
data-testid="role-select"
onChange={(e) => {
// Simulate the validation and submission process
const mockData = { role: e.target.value };
onSubmit(mockData);
}}
>
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
[key, value],
) => (
<option key={key} value={value}>
{key}
</option>
))}
</select>
<button
type="submit"
data-testid="submit-button"
onClick={() => onSubmit({ role: "CLIENT" })}
>
Submit
</button>
</div>
);
}),
}));
describe("Device component", () => {
const setWorkingConfigMock = vi.fn();
const validateRoleSelectionMock = vi.fn();
const mockDeviceConfig = {
role: "CLIENT",
buttonGpio: 0,
buzzerGpio: 0,
rebroadcastMode: "ALL",
nodeInfoBroadcastSecs: 300,
doubleTapAsButtonPress: false,
disableTripleClick: false,
ledHeartbeatDisabled: false,
};
beforeEach(() => {
vi.resetAllMocks();
// Mock the useDevice hook
useDevice.mockReturnValue({
config: {
device: mockDeviceConfig,
},
setWorkingConfig: setWorkingConfigMock,
});
// Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true);
useUnsafeRolesDialog.mockReturnValue({
validateRoleSelection: validateRoleSelectionMock,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the Device form", () => {
render(<Device />);
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it("should use the validateRoleSelection from the unsafe roles hook", () => {
render(<Device />);
expect(useUnsafeRolesDialog).toHaveBeenCalled();
});
it("should call setWorkingConfig when form is submitted", async () => {
render(<Device />);
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.objectContaining({ role: "CLIENT" }),
},
}),
);
});
});
it("should create config with proper structure", async () => {
render(<Device />);
// Simulate form submission
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.any(Object),
},
}),
);
});
});
});

View File

@@ -1,173 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(),
}));
vi.mock("@components/Form/DynamicForm", async () => {
const React = await import("react");
const { useState } = React;
return {
DynamicForm: ({ onSubmit, defaultValues }) => {
const [wifiEnabled, setWifiEnabled] = useState(
defaultValues.wifiEnabled ?? false,
);
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({
...defaultValues,
wifiEnabled,
wifiSsid: ssid,
wifiPsk: psk,
});
}}
data-testid="dynamic-form"
>
<input
type="checkbox"
aria-label="WiFi Enabled"
checked={wifiEnabled}
onChange={(e) => setWifiEnabled(e.target.checked)}
/>
<input
aria-label="SSID"
value={ssid}
onChange={(e) => setSsid(e.target.value)}
disabled={!wifiEnabled}
/>
<input
aria-label="PSK"
value={psk}
onChange={(e) => setPsk(e.target.value)}
disabled={!wifiEnabled}
/>
<button type="submit" data-testid="submit-button">
Submit
</button>
</form>
);
},
};
});
describe("Network component", () => {
const setWorkingConfigMock = vi.fn();
const mockNetworkConfig = {
wifiEnabled: false,
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
ipv4Config: {
ip: 0,
gateway: 0,
subnet: 0,
dns: 0,
},
enabledProtocols:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
rsyslogServer: "",
};
beforeEach(() => {
vi.resetAllMocks();
useDevice.mockReturnValue({
config: {
network: mockNetworkConfig,
},
setWorkingConfig: setWorkingConfigMock,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the Network form", () => {
render(<Network />);
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it("should disable SSID and PSK fields when wifi is off", () => {
render(<Network />);
expect(screen.getByLabelText("SSID")).toBeDisabled();
expect(screen.getByLabelText("PSK")).toBeDisabled();
});
it("should enable SSID and PSK when wifi is toggled on", async () => {
render(<Network />);
const toggle = screen.getByLabelText("WiFi Enabled");
fireEvent.click(toggle); // turns wifiEnabled = true
await waitFor(() => {
expect(screen.getByLabelText("SSID")).not.toBeDisabled();
expect(screen.getByLabelText("PSK")).not.toBeDisabled();
});
});
it("should call setWorkingConfig with the right structure on submit", async () => {
render(<Network />);
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "network",
value: expect.objectContaining({
wifiEnabled: false,
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
rsyslogServer: "",
}),
},
}),
);
});
});
it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
render(<Network />);
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
fireEvent.change(screen.getByLabelText("SSID"), {
target: { value: "MySSID" },
});
fireEvent.change(screen.getByLabelText("PSK"), {
target: { value: "MySecretPSK" },
});
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "network",
value: expect.objectContaining({
wifiEnabled: true,
wifiSsid: "MySSID",
wifiPsk: "MySecretPSK",
}),
},
}),
);
});
});
});

View File

@@ -7,6 +7,7 @@ import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core";
import type { BluetoothDevice } from "web-bluetooth";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
@@ -77,7 +78,7 @@ export const BLE = (
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
}).catch((error) => {
}).catch((error: Error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
}).finally(() => {

View File

@@ -66,7 +66,9 @@ export const HTTP = (
subscribeAll(device, connection, messageStore);
closeDialog();
} catch (error) {
console.error("Connection error:", error);
if (error instanceof Error) {
console.error("Connection error:", error);
}
// Capture all connection errors regardless of type
setConnectionError({ host: data.ip, secure: data.tls });
setConnectionInProgress(false);

View File

@@ -9,7 +9,8 @@ import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import type { SerialPort } from "w3c-web-serial";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export const Serial = (
{ closeDialog }: TabElementProps,
@@ -22,13 +23,13 @@ export const Serial = (
const { t } = useTranslation();
const updateSerialPortList = useCallback(async () => {
setSerialPorts(await navigator?.serial.getPorts());
setSerialPorts(await navigator.serial.getPorts());
}, []);
navigator?.serial?.addEventListener("connect", () => {
navigator.serial.addEventListener("connect", () => {
updateSerialPortList();
});
navigator?.serial?.addEventListener("disconnect", () => {
navigator.serial.addEventListener("disconnect", () => {
updateSerialPortList();
});
useEffect(() => {
@@ -89,7 +90,7 @@ export const Serial = (
await navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick
}).catch((error) => {
}).catch((error: Error) => {
console.error("Error requesting port:", error);
}).finally(() => {
setConnectionInProgress(false);

View File

@@ -56,21 +56,21 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const MESSAGE_STATUS_MAP = useMemo(
(): Record<MessageState, MessageStatusInfo> => ({
[MessageState.Ack]: {
displayText: t("message_item_status_delivered_displayText"),
displayText: t("deliveryStatus.deliveryStatus."),
icon: CheckCircle2,
ariaLabel: t("message_item_status_delivered_ariaLabel"),
ariaLabel: t("deliveryStatus.delivered"),
iconClassName: "text-green-500",
},
[MessageState.Waiting]: {
displayText: t("message_item_status_waiting_displayText"),
displayText: t("deliveryStatus.waiting"),
icon: CircleEllipsis,
ariaLabel: t("message_item_status_waiting_ariaLabel"),
ariaLabel: t("deliveryStatus.waiting"),
iconClassName: "text-slate-400",
},
[MessageState.Failed]: {
displayText: t("message_item_status_failed_displayText"),
displayText: t("deliveryStatus.failed"),
icon: AlertCircle,
ariaLabel: t("message_item_status_failed_ariaLabel"),
ariaLabel: t("deliveryStatus.failed"),
iconClassName: "text-red-500 dark:text-red-400",
},
}),
@@ -78,9 +78,9 @@ export const MessageItem = ({ message }: MessageItemProps) => {
);
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
displayText: t("message_item_status_unknown_displayText"),
displayText: t("delveryStatus.unknown"),
icon: AlertCircle,
ariaLabel: t("message_item_status_unknown_ariaLabel"),
ariaLabel: t("deliveryStatus.unknown"),
iconClassName: "text-red-500 dark:text-red-400",
}), [t]);

View File

@@ -2,29 +2,71 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core";
import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => {
const fromUser = {
user: {
$typeName: "meshtastic.User",
longName: "Source Node",
publicKey: new Uint8Array([1, 2, 3]),
shortName: "Source",
hwModel: 1,
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
id: "source-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
} as Protobuf.Mesh.NodeInfo["user"],
};
const toUser = {
user: {
$typeName: "meshtastic.User",
longName: "Destination Node",
publicKey: new Uint8Array([4, 5, 6]),
shortName: "Destination",
hwModel: 2,
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
id: "destination-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
} as Protobuf.Mesh.NodeInfo["user"],
};
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
[
1,
{ num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo,
{
num: 1,
user: { longName: "Node A", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
[
2,
{ num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo,
{
num: 2,
user: { longName: "Node B", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
[
3,
{ num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo,
{
num: 3,
user: { longName: "Node C", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
],
]);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(useDevice).mockReturnValue({
...mockDeviceStore,
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
@@ -34,16 +76,15 @@ describe("TraceRoute", () => {
it("renders the route to destination with SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } }}
to={{ user: { longName: "Destination Node" } }}
from={fromUser}
to={toUser}
route={[1, 2]}
snrTowards={[10, 20, 30]}
/>,
);
expect(screen.getAllByText("Source Node")).toHaveLength(1);
expect(screen.getByText("Source Node")).toBeInTheDocument();
expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument();
@@ -56,8 +97,8 @@ describe("TraceRoute", () => {
it("renders the route back when provided", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } }}
to={{ user: { longName: "Destination Node" } }}
from={fromUser}
to={toUser}
route={[1]}
snrTowards={[15, 25]}
routeBack={[3]}
@@ -65,46 +106,33 @@ describe("TraceRoute", () => {
/>,
);
// Check for the translated title
expect(screen.getByText("Route back:")).toBeInTheDocument();
// With route back, both names appear twice
expect(screen.getAllByText("Source Node")).toHaveLength(2);
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 25dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } }}
to={{ user: { longName: "Dest" } }}
from={fromUser}
to={toUser}
route={[1]}
/>,
);
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getAllByText("↓ ??dBm")).toHaveLength(2);
});
it("renders hop hex if node is not found", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as unknown}
to={{ user: { longName: "Dest" } } as unknown}
route={[99]}
snrTowards={[5, 15]}
/>,
);
expect(screen.getByText("↓ 5dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
// Check for translated '??' placeholder
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
});
});

View File

@@ -3,9 +3,11 @@ import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
type NodeUser = Pick<Protobuf.Mesh.NodeInfo, "user">;
export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo;
to?: Protobuf.Mesh.NodeInfo;
from: NodeUser;
to: NodeUser;
route: Array<number>;
routeBack?: Array<number>;
snrTowards?: Array<number>;
@@ -14,14 +16,14 @@ export interface TraceRouteProps {
interface RoutePathProps {
title: string;
startNode?: Protobuf.Mesh.NodeInfo;
endNode?: Protobuf.Mesh.NodeInfo;
from: NodeUser;
to: NodeUser;
path: number[];
snr?: number[];
}
const RoutePath = (
{ title, startNode, endNode, path, snr }: RoutePathProps,
{ title, from, to, path, snr }: RoutePathProps,
) => {
const { getNode } = useDevice();
const { t } = useTranslation();
@@ -32,7 +34,7 @@ const RoutePath = (
className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100"
>
<p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p>
<p>{from?.user?.longName}</p>
<p>
{snr?.[0] ?? t("unknown.num")}
{t("unit.dbm")}
@@ -49,7 +51,7 @@ const RoutePath = (
</p>
</span>
))}
<p>{endNode?.user?.longName}</p>
<p>{to?.user?.longName}</p>
</span>
);
};
@@ -67,16 +69,16 @@ export const TraceRoute = ({
<div className="ml-5 flex">
<RoutePath
title={t("traceRoute.routeToDestination")}
startNode={to}
endNode={from}
to={to}
from={from}
path={route}
snr={snrTowards}
/>
{routeBack && routeBack.length > 0 && (
<RoutePath
title={t("traceRoute.routeBack")}
startNode={from}
endNode={to}
to={from}
from={to}
path={routeBack}
snr={snrBack}
/>

View File

@@ -46,44 +46,36 @@ export interface ButtonProps
iconAlignment?: "left" | "right";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
disabled,
icon,
iconAlignment = "left",
children,
...props
},
ref,
) => {
return (
<button
type="button"
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled },
"inline-flex items-center",
)}
ref={ref}
disabled={disabled}
{...props}
>
{icon && iconAlignment === "left" && (
<span className={cn({ "mr-2": !!children })}>{icon}</span>
)}
{children}
{icon && iconAlignment === "right" && (
<span className={cn({ "ml-2": !!children })}>{icon}</span>
)}
</button>
);
},
);
Button.displayName = "Button";
const Button = ({
className,
variant,
size,
disabled,
icon,
iconAlignment = "left",
children,
...props
}: ButtonProps) => {
return (
<button
type="button"
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled },
"inline-flex items-center",
)}
disabled={disabled}
{...props}
>
{icon && iconAlignment === "left" && (
<span className={cn({ "mr-2": !!children })}>{icon}</span>
)}
{children}
{icon && iconAlignment === "right" && (
<span className={cn({ "ml-2": !!children })}>{icon}</span>
)}
</button>
);
};
export { Button, buttonVariants };

View File

@@ -23,18 +23,6 @@ vi.mock("@components/UI/Label.tsx", () => ({
),
}));
vi.mock("@core/utils/cn.ts", () => ({
cn: (...args) => args.filter(Boolean).join(" "),
}));
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
useId: () => "test-id",
};
});
describe("Checkbox", () => {
beforeEach(cleanup);
@@ -67,11 +55,6 @@ describe("Checkbox", () => {
expect(screen.getByRole("checkbox").id).toBe("custom-id");
});
it("generates id when not provided", () => {
render(<Checkbox />);
expect(screen.getByRole("checkbox").id).toBe("test-id");
});
it("renders children in Label component", () => {
render(<Checkbox>Test Label</Checkbox>);
expect(screen.getByTestId("label-component")).toHaveTextContent(

View File

@@ -58,9 +58,7 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogClose = ({
className,
...props
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & {
className?: string;
}) => (
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>) => (
<DialogPrimitive.Close
aria-label="Close"
data-testid="dialog-close-button"

View File

@@ -6,7 +6,11 @@ import {
export interface LinkProps extends RouterLinkProps {
href: string;
children?: React.ReactNode;
children?:
| React.ReactNode
| ((
state: { isActive: boolean; isTransitioning: boolean },
) => React.ReactNode);
className?: string;
}

View File

@@ -1,153 +1,185 @@
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useMemo } from "react";
import { useCallback, 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
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)[];
role: Protobuf.Config.Config_DeviceConfig_Role[];
hwModel: Protobuf.Mesh.HardwareModel[];
};
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",
),
}), []);
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,
};
const nodeFilter = useCallback(
(
node: Protobuf.Mesh.NodeInfo,
filterOverrides?: Partial<FilterState>,
): boolean => {
const filterState: FilterState = {
...defaultFilterValues,
...filterOverrides,
};
if (!node.user) return false;
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, ""),
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;
) {
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;
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;
}
}
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;
});
}
},
[defaultFilterValues],
);
return { nodeFilter, defaultFilterValues, isFilterDirty };
}

View File

@@ -1,142 +1,128 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Table } from "@components/generic/Table/index.tsx";
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={[]}
/>,
);
render(<Table headings={[]} rows={[]} />);
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("Can render a table with headers and no rows.", async () => {
render(
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Long Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true },
]}
rows={[]}
/>,
);
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(9);
expect(screen.getAllByRole("columnheader")).toHaveLength(3);
});
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
const mockDevicesWithShortNameAndConnection = [
// Mock data representing devices
const mockDevices = [
{
user: { shortName: "TST1" },
id: "TST1",
shortName: "TST1",
hopsAway: 1,
lastHeard: Date.now() + 1000,
lastHeard: Date.now() - 3000,
viaMqtt: false,
},
{
user: { shortName: "TST2" },
id: "TST2",
shortName: "TST2",
hopsAway: 0,
lastHeard: Date.now() + 4000,
lastHeard: Date.now() - 1000,
viaMqtt: true,
isFavorite: true, // Favorite device
},
{
user: { shortName: "TST3" },
id: "TST3",
shortName: "TST3",
hopsAway: 4,
lastHeard: Date.now(),
lastHeard: Date.now() - 5000,
viaMqtt: false,
},
{
user: { shortName: "TST4" },
id: "TST4",
shortName: "TST4",
hopsAway: 3,
lastHeard: Date.now() + 2000,
lastHeard: Date.now() - 2000,
viaMqtt: true,
},
];
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>,
<Mono key="lastHeard" data-testheard>
<TimeAgo timestamp={node.lastHeard * 1000} />
</Mono>,
<Mono key="hops" data-testhops>
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
]);
// 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,
},
],
}));
it("Can sort rows appropriately.", async () => {
render(
<Table
headings={[
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true },
]}
rows={mockRows}
/>,
);
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);
// Will be sorted "Last heard" "asc" by default
expect(
[...renderedTable.querySelectorAll("[data-testshortname]")]
.map((el) => el.textContent)
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST2,TST4,TST1,TST3");
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"]);
// Re-sort by Short Name asc
expect(
[...renderedTable.querySelectorAll("[data-testshortname]")]
.map((el) => el.textContent)
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST1,TST2,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"]);
// Re-sort by Short Name desc
expect(
[...renderedTable.querySelectorAll("[data-testshortname]")]
.map((el) => el.textContent)
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST4,TST3,TST2,TST1");
// Click "Connection" to sort by hops asc
fireEvent.click(columnHeaders[2]);
// Re-sort by Hops Away
expect(
[...renderedTable.querySelectorAll("[data-testshortname]")]
.map((el) => el.textContent)
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST2,TST1,TST4,TST3");
// 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"]);
});
});

View File

@@ -1,30 +1,32 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import React from "react";
import { cn } from "@core/utils/cn.ts";
interface FavoriteIconProps {
showFavorite: boolean;
export interface Heading {
title: string;
sortable: boolean;
}
interface AvatarCellProps {
children: React.ReactElement<FavoriteIconProps>;
interface Cell {
content: React.ReactNode;
sortValue: string | number;
}
export interface DataRow {
id: string | number;
isFavorite?: boolean;
cells: Cell[];
}
export interface TableProps {
headings: Heading[];
rows: React.ReactElement<AvatarCellProps>[][];
rows: DataRow[];
}
export interface Heading {
title: string;
type: "blank" | "normal";
sortable: boolean;
}
function numericHops(hopsAway: string | unknown): number {
if (typeof hopsAway !== "string") {
return Number.MAX_SAFE_INTEGER;
function numericHops(hopsAway: string | number): number {
if (typeof hopsAway === "number") {
return hopsAway;
}
if (hopsAway.match(/direct/i)) {
return 0;
@@ -37,7 +39,7 @@ export const Table = ({ headings, rows }: TableProps) => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const headingSort = (title: string) => {
const handleSort = (title: string) => {
if (sortColumn === title) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
@@ -46,72 +48,36 @@ export const Table = ({ headings, rows }: TableProps) => {
}
};
const getElement = (cell: React.ReactNode): React.ReactElement | null => {
if (!React.isValidElement(cell)) {
return null;
}
if (cell.type === React.Fragment) {
const childrenArray = React.Children.toArray(cell.props.children);
const firstElement = childrenArray.find((child) =>
React.isValidElement(child)
);
return (firstElement as React.ReactElement) ?? null;
}
// If not a fragment, return the element itself
return cell;
};
const sortedRows = rows.slice().sort((a, b) => {
if (!sortColumn) return 0;
const sortedRows = useMemo(() => {
if (!sortColumn) return rows;
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
if (columnIndex === -1) return 0;
if (columnIndex === -1) return rows;
const elementA = getElement(a[columnIndex]);
const elementB = getElement(b[columnIndex]);
return [...rows].sort((a, b) => {
if (a.isFavorite !== b.isFavorite) {
return a.isFavorite ? -1 : 1;
}
// Avatar contains the prop showFavorite which indicates isFavorite
const favA = a[0]?.props?.children?.props?.showFavorite ?? false;
const favB = b[0]?.props?.children?.props?.showFavorite ?? false;
const aCell = a.cells[columnIndex];
const bCell = b.cells[columnIndex];
// Always put favorites at the top
if (favA !== favB) return favA ? -1 : 1;
let aValue: string | number;
let bValue: string | number;
if (sortColumn === "Last Heard") {
const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0;
const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0;
if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1;
if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1;
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;
}
if (sortColumn === "Connection") {
const aHopsStr = elementA?.props?.children[0];
const bHopsStr = elementB?.props?.children[0];
const aNumHops = numericHops(aHopsStr);
const bNumHops = numericHops(bHopsStr);
if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1;
if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1;
return 0;
}
const aValue = elementA?.props?.children;
const bValue = elementB?.props?.children;
const valA = aValue ?? "";
const valB = bValue ?? "";
// Ensure consistent comparison for potentially different types
const compareA = typeof valA === "string" || typeof valA === "number"
? valA
: String(valA);
const compareB = typeof valB === "string" || typeof valB === "number"
? valB
: String(valB);
if (compareA < compareB) return sortOrder === "asc" ? -1 : 1;
if (compareA > compareB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
});
}, [rows, sortColumn, sortOrder, headings]);
return (
<table className="min-w-full" style={{ contentVisibility: "auto" }}>
@@ -121,17 +87,15 @@ export const Table = ({ headings, rows }: TableProps) => {
<th
key={heading.title}
scope="col"
className={`py-2 pr-3 text-left ${
heading.sortable
? "cursor-pointer hover:brightness-hover active:brightness-press"
: ""
}`}
onClick={() => heading.sortable && headingSort(heading.title)}
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 === " ")
) {
headingSort(heading.title);
if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
handleSort(heading.title);
}
}}
tabIndex={heading.sortable ? 0 : -1}
@@ -153,49 +117,37 @@ export const Table = ({ headings, rows }: TableProps) => {
</tr>
</thead>
<tbody className="max-w-fit">
{sortedRows.map((row) => {
const firstCellKey =
(React.isValidElement(row[0]) && row[0].key !== null)
? String(row[0].key)
: null;
const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort
const isFavorite = row[0]?.props?.children?.props?.showFavorite ??
false;
return (
<tr
key={rowKey}
className={cn(
"",
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.map((item, cellIndex) => {
const cellKey = `${rowKey}_${cellIndex}`;
return cellIndex === 0
? (
<th
key={cellKey}
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
scope="row"
>
{item}
</th>
)
: (
<td
key={cellKey}
className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
>
{item}
</td>
);
})}
</tr>
);
})}
{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>
);

View File

@@ -10,8 +10,8 @@ interface BrowserSupport {
export function useBrowserFeatureDetection(): BrowserSupport {
const support = useMemo(() => {
const features: [BrowserFeature, boolean][] = [
["Web Bluetooth", !!navigator?.bluetooth],
["Web Serial", !!navigator?.serial],
["Web Bluetooth", !!navigator.bluetooth],
["Web Serial", !!navigator.serial],
[
"Secure Context",
globalThis.location.protocol === "https:" ||

View File

@@ -1,9 +1,9 @@
import Cookies, { type CookieAttributes } from "js-cookie";
import Cookies from "js-cookie";
import { useCallback, useState } from "react";
interface CookieHookResult<T> {
value: T | undefined;
setCookie: (value: T, options?: CookieAttributes) => void;
setCookie: (value: T, options?: Cookies.CookieAttributes) => void;
removeCookie: () => void;
}
@@ -22,7 +22,7 @@ function useCookie<T extends object>(
});
const setCookie = useCallback(
(value: T, options?: CookieAttributes) => {
(value: T, options?: Cookies.CookieAttributes) => {
try {
Cookies.set(cookieName, JSON.stringify(value), options);
setCookieValue(value);

View File

@@ -1,68 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePinnedItems } from "./usePinnedItems.ts";
const mockSetPinnedItems = vi.fn();
const mockUseLocalStorage = vi.fn();
vi.mock("@core/hooks/useLocalStorage.ts", () => ({
default: (...args) => mockUseLocalStorage(...args),
}));
describe("usePinnedItems", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns default pinnedItems and togglePinnedItem", () => {
mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
expect(result.current.pinnedItems).toEqual([]);
expect(typeof result.current.togglePinnedItem).toBe("function");
});
it("adds an item if it's not already pinned", () => {
mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
act(() => {
result.current.togglePinnedItem("item2");
});
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
const updater = mockSetPinnedItems.mock.calls[0][0];
const updated = updater(["item1"]);
expect(updated).toEqual(["item1", "item2"]);
});
it("removes an item if it's already pinned", () => {
mockUseLocalStorage.mockReturnValue([
["item1", "item2"],
mockSetPinnedItems,
]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
act(() => {
result.current.togglePinnedItem("item1");
});
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
const updater = mockSetPinnedItems.mock.calls[0][0];
const updated = updater(["item1", "item2"]);
expect(updated).toEqual(["item2"]);
});
});

View File

@@ -0,0 +1,82 @@
import { vi } from "vitest";
import { type Device } from "./deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
/**
* You can spread this base mock in your tests and override only the
* properties relevant to a specific test case.
*
* @example
* vi.mocked(useDevice).mockReturnValue({
* ...mockDeviceStore,
* getNode: (nodeNum) => mockNodes.get(nodeNum),
* });
*/
export const mockDeviceStore: Device = {
id: 0,
status: 5 as const,
channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
workingConfig: [],
workingModuleConfig: [],
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
nodeErrors: new Map(),
connection: undefined,
activeNode: 0,
waypoints: [],
pendingSettingsChanges: false,
messageDraft: "",
unreadCounts: new Map(),
nodesMap: new Map(),
dialog: {
import: false,
QR: false,
shutdown: false,
reboot: false,
rebootOTA: false,
deviceName: false,
nodeRemoval: false,
pkiBackup: false,
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
deleteMessages: false,
},
setStatus: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
setWorkingConfig: vi.fn(),
setWorkingModuleConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),
addChannel: vi.fn(),
addWaypoint: vi.fn(),
addNodeInfo: vi.fn(),
addUser: vi.fn(),
addPosition: vi.fn(),
addConnection: vi.fn(),
addTraceRoute: vi.fn(),
addMetadata: vi.fn(),
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
getDialogOpen: vi.fn().mockReturnValue(false),
processPacket: vi.fn(),
setMessageDraft: vi.fn(),
setNodeError: vi.fn(),
clearNodeError: vi.fn(),
getNodeError: vi.fn().mockReturnValue(undefined),
hasNodeError: vi.fn().mockReturnValue(false),
incrementUnread: vi.fn(),
resetUnread: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
getNodesLength: vi.fn().mockReturnValue(0),
getNode: vi.fn().mockReturnValue(undefined),
getMyNode: vi.fn(),
sendAdminMessage: vi.fn(),
updateFavorite: vi.fn(),
updateIgnored: vi.fn(),
};

View File

@@ -114,7 +114,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
addDevice: (id: number) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
draft.devices.set(id, {
id,
status: Types.DeviceStatusEnum.DeviceDisconnected,
@@ -151,7 +151,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
setStatus: (status: Types.DeviceStatusEnum) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.status = status;
@@ -161,7 +161,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setConfig: (config: Protobuf.Config.Config) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
@@ -203,7 +203,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
@@ -271,7 +271,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setWorkingConfig: (config: Protobuf.Config.Config) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const index = device.workingConfig.findIndex(
@@ -289,7 +289,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const index = device.workingModuleConfig.findIndex(
@@ -307,7 +307,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.hardware = hardware;
@@ -317,7 +317,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setPendingSettingsChanges: (state) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.pendingSettingsChanges = state;
@@ -327,7 +327,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addChannel: (channel: Protobuf.Channel.Channel) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.channels.set(channel.index, channel);
@@ -337,7 +337,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
const index = device.waypoints.findIndex((wp) =>
@@ -354,7 +354,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addNodeInfo: (nodeInfo) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
@@ -364,7 +364,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setActiveNode: (node) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.activeNode = node;
@@ -374,7 +374,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addUser: (user) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
@@ -389,7 +389,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addPosition: (position) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
@@ -404,7 +404,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addConnection: (connection) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.connection = connection;
@@ -414,7 +414,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addMetadata: (from, metadata) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.metadata.set(from, metadata);
@@ -424,7 +424,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
addTraceRoute: (traceroute) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const routes = device.traceroutes.get(traceroute.from) ?? [];
@@ -433,9 +433,9 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
removeNode: (nodeNum) => {
removeNode: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
@@ -446,7 +446,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.dialog[dialog] = open;
@@ -461,7 +461,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
processPacket(data: ProcessPacketParams) {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const node = device.nodesMap.get(data.from);
@@ -484,7 +484,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
setMessageDraft: (message: string) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.messageDraft = message;
@@ -492,9 +492,9 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
setNodeError: (nodeNum, error) => {
setNodeError: (nodeNum: number, error: string) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
@@ -504,7 +504,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
clearNodeError: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.delete(nodeNum);
@@ -524,7 +524,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
incrementUnread: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
@@ -534,7 +534,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
},
resetUnread: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
device.unreadCounts.set(nodeNum, 0);
@@ -610,10 +610,12 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}));
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
const node = device?.nodesMap.get(nodeNum);
node.isFavorite = isFavorite;
if (node) {
node.isFavorite = isFavorite;
}
}),
);
},
@@ -631,10 +633,12 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}));
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
const node = device?.nodesMap.get(nodeNum);
node.isIgnored = isIgnored;
if (node) {
node.isIgnored = isIgnored;
}
}),
);
},
@@ -651,7 +655,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
removeDevice: (id) => {
set(
produce<DeviceState>((draft) => {
produce<PrivateDeviceState>((draft) => {
draft.devices.delete(id);
}),
);

View File

@@ -1,4 +1,4 @@
import { PersistStorage, StateStorage } from "zustand/middleware";
import { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
import { del, get, set } from "idb-keyval";
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
@@ -56,18 +56,25 @@ const reviver: JsonReviver = (_, value) => {
};
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
getItem: async (name): Promise<PersistedMessageState | null> => {
getItem: async (
name,
): Promise<StorageValue<PersistedMessageState> | null> => {
const str = await zustandIndexDBStorage.getItem(name);
if (!str) return null;
try {
const parsed = JSON.parse(str, reviver) as PersistedMessageState;
const parsed = JSON.parse(str, reviver) as StorageValue<
PersistedMessageState
>;
return parsed;
} catch (error) {
console.error(`Error parsing persisted state (${name}):`, error);
return null;
}
},
setItem: async (name, newValue: PersistedMessageState): Promise<void> => {
setItem: async (
name,
newValue: StorageValue<PersistedMessageState>,
): Promise<void> => {
try {
const str = JSON.stringify(newValue, replacer);
await zustandIndexDBStorage.setItem(name, str);

View File

@@ -4,7 +4,7 @@ import { eventBus } from "@core/utils/eventBus.ts";
describe("EventBus", () => {
beforeEach(() => {
// Reset event listeners before each test
eventBus.listeners = {};
eventBus.offAll();
});
it("should register an event listener and trigger it on emit", () => {

View File

@@ -34,6 +34,14 @@ class EventBus {
}
}
public offAll<T extends EventName>(event?: T): void {
if (event) {
this.listeners[event] = [];
} else {
this.listeners = {};
}
}
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
if (!this.listeners[event]) return;

View File

@@ -60,7 +60,7 @@ describe("IP Address Conversion Functions", () => {
for (const ip of testIps) {
const int = convertIpAddressToInt(ip);
expect(int).not.toBeNull();
if (int !== null) {
if (int !== null && typeof int === "number") {
const convertedBack = convertIntToIpAddress(int);
expect(convertedBack).toBe(ip);
}

18
src/core/utils/sort.ts Normal file
View File

@@ -0,0 +1,18 @@
export function intlSort<T extends PropertyKey>(
arr: T[],
order: "asc" | "desc" = "asc",
locale: Intl.Locale,
): T[] {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
return arr.sort((a, b) => {
const stringA = String(a);
const stringB = String(b);
if (order === "asc") {
return collator.compare(stringA, stringB);
} else {
return collator.compare(stringB, stringA);
}
});
}

View File

@@ -1,80 +0,0 @@
import {
createMemoryHistory,
createRouter,
Outlet,
RootRoute,
Route,
RouterProvider,
} from "@tanstack/react-router";
import { render as rtlRender, RenderOptions } from "@testing-library/react";
import type { FunctionComponent, ReactElement, ReactNode } from "react";
// a root route for the test router.
const rootRoute = new RootRoute({
component: () => (
<>
<Outlet />
</>
),
});
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
initialEntries?: string[];
ui?: ReactElement;
}
let currentRouter: ReturnType<typeof createRouter> | null = null;
/**
* Custom render function for testing components that need TanStack Router context.
* @param ui The main ReactElement to render (your component under test).
* @param options Custom render options including initialEntries for the router.
* @returns An object containing the testing-library render result and the router instance.
*/
const customRender = (
ui: ReactElement,
options: CustomRenderOptions = {},
) => {
const { initialEntries = ["/"], ...renderOptions } = options;
// A specific route that renders the component under test (ui).
// It defaults to the first path in initialEntries or '/'.
const testComponentRoute = new Route({
getParentRoute: () => rootRoute,
path: initialEntries[0] || "/",
component: () => ui, // The component passed to render will be the element for this route
});
const routeTree = rootRoute.addChildren([testComponentRoute]);
const router = createRouter({
history: createMemoryHistory({ initialEntries }),
routeTree,
// You can add default error components or other router options if needed for tests.
// defaultErrorComponent: ({ error }) => <div>Test Error: {error.message}</div>,
});
currentRouter = router; // Store the router instance for access in tests
const Wrapper: FunctionComponent<{ children?: ReactNode }> = (
{ children },
) => {
return (
<>
<RouterProvider router={router} />
{children}
</>
);
};
const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
return {
...renderResult,
router,
};
};
export * from "@testing-library/react";
export { customRender as render };
export const getTestRouter = () => currentRouter;

View File

@@ -35,7 +35,7 @@ i18next
"default": ["en"],
},
fallbackNS: ["common", "ui", "dialog"],
debug: import.meta.env.DEV,
debug: import.meta.env.MODE === "development",
supportedLngs: supportedLanguages?.map((lang) => lang.code),
ns: [
"channels",

View File

@@ -1,7 +1,7 @@
{
"emptyState": "No results found.",
"page": {
"title": "Command Palette"
"title": "Command Menu"
},
"pinGroup": {
"label": "Pin command group"

View File

@@ -80,6 +80,12 @@
},
"showPassword": {
"label": "Show password"
},
"deliveryStatus": {
"delivered": "Delivered",
"failed": "Delivery Failed",
"waiting": "Waiting",
"unknown": "Unknown"
}
},
"general": {

View File

@@ -3,6 +3,10 @@
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@view-transition {
navigation: auto;
}
@theme {
--font-mono:
Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,

View File

@@ -4,14 +4,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
BluetoothIcon,
ListPlusIcon,
NetworkIcon,
PlusIcon,
UsbIcon,
UsersIcon,
} from "lucide-react";
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
@@ -60,32 +53,6 @@ export const Dashboard = () => {
?.longName ??
t("unknown.shortName")}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
{t(
"dashboard.connectionType_ble",
)}
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
{t(
"dashboard.connectionType_serial",
)}
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
{t(
"dashboard.connectionType_network",
)}
</>
)}
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-slate-500">
<UsersIcon
@@ -114,7 +81,6 @@ export const Dashboard = () => {
<Heading as="h3">
{t("dashboard.noDevicesTitle")}
</Heading>
{/* <LanguageSwitcher /> */}
<Subtle>
{t("dashboard.noDevicesDescription")}
</Subtle>

View File

@@ -1,81 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessagesPage } from "./Messages.tsx";
import { useDevice } from "../core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("../core/stores/deviceStore", () => ({
useDevice: vi.fn(),
}));
const mockUseDevice = {
channels: new Map([
[0, {
index: 0,
settings: { name: "Primary" },
role: Protobuf.Channel.Channel_Role.PRIMARY,
}],
]),
nodes: new Map([
[0, {
num: 0,
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" },
}],
[1111, {
num: 1111,
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" },
}],
[2222, {
num: 2222,
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" },
}],
[3333, {
num: 3333,
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" },
}],
]),
hardware: { myNodeNum: 1 },
messages: { broadcast: new Map(), direct: new Map() },
metadata: new Map(),
unreadCounts: new Map([[1111, 3], [2222, 10]]),
resetUnread: vi.fn(),
hasNodeError: vi.fn(),
};
describe.skip("Messages Page", () => {
beforeEach(() => {
vi.mocked(useDevice).mockReturnValue(mockUseDevice);
});
it("sorts unreads to the top", () => {
render(<MessagesPage />);
const buttonOrder = screen.getAllByRole("button").filter((b) =>
b.textContent.includes("Test Node")
);
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210");
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13");
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0");
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3");
});
it("updates unread when active chat changes", () => {
render(<MessagesPage />);
const nodeButton =
screen.getAllByRole("button").filter((b) =>
b.textContent.includes("TN1Test Node 13")
)[0];
fireEvent.click(nodeButton);
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
});
it("does not update the incorrect node", () => {
render(<MessagesPage />);
const nodeButton =
screen.getAllByRole("button").filter((b) =>
b.textContent.includes("TN1Test Node 1")
)[0];
fireEvent.click(nodeButton);
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10);
});
});

View File

@@ -28,9 +28,19 @@ import { Input } from "@components/UI/Input.tsx";
import { randId } from "@core/utils/randId.ts";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { messagesWithParamsRoute } from "@app/routes.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
function SelectMessageChat() {
const { t } = useTranslation("messages");
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
}
export const MessagesPage = () => {
const {
channels,
@@ -46,7 +56,9 @@ export const MessagesPage = () => {
getMessages,
setMessageState,
} = useMessageStore();
const params = useParams({ from: "", shouldThrow: false });
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
const navigate = useNavigate();
const { toast } = useToast();
const { isCollapsed } = useSidebar();
@@ -54,35 +66,34 @@ export const MessagesPage = () => {
const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm);
const chatType = params.type === "direct"
const navigateToChat = useCallback((type: MessageType, id: string) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${id}` });
}, [navigate]);
const chatType = type === "direct"
? MessageType.Direct
: params.type === "broadcast"
? MessageType.Broadcast
: undefined;
const activeChat = params.chatId ? Number(params.chatId) : undefined;
: MessageType.Broadcast;
const numericChatId = Number(chatId);
const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
const currentChannel = channels.get(activeChat);
const otherNode = getNode(activeChat);
useEffect(() => {
if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0];
navigateToChat(MessageType.Broadcast, defaultChannel.index.toString());
}
}, [type, chatId, filteredChannels, navigateToChat]);
const currentChannel = channels.get(numericChatId);
const otherNode = getNode(numericChatId);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const navigateToChat = useCallback((type: MessageType, chatId: number) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${chatId}` });
}, [navigate]);
useEffect(() => {
if (!params.type && !params.chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0];
navigateToChat(MessageType.Broadcast, defaultChannel.index);
}
}, [params.type, params.chatId, filteredChannels, navigateToChat]);
const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
@@ -104,12 +115,8 @@ export const MessagesPage = () => {
};
const sendText = useCallback(async (message: string) => {
const isDirect = chatType === MessageType.Direct;
const toValue = isDirect ? activeChat : MessageType.Broadcast;
const channelValue = isDirect
? Types.ChannelNumber.Primary
: activeChat ?? 0;
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
let messageId: number | undefined;
@@ -123,16 +130,16 @@ export const MessagesPage = () => {
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({
type: chatType,
type: MessageType.Broadcast,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
});
} else {
setMessageState({
type: chatType,
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: activeChat,
nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
});
@@ -145,23 +152,29 @@ export const MessagesPage = () => {
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {
setMessageState({
type: chatType,
type: MessageType.Broadcast,
channelId: channelValue,
messageId: failedId,
newState: MessageState.Failed,
});
} else { // MessageType.Direct
const failedId = messageId ?? randId();
} else {
setMessageState({
type: chatType,
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: activeChat,
nodeB: numericChatId,
messageId: failedId,
newState: MessageState.Failed,
});
}
}
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]);
}, [
numericChatId,
chatId,
chatType,
connection,
getMyNodeNum,
setMessageState,
]);
const renderChatContent = () => {
switch (chatType) {
@@ -170,7 +183,7 @@ export const MessagesPage = () => {
<ChannelChat
messages={getMessages({
type: MessageType.Broadcast,
channelId: activeChat ?? 0,
channelId: numericChatId,
}).reverse()}
/>
);
@@ -180,16 +193,12 @@ export const MessagesPage = () => {
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: activeChat,
nodeB: numericChatId,
}).reverse()}
/>
);
default:
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
return <SelectMessageChat />;
}
};
@@ -210,10 +219,10 @@ export const MessagesPage = () => {
index: channel.index,
ns: "channels",
}))}
active={activeChat === channel.index &&
active={numericChatId === channel.index &&
chatType === MessageType.Broadcast}
onClick={() => {
navigateToChat(MessageType.Broadcast, channel.index);
navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index);
}}
>
@@ -228,11 +237,12 @@ export const MessagesPage = () => {
), [
filteredChannels,
unreadCounts,
activeChat,
numericChatId,
chatType,
isCollapsed,
navigateToChat,
resetUnread,
t,
]);
const rightSidebar = useMemo(
@@ -262,10 +272,10 @@ export const MessagesPage = () => {
label={node.user?.longName ??
t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num &&
active={numericChatId === node.num &&
chatType === MessageType.Direct}
onClick={() => {
navigateToChat(MessageType.Direct, node.num);
navigateToChat(MessageType.Direct, node.num.toString());
resetUnread(node.num);
}}
>
@@ -285,11 +295,12 @@ export const MessagesPage = () => {
[
filteredNodes,
searchTerm,
activeChat,
numericChatId,
chatType,
navigateToChat,
resetUnread,
hasNodeError,
t,
],
);
@@ -330,7 +341,7 @@ export const MessagesPage = () => {
{(isBroadcast || isDirect)
? (
<MessageInput
to={isDirect ? activeChat : MessageType.Broadcast}
to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>

View File

@@ -3,7 +3,11 @@ import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteRespo
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { Table } from "@components/generic/Table/index.tsx";
import {
type DataRow,
type Heading,
Table,
} from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
@@ -93,6 +97,123 @@ const NodesPage = (): JSX.Element => {
setDialogOpen("nodeDetails", true);
}
const tableHeadings: Heading[] = [
{ title: "", sortable: false },
{ title: t("nodesTable.headings.longName"), sortable: true },
{ title: t("nodesTable.headings.connection"), sortable: true },
{ title: t("nodesTable.headings.lastHeard"), sortable: true },
{ title: t("nodesTable.headings.encryption"), sortable: false },
{ title: t("unit.snr"), sortable: true },
{ title: t("nodesTable.headings.model"), sortable: true },
{ title: t("nodesTable.headings.macAddress"), sortable: true },
];
const tableRows: DataRow[] = filteredNodes.map((node) => {
const macAddress = base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName");
return {
id: node.num,
isFavorite: node.isFavorite,
cells: [
{
content: (
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
/>
),
sortValue: node.user?.shortName ?? "", // Non-sortable column
},
{
content: (
<h1
onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0}
role="button"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>
),
sortValue: node.user?.longName ?? numberToHexUnpadded(node.num),
},
{
content: (
<Mono className="w-16">
{node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
: ""}
</Mono>
),
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
},
{
content: (
<Mono>
{node.lastHeard === 0
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</Mono>
),
sortValue: node.lastHeard,
},
{
content: (
<Mono>
{node.user?.publicKey && node.user?.publicKey.length > 0
? <LockIcon className="text-green-600 mx-auto" />
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>
),
sortValue: "", // Non-sortable column
},
{
content: (
<Mono>
{node.snr}
{t("unit.dbm")}/
{Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>
),
sortValue: node.snr,
},
{
content: (
<Mono>
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>
),
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
},
{
content: <Mono>{macAddress}</Mono>,
sortValue: macAddress,
},
],
};
});
return (
<>
<PageLayout
@@ -133,108 +254,8 @@ const NodesPage = (): JSX.Element => {
</div>
<div className="overflow-y-auto">
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{
title: t("nodesTable.headings.longName"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.connection"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.lastHeard"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.encryption"),
type: "normal",
sortable: false,
},
{
title: t("unit.snr"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.model"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.macAddress"),
type: "normal",
sortable: true,
},
]}
rows={filteredNodes.map((node) => [
<div key={node.num}>
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
/>
</div>,
<h1
key="longName"
onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0}
role="button"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>,
<Mono key="hops" className="w-16">
{node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
: ""}
</Mono>,
<Mono key="lastHeard">
{node.lastHeard === 0
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</Mono>,
<Mono key="pki">
{node.user?.publicKey && node.user?.publicKey.length > 0
? <LockIcon className="text-green-600 mx-auto" />
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>,
<Mono key="snr">
{node.snr}
{t("unit.dbm")}/
{Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>,
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName")}
</Mono>,
])}
headings={tableHeadings}
rows={tableRows}
/>
<TracerouteResponseDialog
traceroute={selectedTraceroute}

View File

@@ -1,59 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './routes/__root'
// Create/Update Routes
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {}
}
// Create and export the route tree
export interface FileRoutesByFullPath {}
export interface FileRoutesByTo {}
export interface FileRoutesById {
__root__: typeof rootRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: never
fileRoutesByTo: FileRoutesByTo
to: never
id: '__root__'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {}
const rootRouteChildren: RootRouteChildren = {}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": []
}
}
}
ROUTE_MANIFEST_END */

View File

@@ -4,10 +4,11 @@ import MessagesPage from "@pages/Messages.tsx";
import MapPage from "@pages/Map/index.tsx";
import ConfigPage from "@pages/Config/index.tsx";
import ChannelsPage from "@pages/Channels.tsx";
import NodesPage from "@pages/Nodes.tsx";
import NodesPage from "@pages/Nodes/index.tsx";
import { createRootRoute } from "@tanstack/react-router";
import { App } from "./App.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { z } from "zod";
const rootRoute = createRootRoute({
component: App,
@@ -27,12 +28,42 @@ const messagesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/messages",
component: MessagesPage,
beforeLoad: ({ params }) => {
const DEFAULT_CHANNEL = 0;
if (Object.values(params).length === 0) {
throw redirect({
to: `/messages/broadcast/${DEFAULT_CHANNEL}`,
replace: true,
});
}
},
});
const messagesWithParamsRoute = createRoute({
const chatIdSchema = z.string().refine((val) => {
const num = Number(val);
if (isNaN(num) || !Number.isInteger(num)) {
return false;
}
const isChannelId = num >= 0 && num <= 10;
const isNodeId = num >= 1000000000 && num <= 9999999999;
return isChannelId || isNodeId;
}, {
message: "Chat ID must be a channel (0-10) or a valid node ID.",
});
export const messagesWithParamsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/messages/$type/$chatId",
component: MessagesPage,
parseParams: (params) => ({
type: z.enum(["direct", "broadcast"], {
errorMap: () => ({ message: 'Type must be "direct" or "broadcast".' }),
}).parse(params.type),
chatId: chatIdSchema.parse(params.chatId),
}),
});
const mapRoute = createRoute({

37
src/tests/test-utils.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { ReactElement } from "react";
import { render, RenderOptions } from "@testing-library/react";
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import "../i18n/config.ts";
import { routeTree } from "../routeTree.gen.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
const Providers = () => {
const memoryHistory = createMemoryHistory({
initialEntries: ["/"],
});
const router = createRouter({
routeTree,
history: memoryHistory,
});
return (
<DeviceWrapper>
<RouterProvider router={router} />
</DeviceWrapper>
);
};
const renderWithProviders = (
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) => render(ui, { wrapper: Providers, ...options });
export * from "@testing-library/react";
export { renderWithProviders as render };

11
vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly env: {
readonly VITE_COMMIT_HASH: string;
};
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -32,9 +32,9 @@ export default defineConfig({
targets: [
{
src: "src/i18n/locales/**/*",
dest: "src/i18n/locales"
}
]
dest: "src/i18n/locales",
},
],
}),
],
define: {

View File

@@ -3,6 +3,8 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import { enableMapSet } from "immer";
import process from "node:process";
enableMapSet();
export default defineConfig({
plugins: [
@@ -25,6 +27,6 @@ export default defineConfig({
restoreMocks: true,
root: path.resolve(process.cwd(), "./src"),
include: ["**/*.{test,spec}.{ts,tsx}"],
setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"],
setupFiles: ["./src/tests/setup.ts"],
},
});