48 changed files with 572 additions and 1572 deletions
@ -5,6 +5,7 @@ |
|||||
"jsr:@std/path@^1.1.0": "1.1.0", |
"jsr:@std/path@^1.1.0": "1.1.0", |
||||
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5", |
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5", |
||||
"npm:@jsr/[email protected]": "2.6.2", |
"npm:@jsr/[email protected]": "2.6.2", |
||||
|
"npm:@jsr/[email protected]": "2.6.4", |
||||
"npm:@jsr/[email protected]": "2.6.0-0", |
"npm:@jsr/[email protected]": "2.6.0-0", |
||||
"npm:@jsr/meshtastic__transport-http@*": "0.2.1", |
"npm:@jsr/meshtastic__transport-http@*": "0.2.1", |
||||
"npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1", |
"npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1", |
||||
@ -28,6 +29,7 @@ |
|||||
"npm:@radix-ui/react-tooltip@^1.2.4": "1.2.4_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
"npm:@radix-ui/react-tooltip@^1.2.4": "1.2.4_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
||||
"npm:@tailwindcss/postcss@^4.1.5": "4.1.5", |
"npm:@tailwindcss/postcss@^4.1.5": "4.1.5", |
||||
"npm:@testing-library/jest-dom@^6.6.3": "6.6.3", |
"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/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:@testing-library/user-event@^14.6.1": "14.6.1_@[email protected]", |
||||
"npm:@turf/turf@^7.2.0": "7.2.0", |
"npm:@turf/turf@^7.2.0": "7.2.0", |
||||
@ -37,12 +39,13 @@ |
|||||
"npm:@types/react-dom@^19.1.3": "19.1.3_@[email protected]", |
"npm:@types/react-dom@^19.1.3": "19.1.3_@[email protected]", |
||||
"npm:@types/react@^19.1.2": "19.1.2", |
"npm:@types/react@^19.1.2": "19.1.2", |
||||
"npm:@types/serviceworker@^0.0.133": "0.0.133", |
"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/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:@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:@vitejs/plugin-react@^4.4.1": "[email protected]__@[email protected][email protected]_@[email protected]_@[email protected]", |
||||
"npm:autoprefixer@^10.4.21": "[email protected]", |
"npm:autoprefixer@^10.4.21": "[email protected]", |
||||
"npm:base64-js@^1.5.1": "1.5.1", |
"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:class-variance-authority@~0.7.1": "0.7.1", |
||||
"npm:clsx@^2.1.1": "2.1.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]", |
"npm:cmdk@^1.1.1": "[email protected][email protected][email protected]_@[email protected]_@[email protected]__@[email protected]", |
||||
@ -79,7 +82,7 @@ |
|||||
"npm:vite-plugin-static-copy@3": "[email protected]__@[email protected][email protected]_@[email protected]", |
"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: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: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]" |
"npm:[email protected]": "5.0.4_@[email protected][email protected][email protected]" |
||||
}, |
}, |
||||
"jsr": { |
"jsr": { |
||||
@ -1113,6 +1116,17 @@ |
|||||
], |
], |
||||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.2.tgz" |
"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]": { |
"@jsr/[email protected]": { |
||||
"integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==", |
"integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -1134,21 +1148,21 @@ |
|||||
"@jsr/[email protected]": { |
"@jsr/[email protected]": { |
||||
"integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==", |
"integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
"@jsr/meshtastic__core" |
"@jsr/meshtastic__core@2.6.2" |
||||
], |
], |
||||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz" |
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz" |
||||
}, |
}, |
||||
"@jsr/[email protected]": { |
"@jsr/[email protected]": { |
||||
"integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==", |
"integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==", |
||||
"dependencies": [ |
"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" |
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.1.tgz" |
||||
}, |
}, |
||||
"@jsr/[email protected]": { |
"@jsr/[email protected]": { |
||||
"integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==", |
"integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==", |
||||
"dependencies": [ |
"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" |
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.1.tgz" |
||||
}, |
}, |
||||
@ -2366,6 +2380,20 @@ |
|||||
"redent" |
"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]": { |
"@testing-library/[email protected]_@[email protected]_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]": { |
||||
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", |
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -3852,9 +3880,6 @@ |
|||||
"@types/[email protected]": { |
"@types/[email protected]": { |
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" |
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" |
||||
}, |
}, |
||||
"@types/[email protected]": { |
|
||||
"integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==" |
|
||||
}, |
|
||||
"@types/[email protected]": { |
"@types/[email protected]": { |
||||
"integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" |
"integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" |
||||
}, |
}, |
||||
@ -4325,14 +4350,6 @@ |
|||||
"[email protected]" |
"[email protected]" |
||||
] |
] |
||||
}, |
}, |
||||
"[email protected]": { |
|
||||
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", |
|
||||
"dependencies": [ |
|
||||
"@types/validator", |
|
||||
"libphonenumber-js", |
|
||||
"validator" |
|
||||
] |
|
||||
}, |
|
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", |
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -5470,9 +5487,6 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" |
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" |
||||
}, |
}, |
||||
"[email protected]": { |
|
||||
"integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==" |
|
||||
}, |
|
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", |
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", |
||||
"os": ["darwin"], |
"os": ["darwin"], |
||||
@ -6039,6 +6053,13 @@ |
|||||
"scheduler" |
"scheduler" |
||||
] |
] |
||||
}, |
}, |
||||
|
"[email protected][email protected]": { |
||||
|
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", |
||||
|
"dependencies": [ |
||||
|
"@babel/runtime", |
||||
|
"react" |
||||
|
] |
||||
|
}, |
||||
"[email protected][email protected]": { |
"[email protected][email protected]": { |
||||
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", |
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -6964,9 +6985,6 @@ |
|||||
"which-typed-array" |
"which-typed-array" |
||||
] |
] |
||||
}, |
}, |
||||
"[email protected]": { |
|
||||
"integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==" |
|
||||
}, |
|
||||
"[email protected]_@[email protected]": { |
"[email protected]_@[email protected]": { |
||||
"integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", |
"integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", |
||||
"dependencies": [ |
"dependencies": [ |
||||
@ -7340,8 +7358,8 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" |
"integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" |
||||
}, |
}, |
||||
"[email protected]4.3": { |
"[email protected]5.49": { |
||||
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" |
"integrity": "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==" |
||||
}, |
}, |
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-mla2acNCMkWXBD+c+yeUrBUrzOxYMNFdQ6FGfigGGtEVBPJx07BQeJekjt9DmH1FtZek4E9rE1eRR9qQpxACOQ==" |
"integrity": "sha512-mla2acNCMkWXBD+c+yeUrBUrzOxYMNFdQ6FGfigGGtEVBPJx07BQeJekjt9DmH1FtZek4E9rE1eRR9qQpxACOQ==" |
||||
@ -7362,12 +7380,14 @@ |
|||||
}, |
}, |
||||
"workspace": { |
"workspace": { |
||||
"dependencies": [ |
"dependencies": [ |
||||
"jsr:@std/path@^1.1.0" |
"jsr:@std/path@^1.1.0", |
||||
|
"npm:@types/w3c-web-serial@*", |
||||
|
"npm:@types/web-bluetooth@*" |
||||
], |
], |
||||
"packageJson": { |
"packageJson": { |
||||
"dependencies": [ |
"dependencies": [ |
||||
"npm:@bufbuild/protobuf@^2.2.5", |
"npm:@bufbuild/protobuf@^2.2.5", |
||||
"npm:@jsr/[email protected].2", |
"npm:@jsr/[email protected].4", |
||||
"npm:@jsr/[email protected]", |
"npm:@jsr/[email protected]", |
||||
"npm:@jsr/meshtastic__transport-http@*", |
"npm:@jsr/meshtastic__transport-http@*", |
||||
"npm:@jsr/meshtastic__transport-web-bluetooth@*", |
"npm:@jsr/meshtastic__transport-web-bluetooth@*", |
||||
@ -7391,6 +7411,7 @@ |
|||||
"npm:@radix-ui/react-tooltip@^1.2.4", |
"npm:@radix-ui/react-tooltip@^1.2.4", |
||||
"npm:@tailwindcss/postcss@^4.1.5", |
"npm:@tailwindcss/postcss@^4.1.5", |
||||
"npm:@testing-library/jest-dom@^6.6.3", |
"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/react@^16.3.0", |
||||
"npm:@testing-library/user-event@^14.6.1", |
"npm:@testing-library/user-event@^14.6.1", |
||||
"npm:@turf/turf@^7.2.0", |
"npm:@turf/turf@^7.2.0", |
||||
@ -7405,7 +7426,6 @@ |
|||||
"npm:@vitejs/plugin-react@^4.4.1", |
"npm:@vitejs/plugin-react@^4.4.1", |
||||
"npm:autoprefixer@^10.4.21", |
"npm:autoprefixer@^10.4.21", |
||||
"npm:base64-js@^1.5.1", |
"npm:base64-js@^1.5.1", |
||||
"npm:class-validator@~0.14.2", |
|
||||
"npm:class-variance-authority@~0.7.1", |
"npm:class-variance-authority@~0.7.1", |
||||
"npm:clsx@^2.1.1", |
"npm:clsx@^2.1.1", |
||||
"npm:cmdk@^1.1.1", |
"npm:cmdk@^1.1.1", |
||||
@ -7442,7 +7462,7 @@ |
|||||
"npm:vite-plugin-static-copy@3", |
"npm:vite-plugin-static-copy@3", |
||||
"npm:vite@^6.3.4", |
"npm:vite@^6.3.4", |
||||
"npm:vitest@^3.1.2", |
"npm:vitest@^3.1.2", |
||||
"npm:zod@^3.24.3", |
"npm:zod@^3.25.0", |
||||
"npm:[email protected]" |
"npm:[email protected]" |
||||
] |
] |
||||
} |
} |
||||
|
|||||
@ -35,7 +35,7 @@ |
|||||
"homepage": "https://meshtastic.org", |
"homepage": "https://meshtastic.org", |
||||
"dependencies": { |
"dependencies": { |
||||
"@bufbuild/protobuf": "^2.2.5", |
"@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/js": "npm:@jsr/[email protected]", |
||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
||||
@ -58,6 +58,7 @@ |
|||||
"@radix-ui/react-toggle-group": "^1.1.9", |
"@radix-ui/react-toggle-group": "^1.1.9", |
||||
"@radix-ui/react-tooltip": "^1.2.4", |
"@radix-ui/react-tooltip": "^1.2.4", |
||||
"@turf/turf": "^7.2.0", |
"@turf/turf": "^7.2.0", |
||||
|
"@types/web-bluetooth": "^0.0.21", |
||||
"base64-js": "^1.5.1", |
"base64-js": "^1.5.1", |
||||
"class-variance-authority": "^0.7.1", |
"class-variance-authority": "^0.7.1", |
||||
"clsx": "^2.1.1", |
"clsx": "^2.1.1", |
||||
@ -88,15 +89,15 @@ |
|||||
"@tailwindcss/postcss": "^4.1.5", |
"@tailwindcss/postcss": "^4.1.5", |
||||
"@testing-library/jest-dom": "^6.6.3", |
"@testing-library/jest-dom": "^6.6.3", |
||||
"@testing-library/react": "^16.3.0", |
"@testing-library/react": "^16.3.0", |
||||
|
"@testing-library/react-hooks": "^8.0.1", |
||||
"@testing-library/user-event": "^14.6.1", |
"@testing-library/user-event": "^14.6.1", |
||||
"@types/chrome": "^0.0.318", |
"@types/chrome": "^0.0.318", |
||||
"@types/js-cookie": "^3.0.6", |
|
||||
"@types/node": "^22.15.3", |
"@types/node": "^22.15.3", |
||||
"@types/react": "^19.1.2", |
"@types/react": "^19.1.2", |
||||
"@types/react-dom": "^19.1.3", |
"@types/react-dom": "^19.1.3", |
||||
"@types/serviceworker": "^0.0.133", |
"@types/serviceworker": "^0.0.133", |
||||
|
"@types/js-cookie": "^3.0.6", |
||||
"@types/w3c-web-serial": "^1.0.8", |
"@types/w3c-web-serial": "^1.0.8", |
||||
"@types/web-bluetooth": "^0.0.21", |
|
||||
"@vitejs/plugin-react": "^4.4.1", |
"@vitejs/plugin-react": "^4.4.1", |
||||
"autoprefixer": "^10.4.21", |
"autoprefixer": "^10.4.21", |
||||
"gzipper": "^8.2.1", |
"gzipper": "^8.2.1", |
||||
|
|||||
@ -1,145 +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 { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("@core/stores/deviceStore"); |
|
||||
vi.mock("@core/stores/appStore"); |
|
||||
|
|
||||
const mockUseDevice = vi.mocked(useDevice); |
|
||||
const mockUseAppStore = vi.mocked(useAppStore); |
|
||||
|
|
||||
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,120 +0,0 @@ |
|||||
// deno-lint-ignore-file
|
|
||||
import { fireEvent, render, screen } from "@testing-library/react"; |
|
||||
import { describe, expect, it, vi } from "vitest"; |
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; |
|
||||
import { eventBus } from "@core/utils/eventBus.ts"; |
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; |
|
||||
|
|
||||
describe("UnsafeRolesDialog", () => { |
|
||||
const mockDevice = { |
|
||||
setDialogOpen: vi.fn(), |
|
||||
}; |
|
||||
|
|
||||
const renderWithDeviceContext = (ui: React.ReactNode) => { |
|
||||
return render( |
|
||||
<DeviceWrapper device={mockDevice}> |
|
||||
{ui} |
|
||||
</DeviceWrapper>, |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
it("renders the dialog when open is true", () => { |
|
||||
renderWithDeviceContext( |
|
||||
<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", () => { |
|
||||
renderWithDeviceContext( |
|
||||
<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( |
|
||||
"href", |
|
||||
"https://meshtastic.org/docs/configuration/radio/device/", |
|
||||
); |
|
||||
expect(blogLink).toHaveAttribute( |
|
||||
"href", |
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/", |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
it("does not allow confirmation until checkbox is checked", () => { |
|
||||
renderWithDeviceContext( |
|
||||
<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"); |
|
||||
renderWithDeviceContext( |
|
||||
<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"); |
|
||||
renderWithDeviceContext( |
|
||||
<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"); |
|
||||
renderWithDeviceContext( |
|
||||
<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 { Protobuf } from "@meshtastic/core"; |
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
||||
import { useMemo } from "react"; |
import { useCallback, useMemo } from "react"; |
||||
|
|
||||
export type FilterState = { |
export type FilterState = { |
||||
nodeName: string; |
nodeName: string; |
||||
hopsAway: [number, number]; |
hopsAway: [number, number]; |
||||
lastHeard: [number, number]; |
lastHeard: [number, number]; |
||||
isFavorite: boolean | undefined; // undefined -> don't filter
|
isFavorite: boolean | undefined; |
||||
viaMqtt: boolean | undefined; // undefined -> don't filter
|
viaMqtt: boolean | undefined; |
||||
snr: [number, number]; |
snr: [number, number]; |
||||
channelUtilization: [number, number]; |
channelUtilization: [number, number]; |
||||
airUtilTx: [number, number]; |
airUtilTx: [number, number]; |
||||
batteryLevel: [number, number]; |
batteryLevel: [number, number]; |
||||
voltage: [number, number]; |
voltage: [number, number]; |
||||
role: (Protobuf.Config.Config_DeviceConfig_Role)[]; |
role: Protobuf.Config.Config_DeviceConfig_Role[]; |
||||
hwModel: (Protobuf.Mesh.HardwareModel)[]; |
hwModel: Protobuf.Mesh.HardwareModel[]; |
||||
}; |
}; |
||||
|
|
||||
|
const shallowEqualArray = (a: any[], b: any[]) => |
||||
|
a.length === b.length && a.every((v, i) => v === b[i]); |
||||
|
|
||||
export function useFilterNode() { |
export function useFilterNode() { |
||||
const defaultFilterValues = useMemo<FilterState>(() => ({ |
const defaultFilterValues = useMemo<FilterState>( |
||||
nodeName: "", |
() => ({ |
||||
hopsAway: [0, 7], |
nodeName: "", |
||||
lastHeard: [0, 864000], // 0-10 days
|
hopsAway: [0, 7], |
||||
isFavorite: undefined, |
lastHeard: [0, 864000], // 0-10 days
|
||||
viaMqtt: undefined, |
isFavorite: undefined, |
||||
snr: [-20, 10], |
viaMqtt: undefined, |
||||
channelUtilization: [0, 100], |
snr: [-20, 10], |
||||
airUtilTx: [0, 100], |
channelUtilization: [0, 100], |
||||
batteryLevel: [0, 101], |
airUtilTx: [0, 100], |
||||
voltage: [0, 5], |
batteryLevel: [0, 101], |
||||
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( |
voltage: [0, 5], |
||||
(v): v is Protobuf.Config.Config_DeviceConfig_Role => |
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( |
||||
typeof v === "number", |
(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", |
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 nodeFilter = useCallback( |
||||
const filterState: FilterState = { |
( |
||||
...defaultFilterValues, |
node: Protobuf.Mesh.NodeInfo, |
||||
...filterOverrides, |
filterOverrides?: Partial<FilterState>, |
||||
}; |
): boolean => { |
||||
|
const filterState: FilterState = { |
||||
if (!node.user) return false; |
...defaultFilterValues, |
||||
|
...filterOverrides, |
||||
const nodeName = filterState.nodeName.toLowerCase(); |
}; |
||||
if ( |
|
||||
!( |
if (!node.user) return false; |
||||
node.user?.shortName.toLowerCase().includes(nodeName) || |
|
||||
node.user?.longName.toLowerCase().includes(nodeName) || |
const nodeName = filterState.nodeName.toLowerCase(); |
||||
node?.num.toString().includes(nodeName) || |
if ( |
||||
numberToHexUnpadded(node?.num).includes( |
nodeName && |
||||
nodeName.replace(/!/g, ""), |
!( |
||||
|
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]) { |
const hops = node.hopsAway ?? 7; |
||||
return false; |
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { |
||||
} |
return false; |
||||
|
} |
||||
const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0); |
|
||||
if ( |
const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0); |
||||
secondsAgo < filterState.lastHeard[0] || |
if ( |
||||
( |
secondsAgo < filterState.lastHeard[0] || |
||||
secondsAgo > filterState.lastHeard[1] && |
(secondsAgo > filterState.lastHeard[1] && |
||||
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1] |
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1]) |
||||
) |
) { |
||||
) return false; |
return false; |
||||
|
} |
||||
if ( |
|
||||
typeof filterState.isFavorite !== "undefined" && |
if ( |
||||
node.isFavorite !== filterState.isFavorite |
typeof filterState.isFavorite !== "undefined" && |
||||
) return false; |
node.isFavorite !== filterState.isFavorite |
||||
|
) { |
||||
if ( |
return false; |
||||
typeof filterState.viaMqtt !== "undefined" && |
} |
||||
node.viaMqtt !== filterState.viaMqtt |
|
||||
) return false; |
if ( |
||||
|
typeof filterState.viaMqtt !== "undefined" && |
||||
const snr = node?.snr ?? -20; |
node.viaMqtt !== filterState.viaMqtt |
||||
if ( |
) { |
||||
snr < filterState.snr[0] || |
return false; |
||||
snr > filterState.snr[1] |
} |
||||
) return false; |
|
||||
|
const snr = node.snr ?? -20; |
||||
const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0; |
if (snr < filterState.snr[0] || snr > filterState.snr[1]) return false; |
||||
if ( |
|
||||
channelUtilization < filterState.channelUtilization[0] || |
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; |
||||
channelUtilization > filterState.channelUtilization[1] |
if ( |
||||
) return false; |
channelUtilization < filterState.channelUtilization[0] || |
||||
|
channelUtilization > filterState.channelUtilization[1] |
||||
const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0; |
) { |
||||
if ( |
return false; |
||||
airUtilTx < filterState.airUtilTx[0] || |
} |
||||
airUtilTx > filterState.airUtilTx[1] |
|
||||
) return false; |
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; |
||||
|
if ( |
||||
const batt = node?.deviceMetrics?.batteryLevel ?? 101; |
airUtilTx < filterState.airUtilTx[0] || |
||||
if ( |
airUtilTx > filterState.airUtilTx[1] |
||||
batt < filterState.batteryLevel[0] || |
) { |
||||
batt > filterState.batteryLevel[1] |
return false; |
||||
) return false; |
} |
||||
|
|
||||
const voltage = node?.deviceMetrics?.voltage ?? 0; |
const batt = node.deviceMetrics?.batteryLevel ?? 101; |
||||
if ( |
if ( |
||||
voltage < filterState.voltage[0] || |
batt < filterState.batteryLevel[0] || |
||||
voltage > filterState.voltage[1] |
batt > filterState.batteryLevel[1] |
||||
) return false; |
) { |
||||
|
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 voltage = node.deviceMetrics?.voltage ?? 0; |
||||
|
if ( |
||||
const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ?? |
voltage < filterState.voltage[0] || |
||||
Protobuf.Mesh.HardwareModel.UNSET; |
voltage > filterState.voltage[1] |
||||
if (!filterState.hwModel.includes(hwModel)) return false; |
) { |
||||
|
return false; |
||||
// All conditions are true
|
} |
||||
return true; |
|
||||
} |
const role: Protobuf.Config.Config_DeviceConfig_Role = node.user.role ?? |
||||
|
Protobuf.Config.Config_DeviceConfig_Role.CLIENT; |
||||
// deno-lint-ignore no-explicit-any
|
if (!filterState.role.includes(role)) return false; |
||||
function shallowEqualArray(a: any[], b: any[]) { |
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]); |
const hwModel: Protobuf.Mesh.HardwareModel = node.user.hwModel ?? |
||||
} |
Protobuf.Mesh.HardwareModel.UNSET; |
||||
|
if (!filterState.hwModel.includes(hwModel)) return false; |
||||
function isFilterDirty( |
|
||||
current: FilterState, |
return true; |
||||
overrides?: Partial<FilterState>, |
}, |
||||
): boolean { |
[defaultFilterValues], |
||||
const base: FilterState = overrides |
); |
||||
? { ...defaultFilterValues, ...overrides } |
|
||||
: defaultFilterValues; |
const isFilterDirty = useCallback( |
||||
|
( |
||||
return (Object.keys(base) as (keyof FilterState)[]).some((key) => { |
current: FilterState, |
||||
const curr = current[key]; |
overrides?: Partial<FilterState>, |
||||
const def = base[key]; |
): boolean => { |
||||
return Array.isArray(def) && Array.isArray(curr) |
const base: FilterState = overrides |
||||
? !shallowEqualArray(curr, def) |
? { ...defaultFilterValues, ...overrides } |
||||
: curr !== def; |
: 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 }; |
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