46 changed files with 594 additions and 1585 deletions
@ -5,6 +5,7 @@ |
|||
"jsr:@std/path@^1.1.0": "1.1.0", |
|||
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5", |
|||
"npm:@jsr/[email protected]": "2.6.2", |
|||
"npm:@jsr/[email protected]": "2.6.4", |
|||
"npm:@jsr/[email protected]": "2.6.0-0", |
|||
"npm:@jsr/meshtastic__transport-http@*": "0.2.1", |
|||
"npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1", |
|||
@ -32,6 +33,7 @@ |
|||
"npm:@tanstack/router-devtools@^1.120.15": "1.120.15_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]", |
|||
"npm:@tanstack/router-plugin@^1.120.15": "1.120.15_@[email protected][email protected][email protected][email protected][email protected]__@[email protected][email protected]_@[email protected][email protected][email protected][email protected]_@[email protected]", |
|||
"npm:@testing-library/jest-dom@^6.6.3": "6.6.3", |
|||
"npm:@testing-library/react-hooks@^8.0.1": "8.0.1_@[email protected][email protected][email protected][email protected]", |
|||
"npm:@testing-library/react@^16.3.0": "16.3.0_@[email protected]_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
|||
"npm:@testing-library/user-event@^14.6.1": "14.6.1_@[email protected]", |
|||
"npm:@turf/turf@^7.2.0": "7.2.0", |
|||
@ -41,12 +43,13 @@ |
|||
"npm:@types/react-dom@^19.1.3": "19.1.3_@[email protected]", |
|||
"npm:@types/react@^19.1.2": "19.1.2", |
|||
"npm:@types/serviceworker@^0.0.133": "0.0.133", |
|||
"npm:@types/w3c-web-serial@*": "1.0.8", |
|||
"npm:@types/w3c-web-serial@^1.0.8": "1.0.8", |
|||
"npm:@types/web-bluetooth@*": "0.0.21", |
|||
"npm:@types/web-bluetooth@^0.0.21": "0.0.21", |
|||
"npm:@vitejs/plugin-react@^4.4.1": "[email protected]__@[email protected][email protected]_@[email protected]_@[email protected]", |
|||
"npm:autoprefixer@^10.4.21": "[email protected]", |
|||
"npm:base64-js@^1.5.1": "1.5.1", |
|||
"npm:class-validator@~0.14.2": "0.14.2", |
|||
"npm:class-variance-authority@~0.7.1": "0.7.1", |
|||
"npm:clsx@^2.1.1": "2.1.1", |
|||
"npm:cmdk@^1.1.1": "[email protected][email protected][email protected]_@[email protected]_@[email protected]__@[email protected]", |
|||
@ -83,7 +86,7 @@ |
|||
"npm:vite-plugin-static-copy@3": "[email protected]__@[email protected][email protected]_@[email protected]", |
|||
"npm:vite@^6.3.4": "6.3.4_@[email protected][email protected]", |
|||
"npm:vitest@^3.1.2": "3.1.2_@[email protected][email protected][email protected]__@[email protected][email protected]", |
|||
"npm:zod@^3.24.3": "3.24.3", |
|||
"npm:zod@^3.25.0": "3.25.49", |
|||
"npm:[email protected]": "5.0.4_@[email protected][email protected][email protected]" |
|||
}, |
|||
"jsr": { |
|||
@ -1131,6 +1134,17 @@ |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.2.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-1Kz5DK6peFxluHOJR38vFwfgeJzMXTz+3p6TvibjILVhSQC2U1nu8aJbn6w5zhRqS+j79OmtrRvdzL6VNsTkkQ==", |
|||
"dependencies": [ |
|||
"@bufbuild/protobuf", |
|||
"@jsr/meshtastic__protobufs", |
|||
"crc", |
|||
"ste-simple-events", |
|||
"tslog" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.4.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==", |
|||
"dependencies": [ |
|||
@ -1152,21 +1166,21 @@ |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==", |
|||
"dependencies": [ |
|||
"@jsr/meshtastic__core" |
|||
"@jsr/meshtastic__core@2.6.2" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==", |
|||
"dependencies": [ |
|||
"@jsr/meshtastic__core" |
|||
"@jsr/meshtastic__core@2.6.2" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.1.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==", |
|||
"dependencies": [ |
|||
"@jsr/meshtastic__core" |
|||
"@jsr/meshtastic__core@2.6.2" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.1.tgz" |
|||
}, |
|||
@ -2514,6 +2528,20 @@ |
|||
"redent" |
|||
] |
|||
}, |
|||
"@testing-library/[email protected]_@[email protected][email protected][email protected][email protected]": { |
|||
"integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", |
|||
"dependencies": [ |
|||
"@babel/runtime", |
|||
"@types/react", |
|||
"react", |
|||
"react-dom", |
|||
"[email protected][email protected]" |
|||
], |
|||
"optionalPeers": [ |
|||
"@types/react", |
|||
"react-dom" |
|||
] |
|||
}, |
|||
"@testing-library/[email protected]_@[email protected]_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]": { |
|||
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", |
|||
"dependencies": [ |
|||
@ -4000,9 +4028,6 @@ |
|||
"@types/[email protected]": { |
|||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" |
|||
}, |
|||
"@types/[email protected]": { |
|||
"integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==" |
|||
}, |
|||
"@types/[email protected]": { |
|||
"integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" |
|||
}, |
|||
@ -4485,14 +4510,6 @@ |
|||
"[email protected]" |
|||
] |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", |
|||
"dependencies": [ |
|||
"@types/validator", |
|||
"libphonenumber-js", |
|||
"validator" |
|||
] |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", |
|||
"dependencies": [ |
|||
@ -5639,9 +5656,6 @@ |
|||
"[email protected]": { |
|||
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==" |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", |
|||
"os": ["darwin"], |
|||
@ -6212,6 +6226,13 @@ |
|||
"scheduler" |
|||
] |
|||
}, |
|||
"[email protected][email protected]": { |
|||
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", |
|||
"dependencies": [ |
|||
"@babel/runtime", |
|||
"react" |
|||
] |
|||
}, |
|||
"[email protected][email protected]": { |
|||
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", |
|||
"dependencies": [ |
|||
@ -7174,9 +7195,6 @@ |
|||
"which-typed-array" |
|||
] |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==" |
|||
}, |
|||
"[email protected]_@[email protected]": { |
|||
"integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", |
|||
"dependencies": [ |
|||
@ -7553,8 +7571,8 @@ |
|||
"[email protected]": { |
|||
"integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" |
|||
}, |
|||
"[email protected]4.3": { |
|||
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" |
|||
"[email protected]5.49": { |
|||
"integrity": "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==" |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-mla2acNCMkWXBD+c+yeUrBUrzOxYMNFdQ6FGfigGGtEVBPJx07BQeJekjt9DmH1FtZek4E9rE1eRR9qQpxACOQ==" |
|||
@ -7575,12 +7593,14 @@ |
|||
}, |
|||
"workspace": { |
|||
"dependencies": [ |
|||
"jsr:@std/path@^1.1.0" |
|||
"jsr:@std/path@^1.1.0", |
|||
"npm:@types/w3c-web-serial@*", |
|||
"npm:@types/web-bluetooth@*" |
|||
], |
|||
"packageJson": { |
|||
"dependencies": [ |
|||
"npm:@bufbuild/protobuf@^2.2.5", |
|||
"npm:@jsr/[email protected].2", |
|||
"npm:@jsr/[email protected].4", |
|||
"npm:@jsr/[email protected]", |
|||
"npm:@jsr/meshtastic__transport-http@*", |
|||
"npm:@jsr/meshtastic__transport-web-bluetooth@*", |
|||
@ -7608,6 +7628,7 @@ |
|||
"npm:@tanstack/router-devtools@^1.120.15", |
|||
"npm:@tanstack/router-plugin@^1.120.15", |
|||
"npm:@testing-library/jest-dom@^6.6.3", |
|||
"npm:@testing-library/react-hooks@^8.0.1", |
|||
"npm:@testing-library/react@^16.3.0", |
|||
"npm:@testing-library/user-event@^14.6.1", |
|||
"npm:@turf/turf@^7.2.0", |
|||
@ -7622,7 +7643,6 @@ |
|||
"npm:@vitejs/plugin-react@^4.4.1", |
|||
"npm:autoprefixer@^10.4.21", |
|||
"npm:base64-js@^1.5.1", |
|||
"npm:class-validator@~0.14.2", |
|||
"npm:class-variance-authority@~0.7.1", |
|||
"npm:clsx@^2.1.1", |
|||
"npm:cmdk@^1.1.1", |
|||
@ -7659,7 +7679,7 @@ |
|||
"npm:vite-plugin-static-copy@3", |
|||
"npm:vite@^6.3.4", |
|||
"npm:vitest@^3.1.2", |
|||
"npm:zod@^3.24.3", |
|||
"npm:zod@^3.25.0", |
|||
"npm:[email protected]" |
|||
] |
|||
} |
|||
|
|||
@ -35,7 +35,7 @@ |
|||
"homepage": "https://meshtastic.org", |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^2.2.5", |
|||
"@meshtastic/core": "npm:@jsr/[email protected].2", |
|||
"@meshtastic/core": "npm:@jsr/[email protected].4", |
|||
"@meshtastic/js": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
|||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
|||
@ -61,6 +61,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", |
|||
@ -92,15 +93,15 @@ |
|||
"@tanstack/router-plugin": "^1.120.15", |
|||
"@testing-library/jest-dom": "^6.6.3", |
|||
"@testing-library/react": "^16.3.0", |
|||
"@testing-library/react-hooks": "^8.0.1", |
|||
"@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", |
|||
|
|||
@ -0,0 +1,27 @@ |
|||
import MapPage from "@app/pages/Map/index.tsx"; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
import ChannelsPage from "@pages/Channels.tsx"; |
|||
import ConfigPage from "@pages/Config/index.tsx"; |
|||
import MessagesPage from "@pages/Messages.tsx"; |
|||
import NodesPage from "./pages/Nodes/index.tsx"; |
|||
import { ErrorBoundary } from "react-error-boundary"; |
|||
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; |
|||
|
|||
export const ErrorBoundaryWrapper = ({ |
|||
children, |
|||
}: { children: React.ReactNode }) => ( |
|||
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary> |
|||
); |
|||
|
|||
export const PageRouter = () => { |
|||
const { activePage } = useDevice(); |
|||
return ( |
|||
<ErrorBoundary FallbackComponent={ErrorPage}> |
|||
{activePage === "messages" && <MessagesPage />} |
|||
{activePage === "map" && <MapPage />} |
|||
{activePage === "config" && <ConfigPage />} |
|||
{activePage === "channels" && <ChannelsPage />} |
|||
{activePage === "nodes" && <NodesPage />} |
|||
</ErrorBoundary> |
|||
); |
|||
}; |
|||
@ -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(); |
|||
}); |
|||
}); |
|||
@ -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,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={true} 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={true} 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={true} 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={true} 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={true} 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={true} 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,304 +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"; |
|||
|
|||
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("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 () => { |
|||
const onSubmit = vi.fn(); |
|||
render( |
|||
<DynamicForm<z.infer<typeof schema>> |
|||
onSubmit={onSubmit} |
|||
submitType="onSubmit" |
|||
hasSubmitButton |
|||
validationSchema={schema} |
|||
defaultValues={{ name: "" }} |
|||
fieldGroups={fieldGroups} |
|||
/>, |
|||
); |
|||
|
|||
const btn = screen.getByRole("button", { name: /submit/i }); |
|||
expect(btn).toBeInTheDocument(); |
|||
|
|||
fireEvent.input(screen.getByLabelText("Name"), { target: { value: "ab" } }); |
|||
await screen.findByText("formValidation.tooSmall.string"); |
|||
fireEvent.click(btn); |
|||
expect(onSubmit).not.toHaveBeenCalled(); |
|||
|
|||
fireEvent.input(screen.getByLabelText("Name"), { |
|||
target: { value: "abcd" }, |
|||
}); |
|||
await waitFor(() => |
|||
expect(screen.queryByText("formValidation.tooSmall.string")).toBeNull() |
|||
); |
|||
fireEvent.click(btn); |
|||
|
|||
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"); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -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,175 +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"); |
|||
screen.debug(); |
|||
|
|||
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", |
|||
}), |
|||
}, |
|||
}), |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -1,153 +1,169 @@ |
|||
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 = (a: any[], b: any[]) => |
|||
a.length === b.length && a.every((v, i) => v === b[i]); |
|||
|
|||
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", |
|||
), |
|||
}), []); |
|||
|
|||
function nodeFilter( |
|||
node: Protobuf.Mesh.NodeInfo, |
|||
filterOverrides?: Partial<FilterState>, |
|||
): boolean { |
|||
const filterState: FilterState = { |
|||
...defaultFilterValues, |
|||
...filterOverrides, |
|||
}; |
|||
|
|||
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 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 nodeFilter = useCallback( |
|||
( |
|||
node: Protobuf.Mesh.NodeInfo, |
|||
filterOverrides?: Partial<FilterState>, |
|||
): boolean => { |
|||
const filterState: FilterState = { |
|||
...defaultFilterValues, |
|||
...filterOverrides, |
|||
}; |
|||
|
|||
if (!node.user) return false; |
|||
|
|||
const nodeName = filterState.nodeName.toLowerCase(); |
|||
if ( |
|||
nodeName && |
|||
!( |
|||
node.user?.shortName.toLowerCase().includes(nodeName) || |
|||
node.user?.longName.toLowerCase().includes(nodeName) || |
|||
node.num.toString().includes(nodeName) || |
|||
numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, "")) |
|||
) |
|||
) |
|||
) return false; |
|||
|
|||
const hops = node?.hopsAway ?? 7; |
|||
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { |
|||
return false; |
|||
} |
|||
|
|||
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; |
|||
}); |
|||
} |
|||
) { |
|||
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; |
|||
|
|||
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 }; |
|||
} |
|||
|
|||
@ -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,255 +0,0 @@ |
|||
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx"; |
|||
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx"; |
|||
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 { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { Protobuf, type Types } from "@meshtastic/core"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import { LockIcon, LockOpenIcon } from "lucide-react"; |
|||
import { |
|||
type JSX, |
|||
useCallback, |
|||
useDeferredValue, |
|||
useEffect, |
|||
useMemo, |
|||
useState, |
|||
} from "react"; |
|||
import { base16 } from "rfc4648"; |
|||
import { Input } from "@components/UI/Input.tsx"; |
|||
import { PageLayout } from "@components/PageLayout.tsx"; |
|||
import { |
|||
type FilterState, |
|||
useFilterNode, |
|||
} from "@components/generic/Filter/useFilterNode.ts"; |
|||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; |
|||
import { useTranslation } from "react-i18next"; |
|||
|
|||
export interface DeleteNoteDialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
const NodesPage = (): JSX.Element => { |
|||
const { t } = useTranslation("nodes"); |
|||
const { getNodes, hardware, connection, hasNodeError, setDialogOpen } = |
|||
useDevice(); |
|||
const { setNodeNumDetails } = useAppStore(); |
|||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); |
|||
|
|||
const [selectedTraceroute, setSelectedTraceroute] = useState< |
|||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined |
|||
>(); |
|||
const [selectedLocation, setSelectedLocation] = useState< |
|||
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined |
|||
>(); |
|||
|
|||
const [filterState, setFilterState] = useState<FilterState>(() => |
|||
defaultFilterValues |
|||
); |
|||
const deferredFilterState = useDeferredValue(filterState); |
|||
|
|||
const filteredNodes = useMemo( |
|||
() => getNodes((node) => nodeFilter(node, deferredFilterState)), |
|||
[deferredFilterState, getNodes, nodeFilter], |
|||
); |
|||
|
|||
useEffect(() => { |
|||
if (!connection) return; |
|||
connection.events.onTraceRoutePacket.subscribe(handleTraceroute); |
|||
return () => { |
|||
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute); |
|||
}; |
|||
}, [connection]); |
|||
|
|||
const handleTraceroute = useCallback( |
|||
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => { |
|||
setSelectedTraceroute(traceroute); |
|||
}, |
|||
[], |
|||
); |
|||
|
|||
useEffect(() => { |
|||
if (!connection) return; |
|||
connection.events.onPositionPacket.subscribe(handleLocation); |
|||
return () => { |
|||
connection.events.onPositionPacket.subscribe(handleLocation); |
|||
}; |
|||
}, [connection]); |
|||
|
|||
const handleLocation = useCallback( |
|||
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => { |
|||
if (location.to.valueOf() !== hardware.myNodeNum) return; |
|||
setSelectedLocation(location); |
|||
}, |
|||
[hardware.myNodeNum], |
|||
); |
|||
|
|||
function handleNodeInfoDialog(nodeNum: number): void { |
|||
setNodeNumDetails(nodeNum); |
|||
setDialogOpen("nodeDetails", true); |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<PageLayout |
|||
label="" |
|||
leftBar={<Sidebar />} |
|||
> |
|||
<div className="pl-2 pt-2 flex flex-row"> |
|||
<div className="flex-1 mr-2"> |
|||
<Input |
|||
placeholder={t("search.nodes")} |
|||
value={filterState.nodeName} |
|||
className="bg-transparent" |
|||
showClearButton={!!filterState.nodeName} |
|||
onChange={(e) => |
|||
setFilterState((prev) => ({ |
|||
...prev, |
|||
nodeName: e.target.value, |
|||
}))} |
|||
/> |
|||
</div> |
|||
<div className="flex justify-end"> |
|||
<FilterControl |
|||
filterState={filterState} |
|||
defaultFilterValues={defaultFilterValues} |
|||
setFilterState={setFilterState} |
|||
isDirty={isFilterDirty(filterState)} |
|||
parameters={{ |
|||
popoverContentProps: { |
|||
side: "bottom", |
|||
align: "end", |
|||
sideOffset: 12, |
|||
}, |
|||
popoverTriggerClassName: "mr-1 p-2", |
|||
showTextSearch: false, |
|||
}} |
|||
/> |
|||
</div> |
|||
</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>, |
|||
])} |
|||
/> |
|||
<TracerouteResponseDialog |
|||
traceroute={selectedTraceroute} |
|||
open={!!selectedTraceroute} |
|||
onOpenChange={() => setSelectedTraceroute(undefined)} |
|||
/> |
|||
<LocationResponseDialog |
|||
location={selectedLocation} |
|||
open={!!selectedLocation} |
|||
onOpenChange={() => setSelectedLocation(undefined)} |
|||
/> |
|||
</div> |
|||
</PageLayout> |
|||
</> |
|||
); |
|||
}; |
|||
|
|||
export default NodesPage; |
|||
@ -0,0 +1,89 @@ |
|||
// @/pages/NodesPage.tsx (abbreviated)
|
|||
import { useVirtualizer } from "@tanstack/react-virtual"; |
|||
import { JSX, useCallback, useDeferredValue, useRef, useState } from "react"; |
|||
import { NodeRow } from "./NodeRow.tsx"; |
|||
import { |
|||
FilterState, |
|||
useFilterNode, |
|||
} from "@components/generic/Filter/useFilterNode.ts"; |
|||
import { PageLayout } from "@components/PageLayout.tsx"; |
|||
import { Sidebar } from "@components/Sidebar.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
// ... other imports
|
|||
|
|||
const NodesPage = (): JSX.Element => { |
|||
const { getNodes, hasNodeError, setDialogOpen } = useDevice(); |
|||
const { setNodeNumDetails } = useAppStore(); |
|||
const { nodeFilter, defaultFilterValues } = useFilterNode(); |
|||
|
|||
// No changes to filtering logic, `useDeferredValue` is still a great choice
|
|||
const [filterState, setFilterState] = useState<FilterState>(() => |
|||
defaultFilterValues |
|||
); |
|||
const deferredFilterState = useDeferredValue(filterState); |
|||
const filteredNodes = getNodes((node) => |
|||
nodeFilter(node, deferredFilterState) |
|||
); |
|||
|
|||
const parentRef = useRef<HTMLDivElement>(null); |
|||
|
|||
const rowVirtualizer = useVirtualizer({ |
|||
count: filteredNodes.length, |
|||
getScrollElement: () => parentRef.current, |
|||
estimateSize: () => 64, // Estimate row height in pixels
|
|||
overscan: 5, // Render 5 extra items in each direction
|
|||
}); |
|||
|
|||
const handleNodeInfoDialog = useCallback( |
|||
(nodeNum: number): void => { |
|||
setNodeNumDetails(nodeNum); |
|||
setDialogOpen("nodeDetails", true); |
|||
}, |
|||
[setNodeNumDetails, setDialogOpen], |
|||
); |
|||
|
|||
return ( |
|||
<> |
|||
<PageLayout label="" leftBar={<Sidebar />}> |
|||
{/* ... Filter controls ... */} |
|||
|
|||
{/* Your scrollable container with the ref */} |
|||
<div ref={parentRef} className="overflow-y-auto h-[calc(100vh-200px)]"> |
|||
{/* Adjust height as needed */} |
|||
{/* A container to set the total size of the virtual list */} |
|||
<div |
|||
style={{ |
|||
height: `${rowVirtualizer.getTotalSize()}px`, |
|||
position: "relative", |
|||
}} |
|||
> |
|||
{/* Map over the virtual items, not the full filteredNodes array */} |
|||
{rowVirtualizer.getVirtualItems().map((virtualItem) => { |
|||
const node = filteredNodes[virtualItem.index]; |
|||
return ( |
|||
<NodeRow |
|||
key={node.num} |
|||
node={node} |
|||
hasNodeError={hasNodeError} |
|||
onNodeInfo={handleNodeInfoDialog} |
|||
style={{ |
|||
position: "absolute", |
|||
top: 0, |
|||
left: 0, |
|||
width: "100%", |
|||
height: `${virtualItem.size}px`, |
|||
transform: `translateY(${virtualItem.start}px)`, |
|||
}} |
|||
/> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
{/* ... Dialogs ... */} |
|||
</PageLayout> |
|||
</> |
|||
); |
|||
}; |
|||
|
|||
export default NodesPage; |
|||
@ -0,0 +1,11 @@ |
|||
/// <reference types="vite/client" />
|
|||
|
|||
interface ImportMetaEnv { |
|||
readonly env: { |
|||
readonly VITE_COMMIT_HASH: string; |
|||
}; |
|||
} |
|||
|
|||
interface ImportMeta { |
|||
readonly env: ImportMetaEnv; |
|||
} |
|||
Loading…
Reference in new issue