mirror of
https://github.com/meshtastic/web.git
synced 2026-04-19 13:27:33 -04:00
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:
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -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
|
||||
|
||||
25
.github/workflows/pr.yml
vendored
25
.github/workflows/pr.yml
vendored
@@ -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
|
||||
|
||||
18
deno.json
18
deno.json
@@ -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"
|
||||
]
|
||||
|
||||
20
package.json
20
package.json
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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:" ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
82
src/core/stores/deviceStore.mock.ts
Normal file
82
src/core/stores/deviceStore.mock.ts
Normal 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(),
|
||||
};
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
18
src/core/utils/sort.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"emptyState": "No results found.",
|
||||
"page": {
|
||||
"title": "Command Palette"
|
||||
"title": "Command Menu"
|
||||
},
|
||||
"pinGroup": {
|
||||
"label": "Pin command group"
|
||||
|
||||
@@ -80,6 +80,12 @@
|
||||
},
|
||||
"showPassword": {
|
||||
"label": "Show password"
|
||||
},
|
||||
"deliveryStatus": {
|
||||
"delivered": "Delivered",
|
||||
"failed": "Delivery Failed",
|
||||
"waiting": "Waiting",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
@@ -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 */
|
||||
@@ -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
37
src/tests/test-utils.tsx
Normal 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
11
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly env: {
|
||||
readonly VITE_COMMIT_HASH: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -32,9 +32,9 @@ export default defineConfig({
|
||||
targets: [
|
||||
{
|
||||
src: "src/i18n/locales/**/*",
|
||||
dest: "src/i18n/locales"
|
||||
}
|
||||
]
|
||||
dest: "src/i18n/locales",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user